oarepo-runtime 2.0.0.dev6__py3-none-any.whl → 2.0.0.dev8__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.
- oarepo_runtime/__init__.py +1 -1
- oarepo_runtime/py.typed +1 -0
- oarepo_runtime/resources/__init__.py +16 -0
- oarepo_runtime/resources/config.py +35 -0
- oarepo_runtime/services/config/components.py +526 -0
- {oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/METADATA +1 -1
- {oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/RECORD +10 -6
- {oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/WHEEL +0 -0
- {oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/entry_points.txt +0 -0
- {oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/licenses/LICENSE +0 -0
oarepo_runtime/__init__.py
CHANGED
oarepo_runtime/py.typed
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# marker to show that this module is typed
|
@@ -0,0 +1,16 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2025 CESNET z.s.p.o.
|
3
|
+
#
|
4
|
+
# This file is a part of oarepo-runtime (see http://github.com/oarepo/oarepo-runtime).
|
5
|
+
#
|
6
|
+
# oarepo-runtime is free software; you can redistribute it and/or modify it
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
8
|
+
#
|
9
|
+
|
10
|
+
"""Extensions for RDM API resources."""
|
11
|
+
|
12
|
+
from __future__ import annotations
|
13
|
+
|
14
|
+
from .config import exports_to_response_handlers
|
15
|
+
|
16
|
+
__all__ = ("exports_to_response_handlers",)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2025 CESNET z.s.p.o.
|
3
|
+
#
|
4
|
+
# This file is a part of oarepo-runtime (see http://github.com/oarepo/oarepo-runtime).
|
5
|
+
#
|
6
|
+
# oarepo-runtime is free software; you can redistribute it and/or modify it
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
8
|
+
#
|
9
|
+
|
10
|
+
"""Extensions for RDM API resources."""
|
11
|
+
|
12
|
+
from __future__ import annotations
|
13
|
+
|
14
|
+
from typing import TYPE_CHECKING
|
15
|
+
|
16
|
+
from flask_resources.responses import ResponseHandler
|
17
|
+
from invenio_records_resources.resources.records.headers import etag_headers
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from collections.abc import Iterable
|
21
|
+
|
22
|
+
from oarepo_runtime.api import Export
|
23
|
+
|
24
|
+
|
25
|
+
def exports_to_response_handlers(
|
26
|
+
exports: Iterable[Export],
|
27
|
+
) -> dict[str, ResponseHandler]:
|
28
|
+
"""Convert exports to a dictionary of mimetype -> response handlers."""
|
29
|
+
return {
|
30
|
+
export.mimetype: ResponseHandler(
|
31
|
+
serializer=export.serializer,
|
32
|
+
headers=etag_headers,
|
33
|
+
)
|
34
|
+
for export in exports
|
35
|
+
}
|
@@ -0,0 +1,526 @@
|
|
1
|
+
#
|
2
|
+
# Copyright (c) 2025 CESNET z.s.p.o.
|
3
|
+
#
|
4
|
+
# This file is a part of oarepo-runtime (see http://github.com/oarepo/oarepo-runtime).
|
5
|
+
#
|
6
|
+
# oarepo-runtime is free software; you can redistribute it and/or modify it
|
7
|
+
# under the terms of the MIT License; see LICENSE file for more details.
|
8
|
+
#
|
9
|
+
"""Utilities for deterministic ordering of service components.
|
10
|
+
|
11
|
+
This module provides a mixin that reorders service components while
|
12
|
+
respecting ``affects`` and ``depends_on`` relationships declared on the
|
13
|
+
component classes. It supports wildcard semantics (``"*"``) and preserves
|
14
|
+
the input order whenever it does not conflict with the declared constraints.
|
15
|
+
"""
|
16
|
+
|
17
|
+
from __future__ import annotations
|
18
|
+
|
19
|
+
import heapq
|
20
|
+
import inspect
|
21
|
+
from collections import defaultdict
|
22
|
+
from functools import cached_property, partial
|
23
|
+
from itertools import chain
|
24
|
+
from typing import TYPE_CHECKING, Any, Literal, override
|
25
|
+
|
26
|
+
from invenio_base.utils import obj_or_import_string
|
27
|
+
from invenio_records_resources.services.records.components import ServiceComponent
|
28
|
+
|
29
|
+
if TYPE_CHECKING:
|
30
|
+
from collections.abc import Callable, Generator, Iterable
|
31
|
+
|
32
|
+
from invenio_records_resources.services.records.config import RecordServiceConfig
|
33
|
+
from invenio_records_resources.services.records.service import RecordService
|
34
|
+
|
35
|
+
else:
|
36
|
+
# for mixin typing
|
37
|
+
RecordServiceConfig = object
|
38
|
+
RecordService = object
|
39
|
+
|
40
|
+
|
41
|
+
class ComponentData:
|
42
|
+
"""Normalized metadata extracted from a service component.
|
43
|
+
|
44
|
+
Instances of this helper encapsulate the resolved component class, its
|
45
|
+
relevant MRO, and the parsed ``affects``/``depends_on`` declarations for
|
46
|
+
use by the ordering algorithm.
|
47
|
+
"""
|
48
|
+
|
49
|
+
original_component: Any
|
50
|
+
"""The original component entry as provided in ``config.components``."""
|
51
|
+
|
52
|
+
component_class: type[ServiceComponent]
|
53
|
+
"""The resolved class used for ordering comparisons.
|
54
|
+
|
55
|
+
If the original component is a class, this is that class (validated to be a
|
56
|
+
``ServiceComponent`` subclass). If the original component is a
|
57
|
+
``functools.partial``, this is ``partial.func``. If it is a factory/callable,
|
58
|
+
the callable is invoked and the result's type is used.
|
59
|
+
"""
|
60
|
+
|
61
|
+
component_mro: set[type[ServiceComponent]]
|
62
|
+
"""Set containing ``component_class`` and its mixins/base classes.
|
63
|
+
|
64
|
+
Classes from the ``ServiceComponent`` hierarchy (including ``object``) are
|
65
|
+
excluded. This set is used for efficient membership checks when matching
|
66
|
+
dependencies/affects to other components.
|
67
|
+
"""
|
68
|
+
|
69
|
+
replaces: set[type[ServiceComponent]]
|
70
|
+
"""Classes that this component replaces."""
|
71
|
+
|
72
|
+
replaced_by: set[type[ServiceComponent]]
|
73
|
+
"""Classes that replace this component."""
|
74
|
+
|
75
|
+
affects_all: bool
|
76
|
+
"""Whether the component declares ``affects = "*"`` (i.e., affects all)."""
|
77
|
+
|
78
|
+
depends_on_all: bool
|
79
|
+
"""Whether the component declares ``depends_on = "*"`` (i.e., depends on all)."""
|
80
|
+
|
81
|
+
affects: set[type[ServiceComponent]]
|
82
|
+
"""Classes that this component affects."""
|
83
|
+
|
84
|
+
depends_on: set[type[ServiceComponent]]
|
85
|
+
"""Classes that this component depends on."""
|
86
|
+
|
87
|
+
idx: int
|
88
|
+
"""Position of the item within the current working list during sort.
|
89
|
+
|
90
|
+
This is assigned later by the sorting algorithm and refers to the index in
|
91
|
+
the local subset being processed (not necessarily the original input list).
|
92
|
+
"""
|
93
|
+
|
94
|
+
indeg: int = 0
|
95
|
+
"""Number of incoming edges in the dependency graph (direct prerequisites)."""
|
96
|
+
|
97
|
+
def __init__(
|
98
|
+
self,
|
99
|
+
original_component: Any,
|
100
|
+
service: RecordService,
|
101
|
+
) -> None:
|
102
|
+
"""Resolve and validate metadata from the provided component entry.
|
103
|
+
|
104
|
+
Also validates that a component cannot both affect all and depend on all
|
105
|
+
at the same time (mutually exclusive wildcards).
|
106
|
+
"""
|
107
|
+
self.original_component = original_component
|
108
|
+
self.component_class = self._extract_class_from_component(original_component, service)
|
109
|
+
|
110
|
+
self.component_mro = self._get_service_mro(self.component_class)
|
111
|
+
|
112
|
+
self.affects_all = "*" in getattr(self.component_class, "affects", [])
|
113
|
+
self.depends_on_all = "*" in getattr(self.component_class, "depends_on", [])
|
114
|
+
|
115
|
+
if self.affects_all and self.depends_on_all:
|
116
|
+
raise ValueError(
|
117
|
+
f"Component {self.original_component} cannot affect and depend on all components at the same time."
|
118
|
+
)
|
119
|
+
|
120
|
+
self.affects = self._convert_to_classes(getattr(self.component_class, "affects", None) or [])
|
121
|
+
self.depends_on = self._convert_to_classes(getattr(self.component_class, "depends_on", None) or [])
|
122
|
+
self.replaces = self._convert_to_classes(getattr(self.component_class, "replaces", None) or [])
|
123
|
+
self.replaced_by = self._convert_to_classes(getattr(self.component_class, "replaced_by", None) or [])
|
124
|
+
|
125
|
+
def _extract_class_from_component(self, component: Any, service: RecordService) -> type[ServiceComponent]:
|
126
|
+
"""Resolve a comparable class from the component entry.
|
127
|
+
|
128
|
+
Supported forms:
|
129
|
+
- a class that subclasses ``ServiceComponent``;
|
130
|
+
- a ``functools.partial`` whose ``func`` ultimately resolves to the class;
|
131
|
+
- a factory/callable returning a ``ServiceComponent`` instance when
|
132
|
+
called (the returned instance's type is used).
|
133
|
+
"""
|
134
|
+
# if it is a class, return it
|
135
|
+
if inspect.isclass(component):
|
136
|
+
if not issubclass(component, ServiceComponent):
|
137
|
+
raise TypeError(f"Component {component} is not a subclass of ServiceComponent")
|
138
|
+
return component
|
139
|
+
|
140
|
+
# it might be a partial, so check that out
|
141
|
+
if isinstance(component, partial):
|
142
|
+
return self._extract_class_from_component(component.func, service)
|
143
|
+
|
144
|
+
# as a last option, instantiate the component and return its type
|
145
|
+
inst = component(service)
|
146
|
+
return type(inst)
|
147
|
+
|
148
|
+
def _get_service_mro(self, component_class: type[ServiceComponent]) -> set[type]:
|
149
|
+
"""Get the relevant MRO for ordering comparisons.
|
150
|
+
|
151
|
+
Returns the component class and its base classes/mixins, excluding the
|
152
|
+
``ServiceComponent`` hierarchy (and thus also ``object``).
|
153
|
+
"""
|
154
|
+
return {x for x in component_class.mro() if x not in ServiceComponent.mro()}
|
155
|
+
|
156
|
+
def _convert_to_classes(self, items: Any) -> set[type[ServiceComponent]]:
|
157
|
+
"""Normalize an input list/tuple to a set of component classes.
|
158
|
+
|
159
|
+
Accepts classes or import strings. The special value ``"*"`` is handled
|
160
|
+
by the caller via ``affects_all``/``depends_on_all`` and is not included
|
161
|
+
in the returned set.
|
162
|
+
"""
|
163
|
+
ret: set[type[ServiceComponent]] = set()
|
164
|
+
|
165
|
+
if not isinstance(items, (list, tuple)):
|
166
|
+
if items == "*":
|
167
|
+
return ret
|
168
|
+
raise TypeError(f"Expected list or tuple, got {type(items)}")
|
169
|
+
for item in items:
|
170
|
+
if isinstance(item, str):
|
171
|
+
item = obj_or_import_string(item) # noqa PLW2901
|
172
|
+
|
173
|
+
if inspect.isclass(item):
|
174
|
+
if not issubclass(item, ServiceComponent):
|
175
|
+
raise TypeError(f"Item {item} is not a ServiceComponent subclass")
|
176
|
+
ret.add(item)
|
177
|
+
else:
|
178
|
+
raise TypeError(
|
179
|
+
f"affects or depends_on needs to contain classes, item {item} ({type(item)}) is not a class"
|
180
|
+
)
|
181
|
+
return ret
|
182
|
+
|
183
|
+
@override
|
184
|
+
def __hash__(self):
|
185
|
+
return hash(self.component_class)
|
186
|
+
|
187
|
+
@override
|
188
|
+
def __eq__(self, other: object) -> bool:
|
189
|
+
return self.component_class == other.component_class if isinstance(other, ComponentData) else False
|
190
|
+
|
191
|
+
@override
|
192
|
+
def __repr__(self):
|
193
|
+
return f"CD({self.component_class.__name__})"
|
194
|
+
|
195
|
+
@override
|
196
|
+
def __str__(self):
|
197
|
+
ret = [f"CD({self.component_class.__name__}"]
|
198
|
+
if self.affects_all:
|
199
|
+
ret.append(",a*")
|
200
|
+
if self.depends_on_all:
|
201
|
+
ret.append(",d*")
|
202
|
+
if self.affects:
|
203
|
+
ret.append(f",a={{{', '.join(sorted(c.__name__ for c in self.affects))}}}")
|
204
|
+
if self.depends_on:
|
205
|
+
ret.append(f",d={{{', '.join(sorted(c.__name__ for c in self.depends_on))}}}")
|
206
|
+
ret.append(")")
|
207
|
+
return "".join(ret)
|
208
|
+
|
209
|
+
|
210
|
+
class ComponentsOrderingMixin(RecordService):
|
211
|
+
"""Order ``config.components`` while honoring declared relationships.
|
212
|
+
|
213
|
+
Component classes can declare two optional class attributes:
|
214
|
+
- ``depends_on``: a class or a list of classes that must appear before it;
|
215
|
+
- ``affects``: a class or a list of classes that must appear after it.
|
216
|
+
|
217
|
+
Both attributes may also be the wildcard ``"*"`` to express a relationship
|
218
|
+
with all other components. For example:
|
219
|
+
- if ``A.affects = "*"`` and ``B.affects = A``, then the order must be
|
220
|
+
``B, A, *`` (i.e., ``B`` comes before ``A``);
|
221
|
+
- if ``A.depends_on = "*"`` and ``B.depends_on = A``, then the order must be
|
222
|
+
``*, B, A`` (i.e., everything before ``B`` before ``A``).
|
223
|
+
|
224
|
+
The algorithm performs:
|
225
|
+
1) class deduplication (keep only one occurrence of each class),
|
226
|
+
2) inheritance deduplication (prefer the most specific subclass over its base),
|
227
|
+
3) stable topological sorting that preserves input order whenever possible.
|
228
|
+
"""
|
229
|
+
|
230
|
+
@cached_property
|
231
|
+
def component_classes(self) -> tuple[type[ServiceComponent], ...]:
|
232
|
+
"""Return the ordered component classes as an immutable tuple."""
|
233
|
+
return self._order_components(self.config.components)
|
234
|
+
|
235
|
+
@property
|
236
|
+
def components(self) -> Generator[ServiceComponent]:
|
237
|
+
"""Instantiate and yield components in the computed order."""
|
238
|
+
return (c(self) for c in self.component_classes)
|
239
|
+
|
240
|
+
def _order_components(
|
241
|
+
self,
|
242
|
+
components: Iterable[Any],
|
243
|
+
) -> tuple[Any, ...]:
|
244
|
+
"""Order components based on ``affects``/``depends_on`` semantics.
|
245
|
+
|
246
|
+
Splits components into three groups (``affects_all``, ``rest``,
|
247
|
+
``depends_on_all``), propagates transitive relationships into the edge
|
248
|
+
groups, topologically sorts each group, and finally concatenates them
|
249
|
+
in that order. Returns a tuple of the original component entries.
|
250
|
+
"""
|
251
|
+
component_data = self._deduplicate_components(components)
|
252
|
+
|
253
|
+
affects_all = [x for x in component_data if x.affects_all]
|
254
|
+
depends_on_all = [x for x in component_data if x.depends_on_all]
|
255
|
+
rest = [x for x in component_data if not x.affects_all and not x.depends_on_all]
|
256
|
+
|
257
|
+
# if A is from affects_all
|
258
|
+
# * and A[depends_on=B], add B to the affects_all set
|
259
|
+
# * and B[affects=A], add B to the affects_all set
|
260
|
+
self._propagate_dependencies(affects_all, rest, lambda x: x.depends_on, lambda x: x.affects)
|
261
|
+
# if A is from depends_on_all
|
262
|
+
# * and A[affects=B], add B to the depends_on_all set
|
263
|
+
# * and B[depends_on=A], add B to the depends_on_all set
|
264
|
+
self._propagate_dependencies(depends_on_all, rest, lambda x: x.affects, lambda x: x.depends_on)
|
265
|
+
|
266
|
+
# now the affects_all, rest, depends_on_all are completed and can be sorted
|
267
|
+
affects_all = self._topo_sort(affects_all)
|
268
|
+
rest = self._topo_sort(rest)
|
269
|
+
depends_on_all = self._topo_sort(depends_on_all)
|
270
|
+
|
271
|
+
return tuple(x.original_component for x in chain(affects_all, rest, depends_on_all))
|
272
|
+
|
273
|
+
def _topo_sort(self, components: list[ComponentData]) -> list[ComponentData]:
|
274
|
+
"""Topologically sort by dependencies while preserving relative order.
|
275
|
+
|
276
|
+
Uses indegree counting with a min-heap keyed by the original index to
|
277
|
+
prefer earlier items when multiple nodes become available. Detects and
|
278
|
+
raises a ``ValueError`` with the cyclic subgraph if a cycle exists.
|
279
|
+
"""
|
280
|
+
if not components or len(components) == 1:
|
281
|
+
return components
|
282
|
+
|
283
|
+
for idx, comp in enumerate(components):
|
284
|
+
comp.idx = idx # set the index for later use
|
285
|
+
|
286
|
+
graph = self._create_topo_graph(components)
|
287
|
+
|
288
|
+
# graph gives a mapping of each component to its dependencies, we need to build
|
289
|
+
# an inverse as well
|
290
|
+
inverse_graph = defaultdict(set)
|
291
|
+
for comp, deps in graph.items():
|
292
|
+
comp.indeg = len(deps) # set the indegree
|
293
|
+
for dep in deps:
|
294
|
+
inverse_graph[dep].add(comp)
|
295
|
+
|
296
|
+
# create a queue of all nodes that have no dependencies on other nodes
|
297
|
+
heap = [component.idx for component in components if component.indeg == 0]
|
298
|
+
heapq.heapify(heap)
|
299
|
+
|
300
|
+
ordered: list[ComponentData] = []
|
301
|
+
while heap:
|
302
|
+
# take the top of the heap and take the associated component and add it to
|
303
|
+
# the output sequence
|
304
|
+
idx = heapq.heappop(heap)
|
305
|
+
component = components[idx]
|
306
|
+
ordered.append(component)
|
307
|
+
|
308
|
+
# for each of the items that depend directly on this one, decrease the indeg
|
309
|
+
# of the item. If it reaches zero, add it to the heap. This will reorder
|
310
|
+
# the heap in a way that the next heappop will return the item with the
|
311
|
+
# lowest index (thus handling B->C, C, D will be returned in C, B, D rather
|
312
|
+
# than C, D, B if only indeg would be used).
|
313
|
+
for v in inverse_graph[component]:
|
314
|
+
v.indeg -= 1
|
315
|
+
if v.indeg == 0:
|
316
|
+
heapq.heappush(heap, v.idx)
|
317
|
+
|
318
|
+
if len(ordered) != len(components):
|
319
|
+
# get a list of components that form a cycle
|
320
|
+
cycle_forming_components = {cd for cd in components if cd.indeg > 0}
|
321
|
+
cycled_dependencies = {
|
322
|
+
comp: {dep for dep in deps if dep in cycle_forming_components}
|
323
|
+
for comp, deps in graph.items()
|
324
|
+
if comp in cycle_forming_components
|
325
|
+
}
|
326
|
+
raise ValueError(f"Cycle detected in dependencies: {cycled_dependencies}")
|
327
|
+
|
328
|
+
return ordered
|
329
|
+
|
330
|
+
def _create_topo_graph(self, components: list[ComponentData]) -> dict[ComponentData, set[ComponentData]]:
|
331
|
+
"""Build a dependency graph suitable for topological sorting.
|
332
|
+
|
333
|
+
The resulting mapping has nodes as keys and a set of their direct
|
334
|
+
prerequisites as values. Specifically:
|
335
|
+
- if ``A`` appears in ``B.depends_on``, then ``graph[B]`` contains ``A``;
|
336
|
+
- if ``A`` appears in ``B.affects``, then ``graph[A]`` contains ``B``.
|
337
|
+
"""
|
338
|
+
graph: dict[ComponentData, set[ComponentData]] = {}
|
339
|
+
for comp in components:
|
340
|
+
graph[comp] = set()
|
341
|
+
|
342
|
+
for comp in components:
|
343
|
+
for dep in comp.depends_on:
|
344
|
+
for other in self._find_components(components, dep):
|
345
|
+
graph[comp].add(other)
|
346
|
+
for aff in comp.affects:
|
347
|
+
for other in self._find_components(components, aff):
|
348
|
+
graph[other].add(comp)
|
349
|
+
return graph
|
350
|
+
|
351
|
+
def _find_components(self, components: list[ComponentData], cls: type) -> list[ComponentData]:
|
352
|
+
"""Return components whose ``component_mro`` includes the given class."""
|
353
|
+
return [comp for comp in components if cls in comp.component_mro]
|
354
|
+
|
355
|
+
def _propagate_dependencies(
|
356
|
+
self,
|
357
|
+
selected: list[ComponentData],
|
358
|
+
potential_dependencies: list[ComponentData],
|
359
|
+
selected_dependency_getter: Callable[[ComponentData], set[type[ServiceComponent]]],
|
360
|
+
potential_dependency_getter: Callable[[ComponentData], set[type[ServiceComponent]]],
|
361
|
+
) -> None:
|
362
|
+
"""Enrich the edge groups with items they require or that require them.
|
363
|
+
|
364
|
+
- If any item in ``selected`` depends on an item in ``potential_dependencies``
|
365
|
+
(via ``selected_dependency_getter``), move that dependency into
|
366
|
+
``selected``.
|
367
|
+
- If any item in ``potential_dependencies`` declares it must be together
|
368
|
+
with something in ``selected`` (via ``potential_dependency_getter``),
|
369
|
+
move it into ``selected`` as well.
|
370
|
+
|
371
|
+
Both ``selected`` and ``potential_dependencies`` are modified in place,
|
372
|
+
and propagation continues until a fixed point is reached (transitive closure).
|
373
|
+
"""
|
374
|
+
modified = True
|
375
|
+
already_checked_selected: set[ComponentData] = set()
|
376
|
+
while modified:
|
377
|
+
modified = False
|
378
|
+
|
379
|
+
moved_indices = sorted(
|
380
|
+
self._get_dependencies_from_selected(
|
381
|
+
list(set(selected) - already_checked_selected),
|
382
|
+
potential_dependencies,
|
383
|
+
selected_dependency_getter,
|
384
|
+
)
|
385
|
+
| self._get_dependencies_from_potentials(selected, potential_dependencies, potential_dependency_getter)
|
386
|
+
)
|
387
|
+
already_checked_selected.update(selected)
|
388
|
+
if moved_indices:
|
389
|
+
modified = True # do another round for transitive dependencies
|
390
|
+
selected.extend(potential_dependencies[idx] for idx in moved_indices)
|
391
|
+
for idx in reversed(moved_indices):
|
392
|
+
del potential_dependencies[idx]
|
393
|
+
|
394
|
+
def _get_dependencies_from_selected(
|
395
|
+
self,
|
396
|
+
selected: list[ComponentData],
|
397
|
+
potential_dependencies: list[ComponentData],
|
398
|
+
selected_dependency_getter: Callable[[ComponentData], set[type[ServiceComponent]]],
|
399
|
+
) -> set[int]:
|
400
|
+
"""Get indices of potential dependencies required by items in ``selected``.
|
401
|
+
|
402
|
+
Handles these cases (A in ``selected``, B in ``potential_dependencies``):
|
403
|
+
- A in ``affects_all`` and A.depends_on contains B -> move B to ``affects_all``;
|
404
|
+
- A in ``depends_on_all`` and A.affects contains B -> move B to ``depends_on_all``.
|
405
|
+
"""
|
406
|
+
additional_selected_items = set[int]()
|
407
|
+
|
408
|
+
for s in selected:
|
409
|
+
additional_selected_from_s = selected_dependency_getter(s)
|
410
|
+
if not additional_selected_from_s:
|
411
|
+
continue
|
412
|
+
for idx, dep in enumerate(potential_dependencies):
|
413
|
+
if additional_selected_from_s & dep.component_mro:
|
414
|
+
# the dependency matches, so should be in selected
|
415
|
+
additional_selected_items.add(idx)
|
416
|
+
|
417
|
+
return additional_selected_items
|
418
|
+
|
419
|
+
def _get_dependencies_from_potentials(
|
420
|
+
self,
|
421
|
+
selected: list[ComponentData],
|
422
|
+
potential_dependencies: list[ComponentData],
|
423
|
+
potential_dependency_getter: Callable[[ComponentData], set[type[ServiceComponent]]],
|
424
|
+
) -> set[int]:
|
425
|
+
"""Get indices of potentials that explicitly belong to the edge group.
|
426
|
+
|
427
|
+
Handles these cases (A in ``selected``, B in ``potential_dependencies``):
|
428
|
+
- A in ``affects_all`` and B.affects contains A -> move B to ``affects_all``;
|
429
|
+
- A in ``depends_on_all`` and B.depends_on contains A -> move B to ``depends_on_all``.
|
430
|
+
"""
|
431
|
+
additional_selected_items = set[int]()
|
432
|
+
|
433
|
+
for idx, p in enumerate(potential_dependencies):
|
434
|
+
p_should_be_with = potential_dependency_getter(p)
|
435
|
+
if not p_should_be_with:
|
436
|
+
continue
|
437
|
+
for s in selected:
|
438
|
+
if p_should_be_with & s.component_mro:
|
439
|
+
# the dependency matches, so should be in selected
|
440
|
+
additional_selected_items.add(idx)
|
441
|
+
|
442
|
+
return additional_selected_items
|
443
|
+
|
444
|
+
def _deduplicate_components(
|
445
|
+
self,
|
446
|
+
components: Iterable[Any],
|
447
|
+
) -> list[ComponentData]:
|
448
|
+
"""Build normalized component data and deduplicate by class/inheritance.
|
449
|
+
|
450
|
+
Rules:
|
451
|
+
- keep only one occurrence of a class (class deduplication);
|
452
|
+
- if both a base class and its subclass appear, keep the most specific
|
453
|
+
subclass and drop the base (inheritance deduplication);
|
454
|
+
- otherwise preserve the original input order.
|
455
|
+
"""
|
456
|
+
data: list[ComponentData] = []
|
457
|
+
|
458
|
+
for candidate_component in components:
|
459
|
+
cd = ComponentData(original_component=candidate_component, service=self)
|
460
|
+
|
461
|
+
replaced_indices = []
|
462
|
+
skipped = False
|
463
|
+
for idx, component in enumerate(data):
|
464
|
+
deduplication_action = self._deduplication_action(cd, component)
|
465
|
+
match deduplication_action:
|
466
|
+
case "skip":
|
467
|
+
skipped = True
|
468
|
+
case "replace":
|
469
|
+
replaced_indices.append(idx)
|
470
|
+
case "ok":
|
471
|
+
pass
|
472
|
+
|
473
|
+
if skipped:
|
474
|
+
if replaced_indices:
|
475
|
+
# we have replaced indices and at the same time, the replacement
|
476
|
+
# should be skipped, so just remove the components at those indices
|
477
|
+
# probably never happens, just for sure
|
478
|
+
self._remove_indices_from_data(data, replaced_indices)
|
479
|
+
continue
|
480
|
+
|
481
|
+
if replaced_indices:
|
482
|
+
# we have replaced indices. Remove all but the first index
|
483
|
+
self._remove_indices_from_data(data, replaced_indices[1:])
|
484
|
+
# and replace the first index with this component
|
485
|
+
data[replaced_indices[0]] = cd
|
486
|
+
else:
|
487
|
+
# if no replaced indices, append the new component to the end of the list
|
488
|
+
data.append(cd)
|
489
|
+
|
490
|
+
return data
|
491
|
+
|
492
|
+
def _deduplication_action(
|
493
|
+
self, new_component: ComponentData, existing_component: ComponentData
|
494
|
+
) -> Literal["skip", "replace", "ok"]:
|
495
|
+
"""Get a deduplication action for the given components.
|
496
|
+
|
497
|
+
:param new_component the component that is being added to the list of components
|
498
|
+
:param existing_component the component that is already in the list of components
|
499
|
+
|
500
|
+
:return: the deduplication action to take
|
501
|
+
skip: do not add the new_component to the list of components
|
502
|
+
replace: replace the existing_component with the new_component
|
503
|
+
ok: it is ok to add the new_component to the list of components as
|
504
|
+
it does not interfere with the existing component
|
505
|
+
"""
|
506
|
+
if new_component.component_class == existing_component.component_class:
|
507
|
+
# already inside the data, do not include it twice
|
508
|
+
return "skip"
|
509
|
+
if existing_component.component_class in new_component.replaced_by:
|
510
|
+
# already replaced by something in the data, do not include it
|
511
|
+
return "skip"
|
512
|
+
if existing_component.component_class in new_component.replaces:
|
513
|
+
# the class in data is replaced by this one
|
514
|
+
return "replace"
|
515
|
+
if new_component.component_class in existing_component.replaces:
|
516
|
+
# component says that it replaces me, so skip
|
517
|
+
return "skip"
|
518
|
+
if new_component.component_class in existing_component.replaced_by:
|
519
|
+
# component says it is replaced by myself
|
520
|
+
return "replace"
|
521
|
+
return "ok"
|
522
|
+
|
523
|
+
def _remove_indices_from_data(self, data: list[ComponentData], indices: list[int]) -> None:
|
524
|
+
"""Remove items at the given (sorted) indices from ``data`` in place."""
|
525
|
+
for idx in reversed(indices):
|
526
|
+
del data[idx]
|
@@ -1,8 +1,9 @@
|
|
1
|
-
oarepo_runtime/__init__.py,sha256=
|
1
|
+
oarepo_runtime/__init__.py,sha256=8-qYG9GY0t6VGGGvIq3LNHT9mdmdJ2XrdirOyJuLXLg,685
|
2
2
|
oarepo_runtime/api.py,sha256=P_y8b5AFES0ssv6x_GDg4MtD6TIfoNsOAVMMoiRM560,10330
|
3
3
|
oarepo_runtime/config.py,sha256=RUEPFn_5bKp9Wb0OY-Fb3VK30m35vF5IsLjYaQHhP3g,3838
|
4
4
|
oarepo_runtime/ext.py,sha256=AMb5pMnCSbqIpPyP99YUKlf9vopz_b2ZW-RnvfsEVlk,3254
|
5
5
|
oarepo_runtime/proxies.py,sha256=PXaRiBh5qs5-h8M81cJOgtqypFQcYUSjiSn2TLSujRw,648
|
6
|
+
oarepo_runtime/py.typed,sha256=RznSCjXReEUI9zkmD25E8XniG_MvPpLBF6MyNZA8MmE,42
|
6
7
|
oarepo_runtime/cli/__init__.py,sha256=iPs1a4blP7750rwEXobzK3YHgsfGxoVTZxwWMslAlTY,350
|
7
8
|
oarepo_runtime/cli/search.py,sha256=yqYHZauXsDBPpN4odYsPOWNQ9xWmAofQ407EAyqx6CY,1137
|
8
9
|
oarepo_runtime/records/__init__.py,sha256=AbWzmVCY7MhrpdEeI0e3lKzeugPMUSo8T08-NBVeig4,339
|
@@ -12,9 +13,12 @@ oarepo_runtime/records/pid_providers.py,sha256=pVXVeYmAsXy-IEdM2zHZ7UWkAnzXg1gts
|
|
12
13
|
oarepo_runtime/records/systemfields/__init__.py,sha256=g-u408qyNnsbUTpDtVVwlcyiJaO68GTjDN0W9rXs9pk,524
|
13
14
|
oarepo_runtime/records/systemfields/mapping.py,sha256=66OQavKewJEUMkghymOxvskIO0LUSP2E-MbHryeT5Nk,1968
|
14
15
|
oarepo_runtime/records/systemfields/publication_status.py,sha256=1g3VXNPh0FsiPCpe-7ZuaMEF4x8ffrDrt37Rqnjp0ng,2027
|
16
|
+
oarepo_runtime/resources/__init__.py,sha256=voynQULXoOEviADkbOpekMphZPTAz4IOTg5BF9xPwTM,453
|
17
|
+
oarepo_runtime/resources/config.py,sha256=hJewyZ2FlEm4TtYnQS9JsnKnA6hhtSbvo1PC24-7f7Y,980
|
15
18
|
oarepo_runtime/services/__init__.py,sha256=OGtBgEeaDTyk2RPDNXuKbU9_7egFBZr42SM0gN5FrF4,341
|
16
19
|
oarepo_runtime/services/results.py,sha256=fk-Enx_LwZLbw81yZ7CXVTku86vd3_fjprnb8l5sFHk,6657
|
17
20
|
oarepo_runtime/services/config/__init__.py,sha256=SX1kfIGk8HkohdLQrNpRQUTltksEyDcCa-kFXxrX4e8,711
|
21
|
+
oarepo_runtime/services/config/components.py,sha256=t6zPWcwsL4d_U4PelmHQ50ymDAY_N4YcgVnM6aklktY,23038
|
18
22
|
oarepo_runtime/services/config/link_conditions.py,sha256=raqf4yaBNLqNYgBxVNblo8MRJneVIFkwVNW7IW3AVYI,4309
|
19
23
|
oarepo_runtime/services/config/permissions.py,sha256=x5k61LGnpXyJfXVoCTq2tTVTtPckmBcBtcBJx4UN9EA,3056
|
20
24
|
oarepo_runtime/services/facets/__init__.py,sha256=k39ZYt1dMVOW01QRSTgx3CfuTYwvEWmL0VYTR3huVsE,349
|
@@ -25,8 +29,8 @@ oarepo_runtime/services/records/mapping.py,sha256=y3oeToKEnaRYpMV3q2-2cXNzyzyL3X
|
|
25
29
|
oarepo_runtime/services/schema/__init__.py,sha256=jgAPI_uKC6Ug4KQWnwQVg3-aNaw-eHja323AUFo5ELo,351
|
26
30
|
oarepo_runtime/services/schema/i18n.py,sha256=9D1zOQaPKAnYzejB0vO-m2BJYnam0N0Lrq4jID7twfE,3174
|
27
31
|
oarepo_runtime/services/schema/i18n_ui.py,sha256=DbusphhGDeaobTt4nuwNgKZ6Houlu4Sv3SuMGkdjRRY,3582
|
28
|
-
oarepo_runtime-2.0.0.
|
29
|
-
oarepo_runtime-2.0.0.
|
30
|
-
oarepo_runtime-2.0.0.
|
31
|
-
oarepo_runtime-2.0.0.
|
32
|
-
oarepo_runtime-2.0.0.
|
32
|
+
oarepo_runtime-2.0.0.dev8.dist-info/METADATA,sha256=zCWr2-vXfnWqXgRSmP9PGOBskYE2w8jjd122yxlkNok,4494
|
33
|
+
oarepo_runtime-2.0.0.dev8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
34
|
+
oarepo_runtime-2.0.0.dev8.dist-info/entry_points.txt,sha256=7HqK5jumIgDVJa7ifjPKizginfIm5_R_qUVKPf_Yq-c,145
|
35
|
+
oarepo_runtime-2.0.0.dev8.dist-info/licenses/LICENSE,sha256=h2uWz0OaB3EN-J1ImdGJZzc7yvfQjvHVYdUhQ-H7ypY,1064
|
36
|
+
oarepo_runtime-2.0.0.dev8.dist-info/RECORD,,
|
File without changes
|
{oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/entry_points.txt
RENAMED
File without changes
|
{oarepo_runtime-2.0.0.dev6.dist-info → oarepo_runtime-2.0.0.dev8.dist-info}/licenses/LICENSE
RENAMED
File without changes
|