4141from typing import Any , Callable
4242from uuid import uuid4
4343
44+ from .syntax import ScriptSyntax
4445from .util .types import Args , decode , encode
4546
4647
48+ class TaskException (Exception ):
49+ """
50+ Exception raised when a Task fails to complete successfully.
51+
52+ This exception is raised by Task.wait_for() when the task finishes
53+ in a non-successful state (FAILED, CANCELED, or CRASHED).
54+ """
55+
56+ def __init__ (self , message : str , task : "Task" ) -> None :
57+ super ().__init__ (message )
58+ self .task = task
59+
60+ @property
61+ def status (self ) -> "TaskStatus" :
62+ """Returns the status of the failed task."""
63+ return self .task .status
64+
65+ @property
66+ def task_error (self ) -> str | None :
67+ """Returns the error message from the task, if available."""
68+ return self .task .error
69+
70+
4771class Service :
4872 """
4973 An Appose *service* provides access to a linked Appose *worker* running
@@ -54,7 +78,9 @@ class Service:
5478
5579 _service_count : int = 0
5680
57- def __init__ (self , cwd : str | Path , args : list [str ]) -> None :
81+ def __init__ (
82+ self , cwd : str | Path , args : list [str ], syntax : ScriptSyntax | None = None
83+ ) -> None :
5884 self ._cwd : Path = Path (cwd )
5985 self ._args : list [str ] = args [:]
6086 self ._tasks : dict [str , "Task" ] = {}
@@ -65,6 +91,16 @@ def __init__(self, cwd: str | Path, args: list[str]) -> None:
6591 self ._stderr_thread : threading .Thread | None = None
6692 self ._monitor_thread : threading .Thread | None = None
6793 self ._debug_callback : Callable [[Any ], Any ] | None = None
94+ self ._syntax : ScriptSyntax | None = syntax
95+
96+ def syntax (self ) -> ScriptSyntax | None :
97+ """
98+ Returns the script syntax associated with this service.
99+
100+ Returns:
101+ The script syntax, or None if not configured.
102+ """
103+ return self ._syntax
68104
69105 def debug (self , debug_callback : Callable [[Any ], Any ]) -> None :
70106 """
@@ -126,6 +162,110 @@ def task(
126162 self .start ()
127163 return Task (self , script , inputs , queue )
128164
165+ def get_var (self , name : str ) -> Any :
166+ """
167+ Retrieves a variable's value from the worker process's global scope.
168+
169+ The variable must have been previously exported using task.export()
170+ to be accessible across tasks.
171+
172+ Args:
173+ name: The name of the variable to retrieve.
174+
175+ Returns:
176+ The value of the variable.
177+
178+ Raises:
179+ TaskException: If the variable retrieval fails.
180+ ValueError: If no script syntax has been configured for this service.
181+ """
182+ if self ._syntax is None :
183+ raise ValueError ("No script syntax configured for this service" )
184+ script = self ._syntax .get_var (name )
185+ task = self .task (script ).wait_for ()
186+ return task .outputs .get ("result" )
187+
188+ def put_var (self , name : str , value : Any ) -> None :
189+ """
190+ Sets a variable in the worker process's global scope and exports it
191+ for future use across tasks.
192+
193+ Args:
194+ name: The name of the variable to set in the worker process.
195+ value: The value to assign to the variable.
196+
197+ Raises:
198+ TaskException: If the variable assignment fails.
199+ ValueError: If no script syntax has been configured for this service.
200+ """
201+ if self ._syntax is None :
202+ raise ValueError ("No script syntax configured for this service" )
203+ inputs = {"_value" : value }
204+ script = self ._syntax .put_var (name , "_value" )
205+ self .task (script , inputs ).wait_for ()
206+
207+ def call (self , function : str , * args : Any ) -> Any :
208+ """
209+ Calls a function in the worker process with the given arguments and
210+ returns the result.
211+
212+ The function must be accessible in the worker's global scope (either
213+ built-in or previously defined/imported).
214+
215+ Args:
216+ function: The name of the function to call in the worker process.
217+ *args: The arguments to pass to the function.
218+
219+ Returns:
220+ The result of the function call.
221+
222+ Raises:
223+ TaskException: If the function call fails.
224+ ValueError: If no script syntax has been configured for this service.
225+ """
226+ if self ._syntax is None :
227+ raise ValueError ("No script syntax configured for this service" )
228+ inputs = {}
229+ var_names = []
230+ for i , arg in enumerate (args ):
231+ var_name = f"arg{ i } "
232+ inputs [var_name ] = arg
233+ var_names .append (var_name )
234+ script = self ._syntax .call (function , var_names )
235+ task = self .task (script , inputs ).wait_for ()
236+ return task .outputs .get ("result" )
237+
238+ def proxy (self , var : str , api : type , queue : str | None = None ) -> Any :
239+ """
240+ Creates a proxy object providing strongly typed access to a remote
241+ object in this service's worker process.
242+
243+ Method calls on the proxy are transparently forwarded to the remote
244+ object via Tasks.
245+
246+ Important: The variable must be explicitly exported using
247+ task.export(varName=value) in a previous task. Only exported variables
248+ are accessible across tasks within the same service.
249+
250+ Args:
251+ var: The name of the exported variable in the worker process
252+ referencing the remote object.
253+ api: The interface/protocol class that the proxy should implement.
254+ queue: Optional queue identifier for task execution. Pass "main" to
255+ ensure execution on the worker's main thread.
256+
257+ Returns:
258+ A proxy object that forwards method calls to the remote object.
259+
260+ Raises:
261+ ValueError: If no script syntax has been configured for this service.
262+ """
263+ if self ._syntax is None :
264+ raise ValueError ("No script syntax configured for this service" )
265+ from .util .proxy import create
266+
267+ return create (self , var , queue )
268+
129269 def close (self ) -> None :
130270 """
131271 Close the worker process's input stream, in order to shut it down.
@@ -349,16 +489,56 @@ def listen(self, listener: Callable[["TaskEvent"], None]) -> None:
349489
350490 self .listeners .append (listener )
351491
352- def wait_for (self ) -> None :
492+ def wait_for (self ) -> "Task" :
493+ """
494+ Wait for this task to complete.
495+
496+ Returns:
497+ This task (for method chaining).
498+
499+ Raises:
500+ TaskException: If the task fails, is canceled, or crashes.
501+ """
353502 with self .cv :
354503 if self .status == TaskStatus .INITIAL :
355504 self .start ()
356505
357506 if self .status not in (TaskStatus .QUEUED , TaskStatus .RUNNING ):
358- return
507+ # Task already finished - check if we need to raise
508+ if self .status != TaskStatus .COMPLETE :
509+ self ._raise_if_failed ()
510+ return self
359511
360512 self .cv .wait ()
361513
514+ # After waiting, check if task failed
515+ self ._raise_if_failed ()
516+ return self
517+
518+ def result (self ) -> Any :
519+ """
520+ Returns the result of this task.
521+
522+ This is a convenience method that returns outputs["result"].
523+ For tasks that return a single value (e.g., from an expression),
524+ that value is stored in outputs["result"].
525+
526+ Returns:
527+ The task's result value.
528+ """
529+ return self .outputs .get ("result" )
530+
531+ def _raise_if_failed (self ) -> None :
532+ """Raise TaskException if this task is in a failed state."""
533+ if self .status == TaskStatus .FAILED :
534+ error_msg = self .error if self .error else "Unknown error"
535+ raise TaskException (f"Task failed: { error_msg } " , self )
536+ elif self .status == TaskStatus .CANCELED :
537+ raise TaskException ("Task was canceled" , self )
538+ elif self .status == TaskStatus .CRASHED :
539+ error_msg = self .error if self .error else "Worker process crashed"
540+ raise TaskException (f"Task crashed: { error_msg } " , self )
541+
362542 def cancel (self ) -> None :
363543 """
364544 Send a task cancelation request to the worker process.
0 commit comments