Repository: djrobstep/sqlakeyset
Pull request #89 (2023-07-13 18:24:16)

search mro to handle subclasses such as asyncpg UUID
fixes: https://github.com/djrobstep/sqlakeyset/issues/88

acarapetis: Thinking about this makes me want to rewrite the whole serialization system - in principle, we already know exactly what type we should deserialize the bookmark to, since we know the ordering columns of the query we're paging! Thus the e.g. `uuid:` prefixes are not really necessary, and we could work out that `asyncpg.UUID` is the type we need to convert to by doing some introspection. 

Would be a fair bit of work and a breaking change, though, so not going to try for that right now. I've left a few extra comments in the tests - if you address those I'll be happy to merge this. (If you want to implement the `replace` flag on `custom_bookmark_type` that would also be great, but we can also just leave that for a separate PR.)

Thanks again for your contribution! :)

Patch:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..cea70d0
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,36 @@
+# Read the Docs configuration file for Sphinx projects
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the OS, Python version and other tools you might need
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.11"
+    # You can also specify other tool versions:
+    # nodejs: "20"
+    # rust: "1.70"
+    # golang: "1.20"
+
+# Build documentation in the "doc/" directory with Sphinx
+sphinx:
+  configuration: doc/source/conf.py
+  # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
+  # builder: "dirhtml"
+  # Fail on all warnings to avoid broken references
+  # fail_on_warning: true
+
+# Optionally build your docs in additional formats such as PDF and ePub
+# formats:
+#    - pdf
+#    - epub
+
+# Optional but recommended, declare the Python requirements required
+# to build your documentation
+# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
+python:
+   install:
+   - requirements: doc/requirements.txt
+
diff --git a/README.rst b/README.rst
index bedfec5..3c33679 100644
--- a/README.rst
+++ b/README.rst
@@ -13,6 +13,10 @@ sqlakeyset: offset-free paging for sqlalchemy
     :alt: conda-forge
     :target: https://anaconda.org/conda-forge/sqlakeyset
 
+.. image:: https://readthedocs.org/projects/sqlakeyset/badge/
+   :alt: Documentation @ readthedocs
+   :target: https://sqlakeyset.readthedocs.io/
+
 sqlakeyset implements keyset-based paging for SQLAlchemy (both ORM and core). **Now with full SQLAlchemy 2 support and type hints!**
 
 This library is tested with PostgreSQL, MariaDB/MySQL and SQLite. It should work with many other SQLAlchemy-supported databases, too; but caveat emptor - you should verify the results are correct.
@@ -174,7 +178,7 @@ Limitations
 Documentation
 -------------
 
-Other than this README, there is some basic sphinx documentation, which you can build yourself with e.g. ``make -C doc html``. Hopefully this will be available more conveniently soon - watch this space.
+Other than this README, there is some more detailed API documentation autogenerated from docstrings, which you can `read online at readthedocs.io <https://sqlakeyset.readthedocs.io/>`_ or build yourself with e.g. ``make -C doc html``.
 
 
 Installation
diff --git a/doc/requirements.txt b/doc/requirements.txt
new file mode 100644
index 0000000..d6e1198
--- /dev/null
+++ b/doc/requirements.txt
@@ -0,0 +1,1 @@
+-e .
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 9edf1d1..5384cd1 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -18,8 +18,8 @@
 # -- Project information -----------------------------------------------------
 
 project = "sqlakeyset"
-copyright = "2019, Robert Lechte"
-author = "Robert Lechte"
+copyright = "2019-2023, Robert Lechte & Anthony Carapetis"
+author = "Robert Lechte, Anthony Carapetis"
 
 
 # -- General configuration ---------------------------------------------------
@@ -33,7 +33,7 @@ extensions = [
     "sphinx.ext.viewcode",
 ]
 
-intersphinx_mapping = {"sqlalchemy": ("https://docs.sqlalchemy.org/en/13/", None)}
+intersphinx_mapping = {"sqlalchemy": ("https://docs.sqlalchemy.org/en/20/", None)}
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ["_templates"]
 
