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.
Files changed (90) hide show
  1. {powerfunc-0.3.0 → powerfunc-0.5.0}/.github/workflows/ci.yml +10 -0
  2. {powerfunc-0.3.0 → powerfunc-0.5.0}/.gitignore +3 -0
  3. {powerfunc-0.3.0 → powerfunc-0.5.0}/PKG-INFO +5 -4
  4. {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/providers/gcp.md +34 -1
  5. powerfunc-0.5.0/documentation/providers/modal.md +89 -0
  6. {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/readme.md +9 -0
  7. {powerfunc-0.3.0 → powerfunc-0.5.0}/pyproject.toml +4 -3
  8. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/command_line.py +7 -8
  9. powerfunc-0.5.0/src/powerfunc/compute.py +90 -0
  10. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/conversions.py +28 -15
  11. powerfunc-0.5.0/src/powerfunc/decorator.py +207 -0
  12. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/pandas.py +4 -1
  13. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/polars.py +4 -1
  14. powerfunc-0.5.0/src/powerfunc/providers/gcp_batch.py +308 -0
  15. powerfunc-0.5.0/src/powerfunc/providers/gcp_cloud_run.py +103 -0
  16. powerfunc-0.5.0/src/powerfunc/providers/internal/__init__.py +1 -0
  17. powerfunc-0.5.0/src/powerfunc/providers/internal/codebase_transfer.py +68 -0
  18. powerfunc-0.5.0/src/powerfunc/providers/internal/gcp_batch_auto_location.py +169 -0
  19. powerfunc-0.5.0/src/powerfunc/providers/internal/gcp_shared.py +206 -0
  20. powerfunc-0.5.0/src/powerfunc/providers/internal/generic_execute_entrypoint.py +58 -0
  21. powerfunc-0.5.0/src/powerfunc/providers/internal/generic_setup_entrypoint.py +61 -0
  22. powerfunc-0.5.0/src/powerfunc/providers/internal/job_specification.py +91 -0
  23. powerfunc-0.5.0/src/powerfunc/providers/internal/secret_directories.py +55 -0
  24. powerfunc-0.5.0/src/powerfunc/providers/modal.py +214 -0
  25. powerfunc-0.5.0/tests/powerfunc_tests/cleanup.py +56 -0
  26. {powerfunc-0.3.0/tests/powerfunc_tests/gcp → powerfunc-0.5.0/tests/powerfunc_tests}/conftest.py +26 -7
  27. powerfunc-0.5.0/tests/powerfunc_tests/core/__init__.py +12 -0
  28. powerfunc-0.5.0/tests/powerfunc_tests/core/cli_example.py +40 -0
  29. powerfunc-0.5.0/tests/powerfunc_tests/core/test_configuration.py +95 -0
  30. powerfunc-0.5.0/tests/powerfunc_tests/core/test_future_annotations.py +27 -0
  31. powerfunc-0.5.0/tests/powerfunc_tests/core/test_given_objects.py +90 -0
  32. powerfunc-0.5.0/tests/powerfunc_tests/core/test_remote_files.py +80 -0
  33. powerfunc-0.5.0/tests/powerfunc_tests/core/test_remote_locally.py +128 -0
  34. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/gcp/gcp_shared.py +9 -2
  35. powerfunc-0.5.0/tests/powerfunc_tests/gcp/test_gcp_batch.py +98 -0
  36. powerfunc-0.5.0/tests/powerfunc_tests/gcp/test_gcp_cloud_run.py +102 -0
  37. powerfunc-0.5.0/tests/powerfunc_tests/modal/test_modal.py +68 -0
  38. powerfunc-0.3.0/tests/powerfunc_tests/gcp/gcp_jobs.py → powerfunc-0.5.0/tests/powerfunc_tests/remote_jobs.py +24 -3
  39. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/test_snakemake.py +1 -2
  40. {powerfunc-0.3.0 → powerfunc-0.5.0}/tox.ini +3 -1
  41. {powerfunc-0.3.0 → powerfunc-0.5.0}/uv.lock +271 -280
  42. powerfunc-0.3.0/documentation/providers/modal.md +0 -95
  43. powerfunc-0.3.0/src/powerfunc/compute.py +0 -62
  44. powerfunc-0.3.0/src/powerfunc/decorator.py +0 -116
  45. powerfunc-0.3.0/src/powerfunc/providers/gcp_batch.py +0 -436
  46. powerfunc-0.3.0/src/powerfunc/providers/gcp_cloud_run.py +0 -391
  47. powerfunc-0.3.0/src/powerfunc/providers/modal.py +0 -80
  48. powerfunc-0.3.0/tests/powerfunc_tests/core/cli_example.py +0 -21
  49. powerfunc-0.3.0/tests/powerfunc_tests/core/test_remote.py +0 -85
  50. powerfunc-0.3.0/tests/powerfunc_tests/gcp/test_gcp_batch.py +0 -70
  51. powerfunc-0.3.0/tests/powerfunc_tests/gcp/test_gcp_cloud_run.py +0 -52
  52. powerfunc-0.3.0/tests/powerfunc_tests/modal/test_modal.py +0 -44
  53. powerfunc-0.3.0/tests/powerfunc_tests/snakemake/__init__.py +0 -0
  54. {powerfunc-0.3.0 → powerfunc-0.5.0}/.pre-commit-config.yaml +0 -0
  55. {powerfunc-0.3.0 → powerfunc-0.5.0}/LICENSE.txt +0 -0
  56. {powerfunc-0.3.0 → powerfunc-0.5.0}/README.md +0 -0
  57. {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/advanced_usage.md +0 -0
  58. {powerfunc-0.3.0 → powerfunc-0.5.0}/documentation/integrations/snakemake.md +0 -0
  59. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/__init__.py +0 -0
  60. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/configuration.py +0 -0
  61. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/__init__.py +0 -0
  62. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/arrow.py +0 -0
  63. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/csv_reader.py +0 -0
  64. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/formats/dask.py +0 -0
  65. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/integrations/__init__.py +0 -0
  66. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/integrations/snakemake.py +0 -0
  67. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/providers/__init__.py +0 -0
  68. {powerfunc-0.3.0 → powerfunc-0.5.0}/src/powerfunc/py.typed +0 -0
  69. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/__init__.py +0 -0
  70. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_basic.py +0 -0
  71. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_cli.py +0 -0
  72. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/core/test_conversions.py +0 -0
  73. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.arrow +0 -0
  74. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.csv +0 -0
  75. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.feather +0 -0
  76. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.pandas.json +0 -0
  77. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.parquet +0 -0
  78. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.polars.json +0 -0
  79. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/data.xlsx +0 -0
  80. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/data/generate_data_files.py +0 -0
  81. {powerfunc-0.3.0/tests/powerfunc_tests/core → powerfunc-0.5.0/tests/powerfunc_tests/gcp}/__init__.py +0 -0
  82. {powerfunc-0.3.0/tests/powerfunc_tests/gcp → powerfunc-0.5.0/tests/powerfunc_tests/modal}/__init__.py +0 -0
  83. {powerfunc-0.3.0/tests/powerfunc_tests/modal → powerfunc-0.5.0/tests/powerfunc_tests/snakemake}/__init__.py +0 -0
  84. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/functions.py +0 -0
  85. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/input.smk +0 -0
  86. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/input_params_and_output.smk +0 -0
  87. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/multiple_outputs_rejected.smk +0 -0
  88. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/named_outputs_rejected.smk +0 -0
  89. {powerfunc-0.3.0 → powerfunc-0.5.0}/tests/powerfunc_tests/snakemake/params.smk +0 -0
  90. {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
@@ -16,3 +16,6 @@ venv/
16
16
 
17
17
  # OS
18
18
  .DS_Store
19
+
20
+ # Tests
21
+ .powerfunc_test_secrets/
@@ -1,17 +1,18 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: powerfunc
3
- Version: 0.3.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.3.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
- "cloudpathlib[all]",
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
- cli_functions: list[tuple] = []
14
- _cli_active = False
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
- caller_module = inspect.getmodule(frame.f_back)
24
- if caller_module is None or caller_module.__name__ != "__main__":
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
- wrappers[0] if len(wrappers) == 1 else wrappers,
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
- from contextlib import contextmanager, nullcontext
2
+ import tempfile
3
+ from collections.abc import Callable, Iterator
4
+ from contextlib import AbstractContextManager, contextmanager, nullcontext
4
5
 
5
- from cloudpathlib import AnyPath, CloudPath
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
- def read(value, type_):
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 locally via cloudpathlib first.
54
+ the scheme as native, otherwise downloaded to a temporary local file first.
49
55
  """
50
- if not isinstance(value, (str, pathlib.Path, CloudPath)):
56
+ if not isinstance(value, (str, pathlib.Path, UPath)):
51
57
  return nullcontext(value)
52
- path = AnyPath(str(value))
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
- native_protocols = entry["native_protocols"]
59
- if isinstance(path, CloudPath):
60
- scheme = type(path).cloud_prefix.replace("://", "")
61
- if scheme in native_protocols:
62
- return reader(str(path))
63
- return reader(pathlib.Path(os.fspath(path)))
64
- return reader(pathlib.Path(value))
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 = AnyPath(str(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