pgfsm-async-worker-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,185 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and its derivative works thereof.
46
+
47
+ "Contribution" shall mean, as used in this License, any work of
48
+ authorship, including the original version of the Work and any
49
+ modifications or additions to that Work or Derivative Works of the
50
+ Work, that is intentionally submitted to the Licensor for inclusion
51
+ in the Work by the copyright owner or by an individual or Legal Entity
52
+ authorized to submit on behalf of the copyright owner. For the purposes
53
+ of this definition, "submitted" means any form of electronic, verbal,
54
+ or written communication sent to the Licensor or its representatives,
55
+ including but not limited to communication on electronic mailing lists,
56
+ source code control systems, and issue tracking systems that are managed
57
+ by, or on behalf of, the Licensor for the purpose of discussing and
58
+ improving the Work, but excluding communication that is conspicuously
59
+ marked or designated in writing by the copyright owner as "Not a
60
+ Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
63
+ whom a Contribution has been received by the Licensor and subsequently
64
+ incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a cross-claim
83
+ or counterclaim in a lawsuit) alleging that the Work or a Contribution
84
+ incorporated within the Work constitutes direct or contributory patent
85
+ infringement, then any patent licenses granted to You under this License
86
+ for that Work shall terminate as of the date such litigation is filed.
87
+
88
+ 4. Redistribution. You may reproduce and distribute copies of the Work
89
+ or Derivative Works thereof in any medium, with or without
90
+ modifications, and in Source or Object form, provided that You meet
91
+ the following conditions:
92
+
93
+ (a) You must give any other recipients of the Work or Derivative Works
94
+ a copy of this License; and
95
+
96
+ (b) You must cause any modified files to carry prominent notices
97
+ stating that You changed the files; and
98
+
99
+ (c) You must retain, in the Source form of any Derivative Works that
100
+ You distribute, all copyright, patent, trademark, and attribution
101
+ notices from the Source form of the Work, excluding those notices
102
+ that do not pertain to any part of the Derivative Works; and
103
+
104
+ (d) If the Work includes a "NOTICE" text file as part of its
105
+ distribution, You must include a readable copy of the attribution
106
+ notices contained within such NOTICE file, in at least one of the
107
+ following places: within a NOTICE text file distributed as part of
108
+ the Derivative Works; within the Source form or documentation, if
109
+ provided along with the Derivative Works; or, within a display
110
+ generated by the Derivative Works, if and wherever such third-party
111
+ notices normally appear. The contents of the NOTICE file are for
112
+ informational purposes only and do not modify the License. You may
113
+ add Your own attribution notices within Derivative Works that You
114
+ distribute, alongside or as an addendum to the NOTICE text from the
115
+ Work, provided that such additional attribution notices cannot be
116
+ construed as modifying the License.
117
+
118
+ You may add Your own license statement for Your modifications and may
119
+ provide additional grant of rights to use, reproduce, modify, and
120
+ distribute Your modifications, or for such Derivative Works as a whole,
121
+ under terms and conditions of Your choice, provided Your use,
122
+ reproduction, and distribution of the Work otherwise complies with the
123
+ conditions stated in this License.
124
+
125
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
126
+ any Contribution intentionally submitted for inclusion in the Work
127
+ by You to the Licensor shall be under the terms and conditions of
128
+ this License, without any additional terms or conditions.
129
+ Notwithstanding the above, nothing herein shall supersede or modify
130
+ the terms of any separate license agreement you may have executed
131
+ with Licensor regarding such Contributions.
132
+
133
+ 6. Trademarks. This License does not grant permission to use the trade
134
+ names, trademarks, service marks, or product names of the Licensor,
135
+ except as required for reasonable and customary use in describing the
136
+ origin of the Work and reproducing the content of the NOTICE file.
137
+
138
+ 7. Disclaimer of Warranty. Unless required by applicable law or
139
+ agreed to in writing, Licensor provides the Work (and each
140
+ Contributor provides its Contributions) on an "AS IS" BASIS,
141
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
142
+ implied, including, without limitation, any conditions of title,
143
+ non-infringement, merchantability, or fitness for a particular purpose.
144
+ You are solely responsible for determining the appropriateness of using
145
+ or redistributing the Work and assume any risks associated with Your
146
+ exercise of permissions under this License.
147
+
148
+ 8. Limitation of Liability. In no event and under no legal theory,
149
+ whether in tort (including negligence), contract, or otherwise,
150
+ unless required by applicable law (such as deliberate and grossly
151
+ negligent acts) or agreed to in writing, shall any Contributor be
152
+ liable to You for damages, including any direct, indirect, special,
153
+ incidental, or exemplary damages of any character arising as a result
154
+ of this License or out of the use or inability to use the Work
155
+ (including but not limited to damages for loss of goodwill, work
156
+ stoppage, computer failure or malfunction, or all other commercial
157
+ damages or losses), even if such Contributor has been advised of the
158
+ possibility of such damages.
159
+
160
+ 9. Accepting Warranty or Additional Liability. While redistributing the
161
+ Work or Derivative Works thereof, You may choose to offer, and charge
162
+ a fee for, acceptance of support, warranty, indemnity, or other
163
+ liability obligations and/or rights consistent with this License.
164
+ However, in accepting such obligations, You may offer such obligations
165
+ only on Your own behalf and on Your sole responsibility, not on behalf
166
+ of any other Contributor, and only if You agree to indemnify, defend,
167
+ and hold each Contributor harmless for any liability incurred by, or
168
+ claims asserted against, such Contributor by reason of your accepting
169
+ any such warranty or additional liability.
170
+
171
+ END OF TERMS AND CONDITIONS
172
+
173
+ Copyright 2025 Niraj Kashyap
174
+
175
+ Licensed under the Apache License, Version 2.0 (the "License");
176
+ you may not use this file except in compliance with the License.
177
+ You may obtain a copy of the License at
178
+
179
+ http://www.apache.org/licenses/LICENSE-2.0
180
+
181
+ Unless required by applicable law or agreed to in writing, software
182
+ distributed under the License is distributed on an "AS IS" BASIS,
183
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
184
+ See the License for the specific language governing permissions and
185
+ limitations under the License.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgfsm-async-worker-sdk
3
+ Version: 0.1.0
4
+ Summary: Python worker SDK for the pgfsm Activity Gateway: registers actors and serves invocations over the sidecar gRPC stream.
5
+ Author: Niraj Kashyap
6
+ Author-email: Niraj Kashyap <niraj.38.re@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Requires-Dist: pgfsm-proto-codegen>=0.1.1,<0.2
10
+ Requires-Python: >=3.10
11
+ Project-URL: Repository, https://github.com/pgfsm/fsm
12
+ Project-URL: Source, https://github.com/pgfsm/fsm/tree/main/packages/fsm-async-worker-sdk-python
13
+ Description-Content-Type: text/markdown
14
+
15
+ # pgfsm-async-worker-sdk
16
+
17
+ Python worker SDK for the pgfsm Activity Gateway. A worker process built on it
18
+ connects to the gateway's sidecar Unix socket, registers a set of actors, and
19
+ serves the invocations the gateway routes to them over the
20
+ `pgfsm.sidecargateway.v1.SidecarGatewayService` gRPC stream (stubs from
21
+ [`pgfsm-proto-codegen`](https://pypi.org/project/pgfsm-proto-codegen/)).
22
+
23
+ It never opens a database connection — that stays in the gateway.
24
+
25
+ Python counterpart of the TypeScript
26
+ [`@pgfsm/async-worker-sdk`](https://www.npmjs.com/package/@pgfsm/async-worker-sdk).
27
+
28
+ ## Usage
29
+
30
+ You normally don't write against this package directly. `@pgfsm/compiler`'s
31
+ `generate-async-logic` writes a small `run_async_worker.py` plus a
32
+ `pyproject.toml` that pins this package:
33
+
34
+ ```python
35
+ # async-worker/python/run_async_worker.py (generated)
36
+ import logging
37
+ import sys
38
+
39
+ from pgfsm.async_worker_sdk import run_actor_worker_cli
40
+ from python_actors_registry_generated import ACTOR_REGISTRATIONS
41
+
42
+ if __name__ == "__main__":
43
+ logging.basicConfig(level=logging.INFO)
44
+ raise SystemExit(run_actor_worker_cli(ACTOR_REGISTRATIONS, sys.argv[1:]))
45
+ ```
46
+
47
+ Run it from that directory with [uv](https://docs.astral.sh/uv/):
48
+
49
+ ```bash
50
+ uv run run_async_worker.py list # print the actors in the registry, no gateway needed
51
+ uv run run_async_worker.py start # connect to the gateway and serve invocations
52
+ ```
53
+
54
+ or with pip: `python3 -m pip install pgfsm-async-worker-sdk`, then
55
+ `python3 run_async_worker.py start`.
56
+
57
+ ### Options
58
+
59
+ ```
60
+ -g, --gateway-socket <path> Sidecar socket to connect to (default: /tmp/pgfsm-activity-gateway-workers.sock)
61
+ -i, --worker-id <id> Stable worker identity (default: python-<random>)
62
+ --heartbeat-ms <ms> Heartbeat interval (default: 5000)
63
+ -h, --help Show this help message
64
+ ```
65
+
66
+ SIGINT/SIGTERM stop the worker gracefully (it unregisters from the gateway).
67
+
68
+ ## API
69
+
70
+ - `run_actor_worker_cli(registrations, args, invocation=None) -> int` — the
71
+ `list`/`start` CLI. Returns the process exit code instead of exiting.
72
+ - `ActorWorker(worker_id, gateway_socket_path, registrations, heartbeat_ms=5000)`
73
+ — `run()` registers every actor and serves invocations until `stop()` is
74
+ called or the gateway ends the stream.
75
+
76
+ A registration is a dict:
77
+
78
+ ```python
79
+ {
80
+ "parent_fsm_name": "creditCheck",
81
+ "parent_fsm_version": "v01",
82
+ "async_operation_type": "internalAsyncOperation",
83
+ "async_operation_name": "checkBureau",
84
+ "async_operation_version": "v01",
85
+ "async_operation_language": "python",
86
+ "handler": check_bureau, # (input) -> JSON-serializable output; may be async
87
+ }
88
+ ```
89
+
90
+ A handler that raises is reported to the gateway as an `INTERNAL` invoke error;
91
+ an invoke for an unregistered actor is reported as `NOT_FOUND`.
92
+
93
+ Logging goes through the standard `logging` module (`pgfsm.async_worker_sdk`
94
+ loggers); the library never configures logging itself.
95
+
96
+ ## Releasing (maintainers)
97
+
98
+ Released from the [pgfsm/fsm](https://github.com/pgfsm/fsm) monorepo by
99
+ `.github/workflows/pypi-publish.yml`. Pushing an
100
+ `async-worker-sdk-py-v<version>` tag publishes to PyPI with trusted publishing
101
+ (no API token). This package releases independently of
102
+ [`pgfsm-proto-codegen`](https://pypi.org/project/pgfsm-proto-codegen/).
103
+
104
+ 1. **Pick the version.** While below 1.0: breaking API change → minor (`0.1.0` →
105
+ `0.2.0`); new backward-compatible features → minor; fixes only → patch
106
+ (`0.1.0` → `0.1.1`).
107
+ 2. **Bump it in a PR.** From `packages/fsm-async-worker-sdk-python`:
108
+
109
+ ```bash
110
+ uv version --bump minor # or: --bump patch, or an exact version: uv version 0.2.0
111
+ ```
112
+
113
+ This updates `pyproject.toml` and `uv.lock` together; commit both. If the new
114
+ version needs a newer `pgfsm-proto-codegen`, release that first, then raise
115
+ the `pgfsm-proto-codegen>=` pin here.
116
+ 3. **Tag the merge commit and push the tag.** The tag must be exactly
117
+ `async-worker-sdk-py-v` + `uv version --short`:
118
+
119
+ ```bash
120
+ git fetch origin
121
+ git tag async-worker-sdk-py-v0.2.0 origin/main
122
+ git push origin async-worker-sdk-py-v0.2.0
123
+ ```
124
+
125
+ 4. **Check the release.** `gh run list --workflow pypi-publish.yml` shows the
126
+ run, which checks the tag against `pyproject.toml`, runs the tests, builds,
127
+ and uploads. Then confirm https://pypi.org/project/pgfsm-async-worker-sdk/
128
+ lists the version and it installs:
129
+ `pip install pgfsm-async-worker-sdk==0.2.0`.
130
+
131
+ For prereleases, uv writes the version in PEP 440 form
132
+ (`uv version 0.2.0-alpha.0` stores `0.2.0a0`), so tag
133
+ `async-worker-sdk-py-v0.2.0a0`. pip only installs a prerelease if asked
134
+ explicitly (`--pre` or an exact `==` version).
135
+
136
+ A published version can never be re-uploaded. If a bad version ships, release
137
+ the next patch and yank the bad one on pypi.org. Yanked versions stay
138
+ installable when pinned exactly, but resolvers skip them otherwise.
139
+
140
+ ## License
141
+
142
+ Apache-2.0
@@ -0,0 +1,128 @@
1
+ # pgfsm-async-worker-sdk
2
+
3
+ Python worker SDK for the pgfsm Activity Gateway. A worker process built on it
4
+ connects to the gateway's sidecar Unix socket, registers a set of actors, and
5
+ serves the invocations the gateway routes to them over the
6
+ `pgfsm.sidecargateway.v1.SidecarGatewayService` gRPC stream (stubs from
7
+ [`pgfsm-proto-codegen`](https://pypi.org/project/pgfsm-proto-codegen/)).
8
+
9
+ It never opens a database connection — that stays in the gateway.
10
+
11
+ Python counterpart of the TypeScript
12
+ [`@pgfsm/async-worker-sdk`](https://www.npmjs.com/package/@pgfsm/async-worker-sdk).
13
+
14
+ ## Usage
15
+
16
+ You normally don't write against this package directly. `@pgfsm/compiler`'s
17
+ `generate-async-logic` writes a small `run_async_worker.py` plus a
18
+ `pyproject.toml` that pins this package:
19
+
20
+ ```python
21
+ # async-worker/python/run_async_worker.py (generated)
22
+ import logging
23
+ import sys
24
+
25
+ from pgfsm.async_worker_sdk import run_actor_worker_cli
26
+ from python_actors_registry_generated import ACTOR_REGISTRATIONS
27
+
28
+ if __name__ == "__main__":
29
+ logging.basicConfig(level=logging.INFO)
30
+ raise SystemExit(run_actor_worker_cli(ACTOR_REGISTRATIONS, sys.argv[1:]))
31
+ ```
32
+
33
+ Run it from that directory with [uv](https://docs.astral.sh/uv/):
34
+
35
+ ```bash
36
+ uv run run_async_worker.py list # print the actors in the registry, no gateway needed
37
+ uv run run_async_worker.py start # connect to the gateway and serve invocations
38
+ ```
39
+
40
+ or with pip: `python3 -m pip install pgfsm-async-worker-sdk`, then
41
+ `python3 run_async_worker.py start`.
42
+
43
+ ### Options
44
+
45
+ ```
46
+ -g, --gateway-socket <path> Sidecar socket to connect to (default: /tmp/pgfsm-activity-gateway-workers.sock)
47
+ -i, --worker-id <id> Stable worker identity (default: python-<random>)
48
+ --heartbeat-ms <ms> Heartbeat interval (default: 5000)
49
+ -h, --help Show this help message
50
+ ```
51
+
52
+ SIGINT/SIGTERM stop the worker gracefully (it unregisters from the gateway).
53
+
54
+ ## API
55
+
56
+ - `run_actor_worker_cli(registrations, args, invocation=None) -> int` — the
57
+ `list`/`start` CLI. Returns the process exit code instead of exiting.
58
+ - `ActorWorker(worker_id, gateway_socket_path, registrations, heartbeat_ms=5000)`
59
+ — `run()` registers every actor and serves invocations until `stop()` is
60
+ called or the gateway ends the stream.
61
+
62
+ A registration is a dict:
63
+
64
+ ```python
65
+ {
66
+ "parent_fsm_name": "creditCheck",
67
+ "parent_fsm_version": "v01",
68
+ "async_operation_type": "internalAsyncOperation",
69
+ "async_operation_name": "checkBureau",
70
+ "async_operation_version": "v01",
71
+ "async_operation_language": "python",
72
+ "handler": check_bureau, # (input) -> JSON-serializable output; may be async
73
+ }
74
+ ```
75
+
76
+ A handler that raises is reported to the gateway as an `INTERNAL` invoke error;
77
+ an invoke for an unregistered actor is reported as `NOT_FOUND`.
78
+
79
+ Logging goes through the standard `logging` module (`pgfsm.async_worker_sdk`
80
+ loggers); the library never configures logging itself.
81
+
82
+ ## Releasing (maintainers)
83
+
84
+ Released from the [pgfsm/fsm](https://github.com/pgfsm/fsm) monorepo by
85
+ `.github/workflows/pypi-publish.yml`. Pushing an
86
+ `async-worker-sdk-py-v<version>` tag publishes to PyPI with trusted publishing
87
+ (no API token). This package releases independently of
88
+ [`pgfsm-proto-codegen`](https://pypi.org/project/pgfsm-proto-codegen/).
89
+
90
+ 1. **Pick the version.** While below 1.0: breaking API change → minor (`0.1.0` →
91
+ `0.2.0`); new backward-compatible features → minor; fixes only → patch
92
+ (`0.1.0` → `0.1.1`).
93
+ 2. **Bump it in a PR.** From `packages/fsm-async-worker-sdk-python`:
94
+
95
+ ```bash
96
+ uv version --bump minor # or: --bump patch, or an exact version: uv version 0.2.0
97
+ ```
98
+
99
+ This updates `pyproject.toml` and `uv.lock` together; commit both. If the new
100
+ version needs a newer `pgfsm-proto-codegen`, release that first, then raise
101
+ the `pgfsm-proto-codegen>=` pin here.
102
+ 3. **Tag the merge commit and push the tag.** The tag must be exactly
103
+ `async-worker-sdk-py-v` + `uv version --short`:
104
+
105
+ ```bash
106
+ git fetch origin
107
+ git tag async-worker-sdk-py-v0.2.0 origin/main
108
+ git push origin async-worker-sdk-py-v0.2.0
109
+ ```
110
+
111
+ 4. **Check the release.** `gh run list --workflow pypi-publish.yml` shows the
112
+ run, which checks the tag against `pyproject.toml`, runs the tests, builds,
113
+ and uploads. Then confirm https://pypi.org/project/pgfsm-async-worker-sdk/
114
+ lists the version and it installs:
115
+ `pip install pgfsm-async-worker-sdk==0.2.0`.
116
+
117
+ For prereleases, uv writes the version in PEP 440 form
118
+ (`uv version 0.2.0-alpha.0` stores `0.2.0a0`), so tag
119
+ `async-worker-sdk-py-v0.2.0a0`. pip only installs a prerelease if asked
120
+ explicitly (`--pre` or an exact `==` version).
121
+
122
+ A published version can never be re-uploaded. If a bad version ships, release
123
+ the next patch and yank the bad one on pypi.org. Yanked versions stay
124
+ installable when pinned exactly, but resolvers skip them otherwise.
125
+
126
+ ## License
127
+
128
+ Apache-2.0
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "pgfsm-async-worker-sdk"
3
+ version = "0.1.0"
4
+ description = "Python worker SDK for the pgfsm Activity Gateway: registers actors and serves invocations over the sidecar gRPC stream."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = ["pgfsm-proto-codegen>=0.1.1,<0.2"]
10
+
11
+ [[project.authors]]
12
+ name = "Niraj Kashyap"
13
+ email = "niraj.38.re@gmail.com"
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/pgfsm/fsm"
17
+ Source = "https://github.com/pgfsm/fsm/tree/main/packages/fsm-async-worker-sdk-python"
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8"]
21
+
22
+ [tool.uv.build-backend]
23
+ module-name = "pgfsm.async_worker_sdk"
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.10.12,<0.11.0"]
30
+ build-backend = "uv_build"
@@ -0,0 +1,38 @@
1
+ # Python worker SDK for the pgfsm Activity Gateway — see CLAUDE.md. Managed
2
+ # with uv (`uv sync`, `uv run pytest`, `uv build`). Published to PyPI as
3
+ # pgfsm-async-worker-sdk by .github/workflows/pypi-publish.yml from an
4
+ # `async-worker-sdk-py-v<version>` tag.
5
+ [project]
6
+ name = "pgfsm-async-worker-sdk"
7
+ version = "0.1.0"
8
+ description = "Python worker SDK for the pgfsm Activity Gateway: registers actors and serves invocations over the sidecar gRPC stream."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Niraj Kashyap", email = "niraj.38.re@gmail.com" }]
13
+ # protobuf>=7.35 (via pgfsm-proto-codegen) needs 3.10+.
14
+ requires-python = ">=3.10"
15
+ dependencies = [
16
+ # Generated pgfsm.sidecargateway.v1 client stubs; brings grpcio/protobuf.
17
+ "pgfsm-proto-codegen>=0.1.1,<0.2",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/pgfsm/fsm"
22
+ Source = "https://github.com/pgfsm/fsm/tree/main/packages/fsm-async-worker-sdk-python"
23
+
24
+ [dependency-groups]
25
+ dev = ["pytest>=8"]
26
+
27
+ [tool.uv.build-backend]
28
+ # `pgfsm` is a namespace package shared with pgfsm-proto-codegen
29
+ # (pgfsm.sidecargateway, pgfsm.activitygateway), so there's no
30
+ # src/pgfsm/__init__.py.
31
+ module-name = "pgfsm.async_worker_sdk"
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.10.12,<0.11.0"]
38
+ build-backend = "uv_build"
@@ -0,0 +1,22 @@
1
+ """Python worker SDK for the pgfsm Activity Gateway (pgfsm-async-worker-sdk)."""
2
+
3
+ from .actor_worker import (
4
+ DEFAULT_HEARTBEAT_MS,
5
+ ActorHandler,
6
+ ActorRegistration,
7
+ ActorWorker,
8
+ ProtocolError,
9
+ actor_key,
10
+ )
11
+ from .cli import DEFAULT_GATEWAY_SOCKET_PATH, run_actor_worker_cli
12
+
13
+ __all__ = [
14
+ "DEFAULT_GATEWAY_SOCKET_PATH",
15
+ "DEFAULT_HEARTBEAT_MS",
16
+ "ActorHandler",
17
+ "ActorRegistration",
18
+ "ActorWorker",
19
+ "ProtocolError",
20
+ "actor_key",
21
+ "run_actor_worker_cli",
22
+ ]
@@ -0,0 +1,266 @@
1
+ """ActorWorker: the client end of the Activity Gateway's sidecar leg.
2
+
3
+ Moved here from fsm-compiler-ts's python/worker-sdk-sdk.eta (#364), which used
4
+ to write this whole module into every project as `async-worker/python/sdk.py`.
5
+
6
+ Connects to the gateway's sidecar Unix socket via the generated
7
+ pgfsm.sidecargateway.v1.SidecarGatewayService bidi-streaming client (from the
8
+ `pgfsm-proto-codegen` package), registers actors from a compiler-generated
9
+ registry, and serves invoke requests.
10
+
11
+ Python counterpart of @pgfsm/async-worker-sdk's ActorWorker — same actor_key()
12
+ identity (parent_fsm_name@parent_fsm_version@async_operation_type@async_operation_name@async_operation_version@
13
+ async_operation_language), same register -> heartbeat -> serve lifecycle.
14
+ Outgoing messages (register, heartbeat, invoke_result, invoke_error) are pushed
15
+ onto a thread-safe queue.Queue that doubles as the request generator grpc's
16
+ synchronous stream_stream stub drains on its own thread — the natural Python
17
+ analogue of @pgfsm/async-worker-sdk's push-based AsyncQueue, and a better fit
18
+ than a second manual reader/writer thread pair for the same duplex stream.
19
+
20
+ `ActorWorker` takes the registry's list of dicts (parent_fsm_name/
21
+ parent_fsm_version/async_operation_type/async_operation_name/
22
+ async_operation_version/async_operation_language/handler) directly; it stays
23
+ registry-source-agnostic so it's easy to test with a synthetic list. The
24
+ generated `run_async_worker.py` is what wires it to that project's registry,
25
+ via `run_actor_worker_cli` (see cli.py).
26
+
27
+ Logging is not configured here: this module only calls `logging.getLogger()`.
28
+ The generated entry point configures logging once.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import logging
36
+ import queue
37
+ import threading
38
+ import time
39
+ from typing import Any, Callable, Dict, Iterator, List, Optional
40
+
41
+ import grpc
42
+ from pgfsm.sidecargateway.v1 import sidecar_gateway_pb2 as pb
43
+ from pgfsm.sidecargateway.v1 import sidecar_gateway_pb2_grpc as pb_grpc
44
+
45
+ logger = logging.getLogger("pgfsm.async_worker_sdk")
46
+
47
+ ActorHandler = Callable[[Any], Any]
48
+ ActorRegistration = Dict[str, Any]
49
+
50
+ DEFAULT_HEARTBEAT_MS = 5000
51
+
52
+
53
+ class ProtocolError(Exception):
54
+ pass
55
+
56
+
57
+ def actor_key(
58
+ parent_fsm_name: str,
59
+ parent_fsm_version: str,
60
+ async_operation_type: str,
61
+ async_operation_name: str,
62
+ async_operation_version: str,
63
+ async_operation_language: str,
64
+ ) -> str:
65
+ return (
66
+ f"{parent_fsm_name}@{parent_fsm_version}@{async_operation_type}@{async_operation_name}"
67
+ f"@{async_operation_version}@{async_operation_language}"
68
+ )
69
+
70
+
71
+ def _parse_input_json(input_json: str) -> Any:
72
+ if not input_json.strip():
73
+ return None
74
+ return json.loads(input_json)
75
+
76
+
77
+ class ActorWorker:
78
+ def __init__(
79
+ self,
80
+ worker_id: str,
81
+ gateway_socket_path: str,
82
+ registrations: List[ActorRegistration],
83
+ heartbeat_ms: int = DEFAULT_HEARTBEAT_MS,
84
+ ) -> None:
85
+ self.worker_id = worker_id
86
+ self.language = "python"
87
+ self.gateway_socket_path = gateway_socket_path
88
+ self.registrations = registrations
89
+ self.heartbeat_ms = heartbeat_ms
90
+
91
+ self._handlers: Dict[str, ActorHandler] = {}
92
+ self._stopped = False
93
+ self._outbox: "queue.Queue[Optional[pb.SessionRequest]]" = queue.Queue()
94
+ self._channel: Optional[grpc.Channel] = None
95
+
96
+ def run(self) -> None:
97
+ """Registers every actor and serves invocations until `stop()` is
98
+ called or the gateway ends the stream. Closes the gRPC channel before
99
+ returning."""
100
+ if not self.registrations:
101
+ raise ValueError("no actors to register, refusing to start worker")
102
+
103
+ registered_actors = []
104
+ for reg in self.registrations:
105
+ key = actor_key(
106
+ reg["parent_fsm_name"],
107
+ reg["parent_fsm_version"],
108
+ reg["async_operation_type"],
109
+ reg["async_operation_name"],
110
+ reg["async_operation_version"],
111
+ reg["async_operation_language"],
112
+ )
113
+ self._handlers[key] = reg["handler"]
114
+ registered_actors.append(
115
+ pb.RegisteredActor(
116
+ parent_fsm_name=reg["parent_fsm_name"],
117
+ parent_fsm_version=reg["parent_fsm_version"],
118
+ async_operation_type=reg["async_operation_type"],
119
+ async_operation_name=reg["async_operation_name"],
120
+ async_operation_version=reg["async_operation_version"],
121
+ async_operation_language=reg["async_operation_language"],
122
+ )
123
+ )
124
+
125
+ # grpc-core sends the socket path itself as the HTTP/2 `:authority`
126
+ # for a bare `unix://` target, which a plain (non-grpc-core) HTTP/2
127
+ # server -- the gateway's connect-node adapter -- can't parse as a
128
+ # host. `grpc.default_authority` overrides it with an ordinary
129
+ # hostname, the standard fix for local/UDS channels against such
130
+ # servers.
131
+ self._channel = grpc.insecure_channel(
132
+ f"unix://{self.gateway_socket_path}",
133
+ options=[("grpc.default_authority", "localhost")],
134
+ )
135
+ try:
136
+ self._run_session(pb_grpc.SidecarGatewayServiceStub(self._channel), registered_actors)
137
+ finally:
138
+ # Ends the request stream on every exit path (including a rejected
139
+ # registration) before the channel goes away.
140
+ self.stop()
141
+ self._channel.close()
142
+ self._channel = None
143
+
144
+ def stop(self) -> None:
145
+ if self._stopped:
146
+ return
147
+ self._stopped = True
148
+ self._outbox.put(
149
+ pb.SessionRequest(unregister=pb.Unregister(worker_id=self.worker_id))
150
+ )
151
+ self._outbox.put(None)
152
+
153
+ def _run_session(
154
+ self,
155
+ stub: pb_grpc.SidecarGatewayServiceStub,
156
+ registered_actors: List[pb.RegisteredActor],
157
+ ) -> None:
158
+ self._outbox.put(
159
+ pb.SessionRequest(
160
+ register=pb.Register(
161
+ worker_id=self.worker_id,
162
+ language=self.language,
163
+ protocol_version="1.0",
164
+ actors=registered_actors,
165
+ )
166
+ )
167
+ )
168
+
169
+ response_iter = iter(stub.Session(self._request_iterator()))
170
+
171
+ try:
172
+ first = next(response_iter)
173
+ except StopIteration:
174
+ raise ProtocolError("expected register_ack but got EOF") from None
175
+ if first.WhichOneof("payload") != "register_ack":
176
+ raise ProtocolError(
177
+ f"expected register_ack but got {first.WhichOneof('payload')}"
178
+ )
179
+ if not first.register_ack.accepted:
180
+ raise ProtocolError("gateway rejected registration")
181
+
182
+ logger.info(
183
+ "Worker %s registered %d actor(s) with the gateway",
184
+ self.worker_id,
185
+ len(registered_actors),
186
+ )
187
+
188
+ heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
189
+ heartbeat_thread.start()
190
+
191
+ self._serve_loop(response_iter)
192
+
193
+ def _request_iterator(self) -> Iterator[pb.SessionRequest]:
194
+ while True:
195
+ item = self._outbox.get()
196
+ if item is None:
197
+ return
198
+ yield item
199
+
200
+ def _heartbeat_loop(self) -> None:
201
+ while not self._stopped:
202
+ time.sleep(self.heartbeat_ms / 1000)
203
+ if self._stopped:
204
+ break
205
+ self._outbox.put(
206
+ pb.SessionRequest(heartbeat=pb.Heartbeat(worker_id=self.worker_id))
207
+ )
208
+
209
+ def _serve_loop(self, response_iter: Iterator[pb.SessionResponse]) -> None:
210
+ for response in response_iter:
211
+ if self._stopped:
212
+ break
213
+ case = response.WhichOneof("payload")
214
+ if case == "cancel":
215
+ continue
216
+ if case != "invoke":
217
+ continue
218
+ self._handle_invoke(response.invoke)
219
+
220
+ def _handle_invoke(self, body: "pb.Invoke") -> None:
221
+ key = actor_key(
222
+ body.parent_fsm_name,
223
+ body.parent_fsm_version,
224
+ body.async_operation_type,
225
+ body.async_operation_name,
226
+ body.async_operation_version,
227
+ body.async_operation_language,
228
+ )
229
+ handler = self._handlers.get(key)
230
+
231
+ if handler is None:
232
+ self._send_error(body.invoke_id, "NOT_FOUND", f"actor not found: {key}")
233
+ return
234
+
235
+ started = time.perf_counter()
236
+ try:
237
+ input_value = _parse_input_json(body.input_json)
238
+ if asyncio.iscoroutinefunction(handler):
239
+ output = asyncio.run(handler(input_value))
240
+ else:
241
+ output = handler(input_value)
242
+ duration_ms = max(0, round((time.perf_counter() - started) * 1000))
243
+ self._outbox.put(
244
+ pb.SessionRequest(
245
+ invoke_result=pb.InvokeResult(
246
+ invoke_id=body.invoke_id,
247
+ output_json=json.dumps(output),
248
+ duration_ms=duration_ms,
249
+ )
250
+ )
251
+ )
252
+ except Exception as exc: # noqa: BLE001 — reported to the gateway, not raised
253
+ logger.debug("Actor %s failed: %s", key, exc, exc_info=True)
254
+ self._send_error(body.invoke_id, "INTERNAL", str(exc))
255
+
256
+ def _send_error(self, invoke_id: str, code: str, message: str) -> None:
257
+ self._outbox.put(
258
+ pb.SessionRequest(
259
+ invoke_error=pb.InvokeError(
260
+ invoke_id=invoke_id,
261
+ error=pb.InvokeErrorDetail(
262
+ code=code, message=message, retriable=False
263
+ ),
264
+ )
265
+ )
266
+ )
@@ -0,0 +1,167 @@
1
+ """`list`/`start` command handling for a compiler-generated
2
+ `run_async_worker.py` — moved here from fsm-compiler-ts's
3
+ python/worker-sdk-cli.eta (#364), which used to write this whole file into
4
+ every project as `async-worker/python/cli.py`.
5
+
6
+ Python counterpart of @pgfsm/async-worker-sdk's runActorWorkerCli. Logging is
7
+ not configured here; the generated entry point configures it once before
8
+ calling this.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import logging
15
+ import signal
16
+ import threading
17
+ import uuid
18
+ from typing import List, Optional, Sequence
19
+
20
+ from .actor_worker import DEFAULT_HEARTBEAT_MS, ActorRegistration, ActorWorker
21
+
22
+ logger = logging.getLogger("pgfsm.async_worker_sdk.cli")
23
+
24
+ DEFAULT_GATEWAY_SOCKET_PATH = "/tmp/pgfsm-activity-gateway-workers.sock"
25
+
26
+ _DESCRIPTION = """\
27
+ pgfsm-async-worker-sdk — Python worker for the Activity Gateway
28
+
29
+ commands:
30
+ list Print the actors compiled into this registry, without connecting to the gateway.
31
+ start Connect to the gateway and serve invocations for every actor in the registry until stopped.
32
+
33
+ Actors come from a compiler-generated registry (see fsm-compiler-ts's
34
+ writeAggregateActorsRegistry) -- statically imported, not scanned or
35
+ dynamically loaded at startup.
36
+ """
37
+
38
+
39
+ class _UsageError(Exception):
40
+ pass
41
+
42
+
43
+ class _ArgumentParser(argparse.ArgumentParser):
44
+ """Raises instead of calling sys.exit(), so run_actor_worker_cli can turn a
45
+ usage error into an exit code."""
46
+
47
+ def error(self, message: str) -> None: # type: ignore[override]
48
+ raise _UsageError(message)
49
+
50
+
51
+ def _build_parser(invocation: str) -> _ArgumentParser:
52
+ parser = _ArgumentParser(
53
+ prog=invocation,
54
+ description=_DESCRIPTION,
55
+ formatter_class=argparse.RawDescriptionHelpFormatter,
56
+ epilog=f"example:\n {invocation} start --gateway-socket {DEFAULT_GATEWAY_SOCKET_PATH}",
57
+ add_help=False,
58
+ )
59
+ parser.add_argument("command", nargs="?", choices=["list", "start"])
60
+ parser.add_argument(
61
+ "-g",
62
+ "--gateway-socket",
63
+ default=DEFAULT_GATEWAY_SOCKET_PATH,
64
+ help=f"Sidecar socket to connect to (default: {DEFAULT_GATEWAY_SOCKET_PATH})",
65
+ )
66
+ parser.add_argument(
67
+ "-i",
68
+ "--worker-id",
69
+ default=None,
70
+ help="Stable worker identity (default: python-<random>)",
71
+ )
72
+ parser.add_argument(
73
+ "--heartbeat-ms",
74
+ type=int,
75
+ default=DEFAULT_HEARTBEAT_MS,
76
+ help=f"Heartbeat interval (default: {DEFAULT_HEARTBEAT_MS})",
77
+ )
78
+ parser.add_argument(
79
+ "-h", "--help", action="store_true", help="Show this help message"
80
+ )
81
+ return parser
82
+
83
+
84
+ def run_actor_worker_cli(
85
+ registrations: List[ActorRegistration],
86
+ args: Sequence[str],
87
+ invocation: Optional[str] = None,
88
+ ) -> int:
89
+ """Runs the `list`/`start` worker CLI against `registrations` and returns
90
+ the process exit code — the caller decides whether to exit with it, which
91
+ keeps this testable. `start` installs SIGINT/SIGTERM handlers (when called
92
+ from the main thread) that stop the worker gracefully, and restores the
93
+ previous handlers once it returns.
94
+
95
+ `invocation` is how to run the calling script, shown in `--help`; defaults
96
+ to `python3 run_async_worker.py`.
97
+ """
98
+ parser = _build_parser(invocation or "python3 run_async_worker.py")
99
+
100
+ try:
101
+ parsed = parser.parse_args(list(args))
102
+ except _UsageError as exc:
103
+ logger.error("%s", exc)
104
+ print(parser.format_help())
105
+ return 1
106
+
107
+ if parsed.help:
108
+ print(parser.format_help())
109
+ return 0
110
+
111
+ if parsed.command is None:
112
+ logger.error("First argument must be one of: list, start. Got: (none)")
113
+ print(parser.format_help())
114
+ return 1
115
+
116
+ gateway_socket_path: str = parsed.gateway_socket
117
+ worker_id: str = parsed.worker_id or f"python-{uuid.uuid4().hex[:8]}"
118
+
119
+ logger.info("%d actor(s) compiled into this registry", len(registrations))
120
+ for reg in registrations:
121
+ logger.info(
122
+ " + %s@%s (parent %s@%s)",
123
+ reg["async_operation_name"],
124
+ reg["async_operation_version"],
125
+ reg["parent_fsm_name"],
126
+ reg["parent_fsm_version"],
127
+ )
128
+
129
+ if parsed.command == "list":
130
+ return 0
131
+
132
+ if not registrations:
133
+ logger.error("No actors in the registry, refusing to start worker")
134
+ return 1
135
+
136
+ worker = ActorWorker(
137
+ worker_id=worker_id,
138
+ gateway_socket_path=gateway_socket_path,
139
+ registrations=registrations,
140
+ heartbeat_ms=parsed.heartbeat_ms,
141
+ )
142
+
143
+ def _on_signal(signum: int, frame: object) -> None:
144
+ del signum, frame
145
+ logger.info("Shutdown requested — stopping worker...")
146
+ worker.stop()
147
+
148
+ # signal.signal() only works from the main thread; elsewhere (e.g. a test
149
+ # driving this from a worker thread) the caller stops the worker itself.
150
+ previous_handlers = {}
151
+ if threading.current_thread() is threading.main_thread():
152
+ for sig in (signal.SIGINT, signal.SIGTERM):
153
+ previous_handlers[sig] = signal.signal(sig, _on_signal)
154
+
155
+ try:
156
+ logger.info(
157
+ "Starting worker %s: gateway-socket=%s", worker_id, gateway_socket_path
158
+ )
159
+ worker.run()
160
+ logger.info("Worker %s stopped.", worker_id)
161
+ return 0
162
+ except Exception as exc: # noqa: BLE001 — reported and turned into an exit code
163
+ logger.error("Worker %s failed: %s", worker_id, exc, exc_info=True)
164
+ return 1
165
+ finally:
166
+ for sig, handler in previous_handlers.items():
167
+ signal.signal(sig, handler)