cdxcore 0.1.5__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.

Potentially problematic release.


This version of cdxcore might be problematic. Click here for more details.

cdxcore/version.py ADDED
@@ -0,0 +1,402 @@
1
+ """
2
+ Version handling for functions and classes including their dependencies via decorators.
3
+ """
4
+
5
+ from .util import fmt_list, uniqueLabelExt
6
+ import inspect as inspect
7
+
8
+ uniqueLabel64 = uniqueLabelExt(max_length=64,id_length=8)
9
+ uniqueLabel60 = uniqueLabelExt(max_length=60,id_length=8)
10
+ uniqueLabel48 = uniqueLabelExt(max_length=48,id_length=8)
11
+
12
+ class VersionError(RuntimeError):
13
+ def __init__(self, context, message):
14
+ RuntimeError.__init__(self, context, message)
15
+
16
+ class Version(object):
17
+ """
18
+ Class to track version dependencies for a given function or class 'f'
19
+ Use @version decorator instead.
20
+
21
+ Decorared functions and class have a 'version' member of this type, which has the following properties:
22
+
23
+ input : input version
24
+ full : qualified full version including versions of dependent functions or classes
25
+ unique_id64 : 64 character unique ID
26
+ dependencies: hierarchy of versions
27
+ """
28
+
29
+ def __init__(self, original, version : str, dependencies : list[type], auto_class : bool ):
30
+ """ Wrapper around a versioned function 'f' """
31
+ if version is None:
32
+ raise ValueError("'version' cannot be None")
33
+ self._original = original
34
+ self._input_version = str(version)
35
+ self._input_dependencies = list(dependencies) if not dependencies is None else list()
36
+ self._dependencies = None
37
+ self._class = None # class defining this function
38
+ self._auto_class = auto_class
39
+
40
+ def __str__(self) -> str:
41
+ """ Returns qualified version """
42
+ return self.full
43
+
44
+ def __repr__(self) -> str:
45
+ """ Returns qualified version """
46
+ return self.full
47
+
48
+ def __eq__(self, other) -> bool:
49
+ """ Tests equality of two versions, or a string """
50
+ other = other.full if isinstance(other, Version) else str(other)
51
+ return self.full == other
52
+
53
+ def __neq__(self, other) -> bool:
54
+ """ Tests inequality of two versions, or a string """
55
+ other = other.full if isinstance(other, Version) else str(other)
56
+ return self.full != other
57
+
58
+ @property
59
+ def input(self) -> str:
60
+ """ Returns the version of this function """
61
+ return self._input_version
62
+
63
+ @property
64
+ def unique_id64(self) -> str:
65
+ """
66
+ Returns a unique version string for this version, either the simple readable version or the current version plus a unique hash if the
67
+ simple version exceeds 64 characters.
68
+ """
69
+ return uniqueLabel64(self.full)
70
+
71
+ @property
72
+ def unique_id60(self) -> str:
73
+ """
74
+ Returns a unique version string for this version, either the simple readable version or the current version plus a unique hash if the
75
+ simple version exceeds 60 characters.
76
+ The 60 character version is to support filenames with a three letter extension, so total file name size is at most 64.
77
+ """
78
+ return uniqueLabel60(self.full)
79
+
80
+ @property
81
+ def unique_id48(self) -> str:
82
+ """
83
+ Returns a unique version string for this version, either the simple readable version or the current version plus a unique hash if the
84
+ simple version exceeds 48 characters.
85
+ """
86
+ return uniqueLabel48(self.full)
87
+
88
+ def unique_id(self, max_len : int = 64) -> str:
89
+ """
90
+ Returns a unique version string for this version, either the simple readable version or the current version plus a unique hash if the
91
+ simple version exceeds 'max_len' characters.
92
+ """
93
+ assert max_len >= 4,("'max_len' must be at least 4", max_len)
94
+ id_len = 8 if max_len > 16 else 4
95
+ uniqueHashVersion = uniqueLabelExt(max_length=max_len, id_length=id_len)
96
+ return uniqueHashVersion(self.full)
97
+
98
+ @property
99
+ def full(self) -> str:
100
+ """
101
+ Returns information on the version of 'self' and all dependent functions
102
+ in human readable form. Elements are sorted by name, hence this representation
103
+ can be used to test equality between two versions (see __eq__ and __neq__)
104
+ """
105
+ self._resolve_dependencies()
106
+ def respond( deps ):
107
+ if isinstance(deps,str):
108
+ return deps
109
+ s = ""
110
+ d = deps[1]
111
+ keys = sorted(list(d.keys()))
112
+ for k in keys:
113
+ v = d[k]
114
+ r = k + ": " + respond(v)
115
+ s = r if s=="" else s + ", " + r
116
+ s += " }"
117
+ s = deps[0] + " { " + s
118
+ return s
119
+ return respond(self._dependencies)
120
+
121
+ @property
122
+ def dependencies(self):
123
+ """
124
+ Returns information on the version of 'self' and all dependent functions.
125
+
126
+ For a given function the format is
127
+ If the function has no dependents:
128
+ function_version
129
+ If the function has dependencies 'g'
130
+ ( function_version, { g: g.dependencies } ]
131
+ """
132
+ self._resolve_dependencies()
133
+ return self._dependencies
134
+
135
+ def is_dependent( self, other):
136
+ """
137
+ Determines whether the current function is dependent on 'other'.
138
+ The parameter 'function' can be qualified name, a function, or a class.
139
+
140
+ This function returns None if there is no dependency on 'other',
141
+ or the version of the 'other' it is dependent on.
142
+ """
143
+ other = other.__qualname__ if not isinstance(other, str) else other
144
+ dependencies = self.dependencies
145
+
146
+ def is_dependent( ddict ):
147
+ for k, d in ddict.items():
148
+ if k == other:
149
+ return d if isinstance(d, str) else d[0]
150
+ if isinstance(d, str):
151
+ continue
152
+ ver = is_dependent( d[1] )
153
+ if not ver is None:
154
+ return ver
155
+ return None
156
+ return is_dependent( { self._original.__qualname__: dependencies } )
157
+
158
+ def _resolve_dependencies( self,
159
+ top_context : str = None, # top level context for error messages
160
+ recursive : set = None # set of visited functions
161
+ ):
162
+ """
163
+ Function to be called to compute dependencies for 'original'
164
+
165
+ Parameters
166
+ ----------
167
+ top_context:
168
+ Name of the top level recursive context for error messages
169
+ recursive:
170
+ A set to catch recursive dependencies.
171
+ """
172
+ # quick check whether 'wrapper' has already been resolved
173
+ if not self._dependencies is None:
174
+ return
175
+
176
+ # setup
177
+ local_context = self._original.__qualname__
178
+ top_context = top_context if not top_context is None else local_context
179
+
180
+ def err_context():
181
+ if local_context != top_context:
182
+ return "Error while resolving dependencies for '%s' (as part of resolving dependencies for '%s')" % ( local_context, top_context )
183
+ else:
184
+ return "Error while resolving dependencies for '%s'" % top_context
185
+
186
+ # ensure we do not have a recursive loop
187
+ if not recursive is None:
188
+ if local_context in recursive: raise RecursionError( err_context() + f": recursive dependency on function '{local_context}'" )
189
+ else:
190
+ recursive = set()
191
+ recursive.add(local_context)
192
+
193
+ # collect full qualified dependencies resursively
194
+ version_dependencies = dict()
195
+
196
+ if self._auto_class and not self._class is None:
197
+ version_dependencies[self._class.__qualname__] = self._class.version.dependencies
198
+
199
+ for dep in self._input_dependencies:
200
+ # 'dep' can be a string or simply another decorated function
201
+ # if it is a string, it is of the form A.B.C.f where A,B,C are types and f is a method.
202
+
203
+ if isinstance(dep, str):
204
+ # handle A.B.C.f
205
+ hierarchy = dep.split(".")
206
+ str_dep = dep
207
+
208
+ # expand global lookup with 'self' if present
209
+ source = getattr(self._original,"__globals__", None)
210
+ if source is None:
211
+ raise VersionError( err_context(), f"Cannot resolve dependency for string reference '{dep}': object of type '{type(self._original).__name__}' has no __globals__ to look up in" )
212
+ src_name = "global name space"
213
+ self_ = getattr(self._original,"__self__" if not isinstance(self._original,type) else "__dict__", None)
214
+ if not self_ is None:
215
+ source = dict(source)
216
+ source.update(self_.__dict__)
217
+ src_name = "global name space or members of " + type(self_).__name__
218
+
219
+ # resolve types iteratively
220
+ for part in hierarchy[:-1]:
221
+ source = source.get(part, None)
222
+ if source is None:
223
+ raise VersionError( err_context(), f"Cannot find '{part}' in '{src_name}' as part of resolving dependency on '{str_dep}'; known names: {fmt_list(sorted(list(source.keys())))}" )
224
+ if not isinstance(source, type):
225
+ raise VersionError( err_context(), f"'{part}' in '{src_name}' is not a class/type, but '{type(source).__name__}'. This was part of resolving dependency on '{str_dep}'" )
226
+ source = source.__dict__
227
+ src_name = part
228
+
229
+ # get function
230
+ dep = source.get(hierarchy[-1], None)
231
+ ext = "" if hierarchy[-1]==str_dep else ". (This is part of resoling dependency on '%s')" % str_dep
232
+ if dep is None:
233
+ raise VersionError( err_context(), f"Cannot find '{hierarchy[-1]}' in '{src_name}'; known names: {fmt_list((source.keys()))}{ext}" )
234
+
235
+ if not isinstance( dep, Version ):
236
+ dep_v = getattr(dep, "version", None)
237
+ if dep_v is None: raise VersionError( err_context(), f"Cannot determine version of '{dep.__qualname__}': this is not a versioned function or class as it does not have a 'version' member", )
238
+ if type(dep_v).__name__ != "Version": raise VersionError( err_context(), f"Cannot determine version of '{dep.__qualname__}': 'version' member is of type '{type(dep_v).__name__}' not of type 'Version'" )
239
+ qualname = dep.__qualname__
240
+ else:
241
+ dep_v = dep
242
+ qualname = dep._original.__qualname__
243
+
244
+ # dynamically retrieve dependencies
245
+ dep_v._resolve_dependencies( top_context=top_context, recursive=recursive )
246
+ assert not dep_v._dependencies is None, ("Internal error", qualname, ":", dep, "//", dep_v)
247
+ version_dependencies[qualname] = dep_v._dependencies
248
+
249
+ # add our own to 'resolved dependencies'
250
+ self._dependencies = ( self._input_version, version_dependencies ) if len(version_dependencies) > 0 else self._input_version
251
+
252
+ # uniqueHash
253
+ # ----------
254
+
255
+ def __unique_hash__( self, uniqueHash, debug_trace ) -> str:
256
+ """
257
+ Compute non-hash for use with cdxbasics.util.uniqueHash()
258
+ This function always returns an empty string, which means that the object is never hashed.
259
+ """
260
+ return self.unique_id(max_len=uniqueHash.length)
261
+
262
+ # =======================================================
263
+ # @version
264
+ # =======================================================
265
+
266
+ def version( version : str = "0.0.1" ,
267
+ dependencies : list = [], *,
268
+ auto_class : bool = True,
269
+ raise_if_has_version : bool = True ):
270
+ """
271
+ Decorator for a versioned function or class, which may depend on other versioned functions or classes.
272
+ The point of this decorator is being able to find out the code version of a sequence of function calls,
273
+ and be able to update cached or otherwise stored results accordingly.
274
+ Decoration also works for class members.
275
+
276
+ You can 'version' fuunctions and classes.
277
+ When a class is 'versioned' it will automatically be dependent on the versions of any 'versioned' base classes.
278
+ The same is true for 'versioned' member functions: they will be dependent on the version of the defining class (but not
279
+ of derived classes). Sometimes this behaviour is not helpful. In this case set 'auto_class' to False
280
+ when setting the 'version' for a member fiunction
281
+
282
+
283
+ @version("0.1")
284
+ class A(object):
285
+ @version("0.2") # automatically depends on A
286
+ def f(self, x):
287
+ return x
288
+ @version("0.3", auto_class=False ) # does not depend on A
289
+ def g(self, x):
290
+ return x
291
+
292
+ @version("0.4") # automatically depends on A
293
+ class B(A):
294
+ pass
295
+
296
+ @version("0.4", auto_class=False ) # does not depend on A
297
+ class C(A):
298
+ pass
299
+
300
+ See cdxbasics.vcache as a high level caching mechanism based on version.
301
+ This wraps the more basic cdxbasics.subdir.SubDir.cache_callable.
302
+
303
+ Parameters
304
+ ----------
305
+ version : str, optional
306
+ Version of this function
307
+ dependencies : list, optional
308
+ Names of member functions of self which this function depends on.
309
+ The list can contain explicit function references, or strings.
310
+ If strings are used, then the function's global context and, if appliable, it 'self' will be searched
311
+ for the respective function.
312
+ auto_class : bool
313
+ If True, the default, then the version of member function or an inherited class is automatically dependent
314
+ on the version of the defining/base class. Set to False to turn off.
315
+ raise_if_has_version : bool
316
+ Whether to throw an exception of version are already present.
317
+ This is usually the desired behaviour except if used in another wrapper, see for example vcache.
318
+
319
+ Returns
320
+ -------
321
+ Function or class.
322
+ The returned function or class will the following properties:
323
+ version.input
324
+ returns the input 'version' above
325
+ version.full
326
+ A human readable version string with all dependencies.
327
+ version.unique_id64
328
+ A unique ID of version_full which can be used to identify changes in the total versioning
329
+ accross the dependency structure, of at most 64 characters. Use unique_id() for other lengths.
330
+ version.dependencies
331
+ Returns a hierarchical description of the version of this function and all its dependcies.
332
+ The recursive definition is:
333
+ If the function has no dependencies, return
334
+ version
335
+ If the function has dependencies, return
336
+ ( version, { dependency: dependency.version_full() } )
337
+
338
+ Example
339
+ -------
340
+ class A(object):
341
+ def __init__(self, x=2):
342
+ self.x = x
343
+ @version(version="0.4.1")
344
+ def h(self, y):
345
+ return self.x*y
346
+
347
+ @version(version="0.3.0")
348
+ def h(x,y):
349
+ return x+y
350
+
351
+ @version(version="0.0.2", dependencies=[h])
352
+ def f(x,y):
353
+ return h(y,x)
354
+
355
+ @version(version="0.0.1", dependencies=["f", A.h])
356
+ def g(x,z):
357
+ a = A()
358
+ return f(x*2,z)+a.h(z)
359
+
360
+ g(1,2)
361
+ print("version", g.version.input) -- version 0.0.1
362
+ print("full version", g.version.full ) -- full version 0.0.1 { f: 0.0.2 { h: 0.3.0 }, A.h: 0.4.1 }
363
+ print("full version ID",g.version.unique_id48 ) -- full version ID 0.0.1 { f: 0.0.2 { h: 0.3.0 }, A.h: 0.4.1 }
364
+ """
365
+ def wrap(f):
366
+ dep = dependencies
367
+ existing = getattr(f, "version", None)
368
+ if not existing is None:
369
+ # is 'version' a Version
370
+ if type(existing).__name__ != Version.__name__:
371
+ tmsg = "type" if isinstance(f,type) else "function"
372
+ raise ValueError(f"@version: {tmsg} '{f.__qualname__}' already has a member 'version' but it has type {type(existing).__name__} not {Version}")
373
+ # make sure we were not called twice
374
+ if existing._original == f:
375
+ if not raise_if_has_version:
376
+ return f
377
+ tmsg = "type" if isinstance(f,type) else "function"
378
+ raise ValueError(f"@version: {tmsg} '{f.__qualname__}' already has a member 'version'. It has initial value {existing._input_version}.")
379
+ # auto-create dependencies to base classes:
380
+ # in this case 'existing' is a member of the base class.
381
+ if not existing._original in dependencies and not existing._original.__qualname__ in dependencies and auto_class:
382
+ dep = list(dep)
383
+ dep.append( existing._original )
384
+ if isinstance( f, type ):
385
+ # set '_class' for all Version objects
386
+ # of all members of a type
387
+ funcs = list( inspect.getmembers(f, predicate=inspect.isfunction) )\
388
+ + [ c for c in inspect.getmembers(f, predicate=inspect.isclass) if c[0] != "__class__" ]
389
+ for gname, gf in funcs:
390
+ gversion = getattr(gf, "version", None)
391
+ if gversion is None:
392
+ #print(f"{gname} is not versioned")
393
+ continue
394
+ if not gversion._class is None:
395
+ #print(f"{gname} already has a class {gversion._class.__qualname__}, skipping {f.__qualname__}")
396
+ continue
397
+ gversion._class = f
398
+ f.version = Version(f, version, dep, auto_class=auto_class )
399
+ assert type(f.version).__name__ == Version.__name__
400
+ return f
401
+ return wrap
402
+