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.
- relations_unum-0.1.0/LICENSE.txt +21 -0
- relations_unum-0.1.0/PKG-INFO +31 -0
- relations_unum-0.1.0/README.md +8 -0
- relations_unum-0.1.0/lib/relations_unum.egg-info/PKG-INFO +31 -0
- relations_unum-0.1.0/lib/relations_unum.egg-info/SOURCES.txt +9 -0
- relations_unum-0.1.0/lib/relations_unum.egg-info/dependency_links.txt +1 -0
- relations_unum-0.1.0/lib/relations_unum.egg-info/requires.txt +2 -0
- relations_unum-0.1.0/lib/relations_unum.egg-info/top_level.txt +1 -0
- relations_unum-0.1.0/lib/relations_unum.py +454 -0
- relations_unum-0.1.0/setup.cfg +4 -0
- relations_unum-0.1.0/setup.py +37 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|
+
)
|