timetoalign 0.0.1__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.
tta/registry.py ADDED
@@ -0,0 +1,546 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import warnings
5
+ from functools import cache
6
+ from numbers import Number
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ Any,
10
+ Callable,
11
+ Dict,
12
+ Generic,
13
+ Hashable,
14
+ Iterator,
15
+ Optional,
16
+ Type,
17
+ TypeVar,
18
+ Union,
19
+ )
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from processing.tta import utils
25
+
26
+ if TYPE_CHECKING:
27
+ from .common import RegisteredObject
28
+ from .conversions import ConversionMap
29
+ from .utils import NumberType, TimeUnit
30
+
31
+
32
+ class ID_str(str):
33
+ def __new__(cls, content: str, uid: Optional[Hashable] = None):
34
+ instance = super().__new__(cls, content)
35
+ instance.uid = uid
36
+ return instance
37
+
38
+
39
+ # region Base Registry
40
+
41
+ _R = TypeVar("_R") # Type variable for the items stored in the registry
42
+
43
+
44
+ class Registry(Generic[_R]):
45
+ """
46
+ A generic registry for storing and retrieving items by a hashable key.
47
+ """
48
+
49
+ def __init__(self):
50
+ self._objects: Dict[Hashable, _R] = {}
51
+
52
+ def register(self, key: Hashable, obj_instance: _R) -> _R:
53
+ """
54
+ Registers an item with the given key.
55
+ Warns if overwriting an existing key.
56
+ """
57
+ if key in self._objects:
58
+ warnings.warn(
59
+ f"Object with key {key!r} already registered in this registry."
60
+ )
61
+ self._objects[key] = obj_instance
62
+ return obj_instance
63
+
64
+ def get(self, key: Hashable, default=None) -> Optional[_R]:
65
+ """Retrieves an item by its key."""
66
+ return self._objects.get(key, default)
67
+
68
+ def unregister(self, key: Hashable) -> Optional[_R]:
69
+ """Removes and returns an item by its key, or None if not found."""
70
+ return self._objects.pop(key, None)
71
+
72
+ def __contains__(self, key: Hashable) -> bool:
73
+ return key in self._objects
74
+
75
+ def __len__(self) -> int:
76
+ return len(self._objects)
77
+
78
+ def all_items(self) -> Dict[Hashable, _R]:
79
+ """Returns a copy of all items in the registry."""
80
+ return dict(self._objects)
81
+
82
+
83
+ # endregion Base Registry
84
+
85
+
86
+ # region ClassRegistry
87
+ class ClassRegistry(Registry[Type["RegisteredObject"]]):
88
+ pass
89
+
90
+
91
+ CLASS_REGISTRY = ClassRegistry()
92
+
93
+
94
+ def get_class(class_name: str | Type["RegisteredObject"]) -> Type["RegisteredObject"]:
95
+ if isinstance(class_name, type):
96
+ if class_name.__name__ not in CLASS_REGISTRY:
97
+ raise ValueError(f"Class {class_name.__name__} is not registered.")
98
+ return class_name
99
+ if not isinstance(class_name, str):
100
+ raise ValueError(
101
+ f"Classes are registered with their names as keys. Expected a string, got {type(class_name)}"
102
+ )
103
+ return CLASS_REGISTRY.get(class_name)
104
+
105
+
106
+ def register_class(cls):
107
+ CLASS_REGISTRY.register(cls.__name__, cls)
108
+
109
+
110
+ # endregion ClassRegistry
111
+
112
+ # region ObjectRegistry
113
+
114
+
115
+ class PrefixRegistry(Registry["RegisteredObject"]):
116
+ """
117
+ Manages the creation, storage, and retrieval of objects with unique IDs,
118
+ typically objects that self-register using a specific id_prefix. New IDs are generated
119
+ incrementally, the number format can be controlled by passing number_format to the constructor
120
+ which needs to be a valid f-string format (e.g. "04" for padding four-or-less digit numbers
121
+ with leading zeros)
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ type_prefix: str,
127
+ number_format: str = "",
128
+ ):
129
+ """The number_format argument will be used in an f-string to format the number.
130
+ For example, pass number_format="04" to pad four-or-less digit numbers with leading zeros.
131
+ """
132
+ super().__init__()
133
+ self._type_prefix = type_prefix
134
+ self._counter = 0
135
+ self._number_format = number_format
136
+
137
+ def current_number(self, as_string=True) -> Union[str, int]:
138
+ if as_string:
139
+ return f"{self._counter:{self._number_format}}"
140
+ return self._counter
141
+
142
+ def next_number(self, as_string=True) -> Union[str, int]:
143
+ self._counter += 1
144
+ return self.current_number(as_string=as_string)
145
+
146
+ def _generate_id(
147
+ self,
148
+ description: Optional[str] = None,
149
+ uid: Optional[Hashable] = None,
150
+ ) -> ID_str:
151
+ """Generates a unique ID string based on prefix, counter, and description."""
152
+ # Determine the numeric part of the ID using the counter
153
+ numeric_part_of_id = self.next_number(as_string=True)
154
+ id_string = f"{self._type_prefix}{numeric_part_of_id}"
155
+
156
+ if description:
157
+ sane_description = "".join(
158
+ c if (c.isalnum() or c == "_") else "_" for c in str(description)
159
+ ).strip("_")
160
+ if sane_description:
161
+ id_string += "_" + sane_description
162
+ return ID_str(id_string, uid=uid)
163
+
164
+ def create_and_register(
165
+ self, cls, description: str = "", uid: Optional[Hashable] = None, **kwargs
166
+ ):
167
+ """
168
+ Creates an instance of 'cls', assigns a unique ID, registers it,
169
+ and returns the instance.
170
+ 'cls' __init__ method must accept 'obj_id' as its first argument after self.
171
+ 'description' is used for the descriptive part of the ID.
172
+ Other kwargs are passed to the cls constructor.
173
+ """
174
+ obj_id = self._generate_id(description=description, uid=uid)
175
+ # The class's __init__ must be prepared to accept obj_id
176
+ instance = cls(id_prefix=self._type_prefix, uid=uid, **kwargs)
177
+ if obj_id in self._objects:
178
+ # This should ideally not happen if _generate_id is robust
179
+ raise ValueError(
180
+ f"Critical error: Duplicate ID {obj_id} generated for {self._type_prefix}."
181
+ )
182
+ self._objects[obj_id] = instance
183
+ return instance
184
+
185
+ def get(self, obj_id: str) -> Optional[Any]:
186
+ return super().get(obj_id)
187
+
188
+ def register_existing(self, obj_instance: Any, _key: Optional[str] = None) -> Any:
189
+ """Registers an externally created object that already has an ID."""
190
+ if _key is None:
191
+ if not hasattr(obj_instance, "id") or obj_instance.id is None:
192
+ raise ValueError("Object must have an ID to be registered.")
193
+
194
+ obj_id_str = str(obj_instance.id) # Ensure it's a string for startswith
195
+
196
+ else:
197
+ # _key should only be used for external objects
198
+ obj_id_str = _key
199
+
200
+ if not obj_id_str.startswith(self._type_prefix):
201
+ raise ValueError(
202
+ f"Object ID '{obj_id_str}' does not match registry prefix '{self._type_prefix}'"
203
+ )
204
+
205
+ super().register(obj_id_str, obj_instance)
206
+
207
+ # Try to update counter if a manually registered ID has a higher sequence
208
+ # ID format is "{id_prefix}{int_counter}[_optional_arbitrary_description]"
209
+ # Escape the prefix in case it contains special regex characters
210
+ escaped_prefix = re.escape(self._type_prefix)
211
+ pattern = rf"^{escaped_prefix}(\d+)(?:_.*)?$"
212
+
213
+ match = re.match(pattern, obj_id_str)
214
+ if match:
215
+ num_str = match.group(1) # Get the captured digits
216
+ seq = int(num_str)
217
+ if seq > self._counter:
218
+ self._counter = seq
219
+ return obj_instance
220
+
221
+
222
+ REGISTRY: Dict[str, PrefixRegistry] = {}
223
+
224
+
225
+ def get_registry_by_prefix(prefix: str) -> PrefixRegistry:
226
+ if prefix not in REGISTRY:
227
+ REGISTRY[prefix] = PrefixRegistry(type_prefix=prefix)
228
+ return REGISTRY[prefix]
229
+
230
+
231
+ @cache
232
+ def get_object_by_id(obj_id: str) -> Any:
233
+ """Retrieves an object by its ID from any known ObjectRegistry."""
234
+ for registry_instance in REGISTRY.values():
235
+ if (obj := registry_instance.get(obj_id)) is not None:
236
+ return obj
237
+ raise KeyError(f"Object with ID '{obj_id}' not found in any registry.")
238
+
239
+
240
+ @cache
241
+ def id_is_registered(obj_id: str) -> Any:
242
+ """Returns true if obj_id is registered in any ObjectRegistry."""
243
+ for registry_instance in REGISTRY.values():
244
+ if registry_instance.get(obj_id) is not None:
245
+ return True
246
+ return False
247
+
248
+
249
+ def iter_objects_by_ids(*obj_ids: str) -> Iterator["RegisteredObject"]:
250
+ obj_ids = utils.treat_variadic_argument(obj_ids)
251
+ for obj_id in obj_ids:
252
+ yield get_object_by_id(obj_id)
253
+
254
+
255
+ def register_object(
256
+ obj: Any,
257
+ id_prefix: str,
258
+ uid: Optional[Hashable] = None,
259
+ description: Optional[str] = None,
260
+ ) -> Any: # Return the object itself
261
+ """
262
+ Assigns an ID to an object and registers it in the appropriate registry.
263
+ The object's 'id' attribute will be set.
264
+ """
265
+ reg = get_registry_by_prefix(id_prefix)
266
+ obj._id = reg._generate_id(uid=uid, description=description)
267
+ reg.register_existing(obj) # This uses obj.id as the key
268
+ return obj
269
+
270
+
271
+ def ensure_registration(
272
+ obj: Any,
273
+ id_prefix: Optional[str] = None,
274
+ uid: Optional[Hashable] = None,
275
+ description: Optional[str] = None,
276
+ ) -> str:
277
+ """This makes sure that the object is part of a registry and can be retrieved via an id.
278
+ For internal objects (:class:`RegisteredObject`) this returns simply the id. External objects
279
+ are added to a registry according to their class name and a new ID is returned.
280
+ """
281
+ if (existing_id := getattr(obj, "id", None)) is not None:
282
+ if id_is_registered(existing_id):
283
+ return existing_id
284
+ if id_prefix is not None:
285
+ reg = get_registry_by_prefix(id_prefix)
286
+ else:
287
+ reg = get_registry_by_prefix(obj.__class__.__name__)
288
+ if existing_id is None:
289
+ obj_id = reg._generate_id(uid=uid, description=description)
290
+ else:
291
+ obj_id = existing_id
292
+ reg.register_existing(obj, _key=obj_id)
293
+ return obj_id
294
+
295
+
296
+ # endregion ObjectRegistry
297
+
298
+ # region CoordinatesMapRegistry
299
+
300
+
301
+ class CoordinatesMapRegistry(Registry["CoordinatesMap"]):
302
+ """
303
+ Manages CoordinatesMap instances, keyed by (source_type_key, target_type_key).
304
+ """
305
+
306
+ def __init__(self):
307
+ super().__init__()
308
+ self._get_coordinate_type_func_cache: Optional[Callable] = None
309
+
310
+ def _get_coordinate_type_loader(self) -> Callable:
311
+ """Helper to load get_coordinate_type lazily to avoid import issues."""
312
+ if self._get_coordinate_type_func_cache is None:
313
+ from processing.tta.conversions import get_coordinate_type
314
+
315
+ self._get_coordinate_type_func_cache = get_coordinate_type
316
+ return self._get_coordinate_type_func_cache
317
+
318
+ def make_key_for_map(self, cmap: "ConversionMap") -> tuple:
319
+ return (
320
+ cmap._source_unit,
321
+ cmap._target_unit,
322
+ cmap._target_type,
323
+ cmap._custom_conversion_function,
324
+ )
325
+
326
+ def register_map(self, cmap: "ConversionMap"):
327
+ """Registers a CoordinatesMap instance.
328
+ The key is derived from the map's source and target coordinate types.
329
+ """
330
+ # CoordinatesMap must have source_coordinate_type and target_coordinate_type properties
331
+ key = self.make_key_for_map(cmap)
332
+ if key in self._objects:
333
+ return
334
+ super().register(key, cmap)
335
+
336
+ def get_map(
337
+ self,
338
+ source_unit: TimeUnit,
339
+ target_unit: TimeUnit,
340
+ target_type: Optional[NumberType] = None,
341
+ converter_func: Optional[
342
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
343
+ ] = None,
344
+ ) -> Optional["ConversionMap"]:
345
+ """
346
+ Retrieves a CoordinatesMap instance based on source and target coordinate types.
347
+ """
348
+ key = (source_unit, target_unit, target_type, converter_func)
349
+ super().get(key)
350
+
351
+
352
+ CMAP_REGISTRY = CoordinatesMapRegistry()
353
+
354
+
355
+ def register_cmap(cmap: "ConversionMap"):
356
+ CMAP_REGISTRY.register_map(cmap)
357
+
358
+
359
+ def get_cmap(
360
+ source_unit: TimeUnit,
361
+ target_unit: TimeUnit,
362
+ target_type: Optional[NumberType] = None,
363
+ converter_func: Optional[
364
+ Callable[[Union[Number, np.ndarray]], Union[Number, np.ndarray]]
365
+ ] = None,
366
+ ) -> "ConversionMap":
367
+ return CMAP_REGISTRY.get_map(
368
+ source_unit=source_unit,
369
+ target_unit=target_unit,
370
+ target_type=target_type,
371
+ converter_func=converter_func,
372
+ )
373
+
374
+
375
+ # endregion CoordinatesMapRegistry
376
+
377
+
378
+ # region flyweight
379
+
380
+
381
+ @cache
382
+ def get_property_defaults(cls):
383
+ class_property_defaults = getattr(cls, "_property_defaults", {})
384
+ if class_property_defaults is None:
385
+ class_property_defaults = {}
386
+ elif not isinstance(class_property_defaults, dict):
387
+ class_property_defaults = {field: None for field in class_property_defaults}
388
+ return class_property_defaults
389
+
390
+
391
+ def get_property_values(
392
+ self,
393
+ include_shared=True,
394
+ include_individual=True,
395
+ exclude_properties: Optional[tuple[str]] = ("data",),
396
+ **column2field,
397
+ ) -> dict:
398
+ """Returns a dictionary of property values for a flyweight object."""
399
+ if exclude_properties is None:
400
+ exclude_properties = []
401
+ if include_shared:
402
+ properties_dict = {
403
+ k: v for k, v in self._shared.items() if k not in exclude_properties
404
+ }
405
+ else:
406
+ properties_dict = {}
407
+ if include_individual:
408
+ for name in dir(self):
409
+ if name.startswith("_") or name in exclude_properties:
410
+ continue
411
+ class_attribute = getattr(type(self), name, None)
412
+ if isinstance(class_attribute, property):
413
+ try:
414
+ value = getattr(self, name)
415
+ properties_dict[name] = value
416
+ except AttributeError:
417
+ properties_dict[name] = None
418
+ except Exception as e:
419
+ properties_dict[name] = f"Error accessing property: {e}"
420
+ for column, field in column2field.items():
421
+ properties_dict[column] = self.get(field)
422
+ return properties_dict
423
+
424
+
425
+ def flyweight(**decorator_defaults):
426
+ """Class decorator that lets you define the parameters (and their default values) that are to be
427
+ shared among all instances for which they have the same value configuration. The shared
428
+ values are accessible via the ._shared dict. Shared fields can be defined via decorator
429
+ arguments or a '_property_defaults' class attribute.
430
+
431
+ Conflict Resolution: Decorator arguments take precedence over class attribute defaults.
432
+
433
+ Example:
434
+
435
+ # Defining shared fields via decorator
436
+ @flyweight(data=None)
437
+ class Decorated:
438
+ def __init__(self):
439
+ pass
440
+
441
+ @property
442
+ def data(self):
443
+ return self._shared['data']
444
+
445
+ # Defining shared fields via class attribute (decorator wins)
446
+
447
+ @flyweight(value='decorator_value', another='decorator_another')
448
+ class WithClassAttributes:
449
+ _property_defaults = {'value': 'class_attribute_value', 'default_only': 'only_from_class'}
450
+
451
+ def __init__(self):
452
+ pass
453
+
454
+ @property
455
+ def value(self):
456
+ return self._shared['value']
457
+
458
+ @property
459
+ def another(self):
460
+ return self._shared['another']
461
+
462
+ @property
463
+ def default_only(self):
464
+ return self._shared['default_only']
465
+
466
+ """
467
+
468
+ def decorator(cls):
469
+ _original_new = cls.__new__
470
+ _original_init = (
471
+ cls.__init__
472
+ if hasattr(cls, "__init__") and cls.__init__ is not object.__init__
473
+ else None
474
+ )
475
+
476
+ _shared_dict_cache = {}
477
+
478
+ # Combining _property_defaults class attributes from all base classes in the MRO
479
+ combined_property_defaults = {}
480
+ for base_class in cls.__mro__:
481
+ class_defaults = get_property_defaults(base_class)
482
+ combined_property_defaults.update(class_defaults)
483
+
484
+ shared_params_defaults = dict(
485
+ combined_property_defaults
486
+ ) # start with class attributes
487
+ shared_params_defaults.update(
488
+ decorator_defaults
489
+ ) # update (override) with decorator defaults
490
+
491
+ def __new__(cls, *args, **kwargs):
492
+ shared_kwargs_for_key = {
493
+ k: kwargs.get(k, shared_params_defaults.get(k))
494
+ for k in shared_params_defaults
495
+ }
496
+
497
+ hashable_key_parts = []
498
+ for k, v in sorted(shared_kwargs_for_key.items()):
499
+ # Special handling for unhashable types
500
+ if isinstance(v, (list, dict, set, pd.DataFrame, bytearray)):
501
+ hashable_key_parts.append((k, id(v)))
502
+ else:
503
+ hashable_key_parts.append((k, v))
504
+
505
+ shared_key = tuple(hashable_key_parts)
506
+
507
+ if shared_key not in _shared_dict_cache:
508
+ _shared_dict = {}
509
+ for k, v in shared_kwargs_for_key.items():
510
+ _shared_dict[k] = v
511
+ _shared_dict_cache[shared_key] = _shared_dict
512
+
513
+ cached_shared_dict = _shared_dict_cache[shared_key]
514
+ setattr(
515
+ cls, "get_property_values", get_property_values
516
+ ) # Add get_property_values method to the class
517
+ new_instance = _original_new(cls)
518
+ new_instance._shared = cached_shared_dict
519
+ return new_instance
520
+
521
+ def __init__(self, *args, **kwargs):
522
+ # Use the combined defaults here to remove shared parameters from kwargs
523
+ for k in shared_params_defaults:
524
+ if k in kwargs:
525
+ del kwargs[k]
526
+
527
+ if _original_init:
528
+ _original_init(self, *args, **kwargs)
529
+ else:
530
+ object.__init__(
531
+ self
532
+ ) # Call original object __init__ if no custom one exists
533
+
534
+ cls.shared_params_defaults = shared_params_defaults # For introspection
535
+ cls.__new__ = __new__
536
+ cls.__init__ = __init__
537
+
538
+ return cls
539
+
540
+ return decorator
541
+
542
+
543
+ # endregion flyweight
544
+
545
+ if __name__ == "__main__":
546
+ pass