workpeg 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.
- workpeg-0.1.0/LICENSE +21 -0
- workpeg-0.1.0/PKG-INFO +125 -0
- workpeg-0.1.0/README.md +107 -0
- workpeg-0.1.0/pyproject.toml +44 -0
- workpeg-0.1.0/setup.cfg +4 -0
- workpeg-0.1.0/src/workpeg.egg-info/PKG-INFO +125 -0
- workpeg-0.1.0/src/workpeg.egg-info/SOURCES.txt +19 -0
- workpeg-0.1.0/src/workpeg.egg-info/dependency_links.txt +1 -0
- workpeg-0.1.0/src/workpeg.egg-info/entry_points.txt +3 -0
- workpeg-0.1.0/src/workpeg.egg-info/top_level.txt +1 -0
- workpeg-0.1.0/src/workpeg_sdk/__init__.py +5 -0
- workpeg-0.1.0/src/workpeg_sdk/create_new.py +86 -0
- workpeg-0.1.0/src/workpeg_sdk/runtime.py +164 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/__init__.py +0 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/functions/Dockerfile +17 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/functions/LICENSE +21 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/functions/README.md +81 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/functions/app/__init__.py +0 -0
- workpeg-0.1.0/src/workpeg_sdk/templates/functions/app/main.py +2 -0
- workpeg-0.1.0/tests/test_create_new.py +66 -0
- workpeg-0.1.0/tests/test_run_time.py +305 -0
workpeg-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2011-2025 The Bootstrap Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
workpeg-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workpeg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Workpeg function runtime and SDK
|
|
5
|
+
Author-email: Workpeg <support@workpeg.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/workpeg/workpeg
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/workpeg/workpeg
|
|
9
|
+
Keywords: workpeg,serverless,functions,runtime
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Workpeg SDK
|
|
20
|
+
|
|
21
|
+
SDK for building **Workpeg Pegs and Functions**.
|
|
22
|
+
|
|
23
|
+
Currently focused on:
|
|
24
|
+
|
|
25
|
+
- `workpeg-runtime` – function execution runtime
|
|
26
|
+
- `workpeg-new-function` – project scaffolding
|
|
27
|
+
|
|
28
|
+
Frontend features, Peg integrations, push & deployment tooling are coming soon.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install workpeg
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Getting Started
|
|
41
|
+
|
|
42
|
+
### 1. Create a New Function
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
workpeg-new-function my-peg
|
|
46
|
+
cd my-peg
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This generates:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
my-peg/
|
|
53
|
+
app/
|
|
54
|
+
__init__.py
|
|
55
|
+
main.py
|
|
56
|
+
requirements.txt
|
|
57
|
+
Dockerfile
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 2. Implement Your Function
|
|
63
|
+
|
|
64
|
+
Edit `app/main.py`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
def main(context, payload):
|
|
68
|
+
return payload
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every Workpeg Function must define:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def main(context: dict, payload: dict) -> dict
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `context` → execution metadata (provided by Workpeg)
|
|
78
|
+
- `payload` → input data
|
|
79
|
+
- return value → must be JSON serializable
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 3. Run Locally
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
echo '{"context": {}, "payload": {"hello": "world"}}' | workpeg-runtime
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Example output:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{ "status": "success", "result": { "hello": "world" } }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Runtime
|
|
98
|
+
|
|
99
|
+
`workpeg-runtime`:
|
|
100
|
+
|
|
101
|
+
1. Reads JSON from STDIN
|
|
102
|
+
2. Loads `app.main:main`
|
|
103
|
+
3. Executes the function
|
|
104
|
+
4. Writes structured JSON to STDOUT
|
|
105
|
+
|
|
106
|
+
Override entrypoint:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
FUNCTION_ENTRYPOINT="module.path:function_name" workpeg-runtime
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Roadmap
|
|
115
|
+
|
|
116
|
+
- Peg-level integrations
|
|
117
|
+
- Frontend features (Streamlit/Reflex style)
|
|
118
|
+
- Push & deployment tooling
|
|
119
|
+
- Hosted execution
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
📘 Documentation coming soon.
|
|
124
|
+
|
|
125
|
+
MIT License
|
workpeg-0.1.0/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Workpeg SDK
|
|
2
|
+
|
|
3
|
+
SDK for building **Workpeg Pegs and Functions**.
|
|
4
|
+
|
|
5
|
+
Currently focused on:
|
|
6
|
+
|
|
7
|
+
- `workpeg-runtime` – function execution runtime
|
|
8
|
+
- `workpeg-new-function` – project scaffolding
|
|
9
|
+
|
|
10
|
+
Frontend features, Peg integrations, push & deployment tooling are coming soon.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install workpeg
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Getting Started
|
|
23
|
+
|
|
24
|
+
### 1. Create a New Function
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
workpeg-new-function my-peg
|
|
28
|
+
cd my-peg
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This generates:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
my-peg/
|
|
35
|
+
app/
|
|
36
|
+
__init__.py
|
|
37
|
+
main.py
|
|
38
|
+
requirements.txt
|
|
39
|
+
Dockerfile
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### 2. Implement Your Function
|
|
45
|
+
|
|
46
|
+
Edit `app/main.py`:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
def main(context, payload):
|
|
50
|
+
return payload
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every Workpeg Function must define:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
def main(context: dict, payload: dict) -> dict
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `context` → execution metadata (provided by Workpeg)
|
|
60
|
+
- `payload` → input data
|
|
61
|
+
- return value → must be JSON serializable
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### 3. Run Locally
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
echo '{"context": {}, "payload": {"hello": "world"}}' | workpeg-runtime
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Example output:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{ "status": "success", "result": { "hello": "world" } }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Runtime
|
|
80
|
+
|
|
81
|
+
`workpeg-runtime`:
|
|
82
|
+
|
|
83
|
+
1. Reads JSON from STDIN
|
|
84
|
+
2. Loads `app.main:main`
|
|
85
|
+
3. Executes the function
|
|
86
|
+
4. Writes structured JSON to STDOUT
|
|
87
|
+
|
|
88
|
+
Override entrypoint:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
FUNCTION_ENTRYPOINT="module.path:function_name" workpeg-runtime
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Roadmap
|
|
97
|
+
|
|
98
|
+
- Peg-level integrations
|
|
99
|
+
- Frontend features (Streamlit/Reflex style)
|
|
100
|
+
- Push & deployment tooling
|
|
101
|
+
- Hosted execution
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
📘 Documentation coming soon.
|
|
106
|
+
|
|
107
|
+
MIT License
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "workpeg"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Workpeg function runtime and SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Workpeg", email = "support@workpeg.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["workpeg", "serverless", "functions", "runtime"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
# add any SDK runtime deps here, keep it minimal
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://gitlab.com/workpeg/workpeg"
|
|
29
|
+
Repository = "https://gitlab.com/workpeg/workpeg"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
# This creates the console script "workpeg-runtime"
|
|
33
|
+
workpeg-runtime = "workpeg_sdk.runtime:main"
|
|
34
|
+
workpeg-new-function = "workpeg_sdk.create_new:main"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools]
|
|
37
|
+
package-dir = {"" = "src"}
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
workpeg_sdk = ["templates/**"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
workpeg-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workpeg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Workpeg function runtime and SDK
|
|
5
|
+
Author-email: Workpeg <support@workpeg.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/workpeg/workpeg
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/workpeg/workpeg
|
|
9
|
+
Keywords: workpeg,serverless,functions,runtime
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Workpeg SDK
|
|
20
|
+
|
|
21
|
+
SDK for building **Workpeg Pegs and Functions**.
|
|
22
|
+
|
|
23
|
+
Currently focused on:
|
|
24
|
+
|
|
25
|
+
- `workpeg-runtime` – function execution runtime
|
|
26
|
+
- `workpeg-new-function` – project scaffolding
|
|
27
|
+
|
|
28
|
+
Frontend features, Peg integrations, push & deployment tooling are coming soon.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install workpeg
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Getting Started
|
|
41
|
+
|
|
42
|
+
### 1. Create a New Function
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
workpeg-new-function my-peg
|
|
46
|
+
cd my-peg
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This generates:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
my-peg/
|
|
53
|
+
app/
|
|
54
|
+
__init__.py
|
|
55
|
+
main.py
|
|
56
|
+
requirements.txt
|
|
57
|
+
Dockerfile
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 2. Implement Your Function
|
|
63
|
+
|
|
64
|
+
Edit `app/main.py`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
def main(context, payload):
|
|
68
|
+
return payload
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every Workpeg Function must define:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
def main(context: dict, payload: dict) -> dict
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `context` → execution metadata (provided by Workpeg)
|
|
78
|
+
- `payload` → input data
|
|
79
|
+
- return value → must be JSON serializable
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 3. Run Locally
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
echo '{"context": {}, "payload": {"hello": "world"}}' | workpeg-runtime
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Example output:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{ "status": "success", "result": { "hello": "world" } }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Runtime
|
|
98
|
+
|
|
99
|
+
`workpeg-runtime`:
|
|
100
|
+
|
|
101
|
+
1. Reads JSON from STDIN
|
|
102
|
+
2. Loads `app.main:main`
|
|
103
|
+
3. Executes the function
|
|
104
|
+
4. Writes structured JSON to STDOUT
|
|
105
|
+
|
|
106
|
+
Override entrypoint:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
FUNCTION_ENTRYPOINT="module.path:function_name" workpeg-runtime
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Roadmap
|
|
115
|
+
|
|
116
|
+
- Peg-level integrations
|
|
117
|
+
- Frontend features (Streamlit/Reflex style)
|
|
118
|
+
- Push & deployment tooling
|
|
119
|
+
- Hosted execution
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
📘 Documentation coming soon.
|
|
124
|
+
|
|
125
|
+
MIT License
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/workpeg.egg-info/PKG-INFO
|
|
5
|
+
src/workpeg.egg-info/SOURCES.txt
|
|
6
|
+
src/workpeg.egg-info/dependency_links.txt
|
|
7
|
+
src/workpeg.egg-info/entry_points.txt
|
|
8
|
+
src/workpeg.egg-info/top_level.txt
|
|
9
|
+
src/workpeg_sdk/__init__.py
|
|
10
|
+
src/workpeg_sdk/create_new.py
|
|
11
|
+
src/workpeg_sdk/runtime.py
|
|
12
|
+
src/workpeg_sdk/templates/__init__.py
|
|
13
|
+
src/workpeg_sdk/templates/functions/Dockerfile
|
|
14
|
+
src/workpeg_sdk/templates/functions/LICENSE
|
|
15
|
+
src/workpeg_sdk/templates/functions/README.md
|
|
16
|
+
src/workpeg_sdk/templates/functions/app/__init__.py
|
|
17
|
+
src/workpeg_sdk/templates/functions/app/main.py
|
|
18
|
+
tests/test_create_new.py
|
|
19
|
+
tests/test_run_time.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
workpeg_sdk
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# src/workpeg_sdk/create_new.py
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import importlib.resources as pkg_resources
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TemplateError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _copy_tree(src_root: Path, dst_root: Path, *, force: bool) -> None:
|
|
14
|
+
"""
|
|
15
|
+
Recursively copy src_root directory contents into dst_root.
|
|
16
|
+
"""
|
|
17
|
+
if not src_root.exists():
|
|
18
|
+
raise TemplateError(f"Template root not found: {src_root}")
|
|
19
|
+
|
|
20
|
+
dst_root.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
for item in src_root.rglob("*"):
|
|
23
|
+
rel = item.relative_to(src_root)
|
|
24
|
+
dst = dst_root / rel
|
|
25
|
+
|
|
26
|
+
if item.is_dir():
|
|
27
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if dst.exists() and not force:
|
|
31
|
+
raise TemplateError(f"Target file exists: {dst} (use --force)")
|
|
32
|
+
|
|
33
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
shutil.copy2(item, dst)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_new_project(target_dir: str, *, force: bool = False) -> Path:
|
|
38
|
+
"""
|
|
39
|
+
Copy the function template from workpeg_sdk/templates/functions/*
|
|
40
|
+
into target_dir.
|
|
41
|
+
"""
|
|
42
|
+
target = Path(target_dir).expanduser().resolve()
|
|
43
|
+
|
|
44
|
+
# Get a Traversable pointing at .../workpeg_sdk/templates/functions
|
|
45
|
+
traversable = (
|
|
46
|
+
pkg_resources.files("workpeg_sdk") / "templates" / "functions"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# as_file() gives us a real filesystem path even if the package
|
|
50
|
+
# is loaded from a wheel/zip. This completely avoids the old
|
|
51
|
+
# "not available as a filesystem path" issue.
|
|
52
|
+
with pkg_resources.as_file(traversable) as src_path:
|
|
53
|
+
src_root = Path(src_path)
|
|
54
|
+
_copy_tree(src_root, target, force=force)
|
|
55
|
+
|
|
56
|
+
return target
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
60
|
+
p = argparse.ArgumentParser(
|
|
61
|
+
prog="workpeg-new-function",
|
|
62
|
+
description="Create a new Workpeg function"
|
|
63
|
+
" project from the built-in template.",
|
|
64
|
+
)
|
|
65
|
+
p.add_argument(
|
|
66
|
+
"path",
|
|
67
|
+
help="Target directory to create/populate (e.g. ./my-function).",
|
|
68
|
+
)
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"--force",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Overwrite existing files in the target directory.",
|
|
73
|
+
)
|
|
74
|
+
return p
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None:
|
|
78
|
+
parser = build_parser()
|
|
79
|
+
args = parser.parse_args()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
out = create_new_project(args.path, force=args.force)
|
|
83
|
+
print(str(out))
|
|
84
|
+
except TemplateError as e:
|
|
85
|
+
print(f"ERROR: {e}", file=os.sys.stderr)
|
|
86
|
+
raise SystemExit(2)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# src/workpeg_sdk/runtime.py
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from typing import Any, Callable, Dict, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_ENTRYPOINT = "app.main:main"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FunctionRuntimeError(Exception):
|
|
15
|
+
"""Custom exception for runtime-level errors."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_entrypoint(entry: str) -> Tuple[str, str]:
|
|
20
|
+
"""
|
|
21
|
+
Parse 'module.path:func_name' into (module, func).
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
module_name, func_name = entry.split(":", 1)
|
|
25
|
+
module_name = module_name.strip()
|
|
26
|
+
func_name = func_name.strip()
|
|
27
|
+
if not module_name or not func_name:
|
|
28
|
+
raise ValueError
|
|
29
|
+
return module_name, func_name
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise FunctionRuntimeError(
|
|
32
|
+
f"Invalid FUNCTION_ENTRYPOINT format: '{entry}'. "
|
|
33
|
+
"Expected 'module.path:func_name'."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_cwd_on_syspath() -> None:
|
|
38
|
+
"""
|
|
39
|
+
Ensure current working directory is at the front of sys.path.
|
|
40
|
+
|
|
41
|
+
This is critical when running via a console script installed by pip,
|
|
42
|
+
because sys.path[0] is the script's directory (e.g. venv/bin),
|
|
43
|
+
NOT the current working directory.
|
|
44
|
+
"""
|
|
45
|
+
cwd = os.getcwd()
|
|
46
|
+
if cwd not in sys.path:
|
|
47
|
+
sys.path.insert(0, cwd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_function() -> Callable[[Dict[str, Any], Dict[str, Any]], Any]:
|
|
51
|
+
"""
|
|
52
|
+
Load the user function based on env FUNCTION_ENTRYPOINT or default.
|
|
53
|
+
"""
|
|
54
|
+
_ensure_cwd_on_syspath()
|
|
55
|
+
|
|
56
|
+
entry = os.getenv("FUNCTION_ENTRYPOINT", DEFAULT_ENTRYPOINT)
|
|
57
|
+
module_name, func_name = parse_entrypoint(entry)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
module = importlib.import_module(module_name)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise FunctionRuntimeError(
|
|
63
|
+
f"Failed to import module '{module_name}' from '{entry}': {exc}"
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
fn = getattr(module, func_name)
|
|
68
|
+
except AttributeError as exc:
|
|
69
|
+
raise FunctionRuntimeError(
|
|
70
|
+
f"Module '{module_name}'"
|
|
71
|
+
f" does not define '{func_name}' from '{entry}'."
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
if not callable(fn):
|
|
75
|
+
raise FunctionRuntimeError(
|
|
76
|
+
f"'{entry}' is not callable. Expected a function."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_request() -> Dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Read a single JSON object from stdin.
|
|
85
|
+
Expected format: {"context": {...}, "payload": {...}}
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
raw = sys.stdin.read()
|
|
89
|
+
if not raw.strip():
|
|
90
|
+
raise FunctionRuntimeError("No input received on stdin.")
|
|
91
|
+
data = json.loads(raw)
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise FunctionRuntimeError("Input JSON must be a JSON object.")
|
|
94
|
+
return data
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise FunctionRuntimeError(f"Invalid JSON input: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_once() -> int:
|
|
100
|
+
"""
|
|
101
|
+
Run a single invocation:
|
|
102
|
+
- load function
|
|
103
|
+
- read request
|
|
104
|
+
- call function
|
|
105
|
+
- emit JSON result
|
|
106
|
+
|
|
107
|
+
Returns exit code (0 on success, 1 on error).
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
fn = load_function()
|
|
111
|
+
request = read_request()
|
|
112
|
+
|
|
113
|
+
context = request.get("context", {})
|
|
114
|
+
payload = request.get("payload", {})
|
|
115
|
+
|
|
116
|
+
if not isinstance(context, dict):
|
|
117
|
+
raise FunctionRuntimeError(
|
|
118
|
+
"Field 'context' must be a JSON object.")
|
|
119
|
+
if not isinstance(payload, dict):
|
|
120
|
+
raise FunctionRuntimeError(
|
|
121
|
+
"Field 'payload' must be a JSON object.")
|
|
122
|
+
|
|
123
|
+
result = fn(context, payload)
|
|
124
|
+
|
|
125
|
+
output = {
|
|
126
|
+
"status": "success",
|
|
127
|
+
"result": result,
|
|
128
|
+
}
|
|
129
|
+
print(json.dumps(output))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
except FunctionRuntimeError as e:
|
|
133
|
+
# Runtime / wiring error
|
|
134
|
+
err_output = {
|
|
135
|
+
"status": "error",
|
|
136
|
+
"error_type": "runtime_error",
|
|
137
|
+
"error": str(e),
|
|
138
|
+
}
|
|
139
|
+
# Send JSON to stdout (for the host)
|
|
140
|
+
# and more detail to stderr (for logs)
|
|
141
|
+
print(json.dumps(err_output))
|
|
142
|
+
print(f"[workpeg-runtime] runtime_error: {e}", file=sys.stderr)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
# User code error
|
|
147
|
+
trace = traceback.format_exc()
|
|
148
|
+
err_output = {
|
|
149
|
+
"status": "error",
|
|
150
|
+
"error_type": "user_error",
|
|
151
|
+
"error": str(e),
|
|
152
|
+
"trace": trace,
|
|
153
|
+
}
|
|
154
|
+
print(json.dumps(err_output))
|
|
155
|
+
print(f"[workpeg-runtime] user_error: {e}\n{trace}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
"""
|
|
161
|
+
Console script entrypoint for 'workpeg-runtime'.
|
|
162
|
+
"""
|
|
163
|
+
exit_code = run_once()
|
|
164
|
+
sys.exit(exit_code)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
ENV PYTHONDONTWRITEBYTECODE=1
|
|
4
|
+
ENV PYTHONUNBUFFERED=1
|
|
5
|
+
|
|
6
|
+
RUN useradd -m -u 10001 appuser
|
|
7
|
+
|
|
8
|
+
WORKDIR /app
|
|
9
|
+
|
|
10
|
+
COPY requirements.txt /app/requirements.txt
|
|
11
|
+
RUN pip install --no-cache-dir -r /app/requirements.txt
|
|
12
|
+
|
|
13
|
+
COPY app /app/app
|
|
14
|
+
|
|
15
|
+
USER appuser
|
|
16
|
+
|
|
17
|
+
ENTRYPOINT ["workpeg-runtime"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2011-2025 The Bootstrap Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Workpeg Function Project
|
|
2
|
+
|
|
3
|
+
This directory was created using **Workpeg SDK**.
|
|
4
|
+
|
|
5
|
+
It contains everything you need to build and run a single **Workpeg Function**, which can later be attached to a Peg inside Workpeg.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Project Structure
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
app/
|
|
13
|
+
__init__.py
|
|
14
|
+
main.py # Your function: def main(context, payload) -> dict
|
|
15
|
+
requirements.txt # Python dependencies (must include workpeg)
|
|
16
|
+
Dockerfile # Example container runtime using workpeg-runtime
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Function Contract
|
|
22
|
+
|
|
23
|
+
Your function **must** define:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
def main(context: dict, payload: dict) -> dict:
|
|
27
|
+
...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `context` – execution metadata injected by Workpeg (user, peg, execution id, etc.)
|
|
31
|
+
- `payload` – input data sent by the caller
|
|
32
|
+
- return value – must be JSON serializable
|
|
33
|
+
|
|
34
|
+
By default, the runtime looks for `app.main:main`.
|
|
35
|
+
You can change this using the `FUNCTION_ENTRYPOINT` environment variable:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export FUNCTION_ENTRYPOINT="module.path:function_name"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Running Locally
|
|
44
|
+
|
|
45
|
+
Make sure `workpeg` is installed (or use the generated `requirements.txt`):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -r requirements.txt
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then run the function:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
echo '{"context": {}, "payload": {"hello": "world"}}' | workpeg-runtime
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Expected output:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{ "status": "success", "result": { "hello": "world" } }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Docker (Optional)
|
|
66
|
+
|
|
67
|
+
You can build and run the container using the included `Dockerfile`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
docker build -t my-workpeg-function .
|
|
71
|
+
echo '{"context": {}, "payload": {"hello": "world"}}' | docker run --rm -i my-workpeg-function
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## More
|
|
77
|
+
|
|
78
|
+
This template is part of the official Workpeg SDK.
|
|
79
|
+
|
|
80
|
+
Repo: [https://gitlab.com/workpeg/workpeg](https://gitlab.com/workpeg/workpeg)
|
|
81
|
+
Full documentation and Peg integration guides are coming soon.
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import importlib.resources as pkg_resources
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_create_new_copies_template(tmp_path):
|
|
6
|
+
project_dir = tmp_path / "my-func"
|
|
7
|
+
|
|
8
|
+
# Run the console script
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["workpeg-new-function", str(project_dir)],
|
|
11
|
+
text=True,
|
|
12
|
+
capture_output=True,
|
|
13
|
+
cwd=tmp_path,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
assert result.returncode == 0
|
|
17
|
+
assert project_dir.exists()
|
|
18
|
+
|
|
19
|
+
# Confirm expected files exist
|
|
20
|
+
assert (project_dir / "app" / "__init__.py").exists()
|
|
21
|
+
assert (project_dir / "app" / "main.py").exists()
|
|
22
|
+
|
|
23
|
+
# Confirm main.py content matches template main.py
|
|
24
|
+
template_pkg = "workpeg_sdk.templates.functions.app"
|
|
25
|
+
template_main = pkg_resources.files(
|
|
26
|
+
template_pkg).joinpath("main.py").read_text()
|
|
27
|
+
|
|
28
|
+
created_main = (project_dir / "app" / "main.py").read_text()
|
|
29
|
+
assert created_main == template_main
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_create_new_refuses_overwrite_without_force(tmp_path):
|
|
33
|
+
project_dir = tmp_path / "my-func"
|
|
34
|
+
project_dir.mkdir()
|
|
35
|
+
(project_dir / "app").mkdir()
|
|
36
|
+
(project_dir / "app" / "main.py").write_text("existing")
|
|
37
|
+
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
["workpeg-new-function", str(project_dir)],
|
|
40
|
+
text=True,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
cwd=tmp_path,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert result.returncode == 2
|
|
46
|
+
assert "use --force" in (result.stderr or "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_create_new_overwrites_with_force(tmp_path):
|
|
50
|
+
project_dir = tmp_path / "my-func"
|
|
51
|
+
project_dir.mkdir(parents=True)
|
|
52
|
+
(project_dir / "app").mkdir()
|
|
53
|
+
(project_dir / "app" / "main.py").write_text("existing")
|
|
54
|
+
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["workpeg-new-function", str(project_dir), "--force"],
|
|
57
|
+
text=True,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
cwd=tmp_path,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert result.returncode == 0
|
|
63
|
+
|
|
64
|
+
# Ensure template main.py replaced the old content
|
|
65
|
+
created_main = (project_dir / "app" / "main.py").read_text()
|
|
66
|
+
assert created_main != "existing"
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# tests/test_run_time.py
|
|
2
|
+
|
|
3
|
+
import importlib.resources as pkg_resources
|
|
4
|
+
import subprocess
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from workpeg_sdk.runtime import (
|
|
13
|
+
FunctionRuntimeError,
|
|
14
|
+
parse_entrypoint,
|
|
15
|
+
load_function,
|
|
16
|
+
read_request,
|
|
17
|
+
run_once,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ------------------------------
|
|
22
|
+
# Helpers
|
|
23
|
+
# ------------------------------
|
|
24
|
+
|
|
25
|
+
def _clear_app_modules():
|
|
26
|
+
"""Remove any cached 'app' modules so imports see the new temp package."""
|
|
27
|
+
to_delete = [name for name in sys.modules if name ==
|
|
28
|
+
"app" or name.startswith("app.")]
|
|
29
|
+
for name in to_delete:
|
|
30
|
+
del sys.modules[name]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_simple_app(tmp_path: Path, body: str | None = None):
|
|
34
|
+
"""
|
|
35
|
+
Create app/main.py in tmp_path.
|
|
36
|
+
Default main just returns payload.
|
|
37
|
+
"""
|
|
38
|
+
if body is None:
|
|
39
|
+
body = """
|
|
40
|
+
def main(context, payload):
|
|
41
|
+
return payload
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
app_dir = tmp_path / "app"
|
|
45
|
+
app_dir.mkdir()
|
|
46
|
+
(app_dir / "__init__.py").write_text("")
|
|
47
|
+
(app_dir / "main.py").write_text(body)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ------------------------------
|
|
51
|
+
# parse_entrypoint tests
|
|
52
|
+
# ------------------------------
|
|
53
|
+
|
|
54
|
+
def test_parse_entrypoint_valid():
|
|
55
|
+
module, func = parse_entrypoint("my.module:handler")
|
|
56
|
+
assert module == "my.module"
|
|
57
|
+
assert func == "handler"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parse_entrypoint_invalid_raises():
|
|
61
|
+
with pytest.raises(FunctionRuntimeError):
|
|
62
|
+
parse_entrypoint("no-colon")
|
|
63
|
+
with pytest.raises(FunctionRuntimeError):
|
|
64
|
+
parse_entrypoint(":missing_module")
|
|
65
|
+
with pytest.raises(FunctionRuntimeError):
|
|
66
|
+
parse_entrypoint("missing_func:")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ------------------------------
|
|
70
|
+
# load_function tests
|
|
71
|
+
# ------------------------------
|
|
72
|
+
|
|
73
|
+
def test_load_function_uses_default_entrypoint(tmp_path, monkeypatch):
|
|
74
|
+
"""
|
|
75
|
+
Ensure load_function() imports app.main:main
|
|
76
|
+
from the current working directory.
|
|
77
|
+
"""
|
|
78
|
+
_clear_app_modules()
|
|
79
|
+
_write_simple_app(tmp_path)
|
|
80
|
+
|
|
81
|
+
monkeypatch.chdir(tmp_path)
|
|
82
|
+
monkeypatch.delenv("FUNCTION_ENTRYPOINT", raising=False)
|
|
83
|
+
|
|
84
|
+
fn = load_function()
|
|
85
|
+
assert callable(fn)
|
|
86
|
+
result = fn({"ctx": True}, {"hello": "world"})
|
|
87
|
+
# main just returns payload
|
|
88
|
+
assert result == {"hello": "world"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_load_function_invalid_module_raises(monkeypatch):
|
|
92
|
+
_clear_app_modules()
|
|
93
|
+
monkeypatch.setenv("FUNCTION_ENTRYPOINT", "nonexistent.module:main")
|
|
94
|
+
|
|
95
|
+
with pytest.raises(FunctionRuntimeError) as exc:
|
|
96
|
+
load_function()
|
|
97
|
+
assert "Failed to import module" in str(exc.value)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_load_function_invalid_attribute_raises(tmp_path, monkeypatch):
|
|
101
|
+
"""
|
|
102
|
+
Module exists, but function attribute does not.
|
|
103
|
+
"""
|
|
104
|
+
_clear_app_modules()
|
|
105
|
+
|
|
106
|
+
mod_dir = tmp_path / "app"
|
|
107
|
+
mod_dir.mkdir()
|
|
108
|
+
(mod_dir / "__init__.py").write_text("")
|
|
109
|
+
(mod_dir / "main.py").write_text(
|
|
110
|
+
"""
|
|
111
|
+
FOO = 123
|
|
112
|
+
"""
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
monkeypatch.chdir(tmp_path)
|
|
116
|
+
monkeypatch.setenv("FUNCTION_ENTRYPOINT", "app.main:missing_fn")
|
|
117
|
+
|
|
118
|
+
with pytest.raises(FunctionRuntimeError) as exc:
|
|
119
|
+
load_function()
|
|
120
|
+
assert "does not define 'missing_fn'" in str(exc.value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_load_function_not_callable_raises(tmp_path, monkeypatch):
|
|
124
|
+
"""
|
|
125
|
+
Attribute exists but is not callable.
|
|
126
|
+
"""
|
|
127
|
+
_clear_app_modules()
|
|
128
|
+
|
|
129
|
+
mod_dir = tmp_path / "app"
|
|
130
|
+
mod_dir.mkdir()
|
|
131
|
+
(mod_dir / "__init__.py").write_text("")
|
|
132
|
+
(mod_dir / "main.py").write_text(
|
|
133
|
+
"""
|
|
134
|
+
main = 123 # not callable
|
|
135
|
+
"""
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
monkeypatch.chdir(tmp_path)
|
|
139
|
+
monkeypatch.delenv("FUNCTION_ENTRYPOINT", raising=False)
|
|
140
|
+
|
|
141
|
+
with pytest.raises(FunctionRuntimeError) as exc:
|
|
142
|
+
load_function()
|
|
143
|
+
assert "is not callable" in str(exc.value)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ------------------------------
|
|
147
|
+
# read_request tests
|
|
148
|
+
# ------------------------------
|
|
149
|
+
|
|
150
|
+
def test_read_request_valid(monkeypatch):
|
|
151
|
+
payload = {"context": {"a": 1}, "payload": {"b": 2}}
|
|
152
|
+
stdin = io.StringIO(json.dumps(payload))
|
|
153
|
+
monkeypatch.setattr(sys, "stdin", stdin)
|
|
154
|
+
|
|
155
|
+
result = read_request()
|
|
156
|
+
assert result == payload
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_read_request_empty_raises(monkeypatch):
|
|
160
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
161
|
+
with pytest.raises(FunctionRuntimeError) as exc:
|
|
162
|
+
read_request()
|
|
163
|
+
assert "No input received on stdin" in str(exc.value)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_read_request_invalid_json_raises(monkeypatch):
|
|
167
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("{not-json}"))
|
|
168
|
+
with pytest.raises(FunctionRuntimeError) as exc:
|
|
169
|
+
read_request()
|
|
170
|
+
assert "Invalid JSON input" in str(exc.value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ------------------------------
|
|
174
|
+
# run_once tests
|
|
175
|
+
# ------------------------------
|
|
176
|
+
|
|
177
|
+
def test_run_once_success(tmp_path, monkeypatch, capsys):
|
|
178
|
+
"""
|
|
179
|
+
Full path: load_function + read_request + user main() succeed.
|
|
180
|
+
"""
|
|
181
|
+
_clear_app_modules()
|
|
182
|
+
_write_simple_app(tmp_path)
|
|
183
|
+
|
|
184
|
+
monkeypatch.chdir(tmp_path)
|
|
185
|
+
monkeypatch.delenv("FUNCTION_ENTRYPOINT", raising=False)
|
|
186
|
+
|
|
187
|
+
input_data = {"context": {"user": 1}, "payload": {"x": 10}}
|
|
188
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(input_data)))
|
|
189
|
+
|
|
190
|
+
exit_code = run_once()
|
|
191
|
+
captured = capsys.readouterr()
|
|
192
|
+
|
|
193
|
+
assert exit_code == 0
|
|
194
|
+
stdout_json = json.loads(captured.out)
|
|
195
|
+
assert stdout_json["status"] == "success"
|
|
196
|
+
# main returns payload unchanged
|
|
197
|
+
assert stdout_json["result"] == input_data["payload"]
|
|
198
|
+
assert "[workpeg-runtime]" not in captured.err
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_run_once_runtime_error_invalid_context(tmp_path, monkeypatch, capsys):
|
|
202
|
+
"""
|
|
203
|
+
If 'context' is not a dict, run_once should emit a runtime_error.
|
|
204
|
+
"""
|
|
205
|
+
_clear_app_modules()
|
|
206
|
+
_write_simple_app(tmp_path)
|
|
207
|
+
|
|
208
|
+
monkeypatch.chdir(tmp_path)
|
|
209
|
+
monkeypatch.delenv("FUNCTION_ENTRYPOINT", raising=False)
|
|
210
|
+
|
|
211
|
+
# context is a string, not an object
|
|
212
|
+
input_data = {"context": "not-a-dict", "payload": {"x": 10}}
|
|
213
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(input_data)))
|
|
214
|
+
|
|
215
|
+
exit_code = run_once()
|
|
216
|
+
captured = capsys.readouterr()
|
|
217
|
+
|
|
218
|
+
assert exit_code == 1
|
|
219
|
+
|
|
220
|
+
stdout_json = json.loads(captured.out)
|
|
221
|
+
assert stdout_json["status"] == "error"
|
|
222
|
+
assert stdout_json["error_type"] == "runtime_error"
|
|
223
|
+
assert "context' must be a JSON object" in stdout_json["error"]
|
|
224
|
+
|
|
225
|
+
assert "[workpeg-runtime] runtime_error" in captured.err
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_run_once_user_error_from_function(tmp_path, monkeypatch, capsys):
|
|
229
|
+
"""
|
|
230
|
+
If user function raises, run_once should emit user_error.
|
|
231
|
+
"""
|
|
232
|
+
_clear_app_modules()
|
|
233
|
+
|
|
234
|
+
# main will raise ValueError
|
|
235
|
+
_write_simple_app(
|
|
236
|
+
tmp_path,
|
|
237
|
+
body="""
|
|
238
|
+
def main(context, payload):
|
|
239
|
+
raise ValueError("boom")
|
|
240
|
+
""",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
monkeypatch.chdir(tmp_path)
|
|
244
|
+
monkeypatch.delenv("FUNCTION_ENTRYPOINT", raising=False)
|
|
245
|
+
|
|
246
|
+
input_data = {"context": {}, "payload": {"x": 1}}
|
|
247
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(input_data)))
|
|
248
|
+
|
|
249
|
+
exit_code = run_once()
|
|
250
|
+
captured = capsys.readouterr()
|
|
251
|
+
|
|
252
|
+
assert exit_code == 1
|
|
253
|
+
|
|
254
|
+
stdout_json = json.loads(captured.out)
|
|
255
|
+
assert stdout_json["status"] == "error"
|
|
256
|
+
assert stdout_json["error_type"] == "user_error"
|
|
257
|
+
assert "boom" in stdout_json["error"]
|
|
258
|
+
assert "trace" in stdout_json
|
|
259
|
+
assert "ValueError" in stdout_json["trace"]
|
|
260
|
+
|
|
261
|
+
assert "[workpeg-runtime] user_error" in captured.err
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_workpeg_runtime_template(tmp_path):
|
|
265
|
+
"""
|
|
266
|
+
Test using SDK template function copied from package templates.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
# --- 1. Create app directory ---
|
|
270
|
+
app_dir = tmp_path / "app"
|
|
271
|
+
app_dir.mkdir()
|
|
272
|
+
|
|
273
|
+
# Make it a package
|
|
274
|
+
(app_dir / "__init__.py").write_text("")
|
|
275
|
+
|
|
276
|
+
# --- 2. Load template from installed package ---
|
|
277
|
+
template_pkg = "workpeg_sdk.templates.functions.app"
|
|
278
|
+
|
|
279
|
+
with pkg_resources.files(template_pkg).joinpath("main.py").open("r") as f:
|
|
280
|
+
template_code = f.read()
|
|
281
|
+
|
|
282
|
+
# --- 3. Write template main.py into temp app ---
|
|
283
|
+
(app_dir / "main.py").write_text(template_code)
|
|
284
|
+
|
|
285
|
+
# --- 4. Prepare input ---
|
|
286
|
+
input_payload = {
|
|
287
|
+
"context": {"user_id": 123},
|
|
288
|
+
"payload": {"hello": "world", "x": 42},
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# --- 5. Run CLI ---
|
|
292
|
+
result = subprocess.run(
|
|
293
|
+
["workpeg-runtime"],
|
|
294
|
+
input=json.dumps(input_payload),
|
|
295
|
+
text=True,
|
|
296
|
+
capture_output=True,
|
|
297
|
+
cwd=tmp_path,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
assert result.returncode == 0
|
|
301
|
+
|
|
302
|
+
output = json.loads(result.stdout)
|
|
303
|
+
|
|
304
|
+
assert output["status"] == "success"
|
|
305
|
+
assert output["result"] == input_payload["payload"]
|