pyordering 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyordering/__init__.py +41 -0
- pyordering/compilers.py +135 -0
- pyordering/exceptions.py +56 -0
- pyordering/json_schema.py +49 -0
- pyordering/ordering.py +247 -0
- pyordering/py.typed +0 -0
- pyordering/registry.py +104 -0
- pyordering-0.1.2.dist-info/METADATA +566 -0
- pyordering-0.1.2.dist-info/RECORD +10 -0
- pyordering-0.1.2.dist-info/WHEEL +4 -0
pyordering/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""pyordering: sort orders as composable values.
|
|
2
|
+
|
|
3
|
+
`~stars() >> price() >> name()` is an `Ordering`: keys applied left to right,
|
|
4
|
+
sorting in memory with stable passes, compilable from JSON, and renderable to
|
|
5
|
+
ORM `order_by` clauses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .compilers import KeyDict, OrderingCompiler
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
InvalidKeyError,
|
|
11
|
+
InvalidKeyNameError,
|
|
12
|
+
InvalidOrderingDefinitionError,
|
|
13
|
+
KeyAlreadyRegisteredError,
|
|
14
|
+
KeyArgumentError,
|
|
15
|
+
KeyDoesNotExistError,
|
|
16
|
+
PyorderingError,
|
|
17
|
+
)
|
|
18
|
+
from .json_schema import get_ordering_json_schema
|
|
19
|
+
from .ordering import Key, KeyFactory, KeyFn, Nulls, Ordering, Render, key
|
|
20
|
+
from .registry import OrderingRegistry
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"InvalidKeyError",
|
|
24
|
+
"InvalidKeyNameError",
|
|
25
|
+
"InvalidOrderingDefinitionError",
|
|
26
|
+
"Key",
|
|
27
|
+
"KeyAlreadyRegisteredError",
|
|
28
|
+
"KeyArgumentError",
|
|
29
|
+
"KeyDict",
|
|
30
|
+
"KeyDoesNotExistError",
|
|
31
|
+
"KeyFactory",
|
|
32
|
+
"KeyFn",
|
|
33
|
+
"Nulls",
|
|
34
|
+
"Ordering",
|
|
35
|
+
"OrderingCompiler",
|
|
36
|
+
"OrderingRegistry",
|
|
37
|
+
"PyorderingError",
|
|
38
|
+
"Render",
|
|
39
|
+
"get_ordering_json_schema",
|
|
40
|
+
"key",
|
|
41
|
+
]
|
pyordering/compilers.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Compile an ordering from JSON: `[{"name": "stars", "descending": true}, {"name": "id"}]`.
|
|
2
|
+
|
|
3
|
+
Only registered keys compile, so a definition sent by a client (a `?sort=`
|
|
4
|
+
parameter, a saved view) can sort by nothing but the fields you allowed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, NotRequired, TypedDict, cast
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import index_path, key_path, located
|
|
11
|
+
|
|
12
|
+
from .exceptions import InvalidOrderingDefinitionError, KeyDoesNotExistError, PyorderingError
|
|
13
|
+
from .ordering import KeyFactory, Nulls, Ordering
|
|
14
|
+
|
|
15
|
+
_ALLOWED_KEYS = {"name", "args", "kwargs", "descending", "nulls"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KeyDict(TypedDict):
|
|
19
|
+
name: str
|
|
20
|
+
args: NotRequired[list[Any]]
|
|
21
|
+
kwargs: NotRequired[dict[str, Any]]
|
|
22
|
+
descending: NotRequired[bool]
|
|
23
|
+
nulls: NotRequired[Nulls]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OrderingCompiler[T]:
|
|
27
|
+
"""Compile JSON ordering definitions using a mapping of key factories.
|
|
28
|
+
|
|
29
|
+
Every problem is reported here, before anything is sorted, with a note
|
|
30
|
+
giving its JSON path.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
```python
|
|
34
|
+
>>> from pyordering import OrderingRegistry
|
|
35
|
+
>>> registry = OrderingRegistry[str]()
|
|
36
|
+
>>> @registry.key()
|
|
37
|
+
... def length(word: str) -> int:
|
|
38
|
+
... return len(word)
|
|
39
|
+
>>> compiler = OrderingCompiler(registry.keys)
|
|
40
|
+
>>> ordering = compiler.compile([{"name": "length", "descending": True}])
|
|
41
|
+
>>> ordering, ordering(["fig", "apple", "kiwi"])
|
|
42
|
+
(Ordering(~length), ['apple', 'kiwi', 'fig'])
|
|
43
|
+
>>> compiler.compile([{"name": "size"}])
|
|
44
|
+
Traceback (most recent call last):
|
|
45
|
+
...
|
|
46
|
+
pyordering.exceptions.KeyDoesNotExistError: Key 'size' does not exist. Available keys: length
|
|
47
|
+
at $[0].name
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
__slots__ = ("_keys",)
|
|
54
|
+
|
|
55
|
+
def __init__(self, keys: Mapping[str, KeyFactory[T, ...]], /) -> None:
|
|
56
|
+
self._keys = keys
|
|
57
|
+
|
|
58
|
+
def compile(self, definition: list[KeyDict] | Any, /) -> Ordering[T]:
|
|
59
|
+
"""Compile `definition`, a list of key objects, into an `Ordering`.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
InvalidOrderingDefinitionError: If the definition has the wrong shape.
|
|
63
|
+
KeyDoesNotExistError: If a name is not a registered key.
|
|
64
|
+
KeyArgumentError: If `args` or `kwargs` do not fit the key function.
|
|
65
|
+
InvalidKeyError: If `nulls` is not `"first"` or `"last"`.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(definition, list):
|
|
69
|
+
msg = f"Invalid ordering at $: expected a list, received {definition!r}"
|
|
70
|
+
|
|
71
|
+
raise InvalidOrderingDefinitionError(msg)
|
|
72
|
+
|
|
73
|
+
orderings = [
|
|
74
|
+
self._compile_key(item, index_path("$", index))
|
|
75
|
+
for index, item in enumerate(cast("list[Any]", definition))
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
return Ordering(tuple(k for ordering in orderings for k in ordering.keys))
|
|
79
|
+
|
|
80
|
+
def _compile_key(self, item: Any, path: str) -> Ordering[T]:
|
|
81
|
+
_check_shape(item, path)
|
|
82
|
+
|
|
83
|
+
with located(key_path(path, "name"), PyorderingError):
|
|
84
|
+
if (factory := self._keys.get(item["name"])) is None:
|
|
85
|
+
raise KeyDoesNotExistError(item["name"], self._keys)
|
|
86
|
+
|
|
87
|
+
with located(path, PyorderingError):
|
|
88
|
+
ordering = factory(*item.get("args", []), **item.get("kwargs", {}))
|
|
89
|
+
|
|
90
|
+
if item.get("descending", False):
|
|
91
|
+
ordering = ~ordering
|
|
92
|
+
|
|
93
|
+
with located(key_path(path, "nulls"), PyorderingError):
|
|
94
|
+
return ordering.nulls(item.get("nulls", "last"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_TYPES: dict[str, tuple[type, str]] = {
|
|
101
|
+
"name": (str, "a string"),
|
|
102
|
+
"args": (list, "a list"),
|
|
103
|
+
"kwargs": (dict, "an object"),
|
|
104
|
+
"descending": (bool, "a boolean"),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_shape(item: Any, path: str) -> None:
|
|
109
|
+
if not isinstance(item, Mapping):
|
|
110
|
+
msg = f"Invalid ordering at {path}: expected an object, received {item!r}"
|
|
111
|
+
|
|
112
|
+
raise InvalidOrderingDefinitionError(msg)
|
|
113
|
+
|
|
114
|
+
mapping = cast("Mapping[str, Any]", item)
|
|
115
|
+
|
|
116
|
+
keys = set(mapping)
|
|
117
|
+
|
|
118
|
+
if "name" not in keys:
|
|
119
|
+
msg = f"Invalid ordering at {path}: missing 'name'"
|
|
120
|
+
|
|
121
|
+
raise InvalidOrderingDefinitionError(msg)
|
|
122
|
+
|
|
123
|
+
if unknown := sorted(keys - _ALLOWED_KEYS):
|
|
124
|
+
msg = (
|
|
125
|
+
f"Invalid ordering at {path}: unknown {', '.join(map(repr, unknown))}. "
|
|
126
|
+
f"Allowed keys: {', '.join(sorted(_ALLOWED_KEYS))}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raise InvalidOrderingDefinitionError(msg)
|
|
130
|
+
|
|
131
|
+
for field, (expected, label) in _TYPES.items():
|
|
132
|
+
if field in keys and not isinstance(value := mapping[field], expected):
|
|
133
|
+
msg = f"Invalid ordering at {path}.{field}: expected {label}, received {value!r}"
|
|
134
|
+
|
|
135
|
+
raise InvalidOrderingDefinitionError(msg)
|
pyordering/exceptions.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Exceptions raised by pyordering.
|
|
2
|
+
|
|
3
|
+
Every error is raised while an ordering is *built* (from Python or from JSON),
|
|
4
|
+
never while it sorts. Compiler errors carry a note with their JSON path, e.g.
|
|
5
|
+
`at $[1].name`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import InvalidNameError, NameAlreadyRegisteredError, NameDoesNotExistError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PyorderingError(Exception):
|
|
14
|
+
"""Base class for every exception raised by pyordering."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidKeyError(PyorderingError, ValueError):
|
|
18
|
+
"""Raised when a key function or a key option (such as `nulls`) is invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KeyArgumentError(PyorderingError, TypeError):
|
|
22
|
+
"""Raised when a key factory is called with arguments its function does not accept."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidOrderingDefinitionError(PyorderingError, ValueError):
|
|
26
|
+
"""Raised when a JSON ordering definition does not have the expected shape."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KeyDoesNotExistError(PyorderingError, NameDoesNotExistError):
|
|
30
|
+
"""Raised when a key name is not in the registry.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
```python
|
|
34
|
+
>>> str(KeyDoesNotExistError("rating", ["price", "name"]))
|
|
35
|
+
"Key 'rating' does not exist. Available keys: price, name"
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, name: str, available: Iterable[str], /) -> None:
|
|
42
|
+
super().__init__(name, available, kind="key")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class KeyAlreadyRegisteredError(PyorderingError, NameAlreadyRegisteredError):
|
|
46
|
+
"""Raised when a key name is registered twice."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, name: str, /) -> None:
|
|
49
|
+
super().__init__(name, kind="key")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InvalidKeyNameError(PyorderingError, InvalidNameError):
|
|
53
|
+
"""Raised when a key name is not a valid ASCII Python identifier."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, name: object, /) -> None:
|
|
56
|
+
super().__init__(name, kind="key")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The JSON Schema of an ordering definition, as accepted by `OrderingCompiler.compile`.
|
|
2
|
+
|
|
3
|
+
It is generated from the registered key signatures, so it can be handed to a
|
|
4
|
+
form builder, an API spec, or an LLM that writes sort orders as JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pyvocabulary import JSON_SCHEMA_DIALECT, call_json_schema, json_schema_of
|
|
11
|
+
|
|
12
|
+
from .ordering import Nulls
|
|
13
|
+
|
|
14
|
+
_OPTIONS = {
|
|
15
|
+
"descending": {"type": "boolean", "default": False},
|
|
16
|
+
"nulls": {**json_schema_of(Nulls), "default": "last"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_ordering_json_schema(keys: Mapping[str, Callable[..., Any]], /) -> dict[str, Any]:
|
|
21
|
+
"""Return the JSON Schema of a list of key objects. Pass `registry.keys`.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
```python
|
|
25
|
+
>>> from pyordering import key
|
|
26
|
+
>>> @key
|
|
27
|
+
... def length(word: str) -> int:
|
|
28
|
+
... '''By number of characters.'''
|
|
29
|
+
... return len(word)
|
|
30
|
+
>>> schema = get_ordering_json_schema({"length": length})
|
|
31
|
+
>>> item = schema["items"]["oneOf"][0]
|
|
32
|
+
>>> schema["type"], item["properties"]["name"], item["description"]
|
|
33
|
+
('array', {'const': 'length'}, 'By number of characters.')
|
|
34
|
+
>>> item["properties"]["nulls"]
|
|
35
|
+
{'enum': ['first', 'last'], 'type': 'string', 'default': 'last'}
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
return {
|
|
41
|
+
"$schema": JSON_SCHEMA_DIALECT,
|
|
42
|
+
"type": "array",
|
|
43
|
+
"items": {
|
|
44
|
+
"oneOf": [
|
|
45
|
+
call_json_schema(name, factory, extra_properties=_OPTIONS)
|
|
46
|
+
for name, factory in keys.items()
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
}
|
pyordering/ordering.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Sort orders as values: `~stars() >> price() >> name()`.
|
|
2
|
+
|
|
3
|
+
A *key function* takes the subject first, then its own arguments, and returns
|
|
4
|
+
what to sort by. `@key` turns it into a *key factory*: calling the factory binds
|
|
5
|
+
the arguments and returns a one-key `Ordering`, ready to compose.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
@key
|
|
9
|
+
def price(product: Product) -> Decimal:
|
|
10
|
+
return product.price
|
|
11
|
+
|
|
12
|
+
ordering = ~stars() >> price() # stars descending, then price ascending
|
|
13
|
+
ordering(products) # a new, sorted list
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The same key function works for ORMs when the subject is the model class:
|
|
17
|
+
`price(Product)` returns the `Product.price` column, and `Ordering.clauses`
|
|
18
|
+
hands it to a render function that builds the `order_by` clause.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from functools import reduce, wraps
|
|
24
|
+
from inspect import Parameter, Signature, signature
|
|
25
|
+
from operator import itemgetter
|
|
26
|
+
from typing import Any, Concatenate, Literal, cast, get_args
|
|
27
|
+
|
|
28
|
+
from .exceptions import InvalidKeyError, KeyArgumentError
|
|
29
|
+
|
|
30
|
+
type Nulls = Literal["first", "last"]
|
|
31
|
+
type KeyFn[T, **P] = Callable[Concatenate[T, P], Any]
|
|
32
|
+
type KeyFactory[T, **P] = Callable[P, Ordering[T]]
|
|
33
|
+
type Render[C] = Callable[[Any, bool, Nulls], C | tuple[C, ...]]
|
|
34
|
+
|
|
35
|
+
NULLS: tuple[Nulls, ...] = get_args(Nulls.__value__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Key[T]:
|
|
40
|
+
"""One sort key: what to sort by, the direction, and where `None` values go."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
value: Callable[[T], Any]
|
|
44
|
+
descending: bool = False
|
|
45
|
+
nulls: Nulls = "last"
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if self.nulls not in NULLS:
|
|
49
|
+
msg = f"Invalid nulls {self.nulls!r}. Valid values: {', '.join(NULLS)}"
|
|
50
|
+
|
|
51
|
+
raise InvalidKeyError(msg)
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
direction = "~" if self.descending else ""
|
|
55
|
+
|
|
56
|
+
nulls = " nulls first" if self.nulls == "first" else ""
|
|
57
|
+
|
|
58
|
+
return f"{direction}{self.name}{nulls}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Ordering[T]:
|
|
63
|
+
"""Keys applied left to right. Calling it returns a new sorted list.
|
|
64
|
+
|
|
65
|
+
Each key is a stable sort pass, run from the last key to the first, so
|
|
66
|
+
directions can be mixed for any comparable type, strings and dates included.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
```python
|
|
70
|
+
>>> @key
|
|
71
|
+
... def length(word: str) -> int:
|
|
72
|
+
... return len(word)
|
|
73
|
+
>>> @key
|
|
74
|
+
... def alphabetical(word: str) -> str:
|
|
75
|
+
... return word
|
|
76
|
+
>>> ordering = ~length() >> alphabetical()
|
|
77
|
+
>>> ordering
|
|
78
|
+
Ordering(~length >> alphabetical)
|
|
79
|
+
>>> ordering(["fig", "apple", "kiwi", "plum", "date"])
|
|
80
|
+
['apple', 'date', 'kiwi', 'plum', 'fig']
|
|
81
|
+
>>> (~ordering)(["fig", "apple", "kiwi", "plum", "date"])
|
|
82
|
+
['fig', 'plum', 'kiwi', 'date', 'apple']
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
keys: tuple[Key[T], ...]
|
|
89
|
+
|
|
90
|
+
def __call__(self, items: Iterable[T], /) -> list[T]:
|
|
91
|
+
return reduce(_sort_by, reversed(self.keys), list(items))
|
|
92
|
+
|
|
93
|
+
def __rshift__(self, other: "Ordering[T]", /) -> "Ordering[T]":
|
|
94
|
+
match other:
|
|
95
|
+
case Ordering(keys=keys):
|
|
96
|
+
return Ordering((*self.keys, *keys))
|
|
97
|
+
case _:
|
|
98
|
+
return NotImplemented
|
|
99
|
+
|
|
100
|
+
def __invert__(self) -> "Ordering[T]":
|
|
101
|
+
# `nulls` is kept: whoever set it chose where nulls go on purpose.
|
|
102
|
+
return Ordering(tuple(replace(k, descending=not k.descending) for k in self.keys))
|
|
103
|
+
|
|
104
|
+
def nulls(self, where: Nulls, /) -> "Ordering[T]":
|
|
105
|
+
"""Return a copy where `None` values of every key sort `"first"` or `"last"`.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
```python
|
|
109
|
+
>>> @key
|
|
110
|
+
... def rank(item: dict[str, int | None]) -> int | None:
|
|
111
|
+
... return item["rank"]
|
|
112
|
+
>>> items = [{"rank": 2}, {"rank": None}, {"rank": 1}]
|
|
113
|
+
>>> rank()(items)
|
|
114
|
+
[{'rank': 1}, {'rank': 2}, {'rank': None}]
|
|
115
|
+
>>> rank().nulls("first")(items)
|
|
116
|
+
[{'rank': None}, {'rank': 1}, {'rank': 2}]
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
return Ordering(tuple(replace(k, nulls=where) for k in self.keys))
|
|
122
|
+
|
|
123
|
+
def clauses[C](self, subject: object, render: Render[C], /) -> tuple[C, ...]:
|
|
124
|
+
"""Return ORM `order_by` clauses, one or more per key.
|
|
125
|
+
|
|
126
|
+
Each key function is called with `subject` (usually the model class, so
|
|
127
|
+
it returns a column) and the result goes to `render(expression,
|
|
128
|
+
descending, nulls)`. A tuple returned by `render` is flattened, for
|
|
129
|
+
databases without `NULLS FIRST/LAST` that need two clauses per key.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
```python
|
|
133
|
+
>>> class Table:
|
|
134
|
+
... name = "name_column"
|
|
135
|
+
>>> @key
|
|
136
|
+
... def name(subject) -> str:
|
|
137
|
+
... return subject.name
|
|
138
|
+
>>> render = lambda column, descending, nulls: f"{column} {'DESC' if descending else 'ASC'}"
|
|
139
|
+
>>> (~name()).clauses(Table, render)
|
|
140
|
+
('name_column DESC',)
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
rendered = [
|
|
146
|
+
# The key function is typed for instances; here it is given the model class.
|
|
147
|
+
render(cast("Callable[[object], Any]", k.value)(subject), k.descending, k.nulls)
|
|
148
|
+
for k in self.keys
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
return tuple(
|
|
152
|
+
clause
|
|
153
|
+
for item in rendered
|
|
154
|
+
for clause in (cast("tuple[C, ...]", item) if isinstance(item, tuple) else (item,))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def __repr__(self) -> str:
|
|
158
|
+
return f"Ordering({' >> '.join(map(str, self.keys))})"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def key[T, **P](fn: KeyFn[T, P], /, *, name: str | None = None) -> KeyFactory[T, P]:
|
|
162
|
+
"""Turn a `(subject, *args, **kwargs) -> sort value` function into a key factory.
|
|
163
|
+
|
|
164
|
+
Arguments are checked against the function signature when the factory is
|
|
165
|
+
called, so a mistake fails while the ordering is built, not while it sorts.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
fn: The key function; its first positional parameter receives the subject.
|
|
169
|
+
name: The key name shown in `repr`; defaults to the function name.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
InvalidKeyError: If `fn` does not accept the subject positionally.
|
|
173
|
+
KeyArgumentError: Raised by the factory for arguments `fn` does not accept.
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
```python
|
|
177
|
+
>>> @key
|
|
178
|
+
... def distance(n: int, target: int) -> int:
|
|
179
|
+
... return abs(n - target)
|
|
180
|
+
>>> distance(10)([3, 12, 9, 20])
|
|
181
|
+
[9, 12, 3, 20]
|
|
182
|
+
>>> distance()
|
|
183
|
+
Traceback (most recent call last):
|
|
184
|
+
...
|
|
185
|
+
pyordering.exceptions.KeyArgumentError: Key 'distance' got invalid arguments: missing ...
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
key_name = name or getattr(fn, "__name__", repr(fn))
|
|
191
|
+
|
|
192
|
+
arguments = _arguments_signature(fn, key_name)
|
|
193
|
+
|
|
194
|
+
@wraps(fn)
|
|
195
|
+
def factory(*args: P.args, **kwargs: P.kwargs) -> Ordering[T]:
|
|
196
|
+
try:
|
|
197
|
+
bound = arguments.bind(*args, **kwargs)
|
|
198
|
+
except TypeError as error:
|
|
199
|
+
msg = f"Key '{key_name}' got invalid arguments: {error}"
|
|
200
|
+
|
|
201
|
+
raise KeyArgumentError(msg) from error
|
|
202
|
+
|
|
203
|
+
return Ordering((Key(key_name, lambda subject: fn(subject, *bound.args, **bound.kwargs)),))
|
|
204
|
+
|
|
205
|
+
factory.__signature__ = arguments # type: ignore[attr-defined]
|
|
206
|
+
|
|
207
|
+
return factory
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _sort_by[T](items: list[T], key: Key[T]) -> list[T]:
|
|
214
|
+
# Nulls are split off first: `None` does not compare with other values.
|
|
215
|
+
pairs = [(key.value(item), item) for item in items]
|
|
216
|
+
|
|
217
|
+
present = sorted(
|
|
218
|
+
(pair for pair in pairs if pair[0] is not None),
|
|
219
|
+
key=itemgetter(0),
|
|
220
|
+
reverse=key.descending, # `reverse` keeps the sort stable
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
ordered = [item for _, item in present]
|
|
224
|
+
|
|
225
|
+
missing = [item for value, item in pairs if value is None]
|
|
226
|
+
|
|
227
|
+
return [*missing, *ordered] if key.nulls == "first" else [*ordered, *missing]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _arguments_signature(fn: Callable[..., Any], name: str) -> Signature:
|
|
231
|
+
"""Return the signature of `fn` without its leading subject parameter."""
|
|
232
|
+
try:
|
|
233
|
+
subject, *parameters = signature(fn).parameters.values()
|
|
234
|
+
except ValueError as error:
|
|
235
|
+
msg = f"Key '{name}' must accept the subject as its first positional parameter"
|
|
236
|
+
|
|
237
|
+
raise InvalidKeyError(msg) from error
|
|
238
|
+
|
|
239
|
+
if subject.kind not in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
|
|
240
|
+
msg = (
|
|
241
|
+
f"Key '{name}' must accept the subject as its first positional parameter, "
|
|
242
|
+
f"got {subject.kind.description} parameter '{subject.name}'"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
raise InvalidKeyError(msg)
|
|
246
|
+
|
|
247
|
+
return Signature(parameters)
|
pyordering/py.typed
ADDED
|
File without changes
|
pyordering/registry.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""A registry of named keys: the whitelist of fields a JSON ordering may sort by."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
|
|
6
|
+
from pyvocabulary import Vocabulary
|
|
7
|
+
|
|
8
|
+
from .exceptions import InvalidKeyNameError, KeyAlreadyRegisteredError, KeyDoesNotExistError
|
|
9
|
+
from .ordering import KeyFactory, KeyFn, key
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrderingRegistry[T]:
|
|
13
|
+
"""Collect named keys for subjects of type `T`.
|
|
14
|
+
|
|
15
|
+
Its visible keys are what an `OrderingCompiler` accepts and what
|
|
16
|
+
`get_ordering_json_schema` describes; anything else fails at compile time.
|
|
17
|
+
Hidden keys stay usable from Python but are invisible to both.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
```python
|
|
21
|
+
>>> registry = OrderingRegistry[dict[str, int]]()
|
|
22
|
+
>>> @registry.key()
|
|
23
|
+
... def price(item: dict[str, int]) -> int:
|
|
24
|
+
... return item["price"]
|
|
25
|
+
>>> list(registry.keys)
|
|
26
|
+
['price']
|
|
27
|
+
>>> (~registry["price"]())([{"price": 1}, {"price": 3}])
|
|
28
|
+
[{'price': 3}, {'price': 1}]
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("_keys",)
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._keys: Vocabulary[KeyFactory[T, ...]] = Vocabulary(
|
|
38
|
+
"key",
|
|
39
|
+
does_not_exist=KeyDoesNotExistError,
|
|
40
|
+
already_registered=KeyAlreadyRegisteredError,
|
|
41
|
+
invalid_name=InvalidKeyNameError,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def keys(self) -> Mapping[str, KeyFactory[T, ...]]:
|
|
46
|
+
"""A read-only mapping of every visible key factory, by name."""
|
|
47
|
+
return MappingProxyType(dict(self._keys))
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, name: str, /) -> KeyFactory[T, ...]:
|
|
50
|
+
return self._keys[name]
|
|
51
|
+
|
|
52
|
+
def __contains__(self, name: object, /) -> bool:
|
|
53
|
+
return name in self._keys
|
|
54
|
+
|
|
55
|
+
def __iter__(self) -> Iterator[str]:
|
|
56
|
+
return iter(self._keys)
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self._keys)
|
|
60
|
+
|
|
61
|
+
def key[**P](
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
name: str | None = None,
|
|
65
|
+
description: str | None = None,
|
|
66
|
+
hidden: bool = False,
|
|
67
|
+
) -> Callable[[KeyFn[T, P]], KeyFactory[T, P]]:
|
|
68
|
+
"""Return a decorator registering a key function. See `register`."""
|
|
69
|
+
|
|
70
|
+
def decorator(fn: KeyFn[T, P]) -> KeyFactory[T, P]:
|
|
71
|
+
return self.register(fn, name=name, description=description, hidden=hidden)
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
def register[**P](
|
|
76
|
+
self,
|
|
77
|
+
fn: KeyFn[T, P],
|
|
78
|
+
/,
|
|
79
|
+
*,
|
|
80
|
+
name: str | None = None,
|
|
81
|
+
description: str | None = None,
|
|
82
|
+
hidden: bool = False,
|
|
83
|
+
) -> KeyFactory[T, P]:
|
|
84
|
+
"""Register `fn` as a key and return its factory.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
fn: The key function; its first positional parameter receives the subject.
|
|
88
|
+
name: The key name used in JSON and `repr`; defaults to the function name.
|
|
89
|
+
description: Overrides the function docstring (shown in the JSON Schema).
|
|
90
|
+
hidden: Keep the key out of lookups, the compiler and the JSON Schema.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
InvalidKeyNameError: If the name is not a valid Python identifier.
|
|
94
|
+
KeyAlreadyRegisteredError: If the name is taken.
|
|
95
|
+
InvalidKeyError: If `fn` does not accept the subject positionally.
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
key_name = name or getattr(fn, "__name__", "")
|
|
99
|
+
|
|
100
|
+
factory = key(fn, name=key_name)
|
|
101
|
+
|
|
102
|
+
factory.__doc__ = description or fn.__doc__
|
|
103
|
+
|
|
104
|
+
return self._keys.add(key_name, factory, hidden=hidden)
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyordering
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Sort orders as composable values: stars(descending=True) >> price(), definable as JSON.
|
|
5
|
+
Keywords: sorting,ordering,order-by,json,dsl,composition
|
|
6
|
+
Author: aaltatan
|
|
7
|
+
Author-email: aaltatan <a.altatan@gmail.com>
|
|
8
|
+
License-Expression: GPL-3.0-only
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: pyvocabulary>=0.1.0
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Project-URL: Homepage, https://github.com/aaltatan/pyordering
|
|
21
|
+
Project-URL: Repository, https://github.com/aaltatan/pyordering
|
|
22
|
+
Project-URL: Issues, https://github.com/aaltatan/pyordering/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pyordering
|
|
26
|
+
|
|
27
|
+
Sort orders as composable values. `~stars() >> price() >> name()` is an `Ordering`: keys
|
|
28
|
+
applied left to right that sort lists in memory, compile from JSON, and render to
|
|
29
|
+
SQLAlchemy or Django `order_by` clauses.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
best_first = ~stars() >> price() >> name()
|
|
33
|
+
|
|
34
|
+
best_first(products) # a new sorted list
|
|
35
|
+
|
|
36
|
+
select(Product).order_by(*best_first.clauses(Product, render)) # SQLAlchemy
|
|
37
|
+
|
|
38
|
+
Product.objects.order_by(*best_first.clauses(Product, render)) # Django
|
|
39
|
+
|
|
40
|
+
compiler.compile([{"name": "stars", "descending": True}, {"name": "price"}]) # from JSON
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Piece | What it does |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| [`key`](#quick-start) | Turns a `(subject, ...) -> sort value` function into a factory of one-key orderings |
|
|
46
|
+
| [`Ordering`](#composing-orderings) | Callable: returns a new sorted list. Composes with `>>`, flips with `~`, sets `nulls` |
|
|
47
|
+
| [`OrderingRegistry`](#a-registry-is-the-whitelist) | The named keys a JSON ordering may use, and nothing else |
|
|
48
|
+
| [`OrderingCompiler`](#from-json) | Builds an `Ordering` from JSON, with errors that point at the JSON path |
|
|
49
|
+
| [`get_ordering_json_schema`](#json-schema) | The JSON Schema of that JSON, generated from the key signatures |
|
|
50
|
+
| [`Ordering.clauses`](#sqlalchemy) | ORM `order_by` clauses through a render function you choose; no ORM is imported |
|
|
51
|
+
|
|
52
|
+
It is part of a family of packages that wrap a callable in an immutable value with an
|
|
53
|
+
algebra: [pyspecification](https://github.com/aaltatan/pyspecification) (`T -> bool`),
|
|
54
|
+
[pyformula](https://github.com/aaltatan/pyformula) (`T -> number`),
|
|
55
|
+
[pyflowstep](https://github.com/aaltatan/pyflowstep) (`T -> T`), and pyordering (`T -> sort key`).
|
|
56
|
+
|
|
57
|
+
## Installation
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install pyordering
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
or with uv:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv add pyordering
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Python 3.12+. The only runtime dependency is
|
|
70
|
+
[pyvocabulary](https://github.com/aaltatan/pyvocabulary). SQLAlchemy and Django are optional:
|
|
71
|
+
pyordering never imports them.
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
The examples build on each other. A comment under a line shows its result.
|
|
76
|
+
|
|
77
|
+
A *key function* takes the subject and returns what to sort by. `@key` turns it into a factory:
|
|
78
|
+
calling the factory returns a one-key `Ordering`.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from dataclasses import dataclass
|
|
82
|
+
from datetime import date
|
|
83
|
+
from decimal import Decimal
|
|
84
|
+
from pyordering import key
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Product:
|
|
89
|
+
id: int
|
|
90
|
+
name: str
|
|
91
|
+
price: Decimal
|
|
92
|
+
stars: int | None
|
|
93
|
+
released: date
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
products = [
|
|
97
|
+
Product(1, "kettle", Decimal("30.00"), 4, date(2024, 3, 1)),
|
|
98
|
+
Product(2, "toaster", Decimal("25.00"), None, date(2023, 6, 1)),
|
|
99
|
+
Product(3, "blender", Decimal("30.00"), 5, date(2025, 1, 1)),
|
|
100
|
+
Product(4, "mixer", Decimal("25.00"), 4, date(2022, 9, 1)),
|
|
101
|
+
Product(5, "grill", Decimal("80.00"), None, date(2025, 5, 1)),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@key
|
|
106
|
+
def price(product: Product) -> Decimal:
|
|
107
|
+
return product.price
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@key
|
|
111
|
+
def name(product: Product) -> str:
|
|
112
|
+
return product.name
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
by_price = price() >> name()
|
|
116
|
+
|
|
117
|
+
by_price
|
|
118
|
+
# Ordering(price >> name)
|
|
119
|
+
|
|
120
|
+
[p.name for p in by_price(products)]
|
|
121
|
+
# ['mixer', 'toaster', 'blender', 'kettle', 'grill']
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Calling an ordering never changes its input: it takes any iterable and returns a new list.
|
|
125
|
+
|
|
126
|
+
## Composing orderings
|
|
127
|
+
|
|
128
|
+
`>>` appends keys: later keys break ties left by earlier ones. `~` flips the direction of every
|
|
129
|
+
key. Directions can be mixed freely, for strings and dates too, because each key is a separate
|
|
130
|
+
stable sort pass, run from the last key to the first.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@key
|
|
134
|
+
def released(product: Product) -> date:
|
|
135
|
+
return product.released
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
newest = ~released()
|
|
139
|
+
|
|
140
|
+
[p.name for p in newest(products)]
|
|
141
|
+
# ['grill', 'blender', 'kettle', 'toaster', 'mixer']
|
|
142
|
+
|
|
143
|
+
expensive_then_alphabetical = ~price() >> name()
|
|
144
|
+
|
|
145
|
+
[p.name for p in expensive_then_alphabetical(products)]
|
|
146
|
+
# ['grill', 'blender', 'kettle', 'mixer', 'toaster']
|
|
147
|
+
|
|
148
|
+
~expensive_then_alphabetical
|
|
149
|
+
# Ordering(price >> ~name)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Orderings are frozen dataclasses: every operator returns a new value, so a base ordering can be
|
|
153
|
+
shared and extended safely.
|
|
154
|
+
|
|
155
|
+
### Nulls
|
|
156
|
+
|
|
157
|
+
`None` values do not compare with anything, so each key decides where they go: `"last"` by
|
|
158
|
+
default, in both directions, or `"first"`. `~` keeps that choice, because whoever set it chose
|
|
159
|
+
where nulls go on purpose.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
@key
|
|
163
|
+
def stars(product: Product) -> int | None:
|
|
164
|
+
return product.stars
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
[(p.name, p.stars) for p in (~stars() >> name())(products)]
|
|
168
|
+
# [('blender', 5), ('kettle', 4), ('mixer', 4), ('grill', None), ('toaster', None)]
|
|
169
|
+
|
|
170
|
+
unrated_first = stars().nulls("first") >> name()
|
|
171
|
+
|
|
172
|
+
unrated_first
|
|
173
|
+
# Ordering(stars nulls first >> name)
|
|
174
|
+
|
|
175
|
+
[(p.name, p.stars) for p in unrated_first(products)]
|
|
176
|
+
# [('grill', None), ('toaster', None), ('kettle', 4), ('mixer', 4), ('blender', 5)]
|
|
177
|
+
|
|
178
|
+
~unrated_first
|
|
179
|
+
# Ordering(~stars nulls first >> ~name)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`nulls()` applies to every key of the ordering it is called on, so call it on the single key
|
|
183
|
+
you mean, before composing. An invalid value fails right away:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
stars().nulls("middle")
|
|
187
|
+
# raises InvalidKeyError: Invalid nulls 'middle'. Valid values: first, last
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Keys with arguments
|
|
191
|
+
|
|
192
|
+
Parameters after the subject become arguments of the factory. They are bound when the ordering
|
|
193
|
+
is built, so a wrong argument fails then, not halfway through a sort.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
@key
|
|
197
|
+
def closest_to(product: Product, budget: int, *, penalty_over: int = 1) -> Decimal:
|
|
198
|
+
"""Sort by distance from a budget; going over it can cost more."""
|
|
199
|
+
difference = product.price - budget
|
|
200
|
+
|
|
201
|
+
return difference * penalty_over if difference > 0 else -difference
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
[p.name for p in (closest_to(28, penalty_over=3) >> name())(products)]
|
|
205
|
+
# ['mixer', 'toaster', 'blender', 'kettle', 'grill']
|
|
206
|
+
|
|
207
|
+
closest_to(budget="28", unknown=1)
|
|
208
|
+
# raises KeyArgumentError: Key 'closest_to' got invalid arguments: got an unexpected keyword argument 'unknown'
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Pagination needs a unique last key
|
|
212
|
+
|
|
213
|
+
Pages are only stable when no two rows tie on the whole ordering. Otherwise the database (or
|
|
214
|
+
the input order) decides ties, and rows can repeat or vanish between pages. End every paginated
|
|
215
|
+
ordering with a unique key, usually the id:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
@key
|
|
219
|
+
def product_id(product: Product) -> int:
|
|
220
|
+
return product.id
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
paginated = ~stars() >> price() >> product_id()
|
|
224
|
+
|
|
225
|
+
pages = [paginated(products)[start : start + 2] for start in (0, 2, 4)]
|
|
226
|
+
|
|
227
|
+
[[p.id for p in page] for page in pages]
|
|
228
|
+
# [[3, 4], [1, 2], [5]]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## A registry is the whitelist
|
|
232
|
+
|
|
233
|
+
An `OrderingRegistry` collects named keys. Its visible keys are exactly what the compiler and the
|
|
234
|
+
JSON Schema accept, so a client can never sort by a field you did not register.
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from pyordering import OrderingRegistry
|
|
238
|
+
|
|
239
|
+
registry = OrderingRegistry[Product]()
|
|
240
|
+
|
|
241
|
+
for function in (price, name, stars, released):
|
|
242
|
+
registry.register(function.__wrapped__)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@registry.key(name="id")
|
|
246
|
+
def product_id(product: Product) -> int:
|
|
247
|
+
"""The unique tie-breaker for pagination."""
|
|
248
|
+
return product.id
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
registry.register(closest_to.__wrapped__)
|
|
252
|
+
|
|
253
|
+
list(registry)
|
|
254
|
+
# ['price', 'name', 'stars', 'released', 'id', 'closest_to']
|
|
255
|
+
|
|
256
|
+
registry["stars"]() >> registry["id"]()
|
|
257
|
+
# Ordering(stars >> id)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`@registry.key()` takes `name`, `description` (overrides the docstring in the JSON Schema) and
|
|
261
|
+
`hidden=True` (usable from Python, invisible to the compiler and the schema). Names must be
|
|
262
|
+
Python identifiers, and each one can be registered once:
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
registry["rating"]
|
|
266
|
+
# raises KeyDoesNotExistError: Key 'rating' does not exist. Available keys: price, name, stars, released, id, closest_to
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## From JSON
|
|
270
|
+
|
|
271
|
+
A JSON ordering is a list of key objects: `name`, and optionally `descending`, `nulls`, `args`
|
|
272
|
+
and `kwargs`.
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
from pyordering import OrderingCompiler
|
|
276
|
+
|
|
277
|
+
compiler = OrderingCompiler(registry.keys)
|
|
278
|
+
|
|
279
|
+
ordering = compiler.compile(
|
|
280
|
+
[
|
|
281
|
+
{"name": "stars", "descending": True, "nulls": "first"},
|
|
282
|
+
{"name": "closest_to", "args": [28], "kwargs": {"penalty_over": 3}},
|
|
283
|
+
{"name": "id"},
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
ordering
|
|
288
|
+
# Ordering(~stars nulls first >> closest_to >> id)
|
|
289
|
+
|
|
290
|
+
[p.name for p in ordering(products)]
|
|
291
|
+
# ['toaster', 'grill', 'blender', 'mixer', 'kettle']
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Every problem is reported while compiling, before anything is sorted. The error keeps its type
|
|
295
|
+
and gets a note with the JSON path:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
compiler.compile([{"name": "price"}, {"name": "rating"}])
|
|
299
|
+
# raises KeyDoesNotExistError: Key 'rating' does not exist. Available keys: price, name, stars, released, id, closest_to
|
|
300
|
+
# at $[1].name
|
|
301
|
+
|
|
302
|
+
compiler.compile([{"name": "price", "direction": "desc"}])
|
|
303
|
+
# raises InvalidOrderingDefinitionError: Invalid ordering at $[0]: unknown 'direction'. Allowed keys: args, descending, kwargs, name, nulls
|
|
304
|
+
|
|
305
|
+
compiler.compile([{"name": "closest_to"}])
|
|
306
|
+
# raises KeyArgumentError: Key 'closest_to' got invalid arguments: missing a required argument: 'budget'
|
|
307
|
+
# at $[0]
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
All of them derive from `PyorderingError`.
|
|
311
|
+
|
|
312
|
+
### A `?sort=` query parameter
|
|
313
|
+
|
|
314
|
+
pyordering does not parse query strings, but a common shape (`?sort=-stars,price`) maps onto the
|
|
315
|
+
JSON in one comprehension, and the registry still decides what is allowed:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
def parse_sort(value: str) -> list[dict[str, object]]:
|
|
319
|
+
return [
|
|
320
|
+
{"name": field.removeprefix("-"), "descending": field.startswith("-")}
|
|
321
|
+
for field in value.split(",")
|
|
322
|
+
if field
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
compiler.compile(parse_sort("-stars,price") + [{"name": "id"}])
|
|
327
|
+
# Ordering(~stars >> price >> id)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
## JSON Schema
|
|
331
|
+
|
|
332
|
+
`get_ordering_json_schema` describes exactly what the compiler accepts, generated from the key
|
|
333
|
+
signatures and docstrings. Hand it to a form builder, an OpenAPI spec, or an LLM that writes sort
|
|
334
|
+
orders.
|
|
335
|
+
|
|
336
|
+
```python
|
|
337
|
+
from pyordering import get_ordering_json_schema
|
|
338
|
+
|
|
339
|
+
schema = get_ordering_json_schema(registry.keys)
|
|
340
|
+
|
|
341
|
+
schema["type"], [item["properties"]["name"]["const"] for item in schema["items"]["oneOf"]]
|
|
342
|
+
# ('array', ['price', 'name', 'stars', 'released', 'id', 'closest_to'])
|
|
343
|
+
|
|
344
|
+
closest = schema["items"]["oneOf"][-1]
|
|
345
|
+
|
|
346
|
+
closest["description"]
|
|
347
|
+
# 'Sort by distance from a budget; going over it can cost more.'
|
|
348
|
+
|
|
349
|
+
closest["properties"]["args"]["prefixItems"]
|
|
350
|
+
# [{'type': 'integer'}]
|
|
351
|
+
|
|
352
|
+
closest["properties"]["kwargs"]["properties"]
|
|
353
|
+
# {'budget': {'type': 'integer'}, 'penalty_over': {'type': 'integer', 'default': 1}}
|
|
354
|
+
|
|
355
|
+
closest["properties"]["descending"], closest["properties"]["nulls"]
|
|
356
|
+
# ({'type': 'boolean', 'default': False}, {'enum': ['first', 'last'], 'type': 'string', 'default': 'last'})
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## SQLAlchemy
|
|
360
|
+
|
|
361
|
+
`ordering.clauses(subject, render)` calls each key function with `subject` and passes the result
|
|
362
|
+
to `render(expression, descending, nulls)`. With the model *class* as the subject, a key like
|
|
363
|
+
`product.price` returns the `Product.price` column, so **the same registry sorts dataclasses,
|
|
364
|
+
loaded rows, and SQL queries**. pyordering imports no ORM; the render function is yours.
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
from sqlalchemy import Date, Numeric, String, create_engine, select
|
|
368
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class Base(DeclarativeBase):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class ProductRow(Base):
|
|
376
|
+
__tablename__ = "products"
|
|
377
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
378
|
+
name: Mapped[str] = mapped_column(String(50))
|
|
379
|
+
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
|
380
|
+
stars: Mapped[int | None]
|
|
381
|
+
released: Mapped[date] = mapped_column(Date)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def sqlalchemy_render(column, descending, nulls):
|
|
385
|
+
clause = column.desc() if descending else column.asc()
|
|
386
|
+
|
|
387
|
+
return clause.nulls_first() if nulls == "first" else clause.nulls_last()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
engine = create_engine("sqlite://")
|
|
391
|
+
|
|
392
|
+
Base.metadata.create_all(engine)
|
|
393
|
+
|
|
394
|
+
with Session(engine) as session:
|
|
395
|
+
session.add_all(ProductRow(**vars(p)) for p in products)
|
|
396
|
+
|
|
397
|
+
session.commit()
|
|
398
|
+
|
|
399
|
+
best_first = compiler.compile(
|
|
400
|
+
[{"name": "stars", "descending": True}, {"name": "price"}, {"name": "id"}]
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
query = select(ProductRow).order_by(*best_first.clauses(ProductRow, sqlalchemy_render))
|
|
404
|
+
|
|
405
|
+
print(query)
|
|
406
|
+
# SELECT products.id, products.name, products.price, products.stars, products.released
|
|
407
|
+
# FROM products ORDER BY products.stars DESC NULLS LAST, products.price ASC NULLS LAST, products.id ASC NULLS LAST
|
|
408
|
+
|
|
409
|
+
with Session(engine) as session:
|
|
410
|
+
rows = session.scalars(query).all()
|
|
411
|
+
|
|
412
|
+
[row.name for row in rows]
|
|
413
|
+
# ['blender', 'mixer', 'kettle', 'toaster', 'grill']
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The database and memory agree, and the same ordering sorts the loaded rows too:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
[row.name for row in best_first(rows)] == [p.name for p in best_first(products)] == [
|
|
420
|
+
row.name for row in rows
|
|
421
|
+
]
|
|
422
|
+
# True
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
A key can return any column expression, such as `func.lower(subject.name)`, as long as the
|
|
426
|
+
expression also works on an instance when you sort in memory, or you only use it for queries.
|
|
427
|
+
|
|
428
|
+
### MySQL
|
|
429
|
+
|
|
430
|
+
MySQL has no `NULLS FIRST/LAST`. It sorts `NULL` as the smallest value, so the render function
|
|
431
|
+
returns two clauses per key, the first one sorting by "is null", and `clauses` flattens them:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
from sqlalchemy.dialects import mysql
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def mysql_render(column, descending, nulls):
|
|
438
|
+
is_null = column.is_(None) if nulls == "last" else column.is_not(None)
|
|
439
|
+
|
|
440
|
+
return is_null, column.desc() if descending else column.asc()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
query = select(ProductRow.id).order_by(*best_first.clauses(ProductRow, mysql_render))
|
|
444
|
+
|
|
445
|
+
print(query.compile(dialect=mysql.dialect()))
|
|
446
|
+
# SELECT products.id
|
|
447
|
+
# FROM products ORDER BY products.stars IS NULL, products.stars DESC, products.price IS NULL, products.price ASC, products.id IS NULL, products.id ASC
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
## Django
|
|
451
|
+
|
|
452
|
+
The same idea with Django: on the model class, `product.price` is a field descriptor, which the
|
|
453
|
+
render function turns into an `F` expression. Expressions you return yourself, like
|
|
454
|
+
`Lower("name")`, pass through unchanged.
|
|
455
|
+
|
|
456
|
+
The settings and table creation below stand in for a Django project, so the example runs on its
|
|
457
|
+
own. In a project, the model lives in your app's `models.py`.
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
import django
|
|
461
|
+
from django.conf import settings
|
|
462
|
+
|
|
463
|
+
settings.configure(
|
|
464
|
+
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
django.setup()
|
|
468
|
+
|
|
469
|
+
from django.db import connection, models
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class ProductModel(models.Model):
|
|
473
|
+
name = models.CharField(max_length=50)
|
|
474
|
+
price = models.DecimalField(max_digits=10, decimal_places=2)
|
|
475
|
+
stars = models.IntegerField(null=True)
|
|
476
|
+
released = models.DateField()
|
|
477
|
+
|
|
478
|
+
class Meta:
|
|
479
|
+
app_label = "shop"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
with connection.schema_editor() as editor:
|
|
483
|
+
editor.create_model(ProductModel)
|
|
484
|
+
|
|
485
|
+
ProductModel.objects.bulk_create(ProductModel(**vars(p)) for p in products)
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
The render function:
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
from django.db.models import F
|
|
492
|
+
from django.db.models.query_utils import DeferredAttribute
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def django_render(expression, descending, nulls):
|
|
496
|
+
if isinstance(expression, DeferredAttribute): # `Product.price` on the class
|
|
497
|
+
expression = F(expression.field.name)
|
|
498
|
+
|
|
499
|
+
order = expression.desc if descending else expression.asc
|
|
500
|
+
|
|
501
|
+
return order(nulls_first=nulls == "first" or None, nulls_last=nulls == "last" or None)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
unrated_first = compiler.compile([{"name": "stars", "nulls": "first"}, {"name": "id"}])
|
|
505
|
+
|
|
506
|
+
queryset = ProductModel.objects.order_by(*unrated_first.clauses(ProductModel, django_render))
|
|
507
|
+
|
|
508
|
+
print(queryset.query)
|
|
509
|
+
# SELECT ... FROM "shop_productmodel" ORDER BY "shop_productmodel"."stars" ASC NULLS FIRST, "shop_productmodel"."id" ASC NULLS LAST
|
|
510
|
+
|
|
511
|
+
[product.name for product in queryset]
|
|
512
|
+
# ['toaster', 'grill', 'kettle', 'mixer', 'blender']
|
|
513
|
+
|
|
514
|
+
[p.name for p in unrated_first(products)]
|
|
515
|
+
# ['toaster', 'grill', 'kettle', 'mixer', 'blender']
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Django emulates `NULLS FIRST/LAST` on MySQL by itself, so this render function works there
|
|
519
|
+
unchanged.
|
|
520
|
+
|
|
521
|
+
In a view, the whitelist and the JSON paths do the validation:
|
|
522
|
+
|
|
523
|
+
```python
|
|
524
|
+
from django.http import HttpRequest, JsonResponse
|
|
525
|
+
|
|
526
|
+
from pyordering import PyorderingError
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def products(request: HttpRequest) -> JsonResponse:
|
|
530
|
+
try:
|
|
531
|
+
ordering = compiler.compile(parse_sort(request.GET.get("sort", "")) + [{"name": "id"}])
|
|
532
|
+
except PyorderingError as error:
|
|
533
|
+
return JsonResponse(
|
|
534
|
+
{"error": str(error), "notes": getattr(error, "__notes__", [])}, status=400
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
queryset = ProductModel.objects.order_by(*ordering.clauses(ProductModel, django_render))
|
|
538
|
+
|
|
539
|
+
return JsonResponse({"products": list(queryset.values("id", "name"))})
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
## Reference
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
Ordering(keys: tuple[Key[T], ...])
|
|
546
|
+
ordering(items) # -> list[T], a new sorted list
|
|
547
|
+
ordering >> other # -> Ordering[T], keys of both
|
|
548
|
+
~ordering # -> Ordering[T], every direction flipped, nulls kept
|
|
549
|
+
ordering.nulls("first" | "last") # -> Ordering[T], nulls set on every key
|
|
550
|
+
ordering.clauses(subject, render) # -> tuple of clauses; render(expression, descending, nulls)
|
|
551
|
+
|
|
552
|
+
Key(name, value, descending=False, nulls="last")
|
|
553
|
+
|
|
554
|
+
key(fn, /, *, name=None) # -> factory(*args, **kwargs) -> Ordering[T]
|
|
555
|
+
OrderingRegistry[T]() # .key(name=, description=, hidden=), .register(fn, ...), .keys
|
|
556
|
+
OrderingCompiler(keys).compile([...])
|
|
557
|
+
get_ordering_json_schema(keys)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Exceptions, all subclasses of `PyorderingError`: `InvalidKeyError` (bad key function or `nulls`),
|
|
561
|
+
`KeyArgumentError` (arguments the key function does not accept), `InvalidOrderingDefinitionError`
|
|
562
|
+
(wrong JSON shape), `KeyDoesNotExistError`, `KeyAlreadyRegisteredError`, `InvalidKeyNameError`.
|
|
563
|
+
|
|
564
|
+
## License
|
|
565
|
+
|
|
566
|
+
GPL-3.0-only.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyordering/__init__.py,sha256=qYV5m7yDJQj6KYCHlWC-HvBxnUFTguLrsU0gIUz8KHQ,1060
|
|
2
|
+
pyordering/compilers.py,sha256=TCe7VXSOLAPFiQzLR_BQiybbtVPXsdSZ8KzgCxeF598,4642
|
|
3
|
+
pyordering/exceptions.py,sha256=dW8HHD-9l2Qv75DLEwsOGx3j1LSD13ux7iT3jG72Wxg,1756
|
|
4
|
+
pyordering/json_schema.py,sha256=xFzI-hH8NhelUgENSPI4MQaOvWOUQcrbpMZXWjpF8SQ,1597
|
|
5
|
+
pyordering/ordering.py,sha256=wX5KaP5V1cODZ3sKNShA6IcgtQHykmXBRoIMAexrZjQ,8526
|
|
6
|
+
pyordering/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pyordering/registry.py,sha256=GwAr-YMImu7DwxkTf6h5wQyr5Uxz2hq-gvkuaVukNTs,3463
|
|
8
|
+
pyordering-0.1.2.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
9
|
+
pyordering-0.1.2.dist-info/METADATA,sha256=Zh_As5HkN-m9zOMFjXun9o-F2TJmCuY-G5lgfd8Q4ZE,18740
|
|
10
|
+
pyordering-0.1.2.dist-info/RECORD,,
|