cs-taskqueue 20250120
A general purpose Task and TaskQueue for running tasks with dependencies and failure/retry, potentially in parallel.
Keywords: python3

A general purpose Task and TaskQueue for running tasks with
dependencies and failure/retry, potentially in parallel.

*Latest release 20250120*:
BaseTask.__init__: accept the state as positional or keyword.

## <a name="BaseTask"></a>Class `BaseTask(cs.fsm.FSM, cs.resources.RunStateMixin)`

A base class subclassing `cs.fsm.FSM` with a `RunStateMixin`.

Note that this class and the `FSM` base class does not provide
a `FSM_DEFAULT_STATE` attribute; a default `state` value of
`None` will leave `.fsm_state` _unset_.

This behaviour is is chosen mostly to support subclasses
with unusual behaviour, particularly Django's `Model` class
whose `refresh_from_db` method seems to not refresh fields
which already exist, and setting `.fsm_state` from a
`FSM_DEFAULT_STATE` class attribute thus breaks this method.
Subclasses of this class and `Model` should _not_ provide a
`FSM_DEFAULT_STATE` attribute, instead relying on the field
definition to provide this default in the usual way.

<figure>
    <svg width="8pt" height="8pt"
   viewBox="0.00 0.00 8.00 8.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 4)">
  <title>BaseTask State Diagram</title>
  <polygon fill="white" stroke="none" points="-4,4 -4,-4 4,-4 4,4 -4,4"/>
  </g>
  </svg>
  <figcaption>BaseTask State Diagram</figcaption>
</figure>


*`BaseTask.as_dot(self, name=None, **kw)`*:
Return a DOT syntax digraph starting at this `Task`.
Parameters are as for `Task.tasks_as_dot`.

*`BaseTask.dot_node_label(self)`*:
The default DOT node label.

*`BaseTask.tasks_as_dot(tasks, name=None, *, follow_blocking=False, sep=None)`*:
Return a DOT syntax digraph of the iterable `tasks`.
        Nodes will be coloured according to `DOT_NODE_FILLCOLOR_PALETTE`
        based on their state.

        Parameters:
        * `tasks`: an iterable of `Task`s to populate the graph
        * `name`: optional graph name
        * `follow_blocking`: optional flag to follow each `Task`'s
          `.blocking` attribute recursively and also render those
          `Task`s
        * `sep`: optional node seprator, default `'
'`

*`BaseTask.tasks_as_svg(tasks, name=None, **kw)`*:
Return an SVG diagram of the iterable `tasks`.
This takes the same parameters as `tasks_as_dot`.

## <a name="BlockedError"></a>Class `BlockedError(TaskError)`

Raised by a blocked `Task` if attempted.

## <a name="main"></a>`main(argv)`

Dummy main programme to exercise something.

## <a name="make"></a>`make(*tasks, fail_fast=False, queue=None)`

Generator which completes all the supplied `tasks` by dispatching them
once they are no longer blocked.
Yield each task from `tasks` as it completes (or becomes cancelled).

Parameters:
* `tasks`: `Task`s as positional parameters
* `fail_fast`: default `False`; if true, cease evaluation as soon as a
  task completes in a state with is not `DONE`
* `queue`: optional callable to submit a task for execution later
  via some queue such as `Later` or celery

The following rules are applied by this function:
- if a task is being prepared, raise an `FSMError`
- if a task is already running or queued, wait for its completion
- if a task is pending:
  * if any prerequisite has failed, fail this task
  * if any prerequisite is cancelled, cancel this task
  * if any prerequisite is pending, make it first
  * if any prerequisite is not done, fail this task
  * otherwise dispatch this task and then yield it
- if `fail_fast` and the task is not done, return

Examples:

    >>> t1 = Task('t1', lambda: print('doing t1'), track=True)
    >>> t2 = t1.then('t2', lambda: print('doing t2'), track=True)
    >>> list(make(t2))    # doctest: +ELLIPSIS
    t1 PENDING->dispatch->RUNNING
    doing t1
    t1 RUNNING->done->DONE
    t2 PENDING->dispatch->RUNNING
    doing t2
    t2 RUNNING->done->DONE
    [Task('t2',<function <lambda> at ...>,state='DONE')]

## <a name="make_later"></a>`make_later(L, *tasks, fail_fast=False)`

Dispatch the `tasks` via `L:Later` for asynchronous execution
if it is not already completed.
The caller can wait on `t.result` for completion.

