julee 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. julee/__init__.py +3 -0
  2. julee/api/__init__.py +20 -0
  3. julee/api/app.py +180 -0
  4. julee/api/dependencies.py +257 -0
  5. julee/api/requests.py +175 -0
  6. julee/api/responses.py +43 -0
  7. julee/api/routers/__init__.py +43 -0
  8. julee/api/routers/assembly_specifications.py +212 -0
  9. julee/api/routers/documents.py +182 -0
  10. julee/api/routers/knowledge_service_configs.py +79 -0
  11. julee/api/routers/knowledge_service_queries.py +293 -0
  12. julee/api/routers/system.py +137 -0
  13. julee/api/routers/workflows.py +234 -0
  14. julee/api/services/__init__.py +20 -0
  15. julee/api/services/system_initialization.py +214 -0
  16. julee/api/tests/__init__.py +14 -0
  17. julee/api/tests/routers/__init__.py +17 -0
  18. julee/api/tests/routers/test_assembly_specifications.py +749 -0
  19. julee/api/tests/routers/test_documents.py +301 -0
  20. julee/api/tests/routers/test_knowledge_service_configs.py +234 -0
  21. julee/api/tests/routers/test_knowledge_service_queries.py +738 -0
  22. julee/api/tests/routers/test_system.py +179 -0
  23. julee/api/tests/routers/test_workflows.py +393 -0
  24. julee/api/tests/test_app.py +285 -0
  25. julee/api/tests/test_dependencies.py +245 -0
  26. julee/api/tests/test_requests.py +250 -0
  27. julee/domain/__init__.py +22 -0
  28. julee/domain/models/__init__.py +49 -0
  29. julee/domain/models/assembly/__init__.py +17 -0
  30. julee/domain/models/assembly/assembly.py +103 -0
  31. julee/domain/models/assembly/tests/__init__.py +0 -0
  32. julee/domain/models/assembly/tests/factories.py +37 -0
  33. julee/domain/models/assembly/tests/test_assembly.py +430 -0
  34. julee/domain/models/assembly_specification/__init__.py +24 -0
  35. julee/domain/models/assembly_specification/assembly_specification.py +172 -0
  36. julee/domain/models/assembly_specification/knowledge_service_query.py +123 -0
  37. julee/domain/models/assembly_specification/tests/__init__.py +0 -0
  38. julee/domain/models/assembly_specification/tests/factories.py +78 -0
  39. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +490 -0
  40. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +310 -0
  41. julee/domain/models/custom_fields/__init__.py +0 -0
  42. julee/domain/models/custom_fields/content_stream.py +68 -0
  43. julee/domain/models/custom_fields/tests/__init__.py +0 -0
  44. julee/domain/models/custom_fields/tests/test_custom_fields.py +53 -0
  45. julee/domain/models/document/__init__.py +17 -0
  46. julee/domain/models/document/document.py +150 -0
  47. julee/domain/models/document/tests/__init__.py +0 -0
  48. julee/domain/models/document/tests/factories.py +76 -0
  49. julee/domain/models/document/tests/test_document.py +297 -0
  50. julee/domain/models/knowledge_service_config/__init__.py +17 -0
  51. julee/domain/models/knowledge_service_config/knowledge_service_config.py +86 -0
  52. julee/domain/models/policy/__init__.py +15 -0
  53. julee/domain/models/policy/document_policy_validation.py +220 -0
  54. julee/domain/models/policy/policy.py +203 -0
  55. julee/domain/models/policy/tests/__init__.py +0 -0
  56. julee/domain/models/policy/tests/factories.py +47 -0
  57. julee/domain/models/policy/tests/test_document_policy_validation.py +420 -0
  58. julee/domain/models/policy/tests/test_policy.py +546 -0
  59. julee/domain/repositories/__init__.py +27 -0
  60. julee/domain/repositories/assembly.py +45 -0
  61. julee/domain/repositories/assembly_specification.py +52 -0
  62. julee/domain/repositories/base.py +146 -0
  63. julee/domain/repositories/document.py +49 -0
  64. julee/domain/repositories/document_policy_validation.py +52 -0
  65. julee/domain/repositories/knowledge_service_config.py +54 -0
  66. julee/domain/repositories/knowledge_service_query.py +44 -0
  67. julee/domain/repositories/policy.py +49 -0
  68. julee/domain/use_cases/__init__.py +17 -0
  69. julee/domain/use_cases/decorators.py +107 -0
  70. julee/domain/use_cases/extract_assemble_data.py +649 -0
  71. julee/domain/use_cases/initialize_system_data.py +842 -0
  72. julee/domain/use_cases/tests/__init__.py +7 -0
  73. julee/domain/use_cases/tests/test_extract_assemble_data.py +548 -0
  74. julee/domain/use_cases/tests/test_initialize_system_data.py +455 -0
  75. julee/domain/use_cases/tests/test_validate_document.py +1228 -0
  76. julee/domain/use_cases/validate_document.py +736 -0
  77. julee/fixtures/assembly_specifications.yaml +70 -0
  78. julee/fixtures/documents.yaml +178 -0
  79. julee/fixtures/knowledge_service_configs.yaml +37 -0
  80. julee/fixtures/knowledge_service_queries.yaml +27 -0
  81. julee/repositories/__init__.py +17 -0
  82. julee/repositories/memory/__init__.py +31 -0
  83. julee/repositories/memory/assembly.py +84 -0
  84. julee/repositories/memory/assembly_specification.py +125 -0
  85. julee/repositories/memory/base.py +227 -0
  86. julee/repositories/memory/document.py +149 -0
  87. julee/repositories/memory/document_policy_validation.py +104 -0
  88. julee/repositories/memory/knowledge_service_config.py +123 -0
  89. julee/repositories/memory/knowledge_service_query.py +120 -0
  90. julee/repositories/memory/policy.py +87 -0
  91. julee/repositories/memory/tests/__init__.py +0 -0
  92. julee/repositories/memory/tests/test_document.py +212 -0
  93. julee/repositories/memory/tests/test_document_policy_validation.py +161 -0
  94. julee/repositories/memory/tests/test_policy.py +443 -0
  95. julee/repositories/minio/__init__.py +31 -0
  96. julee/repositories/minio/assembly.py +103 -0
  97. julee/repositories/minio/assembly_specification.py +170 -0
  98. julee/repositories/minio/client.py +570 -0
  99. julee/repositories/minio/document.py +530 -0
  100. julee/repositories/minio/document_policy_validation.py +120 -0
  101. julee/repositories/minio/knowledge_service_config.py +187 -0
  102. julee/repositories/minio/knowledge_service_query.py +211 -0
  103. julee/repositories/minio/policy.py +106 -0
  104. julee/repositories/minio/tests/__init__.py +0 -0
  105. julee/repositories/minio/tests/fake_client.py +213 -0
  106. julee/repositories/minio/tests/test_assembly.py +374 -0
  107. julee/repositories/minio/tests/test_assembly_specification.py +391 -0
  108. julee/repositories/minio/tests/test_client_protocol.py +57 -0
  109. julee/repositories/minio/tests/test_document.py +591 -0
  110. julee/repositories/minio/tests/test_document_policy_validation.py +192 -0
  111. julee/repositories/minio/tests/test_knowledge_service_config.py +374 -0
  112. julee/repositories/minio/tests/test_knowledge_service_query.py +438 -0
  113. julee/repositories/minio/tests/test_policy.py +559 -0
  114. julee/repositories/temporal/__init__.py +38 -0
  115. julee/repositories/temporal/activities.py +114 -0
  116. julee/repositories/temporal/activity_names.py +34 -0
  117. julee/repositories/temporal/proxies.py +159 -0
  118. julee/services/__init__.py +18 -0
  119. julee/services/knowledge_service/__init__.py +48 -0
  120. julee/services/knowledge_service/anthropic/__init__.py +12 -0
  121. julee/services/knowledge_service/anthropic/knowledge_service.py +331 -0
  122. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +318 -0
  123. julee/services/knowledge_service/factory.py +138 -0
  124. julee/services/knowledge_service/knowledge_service.py +160 -0
  125. julee/services/knowledge_service/memory/__init__.py +13 -0
  126. julee/services/knowledge_service/memory/knowledge_service.py +278 -0
  127. julee/services/knowledge_service/memory/test_knowledge_service.py +345 -0
  128. julee/services/knowledge_service/test_factory.py +112 -0
  129. julee/services/temporal/__init__.py +38 -0
  130. julee/services/temporal/activities.py +86 -0
  131. julee/services/temporal/activity_names.py +22 -0
  132. julee/services/temporal/proxies.py +41 -0
  133. julee/util/__init__.py +0 -0
  134. julee/util/domain.py +119 -0
  135. julee/util/repos/__init__.py +0 -0
  136. julee/util/repos/minio/__init__.py +0 -0
  137. julee/util/repos/minio/file_storage.py +213 -0
  138. julee/util/repos/temporal/__init__.py +11 -0
  139. julee/util/repos/temporal/client_proxies/file_storage.py +68 -0
  140. julee/util/repos/temporal/data_converter.py +123 -0
  141. julee/util/repos/temporal/minio_file_storage.py +12 -0
  142. julee/util/repos/temporal/proxies/__init__.py +0 -0
  143. julee/util/repos/temporal/proxies/file_storage.py +58 -0
  144. julee/util/repositories.py +55 -0
  145. julee/util/temporal/__init__.py +22 -0
  146. julee/util/temporal/activities.py +123 -0
  147. julee/util/temporal/decorators.py +473 -0
  148. julee/util/tests/__init__.py +1 -0
  149. julee/util/tests/test_decorators.py +770 -0
  150. julee/util/validation/__init__.py +29 -0
  151. julee/util/validation/repository.py +100 -0
  152. julee/util/validation/type_guards.py +369 -0
  153. julee/worker.py +211 -0
  154. julee/workflows/__init__.py +26 -0
  155. julee/workflows/extract_assemble.py +215 -0
  156. julee/workflows/validate_document.py +228 -0
  157. julee-0.1.0.dist-info/METADATA +195 -0
  158. julee-0.1.0.dist-info/RECORD +161 -0
  159. julee-0.1.0.dist-info/WHEEL +5 -0
  160. julee-0.1.0.dist-info/licenses/LICENSE +674 -0
  161. julee-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,770 @@
