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.
Files changed (35) hide show
  1. {workpeg-0.1.4/src/workpeg.egg-info → workpeg-0.2.0}/PKG-INFO +1 -1
  2. {workpeg-0.1.4 → workpeg-0.2.0}/pyproject.toml +1 -1
  3. {workpeg-0.1.4 → workpeg-0.2.0/src/workpeg.egg-info}/PKG-INFO +1 -1
  4. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/SOURCES.txt +7 -1
  5. workpeg-0.2.0/src/workpeg_sdk/build.py +34 -0
  6. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/cli.py +37 -14
  7. workpeg-0.2.0/src/workpeg_sdk/config.py +39 -0
  8. workpeg-0.2.0/src/workpeg_sdk/run.py +60 -0
  9. workpeg-0.2.0/src/workpeg_sdk/runtime.py +236 -0
  10. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/Dockerfile +1 -1
  11. workpeg-0.2.0/tests/test_build.py +76 -0
  12. workpeg-0.2.0/tests/test_cli.py +124 -0
  13. workpeg-0.2.0/tests/test_run.py +124 -0
  14. workpeg-0.2.0/tests/test_runtime.py +208 -0
  15. workpeg-0.1.4/src/workpeg_sdk/runtime.py +0 -173
  16. workpeg-0.1.4/tests/test_run_time.py +0 -305
  17. {workpeg-0.1.4 → workpeg-0.2.0}/LICENSE +0 -0
  18. {workpeg-0.1.4 → workpeg-0.2.0}/MANIFEST.in +0 -0
  19. {workpeg-0.1.4 → workpeg-0.2.0}/README.md +0 -0
  20. {workpeg-0.1.4 → workpeg-0.2.0}/setup.cfg +0 -0
  21. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/dependency_links.txt +0 -0
  22. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/entry_points.txt +0 -0
  23. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/requires.txt +0 -0
  24. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg.egg-info/top_level.txt +0 -0
  25. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/__init__.py +0 -0
  26. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/create_new.py +0 -0
  27. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/submit.py +0 -0
  28. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/__init__.py +0 -0
  29. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/LICENSE +0 -0
  30. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/README.md +0 -0
  31. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/app/__init__.py +0 -0
  32. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/app/main.py +0 -0
  33. {workpeg-0.1.4 → workpeg-0.2.0}/src/workpeg_sdk/templates/functions/requirements.txt +0 -0
  34. {workpeg-0.1.4 → workpeg-0.2.0}/tests/test_create_new.py +0 -0
  35. {workpeg-0.1.4 → workpeg-0.2.0}/tests/test_submit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workpeg"
7
- version = "0.1.4"
7
+ version = "0.2.0"
8
8
  description = "Workpeg function runtime and SDK"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workpeg
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Workpeg function runtime and SDK
5
5
  Author-email: Workpeg <support@workpeg.com>
6
6
  License: MIT
@@ -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/test_run_time.py
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
- # workpeg new-function <path> [--force]
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
- # workpeg runtime
28
- run_p = sub.add_parser(
29
- "runtime", help="Run a function locally (reads JSON from stdin)")
30
- run_p.set_defaults(_handler="runtime")
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
- # runtime may read stdin, so call directly
52
- runtime.main([])
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())
@@ -15,4 +15,4 @@ COPY app /app/app
15
15
 
16
16
  USER appuser
17
17
 
18
- ENTRYPOINT ["workpeg", "runtime"]
18
+ ENTRYPOINT ["workpeg", "runtime", "--server"]
@@ -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