diff --git a/poetry.lock b/poetry.lock
index c4ac320..8247e20 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,10 +1,9 @@
-# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
 
 [[package]]
 name = "aiomysql"
 version = "0.1.1"
 description = "MySQL driver for asyncio."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -23,7 +22,6 @@ sa = ["sqlalchemy (>=1.0,<1.4)"]
 name = "aiosqlite"
 version = "0.18.0"
 description = "asyncio bridge to the standard sqlite3 module"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -38,7 +36,6 @@ typing_extensions = {version = ">=4.0", markers = "python_version < \"3.8\""}
 name = "arrow"
 version = "1.2.3"
 description = "Better dates & times for Python"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -54,7 +51,6 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "asyncpg"
 version = "0.27.0"
 description = "An asyncio PostgreSQL driver"
-category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -108,7 +104,6 @@ test = ["flake8 (>=5.0.4,<5.1.0)", "uvloop (>=0.15.3)"]
 name = "attrs"
 version = "22.2.0"
 description = "Classes Without Boilerplate"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -127,7 +122,6 @@ tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy
 name = "black"
 version = "23.1.0"
 description = "The uncompromising code formatter."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -178,7 +172,6 @@ uvloop = ["uvloop (>=0.15.2)"]
 name = "click"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -194,7 +187,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
