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 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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [console_scripts]
2
+ workpeg-new-function = workpeg_sdk.create_new:main
3
+ workpeg-runtime = workpeg_sdk.runtime:main
@@ -0,0 +1 @@
1
+ workpeg_sdk
@@ -0,0 +1,5 @@
1
+ # src/workpeg_sdk/__init__.py
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -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.
@@ -0,0 +1,2 @@
1
+ def main(context, payload):
2
+ return payload
@@ -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"]