Skip to content

Commit acd0571

Browse files
authored
Add some utils that have been duplicated in various code bases (#378)
* Add utils to sort records with same syntax as server * Add utils to compare records * Add collection_diff util
1 parent cf0251e commit acd0571

File tree

2 files changed

+237
-0
lines changed

2 files changed

+237
-0
lines changed

src/kinto_http/utils.py

+53
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import json
33
import re
4+
import sys
45
import unicodedata
56
from datetime import date, datetime
67

@@ -9,6 +10,9 @@
910
from kinto_http.constants import VALID_SLUG_REGEXP
1011

1112

13+
MAX_LENGTH_INT = len(str(sys.maxsize * 2 + 1))
14+
15+
1216
def slugify(value):
1317
"""Normalizes string, converts to lowercase, removes non-alpha characters
1418
and converts spaces to hyphens.
@@ -57,3 +61,52 @@ def json_iso_datetime(obj):
5761

5862

5963
json_dumps = functools.partial(json.dumps, default=json_iso_datetime)
64+
65+
66+
def sort_records(records, sort):
67+
"""
68+
Sort records following the same format as the server ``name,-last_modified``.
69+
"""
70+
71+
def reversed(way, value):
72+
if isinstance(value, (int, float)):
73+
value = str(way * value).zfill(MAX_LENGTH_INT)
74+
if isinstance(value, str):
75+
return "".join(chr(255 - ord(c)) for c in value) if way < 0 else value
76+
return str(value)
77+
78+
sort_fields = [
79+
(-1, f.strip()[1:]) if f.startswith("-") else (1, f.strip()) for f in sort.split(",")
80+
]
81+
return sorted(
82+
records, key=lambda r: tuple(reversed(way, r.get(field)) for way, field in sort_fields)
83+
)
84+
85+
86+
def records_equal(a, b):
87+
"""
88+
Compare records attributes, ignoring those assigned automatically
89+
by the server.
90+
"""
91+
ignore_fields = ("last_modified", "schema")
92+
ac = {k: v for k, v in a.items() if k not in ignore_fields}
93+
bc = {k: v for k, v in b.items() if k not in ignore_fields}
94+
return ac == bc
95+
96+
97+
def collection_diff(src, dest):
98+
"""
99+
Compare two lists of records.
100+
"""
101+
dest_by_id = {r["id"]: r for r in dest}
102+
to_create = []
103+
to_update = []
104+
for r in src:
105+
record = dest_by_id.pop(r["id"], None)
106+
if record is None:
107+
to_create.append(r)
108+
elif not records_equal(r, record):
109+
r.pop("last_modified", None)
110+
to_update.append((record, r))
111+
to_delete = list(dest_by_id.values())
112+
return to_create, to_update, to_delete

tests/test_utils.py

+184
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,187 @@ def test_quote_strips_extra_quotes():
5353

5454
def test_quotes_can_take_integers():
5555
assert utils.quote(1234) == '"1234"'
56+
57+
58+
def test_sort_single_field_ascending():
59+
records = [
60+
{"name": "Charlie", "age": 25},
61+
{"name": "Alice", "age": 30},
62+
{"name": "Bob", "age": 20},
63+
]
64+
result = utils.sort_records(records, "name")
65+
expected = [
66+
{"name": "Alice", "age": 30},
67+
{"name": "Bob", "age": 20},
68+
{"name": "Charlie", "age": 25},
69+
]
70+
assert result == expected
71+
72+
73+
def test_sort_single_field_descending():
74+
records = [
75+
{"name": "Charlie", "age": 25},
76+
{"name": "Alice", "age": 30},
77+
{"name": "Bob", "age": 20},
78+
]
79+
result = utils.sort_records(records, "-name")
80+
expected = [
81+
{"name": "Charlie", "age": 25},
82+
{"name": "Bob", "age": 20},
83+
{"name": "Alice", "age": 30},
84+
]
85+
assert result == expected
86+
87+
88+
def test_sort_multiple_fields():
89+
records = [
90+
{"name": "Alice", "age": 30},
91+
{"name": "Bob", "age": 25},
92+
{"name": "Alice", "age": 20},
93+
]
94+
result = utils.sort_records(records, "name,-age")
95+
expected = [
96+
{"name": "Alice", "age": 30},
97+
{"name": "Alice", "age": 20},
98+
{"name": "Bob", "age": 25},
99+
]
100+
assert result == expected
101+
102+
103+
def test_sort_missing_field():
104+
records = [
105+
{"name": "Charlie", "age": 25},
106+
{"name": "Alice"},
107+
{"name": "Bob", "age": 20},
108+
]
109+
result = utils.sort_records(records, "age")
110+
expected = [
111+
{"name": "Bob", "age": 20},
112+
{"name": "Charlie", "age": 25},
113+
{"name": "Alice"}, # Missing "age" is treated as default
114+
]
115+
assert result == expected
116+
117+
118+
def test_sort_numeric_field_descending():
119+
records = [
120+
{"name": "Charlie", "score": 85},
121+
{"name": "Alice", "score": 95},
122+
{"name": "Bob", "score": 111},
123+
]
124+
result = utils.sort_records(records, "-score")
125+
expected = [
126+
{"name": "Bob", "score": 111},
127+
{"name": "Alice", "score": 95},
128+
{"name": "Charlie", "score": 85},
129+
]
130+
assert result == expected
131+
132+
133+
def test_sort_mixed_numeric_and_string():
134+
records = [
135+
{"name": "Charlie", "age": 25},
136+
{"name": "Alice", "age": 20},
137+
{"name": "Bob", "age": 20},
138+
]
139+
result = utils.sort_records(records, "age,-name")
140+
expected = [
141+
{"name": "Bob", "age": 20},
142+
{"name": "Alice", "age": 20},
143+
{"name": "Charlie", "age": 25},
144+
]
145+
assert result == expected
146+
147+
148+
def test_records_equal_identical_records():
149+
a = {"id": 1, "name": "Alice", "last_modified": 123, "schema": "v1"}
150+
b = {"id": 1, "name": "Alice", "last_modified": 456, "schema": "v2"}
151+
assert utils.records_equal(a, b)
152+
153+
154+
def test_records_equal_different_records():
155+
a = {"id": 1, "name": "Alice", "last_modified": 123}
156+
b = {"id": 2, "name": "Bob", "last_modified": 456}
157+
assert not utils.records_equal(a, b)
158+
159+
160+
def test_records_equal_missing_fields():
161+
a = {"id": 1, "name": "Alice", "last_modified": 123}
162+
b = {"id": 1, "name": "Alice"}
163+
assert utils.records_equal(a, b)
164+
165+
166+
def test_records_equal_extra_fields():
167+
a = {"id": 1, "name": "Alice", "extra": "field"}
168+
b = {"id": 1, "name": "Alice"}
169+
assert not utils.records_equal(a, b)
170+
171+
172+
def test_records_equal_empty_records():
173+
a = {}
174+
b = {}
175+
assert utils.records_equal(a, b)
176+
177+
178+
def test_records_equal_only_ignored_fields():
179+
a = {"last_modified": 123, "schema": "v1"}
180+
b = {"last_modified": 456, "schema": "v2"}
181+
assert utils.records_equal(a, b)
182+
183+
184+
def test_collection_diff_create():
185+
src = [{"id": 1, "name": "Alice"}]
186+
dest = []
187+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
188+
assert to_create == [{"id": 1, "name": "Alice"}]
189+
assert to_update == []
190+
assert to_delete == []
191+
192+
193+
def test_collection_diff_update():
194+
src = [{"id": 1, "name": "Alice"}]
195+
dest = [{"id": 1, "name": "Bob"}]
196+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
197+
assert to_create == []
198+
assert to_update == [({"id": 1, "name": "Bob"}, {"id": 1, "name": "Alice"})]
199+
assert to_delete == []
200+
201+
202+
def test_collection_diff_delete():
203+
src = []
204+
dest = [{"id": 1, "name": "Alice"}]
205+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
206+
assert to_create == []
207+
assert to_update == []
208+
assert to_delete == [{"id": 1, "name": "Alice"}]
209+
210+
211+
def test_collection_diff_mixed():
212+
src = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Charlie"}]
213+
dest = [
214+
{"id": 2, "name": "Bob"},
215+
{"id": 3, "name": "CharlieUpdated"},
216+
{"id": 4, "name": "Dave"},
217+
]
218+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
219+
assert to_create == [{"id": 1, "name": "Alice"}]
220+
assert to_update == [({"id": 3, "name": "CharlieUpdated"}, {"id": 3, "name": "Charlie"})]
221+
assert to_delete == [{"id": 4, "name": "Dave"}]
222+
223+
224+
def test_collection_diff_no_changes():
225+
src = [{"id": 1, "name": "Alice"}]
226+
dest = [{"id": 1, "name": "Alice"}]
227+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
228+
assert to_create == []
229+
assert to_update == []
230+
assert to_delete == []
231+
232+
233+
def test_collection_diff_empty_collections():
234+
src = []
235+
dest = []
236+
to_create, to_update, to_delete = utils.collection_diff(src, dest)
237+
assert to_create == []
238+
assert to_update == []
239+
assert to_delete == []

0 commit comments

Comments
 (0)