This calls `make_now()` in a thread and uses `L.defer` to
queue the task and its prerequisites for execution.

## <a name="make_now"></a>`make_now(*tasks, fail_fast=False, queue=None)`

Run the generator `make(*tasks)` to completion and return the
list of completed tasks.

## <a name="Task"></a>Class `Task(BaseTask, cs.threads.HasThreadState)`

A task which may require the completion of other tasks.

The model here may not be quite as expected; it is aimed at
tasks which can be repaired and rerun.
As such, if `self.run(func,...)` raises an exception from
`func` then this `Task` will still block dependent `Task`s.
Dually, a `Task` which completes without an exception is
considered complete and does not block dependent `Task`s.

Keyword parameters:
* `cancel_on_exception`: if true, cancel this `Task` if `.run`
  raises an exception; the default is `False`, allowing repair
  and retry
* `cancel_on_result`: optional callable to test the `Task.result`
  after `.run`; if the callable returns `True` the `Task` is marked
  as cancelled, allowing repair and retry
* `func`: the function to call to complete the `Task`;
  it will be called as `func(*func_args,**func_kwargs)`
* `func_args`: optional positional arguments, default `()`
* `func_kwargs`: optional keyword arguments, default `{}`
* `lock`: optional lock, default an `RLock`
* `state`: initial state, default from `self._state.initial_state`,
  which is initally '`PENDING`'
* `track`: default `False`;
  if `True` then apply a callback for all states to print task transitions;
  otherwise it should be a callback function suitable for `FSM.fsm_callback`
Other arguments are passed to the `Result` initialiser.

Example:

    t1 = Task(name="task1")
    t1.bg(time.sleep, 10)
    t2 = Task(name="task2")
    # prevent t2 from running until t1 completes
    t2.require(t1)
    # try to run sleep(5) for t2 immediately after t1 completes
    t1.notify(t2.call, sleep, 5)

Users wanting more immediate semantics can supply
`cancel_on_exception` and/or `cancel_on_result` to control
these behaviours.

Example:

    t1 = Task(name="task1")
    t1.bg(time.sleep, 2)
    t2 = Task(name="task2")
    # prevent t2 from running until t1 completes
    t2.require(t1)
    # try to run sleep(5) for t2 immediately after t1 completes
    t1.notify(t2.call, sleep, 5)

