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(