powerfunc 0.3.0__tar.gz → 0.5.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.
- {powerfunc-0.3.0 → powerfunc-0.5.0}/.github/workflows/ci.yml +10 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/.gitignore +3 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/PKG-INFO +5 -4
- {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/providers/gcp.md +34 -1
- powerfunc-0.5.0/documentation/providers/modal.md +89 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/readme.md +9 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/pyproject.toml +4 -3
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/command_line.py +7 -8
- powerfunc-0.5.0/src/powerfunc/compute.py +90 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/conversions.py +28 -15
- powerfunc-0.5.0/src/powerfunc/decorator.py +207 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/pandas.py +4 -1
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/polars.py +4 -1
- powerfunc-0.5.0/src/powerfunc/providers/gcp_batch.py +308 -0
- powerfunc-0.5.0/src/powerfunc/providers/gcp_cloud_run.py +103 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/__init__.py +1 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/codebase_transfer.py +68 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/gcp_batch_auto_location.py +169 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/gcp_shared.py +206 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/generic_execute_entrypoint.py +58 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/generic_setup_entrypoint.py +61 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/job_specification.py +91 -0
- powerfunc-0.5.0/src/powerfunc/providers/internal/secret_directories.py +55 -0
- powerfunc-0.5.0/src/powerfunc/providers/modal.py +214 -0
- powerfunc-0.5.0/tests/powerfunc_tests/cleanup.py +56 -0
- {powerfunc-0.3.0/tests/powerfunc_tests/gcp → powerfunc-0.5.0/tests/powerfunc_tests}/conftest.py +26 -7
- powerfunc-0.5.0/tests/powerfunc_tests/core/__init__.py +12 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/cli_example.py +40 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/test_configuration.py +95 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/test_future_annotations.py +27 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/test_given_objects.py +90 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/test_remote_files.py +80 -0
- powerfunc-0.5.0/tests/powerfunc_tests/core/test_remote_locally.py +128 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/gcp/gcp_shared.py +9 -2
- powerfunc-0.5.0/tests/powerfunc_tests/gcp/test_gcp_batch.py +98 -0
- powerfunc-0.5.0/tests/powerfunc_tests/gcp/test_gcp_cloud_run.py +102 -0
- powerfunc-0.5.0/tests/powerfunc_tests/modal/test_modal.py +68 -0
- powerfunc-0.3.0/tests/powerfunc_tests/gcp/gcp_jobs.py → powerfunc-0.5.0/tests/powerfunc_tests/remote_jobs.py +24 -3
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/test_snakemake.py +1 -2
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tox.ini +3 -1
- {powerfunc-0.3.0 → powerfunc-0.5.0}/uv.lock +271 -280
- powerfunc-0.3.0/documentation/providers/modal.md +0 -95
- powerfunc-0.3.0/src/powerfunc/compute.py +0 -62
- powerfunc-0.3.0/src/powerfunc/decorator.py +0 -116
- powerfunc-0.3.0/src/powerfunc/providers/gcp_batch.py +0 -436
- powerfunc-0.3.0/src/powerfunc/providers/gcp_cloud_run.py +0 -391
- powerfunc-0.3.0/src/powerfunc/providers/modal.py +0 -80
- powerfunc-0.3.0/tests/powerfunc_tests/core/cli_example.py +0 -21
- powerfunc-0.3.0/tests/powerfunc_tests/core/test_remote.py +0 -85
- powerfunc-0.3.0/tests/powerfunc_tests/gcp/test_gcp_batch.py +0 -70
- powerfunc-0.3.0/tests/powerfunc_tests/gcp/test_gcp_cloud_run.py +0 -52
- powerfunc-0.3.0/tests/powerfunc_tests/modal/test_modal.py +0 -44
- powerfunc-0.3.0/tests/powerfunc_tests/snakemake/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/.pre-commit-config.yaml +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/LICENSE.txt +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/README.md +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/advanced_usage.md +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/integrations/snakemake.md +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/configuration.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/arrow.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/csv_reader.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/dask.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/integrations/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/integrations/snakemake.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/providers/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/py.typed +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_basic.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_cli.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_conversions.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.arrow +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.csv +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.feather +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.pandas.json +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.parquet +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.polars.json +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.xlsx +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/generate_data_files.py +0 -0
- {powerfunc-0.3.0/tests/powerfunc_tests/core → powerfunc-0.5.0/tests/powerfunc_tests/gcp}/__init__.py +0 -0
- {powerfunc-0.3.0/tests/powerfunc_tests/gcp → powerfunc-0.5.0/tests/powerfunc_tests/modal}/__init__.py +0 -0
- {powerfunc-0.3.0/tests/powerfunc_tests/modal → powerfunc-0.5.0/tests/powerfunc_tests/snakemake}/__init__.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/functions.py +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/input.smk +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/input_params_and_output.smk +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/multiple_outputs_rejected.smk +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/named_outputs_rejected.smk +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/params.smk +0 -0
- {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/positional_rejected.smk +0 -0
|
@@ -47,6 +47,14 @@ jobs:
|
|
|
47
47
|
GCP_BUCKET: gs://powerfunc-temporary-348293
|
|
48
48
|
GCP_REGION: europe-west4
|
|
49
49
|
run: uvx --with tox-uv tox run -e gcp
|
|
50
|
+
# Runs even when the tests fail or the run is cancelled, so a killed test
|
|
51
|
+
# cannot leave a job (and its GPU quota) running behind later runs.
|
|
52
|
+
- name: Delete leftover cloud jobs
|
|
53
|
+
if: always()
|
|
54
|
+
env:
|
|
55
|
+
GOOGLE_CLOUD_API_KEY: ${{ secrets.GOOGLE_CLOUD_API_KEY }}
|
|
56
|
+
GCP_REGION: europe-west4
|
|
57
|
+
run: uv run --no-project --with google-cloud-batch --with google-cloud-run python tests/powerfunc_tests/cleanup.py
|
|
50
58
|
|
|
51
59
|
test-modal:
|
|
52
60
|
runs-on: ubuntu-latest
|
|
@@ -58,6 +66,8 @@ jobs:
|
|
|
58
66
|
enable-cache: true
|
|
59
67
|
- name: Run Modal tests
|
|
60
68
|
env:
|
|
69
|
+
GOOGLE_CLOUD_API_KEY: ${{ secrets.GOOGLE_CLOUD_API_KEY }}
|
|
70
|
+
GCP_BUCKET: gs://powerfunc-temporary-348293
|
|
61
71
|
MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
|
|
62
72
|
MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
|
|
63
73
|
run: uvx --with tox-uv tox run -e modal
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: powerfunc
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Make functions run however you like — CLI, pipeline, or remote compute
|
|
5
5
|
Project-URL: Homepage, https://github.com/ddrakard/powerfunc
|
|
6
6
|
Project-URL: Repository, https://github.com/ddrakard/powerfunc
|
|
7
7
|
Project-URL: Documentation, https://github.com/ddrakard/powerfunc/blob/main/documentation/readme.md
|
|
8
8
|
License-File: LICENSE.txt
|
|
9
9
|
Requires-Python: >=3.9
|
|
10
|
-
Requires-Dist: cloudpathlib[all]
|
|
11
10
|
Requires-Dist: cloudpickle
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
12
|
Requires-Dist: fsspec[adl,gcs,s3]
|
|
13
|
-
Requires-Dist: jsonargparse>=4.49.0
|
|
13
|
+
Requires-Dist: jsonargparse[signatures]>=4.49.0
|
|
14
14
|
Requires-Dist: pydantic>=2
|
|
15
|
+
Requires-Dist: universal-pathlib>=0.2
|
|
15
16
|
Provides-Extra: dev
|
|
16
17
|
Requires-Dist: pre-commit; extra == 'dev'
|
|
17
18
|
Requires-Dist: ruff; extra == 'dev'
|
|
@@ -8,6 +8,7 @@ powerfunc supports two GCP execution backends: [Cloud Run Jobs](https://cloud.go
|
|
|
8
8
|
- [Setup and configuration](#setup-and-configuration)
|
|
9
9
|
- [Cloud Run](#cloud-run)
|
|
10
10
|
- [Batch](#batch)
|
|
11
|
+
- [The remote environment](#the-remote-environment)
|
|
11
12
|
- [Environment variables](#environment-variables)
|
|
12
13
|
- [Predefined configurations](#predefined-configurations)
|
|
13
14
|
- [Supported GPUs](#supported-gpus)
|
|
@@ -116,6 +117,32 @@ result = sum_col("gs://bucket/data.csv", compute=compute)
|
|
|
116
117
|
`machine_type` is optional — Batch selects a machine from the requested CPU/memory when it
|
|
117
118
|
is not given. For GPUs, a compatible `machine_type` is usually required.
|
|
118
119
|
|
|
120
|
+
## The remote environment
|
|
121
|
+
|
|
122
|
+
A job runs in three stages inside the container image given by `image`:
|
|
123
|
+
|
|
124
|
+
1. A bootstrap that needs nothing from the image: it obtains [uv](https://docs.astral.sh/uv/) and uses it to start powerfunc's setup stage (`python -m powerfunc.providers.internal.generic_setup_entrypoint`).
|
|
125
|
+
2. The setup stage unpacks your codebase (the git-tracked files of your repository) into the container's working directory (the image's `WORKDIR`, over any files already there — so an image that already contains your project and its environment gets just your current changes laid on top) and runs the provider's `setup_command`, a shell command, there.
|
|
126
|
+
3. The function is run with `python -m powerfunc.providers.internal.generic_execute_entrypoint` using the `python` found on `PATH` after the setup command.
|
|
127
|
+
|
|
128
|
+
By default (`setup_command` empty, as in the predefined configurations) the image must already provide `python` with powerfunc and your dependencies installed; otherwise `setup_command` has to produce it. Ready-made commands live in `powerfunc.compute`: `UV_PROJECT_SETUP` (`uv sync --locked && . .venv/bin/activate`) treats the codebase as a [uv project](https://docs.astral.sh/uv/guides/projects/); `PIXI_PROJECT_SETUP` installs [pixi](https://pixi.sh), runs `pixi install --locked` and activates the environment; `PIP_PYPROJECT_SETUP` and `PIP_REQUIREMENTS_TXT_SETUP` make a `.venv` with the image's Python and `pip install` the codebase's `pyproject.toml` or `requirements.txt` into it. Any environment manager works the same way as long as it leaves `python` on `PATH`:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
compute:
|
|
132
|
+
class_path: powerfunc.providers.gcp_cloud_run.GcpCloudRunCpuSmall
|
|
133
|
+
init_args:
|
|
134
|
+
timeout: 600
|
|
135
|
+
provider:
|
|
136
|
+
class_path: powerfunc.providers.gcp_cloud_run.GCPCloudRunProvider
|
|
137
|
+
init_args:
|
|
138
|
+
project: my-project
|
|
139
|
+
region: us-central1
|
|
140
|
+
temporary_bucket_path: gs://my-bucket/powerfunc-tmp
|
|
141
|
+
setup_command: "python -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Leave `setup_command` empty when the image already has everything installed.
|
|
145
|
+
|
|
119
146
|
## Environment variables
|
|
120
147
|
|
|
121
148
|
Both Cloud Run and Batch support passing environment variables to the remote container via the `environment_variables` provider option:
|
|
@@ -138,6 +165,12 @@ provider:
|
|
|
138
165
|
MY_API_KEY: secret123
|
|
139
166
|
```
|
|
140
167
|
|
|
168
|
+
Note that these are part of the job definition, so visible to anyone who can view the job in the project while it exists. Whole directories of credentials or other files go by `secret_directories=["config/"]`, as described in the [readme](../readme.md#cloud-providers-and-remote-execution): only the encrypted archive is uploaded to the bucket, and only the decryption key is in the job's environment.
|
|
169
|
+
|
|
170
|
+
## Shared buckets
|
|
171
|
+
|
|
172
|
+
Set the provider's `user_identifier` (e.g. `user_identifier="alice"`) to prefix the job artifacts in `temporary_bucket_path` with it, so you can tell whose jobs are whose.
|
|
173
|
+
|
|
141
174
|
## Supported GPUs
|
|
142
175
|
|
|
143
176
|
Both Cloud Run and Batch support: `t4`, `a100`, `l4`, `v100`.
|
|
@@ -151,7 +184,7 @@ Both Cloud Run and Batch support: `t4`, `a100`, `l4`, `v100`.
|
|
|
151
184
|
| `GcpBatchCpuSmall` | Batch | 1 vCPU | 2GB | `python:3.12-slim` | — |
|
|
152
185
|
| `GcpBatchGpu` | Batch | 4 vCPU | 16GB | `nvidia/cuda:12.1.0-base-ubuntu22.04` | L4 |
|
|
153
186
|
|
|
154
|
-
All presets require a `timeout` argument (in seconds), e.g. `GcpCloudRunCpuSmall(timeout=600)` or `GcpBatchCpuSmall(timeout=600)`.
|
|
187
|
+
All presets expect the image to provide `python` with powerfunc and your dependencies, unless a `setup_command` is given (see [the remote environment](#the-remote-environment)), and require a `timeout` argument (in seconds), e.g. `GcpCloudRunCpuSmall(timeout=600)` or `GcpBatchCpuSmall(timeout=600)`.
|
|
155
188
|
|
|
156
189
|
## Known limitations
|
|
157
190
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Modal
|
|
2
|
+
|
|
3
|
+
This page describes how to execute powerfunc functions remotely on [Modal](https://modal.com).
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [Installation](#installation)
|
|
8
|
+
- [Basic usage](#basic-usage)
|
|
9
|
+
- [The remote environment](#the-remote-environment)
|
|
10
|
+
- [Presets](#presets)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Make sure to install with the `modal` option.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install 'powerfunc[modal]'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
or
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv add 'powerfunc[modal]'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
You must have a Modal account. Then authenticate to Modal:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python -m modal setup
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Basic usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from powerfunc import powerfunc
|
|
36
|
+
from powerfunc.providers.modal import ModalCpuSmall
|
|
37
|
+
|
|
38
|
+
@powerfunc
|
|
39
|
+
def sum_col(df: pd.DataFrame) -> float:
|
|
40
|
+
return float(df["value"].sum())
|
|
41
|
+
|
|
42
|
+
result = sum_col("data.csv", compute=ModalCpuSmall(timeout=600))
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or set a default in `powerfunc.yaml` so all calls run on Modal without passing `compute=`:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
compute:
|
|
49
|
+
class_path: powerfunc.providers.modal.ModalCpuSmall
|
|
50
|
+
init_args:
|
|
51
|
+
timeout: 600
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The remote environment
|
|
55
|
+
|
|
56
|
+
The container image is `image` — a registry image, or Modal's `debian_slim` when empty — built once and cached by Modal. Your codebase (your repository minus `.git` and whatever git ignores) is not built into it but mounted when the container starts, onto the image's working directory (its `WORKDIR`, over any files already there — so an image that already contains your project and its environment gets just your current changes laid on top); Modal uploads only the files it has not seen before, so editing your code costs neither an image build nor a full upload. `setup_command` then runs there, on every call, and the function runs with `python -m powerfunc.providers.internal.generic_execute_entrypoint` using the `python` the setup command left on `PATH`, so the same `setup_command` works here and on GCP. By default (`setup_command` empty, as in the predefined configurations) the image must already have `python` with powerfunc and your dependencies:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from powerfunc.providers.modal import ModalCpuSmall
|
|
60
|
+
|
|
61
|
+
compute = ModalCpuSmall(timeout=600, image="ghcr.io/me/my-project:latest")
|
|
62
|
+
result = sum_col("data.csv", compute=compute)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Otherwise the provider's `setup_command` has to produce it; `powerfunc.compute.UV_PROJECT_SETUP` (`uv sync --locked && . .venv/bin/activate`) treats the codebase as a uv project, `PIXI_PROJECT_SETUP` does the same with pixi, `PIP_PYPROJECT_SETUP`/`PIP_REQUIREMENTS_TXT_SETUP` pip-install the codebase's `pyproject.toml`/`requirements.txt` into a fresh `.venv`, or use any command that leaves `python` on `PATH`. In `powerfunc.yaml`:
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
compute:
|
|
69
|
+
class_path: powerfunc.providers.modal.ModalCpuSmall
|
|
70
|
+
init_args:
|
|
71
|
+
timeout: 600
|
|
72
|
+
provider:
|
|
73
|
+
class_path: powerfunc.providers.modal.ModalProvider
|
|
74
|
+
init_args:
|
|
75
|
+
setup_command: "python -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The command runs verbatim: anything it needs, such as `uv` itself, must be in the image or installed by the command. Since it runs on every call, a dependency install there is paid each time — for anything heavy, put the environment in `image` instead.
|
|
79
|
+
|
|
80
|
+
`ModalProvider(environment_variables={"NAME": "value"})` sets environment variables of the running function. For example, to read `gs://` paths with a service-account key rather than ADC, give the key JSON as [fsspec configuration](https://filesystem-spec.readthedocs.io/en/latest/features.html#configuration): `environment_variables={"FSSPEC_GCS_TOKEN": key_json}`. `secret_directories=["config/"]` sends whole directories (relative to the repository root, git-ignored or not) encrypted, as described in the [readme](../readme.md#cloud-providers-and-remote-execution), rather than in the image.
|
|
81
|
+
|
|
82
|
+
## Presets
|
|
83
|
+
|
|
84
|
+
| Class | CPU | Memory | GPU |
|
|
85
|
+
|---|---|---|---|
|
|
86
|
+
| `ModalCpuSmall` | 1 vCPU | 1GB | — |
|
|
87
|
+
| `ModalGpuA100` | 8 vCPU | 80GB | A100 |
|
|
88
|
+
|
|
89
|
+
All presets require a `timeout` argument (in seconds), e.g. `ModalCpuSmall(timeout=600)`.
|
|
@@ -142,6 +142,8 @@ Each compute provider has its own setup process. See the individual pages for pr
|
|
|
142
142
|
- [GCP](providers/gcp.md)
|
|
143
143
|
- [Modal](providers/modal.md)
|
|
144
144
|
|
|
145
|
+
The codebase — the git repository holding your function — is sent along with the job so the function can be imported there; files git ignores are not. To send credentials or other ignored files too, name their directories (relative to the repository root) in the provider's `secret_directories`, e.g. `ModalProvider(secret_directories=["config/"])`: they are left out of the codebase upload (even if git tracks them), zipped and encrypted with a key made for that call, the key reaching the container only as an environment variable (never stored with the job's other artifacts or in an image), and are decrypted into the codebase at the same paths before the function runs.
|
|
146
|
+
|
|
145
147
|
The [configuration](#configuration) section is useful for working with compute providers, including configuring a default provider.
|
|
146
148
|
|
|
147
149
|
## Configuration
|
|
@@ -183,6 +185,13 @@ When using the CLI, pass a config file with `--config`:
|
|
|
183
185
|
python my_script.py sum_col data.csv --config my_config.yaml
|
|
184
186
|
```
|
|
185
187
|
|
|
188
|
+
The command line always reads the configuration files. A call from Python reads none unless asked, with `config`, the same option as `--config`: a path to a file, or `True` for the default files.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
sum_col("data.csv", config=True)
|
|
192
|
+
sum_col("data.csv", config="my_config.yaml")
|
|
193
|
+
```
|
|
194
|
+
|
|
186
195
|
## Supported formats
|
|
187
196
|
|
|
188
197
|
| Type | Formats |
|
|
@@ -4,16 +4,17 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "powerfunc"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Make functions run however you like — CLI, pipeline, or remote compute"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
dependencies = [
|
|
12
12
|
"pydantic>=2",
|
|
13
|
-
"
|
|
13
|
+
"universal-pathlib>=0.2",
|
|
14
14
|
"fsspec[gcs,s3,adl]",
|
|
15
|
-
"jsonargparse>=4.49.0",
|
|
15
|
+
"jsonargparse[signatures]>=4.49.0",
|
|
16
16
|
"cloudpickle",
|
|
17
|
+
"cryptography",
|
|
17
18
|
]
|
|
18
19
|
|
|
19
20
|
[project.optional-dependencies]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import sys
|
|
3
|
+
from collections.abc import Callable
|
|
3
4
|
|
|
4
5
|
from jsonargparse import CLI
|
|
5
6
|
|
|
@@ -10,8 +11,8 @@ class ExpectedException(Exception):
|
|
|
10
11
|
"""Raised for known user errors that should display just the message, not a traceback."""
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
# Each function's parsed-arguments entry, by the function's name: the command's.
|
|
15
|
+
cli_functions: dict[str, Callable] = {}
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def enable_cli():
|
|
@@ -20,17 +21,15 @@ def enable_cli():
|
|
|
20
21
|
frame = inspect.currentframe()
|
|
21
22
|
if frame is None or frame.f_back is None:
|
|
22
23
|
raise ExpectedException("Frame inspection not supported by this Python implementation.")
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
# By the frame's globals, not inspect.getmodule: under ``python -m`` the same
|
|
25
|
+
# file is often imported by its package name too, and getmodule finds that one.
|
|
26
|
+
if frame.f_back.f_globals.get("__name__") != "__main__":
|
|
25
27
|
return
|
|
26
28
|
if not cli_functions:
|
|
27
29
|
raise ExpectedException("No @powerfunc functions registered for CLI.")
|
|
28
30
|
|
|
29
|
-
global _cli_active
|
|
30
|
-
wrappers = [wrapper for wrapper, _ in cli_functions]
|
|
31
|
-
_cli_active = True
|
|
32
31
|
result = CLI(
|
|
33
|
-
|
|
32
|
+
next(iter(cli_functions.values())) if len(cli_functions) == 1 else cli_functions,
|
|
34
33
|
default_config_files=configuration_paths,
|
|
35
34
|
)
|
|
36
35
|
if result is not None:
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from typing import TYPE_CHECKING, Annotated, Any, Optional, TypeAlias
|
|
3
|
+
|
|
4
|
+
from pydantic import Field, GetCoreSchemaHandler
|
|
5
|
+
from pydantic.dataclasses import dataclass as pydantic_dataclass
|
|
6
|
+
from pydantic_core import core_schema
|
|
7
|
+
|
|
8
|
+
from powerfunc.command_line import ExpectedException
|
|
9
|
+
|
|
10
|
+
Timeout: TypeAlias = Annotated[float, Field(gt=0, description="Maximum job duration in seconds")]
|
|
11
|
+
CpuCount: TypeAlias = Annotated[float, Field(gt=0, description="Number of vCPUs")]
|
|
12
|
+
MemorySize: TypeAlias = Annotated[int, Field(gt=0, description="RAM in MB")]
|
|
13
|
+
DockerImageUri: TypeAlias = Annotated[str, Field(description="Container image URI")]
|
|
14
|
+
GpuModel: TypeAlias = Annotated[
|
|
15
|
+
Optional[str], Field(description="GPU model name, as the provider names it (e.g. 'l4', 'a100')")
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
UV_PROJECT_SETUP = "uv sync --locked && . .venv/bin/activate"
|
|
19
|
+
"""Setup command for a codebase that is a uv project."""
|
|
20
|
+
|
|
21
|
+
_PIXI_URL = "https://pixi.sh/install.sh"
|
|
22
|
+
PIXI_PROJECT_SETUP = (
|
|
23
|
+
'export PATH="$HOME/.pixi/bin:$PATH"; { command -v pixi >/dev/null 2>&1 || '
|
|
24
|
+
"{ command -v curl >/dev/null 2>&1 && curl -fsSL "
|
|
25
|
+
+ _PIXI_URL
|
|
26
|
+
+ " || wget -qO- "
|
|
27
|
+
+ _PIXI_URL
|
|
28
|
+
+ "; }"
|
|
29
|
+
' | PIXI_HOME="$HOME/.pixi" sh; } && pixi install --locked && eval "$(pixi shell-hook)"'
|
|
30
|
+
)
|
|
31
|
+
"""Setup command for a codebase that is a pixi project."""
|
|
32
|
+
|
|
33
|
+
_PIP_VENV = "python -m venv .venv && . .venv/bin/activate"
|
|
34
|
+
PIP_PYPROJECT_SETUP = _PIP_VENV + " && pip install -q ."
|
|
35
|
+
"""Setup command for a codebase that is a pip-installable project (``pyproject.toml``)."""
|
|
36
|
+
PIP_REQUIREMENTS_TXT_SETUP = _PIP_VENV + " && pip install -q -r requirements.txt"
|
|
37
|
+
"""Setup command for a codebase with a pip ``requirements.txt``."""
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from powerfunc.decorator import PowerFunc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Provider:
|
|
44
|
+
"""Base compute provider. Subclass and implement call()."""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def __get_pydantic_core_schema__(
|
|
48
|
+
cls, source_type: Any, handler: GetCoreSchemaHandler
|
|
49
|
+
) -> core_schema.CoreSchema:
|
|
50
|
+
return core_schema.is_instance_schema(cls)
|
|
51
|
+
|
|
52
|
+
def call(self, function: "PowerFunc", arguments: dict, compute: "ComputeSpecification") -> Any:
|
|
53
|
+
"""Run ``function._run_without_parsing(**arguments)`` on the compute, returning its result.
|
|
54
|
+
|
|
55
|
+
``function`` is the powerfunc function itself, named in the job by module and qualname.
|
|
56
|
+
``arguments`` have already been parsed and merged with the configuration
|
|
57
|
+
files, so the compute does neither; there, ``_run_without_parsing`` opens
|
|
58
|
+
the files given for parameters and writes the result to ``output_path``.
|
|
59
|
+
"""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UndefinedProvider(Provider):
|
|
64
|
+
"""Sentinel — raises ExpectedException when called if no provider has been configured."""
|
|
65
|
+
|
|
66
|
+
def call(self, function, arguments, compute):
|
|
67
|
+
raise ExpectedException(
|
|
68
|
+
"No provider configured. Set compute.provider in powerfunc.yaml or pass compute="
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pydantic_dataclass
|
|
73
|
+
class ComputeSpecification:
|
|
74
|
+
"""Specifies compute resources for remote execution."""
|
|
75
|
+
|
|
76
|
+
timeout: Timeout
|
|
77
|
+
cpu: CpuCount
|
|
78
|
+
memory: MemorySize
|
|
79
|
+
image: DockerImageUri = ""
|
|
80
|
+
provider: Provider = dataclasses.field(default_factory=UndefinedProvider)
|
|
81
|
+
gpu: GpuModel = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pydantic_dataclass
|
|
85
|
+
class CpuSmall(ComputeSpecification):
|
|
86
|
+
"""1 vCPU, 2GB RAM, Python 3.12 slim."""
|
|
87
|
+
|
|
88
|
+
cpu: CpuCount = 1.0
|
|
89
|
+
memory: MemorySize = 2048
|
|
90
|
+
image: DockerImageUri = "python:3.12-slim"
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import pathlib
|
|
3
|
-
|
|
2
|
+
import tempfile
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
|
+
from contextlib import AbstractContextManager, contextmanager, nullcontext
|
|
4
5
|
|
|
5
|
-
from
|
|
6
|
+
from upath import UPath
|
|
7
|
+
|
|
8
|
+
_LOCAL_PROTOCOLS = frozenset({"", "file", "local"})
|
|
6
9
|
|
|
7
10
|
# (type_, extension) -> {"reader": ..., "writer": ..., "native_protocols": ...}
|
|
8
11
|
_registry: dict[tuple, dict] = {}
|
|
@@ -40,33 +43,43 @@ def as_context(function):
|
|
|
40
43
|
return wrapper
|
|
41
44
|
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
Reader = Callable[[str | pathlib.Path], AbstractContextManager[object]]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read(value: object, type_: type) -> AbstractContextManager[object]:
|
|
44
50
|
"""Convert a path or URI to the target type, returning a context manager.
|
|
45
51
|
|
|
46
52
|
If no reader is registered for the type and file extension, the value is
|
|
47
53
|
returned unchanged. Cloud URIs are streamed directly if the reader declares
|
|
48
|
-
the scheme as native, otherwise downloaded
|
|
54
|
+
the scheme as native, otherwise downloaded to a temporary local file first.
|
|
49
55
|
"""
|
|
50
|
-
if not isinstance(value, (str, pathlib.Path,
|
|
56
|
+
if not isinstance(value, (str, pathlib.Path, UPath)):
|
|
51
57
|
return nullcontext(value)
|
|
52
|
-
path =
|
|
58
|
+
path = UPath(str(value))
|
|
53
59
|
suffix = path.suffix
|
|
54
60
|
entry = _registry.get((type_, suffix))
|
|
55
61
|
if entry is None or entry["reader"] is None:
|
|
56
62
|
return nullcontext(value)
|
|
57
63
|
reader = entry["reader"]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
if path.protocol in _LOCAL_PROTOCOLS:
|
|
65
|
+
return reader(pathlib.Path(path.path))
|
|
66
|
+
if path.protocol in entry["native_protocols"]:
|
|
67
|
+
return reader(str(path))
|
|
68
|
+
return _temporary_copy_reader(reader, path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def _temporary_copy_reader(reader: Reader, path: UPath) -> Iterator[object]:
|
|
73
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
74
|
+
local = pathlib.Path(directory) / path.name
|
|
75
|
+
local.write_bytes(path.read_bytes())
|
|
76
|
+
with reader(local) as value:
|
|
77
|
+
yield value
|
|
65
78
|
|
|
66
79
|
|
|
67
80
|
def write(value, path):
|
|
68
81
|
"""Write value to path using the registered writer based on file extension."""
|
|
69
|
-
path =
|
|
82
|
+
path = UPath(str(path))
|
|
70
83
|
suffix = path.suffix
|
|
71
84
|
entry = _registry.get((type(value), suffix))
|
|
72
85
|
if entry is None or entry["writer"] is None:
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
from typing import Any, Optional, Protocol, TypeVar, Union, overload
|
|
8
|
+
|
|
9
|
+
from jsonargparse import ArgumentParser
|
|
10
|
+
from upath import UPath
|
|
11
|
+
|
|
12
|
+
import powerfunc.command_line as command_line
|
|
13
|
+
from powerfunc.command_line import ExpectedException
|
|
14
|
+
from powerfunc.compute import ComputeSpecification
|
|
15
|
+
from powerfunc.configuration import configuration_paths
|
|
16
|
+
from powerfunc.conversions import is_readable, read, write
|
|
17
|
+
|
|
18
|
+
_EXTRA_PARAMETERS = [
|
|
19
|
+
inspect.Parameter(
|
|
20
|
+
"compute",
|
|
21
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
22
|
+
default=None,
|
|
23
|
+
annotation=Optional[ComputeSpecification],
|
|
24
|
+
),
|
|
25
|
+
inspect.Parameter(
|
|
26
|
+
"output_path",
|
|
27
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
28
|
+
default=None,
|
|
29
|
+
annotation=Optional[str],
|
|
30
|
+
),
|
|
31
|
+
]
|
|
32
|
+
# The same option as the command line's ``--config``: a configuration file to
|
|
33
|
+
# merge the arguments with, or True for the default files, which the command
|
|
34
|
+
# line reads unless told another. A Python call reads none unless asked.
|
|
35
|
+
_CONFIGURATION_PARAMETER = inspect.Parameter(
|
|
36
|
+
"config",
|
|
37
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
38
|
+
default=None,
|
|
39
|
+
annotation=Union[str, pathlib.Path, bool, None],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _configuration_files(config) -> list[str]:
|
|
44
|
+
if config is None or config is False:
|
|
45
|
+
return []
|
|
46
|
+
if config is True:
|
|
47
|
+
return configuration_paths
|
|
48
|
+
return [str(config)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
R = TypeVar("R", covariant=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PowerFunc(Protocol[R]):
|
|
55
|
+
"""What :func:`powerfunc` makes of a function.
|
|
56
|
+
|
|
57
|
+
Called as the function was, plus ``compute`` to run it elsewhere and
|
|
58
|
+
``output_path`` to write what it returns to a file; the function's own
|
|
59
|
+
parameters are read from its signature at run time, so they are not checked
|
|
60
|
+
statically here.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __call__(
|
|
64
|
+
self,
|
|
65
|
+
*args: Any,
|
|
66
|
+
compute: Optional[ComputeSpecification] = None,
|
|
67
|
+
output_path: Optional[str] = None,
|
|
68
|
+
config: Union[str, pathlib.Path, bool, None] = None,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> R: ...
|
|
71
|
+
|
|
72
|
+
def snakemake(self) -> Any:
|
|
73
|
+
"""Run this function bound to the surrounding Snakemake rule."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def _run_without_parsing(self, **arguments: Any) -> R:
|
|
77
|
+
"""Run with arguments already parsed: by the command line, or before dispatch to compute."""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def optional_arguments_decorator(decorator):
|
|
82
|
+
"""Allows a decorator to be applied with or without argument parentheses."""
|
|
83
|
+
|
|
84
|
+
@functools.wraps(decorator)
|
|
85
|
+
def wrapper(function=None, **kwargs):
|
|
86
|
+
if function is not None:
|
|
87
|
+
return decorator(function, **kwargs)
|
|
88
|
+
return functools.partial(decorator, **kwargs)
|
|
89
|
+
|
|
90
|
+
return wrapper
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@overload
|
|
94
|
+
def powerfunc(function: Callable[..., R], *, cli: bool = True) -> PowerFunc[R]: ...
|
|
95
|
+
@overload
|
|
96
|
+
def powerfunc(
|
|
97
|
+
function: None = None, *, cli: bool = True
|
|
98
|
+
) -> Callable[[Callable[..., R]], PowerFunc[R]]: ...
|
|
99
|
+
@optional_arguments_decorator
|
|
100
|
+
def powerfunc(function=None, *, cli=True):
|
|
101
|
+
"""Decorator to turn a function into a powerfunc function.
|
|
102
|
+
|
|
103
|
+
Applied bare, ``@powerfunc``, or with arguments, ``@powerfunc(cli=False)``.
|
|
104
|
+
"""
|
|
105
|
+
# eval_str=True required for `from __future__ import annotations`
|
|
106
|
+
signature = inspect.signature(function, eval_str=True)
|
|
107
|
+
reserved = {param.name for param in [*_EXTRA_PARAMETERS, _CONFIGURATION_PARAMETER]}
|
|
108
|
+
taken = reserved & signature.parameters.keys()
|
|
109
|
+
if taken:
|
|
110
|
+
raise TypeError(
|
|
111
|
+
f"{function.__name__} has parameters powerfunc adds itself: {', '.join(sorted(taken))}."
|
|
112
|
+
)
|
|
113
|
+
modified_signature = signature.replace(
|
|
114
|
+
parameters=[
|
|
115
|
+
param.replace(
|
|
116
|
+
# str must precede pathlib.Path and UPath: jsonargparse resolves Union
|
|
117
|
+
# subtypes in order and pathlib.Path accepts any string without error, so it
|
|
118
|
+
# would win and mangle "gs://bucket/file" into PosixPath("gs:/bucket/file").
|
|
119
|
+
annotation=Union[param.annotation, str, pathlib.Path, UPath]
|
|
120
|
+
if is_readable(param.annotation)
|
|
121
|
+
else param.annotation
|
|
122
|
+
)
|
|
123
|
+
for param in signature.parameters.values()
|
|
124
|
+
]
|
|
125
|
+
+ _EXTRA_PARAMETERS
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Two entry points: wrapper takes a Python call's arguments as they are,
|
|
129
|
+
# filling in from a configuration file if one is named, then
|
|
130
|
+
# _run_without_parsing dispatches to compute if asked, else reads the path
|
|
131
|
+
# arguments, calls the function and writes the result. The command line,
|
|
132
|
+
# having parsed argv and the default configuration files with jsonargparse,
|
|
133
|
+
# and the compute provider, given arguments without ``compute``, both enter
|
|
134
|
+
# at _run_without_parsing.
|
|
135
|
+
calling_signature = modified_signature.replace(
|
|
136
|
+
parameters=[*modified_signature.parameters.values(), _CONFIGURATION_PARAMETER]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _from_configuration(files, given):
|
|
140
|
+
"""The arguments the configuration ``files`` hold, other than those ``given``."""
|
|
141
|
+
|
|
142
|
+
def dummy():
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
dummy.__signature__ = modified_signature
|
|
146
|
+
parser = ArgumentParser(exit_on_error=False, default_config_files=files)
|
|
147
|
+
parser.add_function_arguments(dummy)
|
|
148
|
+
try:
|
|
149
|
+
return {
|
|
150
|
+
name: value
|
|
151
|
+
for name, value in vars(parser.instantiate(parser.get_defaults())).items()
|
|
152
|
+
if name not in given
|
|
153
|
+
}
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise ExpectedException(str(e)) from e
|
|
156
|
+
|
|
157
|
+
@functools.wraps(function)
|
|
158
|
+
def wrapper(*args, **kwargs):
|
|
159
|
+
"""Run with the arguments as given, and ``config``'s for the rest."""
|
|
160
|
+
arguments = dict(calling_signature.bind_partial(*args, **kwargs).arguments)
|
|
161
|
+
files = _configuration_files(arguments.pop(_CONFIGURATION_PARAMETER.name, None))
|
|
162
|
+
if files:
|
|
163
|
+
arguments.update(_from_configuration(files, arguments))
|
|
164
|
+
try:
|
|
165
|
+
bound = modified_signature.bind(**arguments)
|
|
166
|
+
except TypeError as e:
|
|
167
|
+
raise ExpectedException(str(e)) from e
|
|
168
|
+
bound.apply_defaults()
|
|
169
|
+
return _run_without_parsing(**bound.arguments)
|
|
170
|
+
|
|
171
|
+
def _run_without_parsing(**arguments):
|
|
172
|
+
"""Run on the compute asked for, or here: read the files given, write the result."""
|
|
173
|
+
compute = arguments.pop("compute", None)
|
|
174
|
+
if compute is not None:
|
|
175
|
+
return compute.provider.call(wrapper, arguments, compute)
|
|
176
|
+
output_path = arguments.pop("output_path", None)
|
|
177
|
+
with ExitStack() as stack:
|
|
178
|
+
kwargs = {
|
|
179
|
+
name: stack.enter_context(read(arguments[name], param.annotation))
|
|
180
|
+
for name, param in signature.parameters.items()
|
|
181
|
+
}
|
|
182
|
+
result = function(**kwargs)
|
|
183
|
+
if output_path is not None:
|
|
184
|
+
write(result, output_path)
|
|
185
|
+
return output_path
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
wrapper.__signature__ = calling_signature
|
|
189
|
+
# The CLI parses by this entry's signature and describes it by its docstring
|
|
190
|
+
# (else by its repr, which would name this entry, not the function).
|
|
191
|
+
_run_without_parsing.__doc__ = function.__doc__ or function.__name__
|
|
192
|
+
_run_without_parsing.__signature__ = modified_signature
|
|
193
|
+
wrapper._run_without_parsing = _run_without_parsing
|
|
194
|
+
|
|
195
|
+
def snakemake():
|
|
196
|
+
"""Run this function bound to the surrounding Snakemake rule."""
|
|
197
|
+
from powerfunc.integrations import snakemake as snakemake_integration
|
|
198
|
+
|
|
199
|
+
frame = sys._getframe(1)
|
|
200
|
+
return snakemake_integration.run(wrapper, frame)
|
|
201
|
+
|
|
202
|
+
wrapper.snakemake = snakemake
|
|
203
|
+
|
|
204
|
+
if cli and function.__module__ == "__main__":
|
|
205
|
+
command_line.cli_functions[function.__name__] = _run_without_parsing
|
|
206
|
+
|
|
207
|
+
return wrapper
|