fm-weck 1.4.8__py3-none-any.whl → 1.5.1__py3-none-any.whl

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.
@@ -0,0 +1,332 @@
1
+ # This file is part of fm-weck: executing fm-tools in containerized environments.
2
+ # https://gitlab.com/sosy-lab/software/fm-weck
3
+ #
4
+ # SPDX-FileCopyrightText: 2024 Dirk Beyer <https://www.sosy-lab.org>
5
+ #
6
+ # SPDX-License-Identifier: Apache-2.0
7
+
8
+ import contextlib
9
+ import multiprocessing
10
+ import multiprocessing.synchronize
11
+ import os
12
+ import threading
13
+ import uuid
14
+ from pathlib import Path
15
+ from shutil import rmtree
16
+ from typing import TYPE_CHECKING, Generator, Optional, Tuple, Union
17
+
18
+ from fm_tools.benchexec_helper import DataModel
19
+
20
+ from fm_weck.config import Config
21
+ from fm_weck.exceptions import Failure, RunFailedError, failure_from_exception
22
+ from fm_weck.resources import fm_tools_choice_map, property_choice_map
23
+ from fm_weck.serve import run_guided, run_manual
24
+
25
+ from .server_utils import TMP_DIR
26
+
27
+ if TYPE_CHECKING:
28
+ from fm_weck.grpc_service.proto.fm_weck_service_pb2 import File, RunRequest
29
+ from fm_weck.run_result import RunResult
30
+
31
+
32
+ def _get_unique_id() -> str:
33
+ return str(uuid.uuid4())
34
+
35
+
36
+ class StillRunningError(Exception):
37
+ pass
38
+
39
+
40
+ def worker(setup_complete_flag, queue: multiprocessing.SimpleQueue, initializer, initargs, target, args, kwargs):
41
+ import signal
42
+ import sys
43
+
44
+ # In the rare case that a cancel happens before the target function overrides the
45
+ # signal handler, we need to make sure that the process still sends something over the queue and exits.
46
+ def handle_extremely_fast_sigterm(signum, frame):
47
+ queue.put((False, InterruptedError("Run was canceled before setup complete.")))
48
+ sys.exit(1)
49
+
50
+ signal.signal(signal.SIGTERM, handle_extremely_fast_sigterm)
51
+ setup_complete_flag.set()
52
+
53
+ try:
54
+ if initializer:
55
+ initializer(*initargs)
56
+ result = target(*args, **kwargs)
57
+ if hasattr(result, "exit_code") and getattr(result, "exit_code", 0) != 0:
58
+ raise RunFailedError(result.exit_code, result.command, getattr(result, "raw_output", None))
59
+ queue.put((True, result))
60
+ except Exception as e:
61
+ queue.put((False, failure_from_exception(e)))
62
+
63
+
64
+ class RunHandler:
65
+ mp = multiprocessing.get_context("spawn")
66
+ _success: bool = False
67
+ _result: Optional[Union["RunResult", Failure]] = None
68
+ _setup_complete: Optional[multiprocessing.synchronize.Event] = None
69
+
70
+ def __init__(self, request: "RunRequest"):
71
+ self.request = request
72
+ self.run_id = _get_unique_id()
73
+ self.run_path = TMP_DIR / self.run_id
74
+
75
+ self._is_cancelled: bool = False
76
+
77
+ self._output_log = self.run_path / "output" / "output.txt"
78
+ self._output_dir = self.run_path / "output"
79
+
80
+ self.run_path.mkdir(parents=True, exist_ok=False)
81
+
82
+ self._process = None
83
+ self._done = threading.Event()
84
+ self._queue = self.mp.SimpleQueue()
85
+
86
+ self._result_listener: threading.Thread = None
87
+
88
+ def _set(self, result=Tuple[bool, object]):
89
+ self._success, self._result = result
90
+ if isinstance(self._result, Failure):
91
+ self._success = False
92
+ self._done.set()
93
+
94
+ def ready(self) -> bool:
95
+ return self._done.is_set()
96
+
97
+ @property
98
+ def output(self) -> str:
99
+ if self.is_running():
100
+ try:
101
+ with self._output_log.open("r") as output_file:
102
+ return output_file.read()
103
+ except (FileNotFoundError, PermissionError):
104
+ return ""
105
+
106
+ if self.ready() and self.successful():
107
+ return self._result.raw_output
108
+
109
+ def successful(self) -> bool:
110
+ """
111
+ Returns True if the run was successful, False otherwise.
112
+
113
+ :raises StillRunningError: If the run is still running.
114
+ :raises ValueError: If the run has not been started yet.
115
+ """
116
+ if self.ready():
117
+ return self._success
118
+
119
+ if self._process is None:
120
+ raise ValueError("Run has not been started yet.")
121
+
122
+ raise StillRunningError("The run is still running.")
123
+
124
+ @property
125
+ def output_files(self) -> Generator[str, None, None]:
126
+ """
127
+ Names of the files produced by the run.
128
+ """
129
+ for root, _, files in os.walk(self._output_dir):
130
+ for file in files:
131
+ yield os.path.relpath(os.path.join(root, file), self._output_dir)
132
+
133
+ def is_running(self):
134
+ """
135
+ A run handler is running, if it has been started and is not finished yet.
136
+ """
137
+ return self._process and (not self.ready())
138
+
139
+ def is_canceled(self):
140
+ """
141
+ Returns True if the run was really canceled, i.e. the underlying process terminated, False otherwise.
142
+ Since cancelling a finished run has no effect, calling `cancel_run` will not necessarily
143
+ result in is_canceled() returning True.
144
+ """
145
+
146
+ return self._is_cancelled
147
+
148
+ def _apply(self, func, args, kwds):
149
+ self._setup_complete = self.mp.Event()
150
+ self._process = self.mp.Process(
151
+ target=worker,
152
+ args=(self._setup_complete, self._queue, os.chdir, (str(self.run_path.absolute()),), func, args, kwds),
153
+ daemon=True,
154
+ )
155
+ self._result_listener = threading.Thread(target=self._listen_for_result, daemon=True)
156
+ self._result_listener.start()
157
+ self._process.start()
158
+
159
+ def _listen_for_result(self):
160
+ result = self._queue.get()
161
+ self._process.join()
162
+ self._process.close()
163
+ self._queue.close()
164
+ # Make sure resources are released before
165
+ # setting the result.
166
+ self._set(result)
167
+
168
+ def start(self):
169
+ c_program = self.get_c_program(self.request)
170
+ data_model = self.request.data_model
171
+
172
+ fm_data = self.get_tool(self.request)
173
+ property_path = self.get_property(self.request)
174
+
175
+ tool_version = None
176
+ if self.request.tool.HasField("tool_version"):
177
+ tool_version = self.request.tool.tool_version
178
+
179
+ config = Config()
180
+ config.load()
181
+
182
+ self._apply(
183
+ func=run_guided,
184
+ args=(
185
+ fm_data.absolute(),
186
+ tool_version,
187
+ config,
188
+ property_path.absolute(),
189
+ [c_program],
190
+ ),
191
+ kwds=dict(
192
+ additional_args=[],
193
+ data_model=DataModel[data_model],
194
+ log_output_to=self._output_log.absolute(),
195
+ output_files_to=self._output_dir.absolute(),
196
+ ),
197
+ )
198
+
199
+ def start_expert(self, command: str):
200
+ fm_data = self.get_tool(self.request)
201
+ tool_version = None
202
+ if self.request.tool.HasField("tool_version"):
203
+ tool_version = self.request.tool.tool_version
204
+ command = list(self.request.command)
205
+
206
+ config = Config()
207
+ config.load()
208
+
209
+ self._apply(
210
+ func=run_manual,
211
+ args=(
212
+ fm_data.absolute(),
213
+ tool_version,
214
+ config,
215
+ command,
216
+ ),
217
+ kwds=dict(
218
+ log_output_to=self._output_log.absolute(),
219
+ output_files_to=self._output_dir.absolute(),
220
+ ),
221
+ )
222
+
223
+ def join(self, timeout=None):
224
+ done = self._done.wait(timeout)
225
+ if not done:
226
+ raise TimeoutError(f"Timeout while joining run {self.run_id}.")
227
+
228
+ def cleanup(self):
229
+ if self.is_running():
230
+ raise StillRunningError("The run is still running.")
231
+
232
+ rmtree(self.run_path, ignore_errors=True)
233
+
234
+ def get_tool(self, request: "RunRequest") -> Path:
235
+ tool = request.tool
236
+
237
+ if tool.HasField("tool_id"):
238
+ return fm_tools_choice_map()[tool.tool_id]
239
+ else:
240
+ return self.get_custom_tool(tool.tool_file)
241
+
242
+ def get_custom_tool(self, data: "File") -> Path:
243
+ custom_tool_path = self.run_path / "_custom_tool.yml"
244
+
245
+ with custom_tool_path.open("wb") as custom_tool_file:
246
+ custom_tool_file.write(data.file)
247
+ return custom_tool_path
248
+
249
+ def get_property(self, request: "RunRequest") -> Path:
250
+ property = request.property
251
+
252
+ if property.HasField("property_id"):
253
+ return property_choice_map()[property.property_id]
254
+ else:
255
+ return self.get_custom_property(property.property_file)
256
+
257
+ def get_custom_property(self, property_file: "File") -> Path:
258
+ custom_property_path = self.run_path / "_custom_property.prp"
259
+
260
+ with custom_property_path.open("wb") as custom_property_file:
261
+ custom_property_file.write(property_file.file)
262
+ return custom_property_path
263
+
264
+ def get_c_program(self, request) -> Path:
265
+ c_program = "_c_program.c"
266
+ c_program_path = self.run_path / c_program
267
+ c_program_path.parent.mkdir(parents=True, exist_ok=True)
268
+ with open(c_program_path, "wb") as c_file:
269
+ c_file.write(request.c_program)
270
+ return c_program
271
+
272
+ def cancel_run(self):
273
+ if self.ready():
274
+ # Canceling a run that is already finished has no effect.
275
+ return
276
+
277
+ if self._setup_complete is None:
278
+ raise RuntimeError("Run has not been started yet.")
279
+
280
+ self._setup_complete.wait()
281
+
282
+ if self._process.is_alive():
283
+ self._process.terminate()
284
+ self._is_cancelled = True
285
+
286
+ def get_file(self, file_name: str) -> Path:
287
+ """
288
+ Finds the file with the given name in the output directory of the run.
289
+ :param file_name: The name of the file.
290
+ :return: The path to the file.
291
+ :raises FileNotFoundError: If the file does not exist.
292
+ """
293
+
294
+ file_path = self._output_dir / file_name
295
+ if not file_path.exists():
296
+ raise FileNotFoundError(f"File {file_name} not found.")
297
+ return file_path
298
+
299
+ def glob(self, name_pattern: str) -> Generator[Path, None, None]:
300
+ """
301
+ Finds all files in the output directory of the run that match the given pattern.
302
+ :param name_pattern: The pattern to match.
303
+ :return: The paths to the files.
304
+ """
305
+
306
+ return self._output_dir.glob(name_pattern)
307
+
308
+ def close(self):
309
+ """
310
+ Close the process and the result listener.
311
+ It is a StillRunningError to call this method if the process is still running.
312
+ """
313
+
314
+ if self._process is None or self.ready():
315
+ return
316
+
317
+ if self._process.is_alive():
318
+ raise StillRunningError("Process is still running.")
319
+
320
+ # Should normally not occur, as this would mean, that the process is not alive,
321
+ # but the result listener is still running.
322
+ with contextlib.suppress(ValueError):
323
+ self._process.close()
324
+
325
+ def failed(self) -> bool:
326
+ return self.ready() and not self._success
327
+
328
+ def failure(self):
329
+ """Return the error object/text when failed, else None."""
330
+ if self.failed():
331
+ return self._result
332
+ return None
@@ -0,0 +1,38 @@
1
+ # This file is part of fm-weck: executing fm-tools in containerized environments.
2
+ # https://gitlab.com/sosy-lab/software/fm-weck
3
+ #
4
+ # SPDX-FileCopyrightText: 2024 Dirk Beyer <https://www.sosy-lab.org>
5
+ #
6
+ # SPDX-License-Identifier: Apache-2.0
7
+
8
+ import logging
9
+ from threading import Lock
10
+
11
+ from .request_handling import RunHandler
12
+
13
+ RUNS_IN_PROGRESS = {}
14
+ EXCEPTIONS = {}
15
+ LOCK = Lock()
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def add_run(run_handler: RunHandler) -> str:
20
+ with LOCK:
21
+ RUNS_IN_PROGRESS[run_handler.run_id] = run_handler
22
+
23
+ return run_handler.run_id
24
+
25
+
26
+ def remove_run(run_id: str) -> None:
27
+ with LOCK:
28
+ RUNS_IN_PROGRESS.pop(run_id, None)
29
+
30
+
31
+ def get_run(run_id: str) -> RunHandler:
32
+ with LOCK:
33
+ return RUNS_IN_PROGRESS.get(run_id)
34
+
35
+
36
+ def active_runs() -> frozenset:
37
+ with LOCK:
38
+ return frozenset(RUNS_IN_PROGRESS.keys())
@@ -0,0 +1,27 @@
1
+ # This file is part of fm-weck: executing fm-tools in containerized environments.
2
+ # https://gitlab.com/sosy-lab/software/fm-weck
3
+ #
4
+ # SPDX-FileCopyrightText: 2024 Dirk Beyer <https://www.sosy-lab.org>
5
+ #
6
+ # SPDX-License-Identifier: Apache-2.0
7
+
8
+ import logging
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ TMP_DIR = Path(tempfile.gettempdir()) / "fm_weck"
13
+ TMP_DIR.mkdir(parents=True, exist_ok=True)
14
+ logger = logging.getLogger(__name__)
15
+ logger.setLevel(logging.INFO)
16
+
17
+
18
+ def read_file(file_path: Path) -> bytes:
19
+ """
20
+ Returns the content of a file as bytes.
21
+
22
+ :param file_path: The path to the file.
23
+ :return: The content of the file as bytes.
24
+ """
25
+
26
+ with open(file_path, "rb") as file:
27
+ return file.read()
fm_weck/image_mgr.py CHANGED
@@ -26,6 +26,8 @@ if TYPE_CHECKING:
26
26
 
