diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index df81416..588a088 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -38,7 +38,7 @@ jobs: uv run noxfile.py -s "${{ matrix.session }}" -- --pyargs sqlalchemy_mptt --cov-report xml - name: Upload coverage data - if: ${{ matrix.session != 'lint' }} + if: ${{ startsWith(matrix.session, 'test(') }} uses: coverallsapp/github-action@v2 with: flag-name: run-${{ join(matrix.*, '-') }} diff --git a/CHANGES.rst b/CHANGES.rst index fa97fab..cb40775 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ Versions releases 0.2.x & above 0.5.0 (Unreleased) ================== -see issue #104 +see issues #104 & #110 - Add support for SQLAlchemy 1.4. - Drop official support for PyPy. @@ -12,6 +12,8 @@ see issue #104 weak reference set. - Unify ``after_flush_postexec`` execution path for CPython & PyPy. - Simplify ``get_siblings``. +- Run doctest on all code snippets in the documentation. +- Fix some of the incorrect documentation snippets. 0.4.0 (2025-05-30) ================== diff --git a/docs/CONTRIBUTING.rst b/docs/CONTRIBUTING.rst index 918cb4a..a8acf00 100644 --- a/docs/CONTRIBUTING.rst +++ b/docs/CONTRIBUTING.rst @@ -40,5 +40,19 @@ To run the tests and linters, you can use the following command: For futher details, refer to the ``noxfile.py`` script. +Building Documentation +---------------------- + +The documentation on `ReadtheDocs `_ is manually built from the master branch. +To build the documentation locally, you can run: + +.. code-block:: bash + + $ uv tool install sphinx --with-editable . --with-requirements requirements-doctest.txt + $ cd docs + $ make html + +For futher details, refer to the ``docs/Makefile``. + .. |IRC Freenode| image:: https://img.shields.io/badge/irc-freenode-blue.svg :target: https://webchat.freenode.net/?channels=sacrud diff --git a/docs/conf.py b/docs/conf.py index 29031c1..0b13588 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.mathjax', - # 'sphinx.ext.mathbase', + 'sphinx.ext.doctest', ] # Add any paths that contain templates here, relative to this directory. @@ -69,3 +69,19 @@ 'github_user': 'uralbash', 'github_repo': 'sqlalchemy_mptt', } + +# -- Options for doctest extension ------------------------------------------ +doctest_global_setup = """ +from sqlalchemy import create_engine, Column, Integer, Boolean +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Session + +from sqlalchemy_mptt import tree_manager +from sqlalchemy_mptt.mixins import BaseNestedSets +""" +doctest_global_cleanup = """ +try: + session.flush() +except NameError: + pass +""" diff --git a/docs/crud.rst b/docs/crud.rst index f927f8d..0b906fe 100644 --- a/docs/crud.rst +++ b/docs/crud.rst @@ -6,7 +6,53 @@ INSERT Insert node with parent_id==6 -.. code-block:: python +.. testsetup:: + + from sqlalchemy import create_engine, Column, Integer, Boolean + from sqlalchemy.orm import Session + from sqlalchemy_mptt import tree_manager + from sqlalchemy_mptt.mixins import BaseNestedSets + from sqlalchemy.ext.declarative import declarative_base + + Base = declarative_base() + engine = create_engine("sqlite:///:memory:") + session = Session(engine) + + class Tree(Base, BaseNestedSets): + __tablename__ = "tree" + + id = Column(Integer, primary_key=True) + visible = Column(Boolean) + + def __repr__(self): + return "" % self.id + + Base.metadata.create_all(engine) + tree_manager.register_events(remove=True) + instances = [ + Tree(id=1, parent_id=None), + Tree(id=2, parent_id=1), + Tree(id=3, parent_id=2), + Tree(id=4, parent_id=1), + Tree(id=5, parent_id=4), + Tree(id=6, parent_id=4), + Tree(id=7, parent_id=1), + Tree(id=8, parent_id=7), + Tree(id=9, parent_id=8), + Tree(id=10, parent_id=7), + Tree(id=11, parent_id=10) + ] + for instance in instances: + instance.left = 0 + instance.right = 0 + instance.visible = True + session.add_all(instances) + session.flush() + tree_manager.register_events() + Tree.rebuild_tree(session, tree_id=None) + + +.. testcode:: node = Tree(parent_id=6) session.add(node) @@ -45,7 +91,7 @@ UPDATE Set parent_id=5 for node with id==8 -.. code-block:: python +.. testcode:: node = session.query(Tree).filter(Tree.id == 8).one() node.parent_id = 5 @@ -86,7 +132,7 @@ DELETE Delete node with id==4 -.. code-block:: python +.. testcode:: node = session.query(Tree).filter(Tree.id == 4).one() session.delete(node) diff --git a/docs/initialize.rst b/docs/initialize.rst index 9863334..f1b4a9c 100644 --- a/docs/initialize.rst +++ b/docs/initialize.rst @@ -3,8 +3,7 @@ Setup Create model with MPTT mixin: -.. code-block:: python - :linenos: +.. testcode:: from sqlalchemy import Column, Integer, Boolean from sqlalchemy.ext.declarative import declarative_base @@ -31,14 +30,13 @@ Session factory wrapper For the automatic tree maintainance triggered after session flush to work correctly, wrap the Session factory with :mod:`sqlalchemy_mptt.mptt_sessionmaker` -.. code-block:: python - :linenos: +.. testcode:: from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy_mptt import mptt_sessionmaker - engine = create_engine('...') + engine = create_engine('sqlite:///:memory:') Session = mptt_sessionmaker(sessionmaker(bind=engine)) Using session factory wrapper with flask_sqlalchemy @@ -48,8 +46,7 @@ If you use Flask and SQLAlchemy, you probably use also flask_sqlalchemy extension for integration. In that case the Session creation is not directly accessible. The following allows you to use the wrapper: -.. code-block:: python - :linenos: +.. testcode:: from sqlalchemy_mptt import mptt_sessionmaker from flask_sqlalchemy import SQLAlchemy @@ -76,7 +73,7 @@ Events The tree manager automatically registers events. But you can do it manually: -.. code-block:: python +.. testcode:: from sqlalchemy_mptt import tree_manager @@ -85,7 +82,7 @@ The tree manager automatically registers events. But you can do it manually: Or disable events if it required: -.. code-block:: python +.. testcode:: from sqlalchemy_mptt import tree_manager @@ -103,7 +100,7 @@ Fill table with records, for example, as shown in the picture Represented data of tree like dict -.. code-block:: python +.. testcode:: tree = ( {'id': '1', 'parent_id': None}, @@ -132,7 +129,28 @@ tree, it might become a big overhead. In this case, it is recommended to deactivate automatic tree management, fill in the data, reactivate automatic tree management and finally call manually a rebuild of the tree once at the end: -.. no-code-block:: python +.. testcode:: + :hide: + + from flask import Flask + + class MyModelTree(db.Model, BaseNestedSets): + __tablename__ = "my_model_tree" + + id = db.Column(db.Integer, primary_key=True) + visible = db.Column(db.Boolean) # you custom field + + def __repr__(self): + return "" % self.id + + app = Flask('test') + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + db.init_app(app) + app.app_context().push() + db.create_all() + items = [MyModelTree(**data) for data in tree] + +.. testcode:: from sqlalchemy_mptt import tree_manager @@ -144,13 +162,13 @@ tree management and finally call manually a rebuild of the tree once at the end: for item in items: item.left = 0 item.right = 0 - item.tree_id = 'my_tree_1' + item.tree_id = 1 db.session.add(item) db.session.commit() ... tree_manager.register_events() # enabled MPTT events back - models.MyModelTree.rebuild_tree(db.session, 'my_tree_1') # rebuild lft, rgt value automatically + MyModelTree.rebuild_tree(db.session, 1) # rebuild lft, rgt value automatically After an initial table with tree you can use mptt features. diff --git a/docs/tut_flask.rst b/docs/tut_flask.rst index 3f31142..4dbe985 100644 --- a/docs/tut_flask.rst +++ b/docs/tut_flask.rst @@ -1,67 +1,75 @@ -Setup & Usage with Flask-SQLAlchemy -=================================== +Usage with Flask-SQLAlchemy +=========================== Initialize Flask app and sqlalchemy -.. code-block:: python +.. testsetup:: - from pprint import pprint - from flask import Flask - from flask_sqlalchemy import SQLAlchemy + __name__ = "__main__" - from sqlalchemy_mptt.mixins import BaseNestedSets +.. testcode:: - app = Flask(__name__) - app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/test.db' - db = SQLAlchemy(app) + from pprint import pprint + from flask import Flask + from flask_sqlalchemy import SQLAlchemy + + from sqlalchemy_mptt.mixins import BaseNestedSets + + app = Flask(__name__) + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + db = SQLAlchemy(app) Make models. -.. code-block:: python - :emphasize-lines: 1 +.. testcode:: - class Category(db.Model, BaseNestedSets): - __tablename__ = 'categories' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(400), index=True, unique=True) - items = db.relationship("Product", backref='item', lazy='dynamic') + class Category(db.Model, BaseNestedSets): + __tablename__ = 'categories' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(400), index=True, unique=True) + items = db.relationship("Product", backref='item', lazy='dynamic') - def __repr__(self): - return ''.format(self.name) + def __repr__(self): + return ''.format(self.name) - class Product(db.Model): - __tablename__ = 'products' - id = db.Column(db.Integer, primary_key=True) - category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) - name = db.Column(db.String(475), index=True) + class Product(db.Model): + __tablename__ = 'products' + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) + name = db.Column(db.String(475), index=True) Represent data of tree in table ------------------------------- Add data to table with tree. -.. code-block:: python - - db.session.add(Category(name="root")) # root node - db.session.add_all( # first branch of tree - [ - Category(name="foo", parent_id=1), - Category(name="bar", parent_id=2), - Category(name="baz", parent_id=3), - ] - ) - db.session.add_all( # second branch of tree - [ - Category(name="foo1", parent_id=1), - Category(name="bar1", parent_id=5), - Category(name="baz1", parent_id=5), - ] - ) - - db.drop_all() - db.create_all() - db.session.commit() +.. testcode:: + :hide: + + app.app_context().push() + +.. testcode:: + + db.session.add(Category(name="root")) # root node + db.session.add_all( # first branch of tree + [ + Category(name="foo", parent_id=1), + Category(name="bar", parent_id=2), + Category(name="baz", parent_id=3), + ] + ) + db.session.add_all( # second branch of tree + [ + Category(name="foo1", parent_id=1), + Category(name="bar1", parent_id=5), + Category(name="baz1", parent_id=5), + ] + ) + + db.drop_all() + db.create_all() + db.session.commit() The database entries are added: @@ -127,16 +135,17 @@ something like: | -------------------------- 4 4(baz)5 -.. code-block:: python +.. testcode:: - categories = Category.query.all() + categories = Category.query.all() - for item in categories: - print(item) - pprint(item.drilldown_tree()) - print() + for item in categories: + print(item) + pprint(item.drilldown_tree()) + print() -.. code-block:: text +.. testoutput:: + :options: +NORMALIZE_WHITESPACE [{'children': [{'children': [{'children': [{'node': }], @@ -170,7 +179,7 @@ something like: Represent it to JSON format: -.. code-block:: python +.. testcode:: def cat_to_json(item): return { @@ -182,7 +191,8 @@ Represent it to JSON format: pprint(item.drilldown_tree(json=True, json_fields=cat_to_json)) print() -.. code-block:: text +.. testoutput:: + :options: +NORMALIZE_WHITESPACE [{'children': [{'children': [{'children': [{'id': 4, 'label': '', @@ -253,7 +263,7 @@ Returns a list containing the ancestors and the node itself in tree order. -----|----- 4 4(baz)5 -.. code-block:: python +.. testcode:: for item in categories: print(item) @@ -262,7 +272,8 @@ Returns a list containing the ancestors and the node itself in tree order. pprint(item.path_to_root().all()) print() -.. code-block:: text +.. testoutput:: + :options: +NORMALIZE_WHITESPACE @@ -295,7 +306,7 @@ Returns a list containing the ancestors and the node itself in tree order. Full code --------- -.. code-block:: python3 +.. testcode:: from pprint import pprint from flask import Flask @@ -324,6 +335,7 @@ Full code category_id = db.Column(db.Integer, db.ForeignKey('categories.id')) name = db.Column(db.String(475), index=True) + app.app_context().push() db.session.add(Category(name="root")) # root node db.session.add_all( # first branch of tree [ @@ -443,3 +455,65 @@ Full code [, , ] ''' + +.. testoutput:: + :options: +NORMALIZE_WHITESPACE + :hide: + + + [{'children': [{'children': [{'children': [{'node': }], + 'node': }], + 'node': }, + {'children': [{'node': }, + {'node': }], + 'node': }], + 'node': }] + + + [{'children': [{'children': [{'node': }], + 'node': }], + 'node': }] + + + [{'children': [{'node': }], 'node': }] + + + [{'node': }] + + + [{'children': [{'node': }, {'node': }], + 'node': }] + + + [{'node': }] + + + [{'node': }] + + + + [] + + + + [, ] + + + + [, , ] + + + + [, , , ] + + + + [, ] + + + + [, , ] + + + + [, , ] diff --git a/noxfile.py b/noxfile.py index 6a2c2dc..9480785 100644 --- a/noxfile.py +++ b/noxfile.py @@ -89,8 +89,20 @@ def parametrize_test_versions(): if sqlalchemy_version >= Version("1.2") or python_minor <= 9] +PARAMETRIZED_TEST_VERSIONS = parametrize_test_versions() + + +def install_dependencies(session, session_name, sqlalchemy_version): + """Install dependencies for the given session.""" + session.install( + "-r", f"requirements-{session_name}.txt", + f"sqlalchemy~={sqlalchemy_version}.0", + "-e", "." + ) + + @nox.session() -@nox.parametrize("python,sqlalchemy", parametrize_test_versions()) +@nox.parametrize("python,sqlalchemy", PARAMETRIZED_TEST_VERSIONS) def test(session, sqlalchemy): """Run tests with pytest. @@ -106,13 +118,19 @@ def test(session, sqlalchemy): For fine-grained control over running the tests, refer the nox documentation: https://nox.thea.codes/en/stable/usage.html """ - session.install("-r", "requirements-test.txt") - session.install(f"sqlalchemy~={sqlalchemy}.0") - session.install("-e", ".") + install_dependencies(session, "test", sqlalchemy) pytest_args = session.posargs or ["--pyargs", "sqlalchemy_mptt"] session.run("pytest", *pytest_args, env={"SQLALCHEMY_WARN_20": "1"}) +@nox.session() +@nox.parametrize("python,sqlalchemy", PARAMETRIZED_TEST_VERSIONS[-1:]) +def doctest(session, sqlalchemy): + """Run doctests in the documentation.""" + install_dependencies(session, "doctest", sqlalchemy) + session.run("sphinx-build", "-b", "doctest", "docs", "docs/_build") + + @nox.session(default=False) def dev(session): """Set up a development environment. diff --git a/requirements-docs.txt b/requirements-docs.txt deleted file mode 100644 index 1c70be0..0000000 --- a/requirements-docs.txt +++ /dev/null @@ -1 +0,0 @@ -sqlalchemy_mptt diff --git a/requirements-doctest.txt b/requirements-doctest.txt new file mode 100644 index 0000000..7abff80 --- /dev/null +++ b/requirements-doctest.txt @@ -0,0 +1,2 @@ +flask-sqlalchemy +sphinx diff --git a/requirements-test.txt b/requirements-test.txt index 07d2fbc..7ae6973 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,3 @@ +hypothesis pytest pytest-cov -hypothesis diff --git a/sqlalchemy_mptt/mixins.py b/sqlalchemy_mptt/mixins.py index 4f7533b..ca05c8f 100644 --- a/sqlalchemy_mptt/mixins.py +++ b/sqlalchemy_mptt/mixins.py @@ -9,6 +9,12 @@ """ SQLAlchemy nested sets mixin + +.. testsetup:: + + engine = create_engine('sqlite:///:memory:') + session = Session(bind=engine) + """ # SQLAlchemy from sqlalchemy import Column, Integer, ForeignKey, asc, desc @@ -26,7 +32,7 @@ class BaseNestedSets(object): Example: - .. code:: + .. testcode:: from sqlalchemy import Boolean, Column, create_engine, Integer from sqlalchemy.ext.declarative import declarative_base @@ -45,6 +51,25 @@ class Tree(Base, BaseNestedSets): def __repr__(self): return "" % self.id + + .. testcode:: + :hide: + + Base.metadata.create_all(engine) + node = Tree() + session.add(node) + session.flush() + node7 = Tree(parent=node) + session.add(node7) + session.flush() + node8 = Tree(parent=node7) + session.add(node8) + session.flush() + node10 = Tree(parent=node7) + session.add(node10) + session.flush() + node11 = Tree(parent=node10) + session.add(node11) """ @classmethod @@ -254,12 +279,12 @@ def get_tree(cls, session=None, json=False, json_fields=None, query=None): query (function): it takes :class:`sqlalchemy.orm.query.Query` object as an argument, and returns in a modified form - :: + .. testcode:: def query(nodes): return nodes.filter(node.__class__.tree_id.is_(node.tree_id)) - node.get_tree(session=DBSession, json=True, query=query) + node.get_tree(session=session, json=True, query=query) Example: @@ -312,7 +337,9 @@ def drilldown_tree(self, session=None, json=False, json_fields=None): For example: - node7.drilldown_tree() + .. testcode:: + + node7.drilldown_tree() .. code:: @@ -346,7 +373,9 @@ def path_to_root(self, session=None, order=desc): For example: - node11.path_to_root() + .. testcode:: + + node11.path_to_root() .. code:: @@ -382,7 +411,9 @@ def get_siblings(self, include_self=False, session=None): For example: - node10.get_siblings() -> [Node(8)] + .. testcode:: + + node10.get_siblings() #-> [Node(8)] Only one node is sibling of node10 @@ -420,7 +451,9 @@ def get_children(self, session=None): For example: - node7.get_children() -> [Node(8), Node(10)] + .. testcode:: + + node7.get_children() #-> [Node(8), Node(10)] .. code:: diff --git a/sqlalchemy_mptt/tests/cases/edit_node.py b/sqlalchemy_mptt/tests/cases/edit_node.py index de109d6..97cfe7e 100644 --- a/sqlalchemy_mptt/tests/cases/edit_node.py +++ b/sqlalchemy_mptt/tests/cases/edit_node.py @@ -3,6 +3,7 @@ class Changes(object): def test_update_wo_move(self): """ Update node w/o move initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + .. code:: level Nested sets example @@ -52,6 +53,7 @@ def test_update_wo_move(self): def test_update_wo_move_like_sacrud_save(self): """ Just change attr from node w/o move initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + c .. code:: level Nested sets example @@ -98,6 +100,7 @@ def test_update_wo_move_like_sacrud_save(self): def test_insert_node(self): """ Insert node with parent==6 initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + .. code:: level Nested sets example @@ -157,6 +160,7 @@ def test_insert_node(self): def test_insert_node_near_subtree(self): """ Insert node with parent==4 initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + .. code:: level Nested sets example @@ -219,6 +223,7 @@ def test_insert_after_node(self): def test_delete_node(self): """ Delete node(4) initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + .. code:: level Test delete node @@ -273,6 +278,7 @@ def test_delete_node(self): def test_update_node(self): """ Set parent_id==5 for node(8) initial state of the tree :mod:`sqlalchemy_mptt.tests.add_mptt_tree` + .. code:: level Test update node diff --git a/sqlalchemy_mptt/tests/test_events.py b/sqlalchemy_mptt/tests/test_events.py index f1c80e3..39a5bd1 100644 --- a/sqlalchemy_mptt/tests/test_events.py +++ b/sqlalchemy_mptt/tests/test_events.py @@ -192,7 +192,7 @@ def test_documented_initial_insert(self): tree_manager.register_events(remove=True) # Disable MPTT events - _tree_id = 'tree1' + _tree_id = 1 for node_id, parent_id in [(1, None), (2, 1), (3, 1), (4, 2)]: item = Tree(