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.
Files changed (35) hide show
  1. {muscles_data-0.2.2 → muscles_data-0.2.3}/PKG-INFO +1 -1
  2. {muscles_data-0.2.2 → muscles_data-0.2.3}/pyproject.toml +1 -1
  3. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/__init__.py +9 -0
  4. muscles_data-0.2.3/src/muscles/data/object_lifecycle.py +41 -0
  5. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/policies.py +32 -1
  6. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/policy_search.py +8 -2
  7. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/runtime.py +32 -0
  8. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/PKG-INFO +1 -1
  9. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/SOURCES.txt +1 -0
  10. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_data_runtime.py +14 -0
  11. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_policies.py +25 -0
  12. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_policy_search.py +17 -1
  13. {muscles_data-0.2.2 → muscles_data-0.2.3}/README.md +0 -0
  14. {muscles_data-0.2.2 → muscles_data-0.2.3}/setup.cfg +0 -0
  15. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/actions.py +0 -0
  16. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/adapters/__init__.py +0 -0
  17. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/adapters/memory.py +0 -0
  18. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/catalog.py +0 -0
  19. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/config.py +0 -0
  20. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/contracts.py +0 -0
  21. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/errors.py +0 -0
  22. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/migration_identity.py +0 -0
  23. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/migrations.py +0 -0
  24. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/models.py +0 -0
  25. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/operation_journal.py +0 -0
  26. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/package.py +0 -0
  27. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/ports.py +0 -0
  28. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles/data/versioned_migrations.py +0 -0
  29. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/dependency_links.txt +0 -0
  30. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/requires.txt +0 -0
  31. {muscles_data-0.2.2 → muscles_data-0.2.3}/src/muscles_data.egg-info/top_level.txt +0 -0
  32. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_contracts.py +0 -0
  33. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_event_contracts.py +0 -0
  34. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_migrations.py +0 -0
  35. {muscles_data-0.2.2 → muscles_data-0.2.3}/tests/test_operation_journal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muscles-data
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Framework-level named data resources and typed ports for Muscles
5
5
  Author-email: "Denis B." <denis@butko.info>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "muscles-data"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Framework-level named data resources and typed ports for Muscles"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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
- return tuple(predicates)
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
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muscles-data
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Framework-level named data resources and typed ports for Muscles
5
5
  Author-email: "Denis B." <denis@butko.info>
6
6
  License: MIT
@@ -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