27
27
  CONTAINERFILE = Path(__file__).parent / "resources" / "Containerfile"
28
28
 
29
+ logger = logging.getLogger(__name__)
30
+
29
31
 
30
32
  class ImageMgr(object):
31
33
  """
@@ -49,7 +51,7 @@ class ImageMgr(object):
49
51
  if not image.base_images:
50
52
  raise NoImageError("No base image specified")
51
53
 
52
- logging.info(
54
+ logger.info(
53
55
  "Building image from from base image %s with packages %s", image.base_images[0], image.required_packages
54
56
  )
55
57
  image_cmd = engine.image_from(CONTAINERFILE)
@@ -8,6 +8,5 @@
8
8
 
9
9
  ARG BASE_IMAGE
10
10
  FROM ${BASE_IMAGE}
11
- RUN apt-get update
12
11
  ARG REQUIRED_PACKAGES
13
- RUN apt-get install -y ${REQUIRED_PACKAGES} && rm -rf /var/lib/apt/lists/*
12
+ RUN apt-get update && apt-get install -y ${REQUIRED_PACKAGES} && rm -rf /var/lib/apt/lists/*
@@ -6,6 +6,7 @@
6
6
  # SPDX-License-Identifier: Apache-2.0
7
7
 
8
8
  import importlib.resources as pkg_resources
9
+ from functools import cache
9
10
  from pathlib import Path
10
11
 
11
12
  from . import fm_tools, properties
@@ -19,16 +20,33 @@ RUNEXEC_SCRIPT = "runexec"
19
20
 
20
21
 
21
22
  def iter_fm_data():
22
- for fm_data in pkg_resources.contents(fm_tools):
23
- with pkg_resources.path(fm_tools, fm_data) as fake_context_path:
24
- fm_data_path = Path(fake_context_path)
25
- if fm_data_path.is_file() and (fm_data_path.name.endswith(".yml") or fm_data_path.name.endswith(".yaml")):
26
- yield fm_data_path
23
+ for entry in pkg_resources.files(fm_tools).iterdir():
24
+ if entry.name.endswith((".yml", ".yaml")):
25
+ with pkg_resources.as_file(entry) as path:
26
+ fm_data_path = Path(path)
27
+ if fm_data_path.is_file():
28
+ yield fm_data_path
27
29
 
28
30
 
29
31
  def iter_properties():
30
- for prop in pkg_resources.contents(properties):
31
- with pkg_resources.path(properties, prop) as fake_context_path:
32
- prop_path = Path(fake_context_path)
32
+ for entry in pkg_resources.files(properties).iterdir():
33
+ with pkg_resources.as_file(entry) as path:
34
+ prop_path = Path(path)
33
35
  if prop_path.is_file():
34
36
  yield prop_path
37
+
38
+
39
+ @cache
40
+ def fm_tools_choice_map():
41
+ ignore = {
42
+ "schema.yml",
43
+ }
44
+
45
+ actors = {actor_def.stem: actor_def for actor_def in iter_fm_data() if (actor_def.name not in ignore)}
46
+
47
+ return actors
48
+
49
+
50
+ @cache
51
+ def property_choice_map():
52
+ return {spec_path.stem: spec_path for spec_path in iter_properties() if spec_path.suffix != ".license"}