Skip to content

Commit 641efe8

Browse files
authored
Packaging, heartbeat logic, dependency updates, and other errata (#8)
1 parent 3e1e408 commit 641efe8

28 files changed

+1204
-410
lines changed

.github/workflows/ci.yml

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
fail-fast: true
1414
matrix:
1515
python: ["3.7", "3.10"]
16-
os: [ubuntu-latest] # TODO: macos-latest, windows-latest
16+
os: [ubuntu-latest, macos-latest, windows-latest]
1717
runs-on: ${{ matrix.os }}
1818
steps:
1919
- name: Print build information
@@ -27,12 +27,51 @@ jobs:
2727
- uses: actions/setup-python@v1
2828
with:
2929
python-version: ${{ matrix.python }}
30-
# Needed to tests since they use external server
30+
# Needed for tests since they use external server
3131
- uses: actions/setup-go@v2
3232
with:
3333
go-version: "1.17"
3434
- run: python -m pip install --upgrade wheel poetry poethepoet
3535
- run: poetry install
3636
- run: poe lint
37-
- run: poe build
38-
- run: poe test
37+
- run: poe build-develop
38+
- run: poe test -s -o log_cli_level=DEBUG
39+
40+
# Compile the binaries and upload artifacts
41+
compile-binaries:
42+
strategy:
43+
fail-fast: true
44+
matrix:
45+
include:
46+
- os: ubuntu-latest
47+
package-suffix: linux-amd64
48+
- os: macos-latest
49+
package-suffix: macos-amd64
50+
- os: windows-latest
51+
package-suffix: windows-amd64
52+
runs-on: ${{ matrix.os }}
53+
steps:
54+
- uses: actions/checkout@v2
55+
with:
56+
submodules: recursive
57+
- uses: actions-rs/toolchain@v1
58+
with:
59+
toolchain: stable
60+
- uses: actions/setup-python@v1
61+
with:
62+
python-version: "3.10"
63+
# Needed for tests since they use external server
64+
- uses: actions/setup-go@v2
65+
with:
66+
go-version: "1.17"
67+
- run: python -m pip install --upgrade wheel poetry poethepoet
68+
- run: poetry install
69+
- run: poe gen-protos
70+
- run: poetry build
71+
- run: poe fix-wheel
72+
- run: poe test-dist-single
73+
- uses: actions/upload-artifact@v2
74+
with:
75+
name: packages-${{ matrix.package-suffix }}
76+
path: dist
77+

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
.venv
22
__pycache__
3+
/build
4+
/dist
35
/docs/_build
46
temporalio/api/*
57
!temporalio/api/__init__.py
68
temporalio/bridge/proto/*
79
!temporalio/bridge/proto/__init__.py
810
temporalio/bridge/target/
11+
temporalio/bridge/temporal_sdk_bridge*
912
/tests/helpers/golangserver/golangserver
1013
/tests/helpers/golangworker/golangworker

README.md

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,193 @@
44

55
The Python SDK is under development. There are no compatibility guarantees nor proper documentation pages at this time.
66

7+
## Usage
8+
9+
### Installation
10+
11+
Install the `temporalio` package from [PyPI](https://pypi.org/project/temporalio). If using `pip` directly, this might
12+
look like:
13+
14+
python -m pip install temporalio
15+
16+
### Client
17+
18+
A client can be created and used to start a workflow like so:
19+
20+
```python
21+
from temporalio.client import Client
22+
23+
async def main():
24+
# Create client connected to server at the given address
25+
client = await Client.connect("http://localhost:7233", namespace="my-namespace")
26+
27+
# Start a workflow
28+
handle = await client.start_workflow("my workflow name", "some arg", id="my-workflow-id", task_queue="my-task-queue")
29+
30+
# Wait for result
31+
result = await handle.result()
32+
print(f"Result: {result}")
33+
```
34+
35+
Some things to note about the above code:
36+
37+
* A `Client` does not have an explicit "close"
38+
* Positional arguments can be passed to `start_workflow`
39+
* The `handle` represents the workflow that was started and can be used for more than just getting the result
40+
* Since we are just getting the handle and waiting on the result, we could have called `client.execute_workflow` which
41+
does the same thing
42+
* Clients can have many more options not shown here (e.g. data converters and interceptors)
43+
44+
#### Data Conversion
45+
46+
Data converters are used to convert raw Temporal payloads to/from actual Python types. A custom data converter of type
47+
`temporalio.converter.DataConverter` can be set via the `data_converter` client parameter.
48+
49+
The default data converter supports converting multiple types including:
50+
51+
* `None`
52+
* `bytes`
53+
* `google.protobuf.message.Message` - As JSON when encoding, but has ability to decode binary proto from other languages
54+
* Anything that [`json.dump`](https://docs.python.org/3/library/json.html#json.dump) supports
55+
56+
As a special case in the default converter, [data classes](https://docs.python.org/3/library/dataclasses.html) are
57+
automatically [converted to dictionaries](https://docs.python.org/3/library/dataclasses.html#dataclasses.asdict) before
58+
encoding as JSON. Since Python is a dynamic language, when decoding via
59+
[`json.load`](https://docs.python.org/3/library/json.html#json.load), the type is not known at runtime so, for example,
60+
a JSON object will be a `dict`. As a special case, if the parameter type hint is a data class for a JSON payload, it is
61+
decoded into an instance of that data class (properly recursing into child data classes).
62+
63+
### Activities
64+
65+
#### Activity-only Worker
66+
67+
An activity-only worker can be started like so:
68+
69+
```python
70+
import asyncio
71+
import logging
72+
from temporalio.client import Client
73+
from temporalio.worker import Worker
74+
75+
async def say_hello_activity(name: str) -> str:
76+
return f"Hello, {name}!"
77+
78+
79+
async def main(stop_event: asyncio.Event):
80+
# Create client connected to server at the given address
81+
client = await Client.connect("http://localhost:7233", namespace="my-namespace")
82+
83+
# Run the worker until the event is set
84+
worker = Worker(client, task_queue="my-task-queue", activities={"say-hello-activity": say_hello_activity})
85+
async with worker:
86+
await stop_event.wait()
87+
```
88+
89+
Some things to note about the above code:
90+
91+
* This creates/uses the same client that is used for starting workflows
92+
* The `say_hello_activity` is `async` which is the recommended activity type (see "Types of Activities" below)
93+
* The created worker only runs activities, not workflows
94+
* Activities are passed as a mapping with the key as a string activity name and the value as a callable
95+
* While this example accepts a stop event and uses `async with`, `run()` and `shutdown()` may be used instead
96+
* Workers can have many more options not shown here (e.g. data converters and interceptors)
97+
98+
#### Types of Activities
99+
100+
There are 3 types of activity callables accepted and described below: asynchronous, synchronous multithreaded, and
101+
synchronous multiprocess/other. Only positional parameters are allowed in activity callables.
102+
103+
##### Asynchronous Activities
104+
105+
Asynchronous activities, i.e. functions using `async def`, are the recommended activity type. When using asynchronous
106+
activities no special worker parameters are needed.
107+
108+
Cancellation for asynchronous activities is done via
109+
[`asyncio.Task.cancel`](https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel). This means that
110+
`asyncio.CancelledError` will be raised (and can be caught, but it is not recommended). An activity must heartbeat to
111+
receive cancellation and there are other ways to be notified about cancellation (see "Activity Context" and
112+
"Heartbeating and Cancellation" later).
113+
114+
##### Synchronous Activities
115+
116+
Synchronous activities, i.e. functions that do not have `async def`, can be used with workers, but the
117+
`activity_executor` worker parameter must be set with a `concurrent.futures.Executor` instance to use for executing the
118+
activities.
119+
120+
Cancellation for synchronous activities is done in the background and the activity must choose to listen for it and
121+
react appropriately. An activity must heartbeat to receive cancellation and there are other ways to be notified about
122+
cancellation (see "Activity Context" and "Heartbeating and Cancellation" later).
123+
124+
###### Synchronous Multithreaded Activities
125+
126+
If `activity_executor` is set to an instance of `concurrent.futures.ThreadPoolExecutor` then the synchronous activities
127+
are considered multithreaded activities. Besides `activity_executor`, no other worker parameters are required for
128+
synchronous multithreaded activities.
129+
130+
###### Synchronous Multiprocess/Other Activities
131+
132+
Synchronous activities, i.e. functions that do not have `async def`, can be used with workers, but the
133+
`activity_executor` worker parameter must be set with a `concurrent.futures.Executor` instance to use for executing the
134+
activities. If this is _not_ set to an instance of `concurrent.futures.ThreadPoolExecutor` then the synchronous
135+
activities are considered multiprocess/other activities.
136+
137+
These require special primitives for heartbeating and cancellation. The `shared_state_manager` worker parameter must be
138+
set to an instance of `temporalio.worker.SharedStateManager`. The most common implementation can be created by passing a
139+
`multiprocessing.managers.SyncManager` (i.e. result of `multiprocessing.managers.Manager()`) to
140+
`temporalio.worker.SharedStateManager.create_from_multiprocessing()`.
141+
142+
Also, all of these activity functions must be
143+
["picklable"](https://docs.python.org/3/library/pickle.html#what-can-be-pickled-and-unpickled).
144+
145+
#### Activity Context
146+
147+
During activity execution, an implicit activity context is set as a
148+
[context variable](https://docs.python.org/3/library/contextvars.html). The context variable itself is not visible, but
149+
calls in the `temporalio.activity` package make use of it. Specifically:
150+
151+
* `in_activity()` - Whether an activity context is present
152+
* `info()` - Returns the immutable info of the currently running activity
153+
* `heartbeat(*details)` - Record a heartbeat
154+
* `is_cancelled()` - Whether a cancellation has been requested on this activity
155+
* `wait_for_cancelled()` - `async` call to wait for cancellation request
156+
* `wait_for_cancelled_sync(timeout)` - Synchronous blocking call to wait for cancellation request
157+
* `is_worker_shutdown()` - Whether the worker has started graceful shutdown
158+
* `wait_for_worker_shutdown()` - `async` call to wait for start of graceful worker shutdown
159+
* `wait_for_worker_shutdown_sync(timeout)` - Synchronous blocking call to wait for start of graceful worker shutdown
160+
* `raise_complete_async()` - Raise an error that this activity will be completed asynchronously (i.e. after return of
161+
the activity function in a separate client call)
162+
163+
With the exception of `in_activity()`, if any of the functions are called outside of an activity context, an error
164+
occurs. Synchronous activities cannot call any of the `async` functions.
165+
166+
##### Heartbeating and Cancellation
167+
168+
In order for an activity to be notified of cancellation requests, they must invoke `temporalio.activity.heartbeat()`.
169+
It is strongly recommended that all but the fastest executing activities call this function regularly. "Types of
170+
Activities" has specifics on cancellation for asynchronous and synchronous activities.
171+
172+
In addition to obtaining cancellation information, heartbeats also support detail data that is persisted on the server
173+
for retrieval during activity retry. If an activity calls `temporalio.activity.heartbeat(123, 456)` and then fails and
174+
is retried, `temporalio.activity.info().heartbeat_details` will return an iterable containing `123` and `456` on the
175+
next run.
176+
177+
##### Worker Shutdown
178+
179+
An activity can react to a worker shutdown. Using `is_worker_shutdown` or one of the `wait_for_worker_shutdown`
180+
functions an activity can react to a shutdown.
181+
182+
When the `graceful_shutdown_timeout` worker parameter is given a `datetime.timedelta`, on shutdown the worker will
183+
notify activities of the graceful shutdown. Once that timeout has passed (or if wasn't set), the worker will perform
184+
cancellation of all outstanding activities.
185+
186+
The `shutdown()` invocation will wait on all activities to complete, so if a long-running activity does not at least
187+
respect cancellation, the shutdown may never complete.
188+
189+
## Development
190+
191+
The Python SDK is built to work with Python 3.7 and newer. It is built using
192+
[SDK Core](https://github.com/temporalio/sdk-core/) which is written in Rust.
193+
7194
### Local development environment
8195

9196
- Install the system dependencies:
@@ -19,7 +206,7 @@ The Python SDK is under development. There are no compatibility guarantees nor p
19206
poetry config virtualenvs.in-project true
20207
```
21208

22-
- Install the package dependencies:
209+
- Install the package dependencies (requires Rust):
23210

24211
```bash
25212
poetry install
@@ -28,7 +215,7 @@ The Python SDK is under development. There are no compatibility guarantees nor p
28215
- Build the project (requires Rust):
29216

30217
```bash
31-
poe build
218+
poe build-develop
32219
```
33220

34221
- Run the tests (requires Go):

build.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Additional setup options for Poetry build."""
2+
3+
import shutil
4+
5+
from setuptools_rust import Binding, RustExtension
6+
7+
8+
def build(setup_kwargs):
9+
"""Additional setup options for Poetry build."""
10+
setup_kwargs.update(
11+
# Same as in scripts/setup_bridge.py, but we cannot import that here
12+
# because it's not in the sdist
13+
rust_extensions=[
14+
RustExtension(
15+
"temporalio.bridge.temporal_sdk_bridge",
16+
path="temporalio/bridge/Cargo.toml",
17+
binding=Binding.PyO3,
18+
py_limited_api=True,
19+
features=["pyo3/abi3-py37"],
20+
)
21+
],
22+
zip_safe=False,
23+
# We have to remove packages and package data due to duplicate files
24+
# being generated in the wheel
25+
packages=[],
26+
package_data={},
27+
)
28+
shutil.rmtree("temporalio.egg-info", ignore_errors=True)

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
# The theme to use for HTML and HTML Help pages. See the documentation for
4949
# a list of builtin themes.
5050
#
51-
html_theme = "sphinx_rtd_theme"
51+
html_theme = "furo"
5252

5353
# Add any paths that contain custom static files (such as style sheets) here,
5454
# relative to this directory. They are copied after the builtin static files,

0 commit comments

Comments
 (0)