1
+ """
2
+ Tests for temporal decorators.
3
+
4
+ This module tests the decorators in isolation to ensure they properly wrap
5
+ async methods as Temporal activities and handle type substitution correctly.
6
+ """
7
+
8
+ # Standard library imports
9
+ import asyncio
10
+ import inspect
11
+ from typing import (
12
+ Any,
13
+ List,
14
+ Optional,
15
+ Protocol,
16
+ TypeVar,
17
+ get_args,
18
+ get_origin,
19
+ runtime_checkable,
20
+ )
21
+ from unittest.mock import patch
22
+
23
+ # Third-party imports
24
+ import pytest
25
+ from pydantic import BaseModel
26
+ from temporalio import activity
27
+
28
+ # Project imports
29
+ import julee.util.temporal.decorators as decorators_module
30
+ from julee.domain.repositories.base import BaseRepository
31
+ from julee.util.temporal.decorators import (
32
+ _extract_concrete_type_from_base,
33
+ _needs_pydantic_validation,
34
+ _substitute_typevar_with_concrete,
35
+ temporal_activity_registration,
36
+ temporal_workflow_proxy,
37
+ )
38
+
39
+
40
+ @runtime_checkable
41
+ class MockBaseRepositoryProtocol(Protocol):
42
+ """Mock base repository protocol for testing inheritance."""
43
+
44
+ async def base_async_method(self, arg1: str) -> str:
45
+ """Base async method that should be wrapped."""
46
+ ...
47
+
48
+ def base_sync_method(self, arg1: str) -> str:
49
+ """Base sync method that should NOT be wrapped."""
50
+ ...
51
+
52
+ async def _private_async_method(self, arg1: str) -> str:
53
+ """Private async method that should NOT be wrapped."""
54
+ ...
55
+
56
+
57
+ @runtime_checkable
58
+ class MockRepositoryProtocol(MockBaseRepositoryProtocol, Protocol):
59
+ """Mock repository protocol for testing the decorator."""
60
+
61
+ async def process_payment(self, order_id: str, amount: float) -> dict:
62
+ """Mock payment processing method."""
63
+ ...
64
+
65
+ async def get_payment(self, payment_id: str) -> Optional[dict]:
66
+ """Mock get payment method."""
67
+ ...
68
+
69
+ async def refund_payment(self, payment_id: str) -> dict:
70
+ """Mock refund payment method."""
71
+ ...
72
+
73
+ def sync_method(self, value: str) -> str:
74
+ """Sync method that should NOT be wrapped."""
75
+ ...
76
+
77
+ async def _private_method(self, value: str) -> str:
78
+ """Private async method that should NOT be wrapped."""
79
+ ...
80
+
81
+
82
+ class MockRepository(MockRepositoryProtocol):
83
+ """Concrete mock repository implementation for testing."""
84
+
85
+ async def base_async_method(self, arg1: str) -> str:
86
+ """Base async method that should be wrapped."""
87
+ return f"base_result_{arg1}"
88
+
89
+ def base_sync_method(self, arg1: str) -> str:
90
+ """Base sync method that should NOT be wrapped."""
91
+ return f"base_sync_{arg1}"
92
+
93
+ async def _private_async_method(self, arg1: str) -> str:
94
+ """Private async method that should NOT be wrapped."""
95
+ return f"private_{arg1}"
96
+
97
+ async def process_payment(self, order_id: str, amount: float) -> dict:
98
+ """Mock payment processing method."""
99
+ return {"status": "success", "order_id": order_id, "amount": amount}
100
+
101
+ async def get_payment(self, payment_id: str) -> Optional[dict]:
102
+ """Mock get payment method."""
103
+ if payment_id == "not_found":
104
+ return None
105
+ return {"payment_id": payment_id, "status": "completed"}
106
+
107
+ async def refund_payment(self, payment_id: str) -> dict:
108
+ """Mock refund payment method."""
109
+ return {"status": "refunded", "payment_id": payment_id}
110
+
111
+ def sync_method(self, value: str) -> str:
112
+ """Sync method that should NOT be wrapped."""
113
+ return f"sync_{value}"
114
+
115
+ async def _private_method(self, value: str) -> str:
116
+ """Private async method that should NOT be wrapped."""
117
+ return f"private_{value}"
118
+
119
+
120
+ def test_decorator_wraps_public_async_methods() -> None:
121
+ """Test decorator wraps all public async methods as activities."""
122
+
123
+ @temporal_activity_registration("test.repo")
124
+ class DecoratedRepository(MockRepository):
125
+ pass
126
+
127
+ # Check that async methods are wrapped with activity decorator
128
+ # Use dir() check since hasattr() doesn't work with Temporal activities
129
+ assert "__temporal_activity_definition" in dir(DecoratedRepository.process_payment)
130
+ assert "__temporal_activity_definition" in dir(DecoratedRepository.get_payment)
131
+ assert "__temporal_activity_definition" in dir(DecoratedRepository.refund_payment)
132
+ assert "__temporal_activity_definition" in dir(
133
+ DecoratedRepository.base_async_method
134
+ )
135
+
136
+ # Check activity names by accessing the attribute directly
137
+ process_payment_attrs = {
138
+ attr: getattr(DecoratedRepository.process_payment, attr, None)
139
+ for attr in dir(DecoratedRepository.process_payment)
140
+ if attr == "__temporal_activity_definition"
141
+ }
142
+ get_payment_attrs = {
143
+ attr: getattr(DecoratedRepository.get_payment, attr, None)
144
+ for attr in dir(DecoratedRepository.get_payment)
145
+ if attr == "__temporal_activity_definition"
146
+ }
147
+ refund_payment_attrs = {
148
+ attr: getattr(DecoratedRepository.refund_payment, attr, None)
149
+ for attr in dir(DecoratedRepository.refund_payment)
150
+ if attr == "__temporal_activity_definition"
151
+ }
152
+ base_async_attrs = {
153
+ attr: getattr(DecoratedRepository.base_async_method, attr, None)
154
+ for attr in dir(DecoratedRepository.base_async_method)
155
+ if attr == "__temporal_activity_definition"
156
+ }
157
+
158
+ # Verify the attributes exist and have the expected names
159
+ assert process_payment_attrs
160
+ assert get_payment_attrs
161
+ assert refund_payment_attrs
162
+ assert base_async_attrs
163
+
164
+
165
+ def test_decorator_does_not_wrap_sync_methods() -> None:
166
+ """Test that sync methods are not wrapped as activities."""
167
+
168
+ @temporal_activity_registration("test.repo")
169
+ class DecoratedRepository(MockRepository):
170
+ pass
171
+
172
+ # Check that sync methods are NOT wrapped
173
+ assert "__temporal_activity_definition" not in dir(DecoratedRepository.sync_method)
174
+ assert "__temporal_activity_definition" not in dir(
175
+ DecoratedRepository.base_sync_method
176
+ )
177
+
178
+
179
+ def test_decorator_does_not_wrap_private_methods() -> None:
180
+ """Test that private async methods are not wrapped as activities."""
181
+
182
+ @temporal_activity_registration("test.repo")
183
+ class DecoratedRepository(MockRepository):
184
+ pass
185
+
186
+ # Check that private async methods are NOT wrapped
187
+ assert "__temporal_activity_definition" not in dir(
188
+ DecoratedRepository._private_method
189
+ )
190
+ assert "__temporal_activity_definition" not in dir(
191
+ DecoratedRepository._private_async_method
192
+ )
193
+
194
+
195
+ def test_decorated_methods_preserve_functionality() -> None:
196
+ """Test that decorated methods still work as expected."""
197
+
198
+ @temporal_activity_registration("test.repo")
199
+ class DecoratedRepository(MockRepository):
200
+ pass
201
+
202
+ repo = DecoratedRepository()
203
+
204
+ # Test sync method works normally
205
+ result = repo.sync_method("test")
206
+ assert result == "sync_test"
207
+
208
+ # Test private method works normally
209
+ async def test_private() -> None:
210
+ result = await repo._private_method("test")
211
+ assert result == "private_test"
212
+
213
+ asyncio.run(test_private())
214
+
215
+
216
+ def test_decorated_methods_preserve_metadata() -> None:
217
+ """Test that decorated methods preserve original method metadata."""
218
+
219
+ @temporal_activity_registration("test.repo")
220
+ class DecoratedRepository(MockRepository):
221
+ pass
222
+
223
+ repo = DecoratedRepository()
224
+
225
+ # Check that method names are preserved
226
+ assert repo.process_payment.__name__ == "process_payment"
227
+ assert repo.get_payment.__name__ == "get_payment"
228
+ assert repo.refund_payment.__name__ == "refund_payment"
229
+
230
+ # Check that docstrings are preserved
231
+ assert "Mock payment processing method" in (repo.process_payment.__doc__ or "")
232
+ assert "Mock get payment method" in (repo.get_payment.__doc__ or "")
233
+ assert "Mock refund payment method" in (repo.refund_payment.__doc__ or "")
234
+
235
+
236
+ def test_activity_names_with_different_prefixes() -> None:
237
+ """Test different prefixes generate different activity names."""
238
+
239
+ captured_activity_names = []
240
+ original_activity_defn = activity.defn
241
+
242
+ def mock_activity_defn(name: Optional[str] = None, **kwargs: Any) -> Any:
243
+ """Mock activity.defn to capture the activity names being created."""
244
+ if name:
245
+ captured_activity_names.append(name)
246
+ return original_activity_defn(name=name, **kwargs)
247
+
248
+ with patch(
249
+ "julee.util.temporal.decorators.activity.defn",
250
+ side_effect=mock_activity_defn,
251
+ ):
252
+
253
+ @temporal_activity_registration("test.payment_service")
254
+ class PaymentServiceRepo(MockRepository):
255
+ pass
256
+
257
+ @temporal_activity_registration("test.inventory_service")
258
+ class InventoryServiceRepo(MockRepository):
259
+ pass
260
+
261
+ # Verify that activity names were captured with the correct prefixes
262
+ payment_activities = [
263
+ name
264
+ for name in captured_activity_names
265
+ if name.startswith("test.payment_service")
266
+ ]
267
+ inventory_activities = [
268
+ name
269
+ for name in captured_activity_names
270
+ if name.startswith("test.inventory_service")
271
+ ]
272
+
273
+ # Should have created activities for each async method
274
+ expected_payment_activities = {
275
+ "test.payment_service.process_payment",
276
+ "test.payment_service.get_payment",
277
+ "test.payment_service.refund_payment",
278
+ "test.payment_service.base_async_method",
279
+ }
280
+ expected_inventory_activities = {
281
+ "test.inventory_service.process_payment",
282
+ "test.inventory_service.get_payment",
283
+ "test.inventory_service.refund_payment",
284
+ "test.inventory_service.base_async_method",
285
+ }
286
+
287
+ assert set(payment_activities) == expected_payment_activities
288
+ assert set(inventory_activities) == expected_inventory_activities
289
+
290
+ # Verify no activity names overlap between the two services
291
+ assert not set(payment_activities).intersection(set(inventory_activities))
292
+
293
+
294
+ def test_decorator_handles_inheritance_correctly() -> None:
295
+ """Test that the decorator properly handles method resolution order."""
296
+
297
+ @runtime_checkable
298
+ class ChildRepositoryProtocol(MockRepositoryProtocol, Protocol):
299
+ async def child_method(self, value: str) -> str:
300
+ """Child-specific method."""
301
+ ...
302
+
303
+ class ChildRepository(ChildRepositoryProtocol):
304
+ async def base_async_method(self, arg1: str) -> str:
305
+ return f"base_result_{arg1}"
306
+
307
+ def base_sync_method(self, arg1: str) -> str:
308
+ return f"base_sync_{arg1}"
309
+
310
+ async def _private_async_method(self, arg1: str) -> str:
311
+ return f"private_{arg1}"
312
+
313
+ async def process_payment(self, order_id: str, amount: float) -> dict:
314
+ return {
315
+ "status": "success",
316
+ "order_id": order_id,
317
+ "amount": amount,
318
+ }
319
+
320
+ async def get_payment(self, payment_id: str) -> Optional[dict]:
321
+ if payment_id == "not_found":
322
+ return None
323
+ return {"payment_id": payment_id, "status": "completed"}
324
+
325
+ async def refund_payment(self, payment_id: str) -> dict:
326
+ return {"status": "refunded", "payment_id": payment_id}
327
+
328
+ def sync_method(self, value: str) -> str:
329
+ return f"sync_{value}"
330
+
331
+ async def _private_method(self, value: str) -> str:
332
+ return f"private_{value}"
333
+
334
+ async def child_method(self, value: str) -> str:
335
+ """Child-specific method."""
336
+ return f"child_{value}"
337
+
338
+ @temporal_activity_registration("test.child")
339
+ class DecoratedChildRepository(ChildRepository):
340
+ pass
341
+
342
+ # Check that all async methods are wrapped, including inherited ones
343
+ assert "__temporal_activity_definition" in dir(
344
+ DecoratedChildRepository.child_method
345
+ )
346
+ assert "__temporal_activity_definition" in dir(
347
+ DecoratedChildRepository.process_payment
348
+ )
349
+ assert "__temporal_activity_definition" in dir(
350
+ DecoratedChildRepository.base_async_method
351
+ )
352
+
353
+
354
+ def test_decorator_logs_wrapped_methods() -> None:
355
+ """Test that the decorator logs which methods it wraps."""
356
+
357
+ with patch("julee.util.temporal.decorators.logger") as mock_logger:
358
+
359
+ @temporal_activity_registration("test.logging")
360
+ class DecoratedRepository(MockRepository):
361
+ pass
362
+
363
+ # Check that debug logs were called for each method
364
+ mock_logger.debug.assert_called()
365
+
366
+ # Should have one info call: decorator applied
367
+ assert mock_logger.info.call_count == 1
368
+
369
+ # Check that the final info log contains the expected information
370
+ final_info_call = mock_logger.info.call_args_list[-1]
371
+ assert (
372
+ "Temporal activity registration decorator applied" in final_info_call[0][0]
373
+ )
374
+ assert "DecoratedRepository" in final_info_call[0][0]
375
+
376
+
377
+ def test_empty_class_decorator() -> None:
378
+ """Test decorator behavior with a class that has no async methods."""
379
+
380
+ @runtime_checkable
381
+ class EmptyRepositoryProtocol(Protocol):
382
+ def sync_only(self, value: str) -> str: ...
383
+
384
+ class EmptyRepository(EmptyRepositoryProtocol):
385
+ def sync_only(self, value: str) -> str:
386
+ return f"sync_{value}"
387
+
388
+ @temporal_activity_registration("test.empty")
389
+ class DecoratedEmptyRepository(EmptyRepository):
390
+ pass
391
+
392
+ # Should still work, just no methods wrapped
393
+ assert "__temporal_activity_definition" not in dir(
394
+ DecoratedEmptyRepository.sync_only
395
+ )
396
+
397
+
398
+ def test_decorator_type_preservation() -> None:
399
+ """Test decorator preserves class type for isinstance checks."""
400
+
401
+ @temporal_activity_registration("test.types")
402
+ class DecoratedRepository(MockRepository):
403
+ pass
404
+
405
+ repo = DecoratedRepository()
406
+
407
+ # Check that isinstance still works
408
+ assert isinstance(repo, DecoratedRepository)
409
+ assert isinstance(repo, MockRepository)
410
+ assert isinstance(repo, MockBaseRepositoryProtocol)
411
+
412
+
413
+ def test_multiple_decorations() -> None:
414
+ """Test repository can be decorated multiple times with prefixes."""
415
+
416
+ @temporal_activity_registration("test.first")
417
+ class FirstDecoration(MockRepository):
418
+ pass
419
+
420
+ @temporal_activity_registration("test.second")
421
+ class SecondDecoration(MockRepository):
422
+ pass
423
+
424
+ # Check that each has different activity names
425
+ assert "__temporal_activity_definition" in dir(FirstDecoration.process_payment)
426
+ assert "__temporal_activity_definition" in dir(SecondDecoration.process_payment)
427
+
428
+
429
+ # Test domain models for type substitution tests
430
+ class MockAssemblySpecification(BaseModel):
431
+ """Mock domain model for type substitution tests."""
432
+
433
+ assembly_specification_id: str
434
+ name: str
435
+ status: str = "active"
436
+
437
+
438
+ class MockDocument(BaseModel):
439
+ """Another mock domain model."""
440
+
441
+ document_id: str
442
+ title: str
443
+ content: str
444
+
445
+
446
+ # Test repository protocols
447
+ T = TypeVar("T", bound=BaseModel)
448
+
449
+
450
+ @runtime_checkable
451
+ class MockAssemblySpecificationRepository(
452
+ BaseRepository[MockAssemblySpecification], Protocol
453
+ ):
454
+ """Mock repository inheriting from BaseRepository with concrete type."""
455
+
456
+ pass
457
+
458
+
459
+ @runtime_checkable
460
+ class MockDocumentRepository(BaseRepository[MockDocument], Protocol):
461
+ """Another mock repository with different concrete type."""
462
+
463
+ pass
464
+
465
+
466
+ @runtime_checkable
467
+ class NonGenericRepository(Protocol):
468
+ """Repository that doesn't follow BaseRepository[T] pattern."""
469
+
470
+ async def get(self, id: str) -> Optional[MockDocument]: ...
471
+
472
+
473
+ class TestTypeExtraction:
474
+ """Tests for _extract_concrete_type_from_base function."""
475
+
476
+ def test_extracts_concrete_type_from_direct_inheritance(self) -> None:
477
+ """Test extracting type from direct BaseRepository inheritance."""
478
+ concrete_type = _extract_concrete_type_from_base(
479
+ MockAssemblySpecificationRepository
480
+ )
481
+ assert concrete_type == MockAssemblySpecification
482
+
483
+ def test_extracts_different_concrete_types(self) -> None:
484
+ """Test different repositories extract their concrete types."""
485
+ assembly_type = _extract_concrete_type_from_base(
486
+ MockAssemblySpecificationRepository
487
+ )
488
+ document_type = _extract_concrete_type_from_base(MockDocumentRepository)
489
+
490
+ assert assembly_type == MockAssemblySpecification
491
+ assert document_type == MockDocument
492
+ assert assembly_type != document_type
493
+
494
+ def test_extracts_from_proxy_class_inheritance(self) -> None:
495
+ """Test extracting concrete type from workflow proxy classes."""
496
+
497
+ class TestWorkflowProxy(MockAssemblySpecificationRepository):
498
+ pass
499
+
500
+ concrete_type = _extract_concrete_type_from_base(TestWorkflowProxy)
501
+ assert concrete_type == MockAssemblySpecification
502
+
503
+ def test_returns_none_for_non_generic_repository(self) -> None:
504
+ """Test that non-generic repositories return None."""
505
+ concrete_type = _extract_concrete_type_from_base(NonGenericRepository)
506
+ assert concrete_type is None
507
+
508
+ def test_returns_none_for_object_class(self) -> None:
509
+ """Test that base object class returns None."""
510
+ concrete_type = _extract_concrete_type_from_base(object)
511
+ assert concrete_type is None
512
+
513
+
514
+ class TestTypeSubstitution:
515
+ """Tests for _substitute_typevar_with_concrete function."""
516
+
517
+ def test_substitutes_direct_typevar(self) -> None:
518
+ """Test direct TypeVar substitution."""
519
+ result = _substitute_typevar_with_concrete(T, MockAssemblySpecification)
520
+ assert result == MockAssemblySpecification
521
+
522
+ def test_substitutes_optional_typevar(self) -> None:
523
+ """Test Optional[TypeVar] substitution."""
524
+ optional_t = Optional[T]
525
+ result = _substitute_typevar_with_concrete(
526
+ optional_t, MockAssemblySpecification
527
+ )
528
+
529
+ # Should be Optional[MockAssemblySpecification]
530
+ origin = get_origin(result)
531
+ args = get_args(result)
532
+ assert origin is not None
533
+ assert MockAssemblySpecification in args
534
+ assert type(None) in args
535
+
536
+ def test_substitutes_nested_generics(self) -> None:
537
+ """Test substitution in nested generic types."""
538
+ nested_generic = List[Optional[T]]
539
+ result = _substitute_typevar_with_concrete(nested_generic, MockDocument)
540
+
541
+ # Should be List[Optional[MockDocument]]
542
+ outer_origin = get_origin(result)
543
+ outer_args = get_args(result)
544
+ assert outer_origin is list
545
+ assert len(outer_args) == 1
546
+
547
+ inner_type = outer_args[0]
548
+ inner_args = get_args(inner_type)
549
+ assert MockDocument in inner_args
550
+ assert type(None) in inner_args
551
+
552
+ def test_returns_non_generic_types_unchanged(self) -> None:
553
+ """Test that non-generic types are returned unchanged."""
554
+ result_str = _substitute_typevar_with_concrete(str, MockAssemblySpecification)
555
+ result_int = _substitute_typevar_with_concrete(int, MockAssemblySpecification)
556
+ result_concrete = _substitute_typevar_with_concrete(
557
+ MockDocument, MockAssemblySpecification
558
+ )
559
+
560
+ assert result_str is str
561
+ assert result_int is int
562
+ assert result_concrete == MockDocument
563
+
564
+ def test_handles_none_annotation(self) -> None:
565
+ """Test handling of None annotations."""
566
+ result = _substitute_typevar_with_concrete(None, MockAssemblySpecification)
567
+ assert result is None
568
+
569
+ def test_handles_signature_empty(self) -> None:
570
+ """Test handling of inspect.Signature.empty."""
571
+ result = _substitute_typevar_with_concrete(
572
+ inspect.Signature.empty, MockAssemblySpecification
573
+ )
574
+ assert result == inspect.Signature.empty
575
+
576
+ def test_fails_fast_on_reconstruction_error(self) -> None:
577
+ """Test that reconstruction errors raise informative exceptions."""
578
+
579
+ # Create a mock type that will fail reconstruction
580
+ class FailingOrigin:
581
+ def __getitem__(self, item: Any) -> Any:
582
+ raise TypeError("Mock reconstruction failure")
583
+
584
+ def __str__(self) -> str:
585
+ return "FailingOrigin"
586
+
587
+ # Mock get_origin and get_args to return our failing type
588
+ def mock_get_origin(annotation: Any) -> Any:
589
+ if annotation == "FAILING_TYPE":
590
+ return FailingOrigin()
591
+ return get_origin(annotation)
592
+
593
+ def mock_get_args(annotation: Any) -> tuple[Any, ...]:
594
+ if annotation == "FAILING_TYPE":
595
+ return (T,)
596
+ return get_args(annotation)
597
+
598
+ with (
599
+ patch.object(decorators_module, "get_origin", side_effect=mock_get_origin),
600
+ patch.object(decorators_module, "get_args", side_effect=mock_get_args),
601
+ ):
602
+ with pytest.raises(TypeError) as exc_info:
603
+ _substitute_typevar_with_concrete(
604
+ "FAILING_TYPE", MockAssemblySpecification
605
+ )
606
+
607
+ error_message = str(exc_info.value)
608
+ assert "Failed to reconstruct generic type" in error_message
609
+ assert "FailingOrigin" in error_message
610
+ assert "FAILING_TYPE" in error_message
611
+ assert "MockAssemblySpecification" in error_message
612
+
613
+
614
+ class TestPydanticValidationDetection:
615
+ """Tests for _needs_pydantic_validation function."""
616
+
617
+ def test_detects_pydantic_model_types(self) -> None:
618
+ """Test detection of Pydantic model types."""
619
+ assert _needs_pydantic_validation(MockAssemblySpecification)
620
+ assert _needs_pydantic_validation(MockDocument)
621
+
622
+ def test_detects_optional_pydantic_types(self) -> None:
623
+ """Test detection of Optional[PydanticModel] types."""
624
+ assert _needs_pydantic_validation(Optional[MockAssemblySpecification])
625
+ assert _needs_pydantic_validation(Optional[MockDocument])
626
+
627
+ def test_rejects_non_pydantic_types(self) -> None:
628
+ """Test that non-Pydantic types are not flagged for validation."""
629
+ assert not _needs_pydantic_validation(str)
630
+ assert not _needs_pydantic_validation(int)
631
+ assert not _needs_pydantic_validation(dict)
632
+ assert not _needs_pydantic_validation(Optional[str])
633
+
634
+ def test_rejects_typevar_types(self) -> None:
635
+ """Test TypeVar types aren't flagged for validation (the bug)."""
636
+ assert not _needs_pydantic_validation(T)
637
+ assert not _needs_pydantic_validation(Optional[T])
638
+
639
+ def test_handles_none_and_empty(self) -> None:
640
+ """Test handling of None and Signature.empty."""
641
+ assert not _needs_pydantic_validation(None)
642
+ assert not _needs_pydantic_validation(inspect.Signature.empty)
643
+
644
+
645
+ class TestWorkflowProxyIntegration:
646
+ """Integration tests for temporal_workflow_proxy with substitution."""
647
+
648
+ def test_extracts_type_from_proxy_class(self) -> None:
649
+ """Test decorator extracts concrete types from proxy classes."""
650
+
651
+ @temporal_workflow_proxy(
652
+ activity_base="test.assembly_spec_repo.minio",
653
+ default_timeout_seconds=30,
654
+ )
655
+ class TestWorkflowAssemblySpecificationRepositoryProxy(
656
+ MockAssemblySpecificationRepository
657
+ ):
658
+ pass
659
+
660
+ # Verify type extraction works
661
+ concrete_type = _extract_concrete_type_from_base(
662
+ TestWorkflowAssemblySpecificationRepositoryProxy
663
+ )
664
+ assert concrete_type == MockAssemblySpecification
665
+
666
+ def test_creates_proxy_methods(self) -> None:
667
+ """Test that the decorator creates expected proxy methods."""
668
+
669
+ @temporal_workflow_proxy(
670
+ activity_base="test.document_repo.minio",
671
+ default_timeout_seconds=30,
672
+ )
673
+ class TestWorkflowDocumentRepositoryProxy(MockDocumentRepository):
674
+ pass
675
+
676
+ proxy = TestWorkflowDocumentRepositoryProxy() # type: ignore[abstract]
677
+
678
+ # Check that methods exist
679
+ assert hasattr(proxy, "get")
680
+ assert hasattr(proxy, "save")
681
+ assert hasattr(proxy, "generate_id")
682
+
683
+ # Check instance attributes
684
+ assert hasattr(proxy, "activity_timeout")
685
+ assert hasattr(proxy, "activity_fail_fast_retry_policy")
686
+
687
+ def test_different_repositories_get_different_types(self) -> None:
688
+ """Test that different repositories extract their respective types."""
689
+
690
+ @temporal_workflow_proxy(
691
+ activity_base="test.assembly_spec_repo.minio",
692
+ default_timeout_seconds=30,
693
+ )
694
+ class ProxyA(MockAssemblySpecificationRepository):
695
+ pass
696
+
697
+ @temporal_workflow_proxy(
698
+ activity_base="test.document_repo.minio",
699
+ default_timeout_seconds=30,
700
+ )
701
+ class ProxyB(MockDocumentRepository):
702
+ pass
703
+
704
+ type_a = _extract_concrete_type_from_base(ProxyA)
705
+ type_b = _extract_concrete_type_from_base(ProxyB)
706
+
707
+ assert type_a == MockAssemblySpecification
708
+ assert type_b == MockDocument
709
+ assert type_a != type_b
710
+
711
+ def test_handles_non_generic_repository_gracefully(self) -> None:
712
+ """Test that non-generic repositories are handled gracefully."""
713
+
714
+ @temporal_workflow_proxy(
715
+ activity_base="test.non_generic_repo.minio",
716
+ default_timeout_seconds=30,
717
+ )
718
+ class TestNonGenericProxy(NonGenericRepository):
719
+ pass
720
+
721
+ # Should not raise an error
722
+ proxy = TestNonGenericProxy() # type: ignore[abstract]
723
+ assert hasattr(proxy, "get")
724
+
725
+ # Should return None for concrete type (logged but not errored)
726
+ concrete_type = _extract_concrete_type_from_base(TestNonGenericProxy)
727
+ assert concrete_type is None
728
+
729
+
730
+ class TestEndToEndTypeSubstitution:
731
+ """End-to-end tests demonstrating the complete type substitution fix."""
732
+
733
+ def test_type_substitution_enables_pydantic_validation(self) -> None:
734
+ """Test type substitution enables Pydantic validation."""
735
+ # Simulate the problematic method signature: Optional[~T]
736
+ original_annotation = Optional[T]
737
+
738
+ # Before fix: TypeVar prevents validation
739
+ assert not _needs_pydantic_validation(original_annotation)
740
+
741
+ # After substitution: Concrete type enables validation
742
+ substituted_annotation = _substitute_typevar_with_concrete(
743
+ original_annotation, MockAssemblySpecification
744
+ )
745
+ assert _needs_pydantic_validation(substituted_annotation)
746
+
747
+ def test_demonstrates_original_problem_and_solution(self) -> None:
748
+ """Test dict vs Pydantic object problem and solution."""
749
+ # Create test data
750
+ test_spec = MockAssemblySpecification(
751
+ assembly_specification_id="test-123",
752
+ name="Test Spec",
753
+ status="active",
754
+ )
755
+
756
+ # Convert to dict (simulates what Temporal activity returns)
757
+ activity_result_dict = test_spec.model_dump(mode="json")
758
+
759
+ # Demonstrate the problem: dict doesn't have Pydantic attributes
760
+ assert isinstance(activity_result_dict, dict)
761
+ with pytest.raises(AttributeError):
762
+ # This would fail because dict doesn't have the attribute
763
+ getattr(activity_result_dict, "assembly_specification_id")
764
+
765
+ # Demonstrate the solution: reconstruct Pydantic object
766
+ reconstructed = MockAssemblySpecification.model_validate(activity_result_dict)
767
+ assert isinstance(reconstructed, MockAssemblySpecification)
768
+ assert reconstructed.assembly_specification_id == "test-123" # This works
769
+ assert reconstructed.name == "Test Spec"
770
+ assert reconstructed.status == "active"