quapp-hpc 0.0.1.dev6__tar.gz → 0.0.1.dev8__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 (31) hide show
  1. {quapp_hpc-0.0.1.dev6/quapp_hpc.egg-info → quapp_hpc-0.0.1.dev8}/PKG-INFO +1 -1
  2. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/pyproject.toml +33 -33
  3. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8/quapp_hpc.egg-info}/PKG-INFO +1 -1
  4. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/SOURCES.txt +1 -0
  5. quapp_hpc-0.0.1.dev8/quapp_slurm_hpc/log_writer.py +132 -0
  6. quapp_hpc-0.0.1.dev8/quapp_slurm_hpc/runner.py +215 -0
  7. quapp_hpc-0.0.1.dev6/quapp_slurm_hpc/runner.py +0 -112
  8. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/LICENSE +0 -0
  9. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/README.md +0 -0
  10. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/__init__.py +0 -0
  11. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/__init__.py +0 -0
  12. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/__init__.py +0 -0
  13. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/hpc_invocation.py +0 -0
  14. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/component/backend/slurm_job_fetching.py +0 -0
  15. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/__init__.py +0 -0
  16. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_device_factory.py +0 -0
  17. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_handler_factory.py +0 -0
  18. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/factory/hpc_provider_factory.py +0 -0
  19. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/__init__.py +0 -0
  20. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/invocation_handler.py +0 -0
  21. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/handler/job_fetching_handler.py +0 -0
  22. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/__init__.py +0 -0
  23. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/__init__.py +0 -0
  24. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/device/slurm_device.py +0 -0
  25. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/__init__.py +0 -0
  26. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc/model/provider/slurm_provider.py +0 -0
  27. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/dependency_links.txt +0 -0
  28. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/requires.txt +0 -0
  29. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_hpc.egg-info/top_level.txt +0 -0
  30. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/quapp_slurm_hpc/__init__.py +0 -0
  31. {quapp_hpc-0.0.1.dev6 → quapp_hpc-0.0.1.dev8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-hpc
3
- Version: 0.0.1.dev6
3
+ Version: 0.0.1.dev8
4
4
  Summary: Quapp HPC library — Slurm integration for Quapp Platform
5
5
  Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -1,33 +1,33 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0.0", "wheel==0.45.1"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "quapp-hpc"
7
- version = "0.0.1.dev6"
8
- description = "Quapp HPC library — Slurm integration for Quapp Platform"
9
- readme = "README.md"
10
- authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
11
- license = { file = "LICENSE" }
12
- classifiers = [
13
- "License :: OSI Approved :: MIT License",
14
- "Programming Language :: Python",
15
- "Programming Language :: Python :: 3",
16
- ]
17
- keywords = ["quapp", "quapp-hpc", "slurm", "hpc"]
18
- dependencies = [
19
- "quapp-common==0.0.12.dev12",
20
- "requests>=2.31.0",
21
- "boto3>=1.28.0",
22
- ]
23
- requires-python = ">=3.10,<3.13"
24
-
25
- [project.optional-dependencies]
26
- dev = ["black", "bumpver", "isort", "pip-tools", "pytest"]
27
-
28
- [tool.setuptools.packages.find]
29
- include = ["quapp_hpc*", "quapp_slurm_hpc*"]
30
- exclude = ["*.md", "*.yml", "*.yaml", "*.toml", "tests*", ".gitignore"]
31
-
32
- [project.urls]
33
- Homepage = "https://quapp.cloud/"
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel==0.45.1"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "quapp-hpc"
7
+ version = "0.0.1.dev8"
8
+ description = "Quapp HPC library — Slurm integration for Quapp Platform"
9
+ readme = "README.md"
10
+ authors = [{ name = "CITYNOW Co. Ltd.", email = "corp@citynow.vn" }]
11
+ license = { file = "LICENSE" }
12
+ classifiers = [
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python",
15
+ "Programming Language :: Python :: 3",
16
+ ]
17
+ keywords = ["quapp", "quapp-hpc", "slurm", "hpc"]
18
+ dependencies = [
19
+ "quapp-common==0.0.12.dev12",
20
+ "requests>=2.31.0",
21
+ "boto3>=1.28.0",
22
+ ]
23
+ requires-python = ">=3.10,<3.13"
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["black", "bumpver", "isort", "pip-tools", "pytest"]
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["quapp_hpc*", "quapp_slurm_hpc*"]
30
+ exclude = ["*.md", "*.yml", "*.yaml", "*.toml", "tests*", ".gitignore"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://quapp.cloud/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-hpc
3
- Version: 0.0.1.dev6
3
+ Version: 0.0.1.dev8
4
4
  Summary: Quapp HPC library — Slurm integration for Quapp Platform
5
5
  Author-email: "CITYNOW Co. Ltd." <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -24,4 +24,5 @@ quapp_hpc/model/device/slurm_device.py
24
24
  quapp_hpc/model/provider/__init__.py
25
25
  quapp_hpc/model/provider/slurm_provider.py
26
26
  quapp_slurm_hpc/__init__.py
27
+ quapp_slurm_hpc/log_writer.py
27
28
  quapp_slurm_hpc/runner.py
@@ -0,0 +1,132 @@
1
+ """Pushes a job's stdout/stderr to the backend's live log pipeline (PQF-23860) as it runs,
2
+ instead of only via the final output.json snapshot uploaded at the end.
3
+
4
+ No client for this protocol exists anywhere yet (checked qapp-lib / quapp-libs) — this is the
5
+ first one, reverse-engineered directly from the backend's ingest code:
6
+
7
+ JobLogIngestControllerV1 -- POST {base_url}/writers/{writer_id}/open?attempt=N
8
+ -- POST {base_url}/writers/{writer_id}/chunks?attempt=N
9
+ -- POST {base_url}/writers/{writer_id}/close?attempt=N
10
+ JobLogWriteResult -- the response body, wrapped by the backend's global envelope
11
+ as {"data": {"outcome": ..., "endOffset": ..., "expectedSeq": ...}}
12
+
13
+ Every call carries the token the backend issued for this job attempt in the `logIngest` block
14
+ of the invocation input (see JobLogIngestPayloadProvider on the backend). Best-effort only: a
15
+ failure here must never raise into the handler's own execution — the job still has to run to
16
+ completion and still produce output.json exactly as before.
17
+ """
18
+ import sys
19
+ import uuid
20
+ from typing import Optional
21
+
22
+ import requests
23
+
24
+ _TOKEN_HEADER = "X-Quapp-Log-Token"
25
+ _SEQ_HEADER = "X-Log-Seq"
26
+ _DROPPED_HEADER = "X-Log-Dropped"
27
+ _REQUEST_TIMEOUT_SECONDS = 5
28
+ _STOP_OUTCOMES = {"SEALED", "TOO_LATE", "TRUNCATED"}
29
+
30
+
31
+ class JobLogWriter:
32
+ """A single log writer for one job attempt. Not thread-safe — call from one thread only."""
33
+
34
+ def __init__(self, base_url: str, token: str, attempt: int, headers: Optional[dict] = None):
35
+ self._base_url = base_url.rstrip("/")
36
+ self._token = token
37
+ self._attempt = attempt
38
+ self._extra_headers = headers or {}
39
+ self._writer_id = str(uuid.uuid4())
40
+ self._seq = 0
41
+ self._dropped_bytes = 0
42
+ self._stopped = False
43
+
44
+ @property
45
+ def stopped(self) -> bool:
46
+ """True once the backend told us to stop (sealed/too-late/truncated) or a request
47
+ failed — callers should skip further append()/close() calls once this is set, though
48
+ each method is itself a safe no-op either way."""
49
+ return self._stopped
50
+
51
+ def open(self) -> None:
52
+ if self._stopped:
53
+ return
54
+ self._post("open")
55
+
56
+ def append(self, data: bytes) -> None:
57
+ """Sends one chunk of new output. Silently drops it (and counts it as dropped) once
58
+ streaming has stopped — the handler's own run must never block or fail on this."""
59
+ if not data:
60
+ return
61
+ if self._stopped:
62
+ self._dropped_bytes += len(data)
63
+ return
64
+ outcome, expected_seq = self._post(
65
+ "chunks",
66
+ data=data,
67
+ extra_headers={
68
+ _SEQ_HEADER: str(self._seq),
69
+ _DROPPED_HEADER: str(self._dropped_bytes),
70
+ # The backend's /chunks route is declared `consumes = APPLICATION_OCTET_STREAM`
71
+ # and rejects anything else with 415 — requests does not set this on its own
72
+ # for a raw bytes body.
73
+ "Content-Type": "application/octet-stream",
74
+ },
75
+ )
76
+ if outcome in ("APPENDED", "DUPLICATE"):
77
+ self._seq += 1
78
+ self._dropped_bytes = 0
79
+ elif outcome == "SEQ_GAP" and expected_seq is not None:
80
+ # The backend tells us exactly what it's missing — resync instead of retrying the
81
+ # same seq forever (which would never succeed and would spin indefinitely).
82
+ self._seq = int(expected_seq)
83
+ self._dropped_bytes += len(data)
84
+
85
+ def close(self, exit_code: int, abandoned: bool = False, discarded: bool = False) -> None:
86
+ """Closes the writer. Safe to call even if streaming already stopped earlier (e.g. the
87
+ backend sealed it, or a request failed), so a crash can still attempt a clean close
88
+ from a `finally` block without extra branching."""
89
+ self._post(
90
+ "close",
91
+ json={
92
+ "lastSeq": self._seq - 1,
93
+ "droppedBytes": self._dropped_bytes,
94
+ "abandoned": abandoned,
95
+ "discarded": discarded,
96
+ "exitCode": exit_code,
97
+ },
98
+ )
99
+ self._stopped = True
100
+
101
+ def _post(self, segment: str, *, json=None, data=None, extra_headers: Optional[dict] = None):
102
+ url = f"{self._base_url}/writers/{self._writer_id}/{segment}?attempt={self._attempt}"
103
+ headers = {_TOKEN_HEADER: self._token, **self._extra_headers, **(extra_headers or {})}
104
+ try:
105
+ response = requests.post(
106
+ url, json=json, data=data, headers=headers, timeout=_REQUEST_TIMEOUT_SECONDS
107
+ )
108
+ response.raise_for_status()
109
+ payload = (response.json() or {}).get("data") or {}
110
+ outcome = payload.get("outcome")
111
+ if outcome in _STOP_OUTCOMES:
112
+ self._stopped = True
113
+ return outcome, payload.get("expectedSeq")
114
+ except Exception as exc: # best-effort: never let log streaming break the job itself
115
+ print(f"[log_writer] stopping log stream, request failed: {exc}", file=sys.stderr, flush=True)
116
+ self._stopped = True
117
+ return None, None
118
+
119
+
120
+ def from_input(invocation_input) -> Optional[JobLogWriter]:
121
+ """Builds a writer from the `logIngest` block the backend injects into the job input, or
122
+ None when push logging is disabled or the job predates this feature. Callers must treat
123
+ None as "don't stream this run" — not as an error."""
124
+ log_ingest = invocation_input.get("logIngest") if isinstance(invocation_input, dict) else None
125
+ if not isinstance(log_ingest, dict):
126
+ return None
127
+ base_url = log_ingest.get("baseUrl")
128
+ token = log_ingest.get("token")
129
+ attempt = log_ingest.get("attempt")
130
+ if not base_url or not token or attempt is None:
131
+ return None
132
+ return JobLogWriter(base_url, token, int(attempt), log_ingest.get("headers"))
@@ -0,0 +1,215 @@
1
+ """CLI runner — invoked by the sbatch script inside the Apptainer container.
2
+
3
+ The Java SlurmPlatformManager generates a sbatch script that runs:
4
+
5
+ apptainer exec docker://<image> python -m quapp_slurm_hpc.runner \
6
+ --input-file /data/jobs/<jobId>/input.json \
7
+ --output-file /data/jobs/<jobId>/output.json \
8
+ --handler-dir /home/app/function
9
+
10
+ This module:
11
+ 1. Reads the invocation input from --input-file (JSON written by the sbatch script).
12
+ 2. Loads handler.py from --handler-dir.
13
+ 3. Calls handler.processing(invocation_input) → bash script string.
14
+ 4. Executes the bash script, streaming stdout/stderr live to the backend as it runs (when the
15
+ input carries a `logIngest` block) while also capturing it in full for the final result.
16
+ 5. Calls handler.post_processing(raw_result) if it exists.
17
+ 6. Writes the final result dict to --output-file.
18
+ 7. Uploads output.json to S3 (key: {QUAPP_JOB_ID}/output.json) when SLURM_S3_BUCKET is set.
19
+ """
20
+ import argparse
21
+ import importlib.util
22
+ import json
23
+ import os
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import threading
28
+ import time
29
+ import traceback
30
+ from pathlib import Path
31
+ from types import ModuleType
32
+
33
+ from quapp_slurm_hpc import log_writer
34
+
35
+ _LOG_FLUSH_INTERVAL_SECONDS = 2.0
36
+
37
+
38
+ def _load_handler(handler_dir: str) -> ModuleType:
39
+ handler_path = Path(handler_dir) / "handler.py"
40
+ if not handler_path.exists():
41
+ raise FileNotFoundError(
42
+ f"handler.py not found in {handler_dir!r}. "
43
+ "Ensure the function image was built with the handler in /home/app/function."
44
+ )
45
+ if str(handler_dir) not in sys.path:
46
+ sys.path.insert(0, str(handler_dir))
47
+ spec = importlib.util.spec_from_file_location("handler", handler_path)
48
+ module = importlib.util.module_from_spec(spec)
49
+ spec.loader.exec_module(module)
50
+ return module
51
+
52
+
53
+ def _run_script(script: str, writer: "log_writer.JobLogWriter | None" = None) -> dict:
54
+ """Write script to a temp file and execute it with bash, capturing output.
55
+
56
+ Reads stdout/stderr incrementally as the script runs (instead of blocking until it exits)
57
+ so `writer`, when given, can push new output to the backend every
58
+ `_LOG_FLUSH_INTERVAL_SECONDS` seconds — the final returned dict is unchanged either way; the
59
+ full stdout/stderr are still captured in full for `output.json`, same as before.
60
+ """
61
+ with tempfile.NamedTemporaryFile(
62
+ mode="w", suffix=".sh", delete=False, encoding="utf-8"
63
+ ) as f:
64
+ f.write(script)
65
+ tmp_path = f.name
66
+ try:
67
+ process = subprocess.Popen(
68
+ ["bash", tmp_path],
69
+ stdout=subprocess.PIPE,
70
+ stderr=subprocess.PIPE,
71
+ )
72
+
73
+ stdout_parts: list[bytes] = []
74
+ stderr_parts: list[bytes] = []
75
+ pending_parts: list[bytes] = []
76
+ lock = threading.Lock()
77
+
78
+ def _drain(stream, sink: list[bytes]) -> None:
79
+ for line in iter(stream.readline, b""):
80
+ with lock:
81
+ sink.append(line)
82
+ pending_parts.append(line)
83
+ stream.close()
84
+
85
+ readers = [
86
+ threading.Thread(target=_drain, args=(process.stdout, stdout_parts), daemon=True),
87
+ threading.Thread(target=_drain, args=(process.stderr, stderr_parts), daemon=True),
88
+ ]
89
+ for reader in readers:
90
+ reader.start()
91
+
92
+ def _flush() -> None:
93
+ if writer is None:
94
+ return
95
+ with lock:
96
+ if not pending_parts:
97
+ return
98
+ chunk = b"".join(pending_parts)
99
+ pending_parts.clear()
100
+ writer.append(chunk)
101
+
102
+ last_flush = time.monotonic()
103
+ while True:
104
+ finished = process.poll() is not None and not any(r.is_alive() for r in readers)
105
+ now = time.monotonic()
106
+ if finished or now - last_flush >= _LOG_FLUSH_INTERVAL_SECONDS:
107
+ _flush()
108
+ last_flush = now
109
+ if finished:
110
+ break
111
+ time.sleep(0.2)
112
+
113
+ for reader in readers:
114
+ reader.join()
115
+ exit_code = process.wait()
116
+
117
+ return {
118
+ "exit_code": exit_code,
119
+ "stdout": b"".join(stdout_parts).decode("utf-8", errors="replace"),
120
+ "stderr": b"".join(stderr_parts).decode("utf-8", errors="replace"),
121
+ }
122
+ finally:
123
+ Path(tmp_path).unlink(missing_ok=True)
124
+
125
+
126
+ def main() -> None:
127
+ parser = argparse.ArgumentParser(
128
+ description="Quapp HPC runner — executes handler.py inside an Apptainer container"
129
+ )
130
+ parser.add_argument("--input-file", required=True, help="Path to input JSON file")
131
+ parser.add_argument("--output-file", required=True, help="Path to write output JSON")
132
+ parser.add_argument("--handler-dir", required=True, help="Directory containing handler.py")
133
+ args = parser.parse_args()
134
+
135
+ output: dict = {}
136
+ writer = None
137
+ exit_code_for_close = 1
138
+ try:
139
+ with open(args.input_file, encoding="utf-8") as f:
140
+ invocation_input = json.load(f)
141
+
142
+ # Absent when log push is disabled or the job predates this feature — _run_script and
143
+ # writer.append/close below all treat `writer is None` as "don't stream", not an error.
144
+ writer = log_writer.from_input(invocation_input)
145
+ if writer is not None:
146
+ writer.open()
147
+
148
+ handler = _load_handler(args.handler_dir)
149
+
150
+ if not hasattr(handler, "processing"):
151
+ raise AttributeError(
152
+ "handler.py must define a processing(invocation_input: dict) -> str function"
153
+ )
154
+
155
+ script = handler.processing(invocation_input)
156
+ if not isinstance(script, str):
157
+ raise TypeError(
158
+ f"handler.processing() must return a str (bash script), got {type(script).__name__}"
159
+ )
160
+
161
+ raw_result = _run_script(script, writer)
162
+ exit_code_for_close = raw_result.get("exit_code", 1)
163
+
164
+ if hasattr(handler, "post_processing"):
165
+ output = handler.post_processing(raw_result)
166
+ else:
167
+ output = raw_result
168
+
169
+ except Exception:
170
+ output = {
171
+ "exit_code": 1,
172
+ "stdout": "",
173
+ "stderr": traceback.format_exc(),
174
+ }
175
+ exit_code_for_close = output["exit_code"]
176
+ finally:
177
+ # Close even when the script never ran (e.g. handler.py failed to load) so a writer
178
+ # that was opened above is never left dangling on the backend.
179
+ if writer is not None:
180
+ writer.close(exit_code_for_close)
181
+
182
+ with open(args.output_file, "w", encoding="utf-8") as f:
183
+ json.dump(output, f)
184
+
185
+ _upload_to_s3(args.output_file)
186
+
187
+
188
+ def _upload_to_s3(output_file: str) -> None:
189
+ """Upload output.json to S3 so the Java backend can read the result.
190
+
191
+ Requires SLURM_S3_BUCKET and QUAPP_JOB_ID env vars (injected by sbatch script).
192
+ Skips silently when the bucket is not configured.
193
+ """
194
+ s3_bucket = os.getenv("SLURM_S3_BUCKET", "")
195
+ job_uuid = os.getenv("QUAPP_JOB_ID", "")
196
+ if not s3_bucket or not job_uuid:
197
+ return
198
+
199
+ try:
200
+ import boto3
201
+ s3 = boto3.client(
202
+ "s3",
203
+ region_name=os.getenv("SLURM_S3_REGION", "ap-southeast-1"),
204
+ aws_access_key_id=os.getenv("SLURM_S3_ACCESS_KEY"),
205
+ aws_secret_access_key=os.getenv("SLURM_S3_SECRET_KEY"),
206
+ )
207
+ s3_key = f"{job_uuid}/output.json"
208
+ s3.upload_file(output_file, s3_bucket, s3_key)
209
+ print(f"[runner] Uploaded output to s3://{s3_bucket}/{s3_key}", flush=True)
210
+ except Exception as exc:
211
+ print(f"[runner] WARNING: S3 upload failed: {exc}", file=sys.stderr, flush=True)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -1,112 +0,0 @@
1
- """CLI runner — invoked by the sbatch script inside the Apptainer container.
2
-
3
- The Java SlurmPlatformManager generates a sbatch script that runs:
4
-
5
- apptainer exec docker://<image> python -m quapp_slurm_hpc.runner \
6
- --input-file /data/jobs/<jobId>/input.json \
7
- --output-file /data/jobs/<jobId>/output.json \
8
- --handler-dir /home/app/function
9
-
10
- This module:
11
- 1. Reads the invocation input from --input-file (JSON written by the sbatch script).
12
- 2. Loads handler.py from --handler-dir.
13
- 3. Calls handler.processing(invocation_input) → bash script string.
14
- 4. Executes the bash script, captures stdout / stderr / exit_code.
15
- 5. Calls handler.post_processing(raw_result) if it exists.
16
- 6. Writes the final result dict to --output-file.
17
- """
18
- import argparse
19
- import importlib.util
20
- import json
21
- import subprocess
22
- import sys
23
- import tempfile
24
- import traceback
25
- from pathlib import Path
26
- from types import ModuleType
27
-
28
-
29
- def _load_handler(handler_dir: str) -> ModuleType:
30
- handler_path = Path(handler_dir) / "handler.py"
31
- if not handler_path.exists():
32
- raise FileNotFoundError(
33
- f"handler.py not found in {handler_dir!r}. "
34
- "Ensure the function image was built with the handler in /home/app/function."
35
- )
36
- if str(handler_dir) not in sys.path:
37
- sys.path.insert(0, str(handler_dir))
38
- spec = importlib.util.spec_from_file_location("handler", handler_path)
39
- module = importlib.util.module_from_spec(spec)
40
- spec.loader.exec_module(module)
41
- return module
42
-
43
-
44
- def _run_script(script: str) -> dict:
45
- """Write script to a temp file and execute it with bash, capturing output."""
46
- with tempfile.NamedTemporaryFile(
47
- mode="w", suffix=".sh", delete=False, encoding="utf-8"
48
- ) as f:
49
- f.write(script)
50
- tmp_path = f.name
51
- try:
52
- result = subprocess.run(
53
- ["bash", tmp_path],
54
- capture_output=True,
55
- text=True,
56
- )
57
- return {
58
- "exit_code": result.returncode,
59
- "stdout": result.stdout,
60
- "stderr": result.stderr,
61
- }
62
- finally:
63
- Path(tmp_path).unlink(missing_ok=True)
64
-
65
-
66
- def main() -> None:
67
- parser = argparse.ArgumentParser(
68
- description="Quapp HPC runner — executes handler.py inside an Apptainer container"
69
- )
70
- parser.add_argument("--input-file", required=True, help="Path to input JSON file")
71
- parser.add_argument("--output-file", required=True, help="Path to write output JSON")
72
- parser.add_argument("--handler-dir", required=True, help="Directory containing handler.py")
73
- args = parser.parse_args()
74
-
75
- output: dict = {}
76
- try:
77
- with open(args.input_file, encoding="utf-8") as f:
78
- invocation_input = json.load(f)
79
-
80
- handler = _load_handler(args.handler_dir)
81
-
82
- if not hasattr(handler, "processing"):
83
- raise AttributeError(
84
- "handler.py must define a processing(invocation_input: dict) -> str function"
85
- )
86
-
87
- script = handler.processing(invocation_input)
88
- if not isinstance(script, str):
89
- raise TypeError(
90
- f"handler.processing() must return a str (bash script), got {type(script).__name__}"
91
- )
92
-
93
- raw_result = _run_script(script)
94
-
95
- if hasattr(handler, "post_processing"):
96
- output = handler.post_processing(raw_result)
97
- else:
98
- output = raw_result
99
-
100
- except Exception:
101
- output = {
102
- "exit_code": 1,
103
- "stdout": "",
104
- "stderr": traceback.format_exc(),
105
- }
106
-
107
- with open(args.output_file, "w", encoding="utf-8") as f:
108
- json.dump(output, f)
109
-
110
-
111
- if __name__ == "__main__":
112
- main()
File without changes
File without changes
File without changes