framework-m 0.7.3__tar.gz → 0.9.0__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 (42) hide show
  1. {framework_m-0.7.3 → framework_m-0.9.0}/CHANGELOG.md +25 -0
  2. {framework_m-0.7.3 → framework_m-0.9.0}/PKG-INFO +3 -3
  3. {framework_m-0.7.3 → framework_m-0.9.0}/pyproject.toml +7 -4
  4. {framework_m-0.7.3 → framework_m-0.9.0}/tests/conftest.py +1 -1
  5. {framework_m-0.7.3 → framework_m-0.9.0}/tests/core/test_correction_service.py +98 -1
  6. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_migration_flow.py +2 -2
  7. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_workflow_lifecycle.py +31 -19
  8. framework_m-0.7.3/docs/ports.md +0 -414
  9. {framework_m-0.7.3 → framework_m-0.9.0}/.gitignore +0 -0
  10. {framework_m-0.7.3 → framework_m-0.9.0}/LICENSE +0 -0
  11. {framework_m-0.7.3 → framework_m-0.9.0}/README.md +0 -0
  12. {framework_m-0.7.3 → framework_m-0.9.0}/alembic/README +0 -0
  13. {framework_m-0.7.3 → framework_m-0.9.0}/alembic/env.py +0 -0
  14. {framework_m-0.7.3 → framework_m-0.9.0}/alembic/script.py.mako +0 -0
  15. {framework_m-0.7.3 → framework_m-0.9.0}/alembic.ini +0 -0
  16. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/__init__.py +0 -0
  17. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/cli/__init__.py +0 -0
  18. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/cli/main.py +0 -0
  19. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/core/services/__init__.py +0 -0
  20. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/core/services/correction.py +0 -0
  21. {framework_m-0.7.3 → framework_m-0.9.0}/src/framework_m/py.typed +0 -0
  22. {framework_m-0.7.3 → framework_m-0.9.0}/tests/adapters/auth/test_oauth_adapter.py +0 -0
  23. {framework_m-0.7.3 → framework_m-0.9.0}/tests/adapters/read_model/test_projector.py +0 -0
  24. {framework_m-0.7.3 → framework_m-0.9.0}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
  25. {framework_m-0.7.3 → framework_m-0.9.0}/tests/architecture/test_frontend_bootstrap.py +0 -0
  26. {framework_m-0.7.3 → framework_m-0.9.0}/tests/architecture/test_package_separation.py +0 -0
  27. {framework_m-0.7.3 → framework_m-0.9.0}/tests/core/interfaces/test_read_model.py +0 -0
  28. {framework_m-0.7.3 → framework_m-0.9.0}/tests/core/interfaces/test_report_engine.py +0 -0
  29. {framework_m-0.7.3 → framework_m-0.9.0}/tests/helpers/__init__.py +0 -0
  30. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/__init__.py +0 -0
  31. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_api_endpoints.py +0 -0
  32. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_app_ports.py +0 -0
  33. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_child_table_integration.py +0 -0
  34. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_crud_flow.py +0 -0
  35. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_jobs_events_webhooks.py +0 -0
  36. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_override_migration.py +0 -0
  37. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_postgres_flow.py +0 -0
  38. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_real_http_client.py +0 -0
  39. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_rfc_0008_audit.py +0 -0
  40. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_rfc_0008_correction.py +0 -0
  41. {framework_m-0.7.3 → framework_m-0.9.0}/tests/integration/test_rfc_0008_versioning.py +0 -0
  42. {framework_m-0.7.3 → framework_m-0.9.0}/tests/test_meta.py +0 -0
