gridlinegpu 0.1.10__tar.gz → 0.2.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.
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/PKG-INFO +117 -7
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/README.md +116 -6
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/pyproject.toml +1 -1
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/__init__.py +7 -3
- gridlinegpu-0.2.0/src/gridlinegpu/_app_entry.py +114 -0
- gridlinegpu-0.2.0/src/gridlinegpu/_app_remote.py +175 -0
- gridlinegpu-0.2.0/src/gridlinegpu/_finetune_job.py +121 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_contract.py +10 -1
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_errors.py +72 -2
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_interfaces.py +71 -5
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_job.py +41 -10
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_release.py +1 -1
- gridlinegpu-0.2.0/src/gridlinegpu/app.py +770 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/cli.py +7 -1
- gridlinegpu-0.2.0/src/gridlinegpu/cli_app.py +218 -0
- gridlinegpu-0.2.0/src/gridlinegpu/finetune.py +78 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/PKG-INFO +117 -7
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/SOURCES.txt +10 -0
- gridlinegpu-0.2.0/tests/test_app.py +349 -0
- gridlinegpu-0.2.0/tests/test_app_map.py +360 -0
- gridlinegpu-0.2.0/tests/test_cli_app.py +167 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_client_source.py +1 -1
- gridlinegpu-0.2.0/tests/test_finetune_helper.py +101 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_contract.py +78 -3
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_errors.py +6 -2
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_job.py +22 -7
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_job_results.py +2 -2
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/setup.cfg +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/UNICODE-LICENSE.txt +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/__main__.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_batch.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_access.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_admission.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_capture.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_casefold.json +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_credits.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_history.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_outputs.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_pool.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_public.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_results.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_run.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_submission.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_transfer.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/cli_auth.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/client.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/errors.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/worker.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/dependency_links.txt +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/entry_points.txt +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/requires.txt +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/top_level.txt +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_batch.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_cli.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_cli_auth.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_client.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_finetune_acceptance.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_access.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_admission.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_canary.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_capture.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_capture_gaps.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_credits.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_curated_run.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_history.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_o6c_acceptance.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_pool.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_release.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_results.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_run.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_submission.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_transfer.py +0 -0
- {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client for Gridline YAML workload routing
|
|
5
5
|
Author: Gridline GPU
|
|
6
6
|
License: Proprietary
|
|
@@ -28,13 +28,13 @@ Linux.
|
|
|
28
28
|
1. **Sign up** at https://gridlinegpu.com/signup and verify your email.
|
|
29
29
|
2. **Request access** at https://gridlinegpu.com/get-started: accept the partner beta terms and
|
|
30
30
|
describe your use case in a few lines.
|
|
31
|
-
3. **Wait for approval.** Gridline
|
|
32
|
-
credit
|
|
33
|
-
credit.
|
|
31
|
+
3. **Wait for approval.** Gridline checks each request; approval grants your account beta
|
|
32
|
+
credit, and the get-started page shows the status. There is no payment step: jobs draw on
|
|
33
|
+
that credit.
|
|
34
34
|
4. **Install** the SDK with the source extra (Python 3.10 or newer):
|
|
35
35
|
|
|
36
36
|
```sh
|
|
37
|
-
python -m pip install 'gridlinegpu[source-contract]>=0.
|
|
37
|
+
python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
5. **Create a token** once. Browser approval saves a private, scoped token file; it is never
|
|
@@ -151,6 +151,116 @@ compiler. Without it, the YAML commands and `client.access()` still work and sou
|
|
|
151
151
|
`source_dependencies_missing` with the install line. The "Source runs (beta)" section below has the
|
|
152
152
|
limits, costs and failure codes.
|
|
153
153
|
|
|
154
|
+
## Functions: `App`, `@app.function`, `.remote()` and `.spawn()` (preview)
|
|
155
|
+
|
|
156
|
+
Decorate a Python function instead of writing YAML. Each call becomes one ordinary source job,
|
|
157
|
+
with the same access, credit holds, limits, isolation and billing as `run()`:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import gridlinegpu as gl
|
|
161
|
+
|
|
162
|
+
app = gl.App("transcribe")
|
|
163
|
+
image = gl.Image.curated("gridline/hf-torch-2.5@r1")
|
|
164
|
+
|
|
165
|
+
@app.function(image=image, gpu="any:16GB", outputs={"transcript": "transcript.txt"})
|
|
166
|
+
def transcribe(audio: gl.File, language: str = "en") -> dict:
|
|
167
|
+
text = run_model(audio.path, language)
|
|
168
|
+
(gl.output_dir() / "transcript.txt").write_text(text)
|
|
169
|
+
return {"chars": len(text)}
|
|
170
|
+
|
|
171
|
+
print(transcribe.remote(gl.File("audio/a.flac"))) # waits; files land in gridline-outputs/
|
|
172
|
+
call = transcribe.spawn(gl.File("audio/b.flac")) # returns once the job is admitted
|
|
173
|
+
print(call.job_id, call.get())
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
How the parts work:
|
|
177
|
+
|
|
178
|
+
- **Code.** The function's module and the local modules and packages it imports (read from the
|
|
179
|
+
source, never run) are sent with the job. Add data files with `include=["data/*.json"]`. Inside
|
|
180
|
+
the job, `gridlinegpu` is a small stand-in, so the module imports in any image; only the
|
|
181
|
+
function runs, never `local_entrypoint` or `if __name__ == "__main__"` code.
|
|
182
|
+
- **Arguments and return values** are JSON data plus `gl.File` arguments. There is no pickle.
|
|
183
|
+
Each `gl.File` is uploaded as an input file (at most 512 MiB). The return value is at most
|
|
184
|
+
8 MiB. Larger results are files written under `gl.output_dir()` and declared in `outputs`
|
|
185
|
+
(at most 128 MiB each).
|
|
186
|
+
- **Options:**
|
|
187
|
+
- `gpu="any:<N>GB"` sets the minimum GPU memory (default 8 GB).
|
|
188
|
+
- `models={"base": gl.Model.hf(repo, revision)}` is fetched by the platform and found at
|
|
189
|
+
`gl.model_path("base")`.
|
|
190
|
+
- `kind="training"` marks a training job.
|
|
191
|
+
- `timeout=` is a local limit, enforced best-effort while the SDK waits; past it the job is
|
|
192
|
+
cancelled.
|
|
193
|
+
- **Not yet available:** named GPU types, `max_price`, `capacity` and built images. Each
|
|
194
|
+
raises when used.
|
|
195
|
+
|
|
196
|
+
**Many calls: `.map()` and `.starmap()`.** These return a `MapRun`; iterating it submits the calls
|
|
197
|
+
and yields their values. Nothing is submitted before that.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
for value in transcribe.map([gl.File(path) for path in sorted(Path("audio").glob("*.flac"))],
|
|
201
|
+
kwargs={"language": "en"}):
|
|
202
|
+
print(value)
|
|
203
|
+
results = list(transcribe.starmap([(gl.File("a.flac"), "en"), (gl.File("b.flac"), "de")],
|
|
204
|
+
max_spend_usd=5.0, return_exceptions=True))
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A map runs one of two ways:
|
|
208
|
+
|
|
209
|
+
- **Warm pool.** With `max_spend_usd` (and `warm=None`, the default) or `warm=True`, a worker
|
|
210
|
+
pool of 1 to `max_workers` (at most 4) GPU workers runs the calls. The module loads once per
|
|
211
|
+
worker.
|
|
212
|
+
- Each call is one item, delivered at least once, with exactly one result committed.
|
|
213
|
+
- The function's `timeout` is the item timeout (60 to 3,600 seconds).
|
|
214
|
+
- A pooled function declares at most 7 outputs, each a file name directly in
|
|
215
|
+
`gl.output_dir()`.
|
|
216
|
+
- An account without pools falls back to chunks, unless `warm=True`.
|
|
217
|
+
- **Chunks of ordinary jobs.** `per_job` calls per job (default 16; 1 when the function
|
|
218
|
+
declares outputs), at most `max_running` jobs at once (default 2, the account's
|
|
219
|
+
running-jobs limit). A call that raises is recorded and the job runs its next call.
|
|
220
|
+
|
|
221
|
+
Common to both:
|
|
222
|
+
|
|
223
|
+
- Values come back in input order (`order="completed"` yields each as it finishes).
|
|
224
|
+
- `@app.function(retries=N)` (0 to 4) gives each failed call N more attempts.
|
|
225
|
+
- A call that still fails raises `gl.FunctionError` (`function_raised`, the job's failure code,
|
|
226
|
+
or `item_dead`). With `return_exceptions=True` it is yielded instead.
|
|
227
|
+
- Each job's or item's files land under `gridline-outputs/<function>/<job or pool id>/`.
|
|
228
|
+
|
|
229
|
+
Inside a job, a decorated function is the plain function: call other functions with
|
|
230
|
+
`.local()`. `.remote()` and `.map()` are refused there, because the job has no network.
|
|
231
|
+
|
|
232
|
+
**Fine-tuning in one call.** `gl.finetune.lora()` and `gl.finetune.qlora()` submit one training job on
|
|
233
|
+
`gridline/hf-finetune-2.5@r1`. The job runs Gridline's recipe, trl `SFTTrainer` with a peft LoRA:
|
|
234
|
+
bf16 for LoRA, 4-bit NF4 for QLoRA. The platform fetches the base model at its pinned revision;
|
|
235
|
+
the dataset is a JSON-lines file whose rows have a `text` field:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
run = gl.finetune.qlora(gl.Model.hf("HuggingFaceTB/SmolLM2-135M", revision="93efa2f097d58c2a74874c7e644dbc9b0cee75a2"),
|
|
239
|
+
"data/train.jsonl", eval_dataset="data/eval.jsonl", epochs=2, rank=16)
|
|
240
|
+
metrics = run.get(outputs_dir="adapters/smol") # adapter_model.safetensors, adapter_config.json, metrics.json
|
|
241
|
+
print(metrics["eval_loss_before"], metrics["eval_loss_after"])
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Options: `epochs`, `learning_rate`, `rank`, `alpha`, `dropout`, `max_seq_length`,
|
|
245
|
+
`batch_size`, `max_steps`, `target_modules` and `gpu` (default `"any:16GB"`). The adapter is at
|
|
246
|
+
most 128 MiB. Full fine-tunes (all weights) are not offered yet.
|
|
247
|
+
|
|
248
|
+
**From the command line.** Running submits paid jobs, so it needs `--apply`:
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
gridline app show transcribe_app.py # each function's generated YAML; nothing is sent
|
|
252
|
+
gridline run --apply transcribe_app.py --folder audio # the local_entrypoint, arguments typed from it
|
|
253
|
+
gridline run --apply transcribe_app.py::transcribe --audio a.flac --language en
|
|
254
|
+
gridline run --apply --detach transcribe_app.py::transcribe --audio a.flac # print the job id and exit
|
|
255
|
+
gridline pools status <pool id> # also: items, close, cancel --apply
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`gridline run` imports the file the way `python app.py` would. Each `--name value` is converted
|
|
259
|
+
by the parameter's annotation or default: `int`, `float`, `bool`, `str`, or `gl.File` for a path.
|
|
260
|
+
- **Failures:** a failed job raises `gl.FunctionError` with the job's failure code. An uncertain
|
|
261
|
+
submission raises `SubmissionOutcomeUnknown`, as `run()` does; continue it with
|
|
262
|
+
`transcribe.resume(error.submission_id)`.
|
|
263
|
+
|
|
154
264
|
## Many files: batches of ordinary jobs
|
|
155
265
|
|
|
156
266
|
`client.run_batch()` (0.1.10 or newer) runs one workload over many files, at most `per_job` files
|
|
@@ -272,7 +382,7 @@ files referenced by YAML, returns a route for review, then launches only that ex
|
|
|
272
382
|
fresh plan when you supply a cost ceiling and idempotency key.
|
|
273
383
|
|
|
274
384
|
```sh
|
|
275
|
-
python -m pip install 'gridlinegpu>=0.
|
|
385
|
+
python -m pip install 'gridlinegpu>=0.2.0,<0.3'
|
|
276
386
|
```
|
|
277
387
|
|
|
278
388
|
Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
|
|
@@ -386,7 +496,7 @@ step in the SDK. Request access first (see the quickstart above); `client.access
|
|
|
386
496
|
`gridline access` show where your request stands.
|
|
387
497
|
|
|
388
498
|
```sh
|
|
389
|
-
python -m pip install 'gridlinegpu[source-contract]>=0.
|
|
499
|
+
python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
|
|
390
500
|
gridline setup --apply # once: saves a private, scoped token file
|
|
391
501
|
gridline access # "approved" before your first run
|
|
392
502
|
```
|
|
@@ -10,13 +10,13 @@ Linux.
|
|
|
10
10
|
1. **Sign up** at https://gridlinegpu.com/signup and verify your email.
|
|
11
11
|
2. **Request access** at https://gridlinegpu.com/get-started: accept the partner beta terms and
|
|
12
12
|
describe your use case in a few lines.
|
|
13
|
-
3. **Wait for approval.** Gridline
|
|
14
|
-
credit
|
|
15
|
-
credit.
|
|
13
|
+
3. **Wait for approval.** Gridline checks each request; approval grants your account beta
|
|
14
|
+
credit, and the get-started page shows the status. There is no payment step: jobs draw on
|
|
15
|
+
that credit.
|
|
16
16
|
4. **Install** the SDK with the source extra (Python 3.10 or newer):
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
python -m pip install 'gridlinegpu[source-contract]>=0.
|
|
19
|
+
python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
5. **Create a token** once. Browser approval saves a private, scoped token file; it is never
|
|
@@ -133,6 +133,116 @@ compiler. Without it, the YAML commands and `client.access()` still work and sou
|
|
|
133
133
|
`source_dependencies_missing` with the install line. The "Source runs (beta)" section below has the
|
|
134
134
|
limits, costs and failure codes.
|
|
135
135
|
|
|
136
|
+
## Functions: `App`, `@app.function`, `.remote()` and `.spawn()` (preview)
|
|
137
|
+
|
|
138
|
+
Decorate a Python function instead of writing YAML. Each call becomes one ordinary source job,
|
|
139
|
+
with the same access, credit holds, limits, isolation and billing as `run()`:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import gridlinegpu as gl
|
|
143
|
+
|
|
144
|
+
app = gl.App("transcribe")
|
|
145
|
+
image = gl.Image.curated("gridline/hf-torch-2.5@r1")
|
|
146
|
+
|
|
147
|
+
@app.function(image=image, gpu="any:16GB", outputs={"transcript": "transcript.txt"})
|
|
148
|
+
def transcribe(audio: gl.File, language: str = "en") -> dict:
|
|
149
|
+
text = run_model(audio.path, language)
|
|
150
|
+
(gl.output_dir() / "transcript.txt").write_text(text)
|
|
151
|
+
return {"chars": len(text)}
|
|
152
|
+
|
|
153
|
+
print(transcribe.remote(gl.File("audio/a.flac"))) # waits; files land in gridline-outputs/
|
|
154
|
+
call = transcribe.spawn(gl.File("audio/b.flac")) # returns once the job is admitted
|
|
155
|
+
print(call.job_id, call.get())
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
How the parts work:
|
|
159
|
+
|
|
160
|
+
- **Code.** The function's module and the local modules and packages it imports (read from the
|
|
161
|
+
source, never run) are sent with the job. Add data files with `include=["data/*.json"]`. Inside
|
|
162
|
+
the job, `gridlinegpu` is a small stand-in, so the module imports in any image; only the
|
|
163
|
+
function runs, never `local_entrypoint` or `if __name__ == "__main__"` code.
|
|
164
|
+
- **Arguments and return values** are JSON data plus `gl.File` arguments. There is no pickle.
|
|
165
|
+
Each `gl.File` is uploaded as an input file (at most 512 MiB). The return value is at most
|
|
166
|
+
8 MiB. Larger results are files written under `gl.output_dir()` and declared in `outputs`
|
|
167
|
+
(at most 128 MiB each).
|
|
168
|
+
- **Options:**
|
|
169
|
+
- `gpu="any:<N>GB"` sets the minimum GPU memory (default 8 GB).
|
|
170
|
+
- `models={"base": gl.Model.hf(repo, revision)}` is fetched by the platform and found at
|
|
171
|
+
`gl.model_path("base")`.
|
|
172
|
+
- `kind="training"` marks a training job.
|
|
173
|
+
- `timeout=` is a local limit, enforced best-effort while the SDK waits; past it the job is
|
|
174
|
+
cancelled.
|
|
175
|
+
- **Not yet available:** named GPU types, `max_price`, `capacity` and built images. Each
|
|
176
|
+
raises when used.
|
|
177
|
+
|
|
178
|
+
**Many calls: `.map()` and `.starmap()`.** These return a `MapRun`; iterating it submits the calls
|
|
179
|
+
and yields their values. Nothing is submitted before that.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
for value in transcribe.map([gl.File(path) for path in sorted(Path("audio").glob("*.flac"))],
|
|
183
|
+
kwargs={"language": "en"}):
|
|
184
|
+
print(value)
|
|
185
|
+
results = list(transcribe.starmap([(gl.File("a.flac"), "en"), (gl.File("b.flac"), "de")],
|
|
186
|
+
max_spend_usd=5.0, return_exceptions=True))
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
A map runs one of two ways:
|
|
190
|
+
|
|
191
|
+
- **Warm pool.** With `max_spend_usd` (and `warm=None`, the default) or `warm=True`, a worker
|
|
192
|
+
pool of 1 to `max_workers` (at most 4) GPU workers runs the calls. The module loads once per
|
|
193
|
+
worker.
|
|
194
|
+
- Each call is one item, delivered at least once, with exactly one result committed.
|
|
195
|
+
- The function's `timeout` is the item timeout (60 to 3,600 seconds).
|
|
196
|
+
- A pooled function declares at most 7 outputs, each a file name directly in
|
|
197
|
+
`gl.output_dir()`.
|
|
198
|
+
- An account without pools falls back to chunks, unless `warm=True`.
|
|
199
|
+
- **Chunks of ordinary jobs.** `per_job` calls per job (default 16; 1 when the function
|
|
200
|
+
declares outputs), at most `max_running` jobs at once (default 2, the account's
|
|
201
|
+
running-jobs limit). A call that raises is recorded and the job runs its next call.
|
|
202
|
+
|
|
203
|
+
Common to both:
|
|
204
|
+
|
|
205
|
+
- Values come back in input order (`order="completed"` yields each as it finishes).
|
|
206
|
+
- `@app.function(retries=N)` (0 to 4) gives each failed call N more attempts.
|
|
207
|
+
- A call that still fails raises `gl.FunctionError` (`function_raised`, the job's failure code,
|
|
208
|
+
or `item_dead`). With `return_exceptions=True` it is yielded instead.
|
|
209
|
+
- Each job's or item's files land under `gridline-outputs/<function>/<job or pool id>/`.
|
|
210
|
+
|
|
211
|
+
Inside a job, a decorated function is the plain function: call other functions with
|
|
212
|
+
`.local()`. `.remote()` and `.map()` are refused there, because the job has no network.
|
|
213
|
+
|
|
214
|
+
**Fine-tuning in one call.** `gl.finetune.lora()` and `gl.finetune.qlora()` submit one training job on
|
|
215
|
+
`gridline/hf-finetune-2.5@r1`. The job runs Gridline's recipe, trl `SFTTrainer` with a peft LoRA:
|
|
216
|
+
bf16 for LoRA, 4-bit NF4 for QLoRA. The platform fetches the base model at its pinned revision;
|
|
217
|
+
the dataset is a JSON-lines file whose rows have a `text` field:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
run = gl.finetune.qlora(gl.Model.hf("HuggingFaceTB/SmolLM2-135M", revision="93efa2f097d58c2a74874c7e644dbc9b0cee75a2"),
|
|
221
|
+
"data/train.jsonl", eval_dataset="data/eval.jsonl", epochs=2, rank=16)
|
|
222
|
+
metrics = run.get(outputs_dir="adapters/smol") # adapter_model.safetensors, adapter_config.json, metrics.json
|
|
223
|
+
print(metrics["eval_loss_before"], metrics["eval_loss_after"])
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Options: `epochs`, `learning_rate`, `rank`, `alpha`, `dropout`, `max_seq_length`,
|
|
227
|
+
`batch_size`, `max_steps`, `target_modules` and `gpu` (default `"any:16GB"`). The adapter is at
|
|
228
|
+
most 128 MiB. Full fine-tunes (all weights) are not offered yet.
|
|
229
|
+
|
|
230
|
+
**From the command line.** Running submits paid jobs, so it needs `--apply`:
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
gridline app show transcribe_app.py # each function's generated YAML; nothing is sent
|
|
234
|
+
gridline run --apply transcribe_app.py --folder audio # the local_entrypoint, arguments typed from it
|
|
235
|
+
gridline run --apply transcribe_app.py::transcribe --audio a.flac --language en
|
|
236
|
+
gridline run --apply --detach transcribe_app.py::transcribe --audio a.flac # print the job id and exit
|
|
237
|
+
gridline pools status <pool id> # also: items, close, cancel --apply
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`gridline run` imports the file the way `python app.py` would. Each `--name value` is converted
|
|
241
|
+
by the parameter's annotation or default: `int`, `float`, `bool`, `str`, or `gl.File` for a path.
|
|
242
|
+
- **Failures:** a failed job raises `gl.FunctionError` with the job's failure code. An uncertain
|
|
243
|
+
submission raises `SubmissionOutcomeUnknown`, as `run()` does; continue it with
|
|
244
|
+
`transcribe.resume(error.submission_id)`.
|
|
245
|
+
|
|
136
246
|
## Many files: batches of ordinary jobs
|
|
137
247
|
|
|
138
248
|
`client.run_batch()` (0.1.10 or newer) runs one workload over many files, at most `per_job` files
|
|
@@ -254,7 +364,7 @@ files referenced by YAML, returns a route for review, then launches only that ex
|
|
|
254
364
|
fresh plan when you supply a cost ceiling and idempotency key.
|
|
255
365
|
|
|
256
366
|
```sh
|
|
257
|
-
python -m pip install 'gridlinegpu>=0.
|
|
367
|
+
python -m pip install 'gridlinegpu>=0.2.0,<0.3'
|
|
258
368
|
```
|
|
259
369
|
|
|
260
370
|
Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
|
|
@@ -368,7 +478,7 @@ step in the SDK. Request access first (see the quickstart above); `client.access
|
|
|
368
478
|
`gridline access` show where your request stands.
|
|
369
479
|
|
|
370
480
|
```sh
|
|
371
|
-
python -m pip install 'gridlinegpu[source-contract]>=0.
|
|
481
|
+
python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
|
|
372
482
|
gridline setup --apply # once: saves a private, scoped token file
|
|
373
483
|
gridline access # "approved" before your first run
|
|
374
484
|
```
|
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
from ._batch import BatchResult, BatchRun, batch
|
|
4
4
|
from ._batch import slot_names as batch_slots
|
|
5
|
+
from .app import (App, File, Function, FunctionCall, FunctionError, Image, MapRun, Model, Output, model_path,
|
|
6
|
+
output_dir)
|
|
5
7
|
from .client import GridlineClient, PlannedWorkload
|
|
6
8
|
from .errors import (
|
|
7
9
|
GridlineError, LaunchOutcomeUnknown, OutputsIncomplete, ReceiptPersistenceError, SubmissionOutcomeUnknown,
|
|
8
10
|
WaitTimeout, WorkloadValidationError,
|
|
9
11
|
)
|
|
12
|
+
from . import finetune # noqa: E402 - after app's names, which its job module imports
|
|
10
13
|
|
|
11
14
|
__all__ = [
|
|
12
|
-
"BatchResult", "BatchRun", "
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
+
"App", "BatchResult", "BatchRun", "File", "Function", "FunctionCall", "FunctionError", "GridlineClient",
|
|
16
|
+
"GridlineError", "Image", "LaunchOutcomeUnknown", "MapRun", "Model", "Output", "OutputsIncomplete", "PlannedWorkload",
|
|
17
|
+
"ReceiptPersistenceError", "SubmissionOutcomeUnknown", "WaitTimeout", "WorkloadValidationError", "batch",
|
|
18
|
+
"batch_slots", "finetune", "model_path", "output_dir",
|
|
15
19
|
]
|
|
16
20
|
|
|
17
21
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The job's entry for ``gridlinegpu.App`` functions (stdlib only; staged as _gridline_entry.py).
|
|
2
|
+
|
|
3
|
+
Reads the call file, imports the app module (its ``gridlinegpu`` is the staged stub) and calls
|
|
4
|
+
the plain function once per call, writing the JSON results as the declared return output. With
|
|
5
|
+
``strict`` (one ``spawn()`` call) an exception propagates: the traceback goes to the job's log
|
|
6
|
+
and the job fails as any job whose code fails. Otherwise (a chunk of ``map()`` calls) each
|
|
7
|
+
exception is recorded for its call and the next call runs.
|
|
8
|
+
|
|
9
|
+
In a pool worker (``mode: pool``) the module is imported once and each item is one call: its
|
|
10
|
+
input is a zip of ``call.json`` and the call's files, and its outputs are the item's output
|
|
11
|
+
directory (the JSON return value plus the files the function wrote there). An exception hands
|
|
12
|
+
the item back failed with its type and message; the pool retries it up to its attempts.
|
|
13
|
+
"""
|
|
14
|
+
import importlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import traceback
|
|
21
|
+
import zipfile
|
|
22
|
+
|
|
23
|
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
24
|
+
ERROR_TEXT = 2_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def error_of(exception):
|
|
28
|
+
"""A call's exception as JSON: its type and last line, bounded (the log has the traceback)."""
|
|
29
|
+
return {"type": type(exception).__name__, "message": str(exception)[:ERROR_TEXT]}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_call(gridlinegpu, function, call, paths):
|
|
33
|
+
args = gridlinegpu.decode_value(call["args"], paths)
|
|
34
|
+
kwargs = gridlinegpu.decode_value(call["kwargs"], paths)
|
|
35
|
+
return gridlinegpu.encode_value(function(*args, **kwargs), where="return value")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def unpack(gridlinegpu, archive_path, scratch):
|
|
39
|
+
"""(call, local paths of its files) from one item's zip, its files written under scratch."""
|
|
40
|
+
with zipfile.ZipFile(archive_path) as archive:
|
|
41
|
+
call = json.loads(archive.read(gridlinegpu.ITEM_CALL))
|
|
42
|
+
paths = []
|
|
43
|
+
for index, name in enumerate(call["files"]):
|
|
44
|
+
target = os.path.join(scratch, os.path.basename(gridlinegpu.input_path(index, name)))
|
|
45
|
+
with archive.open(f"files/{index}") as source, open(target, "wb") as sink:
|
|
46
|
+
shutil.copyfileobj(source, sink, 1_048_576)
|
|
47
|
+
paths.append(target)
|
|
48
|
+
return call, paths
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def serve_pool(gridlinegpu, function):
|
|
52
|
+
from gridlinegpu import worker
|
|
53
|
+
|
|
54
|
+
for item in worker.items():
|
|
55
|
+
scratch = tempfile.mkdtemp(prefix="gridline-item-")
|
|
56
|
+
try:
|
|
57
|
+
call, paths = unpack(gridlinegpu, item.input_path, scratch)
|
|
58
|
+
gridlinegpu._current_output = str(item.output_dir)
|
|
59
|
+
value = run_call(gridlinegpu, function, call, paths)
|
|
60
|
+
data = json.dumps({"results": [{"value": value}]}, allow_nan=False, separators=(",", ":"))
|
|
61
|
+
if len(data) > gridlinegpu.RETURN_MAX_BYTES:
|
|
62
|
+
raise ValueError("the return value is larger than 8 MiB; write it under output_dir() instead")
|
|
63
|
+
with open(os.path.join(item.output_dir, os.path.basename(gridlinegpu.RETURN_PATH)), "w",
|
|
64
|
+
encoding="utf-8") as handle:
|
|
65
|
+
handle.write(data)
|
|
66
|
+
except Exception as exception: # noqa: BLE001 - the item goes back failed; the log has the traceback
|
|
67
|
+
traceback.print_exc()
|
|
68
|
+
error = error_of(exception)
|
|
69
|
+
try:
|
|
70
|
+
item.fail(f"{error['type']}: {error['message']}")
|
|
71
|
+
except worker.WorkerError:
|
|
72
|
+
traceback.print_exc()
|
|
73
|
+
continue
|
|
74
|
+
finally:
|
|
75
|
+
gridlinegpu._current_output = None
|
|
76
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
77
|
+
try:
|
|
78
|
+
item.done()
|
|
79
|
+
except worker.WorkerError:
|
|
80
|
+
traceback.print_exc() # handed back (outputs invalid) or retried elsewhere
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main():
|
|
84
|
+
os.environ.setdefault("GRIDLINE_APP_ROOT", ROOT)
|
|
85
|
+
sys.path.insert(0, ROOT)
|
|
86
|
+
import gridlinegpu # the staged stub, which owns the call codec
|
|
87
|
+
|
|
88
|
+
with open(os.path.join(ROOT, gridlinegpu.CALL_FILE), encoding="utf-8") as handle:
|
|
89
|
+
document = json.load(handle)
|
|
90
|
+
function = getattr(importlib.import_module(document["module"]), document["function"])
|
|
91
|
+
if document.get("mode") == "pool":
|
|
92
|
+
serve_pool(gridlinegpu, function)
|
|
93
|
+
return
|
|
94
|
+
paths = [os.path.join(ROOT, path) for path in document["files"]]
|
|
95
|
+
results = []
|
|
96
|
+
for call in document["calls"]:
|
|
97
|
+
if document["strict"]:
|
|
98
|
+
results.append({"value": run_call(gridlinegpu, function, call, paths)})
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
results.append({"value": run_call(gridlinegpu, function, call, paths)})
|
|
102
|
+
except Exception as exception: # noqa: BLE001 - recorded per call; the log has the traceback
|
|
103
|
+
traceback.print_exc()
|
|
104
|
+
results.append({"error": error_of(exception)})
|
|
105
|
+
data = json.dumps({"results": results}, allow_nan=False, separators=(",", ":")).encode("utf-8")
|
|
106
|
+
if len(data) > gridlinegpu.RETURN_MAX_BYTES:
|
|
107
|
+
raise ValueError("the return values are larger than 8 MiB; write results under output_dir() instead")
|
|
108
|
+
gridlinegpu.output_dir()
|
|
109
|
+
with open(os.path.join(ROOT, gridlinegpu.RETURN_PATH), "wb") as handle:
|
|
110
|
+
handle.write(data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The job side of ``gridlinegpu.App`` (stdlib only).
|
|
2
|
+
|
|
3
|
+
``App`` stages this file's text as ``gridlinegpu/__init__.py`` beside the app's own modules, so
|
|
4
|
+
the app module imports inside the GPU job even when the image has no ``gridlinegpu`` (or an
|
|
5
|
+
older one): its ``App``, ``Image``, ``Model`` and ``File`` names exist and do nothing, and the
|
|
6
|
+
job's entry calls the plain function. The local package imports the call codec from here, so
|
|
7
|
+
both sides encode a call the same way. Nothing here touches the network or a credential.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
|
|
14
|
+
CALL_FILE = "_gridline_call.json"
|
|
15
|
+
ENTRY_FILE = "_gridline_entry.py"
|
|
16
|
+
RETURN_OUTPUT = "gl_return"
|
|
17
|
+
RETURN_PATH = "outputs/_gridline_return.json"
|
|
18
|
+
RETURN_MAX_BYTES = 8 * 1024 * 1024
|
|
19
|
+
_MARK = "$gridline"
|
|
20
|
+
_ROOT = pathlib.Path(os.environ.get("GRIDLINE_APP_ROOT") or os.getcwd())
|
|
21
|
+
# A pool worker sets this to the current item's output directory while the item runs.
|
|
22
|
+
_current_output = None
|
|
23
|
+
ITEM_CALL = "call.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class File:
|
|
27
|
+
"""A file argument. Locally, a path to send; inside the job, the staged input (read-only)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path):
|
|
30
|
+
self.path = pathlib.Path(path)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self):
|
|
34
|
+
return self.path.name
|
|
35
|
+
|
|
36
|
+
def __fspath__(self):
|
|
37
|
+
return os.fspath(self.path)
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return f"File({os.fspath(self.path)!r})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode_value(value, files=None, *, where="argument"):
|
|
44
|
+
"""JSON-safe form of ``value``. ``File`` objects become references and, when ``files`` is a
|
|
45
|
+
list, are appended to it (locally: the files to stage). Anything that is not JSON data or a
|
|
46
|
+
``File`` raises TypeError: calls carry JSON data and files only (no pickle)."""
|
|
47
|
+
if value is None or type(value) in (bool, str, int):
|
|
48
|
+
return value
|
|
49
|
+
if type(value) is float:
|
|
50
|
+
if not math.isfinite(value):
|
|
51
|
+
raise TypeError(f"a function {where} must be finite (NaN and infinity are not JSON)")
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, File):
|
|
54
|
+
if files is None:
|
|
55
|
+
raise TypeError(f"a function {where} cannot be a File; write it under output_dir() instead")
|
|
56
|
+
files.append(value)
|
|
57
|
+
return {_MARK: "file", "index": len(files) - 1, "name": value.name}
|
|
58
|
+
if type(value) in (list, tuple):
|
|
59
|
+
return [encode_value(item, files, where=where) for item in value]
|
|
60
|
+
if type(value) is dict:
|
|
61
|
+
encoded = {}
|
|
62
|
+
for key, item in value.items():
|
|
63
|
+
if type(key) is not str:
|
|
64
|
+
raise TypeError(f"a function {where}'s dictionary keys must be strings")
|
|
65
|
+
if key == _MARK:
|
|
66
|
+
raise TypeError(f"a function {where}'s dictionary cannot use the key {_MARK!r}")
|
|
67
|
+
encoded[key] = encode_value(item, files, where=where)
|
|
68
|
+
return encoded
|
|
69
|
+
if isinstance(value, os.PathLike):
|
|
70
|
+
raise TypeError(f"a function {where} that is a path must be wrapped: gridlinegpu.File({os.fspath(value)!r}) "
|
|
71
|
+
"sends the file; str(path) sends the text")
|
|
72
|
+
raise TypeError(f"a function {where} must be JSON data or gridlinegpu.File, not {type(value).__name__}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decode_value(value, file_paths=None):
|
|
76
|
+
"""The inverse of ``encode_value``; ``file_paths[index]`` is where each file landed."""
|
|
77
|
+
if type(value) is list:
|
|
78
|
+
return [decode_value(item, file_paths) for item in value]
|
|
79
|
+
if type(value) is dict:
|
|
80
|
+
if value.get(_MARK) == "file":
|
|
81
|
+
if file_paths is None:
|
|
82
|
+
raise ValueError("a file reference outside a call")
|
|
83
|
+
return File(file_paths[value["index"]])
|
|
84
|
+
return {key: decode_value(item, file_paths) for key, item in value.items()}
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def input_name(index):
|
|
89
|
+
return f"arg_{index}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def input_path(index, name):
|
|
93
|
+
suffix = "".join(pathlib.PurePosixPath(name).suffixes)[-16:]
|
|
94
|
+
return f"inputs/{input_name(index)}{suffix}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def output_dir():
|
|
98
|
+
"""The directory whose declared files become the call's outputs (in a pool, the item's)."""
|
|
99
|
+
if _current_output is not None:
|
|
100
|
+
return pathlib.Path(_current_output)
|
|
101
|
+
directory = _ROOT / "outputs"
|
|
102
|
+
directory.mkdir(exist_ok=True)
|
|
103
|
+
return directory
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def model_path(name):
|
|
107
|
+
"""Where the platform staged the model declared as ``models={name: Model.hf(...)}``."""
|
|
108
|
+
return _ROOT / "models" / name
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Inert:
|
|
112
|
+
"""Accepts any construction or chained call; the job never builds or routes anything."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, *args, **kwargs):
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
def __getattr__(self, name):
|
|
118
|
+
if name.startswith("__"):
|
|
119
|
+
raise AttributeError(name)
|
|
120
|
+
return lambda *args, **kwargs: self
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def _make(cls, *args, **kwargs):
|
|
124
|
+
return cls()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Image(_Inert):
|
|
128
|
+
curated = from_base = from_dockerfile = from_registry = classmethod(_Inert._make.__func__)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Model(_Inert):
|
|
132
|
+
hf = classmethod(_Inert._make.__func__)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Output(_Inert):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Secret(_Inert):
|
|
140
|
+
from_name = classmethod(_Inert._make.__func__)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class FunctionError(Exception):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _in_job(name):
|
|
148
|
+
def refuse(*args, **kwargs):
|
|
149
|
+
raise RuntimeError(f".{name}() is not available inside a Gridline job (no network); call .local()")
|
|
150
|
+
return refuse
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _plain(fn):
|
|
154
|
+
"""The plain function, with ``.local`` (itself) and the remote calls refused."""
|
|
155
|
+
fn.local = fn
|
|
156
|
+
for name in ("remote", "spawn", "map", "starmap"):
|
|
157
|
+
setattr(fn, name, _in_job(name))
|
|
158
|
+
return fn
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class App:
|
|
162
|
+
"""Inside the job: decorators return the plain function and the entrypoint never runs."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, *args, **kwargs):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def function(self, *args, **kwargs):
|
|
168
|
+
if args and callable(args[0]) and not kwargs:
|
|
169
|
+
return _plain(args[0])
|
|
170
|
+
return _plain
|
|
171
|
+
|
|
172
|
+
def local_entrypoint(self, *args, **kwargs):
|
|
173
|
+
if args and callable(args[0]) and not kwargs:
|
|
174
|
+
return args[0]
|
|
175
|
+
return lambda fn: fn
|