Running work in threads¶
A long computation would freeze the app while it runs. main_view.jobs.start(work, *args)
runs work(job, *args) in a thread of its own instead.
The function must not touch a view, because the widgets of a toolkit belong to the main
thread. The function returns its result. The finished signal of its job emits the result
on the main thread, where the callbacks update the views:
from composeui.core.jobs import Job
from composeui.core.view import MainView, define_view
def simulate(job: Job[float], radius: float) -> float: # in its thread
for step in range(100):
if job.is_canceled:
return float("nan")
... # a step of the computation
return stress
@define_view
class PipeMainView(MainView):
...
def _run(self) -> None:
job = self.jobs.start(simulate, self.form.radius.value)
job.finished.connect(self._show_stress) # on the main thread
def _show_stress(self, stress: float) -> None:
self.results.stress.text = f"{stress:g} MPa"
Errors. When the function raises, the
failedsignal of its job emits the exception on the main thread, where a callback shows it (see Failures).Callbacks connected to the signals of a job get the
main_viewandmodelthat they ask for, as for any signal.Running or not.
jobs.is_runningtells whether any job runs.jobs.running_changedemits it when it changes, for example to disable a Run button.
src/examples/taskview.py runs 25 jobs at once, and cancels
them.
Progress and canceling¶
Progress. In the function,
job.report_progress(value)reports a value of your choice, such as a count, a percentage, or a message.job.progressemits each value on the main thread, in the order of the reports, beforefinishedorfailed. Connect it asfinishedandfailedare. Its callbacks can ask formain_viewandmodeltoo.Annotate the function’s job as
Job[ResultT, ProgressT]: the second type is the type of the values, and of the callbacks’ argument.Job[ResultT]is a job without progress, itsProgressTNone.A dropped job emits none of the values that still wait.
job.call_on_main_thread(callback)runs any other callback on the main thread, in the same order and with the same rule for a dropped job.
Canceling.
job.cancel(), orjobs.cancel()for all the jobs, asks the functions to stop. Python can’t stop a thread, so each function checksjob.is_canceled, or waits withjob.sleep(seconds), which returns False once the job is canceled. The function still finishes, andfinishedemits what it returned.
To report rows processed, connect a handler right after starting the job:
from composeui.core.jobs import Job
from composeui.core.view import MainView
def process(job: Job[int, int], count: int) -> int:
for completed in range(1, count + 1):
if job.is_canceled:
return completed - 1
# Process one row here.
job.report_progress(completed)
return count
def show_progress(completed: int, *, main_view: MainView) -> None:
main_view.status_bar.show_message(f"Processed {completed} rows")
main_view = MainView()
job = main_view.jobs.start(process, 3)
job.progress.connect(show_progress)
main_view.jobs.wait() # Only for a main view with no toolkit; apps use their event loop.
Reusable loading tasks¶
Use main_view.jobs.task(work) when a view repeatedly runs the same worker and shows its
loading state. Configure the task once, then call task.start(*args, **kwargs) for each run.
One changed signal reports starts, progress, success, failure, and resets on the main thread.
After it, finished emits the result of a run that succeeds, and failed the exception of a
run that fails, as the signals of a job do.
from composeui.core.jobs import Job
from composeui.core.view import LabelView, MainView, child, define_view
def count_rows(job: Job[int, int], count: int) -> int:
for completed in range(1, count + 1):
if job.is_canceled:
return completed - 1
job.report_progress(completed)
return count
@define_view
class LoadingView(MainView):
status: LabelView = child(LabelView, text="Ready")
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.loader = self.jobs.task(count_rows)
self.loader.changed.connect(self.show_progress)
self.loader.finished.connect(self.show_rows)
self.loader.failed.connect(self.show_error)
def show_progress(self) -> None:
if self.loader.is_running:
self.status.text = f"Processed {self.loader.progress or 0} rows"
def show_rows(self, rows: int) -> None:
self.status.text = f"Done: {rows} rows"
def show_error(self, error: Exception) -> None:
self.status.text = f"Failed: {error}"
main_view = LoadingView()
main_view.loader.start(3)
main_view.jobs.wait() # Only for a main view with no toolkit.
assert main_view.status.text == "Done: 3 rows"
Its state. These attributes are read-only.
is_runningstays true until the outcome of the run comes to the main thread.progressholds the last value reported, also once the run is over.resultholds the result of a run that succeeded, anderrorthe exception of a run that failed.progress,result, anderrorareNonewhile they have no value.has_resulttells a run that succeeded with a result ofNonefrom an idle task or a task that failed.
Start again.
startclearsprogress,result, anderror, emitschangedwithis_runningtrue, and returnsTrue. While the task runs, anotherstartreturnsFalse: it changes nothing and starts no function. Other tasks and jobs can run at the same time. Afinishedorfailedcallback can start the next run.Cancel and drop.
task.cancel()asks only the current run of this task to stop. Asjob.cancel(), it works from any thread, and the function still returns its outcome.jobs.cancel()cancels every run. New, Open, closing the app, andjobs.drop()clear the state of every task and emitchanged, also for a task that is over. The progress and the outcome of a dropped run never reach the task, so they can’t overwrite a new run.One callback per outcome. Connect
finishedto show a result, andfailedto show an error. Their callbacks get the result or the exception, so they read no state.changedthen only shows that a run is going on. New, Open, andjobs.drop()emit neither.Callbacks and types. The callbacks of
changed,finished, andfailedcan ask formain_viewandmodel. The annotations of the function give the types ofresult,progress, and the arguments ofstart.The main thread. Create a task and call its
starton the main thread. From the function of a job, both raiseCalledInJobError.Errors. A task follows the rules of a job for failures and programming errors. After a programming error,
errorandresultareNone,has_resultis false, and the task can start a new run.Not study data. Create the task in
__attrs_post_init__, as an attribute of the instance, not as a view field. The study then does not save it. New and Open keep the same task, and clear its state with the jobs of the study.
Show a task’s state¶
task.show_state(status, views=...) shows the state of the task in status, a
LabelView, and shows views only once the task has a result. It replaces a changed
callback that shows the loading and the error:
from composeui.core.jobs import Job
from composeui.core.view import ChartView, LabelView, MainView, child, define_view
def read_rows(_job: Job[int], count: int) -> int:
return count
@define_view
class RowsView(MainView):
status: LabelView = child(LabelView)
chart: ChartView = child(ChartView)
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.loader = self.jobs.task(read_rows)
self.loader.show_state(
self.status, views=[self.chart], failed_text="Can't read the rows: {error}"
)
main_view = RowsView()
assert main_view.status.text == "Loading..."
assert not main_view.chart.is_visible
main_view.loader.start(3)
main_view.jobs.wait() # Only for a main view with no toolkit.
assert not main_view.status.is_visible
assert main_view.chart.is_visible
Its texts.
statusshowsloading_text, thenprogress_textonce the run reports progress, orfailed_textafter a failure. By default, they are “Loading…”, “Loading… ({progress})”, and “Failed: {error}”.{progress}and{error}take the task’sprogressanderror, with a format if you give one, such as{progress:,}.Translated. The label translates the texts into the main view’s language. ComposeUI’s catalogs hold the default texts, and the app’s catalogs hold the texts that it gives.
From now on. It shows the state at once, also before the first run:
statusshowsloading_text, andviewsare hidden. New, Open, andjobs.drop()clear the result:viewshide again until the next run has a result.With the other signals. Connect
finishedto give the views their data.changed,finished, andfailedkeep their other callbacks.
The Uber pickups example imports its CSV file in a
loading task. show_state shows the count of rows read or the error in a status line, and
the chart and the map once the task has a result. Its finished callback gives them their
data.
Disable views while a task runs¶
task.disables(*views) disables views while the task runs, such as the fields of its run
and its Run button. task.cancel_with(*controls) cancels the run when one of controls is
clicked: a ButtonView, or an ActionView of a menu or a toolbar, such as Stop. They replace
the callbacks that enable and disable the controls at each start and outcome:
from composeui.core.jobs import Job
from composeui.core.view import (
ButtonView,
FormView,
LabelRowInputView,
MainView,
child,
define_view,
)
def solve(job: Job[str], study: str) -> str:
while job.sleep(0.01): # Solve the study here, a step at a time.
pass
return f"{study}: stopped"
@define_view
class SolverForm(FormView):
study: LabelRowInputView = child(LabelRowInputView, label="Study", text="beam.comm")
@define_view
class SolverView(MainView):
form: SolverForm = child(SolverForm)
run: ButtonView = child(ButtonView, text="Run")
stop: ButtonView = child(ButtonView, text="Stop")
def __attrs_post_init__(self) -> None:
super().__attrs_post_init__()
self.solver = self.jobs.task(solve)
self.solver.disables(self.form.study, self.run)
self.solver.cancel_with(self.stop)
self.run.clicked.connect(lambda: self.solver.start(self.form.study.text))
main_view = SolverView()
assert not main_view.stop.is_enabled
main_view.run.clicked.emit()
assert not main_view.run.is_enabled
main_view.stop.clicked.emit()
assert not main_view.stop.is_enabled # Asked to stop: it can't be asked again.
main_view.jobs.wait() # Only for a main view with no toolkit.
assert main_view.run.is_enabled
assert main_view.solver.result == "beam.comm: stopped"
From now on. The task sets
is_enabledat once, then at each start and outcome: a success, a failure, or the end of a canceled run. New, Open, andjobs.drop()end the run, and the views come back too.Asked to stop.
cancel_with’s controls are enabled only while a run goes on, until it is asked to stop.task.cancel()setsis_canceled, andchangedemits: achangedcallback can show “Stopping…” until the run is over.The task’s views. The task alone sets their
is_enabled: don’t set it from another callback too.
How jobs behave¶
These rules hold for every job, and for the jobs of a task.
On the main thread only. Call
jobs.start,jobs.cancel, andjobs.dropfrom a callback, never from the function of a job. The jobs aren’t locked, because only the main thread changes them (job.cancel()works from any thread). Called from the function of a job, they raiseCalledInJobError. To start another job, return what it needs, and start it from the callback that gets it.Failures. When the function raises, the job’s
failedsignal emits the exception on the main thread. Connect a callback to it to show the error, and to clear what shows that the work runs. Do not do it in the function of the job.A
RefusedError(composeui.core.model) logs a warning with its message, without a traceback. Raise it for an expected refusal, for example of bad input or of a rule of the app.Another exception logs an error with its traceback, even when a
failedcallback shows it. Do not turn an unexpected error into aRefusedErrorto hide its traceback.A
ValueError, aKeyError, or an I/O error follows this path too, because external data can cause it.
Programming errors. An uncaught
CalledInJobError,AssertionError,AttributeError,NameError,NotImplementedError, orTypeErrorleaves the function of the job. The job chooses by the type of the exception, not by its cause.Python reports its traceback through
threading.excepthook.faileddoes not emit it, and a task does not keep it as itserror.The job still stops on the main thread: it no longer counts as running, and its task, if any, goes back to idle and emits
changed. The other jobs go on.Without a toolkit,
jobs.wait()runs this clean-up. It does not raise the error again.A dropped job reports its programming errors too.
New, Open, and closing the app cancel the running jobs and drop their results, because the results belong to the study replaced. Closing doesn’t wait for the jobs either. Open drops the jobs once the views’ state in the file is checked, before the model’s data changes. So an Open that fails still stops the jobs.
On the web, while any job runs, the page asks the server every 300 ms for the changes of the jobs. It stops once they are over.
Tests. When no toolkit shows a main view, its jobs emit their results when the test calls
main_view.jobs.wait(), on the thread of the test (see Test the jobs).