<figure>
    <svg width="410pt" height="487pt"
   viewBox="0.00 0.00 409.50 486.50" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 482.5)">
  <title>Task State Diagram</title>
  <polygon fill="white" stroke="none" points="-4,4 -4,-482.5 405.5,-482.5 405.5,4 -4,4"/>
  <!-- PREPARE -->
  <g id="node1" class="node">
  <title>PREPARE</title>
  <ellipse fill="none" stroke="black" cx="191" cy="-460.5" rx="51.35" ry="18"/>
  <text text-anchor="middle" x="191" y="-455.45" font-family="Times,serif" font-size="14.00">PREPARE</text>
  </g>
  <!-- PENDING -->
  <g id="node2" class="node">
  <title>PENDING</title>
  <ellipse fill="none" stroke="black" cx="191" cy="-372" rx="51.35" ry="18"/>
  <text text-anchor="middle" x="191" y="-366.95" font-family="Times,serif" font-size="14.00">PENDING</text>
  </g>
  <!-- PREPARE&#45;&gt;PENDING -->
  <g id="edge1" class="edge">
  <title>PREPARE&#45;&gt;PENDING</title>
  <path fill="none" stroke="black" d="M191,-442.41C191,-430.76 191,-415.05 191,-401.52"/>
  <polygon fill="black" stroke="black" points="194.5,-401.86 191,-391.86 187.5,-401.86 194.5,-401.86"/>
  <text text-anchor="middle" x="214.62" y="-411.2" font-family="Times,serif" font-size="14.00">prepared</text>
  </g>
  <!-- ABORT -->
  <g id="node3" class="node">
  <title>ABORT</title>
  <ellipse fill="none" stroke="black" cx="122" cy="-18" rx="42.14" ry="18"/>
  <text text-anchor="middle" x="122" y="-12.95" font-family="Times,serif" font-size="14.00">ABORT</text>
  </g>
  <!-- PENDING&#45;&gt;ABORT -->
  <g id="edge2" class="edge">
  <title>PENDING&#45;&gt;ABORT</title>
  <path fill="none" stroke="black" d="M139.82,-369.13C83.24,-364.01 0,-346.02 0,-284.5 0,-284.5 0,-284.5 0,-105.5 0,-66.96 41.02,-44.03 75.48,-31.57"/>
  <polygon fill="black" stroke="black" points="76.6,-34.89 84.95,-28.37 74.36,-28.25 76.6,-34.89"/>
  <text text-anchor="middle" x="13.88" y="-189.95" font-family="Times,serif" font-size="14.00">abort</text>
  </g>
  <!-- CANCELLED -->
  <g id="node4" class="node">
  <title>CANCELLED</title>
  <ellipse fill="none" stroke="black" cx="122" cy="-106.5" rx="65.68" ry="18"/>
  <text text-anchor="middle" x="122" y="-101.45" font-family="Times,serif" font-size="14.00">CANCELLED</text>
  </g>
  <!-- PENDING&#45;&gt;CANCELLED -->
  <g id="edge3" class="edge">
  <title>PENDING&#45;&gt;CANCELLED</title>
  <path fill="none" stroke="black" d="M147.93,-361.71C131.39,-356.41 113.31,-348.28 100,-336 67.93,-306.41 67.19,-290.05 57.5,-247.5 47.84,-205.07 76.48,-160.05 98.66,-132.86"/>
  <polygon fill="black" stroke="black" points="101.1,-135.39 104.89,-125.5 95.75,-130.87 101.1,-135.39"/>
  <text text-anchor="middle" x="75.25" y="-234.2" font-family="Times,serif" font-size="14.00">cancel</text>
  </g>
  <!-- RUNNING -->
  <g id="node5" class="node">
  <title>RUNNING</title>
  <ellipse fill="none" stroke="black" cx="266" cy="-195" rx="53.4" ry="18"/>
  <text text-anchor="middle" x="266" y="-189.95" font-family="Times,serif" font-size="14.00">RUNNING</text>
  </g>
  <!-- PENDING&#45;&gt;RUNNING -->
  <g id="edge4" class="edge">
  <title>PENDING&#45;&gt;RUNNING</title>
  <path fill="none" stroke="black" d="M209.05,-355.03C215.06,-349.3 221.6,-342.62 227,-336 238.49,-321.9 241.89,-318.24 249,-301.5 259.48,-276.84 263.55,-246.42 265.1,-224.59"/>
  <polygon fill="black" stroke="black" points="268.59,-224.83 265.66,-214.65 261.6,-224.44 268.59,-224.83"/>
  <text text-anchor="middle" x="281.5" y="-278.45" font-family="Times,serif" font-size="14.00">dispatch</text>
  </g>
  <!-- FAILED -->
  <g id="node6" class="node">
  <title>FAILED</title>
  <ellipse fill="none" stroke="black" cx="350" cy="-106.5" rx="43.67" ry="18"/>
  <text text-anchor="middle" x="350" y="-101.45" font-family="Times,serif" font-size="14.00">FAILED</text>
  </g>
  <!-- PENDING&#45;&gt;FAILED -->
  <g id="edge5" class="edge">
  <title>PENDING&#45;&gt;FAILED</title>
  <path fill="none" stroke="black" d="M226.83,-358.65C253.24,-347.7 287.93,-329.12 308,-301.5 344.17,-251.73 350.32,-177.03 350.73,-136.37"/>
  <polygon fill="black" stroke="black" points="354.23,-136.42 350.7,-126.43 347.23,-136.44 354.23,-136.42"/>
  <text text-anchor="middle" x="351.12" y="-234.2" font-family="Times,serif" font-size="14.00">error</text>
  </g>
  <!-- QUEUED -->
  <g id="node7" class="node">
  <title>QUEUED</title>
  <ellipse fill="none" stroke="black" cx="191" cy="-283.5" rx="48.79" ry="18"/>
  <text text-anchor="middle" x="191" y="-278.45" font-family="Times,serif" font-size="14.00">QUEUED</text>
  </g>
  <!-- PENDING&#45;&gt;QUEUED -->
  <g id="edge6" class="edge">
  <title>PENDING&#45;&gt;QUEUED</title>
  <path fill="none" stroke="black" d="M191,-353.91C191,-342.26 191,-326.55 191,-313.02"/>
  <polygon fill="black" stroke="black" points="194.5,-313.36 191,-303.36 187.5,-313.36 194.5,-313.36"/>
  <text text-anchor="middle" x="207.12" y="-322.7" font-family="Times,serif" font-size="14.00">queue</text>
  </g>
  <!-- CANCELLED&#45;&gt;PENDING -->
  <g id="edge13" class="edge">
  <title>CANCELLED&#45;&gt;PENDING</title>
  <path fill="none" stroke="black" d="M119.46,-124.87C115.1,-159.78 108.84,-240.31 133,-301.5 139.75,-318.6 152.45,-334.59 164.24,-346.91"/>
  <polygon fill="black" stroke="black" points="161.56,-349.17 171.11,-353.75 166.5,-344.21 161.56,-349.17"/>
  <text text-anchor="middle" x="131.75" y="-234.2" font-family="Times,serif" font-size="14.00">retry</text>
  </g>
  <!-- CANCELLED&#45;&gt;ABORT -->
  <g id="edge12" class="edge">
  <title>CANCELLED&#45;&gt;ABORT</title>
  <path fill="none" stroke="black" d="M122,-88.41C122,-76.76 122,-61.05 122,-47.52"/>
  <polygon fill="black" stroke="black" points="125.5,-47.86 122,-37.86 118.5,-47.86 125.5,-47.86"/>
  <text text-anchor="middle" x="135.88" y="-57.2" font-family="Times,serif" font-size="14.00">abort</text>
  </g>
  <!-- RUNNING&#45;&gt;CANCELLED -->
  <g id="edge9" class="edge">
  <title>RUNNING&#45;&gt;CANCELLED</title>
  <path fill="none" stroke="black" d="M240.59,-178.73C217.64,-164.95 183.61,-144.51 157.89,-129.06"/>
  <polygon fill="black" stroke="black" points="159.8,-126.13 149.43,-123.98 156.2,-132.13 159.8,-126.13"/>
  <text text-anchor="middle" x="223.25" y="-145.7" font-family="Times,serif" font-size="14.00">cancel</text>
  </g>
  <!-- RUNNING&#45;&gt;FAILED -->
  <g id="edge11" class="edge">
  <title>RUNNING&#45;&gt;FAILED</title>
  <path fill="none" stroke="black" d="M275.28,-177.11C281.61,-166.47 290.65,-152.84 300.75,-142.5 305.65,-137.48 311.36,-132.69 317.12,-128.35"/>
  <polygon fill="black" stroke="black" points="318.75,-131.48 324.86,-122.83 314.68,-125.79 318.75,-131.48"/>
  <text text-anchor="middle" x="317.62" y="-145.7" font-family="Times,serif" font-size="14.00">except</text>
  </g>
  <!-- DONE -->
  <g id="node8" class="node">
  <title>DONE</title>
  <ellipse fill="none" stroke="black" cx="251" cy="-106.5" rx="36.51" ry="18"/>
  <text text-anchor="middle" x="251" y="-101.45" font-family="Times,serif" font-size="14.00">DONE</text>
  </g>
  <!-- RUNNING&#45;&gt;DONE -->
  <g id="edge10" class="edge">
  <title>RUNNING&#45;&gt;DONE</title>
  <path fill="none" stroke="black" d="M257.58,-176.95C255.23,-171.37 252.98,-165.04 251.75,-159 250.27,-151.73 249.67,-143.76 249.54,-136.31"/>
  <polygon fill="black" stroke="black" points="253.04,-136.39 249.65,-126.35 246.04,-136.31 253.04,-136.39"/>
  <text text-anchor="middle" x="264.12" y="-145.7" font-family="Times,serif" font-size="14.00">done</text>
  </g>
  <!-- FAILED&#45;&gt;PENDING -->
  <g id="edge15" class="edge">
  <title>FAILED&#45;&gt;PENDING</title>
  <path fill="none" stroke="black" d="M358.16,-124.58C360.47,-130.17 362.7,-136.49 364,-142.5 387.66,-252 373.46,-229.86 368,-247.5 352.83,-296.48 338,-309.68 294,-336 278.58,-345.22 260.39,-352.43 243.62,-357.84"/>
  <polygon fill="black" stroke="black" points="242.84,-354.42 234.3,-360.69 244.89,-361.11 242.84,-354.42"/>
  <text text-anchor="middle" x="388.75" y="-234.2" font-family="Times,serif" font-size="14.00">retry</text>
  </g>
  <!-- FAILED&#45;&gt;ABORT -->
  <g id="edge14" class="edge">
  <title>FAILED&#45;&gt;ABORT</title>
  <path fill="none" stroke="black" d="M318.78,-93.65C278.44,-78.35 208.11,-51.67 163.36,-34.69"/>
  <polygon fill="black" stroke="black" points="164.85,-31.51 154.26,-31.24 162.37,-38.06 164.85,-31.51"/>
  <text text-anchor="middle" x="269.88" y="-57.2" font-family="Times,serif" font-size="14.00">abort</text>
  </g>
  <!-- QUEUED&#45;&gt;CANCELLED -->
  <g id="edge7" class="edge">
  <title>QUEUED&#45;&gt;CANCELLED</title>
  <path fill="none" stroke="black" d="M184.26,-265.4C172.3,-235.08 147.3,-171.66 132.88,-135.09"/>
  <polygon fill="black" stroke="black" points="136.25,-134.09 129.32,-126.08 129.74,-136.66 136.25,-134.09"/>
  <text text-anchor="middle" x="180.25" y="-189.95" font-family="Times,serif" font-size="14.00">cancel</text>
  </g>
  <!-- QUEUED&#45;&gt;RUNNING -->
  <g id="edge8" class="edge">
  <title>QUEUED&#45;&gt;RUNNING</title>
  <path fill="none" stroke="black" d="M197.13,-265.5C201.55,-254.82 208.26,-241.19 217,-231 221.04,-226.29 225.84,-221.88 230.84,-217.89"/>
  <polygon fill="black" stroke="black" points="232.74,-220.84 238.69,-212.07 228.57,-215.21 232.74,-220.84"/>
  <text text-anchor="middle" x="239.5" y="-234.2" font-family="Times,serif" font-size="14.00">dispatch</text>
  </g>
  </g>
  </svg>
  <figcaption>Task State Diagram</figcaption>
