relations-unum 0.1.0__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 relations-dil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: relations-unum
3
+ Version: 0.1.0
4
+ Summary: Source for locally using multiple Unifist sources as a single Unum
5
+ Home-page: https://github.com/relations-dil/python-relations-unum
6
+ Author: Gaffer Fitch
7
+ Author-email: relations@gaf3.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE.txt
12
+ Requires-Dist: relations-dil>=0.6.20
13
+ Requires-Dist: relations-unifist>=0.1.0
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: license-file
21
+ Dynamic: requires-dist
22
+ Dynamic: summary
23
+
24
+ # python-relations-unum
25
+
26
+ Source for locally using multiple Unifist sources as a single Unum
27
+
28
+ Part of [Relations](https://github.com/relations-dil/python-relations). Source agnostic: it builds on the
29
+ core library's `Source`, `Model` and hooks only, so it works over any relations source.
30
+
31
+ **Status:** scaffold. See the first ticket for the design and what done looks like.
@@ -0,0 +1,8 @@
1
+ # python-relations-unum
2
+
3
+ Source for locally using multiple Unifist sources as a single Unum
4
+
5
+ Part of [Relations](https://github.com/relations-dil/python-relations). Source agnostic: it builds on the
6
+ core library's `Source`, `Model` and hooks only, so it works over any relations source.
7
+
8
+ **Status:** scaffold. See the first ticket for the design and what done looks like.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: relations-unum
3
+ Version: 0.1.0
4
+ Summary: Source for locally using multiple Unifist sources as a single Unum
5
+ Home-page: https://github.com/relations-dil/python-relations-unum
6
+ Author: Gaffer Fitch
7
+ Author-email: relations@gaf3.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE.txt
12
+ Requires-Dist: relations-dil>=0.6.20
13
+ Requires-Dist: relations-unifist>=0.1.0
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: license-file
21
+ Dynamic: requires-dist
22
+ Dynamic: summary
23
+
24
+ # python-relations-unum
25
+
26
+ Source for locally using multiple Unifist sources as a single Unum
27
+
28
+ Part of [Relations](https://github.com/relations-dil/python-relations). Source agnostic: it builds on the
29
+ core library's `Source`, `Model` and hooks only, so it works over any relations source.
30
+
31
+ **Status:** scaffold. See the first ticket for the design and what done looks like.
@@ -0,0 +1,9 @@
1
+ LICENSE.txt
2
+ README.md
3
+ setup.py
4
+ lib/relations_unum.py
5
+ lib/relations_unum.egg-info/PKG-INFO
6
+ lib/relations_unum.egg-info/SOURCES.txt
7
+ lib/relations_unum.egg-info/dependency_links.txt
8
+ lib/relations_unum.egg-info/requires.txt
9
+ lib/relations_unum.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ relations-dil>=0.6.20
2
+ relations-unifist>=0.1.0
@@ -0,0 +1 @@
1
+ relations_unum
@@ -0,0 +1,454 @@
1
+ """
2
+ Main Relations Unum Module
3
+ """
4
+
5
+ import contextlib
6
+ import copy
7
+ import heapq
8
+ import itertools
9
+
10
+ import relations
11
+ import relations_unifist
12
+
13
+
14
+ class Ordered:
15
+ """
16
+ A row to compare to others by the sorting of a query, each field having a + or a - in front of it
17
+ """
18
+
19
+ def __init__(self, row, sorting):
20
+
21
+ self.row = row
22
+ self.sorting = sorting
23
+
24
+ def __lt__(self, other):
25
+
26
+ for sort in self.sorting:
27
+
28
+ mine, theirs = self.row[sort[1:]], other.row[sort[1:]]
29
+
30
+ if mine != theirs:
31
+ return mine < theirs if sort[0] == "+" else mine > theirs
32
+
33
+ return False
34
+
35
+
36
+ def blank():
37
+ """
38
+ A string column that is never None, blank meaning any when it's matched
39
+ """
40
+
41
+ return relations.Field(str, default="", none=False)
42
+
43
+
44
+ class ClashError(relations.ModelError):
45
+ """
46
+ A record that clashed isn't to be used until it's sorted out
47
+ """
48
+
49
+
50
+ class Clash(relations_unifist.UnifistBase):
51
+ """
52
+ What broke, with the same id as the journal row it's about, version:identifier:resource:owner:writer
53
+ """
54
+
55
+ id = str
56
+ what = dict, False
57
+
58
+
59
+ class Matching:
60
+ """
61
+ What Route and Scribe share, matching a record's id on its parts (identifier, model, source and unum), blank
62
+ meaning any
63
+ """
64
+
65
+ def specificity(self):
66
+ """
67
+ How many of the parts are set, the more the more it matches
68
+ """
69
+
70
+ return sum(1 for part in [self.identifier, self.model, self.source, self.unum] if part)
71
+
72
+ def pattern(self):
73
+ """
74
+ Shown like a record's id, with a * for any, though a blank is what's kept
75
+ """
76
+
77
+ return f"{self.identifier or '*'}:{self.model or '*'}.{self.source or '*'}:{self.unum or '*'}"
78
+
79
+
80
+ class Route(Matching, relations_unifist.UnifistBase):
81
+ """
82
+ Who has the data that matches, in what role, and where to reach them (a url, blank for here)
83
+ """
84
+
85
+ id = int
86
+ identifier = blank()
87
+ model = blank()
88
+ source = blank()
89
+ unum = blank()
90
+ role = ["owner", "writer", "reader"]
91
+ holder = str, False
92
+ url = blank()
93
+
94
+ UNIQUE = ["identifier", "model", "source", "unum", "role", "holder"]
95
+
96
+
97
+ class Scribe(Matching, relations_unifist.UnifistBase):
98
+ """
99
+ Where a write goes for the data that matches, one row for each match
100
+ """
101
+
102
+ id = int
103
+ identifier = blank()
104
+ model = blank()
105
+ source = blank()
106
+ unum = blank()
107
+ to = ["owner", "writer", "local"]
108
+
109
+ UNIQUE = ["identifier", "model", "source", "unum"]
110
+
111
+
112
+ def clashed(record_id):
113
+ """
114
+ The clashes a record has, whoever wrote and whatever version, which are in the clash's id,
115
+ version:identifier:resource:owner:writer, between the version and the writer
116
+ """
117
+
118
+ return list(Clash.many(id__like=f":{record_id}:").retrieve())
119
+
120
+
121
+ def matching(kind, record_id):
122
+ """
123
+ The rows of a kind (Route or Scribe) that match a record's id, which is identifier:model.source:unum
124
+ """
125
+
126
+ identifier, resource, unum = record_id.split(":", 2)
127
+ model, source = resource.split(".", 1)
128
+
129
+ return list(kind.many(
130
+ identifier__in=["", identifier], model__in=["", model], source__in=["", source], unum__in=["", unum]
131
+ ).retrieve())
132
+
133
+
134
+ def route(record_id):
135
+ """
136
+ Who has a record's data, in whatever role. For each role the rows that match the most win, so what a
137
+ model or a record says beats what its unum says. Owners, then writers, then readers, each in the order made
138
+ """
139
+
140
+ roles = ["owner", "writer", "reader"]
141
+ rows = matching(Route, record_id)
142
+ best = {}
143
+
144
+ for row in rows:
145
+ best[row.role] = max(best.get(row.role, 0), row.specificity())
146
+
147
+ return sorted(
148
+ [row for row in rows if row.specificity() == best[row.role]],
149
+ key=lambda row: (roles.index(row.role), row.id)
150
+ )
151
+
152
+
153
+ def scribe(record_id):
154
+ """
155
+ Where a write to a record goes, from the one row that matches the most, the first made if some match as much,
156
+ or None if none match
157
+ """
158
+
159
+ rows = matching(Scribe, record_id)
160
+
161
+ if not rows:
162
+ return None
163
+
164
+ return min(rows, key=lambda row: (-row.specificity(), row.id))
165
+
166
+
167
+ class Source(relations.Source):
168
+ """
169
+ Routes each operation on a record to the source that has it. It stores nothing: it passes the call to the
170
+ source of the unum the record's routes and scribe say, here (local) or another unum's (members). The rest
171
+ is still here for now
172
+ """
173
+
174
+ KIND = "unum"
175
+
176
+ unum = None # Name of this unum
177
+ local = None # Name of the source this unum's own data is in
178
+ members = None # Name of the source of each other unum, by the unum's name
179
+
180
+ chunk = None # How many rows to read from a unum at a time, the model's if not set
181
+
182
+ _syncing = False # Whether what's written and read is the copy here, for sync
183
+
184
+ @contextlib.contextmanager
185
+ def syncing(self):
186
+ """
187
+ Keeps writes and reads here while it's open, because what sync applies is a copy of what's elsewhere
188
+ """
189
+
190
+ self._syncing = True
191
+
192
+ try:
193
+ yield self
194
+ finally:
195
+ self._syncing = False
196
+
197
+ def sources(self, record_id, roles):
198
+ """
199
+ The names of the sources of those who hold a record in the roles, in order, local being this unum's own
200
+ """
201
+
202
+ return [
203
+ self.local if row.holder == self.unum else self.members[row.holder]
204
+ for row in route(record_id) if row.role in roles
205
+ ]
206
+
207
+ def attempt(self, names, action, model, *args, **kwargs):
208
+ """
209
+ Does the action on the first of the sources that can, the last one's error being the one raised
210
+ """
211
+
212
+ *others, last = names
213
+
214
+ for name in others:
215
+ try:
216
+ return getattr(relations.source(name), action)(model, *args, **kwargs)
217
+ except (relations.SourceError, relations.NotFoundError):
218
+ continue
219
+
220
+ return getattr(relations.source(last), action)(model, *args, **kwargs)
221
+
222
+ def init(self, model):
223
+ """
224
+ Every source it routes to has to know the model
225
+ """
226
+
227
+ for name in dict.fromkeys([self.local, *self.members.values()]):
228
+ relations.source(name).init(model)
229
+
230
+ def write(self, action, model, *args, **kwargs):
231
+ """
232
+ Writes to where the scribe says, the owner, the writers or here, or here if there's no one to say
233
+ """
234
+
235
+ rule = None if self._syncing else scribe(model.id)
236
+ names = self.sources(model.id, [rule.to]) if rule and rule.to != "local" else []
237
+
238
+ return self.attempt(names or [self.local], action, model, *args, **kwargs)
239
+
240
+ def create(self, model, *args, **kwargs):
241
+ """
242
+ Creates where it's written to
243
+ """
244
+
245
+ return self.write("create", model, *args, **kwargs)
246
+
247
+ def stream(self, name, model, sorting):
248
+ """
249
+ Reads the rows of a query from one source in order, a page at a time and only as they're asked for.
250
+ A source that can't be reached has no rows, so the others are read without it
251
+ """
252
+
253
+ chunk = self.chunk or model._chunk # pylint: disable=protected-access
254
+ start = 0
255
+
256
+ while True:
257
+
258
+ page = model.__class__.many()
259
+ page._record = copy.deepcopy(model._record) # pylint: disable=protected-access
260
+ page._like = model._like # pylint: disable=protected-access
261
+ page._sort = list(sorting)
262
+ page.limit(chunk, start=start)
263
+
264
+ try:
265
+ rows = relations.source(name).retrieve(page)._models # pylint: disable=protected-access
266
+ except relations.SourceError:
267
+ return
268
+
269
+ yield from rows
270
+
271
+ if len(rows) < chunk:
272
+ return
273
+
274
+ start += chunk
275
+
276
+ def select(self, model):
277
+ """
278
+ Every source's rows of a query in the order it says, as if it was one, with a record that's in more
279
+ than one only once, the first of them, from here before others. Nothing is read until it's needed
280
+ """
281
+
282
+ sorting = model._ordering(model._sort or model._order) # pylint: disable=protected-access
283
+ names = dict.fromkeys([self.local, *self.members.values()])
284
+ seen = set()
285
+
286
+ for ordered in heapq.merge(*[
287
+ (Ordered(row, sorting) for row in self.stream(name, model, sorting)) for name in names
288
+ ]):
289
+ if ordered.row.id not in seen:
290
+ seen.add(ordered.row.id)
291
+ yield ordered.row
292
+
293
+ def retrieve(self, model, *args, **kwargs):
294
+ """
295
+ Retrieves a record by its id from here if this unum holds it, or else its owner, writers and readers
296
+ in turn. A query for many is every source's rows merged in order, and one without an id is from here
297
+ """
298
+
299
+ if model._mode == "many" and not self._syncing: # pylint: disable=protected-access
300
+
301
+ start = model._offset or 0 # pylint: disable=protected-access
302
+ stop = None if model._limit is None else start + model._limit # pylint: disable=protected-access
303
+
304
+ model._models = list(itertools.islice(self.select(model), start, stop)) # pylint: disable=protected-access
305
+ model._record = None # pylint: disable=protected-access
306
+ model._action = "update" # pylint: disable=protected-access
307
+ model.overflow = model.overflow or (model._limit is not None and len(model._models) >= model._limit) # pylint: disable=protected-access
308
+
309
+ return model
310
+
311
+ criteria = model._record._names[model._id].criteria # pylint: disable=protected-access
312
+ names = self.sources(criteria["eq"], ["owner", "writer", "reader"]) if criteria and not self._syncing else []
313
+
314
+ if self.local in names:
315
+ names.remove(self.local)
316
+ names.insert(0, self.local)
317
+
318
+ return self.attempt(names or [self.local], "retrieve", model, *args, **kwargs)
319
+
320
+ def refuse(self, model, force):
321
+ """
322
+ Refuses a record that clashed, unless it's said to use it anyway, or it's sync that's writing
323
+ """
324
+
325
+ if not force and not self._syncing and clashed(model.id):
326
+ raise ClashError(model, "clashed, use force=True to use it anyway")
327
+
328
+ def update(self, model, *args, force=False, **kwargs):
329
+ """
330
+ Updates where it's written to, unless it clashed and force isn't set, which is taken here, not sent on
331
+ """
332
+
333
+ self.refuse(model, force)
334
+
335
+ return self.write("update", model, *args, **kwargs)
336
+
337
+ def delete(self, model, *args, force=False, **kwargs):
338
+ """
339
+ Deletes where it's written to, unless it clashed and force isn't set, which is taken here, not sent on
340
+ """
341
+
342
+ self.refuse(model, force)
343
+
344
+ return self.write("delete", model, *args, **kwargs)
345
+
346
+ def count(self, model, *args, **kwargs):
347
+ """
348
+ Counts here for now
349
+ """
350
+
351
+ return relations.source(self.local).count(model, *args, **kwargs)
352
+
353
+
354
+ def models(*classes):
355
+ """
356
+ The models sync can apply, by the app (source) and block (model) a journal row has
357
+ """
358
+
359
+ return {(model.SOURCE, model.thy().NAME): model for model in classes}
360
+
361
+
362
+ def clash(row, error):
363
+ """
364
+ Records a journal row that can't be applied, with the same id, and returns that it clashed
365
+ """
366
+
367
+ Clash(id=row["id"], what={"error": error}).create()
368
+
369
+ return "clash"
370
+
371
+
372
+ def apply(source, row, models):
373
+ """
374
+ Applies a journal row from another unum here, by making the same write with the writer the row says, so
375
+ the journal row that's written has the same id and the transaction is the same everywhere. A row that's here
376
+ is skipped, and one that clashed is left alone. One that can't be applied, because the record isn't at the
377
+ version it was from, or isn't there, or already is, or its model isn't known, is a clash. Returns what it did
378
+ """
379
+
380
+ # pylint: disable=redefined-outer-name
381
+
382
+ if relations_unifist.Journal.one(row["id"]).retrieve(False):
383
+ return "skipped"
384
+
385
+ if Clash.one(row["id"]).retrieve(False):
386
+ return "clash"
387
+
388
+ what = row["what"]
389
+ model = models.get((what["app"], what["block"]))
390
+
391
+ if model is None:
392
+ return clash(row, "model")
393
+
394
+ writer = row["id"].rsplit(":", 1)[1]
395
+
396
+ with source.syncing():
397
+
398
+ try:
399
+
400
+ if what["action"] == "create":
401
+
402
+ model(**what["after"]).create(writer=writer)
403
+
404
+ else:
405
+
406
+ before = what["before"]
407
+ query = model.many(id=what["id"], version=before["version"])
408
+
409
+ if what["action"] == "update":
410
+ changes = {
411
+ name: value for name, value in what["after"].items()
412
+ if name not in ["id", "version"] and value != before.get(name)
413
+ }
414
+ done = query.set(**changes).update(writer=writer)
415
+ else:
416
+ done = query.delete(writer=writer)
417
+
418
+ if not done:
419
+ return clash(row, "version" if model.many(id=what["id"]).count() else "missing")
420
+
421
+ except relations.UniqueError:
422
+
423
+ return clash(row, "exists")
424
+
425
+ return "applied"
426
+
427
+
428
+ def journal(source, member):
429
+ """
430
+ Another unum's journal, the same as the one here but in the source that unum's records are in. Models
431
+ don't inherit their fields, so it's made from the fields the journal here has
432
+ """
433
+
434
+ return type("Journal", (relations.Model,), {
435
+ **{name: value for name, value in vars(relations_unifist.Journal).items() if not name.startswith("_")},
436
+ "SOURCE": source.members[member]
437
+ })
438
+
439
+
440
+ def sync(source, member, models):
441
+ """
442
+ Applies what another unum's journal has that this one doesn't, oldest first, one row at a time, and
443
+ returns how many were applied, skipped because they were here already, and clashed. If the other unum
444
+ can't be reached the error is raised, to be tried again
445
+ """
446
+
447
+ # pylint: disable=redefined-outer-name
448
+
449
+ counts = {"applied": 0, "skipped": 0, "clash": 0}
450
+
451
+ for row in journal(source, member).many().sort("when", "id").retrieve():
452
+ counts[apply(source, row, models)] += 1
453
+
454
+ return counts
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python
2
+
3
+ import os
4
+ from setuptools import setup
5
+
6
+ with open("README.md", "r") as readme_file:
7
+ long_description = readme_file.read()
8
+
9
+ version = os.environ.get("BUILD_VERSION")
10
+
11
+ if version is None:
12
+ with open("VERSION", "r") as version_file:
13
+ version = version_file.read().strip()
14
+
15
+ setup(
16
+ name="relations-unum",
17
+ version=version,
18
+ package_dir = {'': 'lib'},
19
+ py_modules = [
20
+ 'relations_unum'
21
+ ],
22
+ install_requires=[
23
+ 'relations-dil>=0.6.20',
24
+ 'relations-unifist>=0.1.0'
25
+ ],
26
+ url="https://github.com/relations-dil/python-relations-unum",
27
+ author="Gaffer Fitch",
28
+ author_email="relations@gaf3.com",
29
+ description="Source for locally using multiple Unifist sources as a single Unum",
30
+ long_description=long_description,
31
+ long_description_content_type="text/markdown",
32
+ license_files=('LICENSE.txt',),
33
+ classifiers=[
34
+ "Programming Language :: Python :: 3",
35
+ "License :: OSI Approved :: MIT License"
36
+ ]
37
+ )