@@ -1,3 +1,28 @@
1
+ ## framework-m v0.9.0
2
+
3
+ ### Features
4
+
5
+ - add LagoMeteringAdapter (63a5a93)
6
+
7
+ ### Bug Fixes
8
+
9
+ - import error in NavigationShell.tsx (0aee781)
10
+ - Patched SDK plugin.ts and Cleaned up Redundant Types (a0fce75)
11
+ - format ContextualDrawer.tsx (7d178f3)
12
+ - linkfield and sidebar (b93f52a)
13
+ - unify dev mode resolution and frontend field routing (34df00c)
14
+
15
+ ## framework-m v0.8.0
16
+
17
+ ### Features
18
+
19
+ - pagination, tabbed layout and dynamic fields in desk (86b5e66)
20
+
21
+ ### Bug Fixes
22
+
23
+ - clarify use aiosqlite for db url in test (981c6c4)
24
+ - typecheck error (1e06770)
25
+
1
26
  **framework-m** v0.7.3: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.2: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.1: internal dependency bump due to **framework-m-studio** update## framework-m v0.7.0
2
27
 
3
28
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framework-m
3
- Version: 0.7.3
3
+ Version: 0.9.0
4
4
  Summary: A modular, metadata-driven business application framework
5
5
  Project-URL: Homepage, https://gitlab.com/castlecraft/framework-m
6
6
  Project-URL: Documentation, https://gitlab.com/castlecraft/framework-m#readme
@@ -18,8 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: framework-m-core>=0.13.0
22
- Requires-Dist: framework-m-standard>=0.10.0
21
+ Requires-Dist: framework-m-core>=0.15.0
22
+ Requires-Dist: framework-m-standard>=0.12.0
23
23
  Description-Content-Type: text/markdown
24
24
 
25
25
  # Framework M
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "framework-m"
3
- version = "0.7.3"
3
+ version = "0.9.0"
4
4
  description = "A modular, metadata-driven business application framework"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -21,8 +21,8 @@ classifiers = [
21
21
  ]
22
22
 
23
23
  dependencies = [
24
- "framework-m-core>=0.13.0",
25
- "framework-m-standard>=0.10.0",
24
+ "framework-m-core>=0.15.0",
25
+ "framework-m-standard>=0.12.0",
26
26
  ]
27
27
 
28
28
  [tool.uv.sources]
@@ -83,8 +83,11 @@ filterwarnings = [
83
83
  ]
84
84
 
85
85
  [tool.coverage.run]
86
- source = ["src/framework_m"]
87
86
  branch = true
87
+ omit = [
88
+ "*/tests/*",
89
+ "*conftest.py",
90
+ ]
88
91
 
89
92
  [tool.coverage.report]
