ppio-gpus 0.0.1__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,56 @@
1
+ # If you prefer the allow list template instead of the deny list, see community template:
2
+ # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3
+ #
4
+ # Binaries for programs and plugins
5
+ *.exe
6
+ *.exe~
7
+ *.dll
8
+ *.so
9
+ *.dylib
10
+
11
+ # Test binary, built with `go test -c`
12
+ *.test
13
+
14
+ # Output of the go coverage tool, specifically when used with LiteIDE
15
+ *.out
16
+
17
+ # Dependency directories (remove the comment below to include it)
18
+ # vendor/
19
+
20
+ # Go workspace file
21
+ go.work
22
+ go.work.sum
23
+
24
+ # env file
25
+ .env
26
+ vendor/
27
+
28
+ # controller-gen
29
+ bin/
30
+
31
+ .stfolder
32
+ .stignore
33
+ .idea/
34
+ pkg/mod/
35
+ pkg/sumdb/
36
+
37
+ cmd/autoscaler/autoscaler
38
+ cmd/datawatcher/datawatcher
39
+ cmd/gateway/gateway
40
+ cmd/monolithic-gateway/gateway
41
+ cmd/taskproxy/taskproxy
42
+ cmd/taskscheduler/taskscheduler
43
+ cmd/metricserver/metricserver
44
+ deploy/nitor-*.tgz
45
+ deploy/manifests.yaml
46
+
47
+ main
48
+ *.pkl
49
+ *.pkl.lock
50
+
51
+ # Python
52
+ __pycache__/
53
+ *.pyc
54
+ *.egg-info/
55
+ dist/
56
+ .venv/
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: ppio-gpus
3
+ Version: 0.0.1
4
+ Summary: PPIO GPUs SDK - Python SDK for PPIO serverless GPU endpoints
5
+ Project-URL: Homepage, https://ppio.com/
6
+ Author-email: PPIO <support@ppio.com>
7
+ License: MIT
8
+ Keywords: ai,gpu,inference,ppio,sdk,serverless
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Requires-Dist: aiohttp-retry>=2.8.3
23
+ Requires-Dist: aiohttp>=3.9.0
24
+ Requires-Dist: boto3>=1.28.0
25
+ Requires-Dist: filelock>=3.12.0
26
+ Requires-Dist: requests>=2.25.0
27
+ Requires-Dist: urllib3>=2.0.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # ppio-gpus
31
+
32
+ Python SDK for PPIO serverless GPU endpoints.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install ppio-gpus
38
+ ```
39
+
40
+ ## Quick Start (Client)
41
+
42
+ ```python
43
+ import ppio_gpus
44
+
45
+ ppio_gpus.api_key = "your-api-key"
46
+
47
+ endpoint = ppio_gpus.Endpoint("your-endpoint-id")
48
+ job = endpoint.run({"prompt": "hello world"})
49
+
50
+ print(job.status())
51
+ output = job.output(timeout=60)
52
+ print(output)
53
+ ```
54
+
55
+ ## Quick Start (Worker)
56
+
57
+ ```python
58
+ import ppio_gpus
59
+
60
+ def handler(job):
61
+ job_input = job["input"]
62
+ # ... process ...
63
+ return {"result": "done"}
64
+
65
+ ppio_gpus.start({"handler": handler})
66
+ ```
67
+
68
+ ## Client API
69
+
70
+ - `Endpoint(endpoint_id)` -- create an endpoint client
71
+ - `Endpoint.run(input)` -- async submit a task, returns a `Job` immediately
72
+ - `Endpoint.health()` -- check endpoint health
73
+ - `Endpoint.purge_queue()` -- cancel all pending tasks
74
+ - `Job.status()` -- get current task status
75
+ - `Job.output(timeout=0)` -- get task output, optionally poll until complete
76
+ - `Job.cancel()` -- cancel a running task
77
+
78
+ ## Worker API
79
+
80
+ - `ppio_gpus.start(config)` -- start the worker loop (blocks until SIGTERM)
81
+ - `ppio_gpus.progress_update(job, data)` -- send in-progress status update
82
+ - `ppio_gpus.upload_image(job_id, path)` -- upload image to S3, returns presigned URL
83
+ - `ppio_gpus.upload_file(name, path)` -- upload file to S3
84
+ - `ppio_gpus.upload_bytes(name, data)` -- upload bytes to S3
85
+
86
+ ## Configuration
87
+
88
+ Set credentials via environment variables:
89
+
90
+ ```bash
91
+ export PPIO_API_KEY="your-api-key"
92
+ export PPIO_ENDPOINT_BASE_URL="https://async-public.serverless.ppinfra.com/v1" # default
93
+ ```
94
+
95
+ Or set them directly:
96
+
97
+ ```python
98
+ ppio_gpus.api_key = "your-api-key"
99
+ ppio_gpus.endpoint_url_base = "https://your-custom-url/v1"
100
+ ```
@@ -0,0 +1,71 @@
1
+ # ppio-gpus
2
+
3
+ Python SDK for PPIO serverless GPU endpoints.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install ppio-gpus
9
+ ```
10
+
11
+ ## Quick Start (Client)
12
+
13
+ ```python
14
+ import ppio_gpus
15
+
16
+ ppio_gpus.api_key = "your-api-key"
17
+
18
+ endpoint = ppio_gpus.Endpoint("your-endpoint-id")
19
+ job = endpoint.run({"prompt": "hello world"})
20
+
21
+ print(job.status())
22
+ output = job.output(timeout=60)
23
+ print(output)
24
+ ```
25
+
26
+ ## Quick Start (Worker)
27
+
28
+ ```python
29
+ import ppio_gpus
30
+
31
+ def handler(job):
32
+ job_input = job["input"]
33
+ # ... process ...
34
+ return {"result": "done"}
35
+
36
+ ppio_gpus.start({"handler": handler})
37
+ ```
38
+
39
+ ## Client API
40
+
41
+ - `Endpoint(endpoint_id)` -- create an endpoint client
42
+ - `Endpoint.run(input)` -- async submit a task, returns a `Job` immediately
43
+ - `Endpoint.health()` -- check endpoint health
44
+ - `Endpoint.purge_queue()` -- cancel all pending tasks
45
+ - `Job.status()` -- get current task status
46
+ - `Job.output(timeout=0)` -- get task output, optionally poll until complete
47
+ - `Job.cancel()` -- cancel a running task
48
+
49
+ ## Worker API
50
+
51
+ - `ppio_gpus.start(config)` -- start the worker loop (blocks until SIGTERM)
52
+ - `ppio_gpus.progress_update(job, data)` -- send in-progress status update
53
+ - `ppio_gpus.upload_image(job_id, path)` -- upload image to S3, returns presigned URL
54
+ - `ppio_gpus.upload_file(name, path)` -- upload file to S3
55
+ - `ppio_gpus.upload_bytes(name, data)` -- upload bytes to S3
56
+
57
+ ## Configuration
58
+
59
+ Set credentials via environment variables:
60
+
61
+ ```bash
62
+ export PPIO_API_KEY="your-api-key"
63
+ export PPIO_ENDPOINT_BASE_URL="https://async-public.serverless.ppinfra.com/v1" # default
64
+ ```
65
+
66
+ Or set them directly:
67
+
68
+ ```python
69
+ ppio_gpus.api_key = "your-api-key"
70
+ ppio_gpus.endpoint_url_base = "https://your-custom-url/v1"
71
+ ```
Binary file
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ppio-gpus"
7
+ version = "0.0.1"
8
+ description = "PPIO GPUs SDK - Python SDK for PPIO serverless GPU endpoints"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [
12
+ { name = "PPIO", email = "support@ppio.com" },
13
+ ]
14
+ keywords = ["ppio", "serverless", "gpu", "ai", "inference", "sdk"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.8",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ ]
29
+ requires-python = ">=3.8"
30
+ dependencies = [
31
+ "requests>=2.25.0",
32
+ "aiohttp>=3.9.0",
33
+ "aiohttp-retry>=2.8.3",
34
+ "boto3>=1.28.0",
35
+ "urllib3>=2.0.0",
36
+ "filelock>=3.12.0",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://ppio.com/"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/ppio_gpus"]
@@ -0,0 +1,99 @@
1
+ """
2
+ PPIO GPUs SDK
3
+
4
+ Usage (client)::
5
+
6
+ import ppio_gpus
7
+
8
+ ppio_gpus.api_key = "your-api-key"
9
+ endpoint = ppio_gpus.Endpoint("your-endpoint-id")
10
+ job = endpoint.run({"prompt": "hello"})
11
+ print(job.output(timeout=60))
12
+
13
+ Usage (worker)::
14
+
15
+ import ppio_gpus
16
+
17
+ def handler(job):
18
+ return {"result": job["input"]}
19
+
20
+ ppio_gpus.start({"handler": handler})
21
+ """
22
+
23
+ import argparse
24
+ import os
25
+ import signal
26
+ import sys
27
+ from typing import Any, Dict
28
+
29
+ from .client import Endpoint, Job, HTTPClient
30
+ from .modules.logger import PPIOLogger
31
+ from .modules.progress import progress_update
32
+ from .modules.upload import upload_image, upload_images, upload_file, upload_bytes
33
+ from .worker import run_worker
34
+
35
+ api_key = os.environ.get("PPIO_API_KEY", None)
36
+ endpoint_url_base = os.environ.get(
37
+ "PPIO_ENDPOINT_BASE_URL", "https://async-public.serverless.ppinfra.com/v1"
38
+ )
39
+
40
+ __all__ = [
41
+ # client
42
+ "Endpoint", "Job", "HTTPClient",
43
+ "api_key", "endpoint_url_base",
44
+ # worker
45
+ "start", "progress_update",
46
+ "upload_image", "upload_images", "upload_file", "upload_bytes",
47
+ ]
48
+
49
+ log = PPIOLogger()
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # CLI arguments
53
+ # ---------------------------------------------------------------------------
54
+ _parser = argparse.ArgumentParser(prog="ppio_gpus", description="PPIO Serverless Worker")
55
+ _parser.add_argument(
56
+ "--log_level",
57
+ type=str,
58
+ default=None,
59
+ help="Log level: ERROR, WARN, INFO, DEBUG (default: DEBUG).",
60
+ )
61
+
62
+
63
+ def _set_config_args(config: dict) -> dict:
64
+ args, unknown = _parser.parse_known_args()
65
+ sys.argv = [sys.argv[0]] + unknown
66
+ config["rp_args"] = vars(args)
67
+
68
+ if config["rp_args"].get("log_level"):
69
+ log.set_level(config["rp_args"]["log_level"])
70
+
71
+ return config
72
+
73
+
74
+ def _signal_handler(sig, frame):
75
+ del sig, frame
76
+ log.info("SIGINT received, shutting down.")
77
+ sys.exit(0)
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Public API
82
+ # ---------------------------------------------------------------------------
83
+ def start(config: Dict[str, Any]) -> None:
84
+ """
85
+ Start the PPIO serverless worker.
86
+
87
+ Args:
88
+ config: dict with at least ``"handler"`` (Callable).
89
+ Optional keys:
90
+ - ``"concurrency_modifier"`` -- callable(int) -> int
91
+ - ``"refresh_worker"`` -- bool, restart worker after each job
92
+ - ``"return_aggregate_stream"`` -- bool, accumulate stream chunks
93
+ """
94
+ print("--- Starting PPIO Serverless Worker ---")
95
+
96
+ signal.signal(signal.SIGINT, _signal_handler)
97
+ config = _set_config_args(config)
98
+
99
+ run_worker(config)
@@ -0,0 +1,264 @@
1
+ """
2
+ Async GPU Serverless Client SDK
3
+
4
+ Shared client module for submitting tasks to the async gateway.
5
+ Provides Endpoint, Job, and HTTPClient classes for async task submission.
6
+ """
7
+
8
+ import time
9
+ from typing import Any, Dict, Optional
10
+
11
+ import requests
12
+ from requests.adapters import HTTPAdapter, Retry
13
+
14
+
15
+ API_KEY_NOT_SET_MSG = (
16
+ "API key has not been set. Please set it via the api_key parameter "
17
+ "or the appropriate environment variable."
18
+ )
19
+ UNAUTHORIZED_MSG = "Unauthorized — invalid or expired API key."
20
+ FINAL_STATES = {"COMPLETED", "FAILED", "TIMED_OUT", "CANCELLED"}
21
+
22
+
23
+ def _is_completed(status: str) -> bool:
24
+ return status in FINAL_STATES
25
+
26
+
27
+ # ---------------------------------------------------------------------------- #
28
+ # Client #
29
+ # ---------------------------------------------------------------------------- #
30
+ class HTTPClient:
31
+ """A client for making requests to the async-gateway."""
32
+
33
+ def __init__(self, api_key: str, endpoint_url_base: str):
34
+ """
35
+ Initialize an HTTPClient instance.
36
+
37
+ Args:
38
+ api_key: The API key for authentication.
39
+ endpoint_url_base: The base URL for the async-gateway v1 API.
40
+
41
+ Raises:
42
+ RuntimeError: If the API key is not provided.
43
+ """
44
+ if api_key is None:
45
+ raise RuntimeError(API_KEY_NOT_SET_MSG)
46
+
47
+ self.api_key = api_key
48
+ self.endpoint_url_base = endpoint_url_base.rstrip("/")
49
+
50
+ self.session = requests.Session()
51
+ retries = Retry(total=5, backoff_factor=1, status_forcelist=[408, 429])
52
+ self.session.mount("http://", HTTPAdapter(max_retries=retries))
53
+ self.session.mount("https://", HTTPAdapter(max_retries=retries))
54
+
55
+ self.headers = {
56
+ "Content-Type": "application/json",
57
+ "Authorization": f"Bearer {self.api_key}",
58
+ }
59
+
60
+ def _request(
61
+ self,
62
+ method: str,
63
+ endpoint: str,
64
+ data: Optional[dict] = None,
65
+ timeout: int = 10,
66
+ ):
67
+ """
68
+ Make a request to the specified endpoint using the given HTTP method.
69
+
70
+ Args:
71
+ method: The HTTP method to use ('GET' or 'POST').
72
+ endpoint: The endpoint path appended to endpoint_url_base.
73
+ data: The JSON payload to send with the request.
74
+ timeout: Seconds to wait before giving up.
75
+
76
+ Returns:
77
+ The JSON response from the server.
78
+
79
+ Raises:
80
+ RuntimeError: If the response returns a 401 Unauthorized status.
81
+ requests.HTTPError: If the response contains an unsuccessful status code.
82
+ """
83
+ url = f"{self.endpoint_url_base}/{endpoint}"
84
+ response = self.session.request(
85
+ method, url, headers=self.headers, json=data, timeout=timeout
86
+ )
87
+
88
+ if response.status_code == 401:
89
+ raise RuntimeError(UNAUTHORIZED_MSG)
90
+
91
+ response.raise_for_status()
92
+ return response.json()
93
+
94
+ def post(self, endpoint: str, data: Optional[dict] = None, timeout: int = 10):
95
+ """Post to the endpoint."""
96
+ return self._request("POST", endpoint, data, timeout)
97
+
98
+ def get(self, endpoint: str, timeout: int = 10):
99
+ """Get from the endpoint."""
100
+ return self._request("GET", endpoint, timeout=timeout)
101
+
102
+
103
+ # ---------------------------------------------------------------------------- #
104
+ # Job #
105
+ # ---------------------------------------------------------------------------- #
106
+ class Job:
107
+ """Represents a submitted task on the async-gateway."""
108
+
109
+ def __init__(self, endpoint_id: str, job_id: str, client: HTTPClient):
110
+ """
111
+ Initialize a Job instance.
112
+
113
+ Args:
114
+ endpoint_id: The identifier for the endpoint.
115
+ job_id: The identifier for the task.
116
+ client: An HTTPClient instance to make requests with.
117
+ """
118
+ self.endpoint_id = endpoint_id
119
+ self.job_id = job_id
120
+ self._client = client
121
+
122
+ self._status: Optional[str] = None
123
+ self._output: Optional[Any] = None
124
+
125
+ def _fetch_job(self) -> Dict[str, Any]:
126
+ """Fetch current task state from the gateway."""
127
+ status_url = f"{self.endpoint_id}/status/{self.job_id}"
128
+ job_state = self._client.get(endpoint=status_url)
129
+
130
+ if _is_completed(job_state["status"]):
131
+ self._status = job_state["status"]
132
+ self._output = job_state.get("output", None)
133
+
134
+ return job_state
135
+
136
+ def status(self) -> str:
137
+ """
138
+ Returns the status of the task.
139
+
140
+ Terminal states are cached so subsequent calls avoid extra requests.
141
+ """
142
+ if self._status is not None:
143
+ return self._status
144
+
145
+ return self._fetch_job()["status"]
146
+
147
+ def output(self, timeout: int = 0) -> Any:
148
+ """
149
+ Returns the output of the task.
150
+
151
+ Args:
152
+ timeout: If > 0, polls every second until the task reaches a
153
+ terminal state or the timeout is exceeded.
154
+
155
+ Raises:
156
+ TimeoutError: If the timeout is exceeded before the task completes.
157
+ """
158
+ if timeout > 0:
159
+ remaining = timeout
160
+ while not _is_completed(self.status()):
161
+ time.sleep(1)
162
+ remaining -= 1
163
+ if remaining <= 0:
164
+ raise TimeoutError("Job timed out.")
165
+
166
+ if self._output is not None:
167
+ return self._output
168
+
169
+ return self._fetch_job().get("output", None)
170
+
171
+ def cancel(self, timeout: int = 3) -> Any:
172
+ """
173
+ Cancel the task.
174
+
175
+ Args:
176
+ timeout: Seconds to wait for the server to respond.
177
+ """
178
+ return self._client.post(
179
+ f"{self.endpoint_id}/cancel/{self.job_id}",
180
+ data=None,
181
+ timeout=timeout,
182
+ )
183
+
184
+
185
+ # ---------------------------------------------------------------------------- #
186
+ # Endpoint #
187
+ # ---------------------------------------------------------------------------- #
188
+ class Endpoint:
189
+ """Manages an endpoint for submitting tasks to the async-gateway."""
190
+
191
+ def __init__(
192
+ self,
193
+ endpoint_id: str,
194
+ api_key: Optional[str] = None,
195
+ endpoint_url_base: Optional[str] = None,
196
+ ):
197
+ """
198
+ Initialize an Endpoint instance.
199
+
200
+ If api_key or endpoint_url_base are not provided, the constructor
201
+ reads module-level globals ``ppio_gpus.api_key`` and
202
+ ``ppio_gpus.endpoint_url_base``.
203
+
204
+ Args:
205
+ endpoint_id: The identifier for the endpoint.
206
+ api_key: Optional API key. Falls back to ppio_gpus.api_key.
207
+ endpoint_url_base: Optional base URL. Falls back to ppio_gpus.endpoint_url_base.
208
+ """
209
+ self.endpoint_id = endpoint_id
210
+
211
+ resolved_key = api_key
212
+ resolved_base = endpoint_url_base
213
+
214
+ if resolved_key is None or resolved_base is None:
215
+ import ppio_gpus as _pkg
216
+ if resolved_key is None:
217
+ resolved_key = getattr(_pkg, "api_key", None)
218
+ if resolved_base is None:
219
+ resolved_base = getattr(_pkg, "endpoint_url_base", None)
220
+
221
+ self._client = HTTPClient(api_key=resolved_key, endpoint_url_base=resolved_base)
222
+
223
+ def run(self, request_input: Dict[str, Any]) -> Job:
224
+ """
225
+ Submit a task to the endpoint.
226
+
227
+ Automatically wraps the payload in ``{"input": ...}`` if the dict
228
+ does not already contain an ``"input"`` key.
229
+
230
+ Args:
231
+ request_input: The input payload for the task.
232
+
233
+ Returns:
234
+ A Job instance for tracking the submitted task.
235
+ """
236
+ if "input" not in request_input:
237
+ request_input = {"input": request_input}
238
+
239
+ job_request = self._client.post(
240
+ f"{self.endpoint_id}/run", request_input
241
+ )
242
+ return Job(self.endpoint_id, job_request["id"], self._client)
243
+
244
+ def health(self, timeout: int = 3) -> Dict[str, Any]:
245
+ """
246
+ Check the health of the endpoint.
247
+
248
+ Args:
249
+ timeout: Seconds to wait for the server to respond.
250
+ """
251
+ return self._client.get(
252
+ f"{self.endpoint_id}/health", timeout=timeout
253
+ )
254
+
255
+ def purge_queue(self, timeout: int = 3) -> Dict[str, Any]:
256
+ """
257
+ Purge all pending tasks from the endpoint's queue.
258
+
259
+ Args:
260
+ timeout: Seconds to wait for the server to respond.
261
+ """
262
+ return self._client.post(
263
+ f"{self.endpoint_id}/purge-queue", data=None, timeout=timeout
264
+ )
@@ -0,0 +1,40 @@
1
+ """
2
+ HTTP Client abstractions
3
+ """
4
+
5
+ import os
6
+
7
+ import requests
8
+ from aiohttp import ClientSession, ClientTimeout, TCPConnector, ClientResponseError
9
+
10
+
11
+ class TooManyRequests(ClientResponseError):
12
+ pass
13
+
14
+
15
+ def _get_auth_header():
16
+ # PPIO_AI_API_KEY already contains "Bearer <jwt>" when JWT auth is configured.
17
+ auth_value = os.environ.get("PPIO_AI_API_KEY", "")
18
+ return {
19
+ "Content-Type": "application/json",
20
+ "Authorization": auth_value,
21
+ "Accept-Encoding": "gzip, deflate",
22
+ }
23
+
24
+
25
+ def AsyncClientSession(*args, **kwargs): # pylint: disable=invalid-name
26
+ """
27
+ Factory for aiohttp.ClientSession pre-configured for the PPIO async gateway.
28
+ """
29
+ return ClientSession(
30
+ connector=TCPConnector(limit=0),
31
+ headers=_get_auth_header(),
32
+ timeout=ClientTimeout(600, ceil_threshold=400),
33
+ *args,
34
+ **kwargs,
35
+ )
36
+
37
+
38
+ class SyncClientSession(requests.Session):
39
+ """Thin wrapper around requests.Session."""
40
+ pass
@@ -0,0 +1 @@
1
+ from ppio_gpus.modules import handler, job, logger, ping, progress, scale, upload, worker_state
@@ -0,0 +1,9 @@
1
+ """Handler introspection utilities."""
2
+
3
+ import inspect
4
+ from typing import Callable
5
+
6
+
7
+ def is_generator(handler: Callable) -> bool:
8
+ """Return True if handler is a (async) generator function."""
9
+ return inspect.isgeneratorfunction(handler) or inspect.isasyncgenfunction(handler)