</figure>


*`Task.__call__(self)`*:
Block on `self.result` awaiting completion
by calling `self.result()`.

*`Task.bg(self)`*:
Dispatch a function to complete the `Task` in a separate `Thread`,
returning the `Thread`.
This raises `BlockedError` for a blocked task.
otherwise the thread runs `self.dispatch()`.

*`Task.block(self, otask)`*:
Block another task until we are complete.
The converse of `.require()`.

*`Task.blockers(self)`*:
A generator yielding tasks from `self.required`
which should block this task.
Aborted tasks are not blockers
but if we encounter one we do abort the current task.

*`Task.cancel(self)`*:
Transition this `Task` to `CANCELLED` state.
If the task is running, set `.cancelled` on the `RunState`,
allowing clean task cancellation and subsequent transition
(mediated by the `.run()` method).
Otherwise fire the `'cancel'` event directly.

*`Task.dispatch(self)`*:
Dispatch the `Task`:
If the task is blocked, raise `BlockedError`.
If a prerequisite is aborted, fire the 'abort' method.
Otherwise fire the `'dispatch'` event and then run the
task's function via the `.run()` method.

*`Task.isblocked(self)`*:
A task is blocked if any prerequisite is not complete.

*`Task.iscompleted(self)`*:
This task is completed (even if failed) and does not block contingent tasks.

