dataioc 0.1.0__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.
dataioc/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Lazy, indexed data dependency injection without mandatory numerical libraries."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ._data import (
6
+ DataDescriptor,
7
+ DataIoC,
8
+ DescribedData,
9
+ IndexedData,
10
+ IndexedDataIoC,
11
+ IndexedDataMeta,
12
+ IndexedDataTypeDescriptor,
13
+ SupportsBuild,
14
+ UniqueData,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from ._data_ndarray import DataNDArray as DataNDArray
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "DataDescriptor",
24
+ "DataIoC",
25
+ "DescribedData",
26
+ "IndexedData",
27
+ "IndexedDataIoC",
28
+ "IndexedDataMeta",
29
+ "IndexedDataTypeDescriptor",
30
+ "SupportsBuild",
31
+ "UniqueData",
32
+ ]
33
+
34
+
35
+ def __getattr__(name: str):
36
+ if name == "DataNDArray":
37
+ from ._data_ndarray import DataNDArray
38
+
39
+ return DataNDArray
40
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
dataioc/_data.py ADDED
@@ -0,0 +1,915 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import copy
5
+ import inspect
6
+ import sys
7
+ from collections.abc import Callable
8
+ from typing import (
9
+ Any,
10
+ Generic,
11
+ Optional,
12
+ Protocol,
13
+ TypeVar,
14
+ Union,
15
+ cast,
16
+ overload,
17
+ runtime_checkable,
18
+ )
19
+
20
+ if sys.version_info >= (3, 11):
21
+ from typing import Self
22
+ else:
23
+ from typing_extensions import Self
24
+
25
+ DataT = TypeVar("DataT")
26
+
27
+
28
+ @runtime_checkable
29
+ class SupportsBuild(Protocol):
30
+ """Protocol for objects that can build a value from a data container."""
31
+
32
+ def __build__(self, container: DataIoC) -> Any:
33
+ """Build and return a value using ``container``."""
34
+ ...
35
+
36
+
37
+ class DataDescriptor(Generic[DataT]):
38
+ """Identify a value in ``DataIoC`` and optionally define how to build it.
39
+
40
+ Parameters
41
+ ----------
42
+ id
43
+ Data ID. When omitted, the descriptor is a weak reference to ID 0.
44
+
45
+ Notes
46
+ -----
47
+ A ``DataDescriptor`` is used as a dictionary key, so its subclasses must remain
48
+ hashable and comparable. A subclass satisfies this requirement automatically
49
+ when each of its attributes is itself hashable and comparable.
50
+
51
+ IDs distinguish multiple values of the same type. The default ID is ``0`` when
52
+ no ID is specified. For example, readings from three sensors of the same type
53
+ can be identified by ``Sensor``, ``Sensor[1]``, and ``Sensor[2]`` and mapped to
54
+ different values in ``DataIoC``.
55
+
56
+ Examples
57
+ --------
58
+ An unindexed descriptor such as ``SensorData`` has a weak ID and identifies a
59
+ data type. When used to retrieve data inside ``__build__``, it is automatically
60
+ rebound to the concrete ID currently being built.
61
+
62
+ >>> from dataioc import DataDescriptor, DataIoC, DataNDArray
63
+ >>>
64
+ >>> class SensorData(DataNDArray):
65
+ ... def __new__(cls, data, **kwargs):
66
+ ... return super().__new__(cls, data, **kwargs)
67
+ >>>
68
+ >>> class Sum(DataDescriptor):
69
+ ... def __build__(self, container: DataIoC):
70
+ ... return container[SensorData].sum() # SensorData follows Sum's ID
71
+ >>>
72
+ >>> container = DataIoC().with_data(
73
+ ... SensorData([1, 1, 1]), SensorData[1]([2, 2, 2])
74
+ ... )
75
+ >>> print(container[Sum[0]], container[Sum[1]])
76
+ 3 6
77
+
78
+ An explicitly indexed descriptor such as ``SensorData[1]`` has a strong ID and
79
+ identifies one specific data group. It is not rebound inside ``__build__``.
80
+
81
+ >>> class OffsetSensor0(DataDescriptor):
82
+ ... def __build__(self, container: DataIoC):
83
+ ... base = container[SensorData[0]].sum() # Explicitly read ID 0
84
+ ... return base + container[SensorData]
85
+ >>>
86
+ >>> print(container[OffsetSensor0[0]], container[OffsetSensor0[1]])
87
+ [4 4 4] [5 5 5]
88
+ """
89
+
90
+ __slots__ = ["_id"]
91
+ DefaultWeakID = -1
92
+ DefaultID = -DefaultWeakID - 1
93
+
94
+ def __init__(self, id=DefaultWeakID) -> None:
95
+ self.id = id
96
+
97
+ @property
98
+ def id(self):
99
+ """The non-negative data ID represented by this descriptor."""
100
+ if self.signed_id < 0:
101
+ return -self.signed_id - 1
102
+ else:
103
+ return self.signed_id
104
+
105
+ @id.setter
106
+ def id(self, val):
107
+ self._id = val
108
+
109
+ @property
110
+ def signed_id(self):
111
+ """The internal signed ID, where a negative value marks a weak binding."""
112
+ return self._id
113
+
114
+ @property
115
+ def is_weak_id(self):
116
+ """Whether this descriptor may inherit the current build ID."""
117
+ return self.signed_id < 0
118
+
119
+ def __build__(self, container: DataIoC) -> DataT:
120
+ """Build the value identified by this descriptor.
121
+
122
+ Subclasses override this method to request dependencies from ``container``
123
+ and return the resulting value. The base implementation requires the value
124
+ to be provided explicitly.
125
+
126
+ Parameters
127
+ ----------
128
+ container
129
+ Container used to resolve dependencies.
130
+
131
+ Returns
132
+ -------
133
+ DataT
134
+ The constructed value.
135
+
136
+ Raises
137
+ ------
138
+ NotImplementedError
139
+ If the subclass does not provide a builder.
140
+ """
141
+ raise NotImplementedError(f"{self!r} must be provided.")
142
+
143
+ def index_implicit(self, new_index):
144
+ """Weakly rebind to ``new_index``, preserving an existing strong ID."""
145
+ return self.index(new_index, weak=True)
146
+
147
+ def index_explicit(self, new_index):
148
+ """Explicitly rebind to ``new_index``, overriding any existing ID."""
149
+ return self.index(new_index, weak=False)
150
+
151
+ def index(self, new_index, weak=True):
152
+ """Bind this descriptor to another ID for related values of the same type.
153
+
154
+ For example, readings from three sensors of the same type can be identified
155
+ by:
156
+
157
+ * ``Sensor[0]``
158
+ * ``Sensor[1]``
159
+ * ``Sensor[2]``
160
+
161
+ Parameters
162
+ ----------
163
+ new_index
164
+ New data ID.
165
+ weak
166
+ Whether the rebinding is weak. A weak rebinding has no effect when this
167
+ descriptor already has a strong ID.
168
+
169
+ Returns
170
+ -------
171
+ DataDescriptor
172
+ This descriptor if a weak rebinding is blocked by a strong ID;
173
+ otherwise, a copy bound to ``new_index``.
174
+ """
175
+ ret = self
176
+ if not weak or self.is_weak_id:
177
+ # Explicit rebinding, or rebinding a weak ID, may select the new ID.
178
+ ret = copy.copy(ret)
179
+ ret.id = new_index
180
+
181
+ return ret
182
+
183
+ def __getitem__(self, index) -> DataDescriptor[DataT]:
184
+ """Return a descriptor explicitly rebound to ``index``.
185
+
186
+ Parameters
187
+ ----------
188
+ index
189
+ New data ID.
190
+
191
+ Returns
192
+ -------
193
+ DataDescriptor
194
+ A copy bound to ``index``.
195
+
196
+ Notes
197
+ -----
198
+ Internal code should prefer ``index_implicit`` so weak IDs can inherit the
199
+ current build ID automatically.
200
+ """
201
+ return self.index_explicit(index)
202
+
203
+ def __class_getitem__(cls, id, *args):
204
+ if isinstance(id, int):
205
+ return cls(*args, id=id)
206
+ else:
207
+ # Generic supplies this method at runtime, outside the typed MRO.
208
+ return cast(Any, super()).__class_getitem__(id, *args)
209
+
210
+ def __hash__(self):
211
+ return hash(tuple(getattr(self, k) for k in sorted(self.keys)))
212
+
213
+ def __eq__(self, other):
214
+ if type(self) is not type(other):
215
+ return False
216
+
217
+ for k in self.keys:
218
+ if getattr(self, k) != getattr(other, k):
219
+ return False
220
+ else:
221
+ return True
222
+
223
+ @property
224
+ def keys(self):
225
+ """Attribute names that participate in equality and hashing."""
226
+ keys = []
227
+ for c in reversed(inspect.getmro(type(self))):
228
+ keys.extend(getattr(c, "__slots__", []))
229
+ keys.extend(getattr(self, "__dict__", []))
230
+
231
+ keys = set(keys)
232
+ keys.remove("_id")
233
+ keys.add("id") # Keep the public ID non-negative.
234
+
235
+ return keys
236
+
237
+ @property
238
+ def params(self):
239
+ """Constructor parameters reconstructed from the descriptor state."""
240
+ return {k.strip("_"): getattr(self, k) for k in sorted(self.keys)}
241
+
242
+ def __repr__(self):
243
+ params = self.params
244
+ del params["id"]
245
+
246
+ id_str = f"[{self.id}]" if self.id > 0 else ""
247
+ param_str = ", ".join([f"{k}={repr(v)}" for k, v in params.items()])
248
+
249
+ return f"{type(self).__name__}{id_str}({param_str})"
250
+
251
+ def __copy__(self):
252
+ return type(self)(**self.params)
253
+
254
+
255
+ class IndexedDataTypeDescriptor(DataDescriptor[DataT]):
256
+ """Bind an indexed data type to a data ID."""
257
+
258
+ __slots__ = ["_dtype"]
259
+
260
+ @classmethod
261
+ def of(cls, dtype, id=DataDescriptor.DefaultWeakID):
262
+ """Create the appropriate descriptor for ``dtype`` and ``id``."""
263
+ if isinstance(dtype, IndexedDataMeta):
264
+ return type(dtype).__getitem__(dtype, id)
265
+ elif issubclass(dtype, DataDescriptor):
266
+ return dtype(id=id)
267
+ else:
268
+ # Ordinary types cannot be indexed and always use the default ID.
269
+ return cls(dtype, id=DataDescriptor.DefaultID)
270
+
271
+ def __init__(self, dtype: type, *args, **kwargs) -> None:
272
+ super().__init__(*args, **kwargs)
273
+ self._dtype = dtype
274
+
275
+ @property
276
+ def dtype(self):
277
+ """The data type bound by this descriptor."""
278
+ return self._dtype
279
+
280
+ def __build__(self, container: DataIoC) -> DataT:
281
+ builder = _extract_builder_with_context(self.dtype, self)
282
+ if builder is None:
283
+ raise TypeError(f"No builder for {self.dtype!r}")
284
+ ret = builder(container)
285
+ return ret
286
+
287
+ def __call__(self, *args, **kwargs):
288
+ """Construct a value and associate it with this descriptor."""
289
+ return DescribedData(self, self.dtype(*args, **kwargs))
290
+
291
+ def __repr__(self):
292
+ id_str = f"[{self.id}]" if self.id > 0 else ""
293
+ return f"{self.dtype.__name__}{id_str}"
294
+
295
+
296
+ class DescribedData:
297
+ """Pair a value with the descriptor under which it should be registered."""
298
+
299
+ def __init__(self, desc: DataDescriptor[DataT], data: DataT):
300
+ self.desc = desc
301
+ self.data = data
302
+
303
+
304
+ class IndexedDataMeta(type):
305
+ """Make a data type indexable so ``DataIoC`` can bind related data groups.
306
+
307
+ Notes
308
+ -----
309
+ A derived class can define ``__class_index__`` to customize its indexing
310
+ behavior.
311
+
312
+ A derived class can inherit ``UniqueData`` to indicate that the type is unique
313
+ and does not require indexing.
314
+
315
+ Every type that is not an ``IndexedData`` subclass is treated as unique and is
316
+ not indexed.
317
+ """
318
+
319
+ def __getitem__(self, id=DataDescriptor.DefaultWeakID, *args):
320
+ class_getitem = getattr(self, "__class_index__", None)
321
+ if class_getitem is not None:
322
+ return class_getitem(id, *args)
323
+ else:
324
+ return IndexedDataTypeDescriptor(self, *args, id=id)
325
+
326
+ def __repr__(self):
327
+ return f"{self.__name__}"
328
+
329
+
330
+ class IndexedData(metaclass=IndexedDataMeta):
331
+ """Marker base class for data types that can have one value per ID."""
332
+
333
+ pass
334
+
335
+
336
+ class UniqueData:
337
+ """Make an indexed data type share one value across all build IDs."""
338
+
339
+ @classmethod
340
+ def __class_index__(cls, id, *args):
341
+ # Bind to the default ID so every implicit access resolves the same value.
342
+ return IndexedDataTypeDescriptor(cls, *args, id=DataDescriptor.DefaultID)
343
+
344
+
345
+ class DataIoC:
346
+ """Resolve data dependencies on demand and cache constructed values."""
347
+
348
+ def __init__(self, allow_implicit_registering=True, record_all=False):
349
+ """Initialize a data IoC container.
350
+
351
+ Parameters
352
+ ----------
353
+ allow_implicit_registering
354
+ Allow a requested target's own builder to be registered on first
355
+ access. If false, only explicitly registered data and builders can be
356
+ retrieved or built.
357
+ record_all
358
+ Retain all container access records. By default, only dependencies from
359
+ the current access are retained for diagnostic output. If true, the
360
+ complete access tree is retained and may add overhead.
361
+ """
362
+ self._collection: dict[Union[DataDescriptor, type], Any] = {}
363
+ self._lazy_collection: dict[
364
+ Union[DataDescriptor, type], Callable[[DataIoC], Any]
365
+ ] = {}
366
+ self.allow_implicit_register = allow_implicit_registering
367
+ self.record_all = record_all
368
+
369
+ self._logger = _DataIoCAccessLogger(key=self)
370
+
371
+ def with_data(self, *data: Any) -> Self:
372
+ """Register existing values and return this container.
373
+
374
+ Parameters
375
+ ----------
376
+ *data
377
+ Values to register by their types. Values created through an indexed
378
+ type, such as ``Sensor[1](...)``, retain their descriptors.
379
+
380
+ Returns
381
+ -------
382
+ DataIoC
383
+ This container, allowing chained registration calls.
384
+ """
385
+ for d in data:
386
+ if isinstance(d, DescribedData):
387
+ self[d.desc] = d.data
388
+ else:
389
+ self[type(d)] = d
390
+
391
+ return self
392
+
393
+ def add(
394
+ self,
395
+ data_type: Union[DataT, type[DataT], DataDescriptor[DataT]],
396
+ data: Optional[DataT] = None,
397
+ ) -> Self:
398
+ """Register existing data or a target's own lazy builder.
399
+
400
+ Passing a type or descriptor without ``data`` registers the builder defined
401
+ by that target. Passing an existing value as ``data_type`` registers it by
402
+ type. Passing both a key and a non-``None`` value registers that value under
403
+ the key.
404
+
405
+ Parameters
406
+ ----------
407
+ data_type
408
+ Existing value to register, or the type or descriptor used as a key.
409
+ data
410
+ Existing non-``None`` value to register under ``data_type``. Because
411
+ ``None`` selects builder registration, assign through ``container[key]``
412
+ to store an explicit ``None`` value.
413
+
414
+ Returns
415
+ -------
416
+ DataIoC
417
+ This container, allowing chained registration calls.
418
+
419
+ Raises
420
+ ------
421
+ TypeError
422
+ If no builder can be found or the supplied key is invalid.
423
+ """
424
+ data_type = _descriptor_instance(data_type)
425
+ if data is None:
426
+ if isinstance(data_type, DataDescriptor) or isinstance(data_type, type):
427
+ builder = _extract_builder_with_context(data_type)
428
+ if builder is None:
429
+ raise TypeError(f"No builder for {data_type!r}")
430
+ self._lazy_collection[data_type] = builder
431
+ else:
432
+ self.with_data(data_type)
433
+ else:
434
+ if not isinstance(data_type, (type, DataDescriptor)):
435
+ raise TypeError("A data key must be a type or DataDescriptor.")
436
+ self[data_type] = data
437
+
438
+ return self
439
+
440
+ def add_provider(
441
+ self,
442
+ data_type: Union[type[DataT], DataDescriptor[DataT]],
443
+ provider: Union[SupportsBuild, Callable],
444
+ ) -> Self:
445
+ """Register an alternative lazy builder for a data key.
446
+
447
+ Registering another provider for the same key replaces the builder used by
448
+ future uncached requests. Existing cached values are retained.
449
+
450
+ Parameters
451
+ ----------
452
+ data_type
453
+ Type or descriptor whose value the provider builds.
454
+ provider
455
+ Callable accepting a container, or an object that defines
456
+ ``__build__``.
457
+
458
+ Returns
459
+ -------
460
+ DataIoC
461
+ This container, allowing chained registration calls.
462
+
463
+ Raises
464
+ ------
465
+ TypeError
466
+ If ``provider`` is neither callable nor an object with ``__build__``.
467
+ """
468
+ data_type = _descriptor_instance(data_type)
469
+ initiator = None
470
+ if isinstance(data_type, DataDescriptor):
471
+ initiator = data_type
472
+
473
+ builder = _extract_builder(provider)
474
+ if builder is None:
475
+ raise TypeError("A provider must be callable or define __build__.")
476
+ self._lazy_collection[data_type] = Provider(
477
+ initiator=initiator, builder=builder, target=provider
478
+ )
479
+
480
+ return self
481
+
482
+ @property
483
+ def logger(self):
484
+ """The dependency access logger used for diagnostics."""
485
+ return self._logger
486
+
487
+ @overload
488
+ def __getitem__(self, dtype: DataDescriptor[DataT]) -> DataT: ...
489
+
490
+ @overload
491
+ def __getitem__(self, dtype: type[DataDescriptor[DataT]]) -> DataT: ...
492
+
493
+ @overload
494
+ def __getitem__(self, dtype: type[DataT]) -> DataT: ...
495
+
496
+ def __getitem__(self, dtype: Any) -> Any:
497
+ """Resolve and return the value identified by ``dtype``.
498
+
499
+ A cached value is returned immediately. Otherwise, the registered or
500
+ implicit builder is called and a successful result, including ``None``, is
501
+ cached under the requested key.
502
+
503
+ Parameters
504
+ ----------
505
+ dtype
506
+ Data type or descriptor to resolve.
507
+
508
+ Returns
509
+ -------
510
+ Any
511
+ The registered or constructed value.
512
+
513
+ Raises
514
+ ------
515
+ RuntimeError
516
+ If implicit registration is disabled and no builder is registered.
517
+ TypeError
518
+ If no suitable builder exists.
519
+ """
520
+ dtype = _descriptor_instance(dtype)
521
+ ret: Any = None
522
+ with self._logger.add(dtype):
523
+ if dtype in self._collection:
524
+ ret = self._collection[dtype]
525
+ else:
526
+ builder = self.find_builder(dtype)
527
+
528
+ if builder is None:
529
+ if not self.allow_implicit_register:
530
+ raise RuntimeError(
531
+ f"Builder for {dtype!r} not found in DataIoC."
532
+ )
533
+ else:
534
+ self.add(dtype)
535
+ builder = self.find_builder(dtype)
536
+
537
+ if builder is None:
538
+ raise TypeError(f"No builder for {dtype!r}")
539
+ if builder is not None:
540
+ if isinstance(builder, Provider):
541
+ self._logger.mark_overwrite(builder.target)
542
+
543
+ try:
544
+ ret = builder(self)
545
+ except Exception:
546
+ self._logger.mark_failed()
547
+ if self._logger.at_level0:
548
+ print(self._logger)
549
+ raise
550
+
551
+ self._logger.mark_new()
552
+ self[dtype] = ret
553
+
554
+ if self._logger.at_root and not self.record_all:
555
+ self._logger.clear()
556
+
557
+ return ret
558
+
559
+ def __setitem__(
560
+ self, data_type: Union[type[DataT], DataDescriptor[DataT]], data: DataT
561
+ ):
562
+ """Store ``data`` under a type or descriptor key.
563
+
564
+ Assigning to an indexed ID 0 key also makes the value available through an
565
+ unindexed class lookup. Assigning to a class creates the corresponding ID 0
566
+ mapping as well.
567
+ """
568
+ data_type = _descriptor_instance(data_type)
569
+ self._collection[data_type] = data
570
+ if isinstance(data_type, IndexedDataTypeDescriptor) and data_type.id == 0:
571
+ self._collection[data_type.dtype] = data
572
+ if isinstance(data_type, type):
573
+ # A class binding also supplies the corresponding ID 0 value.
574
+ self._collection[IndexedDataTypeDescriptor.of(data_type)] = data
575
+
576
+ def find_builder(self, dtype: Union[type[DataT], DataDescriptor[DataT]]):
577
+ """Find the registered builder for a type or descriptor.
578
+
579
+ A builder registered by class can be reused by
580
+ ``IndexedDataTypeDescriptor`` instances at every ID. A builder registered
581
+ for a specific ``DataDescriptor`` applies only to that descriptor.
582
+
583
+ Parameters
584
+ ----------
585
+ dtype
586
+ Type or descriptor whose builder should be found.
587
+
588
+ Returns
589
+ -------
590
+ Callable or None
591
+ The matching builder, with indexed context bound when necessary, or
592
+ ``None`` if no builder is registered.
593
+ """
594
+ # Class reads target ID 0; class registrations remain fallbacks for all IDs.
595
+ if isinstance(dtype, IndexedDataMeta):
596
+ dtype = IndexedDataTypeDescriptor.of(dtype, id=DataDescriptor.DefaultID)
597
+
598
+ builder = self._lazy_collection.get(dtype, None)
599
+ if builder is None:
600
+ if isinstance(dtype, IndexedDataTypeDescriptor):
601
+ # For an indexed type, also search for its type-wide builder.
602
+ builder = self._lazy_collection.get(dtype.dtype, None)
603
+ if builder is not None:
604
+ builder = _bind_builder_context(initiator=dtype, builder=builder)
605
+
606
+ return builder
607
+
608
+ def __str__(self):
609
+ return type(self).__name__
610
+
611
+
612
+ class IndexedDataIoC(DataIoC):
613
+ """Container view that rebinds weak dependencies to the active build ID."""
614
+
615
+ def __init__(self, base_container: DataIoC, initiator=None):
616
+ """Initialize an indexed view over ``base_container``.
617
+
618
+ Parameters
619
+ ----------
620
+ base_container
621
+ Container that stores and resolves the underlying values.
622
+ initiator
623
+ Descriptor whose signed ID supplies the current build context.
624
+ """
625
+ super().__init__()
626
+ self._base_container = base_container
627
+ self._initiator = initiator
628
+
629
+ @property
630
+ def id(self):
631
+ """The signed ID used when implicitly rebinding dependencies."""
632
+ if self._initiator is None:
633
+ return DataDescriptor.DefaultWeakID
634
+ else:
635
+ return self._initiator.signed_id
636
+
637
+ def __getattr__(self, item):
638
+ return getattr(self._base_container, item)
639
+
640
+ @overload
641
+ def __getitem__(self, dtype: DataDescriptor[DataT]) -> DataT: ...
642
+
643
+ @overload
644
+ def __getitem__(self, dtype: type[DataDescriptor[DataT]]) -> DataT: ...
645
+
646
+ @overload
647
+ def __getitem__(self, dtype: type[DataT]) -> DataT: ...
648
+
649
+ def __getitem__(self, dtype: Any) -> Any:
650
+ """Resolve ``dtype`` after applying the current indexed context."""
651
+ item = dtype
652
+ if isinstance(item, type):
653
+ item = IndexedDataTypeDescriptor.of(item, id=self.id)
654
+ elif isinstance(item, DataDescriptor):
655
+ item = item.index_implicit(self.id)
656
+
657
+ ret = self._base_container[item]
658
+
659
+ return ret
660
+
661
+
662
+ class _DataIoCDependency:
663
+ def __init__(self, parent: Optional[_DataIoCDependency], key, new=False):
664
+ self._children: list[tuple[Any, _DataIoCDependency]] = []
665
+ self._parent = parent
666
+ self._key = key
667
+ self._new = new
668
+ self._overwrite = None
669
+ self._failed = False
670
+
671
+ def __iter__(self):
672
+ return iter(self._children)
673
+
674
+ def __len__(self):
675
+ return len(self._children)
676
+
677
+ def clear(self):
678
+ self._children.clear()
679
+
680
+ def mark_new(self):
681
+ self._new = True
682
+
683
+ def mark_overwrite(self, key):
684
+ self._overwrite = key
685
+
686
+ def mark_failed(self):
687
+ self._failed = True
688
+
689
+ def add(self, key):
690
+ ret = self.get(key, None)
691
+ if ret is None:
692
+ ret = _DataIoCDependency(parent=self, key=key)
693
+ self[key] = ret
694
+
695
+ return ret
696
+
697
+ @property
698
+ def parent(self):
699
+ return self._parent
700
+
701
+ @property
702
+ def last_child(self):
703
+ if len(self) == 0:
704
+ return None
705
+
706
+ return self._children[-1][1]
707
+
708
+ def __contains__(self, item):
709
+ for k, _v in self:
710
+ if item == k:
711
+ return True
712
+ else:
713
+ return False
714
+
715
+ def __setitem__(self, key, value):
716
+ self._children.append((key, value))
717
+
718
+ def __getitem__(self, item):
719
+ for k, v in self:
720
+ if item == k:
721
+ return v
722
+ else:
723
+ raise KeyError(item)
724
+
725
+ def get(self, item, default=None):
726
+ try:
727
+ return self[item]
728
+ except KeyError:
729
+ return default
730
+
731
+ def to_str(
732
+ self, prefix=None, indent="", table_prefix="", align_first_non_blank=True
733
+ ):
734
+ if prefix is None:
735
+ prefix = " * "
736
+
737
+ this_prefix = prefix
738
+ properties = []
739
+ if self._failed:
740
+ this_prefix = this_prefix.replace("*", "X")
741
+ if this_prefix == prefix:
742
+ properties.append(" X <--- Failed here ")
743
+ if self._new:
744
+ properties.append("Created")
745
+
746
+ if len(properties) == 0:
747
+ props = ""
748
+ else:
749
+ props = " /" + ", ".join(properties) + "/"
750
+
751
+ key = str(self._key)
752
+ if self._overwrite is not None:
753
+ key = str(self._overwrite) + f" ( <- {key})"
754
+
755
+ ret = table_prefix + this_prefix + key + props + "\n"
756
+
757
+ if align_first_non_blank:
758
+ for c in prefix:
759
+ if c == " ":
760
+ indent += " "
761
+ else:
762
+ break
763
+
764
+ remaining = len(self)
765
+ for _k, v in self:
766
+ if remaining > 1:
767
+ ret += v.to_str(
768
+ prefix=prefix, table_prefix=indent + "├─", indent=indent + "│ "
769
+ )
770
+ else:
771
+ ret += v.to_str(
772
+ prefix=prefix, table_prefix=indent + "└─", indent=indent + " "
773
+ )
774
+
775
+ remaining -= 1
776
+
777
+ return ret
778
+
779
+ def __str__(self):
780
+ return self.to_str()
781
+
782
+
783
+ class _DataIoCAccessLogger:
784
+ def __init__(self, key: Any = "root"):
785
+ self.root = _DataIoCDependency(None, key)
786
+ self.current = self.root
787
+
788
+ @property
789
+ def at_level0(self):
790
+ """Whether the current node is the root of a child access tree."""
791
+ return self.current.parent is self.root
792
+
793
+ @property
794
+ def at_root(self):
795
+ """Whether the logger is positioned at the root node."""
796
+ return self.current is self.root
797
+
798
+ @contextlib.contextmanager
799
+ def add(self, key):
800
+ child = self.current.add(key)
801
+
802
+ try:
803
+ self.enter(child)
804
+ yield
805
+ finally:
806
+ self.exit()
807
+
808
+ def clear(self):
809
+ self.root.clear()
810
+
811
+ def mark_new(self):
812
+ self.current.mark_new()
813
+
814
+ def mark_overwrite(self, key):
815
+ self.current.mark_overwrite(key)
816
+
817
+ def mark_failed(self):
818
+ self.current.mark_failed()
819
+
820
+ def __enter__(self):
821
+ self.enter()
822
+
823
+ def __exit__(self, exc_type, exc_val, exc_tb):
824
+ self.exit()
825
+
826
+ def enter(self, node=None):
827
+ if node is None:
828
+ node = self.current.last_child
829
+ if node is None:
830
+ raise RuntimeError("No dependency to enter.")
831
+ self.current = node
832
+
833
+ def exit(self):
834
+ parent = self.current.parent
835
+ if parent is None:
836
+ raise RuntimeError("Cannot exit the dependency tree root.")
837
+ self.current = parent
838
+
839
+ def to_str(self, prefix=None):
840
+ return self.root.to_str(prefix=prefix)
841
+
842
+ def __str__(self):
843
+ return self.to_str()
844
+
845
+
846
+ def _descriptor_instance(dtype: Any) -> Any:
847
+ if isinstance(dtype, type) and issubclass(dtype, DataDescriptor):
848
+ return dtype()
849
+ return dtype
850
+
851
+
852
+ def _extract_builder_with_context(
853
+ dtype: Union[DataDescriptor, SupportsBuild, Callable], initiator=None
854
+ ):
855
+ dtype = _descriptor_instance(dtype)
856
+ if initiator is None:
857
+ if isinstance(dtype, DataDescriptor):
858
+ initiator = dtype
859
+
860
+ builder = _extract_builder(dtype)
861
+
862
+ if builder is None:
863
+ return builder
864
+ else:
865
+ return _bind_builder_context(builder, initiator=initiator)
866
+
867
+
868
+ def _extract_builder(dtype: Union[DataDescriptor, SupportsBuild, Callable]):
869
+ if isinstance(dtype, SupportsBuild):
870
+ builder = dtype.__build__
871
+ elif callable(dtype):
872
+ # Either a type constructor or a direct builder function.
873
+ # TODO: Validate that the callable can be used by DataIoC.
874
+ builder = dtype
875
+ else:
876
+ builder = None
877
+
878
+ return builder
879
+
880
+
881
+ def _bind_builder_context(builder, initiator):
882
+ if initiator is None:
883
+ return builder
884
+ elif isinstance(builder, BuilderWithContext):
885
+ return builder.with_initiator(initiator)
886
+ else:
887
+ return BuilderWithContext(initiator, builder)
888
+
889
+
890
+ class BuilderWithContext:
891
+ def __init__(self, initiator, builder):
892
+ self.initiator = initiator
893
+ self.builder = builder
894
+
895
+ def __call__(self, container: DataIoC):
896
+ return self.builder(IndexedDataIoC(container, initiator=self.initiator))
897
+
898
+ def with_initiator(self, initiator):
899
+ ret = copy.copy(self)
900
+ ret.initiator = initiator
901
+
902
+ return ret
903
+
904
+
905
+ class Provider(BuilderWithContext):
906
+ def __init__(self, initiator, builder, target):
907
+ super().__init__(initiator, builder)
908
+ self._target = target
909
+
910
+ @property
911
+ def target(self):
912
+ if isinstance(self._target, DataDescriptor):
913
+ return self._target
914
+ else:
915
+ return getattr(self._target, "__name__", type(self._target).__name__)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Union
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike
7
+
8
+ from ._data import DataIoC, IndexedData, IndexedDataIoC
9
+
10
+
11
+ def is_homogeneous(inp: np.ndarray, out: np.ndarray):
12
+ """Whether a ufunc result can safely retain the input array subclass."""
13
+ if inp.shape != out.shape:
14
+ # Array shape changes do not preserve the data type's structure.
15
+ return False
16
+ try:
17
+ promoted_dtype = np.promote_types(inp.dtype, out.dtype)
18
+ except TypeError:
19
+ # Dtypes such as StringDType and bool have no common promoted dtype.
20
+ return False
21
+ if out.dtype != promoted_dtype:
22
+ # Results may retain the subclass only through dtype promotion.
23
+ return False
24
+
25
+ return True
26
+
27
+
28
+ class DataNDArray(np.ndarray, IndexedData):
29
+ """NumPy array base class that participates in indexed data resolution.
30
+
31
+ A single input array is viewed as the subclass without an unnecessary copy.
32
+ Multiple input arrays must have the same sample count and are stacked as
33
+ columns. Shape-preserving ufunc results retain the subclass when their dtype is
34
+ the promoted input/output dtype. Slices and reshaped arrays are returned as
35
+ plain ``numpy.ndarray`` instances.
36
+ """
37
+
38
+ def __new__(cls, *arrays: ArrayLike, force_column_stack=False, **kwargs):
39
+ """Construct an indexed array from one or more array-like objects.
40
+
41
+ Parameters
42
+ ----------
43
+ *arrays
44
+ One or more array-like objects. Multiple inputs are stacked as columns.
45
+ force_column_stack
46
+ Stack a single input as one column instead of preserving its shape.
47
+ **kwargs
48
+ Additional arguments accepted by subclass constructors.
49
+
50
+ Raises
51
+ ------
52
+ ValueError
53
+ If no arrays are provided or their sample counts differ.
54
+ TypeError
55
+ If an input is scalar.
56
+ """
57
+ if not arrays:
58
+ raise ValueError("At least one array is required.")
59
+ converted = [np.asarray(array) for array in arrays]
60
+ if any(array.ndim == 0 for array in converted):
61
+ raise TypeError("Arrays must have at least one dimension.")
62
+ if len({len(array) for array in converted}) != 1:
63
+ raise ValueError("Arrays have inconsistent numbers of samples.")
64
+ if force_column_stack or len(arrays) > 1:
65
+ return np.column_stack(converted).view(cls)
66
+ else:
67
+ return converted[0].view(cls)
68
+
69
+ def __array_finalize__(self, obj, **__):
70
+ pass
71
+
72
+ def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
73
+ typ = type(self)
74
+
75
+ inputs = tuple(
76
+ np.asarray(inp) if isinstance(inp, typ) else inp for inp in inputs
77
+ )
78
+ if out is not None:
79
+ kwargs["out"] = tuple(
80
+ np.asarray(o) if isinstance(o, typ) else o for o in out
81
+ )
82
+
83
+ ret = getattr(ufunc, method)(*inputs, **kwargs)
84
+
85
+ if ret is NotImplemented:
86
+ return NotImplemented
87
+
88
+ results = ret if isinstance(ret, tuple) else (ret,)
89
+ outputs = out if out is not None else (None,) * len(results)
90
+ wrapped = []
91
+ for result, output in zip(results, outputs):
92
+ # Explicit outputs must retain their identity, including in-place updates.
93
+ if output is not None:
94
+ wrapped.append(output)
95
+ elif isinstance(result, np.ndarray) and is_homogeneous(self, result):
96
+ wrapped.append(result.view(typ))
97
+ else:
98
+ wrapped.append(result)
99
+ return tuple(wrapped) if isinstance(ret, tuple) else wrapped[0]
100
+
101
+ def reshape(self, *shape, **kwargs) -> Any:
102
+ """Return a reshaped plain ``numpy.ndarray``."""
103
+ return self.view(np.ndarray).reshape(*shape, **kwargs)
104
+
105
+ def __getitem__(self, key):
106
+ """Return slices as plain arrays while preserving NumPy scalar behavior."""
107
+ result = super().__getitem__(key)
108
+ return result.view(np.ndarray) if isinstance(result, np.ndarray) else result
109
+
110
+ @classmethod
111
+ def __build__(cls, container: Union[DataIoC, IndexedDataIoC]):
112
+ """Require instances of the array subclass to be provided explicitly."""
113
+ id_str = f"[{container.id}]" if isinstance(container, IndexedDataIoC) else ""
114
+ raise NotImplementedError(f"{cls.__name__}{id_str} must be provided.")
dataioc/py.typed ADDED
File without changes
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: dataioc
3
+ Version: 0.1.0
4
+ Summary: Declarative data dependency graphs for Python, resolved on demand with provider overrides.
5
+ Project-URL: Documentation, https://dyuu7.github.io/dataioc/
6
+ Project-URL: Issues, https://github.com/dyuu7/dataioc/issues
7
+ Project-URL: Repository, https://github.com/dyuu7/dataioc
8
+ Author: yanang007, dyuu7
9
+ Maintainer: dyuu7
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: IoC,data dependencies,dependency graph,dependency injection,lazy evaluation,providers
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: typing-extensions>=4.10.0; python_version < '3.11'
26
+ Provides-Extra: numpy
27
+ Requires-Dist: numpy<3,>=1.26; extra == 'numpy'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # dataioc
31
+
32
+ **Declarative data dependency graphs for Python.**
33
+
34
+ Each `DataDescriptor` names a quantity and defines how it is derived from its direct dependencies. `DataIoC` composes these local rules into a graph, then resolves and caches only the subgraph required by the result you request. Bind a provider to any quantity to replace that part of the graph without changing downstream calculations.
35
+
36
+ [English](https://github.com/dyuu7/dataioc/blob/main/README.md) | [简体中文](https://github.com/dyuu7/dataioc/blob/main/README.zh-CN.md) | [Documentation](https://dyuu7.github.io/dataioc/) | [PyPI](https://pypi.org/project/dataioc/)
37
+
38
+ [![CI](https://github.com/dyuu7/dataioc/actions/workflows/ci.yml/badge.svg)](https://github.com/dyuu7/dataioc/actions/workflows/ci.yml)
39
+
40
+ ## Example
41
+
42
+ The raw readings `10`, `20`, and `60` represent measurements of `1`, `2`, and `6`.
43
+
44
+ <img src="https://raw.githubusercontent.com/dyuu7/dataioc/main/docs/assets/data-flow.png" width="720" alt="By default, RawReadings are converted into Measurements. When a provider binds recorded data to Measurements, that default derivation is replaced while Statistics and Report remain unchanged." />
45
+
46
+ ```python
47
+ from dataioc import DataDescriptor, DataIoC
48
+
49
+
50
+ class RawReadings(DataDescriptor):
51
+ pass
52
+
53
+
54
+ class Measurements(DataDescriptor):
55
+ def __build__(self, data):
56
+ return tuple(value / 10 for value in data[RawReadings])
57
+
58
+
59
+ class Statistics(DataDescriptor):
60
+ def __build__(self, data):
61
+ values = data[Measurements]
62
+ return sum(values) / len(values), max(values)
63
+
64
+
65
+ class Report(DataDescriptor):
66
+ def __build__(self, data):
67
+ mean, peak = data[Statistics]
68
+ return f"mean={mean:g}, peak={peak:g}"
69
+
70
+
71
+ data = DataIoC().add(RawReadings, (10, 20, 60))
72
+ assert data[Report] == "mean=3, peak=6"
73
+ assert data[Measurements] is data[Measurements]
74
+ ```
75
+
76
+ `data[Report]` is the only request the caller has to make. The container follows the dependencies and caches each value it builds.
77
+
78
+ To use recorded measurements instead, bind a provider for `Measurements`:
79
+
80
+ ```python
81
+ recorded = DataIoC().add_provider(Measurements, lambda _: (1.0, 2.0, 6.0))
82
+ assert recorded[Report] == "mean=3, peak=6"
83
+ ```
84
+
85
+ `RawReadings` is no longer needed; `Statistics` and `Report` stay as they are.
86
+
87
+ ## When it helps
88
+
89
+ If both the inputs and the calculation path are fixed, ordinary function calls are simpler. Use `dataioc` when the relationships stay stable but a value may come from live measurements, recorded data, a simulation, or an estimate.
90
+
91
+ `dataioc` grew out of [deinterf](https://github.com/dyuu7/deinterf). In its [direction-cosine example](https://github.com/dyuu7/deinterf/blob/main/examples/replace_direction_cosine_source_tmi.py), the same compensation terms work whether direction cosines are derived from magnetic-vector measurements or supplied by an INS estimate. [dvmss](https://github.com/dyuu7/dvmss) applies the pattern to simulation: supply the inputs, request `Tmi`, and let the container resolve the intermediate quantities.
92
+
93
+ ## Scope
94
+
95
+ `dataioc` resolves data dependencies synchronously in the current process; it is not a workflow scheduler. Use a fresh container for each dataset or provider configuration. See [Core concepts](https://dyuu7.github.io/dataioc/concepts/) for caching, diagnostics, and other runtime limits.
96
+
97
+ ## Install
98
+
99
+ ```bash
100
+ python -m pip install dataioc
101
+ python -m pip install "dataioc[numpy]"
102
+ ```
103
+
104
+ ## Documentation
105
+
106
+ - [Quickstart](https://dyuu7.github.io/dataioc/quickstart/): build a result from local dependency rules.
107
+ - [Core concepts](https://dyuu7.github.io/dataioc/concepts/): understand descriptors, builders, caching, and failure behavior.
108
+ - [Providers](https://dyuu7.github.io/dataioc/providers/): bind a quantity to another source or derivation.
109
+ - [Indexed data](https://dyuu7.github.io/dataioc/indexed-data/): reuse one model across related data groups.
110
+ - [NumPy](https://dyuu7.github.io/dataioc/numpy/): use array subclasses.
111
+ - [API](https://dyuu7.github.io/dataioc/api/): look up interfaces.
112
+
113
+ ## Contributors
114
+
115
+ [yanang007](https://github.com/yanang007) wrote the original container. [dyuu7](https://github.com/dyuu7) shaped the design, extracted it into `dataioc`, and maintains the project.
116
+
117
+ [![Contributors](https://contrib.rocks/image?repo=dyuu7/dataioc)](https://github.com/dyuu7/dataioc/graphs/contributors)
118
+
119
+ Licensed under the [MIT License](https://github.com/dyuu7/dataioc/blob/main/LICENSE).
@@ -0,0 +1,8 @@
1
+ dataioc/__init__.py,sha256=yV5P2U6HVkPpDoCJDrllOz9h830j_dHlmVcbsPL8KDs,838
2
+ dataioc/_data.py,sha256=KjXjVDENNw48tl1GaaB3fzCd3ha0_C1chVJLMQn9xms,28048
3
+ dataioc/_data_ndarray.py,sha256=uptogKrVmYlPZ1zaH57J0JFj-7aQRsWXVOMJ9No63ao,4425
4
+ dataioc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ dataioc-0.1.0.dist-info/METADATA,sha256=HUBQy3hOpTayd3fYjSAbi1cM3K_4wG4Jrq6JWoTdB5g,5681
6
+ dataioc-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ dataioc-0.1.0.dist-info/licenses/LICENSE,sha256=fqt0g3WFWGRYHlDbA2oA-QIqwQoTsLnhVvQBGE87Wg8,1062
8
+ dataioc-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 dyuu7
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.