muscles-data 0.2.2__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {muscles_data-0.2.2 → muscles_data-0.2.3}/PKG-INFO +1 -1
- {muscles_data-0.2.2 → muscles_data-0.2.3}/pyproject.toml +1 -1
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/__init__.py +9 -0
- muscles_data-0.2.3/src/muscles/data/object_lifecycle.py +41 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/policies.py +32 -1
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/policy_search.py +8 -2
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/runtime.py +32 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/PKG-INFO +1 -1
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/SOURCES.txt +1 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_data_runtime.py +14 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_policies.py +25 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_policy_search.py +17 -1
- {muscles_data-0.2.2 → muscles_data-0.2.3}/README.md +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/setup.cfg +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/actions.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/adapters/__init__.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/adapters/memory.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/catalog.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/config.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/contracts.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/errors.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/migration_identity.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/migrations.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/models.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/operation_journal.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/package.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/ports.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/versioned_migrations.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/dependency_links.txt +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/requires.txt +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/top_level.txt +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_contracts.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_event_contracts.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_migrations.py +0 -0
- {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_operation_journal.py +0 -0
|
@@ -29,7 +29,9 @@ if TYPE_CHECKING:
|
|
|
29
29
|
DataPolicyPipeline,
|
|
30
30
|
DataPredicate,
|
|
31
31
|
DataPredicateOperator,
|
|
32
|
+
DataRelatedExists,
|
|
32
33
|
)
|
|
34
|
+
from .object_lifecycle import ObjectLifecycle, ObjectLifecycleEvent
|
|
33
35
|
from .ports import (
|
|
34
36
|
DocumentStorePort,
|
|
35
37
|
EventConsumerPort,
|
|
@@ -78,6 +80,9 @@ __all__ = [
|
|
|
78
80
|
"SqliteOperationJournal",
|
|
79
81
|
"DataPredicate",
|
|
80
82
|
"DataPredicateOperator",
|
|
83
|
+
"DataRelatedExists",
|
|
84
|
+
"ObjectLifecycle",
|
|
85
|
+
"ObjectLifecycleEvent",
|
|
81
86
|
"DataResourceConfig",
|
|
82
87
|
"DataResourceHandle",
|
|
83
88
|
"DataRuntime",
|
|
@@ -155,9 +160,13 @@ def __getattr__(name: str):
|
|
|
155
160
|
"DataPolicyPipeline",
|
|
156
161
|
"DataPredicate",
|
|
157
162
|
"DataPredicateOperator",
|
|
163
|
+
"DataRelatedExists",
|
|
158
164
|
}:
|
|
159
165
|
from . import policies
|
|
160
166
|
return getattr(policies, name)
|
|
167
|
+
if name in {"ObjectLifecycle", "ObjectLifecycleEvent"}:
|
|
168
|
+
from . import object_lifecycle
|
|
169
|
+
return getattr(object_lifecycle, name)
|
|
161
170
|
if name == "DataResourceConfig":
|
|
162
171
|
from .models import DataResourceConfig
|
|
163
172
|
return DataResourceConfig
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Transaction-bound notifications for package-owned objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Callable, Literal
|
|
7
|
+
|
|
8
|
+
from .policies import DataOperationContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ObjectLifecycleEvent:
|
|
13
|
+
action: Literal["created", "deleted"]
|
|
14
|
+
object_type: str
|
|
15
|
+
object_uid: str
|
|
16
|
+
context: DataOperationContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ObjectLifecycle:
|
|
20
|
+
"""Dispatch handlers synchronously inside the caller's transaction.
|
|
21
|
+
|
|
22
|
+
Handlers receive the transaction used for the object write. Exceptions
|
|
23
|
+
propagate to the caller so that the transaction can be rolled back.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._handlers: list[Callable] = []
|
|
28
|
+
|
|
29
|
+
def register(self, handler: Callable) -> None:
|
|
30
|
+
if not callable(handler):
|
|
31
|
+
raise TypeError("Lifecycle handler must be callable")
|
|
32
|
+
if handler not in self._handlers:
|
|
33
|
+
self._handlers.append(handler)
|
|
34
|
+
|
|
35
|
+
def emit(self, transaction, event: ObjectLifecycleEvent) -> None:
|
|
36
|
+
if event.action not in ("created", "deleted"):
|
|
37
|
+
raise ValueError("Unsupported object lifecycle action")
|
|
38
|
+
if not event.object_type or not event.object_uid:
|
|
39
|
+
raise ValueError("Object type and identifier are required")
|
|
40
|
+
for handler in tuple(self._handlers):
|
|
41
|
+
handler(transaction, event)
|
|
@@ -36,6 +36,25 @@ class DataPredicateOperator(str, Enum):
|
|
|
36
36
|
IN = "in"
|
|
37
37
|
LIKE = "like"
|
|
38
38
|
IS_NULL = "is_null"
|
|
39
|
+
RELATED_EXISTS = "related_exists"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DataRelatedExists:
|
|
44
|
+
"""Match a related table row against a field of the target object."""
|
|
45
|
+
|
|
46
|
+
table: str
|
|
47
|
+
related_field: str
|
|
48
|
+
filters: Mapping[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
if not isinstance(self.table, str) or not self.table:
|
|
52
|
+
raise ValueError("Related table name must be a nonempty string")
|
|
53
|
+
if not isinstance(self.related_field, str) or not self.related_field:
|
|
54
|
+
raise ValueError("Related field name must be a nonempty string")
|
|
55
|
+
if not isinstance(self.filters, Mapping) or any(not isinstance(key, str) or not key for key in self.filters):
|
|
56
|
+
raise ValueError("Related filters must have nonempty string field names")
|
|
57
|
+
object.__setattr__(self, "filters", MappingProxyType(dict(self.filters)))
|
|
39
58
|
|
|
40
59
|
|
|
41
60
|
def like_regex_body(pattern: str) -> str:
|
|
@@ -118,6 +137,7 @@ class DataOperationRequest:
|
|
|
118
137
|
context: DataOperationContext = field(default_factory=DataOperationContext)
|
|
119
138
|
filters: tuple[DataPredicate, ...] = ()
|
|
120
139
|
values: Mapping[str, Any] = field(default_factory=dict)
|
|
140
|
+
object_type: str | None = None
|
|
121
141
|
|
|
122
142
|
def __post_init__(self) -> None:
|
|
123
143
|
object.__setattr__(self, "filters", tuple(self.filters))
|
|
@@ -131,6 +151,7 @@ class DataPolicyDecision:
|
|
|
131
151
|
allowed: bool = True
|
|
132
152
|
predicates: tuple[DataPredicate, ...] = ()
|
|
133
153
|
reason: str | None = None
|
|
154
|
+
replaces_owner_scope: bool = False
|
|
134
155
|
|
|
135
156
|
|
|
136
157
|
class DataOperationPolicy(Protocol):
|
|
@@ -166,13 +187,20 @@ class DataPolicyPipeline:
|
|
|
166
187
|
self._policies.append(policy)
|
|
167
188
|
|
|
168
189
|
def evaluate(self, operation: DataOperationRequest) -> tuple[DataPredicate, ...]:
|
|
190
|
+
return self.evaluate_decision(operation).predicates
|
|
191
|
+
|
|
192
|
+
def evaluate_decision(self, operation: DataOperationRequest) -> DataPolicyDecision:
|
|
193
|
+
"""Combine mandatory predicates and explicit owner-scope authority."""
|
|
169
194
|
predicates: list[DataPredicate] = []
|
|
195
|
+
replaces_owner_scope = False
|
|
170
196
|
for policy in self._policies:
|
|
171
197
|
decision = policy.evaluate(operation)
|
|
172
198
|
if not isinstance(decision, DataPolicyDecision):
|
|
173
199
|
raise TypeError("Data policies must return DataPolicyDecision")
|
|
174
200
|
if not isinstance(decision.allowed, bool):
|
|
175
201
|
raise TypeError("DataPolicyDecision.allowed must be a bool")
|
|
202
|
+
if not isinstance(decision.replaces_owner_scope, bool):
|
|
203
|
+
raise TypeError("DataPolicyDecision.replaces_owner_scope must be a bool")
|
|
176
204
|
if not decision.allowed:
|
|
177
205
|
raise DataPolicyDeniedError(
|
|
178
206
|
decision.reason or f"Data operation '{operation.kind.value}' was denied"
|
|
@@ -181,7 +209,10 @@ class DataPolicyPipeline:
|
|
|
181
209
|
if not isinstance(predicate, DataPredicate):
|
|
182
210
|
raise TypeError("Policy predicates must be DataPredicate instances")
|
|
183
211
|
predicates.append(predicate)
|
|
184
|
-
|
|
212
|
+
if decision.replaces_owner_scope and not decision.predicates:
|
|
213
|
+
raise ValueError("Owner scope replacement requires a policy constraint")
|
|
214
|
+
replaces_owner_scope = replaces_owner_scope or decision.replaces_owner_scope
|
|
215
|
+
return DataPolicyDecision(predicates=tuple(predicates), replaces_owner_scope=replaces_owner_scope)
|
|
185
216
|
|
|
186
217
|
def explain(self, operation: DataOperationRequest) -> dict[str, Any]:
|
|
187
218
|
"""Summarize effective constraints without exposing predicate values."""
|
|
@@ -54,6 +54,8 @@ def _matches(values, predicates):
|
|
|
54
54
|
if not isinstance(values, Mapping):
|
|
55
55
|
return False
|
|
56
56
|
for item in predicates:
|
|
57
|
+
if item.operator is DataPredicateOperator.RELATED_EXISTS:
|
|
58
|
+
raise DataUnsupportedOperationError("Related-record conditions are not supported by search adapters")
|
|
57
59
|
if item.field not in values:
|
|
58
60
|
return False
|
|
59
61
|
actual = values.get(item.field)
|
|
@@ -85,17 +87,21 @@ def _matches(values, predicates):
|
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
class _PolicySearch:
|
|
88
|
-
def __init__(self, adapter, target: str, *, policies, context=None, max_scan=10000):
|
|
90
|
+
def __init__(self, adapter, target: str, *, policies, context=None, max_scan=10000, object_type=None):
|
|
89
91
|
if not isinstance(target, str) or not target:
|
|
90
92
|
raise ValueError("target must be a nonempty resource name")
|
|
91
93
|
self.adapter, self.target, self.policies = adapter, target, policies
|
|
94
|
+
if object_type is not None and (not isinstance(object_type, str) or not object_type):
|
|
95
|
+
raise ValueError("object_type must be a nonempty string")
|
|
96
|
+
self.object_type = object_type
|
|
92
97
|
self.context = context or DataOperationContext()
|
|
93
98
|
if type(max_scan) is not int or max_scan <= 0:
|
|
94
99
|
raise ValueError("max_scan must be a positive integer")
|
|
95
100
|
self.max_scan = max_scan
|
|
96
101
|
|
|
97
102
|
def _evaluate(self, kind, filters=(), values=None):
|
|
98
|
-
return self.policies.evaluate(DataOperationRequest(kind, self.target, self.context, filters, values or {}
|
|
103
|
+
return self.policies.evaluate(DataOperationRequest(kind, self.target, self.context, filters, values or {},
|
|
104
|
+
object_type=self.object_type))
|
|
99
105
|
|
|
100
106
|
def _item_values(self, values):
|
|
101
107
|
payload = dict(values.get("payload") or {})
|
|
@@ -141,14 +141,46 @@ class DataRuntime:
|
|
|
141
141
|
policies: DataPolicyPipeline | None = None,
|
|
142
142
|
migrations: MigrationRegistry | None = None,
|
|
143
143
|
) -> None:
|
|
144
|
+
from .object_lifecycle import ObjectLifecycle
|
|
145
|
+
|
|
144
146
|
self.config = config
|
|
145
147
|
self.catalog = catalog or DataAdapterCatalog.with_defaults()
|
|
146
148
|
self.policies = policies or DataPolicyPipeline()
|
|
149
|
+
self.object_lifecycle = ObjectLifecycle()
|
|
150
|
+
self._object_types: dict[str, str] = {}
|
|
151
|
+
self._object_owner_fields: dict[str, str] = {}
|
|
147
152
|
self.migrations = migrations if migrations is not None else MigrationRegistry()
|
|
148
153
|
self._handles: dict[str, DataResourceHandle] = {}
|
|
149
154
|
self._lock = threading.RLock()
|
|
150
155
|
self._closed = False
|
|
151
156
|
|
|
157
|
+
def register_object_type(self, target: str, object_type: str, *, owner_field: str | None = None) -> None:
|
|
158
|
+
"""Declare a stable object type for a data target."""
|
|
159
|
+
if not isinstance(target, str) or not target or not isinstance(object_type, str) or not object_type:
|
|
160
|
+
raise ValueError("Target and object type must be nonempty strings")
|
|
161
|
+
if owner_field is not None and (not isinstance(owner_field, str) or not owner_field):
|
|
162
|
+
raise ValueError("Owner field must be a nonempty string")
|
|
163
|
+
with self._lock:
|
|
164
|
+
existing = self._object_types.get(target)
|
|
165
|
+
if existing is not None and existing != object_type:
|
|
166
|
+
raise ValueError("Data target already has a different object type")
|
|
167
|
+
existing_owner = self._object_owner_fields.get(target)
|
|
168
|
+
if owner_field is not None and existing_owner is not None and owner_field != existing_owner:
|
|
169
|
+
raise ValueError("Data target already has a different owner field")
|
|
170
|
+
self._object_types[target] = object_type
|
|
171
|
+
if owner_field is not None:
|
|
172
|
+
self._object_owner_fields[target] = owner_field
|
|
173
|
+
|
|
174
|
+
def object_type_for_target(self, target: str) -> str | None:
|
|
175
|
+
with self._lock:
|
|
176
|
+
return self._object_types.get(target)
|
|
177
|
+
|
|
178
|
+
def registered_object_types(self) -> tuple[tuple[str, str, str | None], ...]:
|
|
179
|
+
"""Describe declared targets for composition and data migration."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
return tuple((target, kind, self._object_owner_fields.get(target))
|
|
182
|
+
for target, kind in sorted(self._object_types.items()))
|
|
183
|
+
|
|
152
184
|
def list_resources(self) -> list[dict[str, Any]]:
|
|
153
185
|
return [
|
|
154
186
|
{
|
|
@@ -9,6 +9,7 @@ src/muscles/data/errors.py
|
|
|
9
9
|
src/muscles/data/migration_identity.py
|
|
10
10
|
src/muscles/data/migrations.py
|
|
11
11
|
src/muscles/data/models.py
|
|
12
|
+
src/muscles/data/object_lifecycle.py
|
|
12
13
|
src/muscles/data/operation_journal.py
|
|
13
14
|
src/muscles/data/package.py
|
|
14
15
|
src/muscles/data/policies.py
|
|
@@ -429,3 +429,17 @@ def test_package_public_exports():
|
|
|
429
429
|
assert md.StreamPort
|
|
430
430
|
assert md.SqlResourcePort
|
|
431
431
|
assert DataPackage.namespace == "data"
|
|
432
|
+
def test_registered_object_type_is_stable_and_idempotent():
|
|
433
|
+
from muscles.data.config import DataConfig
|
|
434
|
+
from muscles.data.runtime import DataRuntime
|
|
435
|
+
import pytest
|
|
436
|
+
|
|
437
|
+
runtime = DataRuntime(config=DataConfig.from_raw({"resources": {}}))
|
|
438
|
+
runtime.register_object_type("contacts_contacts", "contacts:contact")
|
|
439
|
+
runtime.register_object_type("contacts_contacts", "contacts:contact")
|
|
440
|
+
assert runtime.object_type_for_target("contacts_contacts") == "contacts:contact"
|
|
441
|
+
with pytest.raises(ValueError, match="different object type"):
|
|
442
|
+
runtime.register_object_type("contacts_contacts", "other:object")
|
|
443
|
+
assert runtime.object_type_for_target("contacts_contacts") == "contacts:contact"
|
|
444
|
+
runtime.close()
|
|
445
|
+
|
|
@@ -61,6 +61,31 @@ def test_pipeline_rejects_invalid_policy_decisions_fail_closed():
|
|
|
61
61
|
DataPolicyPipeline((Policy(decision),)).evaluate(operation)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def test_explicit_ownership_scope_requires_a_constraint_and_preserves_other_policies():
|
|
65
|
+
class Policy:
|
|
66
|
+
def __init__(self, decision):
|
|
67
|
+
self.decision = decision
|
|
68
|
+
def evaluate(self, operation):
|
|
69
|
+
return self.decision
|
|
70
|
+
|
|
71
|
+
operation = DataOperationRequest(DataOperationKind.READ, "items")
|
|
72
|
+
constraint = DataPredicate("team", DataPredicateOperator.EQ, "one")
|
|
73
|
+
pipeline = DataPolicyPipeline((Policy(DataPolicyDecision(predicates=(constraint,), replaces_owner_scope=True)),
|
|
74
|
+
Policy(DataPolicyDecision(predicates=(DataPredicate("active", DataPredicateOperator.EQ, True),)))))
|
|
75
|
+
decision = pipeline.evaluate_decision(operation)
|
|
76
|
+
assert decision.replaces_owner_scope is True
|
|
77
|
+
assert len(decision.predicates) == 2
|
|
78
|
+
assert pipeline.evaluate(operation) == decision.predicates
|
|
79
|
+
with pytest.raises(ValueError, match="constraint"):
|
|
80
|
+
DataPolicyPipeline((Policy(DataPolicyDecision(replaces_owner_scope=True)),)).evaluate_decision(operation)
|
|
81
|
+
with pytest.raises(TypeError):
|
|
82
|
+
DataPolicyPipeline((Policy(DataPolicyDecision(replaces_owner_scope="yes", predicates=(constraint,))),)).evaluate_decision(operation)
|
|
83
|
+
assert not DataPolicyPipeline().evaluate_decision(operation).replaces_owner_scope
|
|
84
|
+
with pytest.raises(DataPolicyDeniedError):
|
|
85
|
+
DataPolicyPipeline((Policy(DataPolicyDecision(predicates=(constraint,), replaces_owner_scope=True)),
|
|
86
|
+
Policy(DataPolicyDecision(allowed=False)))).evaluate_decision(operation)
|
|
87
|
+
|
|
88
|
+
|
|
64
89
|
def test_non_atomic_policy_mode_is_explicit_and_defaults_to_deny():
|
|
65
90
|
from muscles.data.errors import DataUnsupportedOperationError
|
|
66
91
|
from muscles.data.policies import DataNonAtomicPolicyMode
|
|
@@ -2,7 +2,7 @@ import pytest
|
|
|
2
2
|
|
|
3
3
|
from muscles.data.errors import DataBatchWriteError, DataPolicyDeniedError, DataUnsupportedOperationError, DataWriteConflictError
|
|
4
4
|
from muscles.data.models import SearchHit, VectorHit, WriteResult
|
|
5
|
-
from muscles.data.policies import DataNonAtomicPolicyMode, DataOperationContext, DataPolicyDecision, DataPolicyPipeline, DataPredicate, DataPredicateOperator
|
|
5
|
+
from muscles.data.policies import DataNonAtomicPolicyMode, DataOperationContext, DataPolicyDecision, DataPolicyPipeline, DataPredicate, DataPredicateOperator, DataRelatedExists
|
|
6
6
|
from muscles.data.policy_search import PolicySearchIndex, PolicyVectorSearch
|
|
7
7
|
|
|
8
8
|
|
|
@@ -43,6 +43,22 @@ def test_search_applies_policy_to_backend_and_rejects_array_results():
|
|
|
43
43
|
assert backend.calls[0] == ("text", {"$and": [{"kind": "note"}, {"workspace": "a"}]}, 2)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def test_scoped_search_passes_object_type_and_rejects_unsupported_related_policy():
|
|
47
|
+
backend = Backend()
|
|
48
|
+
seen = []
|
|
49
|
+
class RelatedPolicy:
|
|
50
|
+
def evaluate(self, request):
|
|
51
|
+
seen.append(request.object_type)
|
|
52
|
+
return DataPolicyDecision(predicates=(DataPredicate("uid", DataPredicateOperator.RELATED_EXISTS,
|
|
53
|
+
DataRelatedExists("links", "object_uid", {"workspace_uid": "a"})),))
|
|
54
|
+
store = PolicySearchIndex(backend, "articles", policies=DataPolicyPipeline((RelatedPolicy(),)),
|
|
55
|
+
object_type="example:article")
|
|
56
|
+
with pytest.raises(DataUnsupportedOperationError):
|
|
57
|
+
store.search_text("hello")
|
|
58
|
+
assert seen == ["example:article"]
|
|
59
|
+
assert backend.calls == []
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
def test_policy_search_uses_and_closes_snapshot_after_filling_results():
|
|
47
63
|
class SnapshotBackend(Backend):
|
|
48
64
|
def policy_search_snapshot(self, query, filters, batch_size, options):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|