90
93
  exclude_lines = [
@@ -101,7 +101,7 @@ def configured_container() -> Container:
101
101
  container = Container()
102
102
  container.config.from_dict(
103
103
  {
104
- "database_url": "sqlite:///:memory:",
104
+ "database_url": "sqlite+aiosqlite:///:memory:",
105
105
  "debug": True,
106
106
  "app_name": "TestApp",
107
107
  }
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
- from unittest.mock import AsyncMock
6
+ from unittest.mock import AsyncMock, patch
7
7
 
8
8
  import pytest
9
9
  from framework_m_core.domain.base_doctype import BaseDocType, Field
@@ -11,6 +11,7 @@ from framework_m_core.domain.mixins import DocStatus, SubmittableMixin, Versione
11
11
  from framework_m_core.interfaces.audit import AuditLogProtocol
12
12
  from framework_m_core.interfaces.auth_context import UserContext
13
13
  from framework_m_core.interfaces.repository import RepositoryProtocol
14
+ from pydantic import ConfigDict
14
15
 
15
16
  from framework_m.core.services.correction import CorrectionService
16
17
 
@@ -26,6 +27,17 @@ class VersionedInvoice(Invoice, VersionedMixin):
26
27
  """Versioned invoice doctype for in-place correction tests."""
27
28
 
28
29
 
30
+ class PlainDoc(BaseDocType):
31
+ """Plain doc without SubmittableMixin for edge cases."""
32
+
33
+ title: str = Field(default="Test")
34
+ model_config = ConfigDict(extra="ignore")
35
+
36
+
37
+ class PlainVersionedDoc(PlainDoc, VersionedMixin):
38
+ """Versioned plain doc for in-place edge cases."""
39
+
40
+
29
41
  @pytest.fixture
30
42
  def mock_repository() -> AsyncMock:
31
43
  """Repository mock implementing save semantics."""
@@ -239,6 +251,91 @@ class TestCorrectionServiceRestore:
239
251
  user=user_context,
240
252
  )
241
253
 
254
+
255
+ class TestCorrectionServiceEdgeCases:
256
+ """Coverage tests for defensive checks and edge cases."""
257
+
258
+ def test_next_amended_name_unmatchable_pattern(
259
+ self, service: CorrectionService
260
+ ) -> None:
261
+ """Cover branch where regex match is None."""
262
+ assert service._next_amended_name("INV-001-A") == "INV-001-A-1"
263
+
264
+ def test_ensure_submitted_without_docstatus(
265
+ self, service: CorrectionService
266
+ ) -> None:
267
+ """Cover branch where doc has no docstatus attribute."""
268
+ doc = PlainDoc(name="PLAIN-1")
269
+ with pytest.raises(
270
+ ValueError, match="Only submitted documents can be corrected"
271
+ ):
272
+ service._ensure_submitted(doc)
273
+
274
+ def test_clone_with_updates_keep_id_and_no_docstatus(
275
+ self, service: CorrectionService
276
+ ) -> None:
277
+ """Cover branches for drop_id=False and missing docstatus field."""
278
+ doc = PlainDoc(name="PLAIN-1")
279
+ cloned = service._clone_with_updates(
280
+ source=doc,
281
+ new_name="PLAIN-2",
282
+ docstatus=DocStatus.DRAFT,
283
+ drop_id=False,
284
+ )
285
+ assert cloned.id == doc.id
286
+ assert cloned.name == "PLAIN-2"
287
+ assert not hasattr(cloned, "docstatus")
288
+
289
+ def test_document_ref_without_name(self, service: CorrectionService) -> None:
290
+ """Cover branch where doc.name is None/empty."""
291
+ doc = PlainDoc(name="")
292
+ assert service._document_ref(doc) == str(doc.id)
293
+
294
+ @pytest.mark.asyncio
295
+ @patch.object(CorrectionService, "_ensure_submitted")
296
+ async def test_amend_without_copied_docstatus(
297
+ self,
298
+ mock_ensure: Any,
299
+ service: CorrectionService,
300
+ mock_repository: AsyncMock,
301
+ user_context: UserContext,
302
+ ) -> None:
303
+ """Cover hasattr(cancelled, 'docstatus') == False branch."""
304
+ doc = PlainDoc(name="PLAIN-1")
305
+ await service.amend(doc, "Test", user_context)
306
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
307
+ assert not hasattr(cancelled_doc, "docstatus")
308
+
309
+ @pytest.mark.asyncio
310
+ @patch.object(CorrectionService, "_ensure_submitted")
311
+ async def test_restore_without_copied_docstatus(
312
+ self,
313
+ mock_ensure: Any,
314
+ service: CorrectionService,
315
+ mock_repository: AsyncMock,
316
+ user_context: UserContext,
317
+ ) -> None:
318
+ """Cover hasattr(cancelled, 'docstatus') == False branch."""
319
+ doc = PlainDoc(name="PLAIN-1")
320
+ await service.restore(doc, {"title": "Restored"}, "Test", user_context)
321
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
322
+ assert not hasattr(cancelled_doc, "docstatus")
323
+
324
+ @pytest.mark.asyncio
325
+ @patch.object(CorrectionService, "_ensure_submitted")
326
+ async def test_in_place_edit_without_copied_docstatus(
327
+ self,
328
+ mock_ensure: Any,
329
+ service: CorrectionService,
330
+ mock_repository: AsyncMock,
331
+ user_context: UserContext,
332
+ ) -> None:
333
+ """Cover hasattr(editable, 'docstatus') == False branch."""
334
+ doc = PlainVersionedDoc(name="PLAIN-1")
335
+ await service.in_place_edit(doc, "Test", user_context)
336
+ editable_doc = mock_repository.save.await_args_list[1].args[0]
337
+ assert not hasattr(editable_doc, "docstatus")
338
+
242
339
  @pytest.mark.asyncio
243
340
  async def test_restore_raises_if_snapshot_empty(
244
341
  self,
@@ -139,9 +139,9 @@ class TestMigrationFlow:
139
139
  conn.execute(
140
140
  text(
141
141
  "INSERT INTO datadoc "
142
- "(id, name, name_field, creation, modified, owner) "
142
+ "(id, name, name_field, creation, modified, owner, custom_fields) "
143
143
  "VALUES ('test-uuid-1', 'DATA-001', 'Test Data', "
144
- "datetime('now'), datetime('now'), 'test@test.com')"
144
+ "datetime('now'), datetime('now'), 'test@test.com', '{}')"
145
145
  )
146
146
  )
147
147
  conn.commit()
@@ -50,7 +50,13 @@ class InMemoryWorkflowAdapter:
50
50
  "from": "Draft",
51
51
  "to": "Pending Approval",
52
52
  "action": "submit",
53
- "allowed_roles": ["Employee", "Manager", "Admin"],
53
+ "allowed_roles": ["Employee"],
54
+ },
55
+ {
56
+ "from": "Draft",
57
+ "to": "Pending Approval",
58
+ "action": "submit",
59
+ "allowed_roles": ["Manager", "Admin"], # Admins can also submit
54
60
  },
55
61
  {
56
62
  "from": "Pending Approval",
@@ -123,18 +129,15 @@ class InMemoryWorkflowAdapter:
123
129
  current_state_info = self._states[key]
124
130
  workflow_config = self._workflow_config[current_state_info.workflow_name]
125
131
 
126
- # Find matching transition
127
- transition = None
128
- for trans in workflow_config["transitions"]:
129
- if (
130
- trans["from"] == current_state_info.current_state
131
- and trans["action"] == request.action
132
- ):
133
- transition = trans
134
- break
132
+ # Find matching transition(s) for the action
133
+ possible_transitions = [
134
+ t
135
+ for t in workflow_config["transitions"]
136
+ if t["from"] == current_state_info.current_state
137
+ and t["action"] == request.action
138
+ ]
135
139
 
136
- # No valid transition found
137
- if transition is None:
140
+ if not possible_transitions:
138
141
  return TransitionResult(
139
142
  success=False,
140
143
  old_state=current_state_info.current_state,
@@ -143,22 +146,31 @@ class InMemoryWorkflowAdapter:
143
146
  message=f"Invalid action '{request.action}' from state '{current_state_info.current_state}'",
144
147
  )
145
148
 
146
- # Check user role permission
149
+ # Find a transition the user is allowed to perform
147
150
  user_roles = request.user.roles
148
- allowed_roles = transition["allowed_roles"]
151
+ allowed_transition = None
152
+ for trans in possible_transitions:
153
+ allowed_roles = trans.get("allowed_roles")
154
+ if not allowed_roles or any(r in user_roles for r in allowed_roles):
155
+ allowed_transition = trans
156
+ break
149
157
 
150
- if not any(role in allowed_roles for role in user_roles):
158
+ if not allowed_transition:
159
+ # A transition exists, but user lacks permission
160
+ required_roles = {
161
+ r for t in possible_transitions for r in (t.get("allowed_roles") or [])
162
+ }
151
163
  return TransitionResult(
152
164
  success=False,
153
165
  old_state=current_state_info.current_state,
154
166
  new_state=current_state_info.current_state,
155
167
  action=request.action,
156
- message=f"User does not have required role. Required: {allowed_roles}, User has: {user_roles}",
168
+ message=f"User does not have required role. Required: {required_roles}, User has: {user_roles}",
157
169
  )
158
170
 
159
171
  # Perform transition
160
172
  old_state = current_state_info.current_state
161
- new_state = transition["to"]
173
+ new_state = allowed_transition["to"]
162
174
 
163
175
  self._states[key] = current_state_info.model_copy(
164
176
  update={
@@ -197,8 +209,8 @@ class InMemoryWorkflowAdapter:
197
209
  continue
198
210
 
199
211
  # Check if user has required role
200
- allowed_roles = transition["allowed_roles"]
201
- if any(role in allowed_roles for role in user_roles):
212
+ allowed_roles = transition.get("allowed_roles")
213
+ if not allowed_roles or any(r in user_roles for r in allowed_roles):
202
214
  available_actions.append(transition["action"])
203
215
 
204
216
  return available_actions
@@ -1,414 +0,0 @@
1
- # Protocol Interfaces (Ports)
2
-
3
- Framework M defines clear protocol interfaces for all infrastructure concerns. This allows different implementations to be swapped without changing business logic.
4
-
5
- ## Overview
6
-
7
- | Protocol | File | Purpose |
8
- |----------|------|---------|
9
- | `RepositoryProtocol` | `repository.py` | CRUD operations for documents |
10
- | `EventBusProtocol` | `event_bus.py` | Publish/subscribe events |
11
- | `AuthContextProtocol` | `auth_context.py` | Current user context |
12
- | `PermissionProtocol` | `permission.py` | Authorization with RLS |
13
- | `StorageProtocol` | `storage.py` | File storage abstraction |
14
- | `JobQueueProtocol` | `job_queue.py` | Background job processing |
15
- | `CacheProtocol` | `cache.py` | Caching layer |
16
- | `NotificationProtocol` | `notification.py` | Email/SMS notifications |
17
- | `SearchProtocol` | `search.py` | Full-text search |
18
- | `PrintProtocol` | `print.py` | PDF/document generation |
19
- | `I18nProtocol` | `i18n.py` | Internationalization |
20
-
21
- ---
22
-
23
- ## RepositoryProtocol
24
-
25
- **Purpose**: Data access for documents with filtering, pagination, and optimistic concurrency.
26
-
27
- ```python
28
- from typing import Protocol, Generic, TypeVar
29
-
30
- T = TypeVar("T")
31
-
32
- class RepositoryProtocol(Protocol[T]):
33
- async def get(self, name: str) -> T | None:
34
- """Fetch a document by its name."""
35
- ...
36
-
37
- async def save(self, doc: T, version: int | None = None) -> T:
38
- """Save a document with optional optimistic concurrency."""
39
- ...
40
-
41
- async def delete(self, name: str) -> None:
42
- """Delete a document."""
43
- ...
44
-
45
- async def exists(self, name: str) -> bool:
46
- """Check if a document exists."""
47
- ...
48
-
49
- async def list(
50
- self,
51
- filters: FilterSpec | None = None,
52
- order_by: list[OrderSpec] | None = None,
53
- limit: int | None = None,
54
- offset: int | None = None,
55
- ) -> PaginatedResult[T]:
56
- """List documents with filtering and pagination."""
57
- ...
58
- ```
59
-
60
- **Key Types**:
61
- - `FilterSpec`: Nested filter conditions
62
- - `OrderSpec`: Sort specification (field, direction)
63
- - `PaginatedResult`: Paginated response with items and total
64
-
65
- ---
66
-
67
- ## EventBusProtocol
68
-
69
- **Purpose**: Publish/subscribe event system with CloudEvents format.
70
-
71
- ```python
72
- class EventBusProtocol(Protocol):
73
- async def publish(self, event: CloudEvent) -> None:
74
- """Publish an event to all subscribers."""
75
- ...
76
-
77
- async def subscribe(
78
- self,
79
- pattern: str,
80
- handler: EventHandler,
81
- ) -> str:
82
- """Subscribe to events matching a pattern."""
83
- ...
84
-
85
- async def unsubscribe(self, subscription_id: str) -> None:
86
- """Remove a subscription."""
87
- ...
88
- ```
89
-
90
- **Event Types**:
91
- - `doc.created` - Document created
92
- - `doc.updated` - Document modified
93
- - `doc.deleted` - Document deleted
94
- - `workflow.transition` - Workflow state change
95
-
96
- ---
97
-
98
- ## AuthContextProtocol
99
-
100
- **Purpose**: Access to current user, roles, and tenant information.
101
-
102
- ```python
103
- class AuthContextProtocol(Protocol):
104
- @property
105
- def user(self) -> UserContext | None:
106
- """Get current user or None for anonymous."""
107
- ...
108
-
109
- def has_role(self, role: str) -> bool:
110
- """Check if user has a specific role."""
111
- ...
112
-
113
- def is_system_user(self) -> bool:
114
- """Check if running as system (bypass permissions)."""
115
- ...
116
- ```
117
-
118
- **UserContext**:
119
- ```python
120
- class UserContext:
121
- user_id: str
122
- email: str
123
- full_name: str
124
- roles: list[str]
125
- tenant_id: str | None
126
- ```
127
-
128
- ---
129
-
130
- ## PermissionProtocol
131
-
132
- **Purpose**: Check permissions and generate Row-Level Security filters.
133
-
134
- ```python
135
- class PermissionProtocol(Protocol):
136
- async def can(
137
- self,
138
- action: PermissionAction,
139
- doctype: str,
140
- doc: BaseDocType | None = None,
141
- ) -> bool:
142
- """Check if user can perform action."""
143
- ...
144
-
145
- async def get_permitted_filters(
146
- self,
147
- doctype: str,
148
- action: PermissionAction,
149
- ) -> FilterSpec:
150
- """Get RLS filters for list queries."""
151
- ...
152
- ```
153
-
154
- **PermissionAction**: `read`, `write`, `create`, `delete`, `submit`, `cancel`
155
-
156
- ---
157
-
158
- ## StorageProtocol
159
-
160
- **Purpose**: File storage with presigned URLs and metadata.
161
-
162
- ```python
163
- class StorageProtocol(Protocol):
164
- async def save_file(
165
- self,
166
- key: str,
167
- content: bytes | AsyncIterable[bytes],
168
- content_type: str | None = None,
169
- ) -> FileMetadata:
170
- """Save a file."""
171
- ...
172
-
173
- async def get_file(self, key: str) -> bytes:
174
- """Retrieve file content."""
175
- ...
176
-
177
- async def get_url(
178
- self,
179
- key: str,
180
- expires_in: int = 3600,
181
- ) -> str:
182
- """Get a presigned URL for direct access."""
183
- ...
184
-
185
- async def delete_file(self, key: str) -> None:
186
- """Delete a file."""
187
- ...
188
- ```
189
-
190
- ---
191
-
192
- ## JobQueueProtocol
193
-
194
- **Purpose**: Enqueue background jobs with scheduling and retry.
195
-
196
- ```python
197
- class JobQueueProtocol(Protocol):
198
- async def enqueue(
199
- self,
200
- job_name: str,
201
- *args: Any,
202
- defer_by: int | None = None,
203
- **kwargs: Any,
204
- ) -> str:
205
- """Enqueue a job for background execution."""
206
- ...
207
-
208
- async def schedule(
209
- self,
210
- job_name: str,
211
- cron: str,
212
- *args: Any,
213
- **kwargs: Any,
214
- ) -> str:
215
- """Schedule a recurring job."""
216
- ...
217
-
218
- async def cancel(self, job_id: str) -> bool:
219
- """Cancel a pending job."""
220
- ...
221
- ```
222
-
223
- ---
224
-
225
- ## CacheProtocol
226
-
227
- **Purpose**: Key-value caching with TTL and pattern operations.
228
-
229
- ```python
230
- class CacheProtocol(Protocol):
231
- async def get(self, key: str) -> Any | None:
232
- """Get a cached value."""
233
- ...
234
-
235
- async def set(
236
- self,
237
- key: str,
238
- value: Any,
239
- ttl: int | None = None,
240
- ) -> None:
241
- """Set a cached value with optional TTL."""
242
- ...
243
-
244
- async def delete(self, key: str) -> None:
245
- """Delete a cached value."""
246
- ...
247
-
248
- async def delete_pattern(self, pattern: str) -> int:
249
- """Delete all keys matching a pattern."""
250
- ...
251
- ```
252
-
253
- ---
254
-
255
- ## NotificationProtocol
256
-
257
- **Purpose**: Send notifications via email, SMS, push, etc.
258
-
259
- ```python
260
- class NotificationProtocol(Protocol):
261
- async def send(
262
- self,
263
- notification: Notification,
264
- ) -> str:
265
- """Send a notification."""
266
- ...
267
-
268
- async def send_bulk(
269
- self,
270
- notifications: list[Notification],
271
- ) -> list[str]:
272
- """Send multiple notifications."""
273
- ...
274
- ```
275
-
276
- **Notification Types**: `email`, `sms`, `push`, `in_app`
277
-
278
- ---
279
-
280
- ## SearchProtocol
281
-
282
- **Purpose**: Full-text search with facets and highlighting.
283
-
284
- ```python
285
- class SearchProtocol(Protocol):
286
- async def index(
287
- self,
288
- doctype: str,
289
- doc_id: str,
290
- data: dict[str, Any],
291
- ) -> None:
292
- """Index a document for search."""
293
- ...
294
-
295
- async def search(
296
- self,
297
- query: str,
298
- doctypes: list[str] | None = None,
299
- filters: FilterSpec | None = None,
300
- limit: int = 20,
301
- ) -> SearchResult:
302
- """Search documents."""
303
- ...
304
-
305
- async def delete_index(
306
- self,
307
- doctype: str,
308
- doc_id: str,
309
- ) -> None:
310
- """Remove a document from search index."""
311
- ...
312
- ```
313
-
314
- ---
315
-
316
- ## PrintProtocol
317
-
318
- **Purpose**: Generate PDF/HTML documents from templates.
319
-
320
- ```python
321
- class PrintProtocol(Protocol):
322
- async def render(
323
- self,
324
- template: str,
325
- data: dict[str, Any],
326
- format: Literal["pdf", "html"] = "pdf",
327
- ) -> bytes:
328
- """Render a template to PDF or HTML."""
329
- ...
330
-
331
- async def get_template(self, name: str) -> Template:
332
- """Get a print template by name."""
333
- ...
334
- ```
335
-
336
- ---
337
-
338
- ## I18nProtocol
339
-
340
- **Purpose**: Internationalization and localization.
341
-
342
- ```python
343
- class I18nProtocol(Protocol):
344
- def translate(
345
- self,
346
- key: str,
347
- locale: str | None = None,
348
- **kwargs: Any,
349
- ) -> str:
350
- """Translate a key to the target locale."""
351
- ...
352
-
353
- def get_locale(self) -> str:
354
- """Get the current locale."""
355
- ...
356
-
357
- def list_locales(self) -> list[str]:
358
- """List available locales."""
359
- ...
360
- ```
361
-
362
- ---
363
-
364
- ## Using Protocols
365
-
366
- ### In Business Logic
367
-
368
- ```python
369
- from framework_m.core.interfaces import RepositoryProtocol
370
-
371
- class InvoiceService:
372
- def __init__(self, repo: RepositoryProtocol[Invoice]) -> None:
373
- self.repo = repo
374
-
375
- async def create_invoice(self, data: dict) -> Invoice:
376
- invoice = Invoice(**data)
377
- return await self.repo.save(invoice)
378
- ```
379
-
380
- ### With Dependency Injection
381
-
382
- ```python
383
- from dependency_injector.wiring import inject, Provide
384
- from framework_m.core.container import Container
385
- from framework_m.core.interfaces import RepositoryProtocol
386
-
387
- @inject
388
- async def get_invoices(
389
- repo: RepositoryProtocol = Provide[Container.repository],
390
- ) -> list[Invoice]:
391
- result = await repo.list()
392
- return result.items
393
- ```
394
-
395
- ### In Testing
396
-
397
- ```python
398
- class MockRepository:
399
- def __init__(self):
400
- self.data = {}
401
-
402
- async def get(self, name: str) -> Invoice | None:
403
- return self.data.get(name)
404
-
405
- async def save(self, doc: Invoice) -> Invoice:
406
- self.data[doc.name] = doc
407
- return doc
408
-
409
- # In tests
410
- def test_invoice_creation():
411
- repo = MockRepository()
412
- service = InvoiceService(repo)
413
- # ...
414
- ```
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes