axl-workflows 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.
axl/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ AXL Workflows - Lightweight framework for building data and ML workflows.
3
+
4
+ Write once → run anywhere (Dagster locally or Kubeflow in production).
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "Pedro Spinosa"
9
+
10
+ # Core imports
11
+ from .core import Workflow, step, workflow
12
+
13
+ # IO imports
14
+ from .io import (
15
+ CloudpickleIOHandler,
16
+ InMemoryStorage,
17
+ IOHandler,
18
+ LocalFileStorage,
19
+ PickleIOHandler,
20
+ StorageBackend,
21
+ create_storage_from_path,
22
+ registry,
23
+ storage_registry,
24
+ )
25
+
26
+ # Logging imports
27
+ from .logging import WorkflowLogger
28
+
29
+ # Runtime imports
30
+ from .runtime import LocalRuntime
31
+
32
+ __all__ = [
33
+ "workflow",
34
+ "step",
35
+ "Workflow",
36
+ "IOHandler",
37
+ "PickleIOHandler",
38
+ "CloudpickleIOHandler",
39
+ "registry",
40
+ "StorageBackend",
41
+ "LocalFileStorage",
42
+ "InMemoryStorage",
43
+ "storage_registry",
44
+ "create_storage_from_path",
45
+ "LocalRuntime",
46
+ "WorkflowLogger",
47
+ ]
axl/cli.py ADDED
@@ -0,0 +1,322 @@
1
+ """
2
+ CLI entry point for AXL Workflows.
3
+
4
+ This module provides the command-line interface for the axl tool.
5
+ """
6
+
7
+ import importlib
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import typer
13
+ import yaml
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+ from . import __version__
19
+ from .ir import build_ir, get_workflow_info
20
+ from .runtime import LocalRuntime
21
+
22
+ app = typer.Typer(
23
+ name="axl",
24
+ help="AXL Workflows - Lightweight framework for building data and ML workflows",
25
+ add_completion=False,
26
+ )
27
+ console = Console()
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: bool = typer.Option(
33
+ None,
34
+ "--version",
35
+ "-v",
36
+ help="Show version and exit",
37
+ callback=lambda v: typer.echo(f"axl {__version__}") if v else None,
38
+ is_eager=True,
39
+ ),
40
+ ) -> None:
41
+ """
42
+ AXL Workflows - Write once → run anywhere (Dagster locally or Kubeflow in production).
43
+ """
44
+ pass
45
+
46
+
47
+ @app.command()
48
+ def version() -> None:
49
+ """Show version information."""
50
+ console.print(Panel(f"AXL Workflows v{__version__}", title="Version"))
51
+
52
+
53
+ def load_parameters(params_file: str | None) -> dict[str, Any]:
54
+ """
55
+ Load parameters from YAML file.
56
+
57
+ Args:
58
+ params_file: Path to YAML parameters file
59
+
60
+ Returns:
61
+ Dictionary of parameters
62
+
63
+ Raises:
64
+ typer.Exit: If file not found or invalid YAML
65
+ """
66
+ if not params_file:
67
+ return {}
68
+
69
+ try:
70
+ params_path = Path(params_file)
71
+ if not params_path.exists():
72
+ raise FileNotFoundError(f"Parameters file not found: {params_path}")
73
+
74
+ with open(params_path) as f:
75
+ params = yaml.safe_load(f)
76
+
77
+ if not isinstance(params, dict):
78
+ raise ValueError("Parameters file must contain a YAML dictionary")
79
+
80
+ console.print(
81
+ f"[green]Loaded {len(params)} parameters from {params_file}[/green]"
82
+ )
83
+ return params
84
+
85
+ except Exception as e:
86
+ console.print(f"[red]Error loading parameters: {e}[/red]")
87
+ raise typer.Exit(1) from None
88
+
89
+
90
+ def load_workflow_class(module_path: str) -> type:
91
+ """
92
+ Load workflow class from module path.
93
+
94
+ Args:
95
+ module_path: Module path in format 'module:Class' or 'path/to/file.py:Class'
96
+
97
+ Returns:
98
+ Workflow class
99
+
100
+ Raises:
101
+ typer.Exit: If module or class not found
102
+ """
103
+ try:
104
+ if ":" not in module_path:
105
+ raise ValueError(
106
+ "Module path must be in format 'module:Class' or 'path/to/file.py:Class'"
107
+ )
108
+
109
+ module_spec, class_name = module_path.split(":", 1)
110
+
111
+ # Handle file paths
112
+ if module_spec.endswith(".py"):
113
+ file_path = Path(module_spec)
114
+ if not file_path.exists():
115
+ raise FileNotFoundError(f"File not found: {file_path}")
116
+
117
+ # Add parent directory to sys.path
118
+ sys.path.insert(0, str(file_path.parent))
119
+ module_spec = file_path.stem
120
+
121
+ # Import module
122
+ module = importlib.import_module(module_spec)
123
+
124
+ # Get class
125
+ if not hasattr(module, class_name):
126
+ raise AttributeError(
127
+ f"Class '{class_name}' not found in module '{module_spec}'"
128
+ )
129
+
130
+ workflow_class = getattr(module, class_name)
131
+
132
+ # Validate it's a workflow class
133
+ if not hasattr(workflow_class, "_is_workflow"):
134
+ raise ValueError(
135
+ f"Class '{class_name}' is not a workflow class (missing @workflow decorator)"
136
+ )
137
+
138
+ return workflow_class # type: ignore[no-any-return]
139
+
140
+ except Exception as e:
141
+ console.print(f"[red]Error loading workflow: {e}[/red]")
142
+ raise typer.Exit(1) from None
143
+
144
+
145
+ @app.command()
146
+ def validate(
147
+ module: str = typer.Argument(
148
+ ...,
149
+ help="Module path to workflow class (e.g., 'myflow:MyWorkflow')",
150
+ ),
151
+ ) -> None:
152
+ """Validate a workflow definition."""
153
+ try:
154
+ console.print(f"[yellow]Validating workflow: {module}[/yellow]")
155
+
156
+ # Load workflow class
157
+ workflow_class = load_workflow_class(module)
158
+
159
+ # Get basic info
160
+ info = get_workflow_info(workflow_class)
161
+
162
+ # Display workflow info
163
+ table = Table(title="Workflow Information")
164
+ table.add_column("Property", style="cyan")
165
+ table.add_column("Value", style="green")
166
+
167
+ table.add_row("Name", info["name"])
168
+ table.add_row("Image", info["image"])
169
+ table.add_row("IO Handler", info["io_handler"])
170
+ table.add_row("Steps", str(info["step_count"]))
171
+
172
+ console.print(table)
173
+
174
+ # Validate workflow
175
+ console.print("\n[yellow]Building IR and validating...[/yellow]")
176
+ ir_workflow = build_ir(workflow_class)
177
+
178
+ # Display validation results
179
+ console.print("[green]✅ Workflow is valid![/green]")
180
+
181
+ # Show step details
182
+ step_table = Table(title="Steps")
183
+ step_table.add_column("Step", style="cyan")
184
+ step_table.add_column("Inputs", style="blue")
185
+ step_table.add_column("IO Handler", style="green")
186
+
187
+ for node in ir_workflow.nodes:
188
+ inputs = ", ".join(node.inputs) if node.inputs else "None"
189
+ io_handler = node.get_io_handler() or "default"
190
+ step_table.add_row(node.name, inputs, io_handler)
191
+
192
+ console.print(step_table)
193
+
194
+ except Exception as e:
195
+ console.print(f"[red]❌ Validation failed: {e}[/red]")
196
+ raise typer.Exit(1) from None
197
+
198
+
199
+ @app.command()
200
+ def compile(
201
+ module: str = typer.Argument(
202
+ ...,
203
+ help="Module path to workflow class (e.g., 'myflow:MyWorkflow')",
204
+ ),
205
+ target: str = typer.Option(
206
+ "argo",
207
+ "--target",
208
+ "-t",
209
+ help="Target backend (argo, dagster)",
210
+ ),
211
+ out: str = typer.Option(
212
+ None,
213
+ "--out",
214
+ "-o",
215
+ help="Output file path",
216
+ ),
217
+ ) -> None:
218
+ """Compile a workflow to target backend."""
219
+ # TODO: Implement workflow compilation
220
+ console.print(f"[yellow]Compiling {module} to {target}[/yellow]")
221
+ if out:
222
+ console.print(f"[yellow]Output: {out}[/yellow]")
223
+ console.print("[red]Not implemented yet[/red]")
224
+
225
+
226
+ @app.command()
227
+ def run(
228
+ backend: str = typer.Argument(
229
+ "local",
230
+ help="Backend to run on (local, argo, dagster)",
231
+ ),
232
+ module: str = typer.Option(
233
+ None,
234
+ "--module",
235
+ "-m",
236
+ help="Module path to workflow class (e.g., 'examples/iris_example.py:IrisKNN')",
237
+ ),
238
+ storage_backend: str = typer.Option(
239
+ "memory",
240
+ "--storage",
241
+ help="Storage backend for artifacts (memory|local)",
242
+ ),
243
+ workspace: str = typer.Option(
244
+ "axl_workspace",
245
+ "--workspace",
246
+ help="Workspace directory for local storage backend",
247
+ ),
248
+ params: str = typer.Option(
249
+ None,
250
+ "--params",
251
+ "-p",
252
+ help="Parameters file (YAML) to pass to workflow constructor",
253
+ ),
254
+ ) -> None:
255
+ """Run a workflow on the specified backend.
256
+
257
+ Currently implemented: local backend using LocalRuntime.
258
+ """
259
+ try:
260
+ console.print(f"[yellow]Running workflow on {backend}[/yellow]")
261
+
262
+ if backend != "local":
263
+ console.print("[red]Only 'local' backend is implemented for now[/red]")
264
+ raise typer.Exit(1)
265
+
266
+ if not module:
267
+ console.print("[red]--module is required for local execution[/red]")
268
+ raise typer.Exit(1)
269
+
270
+ # Load parameters if provided
271
+ workflow_params = load_parameters(params)
272
+
273
+ # Load workflow class
274
+ workflow_class = load_workflow_class(module)
275
+
276
+ # Build IR
277
+ ir_workflow = build_ir(workflow_class)
278
+
279
+ # Instantiate workflow with parameters
280
+ wf_instance = workflow_class(**workflow_params)
281
+
282
+ # Execute locally
283
+ console.print(
284
+ f"[yellow]Executing locally (storage={storage_backend}, workspace='{workspace}')[/yellow]"
285
+ )
286
+ runtime = LocalRuntime(
287
+ workspace_path=workspace, storage_backend=storage_backend
288
+ )
289
+ runtime.execute_workflow(ir_workflow, wf_instance)
290
+
291
+ console.print(Panel("Local run completed", title="Run"))
292
+
293
+ except typer.Exit:
294
+ raise
295
+ except Exception as e:
296
+ console.print(f"[red]Run failed: {e}[/red]")
297
+ raise typer.Exit(1) from None
298
+
299
+
300
+ @app.command()
301
+ def render(
302
+ module: str = typer.Argument(
303
+ ...,
304
+ help="Module path to workflow class",
305
+ ),
306
+ out: str = typer.Option(
307
+ None,
308
+ "--out",
309
+ "-o",
310
+ help="Output file path (e.g., dag.png)",
311
+ ),
312
+ ) -> None:
313
+ """Render workflow DAG as a graph."""
314
+ # TODO: Implement DAG rendering
315
+ console.print(f"[yellow]Rendering DAG for {module}[/yellow]")
316
+ if out:
317
+ console.print(f"[yellow]Output: {out}[/yellow]")
318
+ console.print("[red]Not implemented yet[/red]")
319
+
320
+
321
+ if __name__ == "__main__":
322
+ app()
@@ -0,0 +1,11 @@
1
+ """
2
+ Compilers for AXL Workflows.
3
+
4
+ This module contains backend-specific compilers that convert IR to target formats.
5
+ """
6
+
7
+ # TODO: Implement compiler modules
8
+ # from .argo import ArgoCompiler
9
+ # from .dagster import DagsterCompiler
10
+
11
+ __all__: list[str] = []
axl/core/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ Core DSL components for AXL Workflows.
3
+
4
+ This module contains the main decorators and base classes for defining workflows.
5
+ """
6
+
7
+ from .decorators import step, workflow
8
+ from .workflow import Workflow
9
+
10
+ __all__ = ["workflow", "step", "Workflow"]
axl/core/decorators.py ADDED
@@ -0,0 +1,158 @@
1
+ """
2
+ Decorators for AXL Workflows.
3
+
4
+ This module contains the @workflow and @step decorators for defining workflows.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from functools import wraps
11
+ from typing import Any, Protocol, TypedDict, TypeVar, cast, runtime_checkable
12
+
13
+ from ..ir import validate_step_args
14
+ from .workflow import Workflow
15
+
16
+ T = TypeVar("T", bound=type[Workflow])
17
+
18
+
19
+ class StepConfig(TypedDict, total=False):
20
+ """Typed configuration attached to step functions at decoration time."""
21
+
22
+ io_handler: str
23
+ input_mode: str | dict[str, str]
24
+ resources: dict[str, Any]
25
+ retries: int
26
+ env: dict[str, str]
27
+
28
+
29
+ @runtime_checkable
30
+ class StepFunction(Protocol):
31
+ """Protocol for functions decorated with @step."""
32
+
33
+ _is_step: bool
34
+ _step_config: StepConfig
35
+ _original_func: Callable[..., Any]
36
+
37
+ def __call__(self, *args: Any, **kwargs: Any) -> Any: ...
38
+
39
+
40
+ def workflow(
41
+ *,
42
+ name: str | None = None,
43
+ image: str | None = None,
44
+ io_handler: str | None = None,
45
+ ) -> Callable[[T], T]:
46
+ """
47
+ Decorator for defining workflows with configuration overrides.
48
+
49
+ Args:
50
+ name: Workflow name (optional, defaults to class name)
51
+ image: Docker image override
52
+ io_handler: IO handler override
53
+
54
+ Returns:
55
+ Decorated workflow class
56
+
57
+ Example:
58
+ @workflow(name="my-workflow", image="custom:latest")
59
+ class MyWorkflow(Workflow):
60
+ def graph(self):
61
+ return self.step1()
62
+ """
63
+
64
+ def decorator(cls: T) -> T:
65
+ # Validate that the class inherits from Workflow
66
+ if not issubclass(cls, Workflow):
67
+ raise TypeError(f"Class {cls.__name__} must inherit from Workflow")
68
+
69
+ # Store original __init__ method
70
+ original_init = cls.__init__
71
+
72
+ def new_init(self: Workflow, *args: Any, **kwargs: Any) -> None:
73
+ # Apply decorator overrides to kwargs
74
+ if image is not None:
75
+ kwargs["image"] = image
76
+ if io_handler is not None:
77
+ kwargs["io_handler"] = io_handler
78
+
79
+ # Call original __init__ with updated kwargs
80
+ original_init(self, *args, **kwargs)
81
+
82
+ # Set workflow name (prefer decorator override)
83
+ self.name = name or cls.__name__
84
+
85
+ # Replace the __init__ method
86
+ cls.__init__ = new_init # type: ignore[method-assign]
87
+
88
+ # Add workflow metadata on the class (dynamic attrs by design)
89
+ cls._is_workflow = True # type: ignore[attr-defined]
90
+ cls._workflow_name = name or cls.__name__ # type: ignore[attr-defined]
91
+
92
+ return cls
93
+
94
+ return decorator
95
+
96
+
97
+ def step(
98
+ *,
99
+ io_handler: str | None = None,
100
+ input_mode: str | dict[str, str] | None = None,
101
+ resources: dict[str, Any] | None = None,
102
+ retries: int | None = None,
103
+ env: dict[str, str] | None = None,
104
+ ) -> Callable[[Callable[..., Any]], StepFunction]:
105
+ """
106
+ Decorator for defining workflow steps with configuration.
107
+
108
+ Args:
109
+ io_handler: IO handler override for this step
110
+ input_mode: Input mode override ("object", "path", or {arg: mode})
111
+ resources: Resource requirements (CPU, memory, etc.)
112
+ retries: Number of retries for this step
113
+ env: Environment variables for this step
114
+
115
+ Returns:
116
+ Decorated step function
117
+
118
+ Example:
119
+ @step(io_handler="parquet", retries=3)
120
+ def preprocess(self, data):
121
+ return processed_data
122
+ """
123
+
124
+ def decorator(func: Callable[..., Any]) -> StepFunction:
125
+ # Build step configuration (only include provided keys)
126
+ step_cfg: StepConfig = {}
127
+ if io_handler is not None:
128
+ step_cfg["io_handler"] = io_handler
129
+ if input_mode is not None:
130
+ step_cfg["input_mode"] = input_mode
131
+ if resources is not None:
132
+ step_cfg["resources"] = resources
133
+ if retries is not None:
134
+ step_cfg["retries"] = retries
135
+ if env is not None:
136
+ step_cfg["env"] = env
137
+
138
+ # Attach attributes to the original function (ruff prefers direct assigns)
139
+ cast(Any, func)._is_step = True
140
+ cast(Any, func)._step_config = step_cfg
141
+
142
+ # Create a wrapper that validates arguments
143
+ @wraps(func)
144
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
145
+ # Validate that all arguments are OutputRef instances
146
+ validate_step_args(func.__name__, args, kwargs)
147
+ # Call the original function
148
+ return func(*args, **kwargs)
149
+
150
+ # Copy attributes to wrapper so runtime can read them
151
+ cast(Any, wrapper)._is_step = True
152
+ cast(Any, wrapper)._step_config = step_cfg
153
+ cast(Any, wrapper)._original_func = func
154
+
155
+ # Explicit cast so mypy understands wrapper carries the attributes
156
+ return cast(StepFunction, wrapper)
157
+
158
+ return decorator
axl/core/workflow.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ Workflow base class for AXL Workflows.
3
+
4
+ This module contains the core Workflow base class with configuration
5
+ and validation capabilities.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from ..ir import OutputRef
11
+ from ..logging import WorkflowLogger
12
+
13
+
14
+ class Workflow:
15
+ """Base class for workflows with defaults and configuration."""
16
+
17
+ def __init__(
18
+ self,
19
+ image: str = "ghcr.io/axl-workflows/runner:latest",
20
+ io_handler: str = "pickle",
21
+ **params: Any,
22
+ ) -> None:
23
+ """
24
+ Initialize workflow with configuration and parameters.
25
+
26
+ Args:
27
+ image: Docker image for workflow execution
28
+ io_handler: IO handler for step outputs (pickle, parquet, numpy, torch)
29
+ **params: Additional parameters passed to the workflow
30
+ """
31
+ self.image = image
32
+ self.io_handler = io_handler
33
+ self.name: str = "" # Will be set by @workflow decorator
34
+
35
+ # Store parameters
36
+ self.workflow_params = params
37
+
38
+ # Initialize logger for this workflow instance
39
+ self.log = WorkflowLogger(self.name)
40
+
41
+ # Validate configuration
42
+ self._validate_config()
43
+
44
+ def _validate_config(self) -> None:
45
+ """Validate workflow configuration."""
46
+ if self.io_handler not in ["pickle", "parquet", "numpy", "torch"]:
47
+ raise ValueError(
48
+ f"io_handler must be one of ['pickle', 'parquet', 'numpy', 'torch'], "
49
+ f"got {self.io_handler}"
50
+ )
51
+
52
+ def configure(self, **kwargs: Any) -> None:
53
+ """
54
+ Configure workflow settings after initialization.
55
+
56
+ Args:
57
+ **kwargs: Configuration options to update
58
+ """
59
+ for key, value in kwargs.items():
60
+ if hasattr(self, key):
61
+ setattr(self, key, value)
62
+ else:
63
+ raise ValueError(f"Unknown configuration option: {key}")
64
+
65
+ # Re-validate after configuration changes
66
+ self._validate_config()
67
+
68
+ def get_workflow_config(self) -> dict[str, Any]:
69
+ """
70
+ Get workflow configuration for IR building.
71
+
72
+ Returns:
73
+ Dictionary with workflow configuration
74
+ """
75
+ return {
76
+ "image": self.image,
77
+ "io_handler": self.io_handler,
78
+ }
79
+
80
+ def graph(self) -> Any:
81
+ """
82
+ Define the workflow graph. Must be implemented by subclasses.
83
+
84
+ Returns:
85
+ Workflow output (typically the final step result as OutputRef)
86
+ """
87
+ raise NotImplementedError("graph method must be implemented")
88
+
89
+ def __getattribute__(self, name: str) -> Any:
90
+ """
91
+ Intercept step method calls to return OutputRef instances.
92
+
93
+ Step method calls always return OutputRef instances for IR building.
94
+
95
+ Args:
96
+ name: Method name to look up
97
+
98
+ Returns:
99
+ OutputRef if it's a step method, otherwise normal attribute
100
+ """
101
+ # Get the attribute normally first
102
+ attr = object.__getattribute__(self, name)
103
+
104
+ # Check if this is a step method
105
+ if hasattr(attr, "_is_step") and attr._is_step:
106
+ # Create OutputRef with step configuration
107
+ output_ref = OutputRef(name)
108
+ if hasattr(attr, "_step_config"):
109
+ output_ref.metadata["step_config"] = attr._step_config
110
+ return output_ref
111
+
112
+ return attr
113
+
114
+ def __repr__(self) -> str:
115
+ """String representation of the workflow."""
116
+ return (
117
+ f"{self.__class__.__name__}("
118
+ f"image='{self.image}', "
119
+ f"io_handler='{self.io_handler}')"
120
+ )
axl/io/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """IO handlers for AXL Workflows."""
2
+
3
+ from .handlers import CloudpickleIOHandler, PickleIOHandler
4
+ from .protocol import IOHandler
5
+ from .registry import IOHandlerRegistry, registry
6
+ from .storage import (
7
+ InMemoryStorage,
8
+ LocalFileStorage,
9
+ StorageBackend,
10
+ StorageRegistry,
11
+ create_storage_from_path,
12
+ storage_registry,
13
+ )
14
+
15
+ __all__ = [
16
+ "IOHandler",
17
+ "PickleIOHandler",
18
+ "CloudpickleIOHandler",
19
+ "IOHandlerRegistry",
20
+ "registry",
21
+ "StorageBackend",
22
+ "LocalFileStorage",
23
+ "InMemoryStorage",
24
+ "StorageRegistry",
25
+ "storage_registry",
26
+ "create_storage_from_path",
27
+ ]
@@ -0,0 +1,9 @@
1
+ """IO handler implementations for AXL Workflows."""
2
+
3
+ from .cloudpickle import CloudpickleIOHandler
4
+ from .pickle import PickleIOHandler
5
+
6
+ __all__ = [
7
+ "PickleIOHandler",
8
+ "CloudpickleIOHandler",
9
+ ]