ccflow-celery 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.
@@ -0,0 +1,4 @@
1
+ from .app import *
2
+ from .evaluators import *
3
+
4
+ __version__ = "0.1.0"
ccflow_celery/app.py ADDED
@@ -0,0 +1,61 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from ccflow import BaseModel
4
+ from celery import Celery
5
+ from pydantic import Field
6
+
7
+ __all__ = (
8
+ "CeleryConfig",
9
+ "CeleryApp",
10
+ )
11
+
12
+
13
+ class CeleryConfig(BaseModel):
14
+ """Configuration for a Celery application."""
15
+
16
+ broker_url: str = Field(default="redis://localhost:6379/0", description="Celery broker URL")
17
+ result_backend: Optional[str] = Field(default="redis://localhost:6379/0", description="Celery result backend URL")
18
+ task_serializer: str = "json"
19
+ result_serializer: str = "json"
20
+ accept_content: list = Field(default_factory=lambda: ["json"])
21
+ task_track_started: bool = True
22
+ task_default_queue: str = "default"
23
+ task_routes: Optional[Dict[str, str]] = None
24
+ worker_concurrency: Optional[int] = None
25
+ task_always_eager: bool = Field(default=False, description="If True, tasks execute locally without a broker")
26
+ task_eager_propagates: bool = Field(default=True, description="If True, eager tasks propagate exceptions")
27
+
28
+
29
+ class CeleryApp(BaseModel):
30
+ """Wrapper around a Celery application instance."""
31
+
32
+ name: str = Field(default="ccflow", description="Celery app name")
33
+ config: CeleryConfig = Field(default_factory=CeleryConfig)
34
+
35
+ _app: Any = None
36
+
37
+ def get_app(self):
38
+ if self._app is None:
39
+ self._app = Celery(self.name)
40
+ self._app.conf.update(
41
+ broker_url=self.config.broker_url,
42
+ result_backend=self.config.result_backend,
43
+ task_serializer=self.config.task_serializer,
44
+ result_serializer=self.config.result_serializer,
45
+ accept_content=self.config.accept_content,
46
+ task_track_started=self.config.task_track_started,
47
+ task_default_queue=self.config.task_default_queue,
48
+ )
49
+ if self.config.task_routes:
50
+ self._app.conf.task_routes = self.config.task_routes
51
+ if self.config.worker_concurrency:
52
+ self._app.conf.worker_concurrency = self.config.worker_concurrency
53
+ if self.config.task_always_eager:
54
+ self._app.conf.task_always_eager = True
55
+ self._app.conf.task_eager_propagates = self.config.task_eager_propagates
56
+ # Register the default task so it can be invoked by name
57
+ # (required for eager mode; harmless in broker mode)
58
+ from .tasks import execute_model_task
59
+
60
+ self._app.task(execute_model_task, name="ccflow_celery.tasks.execute_model_task")
61
+ return self._app
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+ from typing import List, Optional
3
+
4
+ from ccflow import ModelRegistry
5
+ from ccflow.utils.hydra import ConfigLoadResult, load_config as base_load_config
6
+
7
+ __all__ = ("load_config",)
8
+
9
+
10
+ def load_config(
11
+ overrides: Optional[List[str]] = None,
12
+ overwrite: bool = False,
13
+ config_dir: Optional[str] = None,
14
+ config_key: Optional[str] = None,
15
+ version_base: Optional[str] = None,
16
+ ) -> ConfigLoadResult:
17
+ parent_dir = str(Path(__file__).resolve().parent)
18
+ result = base_load_config(
19
+ root_config_dir=parent_dir,
20
+ root_config_name="base",
21
+ config_dir=config_dir,
22
+ overrides=overrides,
23
+ version_base=version_base,
24
+ basepath=parent_dir,
25
+ debug=False,
26
+ )
27
+ cfg = result.cfg
28
+ if config_key is not None:
29
+ cfg = cfg[config_key]
30
+ r = ModelRegistry.root()
31
+ r.load_config(cfg, overwrite=overwrite)
32
+ return r
@@ -0,0 +1,2 @@
1
+ defaults:
2
+ - _self_
@@ -0,0 +1,18 @@
1
+ # @package cli.model
2
+ # Celery evaluator — distributed graph execution via Celery workers.
3
+ # Apply via Hydra override: evaluator=ccflow_celery
4
+ #
5
+ # Requires a running Celery broker (e.g. Redis) and result backend.
6
+ # Set broker/backend URLs via environment variables or override in this file.
7
+ evaluator:
8
+ _target_: ccflow.evaluators.MultiEvaluator
9
+ evaluators:
10
+ - _target_: ccflow_celery.CeleryGraphEvaluator
11
+ celery_app:
12
+ _target_: ccflow_celery.CeleryApp
13
+ name: ccflow
14
+ config:
15
+ _target_: ccflow_celery.CeleryConfig
16
+ broker_url: ${oc.env:CELERY_BROKER_URL,redis://localhost:6379/0}
17
+ result_backend: ${oc.env:CELERY_RESULT_BACKEND,redis://localhost:6379/1}
18
+ - _target_: ccflow.evaluators.MemoryCacheEvaluator
@@ -0,0 +1,16 @@
1
+ # @package cli.model
2
+ # Celery evaluator in eager mode — tasks run locally, no broker required.
3
+ # Apply via Hydra override: evaluator=ccflow_celery_eager
4
+ evaluator:
5
+ _target_: ccflow.evaluators.MultiEvaluator
6
+ evaluators:
7
+ - _target_: ccflow_celery.CeleryGraphEvaluator
8
+ app:
9
+ _target_: ccflow_celery.CeleryApp
10
+ name: ccflow
11
+ config:
12
+ _target_: ccflow_celery.CeleryConfig
13
+ broker_url: "memory://"
14
+ result_backend: "cache+memory://"
15
+ task_always_eager: true
16
+ - _target_: ccflow.evaluators.MemoryCacheEvaluator
@@ -0,0 +1,216 @@
1
+ import graphlib
2
+ from logging import getLogger
3
+ from typing import Any, Optional
4
+
5
+ from ccflow import EvaluatorBase, GenericResult
6
+ from ccflow.callable import ModelEvaluationContext
7
+ from ccflow.evaluators.common import get_dependency_graph
8
+ from celery import group
9
+ from pydantic import Field, PrivateAttr
10
+ from typing_extensions import override
11
+
12
+ from .app import CeleryApp
13
+
14
+ __all__ = (
15
+ "CeleryEvaluator",
16
+ "CeleryGraphEvaluator",
17
+ )
18
+
19
+ _log = getLogger(__name__)
20
+
21
+
22
+ def _resolve_origin(cls):
23
+ """Resolve parameterized generics (e.g. GenericContext[int]) to their origin class."""
24
+ meta = getattr(cls, "__pydantic_generic_metadata__", None)
25
+ if meta and meta.get("origin") is not None:
26
+ return meta["origin"]
27
+ return cls
28
+
29
+
30
+ def _model_fqn(model) -> str:
31
+ """Get the fully qualified class name of a model."""
32
+ cls = _resolve_origin(type(model))
33
+ return f"{cls.__module__}.{cls.__qualname__}"
34
+
35
+
36
+ def _context_fqn(context) -> str:
37
+ """Get the fully qualified class name of a context."""
38
+ cls = _resolve_origin(type(context))
39
+ return f"{cls.__module__}.{cls.__qualname__}"
40
+
41
+
42
+ def _serialize_model(model):
43
+ """Serialize a model to (fqn, config_dict) for Celery task dispatch."""
44
+ fqn = _model_fqn(model)
45
+ config = model.model_dump(exclude={"type_"}) if hasattr(model, "model_dump") else {}
46
+ return fqn, config
47
+
48
+
49
+ def _serialize_context(ctx):
50
+ """Serialize a context to (fqn, config_dict) for Celery task dispatch."""
51
+ fqn = _context_fqn(ctx)
52
+ config = ctx.model_dump(exclude={"type_"}) if hasattr(ctx, "model_dump") else {}
53
+ return fqn, config
54
+
55
+
56
+ def _unwrap_eval_ctx(eval_ctx):
57
+ """Unwrap evaluator-wrapping layers to get the actual model and context.
58
+
59
+ The dependency graph may wrap nodes as ModelEvaluationContext(model=evaluator,
60
+ context=ModelEvaluationContext(model=actual_model, context=actual_context)).
61
+ """
62
+ inner = eval_ctx
63
+ while isinstance(inner.context, ModelEvaluationContext):
64
+ inner = inner.context
65
+ return inner.model, inner.context
66
+
67
+
68
+ class CeleryEvaluator(EvaluatorBase):
69
+ """Evaluator that dispatches model execution to Celery workers.
70
+
71
+ Serializes the model and context, submits as a Celery task,
72
+ and waits for the result. The model must be reconstructable from its
73
+ Pydantic config dump.
74
+ """
75
+
76
+ app: CeleryApp = Field(default_factory=CeleryApp)
77
+ timeout: Optional[float] = Field(default=300.0, description="Timeout in seconds for waiting on task result")
78
+ task_name: str = Field(default="ccflow_celery.tasks.execute_model_task", description="Registered Celery task name")
79
+
80
+ _celery_app: Any = PrivateAttr(default=None)
81
+
82
+ def _get_celery_app(self):
83
+ if self._celery_app is None:
84
+ self._celery_app = self.app.get_app()
85
+ return self._celery_app
86
+
87
+ @override
88
+ def __call__(self, context: ModelEvaluationContext) -> Any:
89
+ model = context.model
90
+ ctx = context.context
91
+
92
+ # For __deps__ calls or non-__call__ calls, execute locally
93
+ if hasattr(context, "fn") and context.fn != "__call__":
94
+ return context()
95
+
96
+ # Serialize model and context
97
+ model_class, model_config = _serialize_model(model)
98
+ context_class, context_config = _serialize_context(ctx)
99
+
100
+ celery_app = self._get_celery_app()
101
+
102
+ _log.info("Submitting Celery task: %s(%s)", model_class, context_class)
103
+
104
+ # Send task to Celery
105
+ task = celery_app.send_task(
106
+ self.task_name,
107
+ args=[model_class, model_config, context_class, context_config],
108
+ )
109
+
110
+ # Wait for result
111
+ result_data = task.get(timeout=self.timeout)
112
+ _log.info("Celery task completed: %s", task.id)
113
+
114
+ # Reconstruct result
115
+ if hasattr(model, "result_type"):
116
+ result_cls = model.result_type
117
+ try:
118
+ return result_cls(**result_data)
119
+ except Exception:
120
+ pass
121
+
122
+ return GenericResult(value=result_data)
123
+
124
+
125
+ class CeleryGraphEvaluator(EvaluatorBase):
126
+ """Evaluator that parallelizes DAG execution via Celery.
127
+
128
+ Builds the dependency graph (like GraphEvaluator), then submits
129
+ independent nodes as parallel Celery tasks using Celery groups.
130
+ Nodes that depend on other nodes wait for their dependencies first.
131
+ """
132
+
133
+ app: CeleryApp = Field(default_factory=CeleryApp)
134
+ timeout: Optional[float] = Field(default=600.0, description="Timeout for the entire graph execution")
135
+ task_name: str = Field(default="ccflow_celery.tasks.execute_model_task", description="Registered Celery task name")
136
+
137
+ _celery_app: Any = PrivateAttr(default=None)
138
+ _is_evaluating: bool = PrivateAttr(False)
139
+
140
+ def _get_celery_app(self):
141
+ if self._celery_app is None:
142
+ self._celery_app = self.app.get_app()
143
+ return self._celery_app
144
+
145
+ @override
146
+ def __call__(self, context: ModelEvaluationContext) -> Any:
147
+ # Avoid re-entrancy
148
+ if self._is_evaluating:
149
+ return context()
150
+
151
+ self._is_evaluating = True
152
+ root_result = None
153
+
154
+ try:
155
+ graph = get_dependency_graph(context)
156
+ ts = graphlib.TopologicalSorter(graph.graph)
157
+
158
+ celery_app = self._get_celery_app()
159
+ results_map = {}
160
+
161
+ # Process nodes level by level for maximum parallelism
162
+ ts.prepare()
163
+ while ts.is_active():
164
+ ready_nodes = list(ts.get_ready())
165
+
166
+ # Submit all ready nodes in parallel
167
+ tasks = []
168
+ for key in ready_nodes:
169
+ eval_ctx = graph.ids[key]
170
+ model, ctx = _unwrap_eval_ctx(eval_ctx)
171
+
172
+ model_class, model_config = _serialize_model(model)
173
+ context_class, context_config = _serialize_context(ctx)
174
+
175
+ sig = celery_app.signature(
176
+ self.task_name,
177
+ args=[model_class, model_config, context_class, context_config],
178
+ )
179
+ tasks.append((key, sig))
180
+
181
+ if len(tasks) == 1:
182
+ # Single task — submit directly
183
+ key, sig = tasks[0]
184
+ async_result = sig.apply_async()
185
+ result = async_result.get(timeout=self.timeout)
186
+ results_map[key] = result
187
+ if key == graph.root_id:
188
+ root_result = result
189
+ ts.done(key)
190
+ else:
191
+ # Multiple tasks — use Celery group for parallelism
192
+ keys = [k for k, _ in tasks]
193
+ sigs = [s for _, s in tasks]
194
+ group_result = group(sigs).apply_async()
195
+ group_results = group_result.get(timeout=self.timeout)
196
+
197
+ for key, result in zip(keys, group_results):
198
+ results_map[key] = result
199
+ if key == graph.root_id:
200
+ root_result = result
201
+ ts.done(key)
202
+
203
+ finally:
204
+ self._is_evaluating = False
205
+
206
+ # Reconstruct result for the root node
207
+ if root_result is not None and hasattr(context, "model"):
208
+ model = context.model
209
+ if hasattr(model, "result_type"):
210
+ result_cls = model.result_type
211
+ try:
212
+ return result_cls(**root_result)
213
+ except Exception:
214
+ pass
215
+
216
+ return GenericResult(value=root_result)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,79 @@
1
+ """Fibonacci example using ccflow-celery with local (eager) execution.
2
+
3
+ Demonstrates:
4
+ - Defining a CallableModel with __deps__ for dependency-graph discovery
5
+ - Using CeleryGraphEvaluator in eager mode (no message broker required)
6
+ - Graph-parallel dispatch of Fibonacci sub-problems
7
+
8
+ Usage:
9
+ python -m ccflow_celery.examples.fibonacci
10
+ """
11
+
12
+ from ccflow import CallableModel, Flow, FlowOptionsOverride, GenericContext, GenericResult
13
+ from ccflow.evaluators import MemoryCacheEvaluator, MultiEvaluator
14
+
15
+ from ccflow_celery import CeleryApp, CeleryConfig, CeleryGraphEvaluator
16
+
17
+ __all__ = (
18
+ "FibonacciModel",
19
+ "main",
20
+ )
21
+
22
+
23
+ class FibonacciModel(CallableModel):
24
+ """Compute Fibonacci numbers via ccflow dependency graphs.
25
+
26
+ The graph evaluator calls __deps__ to discover the full dependency tree,
27
+ then dispatches each node as a Celery task in topological order.
28
+ """
29
+
30
+ @Flow.call
31
+ def __call__(self, context: GenericContext[int]) -> GenericResult[int]:
32
+ n = context.value
33
+ if n <= 1:
34
+ return GenericResult[int](value=n)
35
+ a = self(GenericContext[int](value=n - 1)).value
36
+ b = self(GenericContext[int](value=n - 2)).value
37
+ return GenericResult[int](value=a + b)
38
+
39
+ @Flow.deps
40
+ def __deps__(self, context: GenericContext[int]):
41
+ if context.value <= 1:
42
+ return []
43
+ return [
44
+ (
45
+ self,
46
+ [
47
+ GenericContext[int](value=context.value - 2),
48
+ GenericContext[int](value=context.value - 1),
49
+ ],
50
+ )
51
+ ]
52
+
53
+
54
+ def main():
55
+ # Eager mode: tasks run in-process, no Redis/RabbitMQ needed
56
+ config = CeleryConfig(
57
+ broker_url="memory://",
58
+ result_backend="cache+memory://",
59
+ task_always_eager=True,
60
+ )
61
+ app = CeleryApp(name="fibonacci", config=config)
62
+
63
+ evaluator = MultiEvaluator(
64
+ evaluators=[
65
+ CeleryGraphEvaluator(app=app),
66
+ MemoryCacheEvaluator(),
67
+ ]
68
+ )
69
+
70
+ model = FibonacciModel()
71
+
72
+ with FlowOptionsOverride(options={"cacheable": True, "evaluator": evaluator}):
73
+ for n in range(11):
74
+ result = model(GenericContext[int](value=n))
75
+ print(f"fib({n}) = {result.value}")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ main()
ccflow_celery/tasks.py ADDED
@@ -0,0 +1,42 @@
1
+ import importlib
2
+ from logging import getLogger
3
+
4
+ _log = getLogger(__name__)
5
+
6
+ __all__ = ("execute_model_task",)
7
+
8
+
9
+ def execute_model_task(model_class: str, model_config: dict, context_class: str, context_config: dict) -> dict:
10
+ """Execute a ccflow CallableModel on a Celery worker.
11
+
12
+ This is the Celery task function that reconstructs a model and context
13
+ from their serialized configurations and executes the model.
14
+
15
+ Args:
16
+ model_class: Fully qualified class name (e.g., "ccflow_s3.S3Model")
17
+ model_config: Dict of model configuration (pydantic model_dump)
18
+ context_class: Fully qualified class name for the context
19
+ context_config: Dict of context configuration (pydantic model_dump)
20
+
21
+ Returns:
22
+ Dict representation of the result (model_dump)
23
+ """
24
+
25
+ def _import_class(class_path: str):
26
+ module_path, class_name = class_path.rsplit(".", 1)
27
+ module = importlib.import_module(module_path)
28
+ return getattr(module, class_name)
29
+
30
+ model_cls = _import_class(model_class)
31
+ context_cls = _import_class(context_class)
32
+
33
+ model = model_cls(**model_config)
34
+ context = context_cls(**context_config)
35
+
36
+ _log.info("Executing %s with context %s", model_class, context_class)
37
+ result = model(context)
38
+
39
+ if hasattr(result, "model_dump"):
40
+ # Exclude type_ to avoid ccflow's polymorphic validator interfering on reconstruction
41
+ return result.model_dump(exclude={"type_"})
42
+ return {"value": result}
@@ -0,0 +1,340 @@
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ from ccflow import CallableModel, Flow, GenericResult, NullContext
5
+
6
+ from ccflow_celery import * # noqa: F403
7
+ from ccflow_celery.app import CeleryApp, CeleryConfig
8
+ from ccflow_celery.evaluators import CeleryEvaluator, CeleryGraphEvaluator, _context_fqn, _model_fqn
9
+ from ccflow_celery.tasks import execute_model_task
10
+
11
+
12
+ class TestCeleryConfig:
13
+ def test_defaults(self):
14
+ cfg = CeleryConfig()
15
+ assert cfg.broker_url == "redis://localhost:6379/0"
16
+ assert cfg.result_backend == "redis://localhost:6379/0"
17
+ assert cfg.task_serializer == "json"
18
+ assert cfg.result_serializer == "json"
19
+ assert cfg.accept_content == ["json"]
20
+ assert cfg.task_track_started is True
21
+ assert cfg.task_default_queue == "default"
22
+ assert cfg.task_routes is None
23
+ assert cfg.worker_concurrency is None
24
+
25
+ def test_custom(self):
26
+ cfg = CeleryConfig(broker_url="amqp://broker", task_routes={"t": "q"}, worker_concurrency=4)
27
+ assert cfg.broker_url == "amqp://broker"
28
+ assert cfg.task_routes == {"t": "q"}
29
+ assert cfg.worker_concurrency == 4
30
+
31
+
32
+ class TestCeleryApp:
33
+ def test_defaults(self):
34
+ app = CeleryApp()
35
+ assert app.name == "ccflow"
36
+ assert isinstance(app.config, CeleryConfig)
37
+
38
+ @patch("ccflow_celery.app.Celery")
39
+ def test_get_app_creates_celery(self, MockCelery):
40
+ mock_instance = MagicMock()
41
+ MockCelery.return_value = mock_instance
42
+ app = CeleryApp(name="myapp")
43
+ result = app.get_app()
44
+ MockCelery.assert_called_once_with("myapp")
45
+ mock_instance.conf.update.assert_called_once()
46
+ assert result is mock_instance
47
+
48
+ @patch("ccflow_celery.app.Celery")
49
+ def test_get_app_caches(self, MockCelery):
50
+ mock_instance = MagicMock()
51
+ MockCelery.return_value = mock_instance
52
+ app = CeleryApp()
53
+ first = app.get_app()
54
+ second = app.get_app()
55
+ assert first is second
56
+ MockCelery.assert_called_once()
57
+
58
+ @patch("ccflow_celery.app.Celery")
59
+ def test_get_app_applies_task_routes(self, MockCelery):
60
+ mock_instance = MagicMock()
61
+ MockCelery.return_value = mock_instance
62
+ cfg = CeleryConfig(task_routes={"task.name": "queue"})
63
+ app = CeleryApp(config=cfg)
64
+ app.get_app()
65
+ assert mock_instance.conf.task_routes == {"task.name": "queue"}
66
+
67
+ @patch("ccflow_celery.app.Celery")
68
+ def test_get_app_applies_worker_concurrency(self, MockCelery):
69
+ mock_instance = MagicMock()
70
+ MockCelery.return_value = mock_instance
71
+ cfg = CeleryConfig(worker_concurrency=8)
72
+ app = CeleryApp(config=cfg)
73
+ app.get_app()
74
+ assert mock_instance.conf.worker_concurrency == 8
75
+
76
+
77
+ class _DummyContext(NullContext):
78
+ pass
79
+
80
+
81
+ class _DummyModel(CallableModel):
82
+ @Flow.call
83
+ def __call__(self, context: _DummyContext) -> GenericResult:
84
+ return GenericResult(value=None)
85
+
86
+
87
+ class TestFQNHelpers:
88
+ def test_model_fqn(self):
89
+ fqn = _model_fqn(_DummyModel())
90
+ assert fqn.endswith("_DummyModel")
91
+ assert __name__ in fqn or "test_all" in fqn
92
+
93
+ def test_context_fqn(self):
94
+ fqn = _context_fqn(_DummyContext())
95
+ assert fqn.endswith("_DummyContext")
96
+
97
+
98
+ class TestExecuteModelTask:
99
+ def test_basic_execution(self):
100
+ """Round-trip: serialise a simple model+context, execute in the task."""
101
+ model = _DummyModel()
102
+ ctx = _DummyContext()
103
+ model_fqn = _model_fqn(model)
104
+ ctx_fqn = _context_fqn(ctx)
105
+
106
+ result = execute_model_task(model_fqn, model.model_dump(), ctx_fqn, ctx.model_dump())
107
+ assert isinstance(result, dict)
108
+
109
+ def test_result_has_model_dump(self):
110
+ """If the model returns a ResultBase, execute_model_task returns model_dump()."""
111
+ model = _DummyModel()
112
+ ctx = _DummyContext()
113
+ result = execute_model_task(
114
+ _model_fqn(model),
115
+ model.model_dump(),
116
+ _context_fqn(ctx),
117
+ ctx.model_dump(),
118
+ )
119
+ assert isinstance(result, dict)
120
+
121
+ def test_bad_class_path_raises(self):
122
+ with pytest.raises((ModuleNotFoundError, AttributeError)):
123
+ execute_model_task("no.such.Module", {}, "no.such.Context", {})
124
+
125
+
126
+ class _FakeEvalContext:
127
+ """Mimics ModelEvaluationContext for unit testing."""
128
+
129
+ def __init__(self, model, context, fn="__call__"):
130
+ self.model = model
131
+ self.context = context
132
+ self.fn = fn
133
+ self.options = {}
134
+
135
+ def __call__(self):
136
+ return getattr(self.model, self.fn)(self.context) if self.fn == "__call__" else getattr(self.model, self.fn)()
137
+
138
+
139
+ class TestCeleryEvaluator:
140
+ def _make_evaluator(self):
141
+ return CeleryEvaluator(app=CeleryApp())
142
+
143
+ @patch("ccflow_celery.app.Celery")
144
+ def test_remote_dispatch(self, MockCelery):
145
+ mock_app = MagicMock()
146
+ MockCelery.return_value = mock_app
147
+ mock_task = MagicMock()
148
+ mock_task.get.return_value = {"value": 42}
149
+ mock_app.send_task.return_value = mock_task
150
+
151
+ ev = self._make_evaluator()
152
+ model = _DummyModel()
153
+ ctx = _DummyContext()
154
+ eval_ctx = _FakeEvalContext(model, ctx)
155
+
156
+ result = ev(eval_ctx)
157
+
158
+ mock_app.send_task.assert_called_once()
159
+ call_args = mock_app.send_task.call_args
160
+ assert call_args[0][0] == "ccflow_celery.tasks.execute_model_task"
161
+ assert isinstance(result, GenericResult)
162
+
163
+ def test_non_call_fn_runs_locally(self):
164
+ """When fn != '__call__', CeleryEvaluator executes locally."""
165
+ ev = self._make_evaluator()
166
+ model = _DummyModel()
167
+ ctx = _DummyContext()
168
+ eval_ctx = _FakeEvalContext(model, ctx, fn="__call__")
169
+ # Override fn to a non-__call__ to trigger local execution
170
+ eval_ctx.fn = "__deps__"
171
+ # __deps__ doesn't exist on the model, so let's mock it
172
+ model.__deps__ = MagicMock(return_value=[])
173
+ eval_ctx.model = model
174
+ eval_ctx.__call__ = lambda: model.__deps__()
175
+
176
+ _ = ev(eval_ctx)
177
+ model.__deps__.assert_called_once()
178
+
179
+ @patch("ccflow_celery.app.Celery")
180
+ def test_result_reconstruction_with_result_type(self, MockCelery):
181
+ """When model has result_type, evaluator reconstructs from it."""
182
+ mock_app = MagicMock()
183
+ MockCelery.return_value = mock_app
184
+ mock_task = MagicMock()
185
+ mock_task.get.return_value = {"value": "reconstructed"}
186
+ mock_app.send_task.return_value = mock_task
187
+
188
+ ev = self._make_evaluator()
189
+ model = _DummyModel()
190
+ ctx = _DummyContext()
191
+ eval_ctx = _FakeEvalContext(model, ctx)
192
+
193
+ result = ev(eval_ctx)
194
+ # DummyModel inherits result_type from CallableModel → GenericResult
195
+ assert isinstance(result, GenericResult)
196
+ assert result.value == "reconstructed"
197
+
198
+ @patch("ccflow_celery.app.Celery")
199
+ def test_celery_app_caching(self, MockCelery):
200
+ mock_app = MagicMock()
201
+ MockCelery.return_value = mock_app
202
+ mock_task = MagicMock()
203
+ mock_task.get.return_value = {"value": None}
204
+ mock_app.send_task.return_value = mock_task
205
+
206
+ ev = self._make_evaluator()
207
+ model = _DummyModel()
208
+ ctx = _DummyContext()
209
+
210
+ ev(_FakeEvalContext(model, ctx))
211
+ ev(_FakeEvalContext(model, ctx))
212
+
213
+ # Celery instance created only once via CeleryApp.get_app()
214
+ MockCelery.assert_called_once()
215
+
216
+
217
+ class TestCeleryGraphEvaluator:
218
+ def _make_evaluator(self):
219
+ return CeleryGraphEvaluator(app=CeleryApp())
220
+
221
+ def test_reentrant_calls_locally(self):
222
+ """When _is_evaluating is True, graph evaluator executes context() directly."""
223
+ ev = self._make_evaluator()
224
+ ev._is_evaluating = True
225
+
226
+ sentinel = object()
227
+ eval_ctx = MagicMock()
228
+ eval_ctx.return_value = sentinel
229
+
230
+ result = ev(eval_ctx)
231
+ eval_ctx.assert_called_once()
232
+ assert result is sentinel
233
+
234
+ @patch("ccflow_celery.evaluators.get_dependency_graph")
235
+ @patch("ccflow_celery.app.Celery")
236
+ def test_single_node_graph(self, MockCelery, mock_get_dep):
237
+ """Graph with one node dispatches via apply_async directly (no group)."""
238
+ mock_app = MagicMock()
239
+ MockCelery.return_value = mock_app
240
+
241
+ model = _DummyModel()
242
+ ctx = _DummyContext()
243
+ eval_ctx_inner = _FakeEvalContext(model, ctx)
244
+
245
+ # Build a mock dependency graph with one node
246
+ mock_graph = MagicMock()
247
+ root_key = "root_id"
248
+ mock_graph.graph = {root_key: set()} # one node, no deps
249
+ mock_graph.ids = {root_key: eval_ctx_inner}
250
+ mock_graph.root_id = root_key
251
+ mock_get_dep.return_value = mock_graph
252
+
253
+ # Mock signature and async result
254
+ mock_sig = MagicMock()
255
+ mock_async = MagicMock()
256
+ mock_async.get.return_value = {"value": "root_val"}
257
+ mock_sig.apply_async.return_value = mock_async
258
+ mock_app.signature.return_value = mock_sig
259
+
260
+ ev = self._make_evaluator()
261
+ outer_ctx = _FakeEvalContext(model, ctx)
262
+
263
+ result = ev(outer_ctx)
264
+
265
+ mock_app.signature.assert_called_once()
266
+ mock_sig.apply_async.assert_called_once()
267
+ assert isinstance(result, GenericResult)
268
+
269
+ @patch("ccflow_celery.evaluators.get_dependency_graph")
270
+ @patch("ccflow_celery.evaluators.group")
271
+ @patch("ccflow_celery.app.Celery")
272
+ def test_parallel_nodes_use_group(self, MockCelery, mock_group, mock_get_dep):
273
+ """Multiple independent nodes at the same level use Celery group."""
274
+ mock_app = MagicMock()
275
+ MockCelery.return_value = mock_app
276
+
277
+ model = _DummyModel()
278
+ ctx = _DummyContext()
279
+
280
+ # Two independent leaf nodes, then a root depending on both
281
+ leaf1_ctx = _FakeEvalContext(model, ctx)
282
+ leaf2_ctx = _FakeEvalContext(model, ctx)
283
+ root_ctx = _FakeEvalContext(model, ctx)
284
+
285
+ mock_graph = MagicMock()
286
+ mock_graph.graph = {
287
+ "root": {"leaf1", "leaf2"},
288
+ "leaf1": set(),
289
+ "leaf2": set(),
290
+ }
291
+ mock_graph.ids = {
292
+ "leaf1": leaf1_ctx,
293
+ "leaf2": leaf2_ctx,
294
+ "root": root_ctx,
295
+ }
296
+ mock_graph.root_id = "root"
297
+ mock_get_dep.return_value = mock_graph
298
+
299
+ # Mock signature
300
+ mock_sig = MagicMock()
301
+ mock_app.signature.return_value = mock_sig
302
+
303
+ # Mock group result for the two leaves
304
+ mock_group_result = MagicMock()
305
+ mock_group_result.get.return_value = [{"value": "l1"}, {"value": "l2"}]
306
+ mock_group_instance = MagicMock()
307
+ mock_group_instance.apply_async.return_value = mock_group_result
308
+ mock_group.return_value = mock_group_instance
309
+
310
+ # Root will be a single task
311
+ mock_root_async = MagicMock()
312
+ mock_root_async.get.return_value = {"value": "root_val"}
313
+ mock_sig.apply_async.return_value = mock_root_async
314
+
315
+ ev = self._make_evaluator()
316
+ outer_ctx = _FakeEvalContext(model, ctx)
317
+
318
+ result = ev(outer_ctx)
319
+
320
+ # group() should have been called for the parallel leaf nodes
321
+ mock_group.assert_called_once()
322
+ assert isinstance(result, GenericResult)
323
+
324
+ @patch("ccflow_celery.evaluators.get_dependency_graph")
325
+ @patch("ccflow_celery.app.Celery")
326
+ def test_is_evaluating_reset_on_exception(self, MockCelery, mock_get_dep):
327
+ """_is_evaluating is reset even when graph processing raises."""
328
+ mock_app = MagicMock()
329
+ MockCelery.return_value = mock_app
330
+ mock_get_dep.side_effect = RuntimeError("boom")
331
+
332
+ ev = self._make_evaluator()
333
+ model = _DummyModel()
334
+ ctx = _DummyContext()
335
+ outer_ctx = _FakeEvalContext(model, ctx)
336
+
337
+ with pytest.raises(RuntimeError, match="boom"):
338
+ ev(outer_ctx)
339
+
340
+ assert ev._is_evaluating is False
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccflow-celery
3
+ Version: 0.1.0
4
+ Summary: Celery evaluator for ccflow
5
+ Project-URL: Repository, https://github.com/1kbgz/ccflow-celery
6
+ Project-URL: Homepage, https://github.com/1kbgz/ccflow-celery
7
+ Author-email: 1kbgz <dev@1kbgz.com>
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: Implementation :: CPython
19
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: ccflow>=0.6.10
22
+ Requires-Dist: celery
23
+ Provides-Extra: develop
24
+ Requires-Dist: build; extra == 'develop'
25
+ Requires-Dist: bump-my-version; extra == 'develop'
26
+ Requires-Dist: check-dist; extra == 'develop'
27
+ Requires-Dist: codespell; extra == 'develop'
28
+ Requires-Dist: hatchling; extra == 'develop'
29
+ Requires-Dist: mdformat; extra == 'develop'
30
+ Requires-Dist: mdformat-tables>=1; extra == 'develop'
31
+ Requires-Dist: pytest; extra == 'develop'
32
+ Requires-Dist: pytest-cov; extra == 'develop'
33
+ Requires-Dist: ruff; extra == 'develop'
34
+ Requires-Dist: twine; extra == 'develop'
35
+ Requires-Dist: ty; extra == 'develop'
36
+ Requires-Dist: uv; extra == 'develop'
37
+ Requires-Dist: wheel; extra == 'develop'
38
+ Requires-Dist: yardang; extra == 'develop'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # ccflow-celery
42
+
43
+ Celery evaluator for ccflow
44
+
45
+ [![Build Status](https://github.com/1kbgz/ccflow-celery/actions/workflows/build.yaml/badge.svg?branch=main&event=push)](https://github.com/1kbgz/ccflow-celery/actions/workflows/build.yaml)
46
+ [![codecov](https://codecov.io/gh/1kbgz/ccflow-celery/branch/main/graph/badge.svg)](https://codecov.io/gh/1kbgz/ccflow-celery)
47
+ [![License](https://img.shields.io/github/license/1kbgz/ccflow-celery)](https://github.com/1kbgz/ccflow-celery)
48
+ [![PyPI](https://img.shields.io/pypi/v/ccflow-celery.svg)](https://pypi.python.org/pypi/ccflow-celery)
49
+
50
+ ## Overview
51
+
52
+ > [!NOTE]
53
+ > This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base).
@@ -0,0 +1,15 @@
1
+ ccflow_celery/__init__.py,sha256=VQmaEAeel5PlKx76UEpfi2VuU2EFD0xB455hrKmKzE4,68
2
+ ccflow_celery/app.py,sha256=Ci2ntwDNc2n7YHYrWwpzCjqbiUNyT5mafKtFPkrDuc4,2557
3
+ ccflow_celery/evaluators.py,sha256=Iy2luGeaJs2Oq0JtK-Zs9VdA4D7jYLMrxuxZD17LFzI,7550
4
+ ccflow_celery/tasks.py,sha256=WiOXkEld_8GhLcOQ7-oE7vcPX9a8dtGQepReyaPslpM,1490
5
+ ccflow_celery/config/__init__.py,sha256=crVs8X8MDPQGZ0Z6opSstXercD7bLHxQ5pU4eBnwjA8,905
6
+ ccflow_celery/config/base.yaml,sha256=qb0bPXzDk0pb-lyec9eYjipcRbbXzd9g0pW9285QmaQ,21
7
+ ccflow_celery/config/evaluator/ccflow_celery.yaml,sha256=_2cz2EDrdb0SDL-8BiEhirGBJGg1pOQNe3Jpau37XA4,770
8
+ ccflow_celery/config/evaluator/ccflow_celery_eager.yaml,sha256=wn3bdXA9p7UcI2ylu1Fp-jcvdm8eT3Fv3QQEprZPFI4,580
9
+ ccflow_celery/examples/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
10
+ ccflow_celery/examples/fibonacci.py,sha256=_sn0Sdze4BfQWvsCfSZzxL9AepDOS62T7zDPeJjdOIA,2300
11
+ ccflow_celery/tests/test_all.py,sha256=g9rjGrecEEX_9CnyIJX-xuc9jWOSr2E0Whya6Xd5xbs,11930
12
+ ccflow_celery-0.1.0.dist-info/METADATA,sha256=Za2gPukIR08UKhL8WEshu-OJAM6IkEWXAROPr0cUPzY,2414
13
+ ccflow_celery-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
14
+ ccflow_celery-0.1.0.dist-info/licenses/LICENSE,sha256=RcPqVYf2JyhlV4FlBQud0104R3fT1_-7XbT-vp4eLyE,11335
15
+ ccflow_celery-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 1kbgz
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.