runit-sdk 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.
- runit_sdk-0.1.0/PKG-INFO +112 -0
- runit_sdk-0.1.0/README.md +75 -0
- runit_sdk-0.1.0/pyproject.toml +67 -0
- runit_sdk-0.1.0/runit/__init__.py +21 -0
- runit_sdk-0.1.0/runit/_app.py +55 -0
- runit_sdk-0.1.0/runit/_storage.py +216 -0
- runit_sdk-0.1.0/runit/artifacts.py +146 -0
- runit_sdk-0.1.0/runit/context.py +110 -0
- runit_sdk-0.1.0/runit_sdk.egg-info/PKG-INFO +112 -0
- runit_sdk-0.1.0/runit_sdk.egg-info/SOURCES.txt +17 -0
- runit_sdk-0.1.0/runit_sdk.egg-info/dependency_links.txt +1 -0
- runit_sdk-0.1.0/runit_sdk.egg-info/requires.txt +16 -0
- runit_sdk-0.1.0/runit_sdk.egg-info/top_level.txt +1 -0
- runit_sdk-0.1.0/setup.cfg +4 -0
- runit_sdk-0.1.0/setup.py +54 -0
- runit_sdk-0.1.0/tests/test_app.py +45 -0
- runit_sdk-0.1.0/tests/test_artifacts.py +134 -0
- runit_sdk-0.1.0/tests/test_context.py +257 -0
- runit_sdk-0.1.0/tests/test_storage.py +136 -0
runit_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runit-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for building apps on RunIt
|
|
5
|
+
Home-page: https://github.com/buildingopen/runit
|
|
6
|
+
Author: RunIt
|
|
7
|
+
Author-email: RunIt <hello@buildingopen.org>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Documentation, https://docs.runit.dev
|
|
10
|
+
Project-URL: Source, https://github.com/buildingopen/runit
|
|
11
|
+
Project-URL: Tracker, https://github.com/buildingopen/runit/issues
|
|
12
|
+
Keywords: fastapi,runit,sdk,api,deployment
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Provides-Extra: dataframe
|
|
22
|
+
Requires-Dist: pandas>=2.0.0; extra == "dataframe"
|
|
23
|
+
Requires-Dist: pyarrow>=14.0.0; extra == "dataframe"
|
|
24
|
+
Provides-Extra: excel
|
|
25
|
+
Requires-Dist: pandas>=2.0.0; extra == "excel"
|
|
26
|
+
Requires-Dist: openpyxl>=3.1.0; extra == "excel"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pandas>=2.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: pyarrow>=14.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: openpyxl>=3.1.0; extra == "dev"
|
|
34
|
+
Dynamic: author
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: requires-python
|
|
37
|
+
|
|
38
|
+
# RunIt SDK
|
|
39
|
+
|
|
40
|
+
Simple utilities for building FastAPI apps on RunIt.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
The SDK is automatically available in the RunIt runtime. For local development:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or install from PyPI (when published):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install runit
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from fastapi import FastAPI
|
|
60
|
+
from runit import context, save_artifact
|
|
61
|
+
|
|
62
|
+
app = FastAPI()
|
|
63
|
+
|
|
64
|
+
@app.post("/process")
|
|
65
|
+
async def process(data: dict):
|
|
66
|
+
# Access secrets
|
|
67
|
+
api_key = context.get_secret("OPENAI_API_KEY")
|
|
68
|
+
|
|
69
|
+
# Access uploaded context
|
|
70
|
+
company = context.get_context("company")
|
|
71
|
+
|
|
72
|
+
# Save results
|
|
73
|
+
save_artifact("output.json", json.dumps(result))
|
|
74
|
+
|
|
75
|
+
return {"status": "success"}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Features
|
|
79
|
+
|
|
80
|
+
- **Context Access** - Read secrets and uploaded JSON data
|
|
81
|
+
- **Artifact Saving** - Write outputs that users can download
|
|
82
|
+
- **DataFrame Export** - Save pandas/polars DataFrames in multiple formats
|
|
83
|
+
- **Zero Dependencies** - Core functionality has no required dependencies
|
|
84
|
+
|
|
85
|
+
## Documentation
|
|
86
|
+
|
|
87
|
+
See the [SDK Guide](../../../docs/SDK_GUIDE.md) for complete documentation and examples.
|
|
88
|
+
|
|
89
|
+
## Sample Apps
|
|
90
|
+
|
|
91
|
+
Check out the sample apps in `../samples/`:
|
|
92
|
+
|
|
93
|
+
- `extract-company` - URL scraping with artifacts
|
|
94
|
+
- `image-analysis` - File upload and image processing
|
|
95
|
+
- `bulk-processor` - Batch processing with error handling
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Install with dev dependencies
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
|
|
103
|
+
# Run tests
|
|
104
|
+
pytest
|
|
105
|
+
|
|
106
|
+
# Run tests with coverage
|
|
107
|
+
pytest --cov=runit --cov-report=html
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# RunIt SDK
|
|
2
|
+
|
|
3
|
+
Simple utilities for building FastAPI apps on RunIt.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
The SDK is automatically available in the RunIt runtime. For local development:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or install from PyPI (when published):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install runit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from runit import context, save_artifact
|
|
24
|
+
|
|
25
|
+
app = FastAPI()
|
|
26
|
+
|
|
27
|
+
@app.post("/process")
|
|
28
|
+
async def process(data: dict):
|
|
29
|
+
# Access secrets
|
|
30
|
+
api_key = context.get_secret("OPENAI_API_KEY")
|
|
31
|
+
|
|
32
|
+
# Access uploaded context
|
|
33
|
+
company = context.get_context("company")
|
|
34
|
+
|
|
35
|
+
# Save results
|
|
36
|
+
save_artifact("output.json", json.dumps(result))
|
|
37
|
+
|
|
38
|
+
return {"status": "success"}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Context Access** - Read secrets and uploaded JSON data
|
|
44
|
+
- **Artifact Saving** - Write outputs that users can download
|
|
45
|
+
- **DataFrame Export** - Save pandas/polars DataFrames in multiple formats
|
|
46
|
+
- **Zero Dependencies** - Core functionality has no required dependencies
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
See the [SDK Guide](../../../docs/SDK_GUIDE.md) for complete documentation and examples.
|
|
51
|
+
|
|
52
|
+
## Sample Apps
|
|
53
|
+
|
|
54
|
+
Check out the sample apps in `../samples/`:
|
|
55
|
+
|
|
56
|
+
- `extract-company` - URL scraping with artifacts
|
|
57
|
+
- `image-analysis` - File upload and image processing
|
|
58
|
+
- `bulk-processor` - Batch processing with error handling
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Install with dev dependencies
|
|
64
|
+
pip install -e ".[dev]"
|
|
65
|
+
|
|
66
|
+
# Run tests
|
|
67
|
+
pytest
|
|
68
|
+
|
|
69
|
+
# Run tests with coverage
|
|
70
|
+
pytest --cov=runit --cov-report=html
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runit-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SDK for building apps on RunIt"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "RunIt", email = "hello@buildingopen.org"}
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
]
|
|
23
|
+
keywords = ["fastapi", "runit", "sdk", "api", "deployment"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dataframe = [
|
|
27
|
+
"pandas>=2.0.0",
|
|
28
|
+
"pyarrow>=14.0.0",
|
|
29
|
+
]
|
|
30
|
+
excel = [
|
|
31
|
+
"pandas>=2.0.0",
|
|
32
|
+
"openpyxl>=3.1.0",
|
|
33
|
+
]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=7.4.0",
|
|
36
|
+
"pytest-asyncio>=0.23.0",
|
|
37
|
+
"pytest-cov>=4.1.0",
|
|
38
|
+
"pandas>=2.0.0",
|
|
39
|
+
"pyarrow>=14.0.0",
|
|
40
|
+
"openpyxl>=3.1.0",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Documentation = "https://docs.runit.dev"
|
|
45
|
+
Source = "https://github.com/buildingopen/runit"
|
|
46
|
+
Tracker = "https://github.com/buildingopen/runit/issues"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
python_files = ["test_*.py"]
|
|
51
|
+
python_classes = ["Test*"]
|
|
52
|
+
python_functions = ["test_*"]
|
|
53
|
+
addopts = "-v --strict-markers"
|
|
54
|
+
|
|
55
|
+
[tool.coverage.run]
|
|
56
|
+
source = ["runit"]
|
|
57
|
+
omit = ["tests/*"]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.report]
|
|
60
|
+
fail_under = 77
|
|
61
|
+
exclude_lines = [
|
|
62
|
+
"pragma: no cover",
|
|
63
|
+
"def __repr__",
|
|
64
|
+
"raise AssertionError",
|
|
65
|
+
"raise NotImplementedError",
|
|
66
|
+
"if __name__ == .__main__.:",
|
|
67
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ABOUTME: RunIt Python SDK. Exposes app, storage, remember, artifacts, and context.
|
|
2
|
+
# ABOUTME: Usage: from runit import app, remember; @app.action; remember("key", value)
|
|
3
|
+
|
|
4
|
+
from runit._app import App, app
|
|
5
|
+
from runit._storage import forget, remember, storage
|
|
6
|
+
from runit.artifacts import save_artifact, save_dataframe, save_json
|
|
7
|
+
from runit.context import Context, context
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = [
|
|
11
|
+
"app",
|
|
12
|
+
"App",
|
|
13
|
+
"storage",
|
|
14
|
+
"remember",
|
|
15
|
+
"forget",
|
|
16
|
+
"save_artifact",
|
|
17
|
+
"save_dataframe",
|
|
18
|
+
"save_json",
|
|
19
|
+
"context",
|
|
20
|
+
"Context",
|
|
21
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ABOUTME: RunIt app class with @app.action decorator for marking functions as RunIt actions.
|
|
2
|
+
# ABOUTME: Syntactic sugar that makes intent explicit.
|
|
3
|
+
# ABOUTME: Runner checks _runit_action attribute on functions.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class App:
|
|
7
|
+
"""RunIt application container.
|
|
8
|
+
|
|
9
|
+
Use @app.action to mark functions as RunIt actions.
|
|
10
|
+
The runner uses this to discover which functions to expose.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
from runit import app
|
|
14
|
+
|
|
15
|
+
@app.action
|
|
16
|
+
def greet(name: str) -> dict:
|
|
17
|
+
return {"message": f"Hello, {name}!"}
|
|
18
|
+
|
|
19
|
+
@app.action(name="custom_name")
|
|
20
|
+
def my_func(x: int) -> dict:
|
|
21
|
+
return {"result": x * 2}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._actions = []
|
|
26
|
+
|
|
27
|
+
def action(self, func=None, *, name=None):
|
|
28
|
+
"""Mark a function as a RunIt action.
|
|
29
|
+
|
|
30
|
+
Can be used with or without arguments:
|
|
31
|
+
@app.action
|
|
32
|
+
def my_func(): ...
|
|
33
|
+
|
|
34
|
+
@app.action(name="custom")
|
|
35
|
+
def my_func(): ...
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def decorator(f):
|
|
39
|
+
f._runit_action = True
|
|
40
|
+
f._runit_name = name or f.__name__
|
|
41
|
+
self._actions.append(f)
|
|
42
|
+
return f
|
|
43
|
+
|
|
44
|
+
if func is not None:
|
|
45
|
+
return decorator(func)
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def actions(self):
|
|
50
|
+
"""List of registered action functions."""
|
|
51
|
+
return list(self._actions)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Singleton instance
|
|
55
|
+
app = App()
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# ABOUTME: StorageClient for persistent key-value storage inside runner containers.
|
|
2
|
+
# ABOUTME: Reads/writes files at RUNIT_STORAGE_DIR (/storage).
|
|
3
|
+
# ABOUTME: Atomic writes via rename. 10MB/value, 100MB/project.
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
KEY_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+$")
|
|
11
|
+
MAX_KEY_LENGTH = 256
|
|
12
|
+
MAX_VALUE_SIZE = 10 * 1024 * 1024 # 10MB per value
|
|
13
|
+
DEFAULT_MAX_PROJECT_SIZE = 100 * 1024 * 1024 # 100MB per project
|
|
14
|
+
USAGE_FILE = ".usage"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StorageClient:
|
|
18
|
+
"""Persistent key-value storage for RunIt projects.
|
|
19
|
+
|
|
20
|
+
Values are stored as files in the storage directory, mounted by the runner.
|
|
21
|
+
Supports JSON-serializable values and raw strings.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self._dir = os.environ.get("RUNIT_STORAGE_DIR", "/storage")
|
|
26
|
+
self._max_project_size = int(
|
|
27
|
+
os.environ.get("RUNIT_STORAGE_MAX_PROJECT_SIZE", str(DEFAULT_MAX_PROJECT_SIZE))
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _validate_key(self, key):
|
|
31
|
+
if not key or len(key) == 0:
|
|
32
|
+
raise ValueError("Key is required")
|
|
33
|
+
if len(key) > MAX_KEY_LENGTH:
|
|
34
|
+
raise ValueError(f"Key exceeds maximum length of {MAX_KEY_LENGTH} characters")
|
|
35
|
+
if not KEY_PATTERN.match(key):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Key must contain only alphanumeric characters, dots, underscores, and hyphens"
|
|
38
|
+
)
|
|
39
|
+
if ".." in key or key.startswith(".") or key.endswith("."):
|
|
40
|
+
raise ValueError("Key must not start/end with dots or contain consecutive dots")
|
|
41
|
+
|
|
42
|
+
def _path(self, key):
|
|
43
|
+
path = os.path.join(self._dir, key)
|
|
44
|
+
real = os.path.realpath(path)
|
|
45
|
+
storage_dir = os.path.realpath(self._dir)
|
|
46
|
+
if not real.startswith(storage_dir + os.sep) and real != storage_dir:
|
|
47
|
+
raise ValueError("Storage key resolves outside storage directory")
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
def _ensure_dir(self):
|
|
51
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
def _compute_usage(self):
|
|
54
|
+
"""Calculate total storage usage by scanning files."""
|
|
55
|
+
total = 0
|
|
56
|
+
if not os.path.isdir(self._dir):
|
|
57
|
+
return 0
|
|
58
|
+
for name in os.listdir(self._dir):
|
|
59
|
+
if name == USAGE_FILE or name.endswith(".tmp"):
|
|
60
|
+
continue
|
|
61
|
+
fpath = os.path.join(self._dir, name)
|
|
62
|
+
if os.path.isfile(fpath):
|
|
63
|
+
total += os.path.getsize(fpath)
|
|
64
|
+
return total
|
|
65
|
+
|
|
66
|
+
def _update_usage(self):
|
|
67
|
+
"""Write current usage to .usage file."""
|
|
68
|
+
usage = self._compute_usage()
|
|
69
|
+
usage_path = os.path.join(self._dir, USAGE_FILE)
|
|
70
|
+
with open(usage_path, "w") as f:
|
|
71
|
+
f.write(str(usage))
|
|
72
|
+
return usage
|
|
73
|
+
|
|
74
|
+
def set(self, key, value):
|
|
75
|
+
"""Store a value. Accepts any JSON-serializable object or string.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
key: Storage key (alphanumeric, dots, underscores, hyphens; max 256 chars)
|
|
79
|
+
value: Value to store (JSON-serialized automatically)
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If key is invalid or value exceeds size limits
|
|
83
|
+
"""
|
|
84
|
+
self._validate_key(key)
|
|
85
|
+
self._ensure_dir()
|
|
86
|
+
|
|
87
|
+
serialized = json.dumps(value)
|
|
88
|
+
size = len(serialized.encode("utf-8"))
|
|
89
|
+
|
|
90
|
+
if size > MAX_VALUE_SIZE:
|
|
91
|
+
raise ValueError(f"Value size ({size} bytes) exceeds maximum of {MAX_VALUE_SIZE} bytes")
|
|
92
|
+
|
|
93
|
+
# Check quota
|
|
94
|
+
existing_size = 0
|
|
95
|
+
existing_path = self._path(key)
|
|
96
|
+
if os.path.isfile(existing_path):
|
|
97
|
+
existing_size = os.path.getsize(existing_path)
|
|
98
|
+
|
|
99
|
+
current_usage = self._compute_usage()
|
|
100
|
+
projected = current_usage - existing_size + size
|
|
101
|
+
if projected > self._max_project_size:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Project storage quota exceeded ({projected} / {self._max_project_size} bytes)"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Atomic write: write to temp, then rename
|
|
107
|
+
fd, tmp_path = tempfile.mkstemp(dir=self._dir, suffix=".tmp")
|
|
108
|
+
try:
|
|
109
|
+
with os.fdopen(fd, "w") as f:
|
|
110
|
+
f.write(serialized)
|
|
111
|
+
os.rename(tmp_path, existing_path)
|
|
112
|
+
except Exception:
|
|
113
|
+
# Clean up temp file on failure
|
|
114
|
+
try:
|
|
115
|
+
os.unlink(tmp_path)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
self._update_usage()
|
|
121
|
+
|
|
122
|
+
def get(self, key, default=None):
|
|
123
|
+
"""Retrieve a value by key.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
key: Storage key
|
|
127
|
+
default: Value to return if key doesn't exist (default: None)
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The stored value (deserialized from JSON), or default if not found
|
|
131
|
+
"""
|
|
132
|
+
self._validate_key(key)
|
|
133
|
+
path = self._path(key)
|
|
134
|
+
if not os.path.isfile(path):
|
|
135
|
+
return default
|
|
136
|
+
|
|
137
|
+
with open(path, "r") as f:
|
|
138
|
+
raw = f.read()
|
|
139
|
+
return json.loads(raw)
|
|
140
|
+
|
|
141
|
+
def delete(self, key):
|
|
142
|
+
"""Delete a value by key.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
key: Storage key
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
True if the key existed and was deleted, False if not found
|
|
149
|
+
"""
|
|
150
|
+
self._validate_key(key)
|
|
151
|
+
path = self._path(key)
|
|
152
|
+
if not os.path.isfile(path):
|
|
153
|
+
return False
|
|
154
|
+
os.unlink(path)
|
|
155
|
+
self._update_usage()
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def exists(self, key):
|
|
159
|
+
"""Check if a key exists.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
key: Storage key
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if the key exists
|
|
166
|
+
"""
|
|
167
|
+
self._validate_key(key)
|
|
168
|
+
return os.path.isfile(self._path(key))
|
|
169
|
+
|
|
170
|
+
def list(self):
|
|
171
|
+
"""List all storage keys.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of key names (strings)
|
|
175
|
+
"""
|
|
176
|
+
if not os.path.isdir(self._dir):
|
|
177
|
+
return []
|
|
178
|
+
keys = []
|
|
179
|
+
for name in sorted(os.listdir(self._dir)):
|
|
180
|
+
if name == USAGE_FILE or name.endswith(".tmp"):
|
|
181
|
+
continue
|
|
182
|
+
fpath = os.path.join(self._dir, name)
|
|
183
|
+
if os.path.isfile(fpath):
|
|
184
|
+
keys.append(name)
|
|
185
|
+
return keys
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Singleton instance
|
|
189
|
+
storage = StorageClient()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---- Human-friendly aliases ----
|
|
193
|
+
|
|
194
|
+
_SENTINEL = object()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def remember(key, value=_SENTINEL):
|
|
198
|
+
"""If one arg: recall a stored value. If two args: store a value.
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
remember("username", "Alice") # stores "Alice"
|
|
202
|
+
remember("username") # returns "Alice"
|
|
203
|
+
"""
|
|
204
|
+
if value is _SENTINEL:
|
|
205
|
+
return storage.get(key)
|
|
206
|
+
storage.set(key, value)
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def forget(key):
|
|
211
|
+
"""Delete a stored value.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
forget("username") # removes "username" from storage
|
|
215
|
+
"""
|
|
216
|
+
storage.delete(key)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Artifact writing utilities for RunIt.
|
|
3
|
+
|
|
4
|
+
Provides helpers to save outputs that users can download:
|
|
5
|
+
- save_artifact(): Save any file (text, binary, JSON)
|
|
6
|
+
- save_dataframe(): Save pandas/polars DataFrames in various formats
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
# Get artifacts directory from environment
|
|
15
|
+
ARTIFACTS_DIR = Path(os.getenv("EL_ARTIFACTS_DIR", "/artifacts"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def save_artifact(filename: str, data: bytes | str) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Save artifact and return path.
|
|
21
|
+
|
|
22
|
+
Artifacts are collected from /artifacts/ and made available for download.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
filename: Name of the file to save
|
|
26
|
+
data: File content (string or bytes)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Absolute path to saved file
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> from runit import save_artifact
|
|
33
|
+
>>>
|
|
34
|
+
>>> # Save text file
|
|
35
|
+
>>> save_artifact("output.txt", "Hello World")
|
|
36
|
+
>>>
|
|
37
|
+
>>> # Save JSON
|
|
38
|
+
>>> import json
|
|
39
|
+
>>> save_artifact("data.json", json.dumps({"key": "value"}))
|
|
40
|
+
>>>
|
|
41
|
+
>>> # Save binary data
|
|
42
|
+
>>> save_artifact("image.png", image_bytes)
|
|
43
|
+
"""
|
|
44
|
+
# Ensure artifacts directory exists
|
|
45
|
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
# Build full path
|
|
48
|
+
path = ARTIFACTS_DIR / filename
|
|
49
|
+
|
|
50
|
+
# Write data
|
|
51
|
+
if isinstance(data, str):
|
|
52
|
+
path.write_text(data, encoding="utf-8")
|
|
53
|
+
else:
|
|
54
|
+
path.write_bytes(data)
|
|
55
|
+
|
|
56
|
+
return str(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_dataframe(
|
|
60
|
+
df: Any, filename: str, format: Literal["csv", "json", "parquet", "excel"] = "csv"
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Save pandas or polars DataFrame as artifact.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
df: DataFrame to save (pandas or polars)
|
|
67
|
+
filename: Name of the file to save
|
|
68
|
+
format: Output format - "csv", "json", "parquet", or "excel"
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Absolute path to saved file
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
>>> import pandas as pd
|
|
75
|
+
>>> from runit import save_dataframe
|
|
76
|
+
>>>
|
|
77
|
+
>>> df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
78
|
+
>>>
|
|
79
|
+
>>> # Save as CSV
|
|
80
|
+
>>> save_dataframe(df, "output.csv")
|
|
81
|
+
>>>
|
|
82
|
+
>>> # Save as JSON
|
|
83
|
+
>>> save_dataframe(df, "output.json", format="json")
|
|
84
|
+
>>>
|
|
85
|
+
>>> # Save as Parquet
|
|
86
|
+
>>> save_dataframe(df, "output.parquet", format="parquet")
|
|
87
|
+
"""
|
|
88
|
+
# Ensure artifacts directory exists
|
|
89
|
+
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# Build full path
|
|
92
|
+
path = ARTIFACTS_DIR / filename
|
|
93
|
+
|
|
94
|
+
# Try to detect DataFrame type and save accordingly
|
|
95
|
+
df_type = type(df).__name__
|
|
96
|
+
|
|
97
|
+
if format == "csv":
|
|
98
|
+
if hasattr(df, "to_csv"):
|
|
99
|
+
df.to_csv(path, index=False)
|
|
100
|
+
else:
|
|
101
|
+
raise TypeError(f"DataFrame type {df_type} does not support CSV export")
|
|
102
|
+
|
|
103
|
+
elif format == "json":
|
|
104
|
+
if hasattr(df, "to_json"):
|
|
105
|
+
df.to_json(path, orient="records", indent=2)
|
|
106
|
+
else:
|
|
107
|
+
raise TypeError(f"DataFrame type {df_type} does not support JSON export")
|
|
108
|
+
|
|
109
|
+
elif format == "parquet":
|
|
110
|
+
if hasattr(df, "to_parquet"):
|
|
111
|
+
df.to_parquet(path)
|
|
112
|
+
else:
|
|
113
|
+
raise TypeError(f"DataFrame type {df_type} does not support Parquet export")
|
|
114
|
+
|
|
115
|
+
elif format == "excel":
|
|
116
|
+
if hasattr(df, "to_excel"):
|
|
117
|
+
df.to_excel(path, index=False)
|
|
118
|
+
else:
|
|
119
|
+
raise TypeError(f"DataFrame type {df_type} does not support Excel export")
|
|
120
|
+
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
123
|
+
|
|
124
|
+
return str(path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_json(filename: str, data: Any) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Save data as JSON artifact.
|
|
130
|
+
|
|
131
|
+
Convenience wrapper around save_artifact() for JSON data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
filename: Name of the file to save
|
|
135
|
+
data: Any JSON-serializable data
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Absolute path to saved file
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
>>> from runit import save_json
|
|
142
|
+
>>>
|
|
143
|
+
>>> save_json("result.json", {"status": "success", "count": 42})
|
|
144
|
+
"""
|
|
145
|
+
json_str = json.dumps(data, indent=2, ensure_ascii=False)
|
|
146
|
+
return save_artifact(filename, json_str)
|