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.
- julee/__init__.py +3 -0
- julee/api/__init__.py +20 -0
- julee/api/app.py +180 -0
- julee/api/dependencies.py +257 -0
- julee/api/requests.py +175 -0
- julee/api/responses.py +43 -0
- julee/api/routers/__init__.py +43 -0
- julee/api/routers/assembly_specifications.py +212 -0
- julee/api/routers/documents.py +182 -0
- julee/api/routers/knowledge_service_configs.py +79 -0
- julee/api/routers/knowledge_service_queries.py +293 -0
- julee/api/routers/system.py +137 -0
- julee/api/routers/workflows.py +234 -0
- julee/api/services/__init__.py +20 -0
- julee/api/services/system_initialization.py +214 -0
- julee/api/tests/__init__.py +14 -0
- julee/api/tests/routers/__init__.py +17 -0
- julee/api/tests/routers/test_assembly_specifications.py +749 -0
- julee/api/tests/routers/test_documents.py +301 -0
- julee/api/tests/routers/test_knowledge_service_configs.py +234 -0
- julee/api/tests/routers/test_knowledge_service_queries.py +738 -0
- julee/api/tests/routers/test_system.py +179 -0
- julee/api/tests/routers/test_workflows.py +393 -0
- julee/api/tests/test_app.py +285 -0
- julee/api/tests/test_dependencies.py +245 -0
- julee/api/tests/test_requests.py +250 -0
- julee/domain/__init__.py +22 -0
- julee/domain/models/__init__.py +49 -0
- julee/domain/models/assembly/__init__.py +17 -0
- julee/domain/models/assembly/assembly.py +103 -0
- julee/domain/models/assembly/tests/__init__.py +0 -0
- julee/domain/models/assembly/tests/factories.py +37 -0
- julee/domain/models/assembly/tests/test_assembly.py +430 -0
- julee/domain/models/assembly_specification/__init__.py +24 -0
- julee/domain/models/assembly_specification/assembly_specification.py +172 -0
- julee/domain/models/assembly_specification/knowledge_service_query.py +123 -0
- julee/domain/models/assembly_specification/tests/__init__.py +0 -0
- julee/domain/models/assembly_specification/tests/factories.py +78 -0
- julee/domain/models/assembly_specification/tests/test_assembly_specification.py +490 -0
- julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +310 -0
- julee/domain/models/custom_fields/__init__.py +0 -0
- julee/domain/models/custom_fields/content_stream.py +68 -0
- julee/domain/models/custom_fields/tests/__init__.py +0 -0
- julee/domain/models/custom_fields/tests/test_custom_fields.py +53 -0
- julee/domain/models/document/__init__.py +17 -0
- julee/domain/models/document/document.py +150 -0
- julee/domain/models/document/tests/__init__.py +0 -0
- julee/domain/models/document/tests/factories.py +76 -0
- julee/domain/models/document/tests/test_document.py +297 -0
- julee/domain/models/knowledge_service_config/__init__.py +17 -0
- julee/domain/models/knowledge_service_config/knowledge_service_config.py +86 -0
- julee/domain/models/policy/__init__.py +15 -0
- julee/domain/models/policy/document_policy_validation.py +220 -0
- julee/domain/models/policy/policy.py +203 -0
- julee/domain/models/policy/tests/__init__.py +0 -0
- julee/domain/models/policy/tests/factories.py +47 -0
- julee/domain/models/policy/tests/test_document_policy_validation.py +420 -0
- julee/domain/models/policy/tests/test_policy.py +546 -0
- julee/domain/repositories/__init__.py +27 -0
- julee/domain/repositories/assembly.py +45 -0
- julee/domain/repositories/assembly_specification.py +52 -0
- julee/domain/repositories/base.py +146 -0
- julee/domain/repositories/document.py +49 -0
- julee/domain/repositories/document_policy_validation.py +52 -0
- julee/domain/repositories/knowledge_service_config.py +54 -0
- julee/domain/repositories/knowledge_service_query.py +44 -0
- julee/domain/repositories/policy.py +49 -0
- julee/domain/use_cases/__init__.py +17 -0
- julee/domain/use_cases/decorators.py +107 -0
- julee/domain/use_cases/extract_assemble_data.py +649 -0
- julee/domain/use_cases/initialize_system_data.py +842 -0
- julee/domain/use_cases/tests/__init__.py +7 -0
- julee/domain/use_cases/tests/test_extract_assemble_data.py +548 -0
- julee/domain/use_cases/tests/test_initialize_system_data.py +455 -0
- julee/domain/use_cases/tests/test_validate_document.py +1228 -0
- julee/domain/use_cases/validate_document.py +736 -0
- julee/fixtures/assembly_specifications.yaml +70 -0
- julee/fixtures/documents.yaml +178 -0
- julee/fixtures/knowledge_service_configs.yaml +37 -0
- julee/fixtures/knowledge_service_queries.yaml +27 -0
- julee/repositories/__init__.py +17 -0
- julee/repositories/memory/__init__.py +31 -0
- julee/repositories/memory/assembly.py +84 -0
- julee/repositories/memory/assembly_specification.py +125 -0
- julee/repositories/memory/base.py +227 -0
- julee/repositories/memory/document.py +149 -0
- julee/repositories/memory/document_policy_validation.py +104 -0
- julee/repositories/memory/knowledge_service_config.py +123 -0
- julee/repositories/memory/knowledge_service_query.py +120 -0
- julee/repositories/memory/policy.py +87 -0
- julee/repositories/memory/tests/__init__.py +0 -0
- julee/repositories/memory/tests/test_document.py +212 -0
- julee/repositories/memory/tests/test_document_policy_validation.py +161 -0
- julee/repositories/memory/tests/test_policy.py +443 -0
- julee/repositories/minio/__init__.py +31 -0
- julee/repositories/minio/assembly.py +103 -0
- julee/repositories/minio/assembly_specification.py +170 -0
- julee/repositories/minio/client.py +570 -0
- julee/repositories/minio/document.py +530 -0
- julee/repositories/minio/document_policy_validation.py +120 -0
- julee/repositories/minio/knowledge_service_config.py +187 -0
- julee/repositories/minio/knowledge_service_query.py +211 -0
- julee/repositories/minio/policy.py +106 -0
- julee/repositories/minio/tests/__init__.py +0 -0
- julee/repositories/minio/tests/fake_client.py +213 -0
- julee/repositories/minio/tests/test_assembly.py +374 -0
- julee/repositories/minio/tests/test_assembly_specification.py +391 -0
- julee/repositories/minio/tests/test_client_protocol.py +57 -0
- julee/repositories/minio/tests/test_document.py +591 -0
- julee/repositories/minio/tests/test_document_policy_validation.py +192 -0
- julee/repositories/minio/tests/test_knowledge_service_config.py +374 -0
- julee/repositories/minio/tests/test_knowledge_service_query.py +438 -0
- julee/repositories/minio/tests/test_policy.py +559 -0
- julee/repositories/temporal/__init__.py +38 -0
- julee/repositories/temporal/activities.py +114 -0
- julee/repositories/temporal/activity_names.py +34 -0
- julee/repositories/temporal/proxies.py +159 -0
- julee/services/__init__.py +18 -0
- julee/services/knowledge_service/__init__.py +48 -0
- julee/services/knowledge_service/anthropic/__init__.py +12 -0
- julee/services/knowledge_service/anthropic/knowledge_service.py +331 -0
- julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +318 -0
- julee/services/knowledge_service/factory.py +138 -0
- julee/services/knowledge_service/knowledge_service.py +160 -0
- julee/services/knowledge_service/memory/__init__.py +13 -0
- julee/services/knowledge_service/memory/knowledge_service.py +278 -0
- julee/services/knowledge_service/memory/test_knowledge_service.py +345 -0
- julee/services/knowledge_service/test_factory.py +112 -0
- julee/services/temporal/__init__.py +38 -0
- julee/services/temporal/activities.py +86 -0
- julee/services/temporal/activity_names.py +22 -0
- julee/services/temporal/proxies.py +41 -0
- julee/util/__init__.py +0 -0
- julee/util/domain.py +119 -0
- julee/util/repos/__init__.py +0 -0
- julee/util/repos/minio/__init__.py +0 -0
- julee/util/repos/minio/file_storage.py +213 -0
- julee/util/repos/temporal/__init__.py +11 -0
- julee/util/repos/temporal/client_proxies/file_storage.py +68 -0
- julee/util/repos/temporal/data_converter.py +123 -0
- julee/util/repos/temporal/minio_file_storage.py +12 -0
- julee/util/repos/temporal/proxies/__init__.py +0 -0
- julee/util/repos/temporal/proxies/file_storage.py +58 -0
- julee/util/repositories.py +55 -0
- julee/util/temporal/__init__.py +22 -0
- julee/util/temporal/activities.py +123 -0
- julee/util/temporal/decorators.py +473 -0
- julee/util/tests/__init__.py +1 -0
- julee/util/tests/test_decorators.py +770 -0
- julee/util/validation/__init__.py +29 -0
- julee/util/validation/repository.py +100 -0
- julee/util/validation/type_guards.py +369 -0
- julee/worker.py +211 -0
- julee/workflows/__init__.py +26 -0
- julee/workflows/extract_assemble.py +215 -0
- julee/workflows/validate_document.py +228 -0
- julee-0.1.0.dist-info/METADATA +195 -0
- julee-0.1.0.dist-info/RECORD +161 -0
- julee-0.1.0.dist-info/WHEEL +5 -0
- julee-0.1.0.dist-info/licenses/LICENSE +674 -0
- julee-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for working with Temporal activities.
|
|
3
|
+
|
|
4
|
+
This module provides helper functions for automatically discovering and
|
|
5
|
+
collecting activity methods from decorated instances, eliminating the need
|
|
6
|
+
for manual activity registration boilerplate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discover_protocol_methods(
|
|
17
|
+
cls_hierarchy: tuple[type, ...],
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Discover protocol methods that should be wrapped as activities.
|
|
21
|
+
|
|
22
|
+
This function finds async methods defined in protocol interfaces within
|
|
23
|
+
a class hierarchy. It's used to automatically collect methods that should
|
|
24
|
+
be registered as Temporal activities.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
cls_hierarchy: The class MRO (method resolution order)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Dict mapping method names to method objects from the concrete class
|
|
31
|
+
"""
|
|
32
|
+
methods_to_wrap = {}
|
|
33
|
+
concrete_class = cls_hierarchy[0] # The actual class being decorated
|
|
34
|
+
|
|
35
|
+
# Look for protocol interfaces (classes with runtime_checkable/Protocol)
|
|
36
|
+
for base_class in cls_hierarchy:
|
|
37
|
+
# Skip object base class
|
|
38
|
+
if base_class is object:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Check if this is a protocol class
|
|
42
|
+
has_protocol_attr = hasattr(base_class, "__protocol__")
|
|
43
|
+
has_is_protocol = getattr(base_class, "_is_protocol", False)
|
|
44
|
+
has_protocol_in_str = "Protocol" in str(base_class)
|
|
45
|
+
|
|
46
|
+
is_protocol = has_protocol_attr or has_is_protocol or has_protocol_in_str
|
|
47
|
+
|
|
48
|
+
# Only process protocol classes for architectural compliance
|
|
49
|
+
if is_protocol:
|
|
50
|
+
# Get method names defined in this class, but get the actual
|
|
51
|
+
# implementation from the concrete class
|
|
52
|
+
for name in base_class.__dict__:
|
|
53
|
+
if name in methods_to_wrap:
|
|
54
|
+
continue # Already found this method
|
|
55
|
+
|
|
56
|
+
base_method = getattr(base_class, name)
|
|
57
|
+
# Only wrap async methods that don't start with underscore
|
|
58
|
+
if inspect.iscoroutinefunction(base_method) and not name.startswith(
|
|
59
|
+
"_"
|
|
60
|
+
):
|
|
61
|
+
# Get the concrete implementation from the actual class
|
|
62
|
+
if hasattr(concrete_class, name):
|
|
63
|
+
concrete_method = getattr(concrete_class, name)
|
|
64
|
+
methods_to_wrap[name] = concrete_method
|
|
65
|
+
|
|
66
|
+
# Log final results
|
|
67
|
+
final_method_names = list(methods_to_wrap.keys())
|
|
68
|
+
logger.debug(f"Method discovery found {len(methods_to_wrap)}: {final_method_names}")
|
|
69
|
+
return methods_to_wrap
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def collect_activities_from_instances(*instances: Any) -> list[Any]:
|
|
73
|
+
"""Automatically collect all activity methods from decorated instances.
|
|
74
|
+
|
|
75
|
+
Uses protocol method discovery to find and collect all methods that have
|
|
76
|
+
been wrapped as Temporal activities by the @temporal_activity_registration
|
|
77
|
+
decorator. This ensures we don't miss any activities and eliminates
|
|
78
|
+
boilerplate registration code.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
*instances: Repository and service instances decorated with
|
|
82
|
+
@temporal_activity_registration
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of activity methods ready for Worker registration
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
# Instead of manually listing all activities:
|
|
89
|
+
activities = [
|
|
90
|
+
repo.generate_id,
|
|
91
|
+
repo.save,
|
|
92
|
+
repo.get,
|
|
93
|
+
# ... many more lines
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Use automatic discovery:
|
|
97
|
+
activities = collect_activities_from_instances(
|
|
98
|
+
temporal_assembly_repo,
|
|
99
|
+
temporal_document_repo,
|
|
100
|
+
temporal_knowledge_service,
|
|
101
|
+
)
|
|
102
|
+
"""
|
|
103
|
+
activities = []
|
|
104
|
+
|
|
105
|
+
for instance in instances:
|
|
106
|
+
# Use the same discovery logic as the decorator
|
|
107
|
+
methods_to_collect = discover_protocol_methods(instance.__class__.__mro__)
|
|
108
|
+
|
|
109
|
+
# Get the actual bound methods from the instance
|
|
110
|
+
for method_name in methods_to_collect:
|
|
111
|
+
if hasattr(instance, method_name):
|
|
112
|
+
bound_method = getattr(instance, method_name)
|
|
113
|
+
activities.append(bound_method)
|
|
114
|
+
logger.debug(
|
|
115
|
+
f"Collected activity method: "
|
|
116
|
+
f"{instance.__class__.__name__}.{method_name}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
logger.info(
|
|
120
|
+
f"Collected {len(activities)} activities from " f"{len(instances)} instances"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return activities
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Temporal decorators for automatically creating activities and workflow proxies
|
|
3
|
+
|
|
4
|
+
This module provides decorators that automatically:
|
|
5
|
+
1. Wrap protocol methods as Temporal activities
|
|
6
|
+
2. Generate workflow proxy classes that delegate to activities
|
|
7
|
+
Both reduce boilerplate and ensure consistent patterns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from typing import (
|
|
15
|
+
Any,
|
|
16
|
+
Callable,
|
|
17
|
+
Optional,
|
|
18
|
+
Type,
|
|
19
|
+
TypeVar,
|
|
20
|
+
get_args,
|
|
21
|
+
get_origin,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
from temporalio import activity, workflow
|
|
26
|
+
from temporalio.common import RetryPolicy
|
|
27
|
+
|
|
28
|
+
from julee.domain.repositories.base import BaseRepository
|
|
29
|
+
|
|
30
|
+
from .activities import discover_protocol_methods
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _extract_concrete_type_from_base(cls: type) -> Optional[type]:
|
|
38
|
+
"""
|
|
39
|
+
Extract the concrete type argument from a generic base class.
|
|
40
|
+
|
|
41
|
+
For example, if a class inherits from
|
|
42
|
+
BaseRepository[AssemblySpecification], this function will return
|
|
43
|
+
AssemblySpecification.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cls: Class to analyze for generic base types
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The concrete type if found, None otherwise
|
|
50
|
+
"""
|
|
51
|
+
# Check the class hierarchy for generic base classes
|
|
52
|
+
for base in cls.__mro__:
|
|
53
|
+
# Check if this class has __orig_bases__ (generic type information)
|
|
54
|
+
if hasattr(base, "__orig_bases__"):
|
|
55
|
+
for orig_base in base.__orig_bases__:
|
|
56
|
+
origin = get_origin(orig_base)
|
|
57
|
+
if origin is not None:
|
|
58
|
+
args = get_args(orig_base)
|
|
59
|
+
# Look for BaseRepository[ConcreteType] pattern
|
|
60
|
+
if origin is BaseRepository and len(args) == 1:
|
|
61
|
+
concrete_type = args[0]
|
|
62
|
+
# Make sure it's a concrete type, not another TypeVar
|
|
63
|
+
if not isinstance(concrete_type, TypeVar):
|
|
64
|
+
logger.debug(
|
|
65
|
+
f"Extracted concrete type {concrete_type} "
|
|
66
|
+
f"from {orig_base}"
|
|
67
|
+
)
|
|
68
|
+
return concrete_type # type: ignore[no-any-return]
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _substitute_typevar_with_concrete(annotation: Any, concrete_type: type) -> Any:
|
|
73
|
+
"""
|
|
74
|
+
Substitute TypeVar instances with concrete type in type annotations.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
annotation: Type annotation that may contain TypeVars
|
|
78
|
+
concrete_type: Concrete type to substitute for TypeVars
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Type annotation with TypeVars replaced by concrete type
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
TypeError: If type reconstruction fails during substitution
|
|
85
|
+
"""
|
|
86
|
+
if isinstance(annotation, TypeVar):
|
|
87
|
+
return concrete_type
|
|
88
|
+
|
|
89
|
+
origin = get_origin(annotation)
|
|
90
|
+
if origin is not None:
|
|
91
|
+
args = get_args(annotation)
|
|
92
|
+
if args:
|
|
93
|
+
# Recursively substitute in generic arguments
|
|
94
|
+
new_args = tuple(
|
|
95
|
+
_substitute_typevar_with_concrete(arg, concrete_type) for arg in args
|
|
96
|
+
)
|
|
97
|
+
# Reconstruct the generic type with substituted arguments
|
|
98
|
+
try:
|
|
99
|
+
return origin[new_args] # type: ignore
|
|
100
|
+
except TypeError as e:
|
|
101
|
+
# Fail fast - type reconstruction should work if substituting
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"Failed to reconstruct generic type {origin} with "
|
|
104
|
+
f"args {new_args}. "
|
|
105
|
+
f"Original annotation: {annotation}, "
|
|
106
|
+
f"concrete type: {concrete_type}. "
|
|
107
|
+
f"This indicates an issue with type substitution logic."
|
|
108
|
+
) from e
|
|
109
|
+
|
|
110
|
+
# origin is None - normal for non-generic types like str, bool, etc.
|
|
111
|
+
return annotation
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def temporal_activity_registration(
|
|
115
|
+
activity_prefix: str,
|
|
116
|
+
) -> Callable[[Type[T]], Type[T]]:
|
|
117
|
+
"""
|
|
118
|
+
Class decorator that wraps async protocol methods as Temporal activities.
|
|
119
|
+
|
|
120
|
+
This decorator inspects the class and wraps all async methods (coroutine
|
|
121
|
+
functions) that don't start with underscore as Temporal activities. The
|
|
122
|
+
activity names are generated using the provided prefix and the method
|
|
123
|
+
name.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
activity_prefix: Prefix for activity names (e.g.,
|
|
127
|
+
"sample.payment_repo.minio") Method names will be appended to create
|
|
128
|
+
full activity names like "sample.payment_repo.minio.process_payment"
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The decorated class with all async methods wrapped as Temporal
|
|
132
|
+
activities
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
@temporal_activity_registration("sample.payment_repo.minio")
|
|
136
|
+
class TemporalMinioPaymentRepository(MinioPaymentRepository):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# This automatically creates activities for all protocol methods:
|
|
140
|
+
# - process_payment ->
|
|
141
|
+
# "sample.payment_repo.minio.process_payment"
|
|
142
|
+
# - get_payment ->
|
|
143
|
+
# "sample.payment_repo.minio.get_payment"
|
|
144
|
+
# - refund_payment ->
|
|
145
|
+
# "sample.payment_repo.minio.refund_payment"
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
149
|
+
logger.debug(
|
|
150
|
+
f"Applying temporal_activity_registration decorator to {cls.__name__}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Track which methods we wrap for logging
|
|
154
|
+
wrapped_methods = []
|
|
155
|
+
|
|
156
|
+
# Use common method discovery - for activities, wrap protocol methods
|
|
157
|
+
async_methods_to_wrap = discover_protocol_methods(cls.__mro__)
|
|
158
|
+
|
|
159
|
+
# Now wrap all the async methods we found
|
|
160
|
+
for name, method in async_methods_to_wrap.items():
|
|
161
|
+
# Create activity name by combining prefix and method name
|
|
162
|
+
activity_name = f"{activity_prefix}.{name}"
|
|
163
|
+
|
|
164
|
+
logger.debug(f"Wrapping method {name} as activity {activity_name}")
|
|
165
|
+
|
|
166
|
+
# Create a new method that calls the original to avoid decorator
|
|
167
|
+
# conflicts while preserving the exact signature for Pydantic
|
|
168
|
+
def create_wrapper_method(
|
|
169
|
+
original_method: Callable[..., Any], method_name: str
|
|
170
|
+
) -> Callable[..., Any]:
|
|
171
|
+
# Create wrapper with preserved signature for proper type
|
|
172
|
+
# conversion
|
|
173
|
+
|
|
174
|
+
@functools.wraps(original_method)
|
|
175
|
+
async def wrapper_method(*args: Any, **kwargs: Any) -> Any:
|
|
176
|
+
return await original_method(*args, **kwargs)
|
|
177
|
+
|
|
178
|
+
# Preserve method metadata explicitly
|
|
179
|
+
wrapper_method.__name__ = method_name
|
|
180
|
+
wrapper_method.__qualname__ = f"{cls.__name__}.{method_name}"
|
|
181
|
+
wrapper_method.__doc__ = original_method.__doc__
|
|
182
|
+
wrapper_method.__annotations__ = getattr(
|
|
183
|
+
original_method, "__annotations__", {}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return wrapper_method
|
|
187
|
+
|
|
188
|
+
# Create the wrapper and apply activity decorator
|
|
189
|
+
wrapper_method = create_wrapper_method(method, name)
|
|
190
|
+
wrapped_method = activity.defn(name=activity_name)(wrapper_method)
|
|
191
|
+
|
|
192
|
+
# Replace the method on the class with the wrapped version
|
|
193
|
+
setattr(cls, name, wrapped_method)
|
|
194
|
+
|
|
195
|
+
wrapped_methods.append(name)
|
|
196
|
+
|
|
197
|
+
logger.info(
|
|
198
|
+
f"Temporal activity registration decorator applied to {cls.__name__}",
|
|
199
|
+
extra={
|
|
200
|
+
"wrapped_methods": wrapped_methods,
|
|
201
|
+
"activity_prefix": activity_prefix,
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return cls
|
|
206
|
+
|
|
207
|
+
return decorator
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def temporal_workflow_proxy(
|
|
211
|
+
activity_base: str,
|
|
212
|
+
default_timeout_seconds: int = 30,
|
|
213
|
+
retry_methods: Optional[list[str]] = None,
|
|
214
|
+
) -> Callable[[Type[T]], Type[T]]:
|
|
215
|
+
"""
|
|
216
|
+
Class decorator that automatically creates workflow proxy methods that
|
|
217
|
+
delegate to Temporal activities.
|
|
218
|
+
|
|
219
|
+
This decorator inspects the protocol/interface being implemented and
|
|
220
|
+
generates methods that call workflow.execute_activity with the appropriate
|
|
221
|
+
activity names, timeouts, and retry policies.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
activity_base: Base activity name (e.g., "julee.document_repo.minio")
|
|
225
|
+
default_timeout_seconds: Default timeout for activities in seconds
|
|
226
|
+
retry_methods: List of method names that should use retry policies
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The decorated class with all protocol methods implemented as
|
|
230
|
+
workflow activity calls
|
|
231
|
+
|
|
232
|
+
Example:
|
|
233
|
+
@temporal_workflow_proxy(
|
|
234
|
+
"julee.document_repo.minio",
|
|
235
|
+
default_timeout_seconds=30,
|
|
236
|
+
retry_methods=["save", "generate_id"]
|
|
237
|
+
)
|
|
238
|
+
class WorkflowDocumentRepositoryProxy(DocumentRepository):
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
# This automatically creates workflow methods for all methods:
|
|
242
|
+
# - get() -> calls "julee.document_repo.minio.get" activity
|
|
243
|
+
# - save() -> calls "julee.document_repo.minio.save" with retry
|
|
244
|
+
# - generate_id() -> calls "julee.document_repo.minio.generate_id"
|
|
245
|
+
# with retry
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
def decorator(cls: Type[T]) -> Type[T]:
|
|
249
|
+
logger.debug(f"Applying temporal_workflow_proxy decorator to {cls.__name__}")
|
|
250
|
+
|
|
251
|
+
retry_methods_set = set(retry_methods or [])
|
|
252
|
+
|
|
253
|
+
# Create default retry policy for methods that need it
|
|
254
|
+
fail_fast_retry_policy = RetryPolicy(
|
|
255
|
+
initial_interval=timedelta(seconds=1),
|
|
256
|
+
maximum_attempts=1,
|
|
257
|
+
backoff_coefficient=1.0,
|
|
258
|
+
maximum_interval=timedelta(seconds=1),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Use method discovery - for workflow proxies, wrap protocol methods
|
|
262
|
+
methods_to_implement = discover_protocol_methods(cls.__mro__)
|
|
263
|
+
|
|
264
|
+
# Generate workflow proxy methods
|
|
265
|
+
wrapped_methods = []
|
|
266
|
+
|
|
267
|
+
for method_name, original_method in methods_to_implement.items():
|
|
268
|
+
logger.debug(
|
|
269
|
+
f"Creating workflow proxy method {method_name} -> "
|
|
270
|
+
f"{activity_base}.{method_name}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Get method signature for type hints
|
|
274
|
+
sig = inspect.signature(original_method)
|
|
275
|
+
return_annotation = sig.return_annotation
|
|
276
|
+
|
|
277
|
+
# Try to extract concrete type from class inheritance
|
|
278
|
+
concrete_type = _extract_concrete_type_from_base(cls)
|
|
279
|
+
|
|
280
|
+
# Substitute TypeVars with concrete type if found
|
|
281
|
+
if concrete_type is not None:
|
|
282
|
+
return_annotation = _substitute_typevar_with_concrete(
|
|
283
|
+
return_annotation, concrete_type
|
|
284
|
+
)
|
|
285
|
+
logger.debug(
|
|
286
|
+
f"Substituted TypeVar in {method_name} return type: "
|
|
287
|
+
f"{sig.return_annotation} -> {return_annotation}"
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
# Log when we couldn't extract concrete type - might indicate
|
|
291
|
+
# a repository that doesn't follow BaseRepository[T] pattern
|
|
292
|
+
logger.debug(
|
|
293
|
+
f"No concrete type for {cls.__name__}.{method_name}. "
|
|
294
|
+
f"Return annotation: {sig.return_annotation}. "
|
|
295
|
+
f"If Pydantic objects returned, ensure repo inherits "
|
|
296
|
+
f"from BaseRepository[ConcreteType]."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Determine if return type needs Pydantic validation
|
|
300
|
+
needs_validation = _needs_pydantic_validation(return_annotation)
|
|
301
|
+
is_optional = _is_optional_type(return_annotation)
|
|
302
|
+
inner_type = (
|
|
303
|
+
_get_optional_inner_type(return_annotation)
|
|
304
|
+
if is_optional
|
|
305
|
+
else return_annotation
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def create_workflow_method(
|
|
309
|
+
method_name: str,
|
|
310
|
+
needs_validation: bool,
|
|
311
|
+
is_optional: bool,
|
|
312
|
+
inner_type: Any,
|
|
313
|
+
original_method: Any,
|
|
314
|
+
) -> Callable[..., Any]:
|
|
315
|
+
@functools.wraps(original_method)
|
|
316
|
+
async def workflow_method(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
317
|
+
# Create activity name
|
|
318
|
+
activity_name = f"{activity_base}.{method_name}"
|
|
319
|
+
|
|
320
|
+
# Set up activity options
|
|
321
|
+
activity_timeout = timedelta(seconds=default_timeout_seconds)
|
|
322
|
+
retry_policy = None
|
|
323
|
+
|
|
324
|
+
# Add retry policy if this method needs it
|
|
325
|
+
if method_name in retry_methods_set:
|
|
326
|
+
retry_policy = fail_fast_retry_policy
|
|
327
|
+
|
|
328
|
+
# Log the call
|
|
329
|
+
logger.debug(
|
|
330
|
+
f"Workflow: Calling {method_name} activity",
|
|
331
|
+
extra={
|
|
332
|
+
"activity_name": activity_name,
|
|
333
|
+
"args_count": len(args),
|
|
334
|
+
"kwargs_count": len(kwargs),
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Prepare arguments (exclude self)
|
|
339
|
+
activity_args = args if args else ()
|
|
340
|
+
|
|
341
|
+
# Handle kwargs - Temporal doesn't support kwargs directly
|
|
342
|
+
if kwargs:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"Keyword arguments not supported in workflow "
|
|
345
|
+
f"proxy for {method_name}. Temporal activities "
|
|
346
|
+
f"only accept positional arguments. Please "
|
|
347
|
+
f"modify the calling code to use positional "
|
|
348
|
+
f"arguments instead of: {list(kwargs.keys())}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Execute the activity
|
|
352
|
+
if activity_args:
|
|
353
|
+
raw_result = await workflow.execute_activity(
|
|
354
|
+
activity_name,
|
|
355
|
+
args=activity_args,
|
|
356
|
+
start_to_close_timeout=activity_timeout,
|
|
357
|
+
retry_policy=retry_policy,
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
raw_result = await workflow.execute_activity(
|
|
361
|
+
activity_name,
|
|
362
|
+
start_to_close_timeout=activity_timeout,
|
|
363
|
+
retry_policy=retry_policy,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Handle return value validation
|
|
367
|
+
result = raw_result
|
|
368
|
+
if needs_validation and raw_result is not None:
|
|
369
|
+
if hasattr(inner_type, "model_validate"):
|
|
370
|
+
result = inner_type.model_validate(
|
|
371
|
+
raw_result,
|
|
372
|
+
context={"temporal_validation": True},
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
# For other types, just return as-is
|
|
376
|
+
result = raw_result
|
|
377
|
+
elif (
|
|
378
|
+
is_optional
|
|
379
|
+
and raw_result is not None
|
|
380
|
+
and hasattr(inner_type, "model_validate")
|
|
381
|
+
):
|
|
382
|
+
result = inner_type.model_validate(
|
|
383
|
+
raw_result, context={"temporal_validation": True}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Log completion
|
|
387
|
+
logger.debug(
|
|
388
|
+
f"Workflow: {method_name} activity completed",
|
|
389
|
+
extra={
|
|
390
|
+
"activity_name": activity_name,
|
|
391
|
+
"result_type": type(result).__name__,
|
|
392
|
+
},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return result
|
|
396
|
+
|
|
397
|
+
return workflow_method
|
|
398
|
+
|
|
399
|
+
# Create and set the method on the class
|
|
400
|
+
workflow_method = create_workflow_method(
|
|
401
|
+
method_name,
|
|
402
|
+
needs_validation,
|
|
403
|
+
is_optional,
|
|
404
|
+
inner_type,
|
|
405
|
+
original_method,
|
|
406
|
+
)
|
|
407
|
+
setattr(cls, method_name, workflow_method)
|
|
408
|
+
wrapped_methods.append(method_name)
|
|
409
|
+
|
|
410
|
+
# Always generate __init__ that calls super() for consistent init
|
|
411
|
+
def __init__(proxy_self: Any) -> None:
|
|
412
|
+
# Call parent __init__ to preserve any existing init logic
|
|
413
|
+
super(cls, proxy_self).__init__()
|
|
414
|
+
# Set instance variables for consistency with manual pattern
|
|
415
|
+
proxy_self.activity_timeout = timedelta(seconds=default_timeout_seconds)
|
|
416
|
+
proxy_self.activity_fail_fast_retry_policy = fail_fast_retry_policy
|
|
417
|
+
logger.debug(f"Initialized {cls.__name__}")
|
|
418
|
+
|
|
419
|
+
setattr(cls, "__init__", __init__)
|
|
420
|
+
|
|
421
|
+
logger.info(
|
|
422
|
+
f"Temporal workflow proxy decorator applied to {cls.__name__}",
|
|
423
|
+
extra={
|
|
424
|
+
"wrapped_methods": wrapped_methods,
|
|
425
|
+
"activity_base": activity_base,
|
|
426
|
+
"default_timeout_seconds": default_timeout_seconds,
|
|
427
|
+
"retry_methods": list(retry_methods_set),
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return cls
|
|
432
|
+
|
|
433
|
+
return decorator
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _needs_pydantic_validation(annotation: Any) -> bool:
|
|
437
|
+
"""Check if a type annotation indicates a Pydantic model."""
|
|
438
|
+
if annotation is None or annotation == inspect.Signature.empty:
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
# Handle Optional types
|
|
442
|
+
if _is_optional_type(annotation):
|
|
443
|
+
inner_type = _get_optional_inner_type(annotation)
|
|
444
|
+
return _is_pydantic_model(inner_type)
|
|
445
|
+
|
|
446
|
+
return _is_pydantic_model(annotation)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _is_pydantic_model(type_hint: Any) -> bool:
|
|
450
|
+
"""Check if a type is a Pydantic model."""
|
|
451
|
+
if inspect.isclass(type_hint) and issubclass(type_hint, BaseModel):
|
|
452
|
+
return True
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _is_optional_type(annotation: Any) -> bool:
|
|
457
|
+
"""Check if a type annotation is Optional[T]."""
|
|
458
|
+
origin = get_origin(annotation)
|
|
459
|
+
if origin is not None:
|
|
460
|
+
args = get_args(annotation)
|
|
461
|
+
# Optional[T] is Union[T, None]
|
|
462
|
+
return (hasattr(origin, "__name__") and origin.__name__ == "UnionType") or (
|
|
463
|
+
str(origin) == "typing.Union" and len(args) == 2 and type(None) in args
|
|
464
|
+
)
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _get_optional_inner_type(annotation: Any) -> Any:
|
|
469
|
+
"""Get the inner type from Optional[T]."""
|
|
470
|
+
args = get_args(annotation)
|
|
471
|
+
if len(args) == 2:
|
|
472
|
+
return args[0] if args[1] is type(None) else args[1]
|
|
473
|
+
return annotation
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Empty __init__.py file to make util/tests a Python package
|