spadelib 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spadelib-0.1.0/PKG-INFO +156 -0
- spadelib-0.1.0/README.md +135 -0
- spadelib-0.1.0/pyproject.toml +42 -0
- spadelib-0.1.0/src/spadelib/__init__.py +31 -0
- spadelib-0.1.0/src/spadelib/_output.py +101 -0
- spadelib-0.1.0/src/spadelib/_scanning.py +89 -0
- spadelib-0.1.0/src/spadelib/build.py +88 -0
- spadelib-0.1.0/src/spadelib/py.typed +0 -0
- spadelib-0.1.0/src/spadelib/run.py +44 -0
- spadelib-0.1.0/src/spadelib/secrets.py +50 -0
- spadelib-0.1.0/src/spadelib/types.py +51 -0
spadelib-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spadelib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spade block authoring library for Python
|
|
5
|
+
Keywords: spade,blocks,pipeline,data
|
|
6
|
+
Author: krbundy
|
|
7
|
+
Author-email: krbundy <kenneth.bundy@maine.edu>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: pydantic>=2.12.5
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Project-URL: Repository, https://github.com/krbundy/psae_monorepo
|
|
19
|
+
Project-URL: Issues, https://github.com/krbundy/psae_monorepo/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Spade Python Library
|
|
23
|
+
|
|
24
|
+
The Python library for authoring [Spade](../../spec/core.md) blocks. Define a handler function, call `run()`, and the library handles parameter loading, input discovery, and output writing.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv add spadelib
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from spadelib import run, RasterFile
|
|
41
|
+
|
|
42
|
+
def handler(source: RasterFile, buffer_distance: int) -> RasterFile:
|
|
43
|
+
# Your processing logic here
|
|
44
|
+
result_path = process(source.path, buffer_distance)
|
|
45
|
+
return RasterFile(path=result_path)
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
run(handler)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That's it. The `run()` function:
|
|
52
|
+
|
|
53
|
+
1. Loads scalar parameters from `params.yaml`
|
|
54
|
+
2. Scans `inputs/` for file-based arguments, matched by name to your function parameters
|
|
55
|
+
3. Calls your handler with the combined arguments
|
|
56
|
+
4. Writes the return value to `outputs/`
|
|
57
|
+
|
|
58
|
+
## Types
|
|
59
|
+
|
|
60
|
+
Use type hints on your handler to control how inputs are loaded and outputs are written.
|
|
61
|
+
|
|
62
|
+
### File Types
|
|
63
|
+
|
|
64
|
+
| Type | Description |
|
|
65
|
+
|------|-------------|
|
|
66
|
+
| `File` | Generic single file |
|
|
67
|
+
| `RasterFile` | Raster data (e.g., GeoTIFF) |
|
|
68
|
+
| `VectorFile` | Vector data (e.g., GeoJSON) |
|
|
69
|
+
| `TabularFile` | Tabular data (e.g., CSV) |
|
|
70
|
+
| `JsonFile` | JSON data |
|
|
71
|
+
|
|
72
|
+
Each has a `path: str` field pointing to the file location.
|
|
73
|
+
|
|
74
|
+
### Directory Type
|
|
75
|
+
|
|
76
|
+
| Type | Description |
|
|
77
|
+
|------|-------------|
|
|
78
|
+
| `Directory` | Directory-based input (e.g., shapefiles) |
|
|
79
|
+
|
|
80
|
+
Has a `path: str` field pointing to the directory.
|
|
81
|
+
|
|
82
|
+
### Collection Types
|
|
83
|
+
|
|
84
|
+
| Type | Description |
|
|
85
|
+
|------|-------------|
|
|
86
|
+
| `FileCollection` | Collection of files |
|
|
87
|
+
| `RasterFileCollection` | Collection of raster files |
|
|
88
|
+
| `VectorFileCollection` | Collection of vector files |
|
|
89
|
+
| `TabularFileCollection` | Collection of tabular files |
|
|
90
|
+
|
|
91
|
+
Each has a `paths: list[str]` field.
|
|
92
|
+
|
|
93
|
+
## How Inputs Are Resolved
|
|
94
|
+
|
|
95
|
+
The handler's parameter names are matched against two sources:
|
|
96
|
+
|
|
97
|
+
- **Scalar parameters** (`str`, `int`, `float`, `bool`): loaded from `params.yaml`
|
|
98
|
+
- **File-based inputs**: discovered from subdirectories in `inputs/`, where each subdirectory name matches a parameter name
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
params.yaml # scalar args: {"resolution": 10, "method": "nearest"}
|
|
102
|
+
inputs/
|
|
103
|
+
reference/
|
|
104
|
+
data.tif # -> handler(reference=RasterFile(path="inputs/reference/data.tif"))
|
|
105
|
+
target/
|
|
106
|
+
data.tif # -> handler(target=RasterFile(path="inputs/target/data.tif"))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Type hints determine how each input is constructed:
|
|
110
|
+
|
|
111
|
+
- `File` subclass: expects a single file in the subdirectory
|
|
112
|
+
- `Directory`: uses the subdirectory path itself
|
|
113
|
+
- `FileCollection` subclass: collects all files in the subdirectory
|
|
114
|
+
|
|
115
|
+
## Output Handling
|
|
116
|
+
|
|
117
|
+
Return a typed value from your handler and `run()` writes it to `outputs/`:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
# Single output
|
|
121
|
+
def handler(source: RasterFile) -> RasterFile:
|
|
122
|
+
return RasterFile(path="result.tif")
|
|
123
|
+
|
|
124
|
+
# Multiple outputs
|
|
125
|
+
def handler(source: RasterFile) -> dict:
|
|
126
|
+
return {
|
|
127
|
+
"raster": RasterFile(path="result.tif"),
|
|
128
|
+
"summary": JsonFile(path="stats.json"),
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Output names are resolved from `block.yaml` (if available) or inferred from the return type.
|
|
133
|
+
|
|
134
|
+
## The `build()` Function
|
|
135
|
+
|
|
136
|
+
Generate a block manifest from a handler's signature:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from spadelib import build, RasterFile, VectorFile
|
|
140
|
+
|
|
141
|
+
def handler(raster: RasterFile, buffer: int) -> VectorFile:
|
|
142
|
+
"""Converts raster boundaries to vector polygons."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
manifest = build(handler)
|
|
146
|
+
# {'description': 'Converts raster boundaries to vector polygons.',
|
|
147
|
+
# 'inputs': {'raster': {'type': 'file', 'format': 'GeoTIFF'},
|
|
148
|
+
# 'buffer': {'type': 'number'}},
|
|
149
|
+
# 'outputs': {'vector': {'type': 'file', 'format': 'GeoJSON'}}}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Testing
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
uv run pytest
|
|
156
|
+
```
|
spadelib-0.1.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Spade Python Library
|
|
2
|
+
|
|
3
|
+
The Python library for authoring [Spade](../../spec/core.md) blocks. Define a handler function, call `run()`, and the library handles parameter loading, input discovery, and output writing.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add spadelib
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv sync
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from spadelib import run, RasterFile
|
|
20
|
+
|
|
21
|
+
def handler(source: RasterFile, buffer_distance: int) -> RasterFile:
|
|
22
|
+
# Your processing logic here
|
|
23
|
+
result_path = process(source.path, buffer_distance)
|
|
24
|
+
return RasterFile(path=result_path)
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
run(handler)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's it. The `run()` function:
|
|
31
|
+
|
|
32
|
+
1. Loads scalar parameters from `params.yaml`
|
|
33
|
+
2. Scans `inputs/` for file-based arguments, matched by name to your function parameters
|
|
34
|
+
3. Calls your handler with the combined arguments
|
|
35
|
+
4. Writes the return value to `outputs/`
|
|
36
|
+
|
|
37
|
+
## Types
|
|
38
|
+
|
|
39
|
+
Use type hints on your handler to control how inputs are loaded and outputs are written.
|
|
40
|
+
|
|
41
|
+
### File Types
|
|
42
|
+
|
|
43
|
+
| Type | Description |
|
|
44
|
+
|------|-------------|
|
|
45
|
+
| `File` | Generic single file |
|
|
46
|
+
| `RasterFile` | Raster data (e.g., GeoTIFF) |
|
|
47
|
+
| `VectorFile` | Vector data (e.g., GeoJSON) |
|
|
48
|
+
| `TabularFile` | Tabular data (e.g., CSV) |
|
|
49
|
+
| `JsonFile` | JSON data |
|
|
50
|
+
|
|
51
|
+
Each has a `path: str` field pointing to the file location.
|
|
52
|
+
|
|
53
|
+
### Directory Type
|
|
54
|
+
|
|
55
|
+
| Type | Description |
|
|
56
|
+
|------|-------------|
|
|
57
|
+
| `Directory` | Directory-based input (e.g., shapefiles) |
|
|
58
|
+
|
|
59
|
+
Has a `path: str` field pointing to the directory.
|
|
60
|
+
|
|
61
|
+
### Collection Types
|
|
62
|
+
|
|
63
|
+
| Type | Description |
|
|
64
|
+
|------|-------------|
|
|
65
|
+
| `FileCollection` | Collection of files |
|
|
66
|
+
| `RasterFileCollection` | Collection of raster files |
|
|
67
|
+
| `VectorFileCollection` | Collection of vector files |
|
|
68
|
+
| `TabularFileCollection` | Collection of tabular files |
|
|
69
|
+
|
|
70
|
+
Each has a `paths: list[str]` field.
|
|
71
|
+
|
|
72
|
+
## How Inputs Are Resolved
|
|
73
|
+
|
|
74
|
+
The handler's parameter names are matched against two sources:
|
|
75
|
+
|
|
76
|
+
- **Scalar parameters** (`str`, `int`, `float`, `bool`): loaded from `params.yaml`
|
|
77
|
+
- **File-based inputs**: discovered from subdirectories in `inputs/`, where each subdirectory name matches a parameter name
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
params.yaml # scalar args: {"resolution": 10, "method": "nearest"}
|
|
81
|
+
inputs/
|
|
82
|
+
reference/
|
|
83
|
+
data.tif # -> handler(reference=RasterFile(path="inputs/reference/data.tif"))
|
|
84
|
+
target/
|
|
85
|
+
data.tif # -> handler(target=RasterFile(path="inputs/target/data.tif"))
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Type hints determine how each input is constructed:
|
|
89
|
+
|
|
90
|
+
- `File` subclass: expects a single file in the subdirectory
|
|
91
|
+
- `Directory`: uses the subdirectory path itself
|
|
92
|
+
- `FileCollection` subclass: collects all files in the subdirectory
|
|
93
|
+
|
|
94
|
+
## Output Handling
|
|
95
|
+
|
|
96
|
+
Return a typed value from your handler and `run()` writes it to `outputs/`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# Single output
|
|
100
|
+
def handler(source: RasterFile) -> RasterFile:
|
|
101
|
+
return RasterFile(path="result.tif")
|
|
102
|
+
|
|
103
|
+
# Multiple outputs
|
|
104
|
+
def handler(source: RasterFile) -> dict:
|
|
105
|
+
return {
|
|
106
|
+
"raster": RasterFile(path="result.tif"),
|
|
107
|
+
"summary": JsonFile(path="stats.json"),
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Output names are resolved from `block.yaml` (if available) or inferred from the return type.
|
|
112
|
+
|
|
113
|
+
## The `build()` Function
|
|
114
|
+
|
|
115
|
+
Generate a block manifest from a handler's signature:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from spadelib import build, RasterFile, VectorFile
|
|
119
|
+
|
|
120
|
+
def handler(raster: RasterFile, buffer: int) -> VectorFile:
|
|
121
|
+
"""Converts raster boundaries to vector polygons."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
manifest = build(handler)
|
|
125
|
+
# {'description': 'Converts raster boundaries to vector polygons.',
|
|
126
|
+
# 'inputs': {'raster': {'type': 'file', 'format': 'GeoTIFF'},
|
|
127
|
+
# 'buffer': {'type': 'number'}},
|
|
128
|
+
# 'outputs': {'vector': {'type': 'file', 'format': 'GeoJSON'}}}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Testing
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv run pytest
|
|
135
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# Distribution (PyPI) name. The import name stays "spadelib" via
|
|
3
|
+
# [tool.uv.build-backend].module-name below, so block code keeps using
|
|
4
|
+
# `import spadelib` regardless of this.
|
|
5
|
+
name = "spadelib"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "Spade block authoring library for Python"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "krbundy", email = "kenneth.bundy@maine.edu" }
|
|
13
|
+
]
|
|
14
|
+
keywords = ["spade", "blocks", "pipeline", "data"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pydantic>=2.12.5",
|
|
25
|
+
"pyyaml>=6.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Repository = "https://github.com/krbundy/psae_monorepo"
|
|
30
|
+
Issues = "https://github.com/krbundy/psae_monorepo/issues"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.10.0,<0.11.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[tool.uv.build-backend]
|
|
42
|
+
module-name = "spadelib"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from spadelib.types import (
|
|
2
|
+
Directory,
|
|
3
|
+
File,
|
|
4
|
+
FileCollection,
|
|
5
|
+
JsonFile,
|
|
6
|
+
RasterFile,
|
|
7
|
+
RasterFileCollection,
|
|
8
|
+
TabularFile,
|
|
9
|
+
TabularFileCollection,
|
|
10
|
+
VectorFile,
|
|
11
|
+
VectorFileCollection,
|
|
12
|
+
)
|
|
13
|
+
from spadelib.run import run
|
|
14
|
+
from spadelib.build import build
|
|
15
|
+
from spadelib.secrets import get_secret
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"File",
|
|
19
|
+
"Directory",
|
|
20
|
+
"RasterFile",
|
|
21
|
+
"VectorFile",
|
|
22
|
+
"TabularFile",
|
|
23
|
+
"JsonFile",
|
|
24
|
+
"FileCollection",
|
|
25
|
+
"RasterFileCollection",
|
|
26
|
+
"VectorFileCollection",
|
|
27
|
+
"TabularFileCollection",
|
|
28
|
+
"run",
|
|
29
|
+
"build",
|
|
30
|
+
"get_secret",
|
|
31
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from spadelib.types import (
|
|
9
|
+
Directory,
|
|
10
|
+
File,
|
|
11
|
+
FileCollection,
|
|
12
|
+
JsonFile,
|
|
13
|
+
RasterFile,
|
|
14
|
+
RasterFileCollection,
|
|
15
|
+
TabularFile,
|
|
16
|
+
TabularFileCollection,
|
|
17
|
+
VectorFile,
|
|
18
|
+
VectorFileCollection,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_TYPE_TO_DEFAULT_NAME: dict[type, str] = {
|
|
22
|
+
File: "file",
|
|
23
|
+
RasterFile: "raster",
|
|
24
|
+
VectorFile: "vector",
|
|
25
|
+
TabularFile: "tabular",
|
|
26
|
+
JsonFile: "json",
|
|
27
|
+
Directory: "directory",
|
|
28
|
+
FileCollection: "files",
|
|
29
|
+
RasterFileCollection: "rasters",
|
|
30
|
+
VectorFileCollection: "vectors",
|
|
31
|
+
TabularFileCollection: "tables",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_block_manifest() -> dict | None:
|
|
36
|
+
"""Attempt to read the block manifest for output declarations.
|
|
37
|
+
|
|
38
|
+
Checks (in order):
|
|
39
|
+
1. SPADE_BLOCK_MANIFEST environment variable
|
|
40
|
+
2. block.yaml in the current working directory
|
|
41
|
+
"""
|
|
42
|
+
manifest_path = os.environ.get("SPADE_BLOCK_MANIFEST")
|
|
43
|
+
if manifest_path and Path(manifest_path).exists():
|
|
44
|
+
with open(manifest_path, "r") as f:
|
|
45
|
+
manifest = yaml.safe_load(f)
|
|
46
|
+
return manifest.get("outputs") if manifest else None
|
|
47
|
+
|
|
48
|
+
block_yaml = Path("block.yaml")
|
|
49
|
+
if block_yaml.exists():
|
|
50
|
+
with open(block_yaml, "r") as f:
|
|
51
|
+
manifest = yaml.safe_load(f)
|
|
52
|
+
return manifest.get("outputs") if manifest else None
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _infer_output_name(value: Any) -> str:
|
|
58
|
+
"""Infer output name from the type of the return value."""
|
|
59
|
+
for type_cls, name in _TYPE_TO_DEFAULT_NAME.items():
|
|
60
|
+
if type(value) is type_cls:
|
|
61
|
+
return name
|
|
62
|
+
return type(value).__name__.lower()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_single_output(name: str, value: Any) -> None:
|
|
66
|
+
"""Write a single output value to outputs/<name>/."""
|
|
67
|
+
output_dir = Path("outputs") / name
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
if isinstance(value, FileCollection):
|
|
71
|
+
for file_path in value.paths:
|
|
72
|
+
src = Path(file_path)
|
|
73
|
+
shutil.copy2(src, output_dir / src.name)
|
|
74
|
+
elif isinstance(value, Directory):
|
|
75
|
+
src = Path(value.path)
|
|
76
|
+
for item in src.iterdir():
|
|
77
|
+
if item.is_file():
|
|
78
|
+
shutil.copy2(item, output_dir / item.name)
|
|
79
|
+
elif item.is_dir():
|
|
80
|
+
shutil.copytree(item, output_dir / item.name)
|
|
81
|
+
elif isinstance(value, File):
|
|
82
|
+
src = Path(value.path)
|
|
83
|
+
shutil.copy2(src, output_dir / src.name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_outputs(result: Any, manifest_outputs: dict | None = None) -> None:
|
|
87
|
+
"""Write handler return value(s) to the outputs/ directory."""
|
|
88
|
+
if result is None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
Path("outputs").mkdir(exist_ok=True)
|
|
92
|
+
|
|
93
|
+
if isinstance(result, dict):
|
|
94
|
+
for name, value in result.items():
|
|
95
|
+
_write_single_output(name, value)
|
|
96
|
+
elif isinstance(result, (File, Directory, FileCollection)):
|
|
97
|
+
if manifest_outputs and len(manifest_outputs) == 1:
|
|
98
|
+
name = next(iter(manifest_outputs))
|
|
99
|
+
else:
|
|
100
|
+
name = _infer_output_name(result)
|
|
101
|
+
_write_single_output(name, result)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Callable, get_type_hints
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
|
|
6
|
+
from spadelib.types import (
|
|
7
|
+
Directory,
|
|
8
|
+
File,
|
|
9
|
+
FileCollection,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_params() -> dict:
|
|
14
|
+
"""Load scalar parameters from params.yaml."""
|
|
15
|
+
params_path = Path("params.yaml")
|
|
16
|
+
if not params_path.exists():
|
|
17
|
+
return {}
|
|
18
|
+
with open(params_path, "r") as f:
|
|
19
|
+
params = yaml.safe_load(f)
|
|
20
|
+
return params if params is not None else {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def scan_inputs(type_hints: dict[str, type]) -> dict[str, Any]:
|
|
24
|
+
"""Scan the inputs/ directory and build typed arguments.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
type_hints: Mapping of parameter name -> expected type from the
|
|
28
|
+
handler's annotations.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dict mapping parameter name -> typed value.
|
|
32
|
+
"""
|
|
33
|
+
inputs_dir = Path("inputs")
|
|
34
|
+
if not inputs_dir.exists():
|
|
35
|
+
return {}
|
|
36
|
+
|
|
37
|
+
result = {}
|
|
38
|
+
for subdir in sorted(inputs_dir.iterdir()):
|
|
39
|
+
if not subdir.is_dir():
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
name = subdir.name
|
|
43
|
+
expected_type = type_hints.get(name)
|
|
44
|
+
|
|
45
|
+
files = sorted([f for f in subdir.iterdir() if f.is_file()])
|
|
46
|
+
|
|
47
|
+
if expected_type is not None:
|
|
48
|
+
try:
|
|
49
|
+
if issubclass(expected_type, Directory):
|
|
50
|
+
result[name] = expected_type(path=str(subdir))
|
|
51
|
+
continue
|
|
52
|
+
elif issubclass(expected_type, FileCollection):
|
|
53
|
+
result[name] = expected_type(paths=[str(f) for f in files])
|
|
54
|
+
continue
|
|
55
|
+
elif issubclass(expected_type, File):
|
|
56
|
+
if not files:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Input '{name}' expects a file but directory "
|
|
59
|
+
f"'{subdir}' is empty"
|
|
60
|
+
)
|
|
61
|
+
result[name] = expected_type(path=str(files[0]))
|
|
62
|
+
continue
|
|
63
|
+
except TypeError:
|
|
64
|
+
pass # expected_type is not a class, fall through
|
|
65
|
+
|
|
66
|
+
# Default: infer from file count
|
|
67
|
+
if not files:
|
|
68
|
+
raise ValueError(f"Input directory '{subdir}' is empty")
|
|
69
|
+
if len(files) == 1:
|
|
70
|
+
result[name] = File(path=str(files[0]))
|
|
71
|
+
else:
|
|
72
|
+
result[name] = FileCollection(paths=[str(f) for f in files])
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_function_args(fn: Callable) -> dict[str, Any]:
|
|
78
|
+
"""Build the full arguments dict for the handler function.
|
|
79
|
+
|
|
80
|
+
Merges scalar parameters from params.yaml with file-based inputs
|
|
81
|
+
from the inputs/ directory. Inputs take precedence over params.
|
|
82
|
+
"""
|
|
83
|
+
type_hints = get_type_hints(fn)
|
|
84
|
+
type_hints.pop("return", None)
|
|
85
|
+
|
|
86
|
+
params = load_params()
|
|
87
|
+
inputs = scan_inputs(type_hints)
|
|
88
|
+
|
|
89
|
+
return params | inputs
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Callable, get_type_hints
|
|
3
|
+
|
|
4
|
+
from spadelib.types import (
|
|
5
|
+
Directory,
|
|
6
|
+
File,
|
|
7
|
+
FileCollection,
|
|
8
|
+
JsonFile,
|
|
9
|
+
RasterFile,
|
|
10
|
+
RasterFileCollection,
|
|
11
|
+
TabularFile,
|
|
12
|
+
TabularFileCollection,
|
|
13
|
+
VectorFile,
|
|
14
|
+
VectorFileCollection,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_PYTHON_TYPE_TO_MANIFEST: dict[type, dict[str, str]] = {
|
|
18
|
+
File: {"type": "file"},
|
|
19
|
+
RasterFile: {"type": "file", "format": "GeoTIFF"},
|
|
20
|
+
VectorFile: {"type": "file", "format": "GeoJSON"},
|
|
21
|
+
TabularFile: {"type": "file", "format": "CSV"},
|
|
22
|
+
JsonFile: {"type": "json"},
|
|
23
|
+
Directory: {"type": "directory"},
|
|
24
|
+
FileCollection: {"type": "collection", "item_type": "file"},
|
|
25
|
+
RasterFileCollection: {"type": "collection", "item_type": "file", "format": "GeoTIFF"},
|
|
26
|
+
VectorFileCollection: {"type": "collection", "item_type": "file", "format": "GeoJSON"},
|
|
27
|
+
TabularFileCollection: {"type": "collection", "item_type": "file", "format": "CSV"},
|
|
28
|
+
str: {"type": "string"},
|
|
29
|
+
int: {"type": "number"},
|
|
30
|
+
float: {"type": "number"},
|
|
31
|
+
bool: {"type": "boolean"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_TYPE_TO_OUTPUT_NAME: dict[type, str] = {
|
|
35
|
+
File: "file",
|
|
36
|
+
RasterFile: "raster",
|
|
37
|
+
VectorFile: "vector",
|
|
38
|
+
TabularFile: "tabular",
|
|
39
|
+
JsonFile: "json",
|
|
40
|
+
Directory: "directory",
|
|
41
|
+
FileCollection: "files",
|
|
42
|
+
RasterFileCollection: "rasters",
|
|
43
|
+
VectorFileCollection: "vectors",
|
|
44
|
+
TabularFileCollection: "tables",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build(fn: Callable) -> dict:
|
|
49
|
+
"""Generate a block manifest dict from a handler function's signature.
|
|
50
|
+
|
|
51
|
+
Inspects the function's type hints and docstring to produce a dict
|
|
52
|
+
that can be serialized to block.yaml.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
fn: The handler function to inspect.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A dict representing the block manifest (inputs, outputs, description).
|
|
59
|
+
"""
|
|
60
|
+
hints = get_type_hints(fn)
|
|
61
|
+
sig = inspect.signature(fn)
|
|
62
|
+
|
|
63
|
+
inputs: dict[str, Any] = {}
|
|
64
|
+
for param_name in sig.parameters:
|
|
65
|
+
param_type = hints.get(param_name)
|
|
66
|
+
if param_type is None:
|
|
67
|
+
continue
|
|
68
|
+
manifest_entry = _PYTHON_TYPE_TO_MANIFEST.get(param_type)
|
|
69
|
+
if manifest_entry is not None:
|
|
70
|
+
inputs[param_name] = dict(manifest_entry)
|
|
71
|
+
|
|
72
|
+
outputs: dict[str, Any] = {}
|
|
73
|
+
return_type = hints.get("return")
|
|
74
|
+
if return_type is not None and return_type is not type(None):
|
|
75
|
+
manifest_entry = _PYTHON_TYPE_TO_MANIFEST.get(return_type)
|
|
76
|
+
if manifest_entry is not None:
|
|
77
|
+
output_name = _TYPE_TO_OUTPUT_NAME.get(return_type, "output")
|
|
78
|
+
outputs[output_name] = dict(manifest_entry)
|
|
79
|
+
|
|
80
|
+
manifest: dict[str, Any] = {}
|
|
81
|
+
|
|
82
|
+
if fn.__doc__:
|
|
83
|
+
manifest["description"] = fn.__doc__.strip()
|
|
84
|
+
|
|
85
|
+
manifest["inputs"] = inputs
|
|
86
|
+
manifest["outputs"] = outputs
|
|
87
|
+
|
|
88
|
+
return manifest
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from spadelib._output import read_block_manifest, write_outputs
|
|
5
|
+
from spadelib._scanning import build_function_args
|
|
6
|
+
from spadelib.secrets import _load_secrets
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(fn: Callable) -> None:
|
|
10
|
+
"""Execute a handler function as a Spade block.
|
|
11
|
+
|
|
12
|
+
1. Load scalar parameters from params.yaml
|
|
13
|
+
2. Scan inputs/ directory for file-based arguments
|
|
14
|
+
3. Build the function arguments dict
|
|
15
|
+
4. Call the handler function
|
|
16
|
+
5. Write return value(s) to outputs/ directory
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
fn: The handler function to execute. Its type hints determine
|
|
20
|
+
how inputs are loaded and outputs are written.
|
|
21
|
+
"""
|
|
22
|
+
# Scrub SPADE_SECRETS from the environment early (before the handler runs),
|
|
23
|
+
# even if the block never calls get_secret. Idempotent.
|
|
24
|
+
_load_secrets()
|
|
25
|
+
|
|
26
|
+
args = build_function_args(fn)
|
|
27
|
+
|
|
28
|
+
sig = inspect.signature(fn)
|
|
29
|
+
param_names = set(sig.parameters.keys())
|
|
30
|
+
|
|
31
|
+
has_var_keyword = any(
|
|
32
|
+
p.kind == inspect.Parameter.VAR_KEYWORD
|
|
33
|
+
for p in sig.parameters.values()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if has_var_keyword:
|
|
37
|
+
filtered_args = args
|
|
38
|
+
else:
|
|
39
|
+
filtered_args = {k: v for k, v in args.items() if k in param_names}
|
|
40
|
+
|
|
41
|
+
result = fn(**filtered_args)
|
|
42
|
+
|
|
43
|
+
manifest_outputs = read_block_manifest()
|
|
44
|
+
write_outputs(result, manifest_outputs)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Access to secrets injected into a block by the Spade runtime.
|
|
2
|
+
|
|
3
|
+
Secrets are delivered as the ``SPADE_SECRETS`` environment variable — a JSON
|
|
4
|
+
object mapping the block's logical secret names to their values — by the worker
|
|
5
|
+
(cloud) or the CLI (local). This module parses that blob, serves values through
|
|
6
|
+
:func:`get_secret`, and scrubs the variable from the environment so it is not
|
|
7
|
+
inherited by any subprocess the block spawns. See ``spec/secrets.md`` §4.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Dict, Optional
|
|
13
|
+
|
|
14
|
+
_secrets: Optional[Dict[str, str]] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_secrets(raw: Optional[str]) -> Dict[str, str]:
|
|
18
|
+
if not raw:
|
|
19
|
+
return {}
|
|
20
|
+
return json.loads(raw)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_secrets() -> Dict[str, str]:
|
|
24
|
+
"""Parse and cache ``SPADE_SECRETS``, removing it from the environment.
|
|
25
|
+
|
|
26
|
+
The variable is popped on first read so it is not inherited by subprocesses
|
|
27
|
+
the block spawns. Idempotent: subsequent calls return the cached mapping.
|
|
28
|
+
"""
|
|
29
|
+
global _secrets
|
|
30
|
+
if _secrets is None:
|
|
31
|
+
_secrets = _parse_secrets(os.environ.pop("SPADE_SECRETS", None))
|
|
32
|
+
return _secrets
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_secret(name: str) -> str:
|
|
36
|
+
"""Return the secret bound to a logical ``name`` for this block.
|
|
37
|
+
|
|
38
|
+
The mapping from logical name to a stored secret is declared in the
|
|
39
|
+
pipeline (``spec/secrets.md`` §3.2); the value is injected by the worker
|
|
40
|
+
(cloud) or CLI (local). Raises :class:`KeyError` if the name was not
|
|
41
|
+
provided — a declared-but-unresolved secret is a real error, not empty.
|
|
42
|
+
"""
|
|
43
|
+
secrets = _load_secrets()
|
|
44
|
+
try:
|
|
45
|
+
return secrets[name]
|
|
46
|
+
except KeyError:
|
|
47
|
+
raise KeyError(
|
|
48
|
+
f"secret {name!r} was not provided to this block; "
|
|
49
|
+
"declare it in the pipeline's 'secrets' mapping"
|
|
50
|
+
) from None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class File(BaseModel):
|
|
5
|
+
"""Base class for single-file inputs/outputs."""
|
|
6
|
+
path: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Directory(BaseModel):
|
|
10
|
+
"""Base class for directory-based inputs/outputs."""
|
|
11
|
+
path: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RasterFile(File):
|
|
15
|
+
"""Raster data file (e.g., GeoTIFF)."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VectorFile(File):
|
|
20
|
+
"""Vector data file (e.g., GeoJSON, Shapefile)."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TabularFile(File):
|
|
25
|
+
"""Tabular data file (e.g., CSV, Parquet)."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JsonFile(File):
|
|
30
|
+
"""JSON data file."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileCollection(BaseModel):
|
|
35
|
+
"""Base class for a collection of files."""
|
|
36
|
+
paths: list[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RasterFileCollection(FileCollection):
|
|
40
|
+
"""Collection of raster data files."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VectorFileCollection(FileCollection):
|
|
45
|
+
"""Collection of vector data files."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TabularFileCollection(FileCollection):
|
|
50
|
+
"""Collection of tabular data files."""
|
|
51
|
+
pass
|