pyordering 0.1.2__tar.gz

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.
@@ -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.