-category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
@@ -206,7 +198,6 @@ files = [
 name = "coverage"
 version = "7.2.2"
 description = "Code coverage measurement for Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -273,7 +264,6 @@ toml = ["tomli"]
 name = "exceptiongroup"
 version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -288,7 +278,6 @@ test = ["pytest (>=6)"]
 name = "flake8"
 version = "5.0.4"
 description = "the modular source code checker: pep8 pyflakes and co"
-category = "dev"
 optional = false
 python-versions = ">=3.6.1"
 files = [
@@ -306,7 +295,6 @@ pyflakes = ">=2.5.0,<2.6.0"
 name = "greenlet"
 version = "2.0.2"
 description = "Lightweight in-process concurrent programming"
-category = "main"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
 files = [
@@ -380,7 +368,6 @@ test = ["objgraph", "psutil"]
 name = "importlib-metadata"
 version = "4.2.0"
 description = "Read metadata from Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -400,7 +387,6 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517",
 name = "iniconfig"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -412,7 +398,6 @@ files = [
 name = "isort"
 version = "5.11.5"
 description = "A Python utility / library to sort Python imports."
-category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -430,7 +415,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "markdown-it-py"
 version = "2.2.0"
 description = "Python port of markdown-it. Markdown parsing, done right!"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -456,7 +440,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
 name = "mccabe"
 version = "0.7.0"
 description = "McCabe checker, plugin for flake8"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -468,7 +451,6 @@ files = [
 name = "mdurl"
 version = "0.1.2"
 description = "Markdown URL utilities"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -480,7 +462,6 @@ files = [
 name = "mypy-extensions"
 version = "1.0.0"
 description = "Type system extensions for programs checked with the mypy type checker."
-category = "dev"
 optional = false
 python-versions = ">=3.5"
 files = [
@@ -492,7 +473,6 @@ files = [
 name = "packaging"
 version = "23.0"
 description = "Core utilities for Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -504,7 +484,6 @@ files = [
 name = "pathspec"
 version = "0.11.1"
 description = "Utility library for gitignore style pattern matching of file paths."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -516,7 +495,6 @@ files = [
 name = "platformdirs"
 version = "3.1.1"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -535,7 +513,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytes
 name = "pluggy"
 version = "1.0.0"
 description = "plugin and hook calling mechanisms for python"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -554,7 +531,6 @@ testing = ["pytest", "pytest-benchmark"]
 name = "pprintpp"
 version = "0.4.0"
 description = "A drop-in replacement for pprint that's actually pretty"
-category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -566,7 +542,6 @@ files = [
 name = "psycopg2-binary"
 version = "2.9.5"
 description = "psycopg2 - Python-PostgreSQL Database Adapter"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -647,7 +622,6 @@ files = [
 name = "pycodestyle"
 version = "2.9.1"
 description = "Python style guide checker"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -659,7 +633,6 @@ files = [
 name = "pyflakes"
 version = "2.5.0"
 description = "passive checker of Python programs"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -669,14 +642,13 @@ files = [
 
 [[package]]
 name = "pygments"
-version = "2.14.0"
+version = "2.15.0"
 description = "Pygments is a syntax highlighting package written in Python."
-category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 files = [
-    {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"},
-    {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"},
+    {file = "Pygments-2.15.0-py3-none-any.whl", hash = "sha256:77a3299119af881904cd5ecd1ac6a66214b6e9bed1f2db16993b54adede64094"},
+    {file = "Pygments-2.15.0.tar.gz", hash = "sha256:f7e36cffc4c517fbc252861b9a6e4644ca0e5abadf9a113c72d1358ad09b9500"},
 ]
 
 [package.extras]
@@ -686,7 +658,6 @@ plugins = ["importlib-metadata"]
 name = "pymysql"
 version = "1.0.2"
 description = "Pure Python MySQL Driver"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -702,7 +673,6 @@ rsa = ["cryptography"]
 name = "pytest"
 version = "7.2.2"
 description = "pytest: simple powerful testing with Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -727,7 +697,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.
 name = "pytest-asyncio"
 version = "0.21.0"
 description = "Pytest support for asyncio"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -747,7 +716,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy
 name = "pytest-clarity"
 version = "1.0.1"
 description = "A plugin providing an alternative, colourful diff output for failing assertions."
-category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
@@ -763,7 +731,6 @@ rich = ">=8.0.0"
 name = "pytest-cov"
 version = "4.0.0"
 description = "Pytest plugin for measuring coverage."
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -782,7 +749,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
 name = "python-dateutil"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
@@ -797,7 +763,6 @@ six = ">=1.5"
 name = "pytz"
 version = "2022.7.1"
 description = "World timezone definitions, modern and historical"
-category = "dev"
 optional = false
 python-versions = "*"
 files = [
@@ -809,7 +774,6 @@ files = [
 name = "rich"
 version = "13.3.2"
 description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
-category = "dev"
 optional = false
 python-versions = ">=3.7.0"
 files = [
@@ -829,7 +793,6 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
-category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -841,7 +804,6 @@ files = [
 name = "sqlalchemy"
 version = "2.0.7"
 description = "Database Abstraction Library"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -889,7 +851,7 @@ files = [
 ]
 
 [package.dependencies]
-greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"asyncio\""}
+greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\" or extra == \"asyncio\""}
 importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 typing-extensions = ">=4.2.0"
 
@@ -920,7 +882,6 @@ sqlcipher = ["sqlcipher3-binary"]
 name = "sqlalchemy-utils"
 version = "0.40.0"
 description = "Various utility functions for SQLAlchemy."
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -950,7 +911,6 @@ url = ["furl (>=0.4.1)"]
 name = "sqlbag"
 version = "0.1.1579049654"
 description = "various snippets of SQL-related boilerplate"
-category = "dev"
 optional = false
 python-versions = "*"
 files = []
@@ -975,7 +935,6 @@ resolved_reference = "0a97a185070228b43cb86181e5df8051f46ebc86"
 name = "toml"
 version = "0.10.2"
 description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
@@ -987,7 +946,6 @@ files = [
 name = "tomli"
 version = "2.0.1"
 description = "A lil' TOML parser"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -999,7 +957,6 @@ files = [
 name = "typed-ast"
 version = "1.5.4"
 description = "a fork of Python 2 and 3 ast modules with type comment support"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
 files = [
@@ -1033,7 +990,6 @@ files = [
 name = "typing-extensions"
 version = "4.5.0"
 description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -1045,7 +1001,6 @@ files = [
 name = "zipp"
 version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
 optional = false
 python-versions = ">=3.7"
 files = [
diff --git a/sqlakeyset/serial/serial.py b/sqlakeyset/serial/serial.py
index 0b90b1b..0eb9088 100644
--- a/sqlakeyset/serial/serial.py
+++ b/sqlakeyset/serial/serial.py
@@ -9,6 +9,7 @@ import uuid
 import dateutil.parser
 import csv
 from io import StringIO
+from contextlib import suppress
 
 
 class InvalidPage(ValueError):
@@ -82,13 +83,26 @@ TYPES = [
     (datetime.time, "t"),
 ]
 
+
 # These special values are serialized without prefix codes.
 BUILTINS = {
     "x": None,
     "true": True,
     "false": False,
 }
-BUILTINS_INV = {v: k for k, v in BUILTINS.items()}
+
+
+class NotABuiltin(Exception):
+    pass
+
+
+def invert_builtin(x) -> str:
+    for k, v in BUILTINS.items():
+        if x is v:
+            return k
+
+    raise NotABuiltin()
+
 
 T = TypeVar("T")
 
@@ -148,29 +162,34 @@ class Serial(object):
             return None
         return tuple(self.unserialize_value(_) for _ in self.split(s))
 
+    def get_serializer(self, x):
+        for cls in type(x).__mro__:
+            with suppress(KeyError):
+                return self.serializers[cls]
+
+        return None
+
     def serialize_value(self, x) -> str:
-        try:
-            serializer = self.serializers[type(x)]
-        except KeyError:
-            pass  # fall through to builtins
-        else:
-            try:
-                c, x = serializer(x)
-            except Exception as e:
-                raise PageSerializationError(
-                    "Custom bookmark serializer " "encountered error"
-                ) from e
-            else:
-                return "{}:{}".format(c, x)
+        with suppress(NotABuiltin):
+            return invert_builtin(x)
 
-        try:
-            return BUILTINS_INV[x]
-        except KeyError:
+        serializer = self.get_serializer(x)
+
+        if serializer is None:
             raise UnregisteredType(
                 "Don't know how to serialize type of {} ({}). "
                 "Use custom_bookmark_type to register it.".format(x, type(x))
             )
 
+        try:
+            c, x = serializer(x)
+        except Exception as e:
+            raise PageSerializationError(
+                "Custom bookmark serializer " "encountered error"
+            ) from e
+
+        return "{}:{}".format(c, x)
+
     def unserialize_value(self, x: str):
         try:
             c, v = x.split(":", 1)


Test patch:
diff --git a/tests/test_ser.py b/tests/test_ser.py
index eec8dc3..8dd42e3 100644
--- a/tests/test_ser.py
+++ b/tests/test_ser.py
@@ -95,6 +95,46 @@ def test_bad_serializer():
         s.serialize_value(Q())
 
 
+def test_subclass():
+    class SubclassedUUID(uuid.UUID):  # such as asyncpg.pgproto.pgproto.UUID
+        pass
+
+    _uuid = s.serialize_value(SubclassedUUID("939d4cc9-830d-4cca-bd74-3ec3d541a9b3"))
+    assert _uuid == "uuid:939d4cc9-830d-4cca-bd74-3ec3d541a9b3"
+
+
+def test_subclass_mro_order():
+    class A:
+        pass
+
+    class B:
+        pass
+
+    class C(A, B):
+        pass
+
+    class D(C, B):
+        pass
+
+    def a(x):
+        return "a"
+
+    def b(x):
+        return "b"
+
+    s.register_type(A, code="suba", serializer=a)
+    s.register_type(B, code="subb", serializer=b)
+
+    assert s.serialize_value(D()) == "suba:a"
+
+    def c(x):
+        return "c"
+
+    s.register_type(C, code="subc", serializer=c)
+
+    assert s.serialize_value(D()) == "subc:c"
+
+
 def test_serial():
     assert s.serialize_value(None) == "x"
     assert s.serialize_value(True) == "true"