workpeg 0.1.4__tar.gz → 0.2.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.4/src/workpeg.egg-info → workpeg-0.2.0}/PKG-INFO +1 -1
- {workpeg-0.1.4 → workpeg-0.2.0}/pyproject.toml +1 -1
- {workpeg-0.1.4 → workpeg-0.2.0/src/workpeg.egg-info}/PKG-INFO +1 -1
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/SOURCES.txt +7 -1
- workpeg-0.2.0/src/workpeg_sdk/build.py +34 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/cli.py +37 -14
- workpeg-0.2.0/src/workpeg_sdk/config.py +39 -0
- workpeg-0.2.0/src/workpeg_sdk/run.py +60 -0
- workpeg-0.2.0/src/workpeg_sdk/runtime.py +236 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/Dockerfile +1 -1
- workpeg-0.2.0/tests/test_build.py +76 -0
- workpeg-0.2.0/tests/test_cli.py +124 -0
- workpeg-0.2.0/tests/test_run.py +124 -0
- workpeg-0.2.0/tests/test_runtime.py +208 -0
- workpeg-0.1.4/src/workpeg_sdk/runtime.py +0 -173
- workpeg-0.1.4/tests/test_run_time.py +0 -305
- {workpeg-0.1.4 → workpeg-0.2.0}/LICENSE +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/MANIFEST.in +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/README.md +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/setup.cfg +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/entry_points.txt +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/requires.txt +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/top_level.txt +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/__init__.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/create_new.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/submit.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/__init__.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/LICENSE +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/README.md +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/app/__init__.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/app/main.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/requirements.txt +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/tests/test_create_new.py +0 -0
- {workpeg-0.1.4 → workpeg-0.2.0}/tests/test_submit.py +0 -0
|
@@ -9,8 +9,11 @@ src/workpeg.egg-info/entry_points.txt
|
|
|
9
9
|
src/workpeg.egg-info/requires.txt
|
|
10
10
|
src/workpeg.egg-info/top_level.txt
|
|
11
11
|
src/workpeg_sdk/__init__.py
|
|
12
|
+
src/workpeg_sdk/build.py
|
|
12
13
|
src/workpeg_sdk/cli.py
|
|
14
|
+
src/workpeg_sdk/config.py
|
|
13
15
|
src/workpeg_sdk/create_new.py
|
|
16
|
+
src/workpeg_sdk/run.py
|
|
14
17
|
src/workpeg_sdk/runtime.py
|
|
15
18
|
src/workpeg_sdk/submit.py
|
|
16
19
|
src/workpeg_sdk/templates/__init__.py
|
|
@@ -20,6 +23,9 @@ src/workpeg_sdk/templates/functions/README.md
|
|
|
20
23
|
src/workpeg_sdk/templates/functions/requirements.txt
|
|
21
24
|
src/workpeg_sdk/templates/functions/app/__init__.py
|
|
22
25
|
src/workpeg_sdk/templates/functions/app/main.py
|
|
26
|
+
tests/test_build.py
|
|
27
|
+
tests/test_cli.py
|
|
23
28
|
tests/test_create_new.py
|
|
24
|
-
tests/
|
|
29
|
+
tests/test_run.py
|
|
30
|
+
tests/test_runtime.py
|
|
25
31
|
tests/test_submit.py
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# src/workpeg_sdk/build.py
|
|
2
|
+
# import hashlib
|
|
3
|
+
# import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from workpeg_sdk.config import load_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def slugify(value: str) -> str:
|
|
11
|
+
return (
|
|
12
|
+
value.lower()
|
|
13
|
+
.replace("_", "-")
|
|
14
|
+
.replace(".", "-")
|
|
15
|
+
.replace(" ", "-")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_image_name(project_path: Path) -> str:
|
|
20
|
+
project_name = slugify(project_path.resolve().name)
|
|
21
|
+
return f"workpeg-fn-{project_name}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_image(path: str = ".", tag: str | None = None) -> str:
|
|
25
|
+
project_path = Path(path).resolve()
|
|
26
|
+
cfg = load_config(project_path / "workpeg.json")
|
|
27
|
+
|
|
28
|
+
image = tag or cfg.get("build", {}).get("image") or default_image_name(project_path)
|
|
29
|
+
|
|
30
|
+
subprocess.run(
|
|
31
|
+
["docker", "build", "-t", image, str(project_path)],
|
|
32
|
+
check=True,
|
|
33
|
+
)
|
|
34
|
+
return image
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
+
# src/workpeg_sdk/cli.py
|
|
1
2
|
import argparse
|
|
2
3
|
import sys
|
|
3
4
|
|
|
4
|
-
from workpeg_sdk import create_new, runtime, submit
|
|
5
|
+
from workpeg_sdk import build, create_new, run, runtime, submit
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def build_parser() -> argparse.ArgumentParser:
|
|
8
9
|
p = argparse.ArgumentParser(prog="workpeg")
|
|
9
10
|
sub = p.add_subparsers(dest="command", required=True)
|
|
10
11
|
|
|
11
|
-
# workpeg submit
|
|
12
|
-
# <function>:<version> [--api ...] [--path ...] [--timeout ...]
|
|
13
12
|
submit_p = sub.add_parser("submit", help="Submit a function bundle")
|
|
14
13
|
submit_p.add_argument("ref")
|
|
15
14
|
submit_p.add_argument("--api", default=None)
|
|
@@ -17,17 +16,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
17
16
|
submit_p.add_argument("--timeout", type=int, default=None)
|
|
18
17
|
submit_p.set_defaults(_handler="submit")
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
new_p = sub.add_parser(
|
|
22
|
-
"new-function", help="Create a new function project from template")
|
|
19
|
+
new_p = sub.add_parser("new-function", help="Create a new function project from template")
|
|
23
20
|
new_p.add_argument("path")
|
|
24
21
|
new_p.add_argument("--force", action="store_true")
|
|
25
22
|
new_p.set_defaults(_handler="new-function")
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
runtime_p = sub.add_parser("runtime", help="Run the function runtime")
|
|
25
|
+
runtime_p.add_argument("--server", action="store_true")
|
|
26
|
+
runtime_p.set_defaults(_handler="runtime")
|
|
27
|
+
|
|
28
|
+
build_p = sub.add_parser("build", help="Build the function docker image")
|
|
29
|
+
build_p.add_argument("--path", default=".")
|
|
30
|
+
build_p.add_argument("--tag", default=None)
|
|
31
|
+
build_p.set_defaults(_handler="build")
|
|
32
|
+
|
|
33
|
+
run_p = sub.add_parser("run", help="Run the function using a configured runtime")
|
|
34
|
+
run_p.add_argument("--with", dest="with_runtime", choices=["docker", "cracker"])
|
|
35
|
+
run_p.add_argument("--path", default=".")
|
|
36
|
+
run_p.add_argument("--no-build", action="store_true")
|
|
37
|
+
run_p.set_defaults(_handler="run")
|
|
31
38
|
|
|
32
39
|
return p
|
|
33
40
|
|
|
@@ -38,7 +45,6 @@ def main(argv=None) -> None:
|
|
|
38
45
|
args = parser.parse_args(argv)
|
|
39
46
|
|
|
40
47
|
if args._handler == "new-function":
|
|
41
|
-
# call the implementation directly (no double-argparse)
|
|
42
48
|
try:
|
|
43
49
|
out = create_new.create_new_project(args.path, force=args.force)
|
|
44
50
|
print(str(out))
|
|
@@ -48,12 +54,13 @@ def main(argv=None) -> None:
|
|
|
48
54
|
return
|
|
49
55
|
|
|
50
56
|
if args._handler == "runtime":
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
runtime_argv = []
|
|
58
|
+
if args.server:
|
|
59
|
+
runtime_argv.append("--server")
|
|
60
|
+
runtime.main(runtime_argv)
|
|
53
61
|
return
|
|
54
62
|
|
|
55
63
|
if args._handler == "submit":
|
|
56
|
-
# build argv for submit.main (it already supports argv)
|
|
57
64
|
submit_argv = [args.ref]
|
|
58
65
|
if args.api:
|
|
59
66
|
submit_argv += ["--api", args.api]
|
|
@@ -63,3 +70,19 @@ def main(argv=None) -> None:
|
|
|
63
70
|
submit_argv += ["--timeout", str(args.timeout)]
|
|
64
71
|
submit.main(submit_argv)
|
|
65
72
|
return
|
|
73
|
+
|
|
74
|
+
if args._handler == "build":
|
|
75
|
+
image = build.build_image(path=args.path, tag=args.tag)
|
|
76
|
+
print(image)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if args._handler == "run":
|
|
80
|
+
run_argv = []
|
|
81
|
+
if args.with_runtime:
|
|
82
|
+
run_argv += ["--with", args.with_runtime]
|
|
83
|
+
if args.path:
|
|
84
|
+
run_argv += ["--path", args.path]
|
|
85
|
+
if args.no_build:
|
|
86
|
+
run_argv.append("--no-build")
|
|
87
|
+
run.main(run_argv)
|
|
88
|
+
return
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# src/workpeg_sdk/config.py
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEFAULT_CONFIG = {
|
|
7
|
+
"function": {
|
|
8
|
+
"entrypoint": "app.main:main",
|
|
9
|
+
},
|
|
10
|
+
"runtime": {
|
|
11
|
+
"default": "cracker",
|
|
12
|
+
"docker": {
|
|
13
|
+
"host": "127.0.0.1",
|
|
14
|
+
"port": 8000,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"build": {
|
|
18
|
+
"image": None,
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def deep_merge(base: dict, override: dict) -> dict:
|
|
24
|
+
out = dict(base)
|
|
25
|
+
for key, value in override.items():
|
|
26
|
+
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
27
|
+
out[key] = deep_merge(out[key], value)
|
|
28
|
+
else:
|
|
29
|
+
out[key] = value
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_config(path: str = "workpeg.json") -> dict:
|
|
34
|
+
cfg_path = Path(path)
|
|
35
|
+
if not cfg_path.exists():
|
|
36
|
+
return DEFAULT_CONFIG.copy()
|
|
37
|
+
|
|
38
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
39
|
+
return deep_merge(DEFAULT_CONFIG, data)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# src/workpeg_sdk/run.py
|
|
2
|
+
# import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from workpeg_sdk.build import build_image
|
|
7
|
+
from workpeg_sdk.config import load_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_runtime(with_runtime: str | None, config: dict) -> str:
|
|
11
|
+
if with_runtime:
|
|
12
|
+
return with_runtime
|
|
13
|
+
return config.get("runtime", {}).get("default") or "cracker"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_with_docker(path: str = ".", build_first: bool = True) -> None:
|
|
17
|
+
project_path = Path(path).resolve()
|
|
18
|
+
cfg = load_config(project_path / "workpeg.json")
|
|
19
|
+
port = cfg.get("runtime", {}).get("docker", {}).get("port", 8000)
|
|
20
|
+
|
|
21
|
+
image = build_image(path) if build_first else (
|
|
22
|
+
cfg.get("build", {}).get("image") or project_path.name
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
subprocess.run(
|
|
26
|
+
[
|
|
27
|
+
"docker", "run", "--rm",
|
|
28
|
+
"-p", f"{port}:{port}",
|
|
29
|
+
image,
|
|
30
|
+
"workpeg", "runtime", "--server",
|
|
31
|
+
],
|
|
32
|
+
check=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_with_cracker(path: str = ".") -> None:
|
|
37
|
+
raise NotImplementedError("Cracker runtime is not implemented yet.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv=None) -> None:
|
|
41
|
+
import argparse
|
|
42
|
+
|
|
43
|
+
parser = argparse.ArgumentParser(prog="workpeg run")
|
|
44
|
+
parser.add_argument("--with", dest="with_runtime", choices=["docker", "cracker"])
|
|
45
|
+
parser.add_argument("--path", default=".")
|
|
46
|
+
parser.add_argument("--no-build", action="store_true")
|
|
47
|
+
args = parser.parse_args(argv or [])
|
|
48
|
+
|
|
49
|
+
cfg = load_config(Path(args.path) / "workpeg.json")
|
|
50
|
+
runtime = resolve_runtime(args.with_runtime, cfg)
|
|
51
|
+
|
|
52
|
+
if runtime == "docker":
|
|
53
|
+
run_with_docker(path=args.path, build_first=not args.no_build)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if runtime == "cracker":
|
|
57
|
+
run_with_cracker(path=args.path)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
raise ValueError(f"Unsupported runtime: {runtime}")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# src/workpeg_sdk/runtime.py
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from typing import Any, Callable, Dict, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENTRYPOINT = "app.main:main"
|
|
13
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
14
|
+
DEFAULT_PORT = 8000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FunctionRuntimeError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _json_dump(obj: Any) -> str:
|
|
22
|
+
try:
|
|
23
|
+
return json.dumps(obj)
|
|
24
|
+
except TypeError as exc:
|
|
25
|
+
raise FunctionRuntimeError(
|
|
26
|
+
f"Function returned a non-JSON-serializable value: {exc}"
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_entrypoint(entry: str) -> Tuple[str, str]:
|
|
31
|
+
try:
|
|
32
|
+
module_name, func_name = entry.split(":", 1)
|
|
33
|
+
module_name = module_name.strip()
|
|
34
|
+
func_name = func_name.strip()
|
|
35
|
+
if not module_name or not func_name:
|
|
36
|
+
raise ValueError
|
|
37
|
+
return module_name, func_name
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise FunctionRuntimeError(
|
|
40
|
+
f"Invalid FUNCTION_ENTRYPOINT format: '{entry}'. "
|
|
41
|
+
"Expected 'module.path:func_name'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_cwd_on_syspath() -> None:
|
|
46
|
+
cwd = os.getcwd()
|
|
47
|
+
if cwd not in sys.path:
|
|
48
|
+
sys.path.insert(0, cwd)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_function(entry: str | None = None):
|
|
52
|
+
_ensure_cwd_on_syspath()
|
|
53
|
+
|
|
54
|
+
entry = entry or os.getenv("FUNCTION_ENTRYPOINT", DEFAULT_ENTRYPOINT)
|
|
55
|
+
module_name, func_name = parse_entrypoint(entry)
|
|
56
|
+
|
|
57
|
+
importlib.invalidate_caches()
|
|
58
|
+
|
|
59
|
+
# Clear module + parent packages from cache
|
|
60
|
+
parts = module_name.split(".")
|
|
61
|
+
for i in range(len(parts), 0, -1):
|
|
62
|
+
candidate = ".".join(parts[:i])
|
|
63
|
+
sys.modules.pop(candidate, None)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
module = importlib.import_module(module_name)
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
raise FunctionRuntimeError(
|
|
69
|
+
f"Failed to import module '{module_name}' from '{entry}': {exc}"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
fn = getattr(module, func_name)
|
|
74
|
+
except AttributeError as exc:
|
|
75
|
+
raise FunctionRuntimeError(
|
|
76
|
+
f"Module '{module_name}' does not define '{func_name}' from '{entry}'."
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
if not callable(fn):
|
|
80
|
+
raise FunctionRuntimeError(
|
|
81
|
+
f"'{entry}' is not callable. Expected a function."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return fn
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_request() -> Dict[str, Any]:
|
|
88
|
+
try:
|
|
89
|
+
raw = sys.stdin.read()
|
|
90
|
+
if not raw.strip():
|
|
91
|
+
raise FunctionRuntimeError("No input received on stdin.")
|
|
92
|
+
data = json.loads(raw)
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
raise FunctionRuntimeError("Input JSON must be a JSON object.")
|
|
95
|
+
return data
|
|
96
|
+
except json.JSONDecodeError as exc:
|
|
97
|
+
raise FunctionRuntimeError(f"Invalid JSON input: {exc}") from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def invoke_function(
|
|
101
|
+
fn: Callable[[Dict[str, Any], Dict[str, Any]], Any],
|
|
102
|
+
request: Dict[str, Any],
|
|
103
|
+
) -> Tuple[Dict[str, Any], int]:
|
|
104
|
+
try:
|
|
105
|
+
context = request.get("context", {})
|
|
106
|
+
payload = request.get("payload", {})
|
|
107
|
+
|
|
108
|
+
if not isinstance(context, dict):
|
|
109
|
+
raise FunctionRuntimeError("Field 'context' must be a JSON object.")
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
raise FunctionRuntimeError("Field 'payload' must be a JSON object.")
|
|
112
|
+
|
|
113
|
+
result = fn(context, payload)
|
|
114
|
+
return {"status": "success", "result": result}, 0
|
|
115
|
+
|
|
116
|
+
except FunctionRuntimeError as e:
|
|
117
|
+
return {
|
|
118
|
+
"status": "error",
|
|
119
|
+
"error_type": "runtime_error",
|
|
120
|
+
"error": str(e),
|
|
121
|
+
}, 1
|
|
122
|
+
|
|
123
|
+
except Exception as e:
|
|
124
|
+
trace = traceback.format_exc()
|
|
125
|
+
return {
|
|
126
|
+
"status": "error",
|
|
127
|
+
"error_type": "user_error",
|
|
128
|
+
"error": str(e),
|
|
129
|
+
"trace": trace,
|
|
130
|
+
}, 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_once(entrypoint: str | None = None) -> int:
|
|
134
|
+
try:
|
|
135
|
+
fn = load_function(entrypoint)
|
|
136
|
+
request = read_request()
|
|
137
|
+
|
|
138
|
+
response, exit_code = invoke_function(fn, request)
|
|
139
|
+
|
|
140
|
+
print(_json_dump(response), flush=True)
|
|
141
|
+
return exit_code
|
|
142
|
+
|
|
143
|
+
except FunctionRuntimeError as e:
|
|
144
|
+
err_output = {
|
|
145
|
+
"status": "error",
|
|
146
|
+
"error_type": "runtime_error",
|
|
147
|
+
"error": str(e),
|
|
148
|
+
}
|
|
149
|
+
print(_json_dump(err_output), flush=True)
|
|
150
|
+
print(f"[workpeg runtime] runtime_error: {e}", file=sys.stderr)
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
trace = traceback.format_exc()
|
|
155
|
+
err_output = {
|
|
156
|
+
"status": "error",
|
|
157
|
+
"error_type": "user_error",
|
|
158
|
+
"error": str(e),
|
|
159
|
+
"trace": trace,
|
|
160
|
+
}
|
|
161
|
+
print(_json_dump(err_output), flush=True)
|
|
162
|
+
print(f"[workpeg runtime] user_error: {e}\n{trace}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def serve(host: str = DEFAULT_HOST,
|
|
167
|
+
port: int = DEFAULT_PORT,
|
|
168
|
+
entrypoint: str | None = None) -> None:
|
|
169
|
+
fn = load_function(entrypoint)
|
|
170
|
+
|
|
171
|
+
class Handler(BaseHTTPRequestHandler):
|
|
172
|
+
def _send_json(self, status_code: int, payload: Dict[str, Any]) -> None:
|
|
173
|
+
body = _json_dump(payload).encode("utf-8")
|
|
174
|
+
self.send_response(status_code)
|
|
175
|
+
self.send_header("Content-Type", "application/json")
|
|
176
|
+
self.send_header("Content-Length", str(len(body)))
|
|
177
|
+
self.end_headers()
|
|
178
|
+
self.wfile.write(body)
|
|
179
|
+
|
|
180
|
+
def do_GET(self) -> None:
|
|
181
|
+
if self.path == "/healthz":
|
|
182
|
+
self._send_json(200, {"status": "ok"})
|
|
183
|
+
return
|
|
184
|
+
self._send_json(404, {"status": "error", "error": "Not found"})
|
|
185
|
+
|
|
186
|
+
def do_POST(self) -> None:
|
|
187
|
+
if self.path != "/invoke":
|
|
188
|
+
self._send_json(404, {"status": "error", "error": "Not found"})
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
193
|
+
raw = self.rfile.read(content_length).decode("utf-8")
|
|
194
|
+
request = json.loads(raw)
|
|
195
|
+
|
|
196
|
+
if not isinstance(request, dict):
|
|
197
|
+
raise FunctionRuntimeError("Input JSON must be a JSON object.")
|
|
198
|
+
|
|
199
|
+
response, exit_code = invoke_function(fn, request)
|
|
200
|
+
self._send_json(200 if exit_code == 0 else 400, response)
|
|
201
|
+
|
|
202
|
+
except json.JSONDecodeError as exc:
|
|
203
|
+
self._send_json(
|
|
204
|
+
400,
|
|
205
|
+
{
|
|
206
|
+
"status": "error",
|
|
207
|
+
"error_type": "runtime_error",
|
|
208
|
+
"error": f"Invalid JSON input: {exc}",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
self._send_json(
|
|
213
|
+
500,
|
|
214
|
+
{
|
|
215
|
+
"status": "error",
|
|
216
|
+
"error_type": "runtime_error",
|
|
217
|
+
"error": str(exc),
|
|
218
|
+
"trace": traceback.format_exc(),
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
223
|
+
print(f"[workpeg runtime] {fmt % args}", file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
server = ThreadingHTTPServer((host, port), Handler)
|
|
226
|
+
print(f"[workpeg runtime] listening on http://{host}:{port}", file=sys.stderr)
|
|
227
|
+
server.serve_forever()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv=None) -> None:
|
|
231
|
+
argv = argv or []
|
|
232
|
+
if "--server" in argv:
|
|
233
|
+
serve()
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
sys.exit(run_once())
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from workpeg_sdk import build
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_slugify():
|
|
7
|
+
assert build.slugify("My_Function.1") == "my-function-1"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_default_image_name(tmp_path):
|
|
11
|
+
project = tmp_path / "My Project"
|
|
12
|
+
project.mkdir()
|
|
13
|
+
assert build.default_image_name(project) == "workpeg-fn-my-project"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_build_image_uses_default_name(monkeypatch, tmp_path):
|
|
17
|
+
project = tmp_path / "demo"
|
|
18
|
+
project.mkdir()
|
|
19
|
+
(project / "workpeg.json").write_text("{}")
|
|
20
|
+
|
|
21
|
+
calls = []
|
|
22
|
+
|
|
23
|
+
def fake_run(cmd, check):
|
|
24
|
+
calls.append((cmd, check))
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(build.subprocess, "run", fake_run)
|
|
27
|
+
|
|
28
|
+
image = build.build_image(path=str(project))
|
|
29
|
+
assert image == "workpeg-fn-demo"
|
|
30
|
+
|
|
31
|
+
assert len(calls) == 1
|
|
32
|
+
cmd, check = calls[0]
|
|
33
|
+
assert cmd == ["docker", "build", "-t", "workpeg-fn-demo", str(project.resolve())]
|
|
34
|
+
assert check is True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_image_uses_explicit_tag(monkeypatch, tmp_path):
|
|
38
|
+
project = tmp_path / "demo"
|
|
39
|
+
project.mkdir()
|
|
40
|
+
(project / "workpeg.json").write_text("{}")
|
|
41
|
+
|
|
42
|
+
calls = []
|
|
43
|
+
|
|
44
|
+
def fake_run(cmd, check):
|
|
45
|
+
calls.append((cmd, check))
|
|
46
|
+
|
|
47
|
+
monkeypatch.setattr(build.subprocess, "run", fake_run)
|
|
48
|
+
|
|
49
|
+
image = build.build_image(path=str(project), tag="custom-image")
|
|
50
|
+
assert image == "custom-image"
|
|
51
|
+
|
|
52
|
+
cmd, check = calls[0]
|
|
53
|
+
assert cmd == ["docker", "build", "-t", "custom-image", str(project.resolve())]
|
|
54
|
+
assert check is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_build_image_uses_config_image(monkeypatch, tmp_path):
|
|
58
|
+
project = tmp_path / "demo"
|
|
59
|
+
project.mkdir()
|
|
60
|
+
(project / "workpeg.json").write_text(
|
|
61
|
+
'{"build": {"image": "configured-image"}}'
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
calls = []
|
|
65
|
+
|
|
66
|
+
def fake_run(cmd, check):
|
|
67
|
+
calls.append((cmd, check))
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(build.subprocess, "run", fake_run)
|
|
70
|
+
|
|
71
|
+
image = build.build_image(path=str(project))
|
|
72
|
+
assert image == "configured-image"
|
|
73
|
+
|
|
74
|
+
cmd, check = calls[0]
|
|
75
|
+
assert cmd == ["docker", "build", "-t", "configured-image", str(project.resolve())]
|
|
76
|
+
assert check is True
|