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.
@@ -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)