*`Task.join(self)`*:
Wait for this task to complete.

*`Task.make(self, fail_fast=False)`*:
Complete `self` and its prerequisites.
This calls the global `make()` function with `self`.
It returns a Boolean indicating whether this task completed.

*`Task.perthread_state`*

*`Task.require(self, otask: 'TaskSubType')`*:
Add a requirement that `otask` be complete before we proceed.
This is the simple `Task` only version of `.then()`.

*`Task.run(self)`*:
Run the function associated with this task,
completing the `self.result` `Result` appropriately when finished.

*WARNING*: this _ignores_ the current state and any blocking `Task`s.
You should usually use `dispatch` or `make`.

During the run the thread local `Task.default()`
will be `self` and the `self.runstate` will be running.

Otherwise run `func_result=self.func(*self.func_args,**self.func_kwargs)`
with the following effects:
* if the function raises a `CancellationError`, cancel the `Task`
* if the function raises another exception,
  if `self.cancel_on_exception` then cancel the task
  else complete `self.result` with the exception
  and fire the `'error'` `event
* if `self.runstate.canceled` or `self.cancel_on_result`
  was provided and `self.cancel_on_result(func_result)` is
  true, cancel the task
* otherwise complete `self.result` with `func_result`
  and fire the `'done'` event

*`Task.then(self, func: Union[str, Callable, ForwardRef('TaskSubType')], *a, func_args=(), func_kwargs=None, **task_kw)`*:
Prepare a new `Task` or function which may not run before `self` completes.
This may be called in two ways:
- `task.then(some_Task): block the `Task` instance `some_Task` behind `self`
- `task.then([name,]func[,func_args=][,func_kwargs=][,Task_kwargs...]):
  make a new `Task` to be blocked behind `self`
Return the new `Task`.

This supports preparing a chain of actions:

    >>> t_root = Task("t_root", lambda: 0)
    >>> t_leaf = t_root.then(lambda: 1).then(lambda: 2)
    >>> t_root.iscompleted()   # the root task has not yet run
    False
    >>> t_leaf.iscompleted()   # the final task has not yet run
    False
    >>> # t_leaf is blocked by t_root
    >>> t_leaf.dispatch()      # doctest: +ELLIPSIS
    Traceback (most recent call last):
      ...
    cs.taskqueue.BlockedError: ...
    >>> t_leaf.make()          # make the leaf, but make t_root first
    True
    >>> t_root.iscompleted()   # implicitly completed by make
    True
    >>> t_leaf.iscompleted()
    True

## <a name="TaskError"></a>Class `TaskError(cs.fsm.FSMError)`

Raised by `Task` related errors.

## <a name="TaskQueue"></a>Class `TaskQueue`

A task queue for managing and running a set of related tasks.

Unlike `make` and `Task.make`, this is aimed at a "dispatch" worker
which dispatches individual tasks as required.

Example 1, put 2 dependent tasks in a queue and run:

     >>> t1 = Task("t1", lambda: print("t1"))
     >>> t2 = t1.then("t2", lambda: print("t2"))
     >>> q = TaskQueue(t1, t2)
     >>> for _ in q.run(): pass
     ...
     t1
     t2

Example 2, put 1 task in a queue and run.
The queue only runs the specified tasks:

     >>> t1 = Task("t1", lambda: print("t1"))
     >>> t2 = t1.then("t2", lambda: print("t2"))
     >>> q = TaskQueue(t1)
     >>> for _ in q.run(): pass
     ...
     t1

Example 2, put 1 task in a queue with `run_dependent_tasks=True` and run.
The queue pulls in the dependencies of completed tasks and also runs those:

     >>> t1 = Task("t1", lambda: print("t1"))
     >>> t2 = t1.then("t2", lambda: print("t2"))
     >>> q = TaskQueue(t1, run_dependent_tasks=True)
     >>> for _ in q.run(): pass
     ...
     t1
     t2

*`TaskQueue.__init__(self, *tasks, run_dependent_tasks=False)`*:
Initialise the queue with the supplied `tasks`.

*`TaskQueue.add(self, task)`*:
Add a task to the tasks managed by this queue.

*`TaskQueue.as_dot(self, name=None, **kw)`*:
Compute a DOT syntax graph description of the tasks in the queue.

*`TaskQueue.get(self)`*:
Pull a completed or an unblocked pending task from the queue.
Return the task or `None` if nothing is available.

The returned task is no longer tracked by this queue.

*`TaskQueue.run(self, runstate=None, once=False)`*:
Process tasks in the queue until the queue has no completed tasks,
yielding each task, immediately if `task.iscompleted()`
otherwise after `taks.dispatch()`.

An optional `RunState` may be provided to allow early termination
via `runstate.cancel()`.

An incomplete task is `dispatch`ed before `yield`;
ideally it will be complete when the yield happens,
but its semantics might mean it is in another state such as `CANCELLED`.
The consumer of `run` must handle these situations.

# Release Log



*Release 20250120*:
BaseTask.__init__: accept the state as positional or keyword.

*Release 20240423*:
Small fixes.

*Release 20230401*:
Add missing requirement to DISTINFO.

*Release 20230331*:
* Task: subclass BaseTask instead of (FSM, RunStateMixin).
* BaseTask.__init__: use @uses_runstate to ensure we've got a RunState.

*Release 20230217*:
Task: subclass HasThreadState, drop .current_task() class method.

*Release 20221207*:
* Pull out core stuff from Task into BaseTask, aids subclassing.
* BaseTask: explainatory docustring about unusual FSM_DEFAULT_STATE design choice.
* BaseTask.tasks_as_dot: express the edges using the node ids instead of their labels.
* BaseTask: new tasks_as_svg() method like tasks_as_dot() but returning SVG.

*Release 20220805*:
Initial PyPI release.