fm-weck 1.4.8__py3-none-any.whl → 1.5.0__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.
fm_weck/__init__.py CHANGED
@@ -8,4 +8,4 @@
8
8
  from .config import Config # noqa: F401
9
9
  from .image_mgr import ImageMgr # noqa: F401
10
10
 
11
- __version__ = "1.4.8"
11
+ __version__ = "1.5.0"
fm_weck/capture.py ADDED
@@ -0,0 +1,31 @@
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 io
9
+ import os
10
+ import sys
11
+ from typing import Literal
12
+
13
+
14
+ class Capture:
15
+ def __init__(self, target: io.TextIOBase, stream: Literal["stdout, stderr, stdin"] = "stdout"):
16
+ self.io_stream = getattr(sys, stream)
17
+ self._copy = os.dup(self.io_stream.fileno())
18
+ os.dup2(target.fileno(), self.io_stream.fileno())
19
+
20
+ def __del__(self):
21
+ # Ensure cleanup in case the context manager isn't used
22
+ self._cleanup()
23
+
24
+ def _cleanup(self):
25
+ os.dup2(self._copy, self.io_stream.fileno())
26
+
27
+ def __enter__(self):
28
+ return self # Return the instance for use within the context
29
+
30
+ def __exit__(self, exc_type, exc_val, exc_tb):
31
+ self._cleanup() # Clean up resources upon leaving the context
fm_weck/cli.py CHANGED
@@ -11,7 +11,6 @@ import logging
11
11
  import os
12
12
  from argparse import Namespace
13
13
  from dataclasses import dataclass
14
- from functools import cache
15
14
  from pathlib import Path
16
15
  from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union
17
16
 
@@ -39,7 +38,7 @@ import contextlib
39
38
  from fm_weck import Config
40
39
  from fm_weck.cache_mgr import ask_and_clear, clear_cache
41
40
  from fm_weck.config import _SEARCH_ORDER
42
- from fm_weck.resources import iter_fm_data, iter_properties
41
+ from fm_weck.resources import fm_tools_choice_map, iter_fm_data, property_choice_map
43
42
 
44
43
  from . import __version__
45
44
  from .exceptions import NoImageError
@@ -112,9 +111,9 @@ class ShellCompletion:
112
111
  def add_tool_arg(parser, nargs: str | None = "?"):
113
112
  parser.add_argument(
114
113
  "TOOL",
115
- help="The tool to obtain the container from. Can be the form <tool>:<version>. "
114
+ help="The tool to obtain the container from. Can be in the form <tool>:<version>. "
116
115
  "The TOOL is either the name of a bundled tool (c.f. fm-weck --list) or "
117
- "the path to a fm-tools yaml file.",
116
+ "the path to an fm-tools YAML file.",
118
117
  type=ToolQualifier,
119
118
  nargs=nargs,
120
119
  ).completer = ShellCompletion.tool_completer
@@ -131,6 +130,27 @@ def add_shared_args_for_run_modes(parser):
131
130
  add_tool_arg(parser, nargs=None)
132
131
 
133
132
 
133
+ def add_shared_args_for_client(parser):
134
+ parser.add_argument(
135
+ "--host",
136
+ action="store",
137
+ dest="host",
138
+ type=str,
139
+ help=("Specifies the IP address and the port of the server."),
140
+ required=True,
141
+ default=None,
142
+ )
143
+
144
+ parser.add_argument(
145
+ "--timelimit",
146
+ action="store",
147
+ dest="timelimit",
148
+ type=str,
149
+ help=("Specifies the maximum amount of time to wait for the server to finish a run, in seconds."),
150
+ default=10,
151
+ )
152
+
153
+
134
154
  def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
135
155
  parser = argparse.ArgumentParser(description="fm-weck")
136
156
 
@@ -192,9 +212,9 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
192
212
  "--spec",
193
213
  action="store",
194
214
  help=(
195
- "Property to that is forwarded to the fm-tool."
215
+ "Property that is forwarded to the fm-tool."
196
216
  " Either a path to a property file or a property name from SV-COMP or Test-Comp."
197
- " Use fm-weck serve --list to see all properties that can be called by name."
217
+ " Use fm-weck serve --list to view all properties that can be called by name."
198
218
  ),
199
219
  required=False,
200
220
  default=None,
@@ -318,6 +338,98 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
318
338
  ).completer = ShellCompletion.versions_completer # type: ignore[assignment]
319
339
  versions.set_defaults(main=main_versions)
320
340
 
341
+ server = subparsers.add_parser("server", aliases=["s"], help="Run fm-weck remotely on a server.")
342
+ server.add_argument(
343
+ "--port",
344
+ action="store",
345
+ dest="port",
346
+ type=str,
347
+ help=("Specifies the port number on which the server will listen."),
348
+ required=True,
349
+ default=None,
350
+ )
351
+
352
+ server.add_argument(
353
+ "--listen",
354
+ action="store",
355
+ dest="ipaddr",
356
+ type=str,
357
+ help=("Specifies the IP address on which the server will listen."),
358
+ required=True,
359
+ default=None,
360
+ )
361
+ server.set_defaults(main=main_server)
362
+
363
+ client = subparsers.add_parser("remote-run", aliases=["rr"], help="Execute tasks remotely.")
364
+ client.add_argument(
365
+ "-p",
366
+ "--property",
367
+ "--spec",
368
+ action="store",
369
+ help=(
370
+ "Property that is forwarded to the fm-tool."
371
+ " Either a path to a property file or a property name from SV-COMP or Test-Comp."
372
+ " Use fm-weck serve --list to view all properties that can be called by name."
373
+ ),
374
+ required=True,
375
+ default=None,
376
+ )
377
+
378
+ add_shared_args_for_client(client)
379
+ add_tool_arg(client, nargs=None)
380
+ client.add_argument("files", metavar="FILES", nargs="+", help="Files to pass to the tool.")
381
+ client.set_defaults(main=main_client)
382
+
383
+ client_expert = subparsers.add_parser(
384
+ "remote-expert", aliases=["re"], help="Execute tasks remotely in expert mode."
385
+ )
386
+ add_shared_args_for_client(client_expert)
387
+ add_tool_arg(client_expert, nargs=None)
388
+ client_expert.add_argument("argument_list", metavar="args", nargs="*", help="Arguments for the fm-tool.")
389
+ client_expert.set_defaults(main=main_client_expert)
390
+
391
+ client_get_run = subparsers.add_parser(
392
+ "get-run", aliases=["gr"], help="Get the result for a remotely executed task."
393
+ )
394
+ add_shared_args_for_client(client_get_run)
395
+ client_get_run.add_argument("run_id", metavar="RUN-ID", nargs=1, help="The run ID for which to get the result.")
396
+ client_get_run.set_defaults(main=main_client_get_run)
397
+
398
+ client_query_files = subparsers.add_parser(
399
+ "query-files", aliases=["qf"], help="Get the resulting files for a remotely executed task."
400
+ )
401
+ client_query_files.add_argument("run_id", metavar="RUN-ID", nargs=1, help="The run ID for which to get the result.")
402
+ client_query_files.add_argument("file_names", metavar="files", nargs="*", help="Files to query for.")
403
+ add_shared_args_for_client(client_query_files)
404
+ client_query_files.add_argument(
405
+ "--output-path",
406
+ action="store",
407
+ dest="output_path",
408
+ type=Path,
409
+ help=(
410
+ "Specifies the location where the incoming files from the server will be stored, "
411
+ "relative to the current working directory."
412
+ ),
413
+ default=Path.cwd(),
414
+ )
415
+ client_query_files.set_defaults(main=main_client_query_files)
416
+
417
+ smoke_test = subparsers.add_parser("smoke-test", help="Run a smoke test on the tool.")
418
+ smoke_test.add_argument(
419
+ "TOOL",
420
+ help="The tool for which to run the smoke test.",
421
+ type=ToolQualifier,
422
+ nargs=1,
423
+ ).completer = ShellCompletion.versions_completer
424
+ smoke_test.add_argument(
425
+ "--gitlab-ci-mode",
426
+ action="store_true",
427
+ help="Run in GitLab CI mode: directly install required packages with apt instead of building/pulling images.",
428
+ required=False,
429
+ default=False,
430
+ )
431
+ smoke_test.set_defaults(main=main_smoke_test)
432
+
321
433
  with contextlib.suppress(ImportError):
322
434
  import argcomplete
323
435
 
@@ -341,22 +453,6 @@ def parse(raw_args: list[str]) -> Tuple[Callable[[], None], Namespace]:
341
453
  return help_callback, parser.parse_args(raw_args)
342
454
 
343
455
 
344
- @cache
345
- def fm_tools_choice_map():
346
- ignore = {
347
- "schema.yml",
348
- }
349
-
350
- actors = {actor_def.stem: actor_def for actor_def in iter_fm_data() if (actor_def.name not in ignore)}
351
-
352
- return actors
353
-
354
-
355
- @cache
356
- def property_choice_map():
357
- return {spec_path.stem: spec_path for spec_path in iter_properties() if spec_path.suffix != ".license"}
358
-
359
-
360
456
  def list_known_tools():
361
457
  return fm_tools_choice_map().keys()
362
458
 
@@ -380,6 +476,13 @@ def resolve_property(prop_name: str) -> Path:
380
476
  return property_choice_map()[prop_name]
381
477
 
382
478
 
479
+ def resolve_property_for_server(prop_name: str) -> Union[Path, str]:
480
+ if (as_path := Path(prop_name)).exists() and as_path.is_file():
481
+ return as_path
482
+
483
+ return prop_name
484
+
485
+
383
486
  def set_log_options(loglevel: Optional[str], logfile: Optional[str], config: dict[str, Any]):
384
487
  level = "WARNING"
385
488
  level = loglevel.upper() if loglevel else config.get("logging", {}).get("level", level)
@@ -519,6 +622,98 @@ def main_versions(args: argparse.Namespace):
519
622
  VersionListing(tool_paths).print_versions()
520
623
 
521
624
 
625
+ def main_server(args: argparse.Namespace):
626
+ from .grpc_service import serve
627
+
628
+ serve(ipaddr=args.ipaddr, port=args.port)
629
+
630
+
631
+ def main_client(args: argparse.Namespace):
632
+ from .grpc_service import client_run
633
+
634
+ tool = resolve_tool(args.TOOL)
635
+ property = resolve_property(args.property)
636
+ timelimit, _ = check_client_options(args.timelimit)
637
+
638
+ client_run((tool, args.TOOL.version), args.host, property, args.files, timelimit)
639
+
640
+
641
+ def main_client_expert(args: argparse.Namespace):
642
+ from .grpc_service import client_expert_run
643
+
644
+ tool = resolve_tool(args.TOOL)
645
+ command = args.argument_list
646
+ client_expert_run((tool, args.TOOL.version), args.host, command, args.timelimit)
647
+
648
+
649
+ def main_client_get_run(args: argparse.Namespace):
650
+ from .grpc_service import client_get_run
651
+
652
+ timelimit, _ = check_client_options(args.timelimit)
653
+ client_get_run(args.host, args.run_id[0], timelimit)
654
+
655
+
656
+ def main_client_query_files(args: argparse.Namespace):
657
+ from .grpc_service import query_files
658
+
659
+ timelimit, _ = check_client_options(args.timelimit)
660
+ file_names = args.file_names
661
+
662
+ query_files(args.host, args.run_id[0], file_names, timelimit, args.output_path)
663
+
664
+
665
+ def check_client_options(timelimit_arg, output_path=None):
666
+ try:
667
+ timelimit = int(timelimit_arg)
668
+ except ValueError:
669
+ logger.error("Invalid timelimit value: %s. It must be an integer.", timelimit_arg)
670
+ exit(1)
671
+
672
+ if output_path:
673
+ output_path = Path.cwd() / Path(output_path)
674
+ if not output_path.exists():
675
+ try:
676
+ output_path.mkdir(parents=True, exist_ok=True)
677
+ except Exception as e:
678
+ logger.error("Failed to create output path %s: %s", output_path, e)
679
+ exit(1)
680
+
681
+ return timelimit, output_path
682
+
683
+
684
+ def main_smoke_test(args: argparse.Namespace):
685
+ from .serve import setup_fm_tool
686
+ from .smoke_test_mode import run_smoke_test, run_smoke_test_gitlab_ci
687
+
688
+ try:
689
+ tool = resolve_tool(args.TOOL[0])
690
+ except KeyError:
691
+ logger.error("Unknown tool: %s", args.TOOL[0].tool)
692
+ return 1
693
+
694
+ fm_data, shelve_space = setup_fm_tool(
695
+ fm_tool=tool,
696
+ version=args.TOOL[0].version,
697
+ configuration=Config(),
698
+ )
699
+
700
+ if args.gitlab_ci_mode:
701
+ from subprocess import CalledProcessError
702
+
703
+ try:
704
+ run_smoke_test_gitlab_ci(fm_data, shelve_space)
705
+ except CalledProcessError as e:
706
+ return e.returncode
707
+
708
+ return 0
709
+
710
+ try:
711
+ run_smoke_test(fm_data, shelve_space, Config())
712
+ except ValueError as e:
713
+ logger.error(e)
714
+ return 1
715
+
716
+
522
717
  def log_no_image_error(tool, config):
523
718
  order = []
524
719
  for path in _SEARCH_ORDER:
fm_weck/engine.py CHANGED
@@ -7,17 +7,23 @@
7
7
 
8
8
  import io
9
9
  import logging
10
+ import os
10
11
  import platform
12
+ import shlex
11
13
  import shutil
12
14
  import signal
13
15
  import subprocess
14
16
  import sys
17
+ import threading
15
18
  from abc import ABC, abstractmethod
16
19
  from functools import cached_property, singledispatchmethod
17
20
  from pathlib import Path
18
21
  from tempfile import mkdtemp
22
+ from threading import Thread
19
23
  from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Union
20
24
 
25
+ from fm_weck.capture import Capture
26
+ from fm_weck.file_util import ensure_linux_style
21
27
  from fm_weck.run_result import RunResult
22
28
 
23
29
  try:
@@ -56,8 +62,11 @@ class Engine(ABC):
56
62
  print_output_to_stdout: bool = True
57
63
  add_benchexec_capabilities: bool = False
58
64
  add_mounting_capabilities: bool = True
65
+ _use_overlay: bool = False
66
+ overlay_tool_dir: Optional[str] = None
59
67
  image: Optional[str] = None
60
68
  dry_run: bool = False
69
+ work_dir: Optional[Path] = Path(CWD_MOUNT_LOCATION)
61
70
 
62
71
  def __init__(self, image: Union[str, FmImageConfig]):
63
72
  self._tmp_output_dir = Path(mkdtemp("fm_weck_output")).resolve()
@@ -77,11 +86,13 @@ class Engine(ABC):
77
86
  def get_workdir(self) -> str:
78
87
  return Path(CWD_MOUNT_LOCATION).as_posix()
79
88
 
80
- def set_output_log(self, output_log: Path):
81
- self.log_file = output_log
89
+ def set_log_file(self, log_file: Path):
90
+ self.log_file = log_file
91
+ self.log_file.parent.mkdir(parents=True, exist_ok=True)
82
92
 
83
- def set_output_files_dir(self, output_log: Path):
84
- self.output_dir = output_log
93
+ def set_output_dir(self, output_dir: Path):
94
+ self.output_dir = output_dir
95
+ self.output_dir.mkdir(parents=True, exist_ok=True)
85
96
 
86
97
  def mount(self, source: str, target: str):
87
98
  self.extra_args["mounts"] = self.extra_args.get("mounts", []) + [
@@ -120,6 +131,10 @@ class Engine(ABC):
120
131
  def add_environment(self):
121
132
  return sum([["-e", f"{key}={value}"] for key, value in self.env.items()], [])
122
133
 
134
+ def use_overlay(self, overlay_dir: str):
135
+ self._use_overlay = True
136
+ self.overlay_tool_dir = overlay_dir
137
+
123
138
  def setup_command(self):
124
139
  return [
125
140
  "--security-opt",
@@ -184,9 +199,32 @@ class Engine(ABC):
184
199
  base.append(value)
185
200
 
186
201
  _command = self._prep_command(command)
202
+
203
+ if self._use_overlay:
204
+ _command = (f"{CACHE_MOUNT_LOCATION}/.scripts/run_with_overlay.sh", self.overlay_tool_dir, *_command)
205
+
187
206
  return base + [self.image, *_command]
188
207
 
189
- def _prep_command(self, command: Iterable[str]) -> tuple[str | Path, ...]:
208
+ def assemble_smoke_test_command(self, command: tuple[str, ...]):
209
+ base = self.base_command()
210
+ if self.add_benchexec_capabilities:
211
+ base += self.benchexec_capabilities()
212
+
213
+ if self.add_mounting_capabilities:
214
+ base += self.mounting_capabilities()
215
+
216
+ if self.interactive:
217
+ base += self.interactive_command()
218
+
219
+ for value in self.extra_args.values():
220
+ if isinstance(value, list) and not isinstance(value, str):
221
+ base += value
222
+ else:
223
+ base.append(value)
224
+
225
+ return base + command
226
+
227
+ def _prep_command(self, command: tuple[str, ...]) -> tuple[str, ...]:
190
228
  """We want to map absolute paths of the current working directory to the
191
229
  working directory of the container."""
192
230
 
@@ -204,7 +242,7 @@ class Engine(ABC):
204
242
  return p
205
243
  mapped = _map_path(Path(p))
206
244
  if Path(p) == mapped:
207
- return Path(p).as_posix()
245
+ return ensure_linux_style(p)
208
246
  else:
209
247
  return Path(mapped).as_posix()
210
248
 
@@ -234,6 +272,8 @@ class Engine(ABC):
234
272
  return Docker
235
273
  if engine == "podman":
236
274
  return Podman
275
+ if engine == "runexec" or engine == "benchexec":
276
+ return Runexec
237
277
 
238
278
  raise ValueError(f"Unknown engine {engine}")
239
279
 
@@ -360,9 +400,13 @@ class Engine(ABC):
360
400
  logging.info("Received signal %s. Terminating container process.", signal_received)
361
401
  process.send_signal(signal.SIGTERM)
362
402
 
403
+ def register_signal(signum, handler) -> None:
404
+ if threading.current_thread() is threading.main_thread():
405
+ signal.signal(signum, handler)
406
+
363
407
  # Register signal handler
364
- signal.signal(signal.SIGINT, terminate_process_group)
365
- signal.signal(signal.SIGTERM, terminate_process_group)
408
+ register_signal(signal.SIGINT, terminate_process_group)
409
+ register_signal(signal.SIGTERM, terminate_process_group)
366
410
 
367
411
  logger.debug("\n\nRunning command:\n%s\n\n", " ".join(map(str, command)))
368
412
 
@@ -378,11 +422,14 @@ class Engine(ABC):
378
422
  if self.print_output_to_stdout:
379
423
  sys.stdout.write(line)
380
424
 
425
+ file_handle = None
381
426
  if self.log_file is None:
382
- run_and_poll(full_stdout.write) # type: ignore
427
+ polling_thread = Thread(target=run_and_poll, args=(full_stdout.write,))
383
428
  else:
384
- with self.log_file.open("w") as log:
385
- run_and_poll(log.write) # type: ignore
429
+ file_handle = self.log_file.open("w")
430
+ polling_thread = Thread(target=run_and_poll, args=(file_handle.write,))
431
+
432
+ polling_thread.start()
386
433
 
387
434
  assert process is not None, "Process should be defined at this point."
388
435
  try:
@@ -391,6 +438,10 @@ class Engine(ABC):
391
438
  process.terminate()
392
439
  process.wait()
393
440
 
441
+ polling_thread.join()
442
+ if file_handle is not None:
443
+ file_handle.close()
444
+
394
445
  if self.log_file is not None:
395
446
  with self.log_file.open("r") as log:
396
447
  return RunResult(command, process.returncode, log.read())
@@ -405,7 +456,7 @@ class Engine(ABC):
405
456
  logger.debug("Running: %s", command)
406
457
  if self.dry_run:
407
458
  print("Command to be executed:")
408
- print(" ".join(map(str, command)))
459
+ print(shlex.join(command))
409
460
  return RunResult(command, 0, "Dry run: no command executed.")
410
461
 
411
462
  if self.interactive or not self.handle_io:
@@ -506,3 +557,84 @@ class Docker(Engine):
506
557
  "label=disable",
507
558
  "-v /sys/fs/cgroup:/sys/fs/cgroup",
508
559
  ]
560
+
561
+
562
+ class Runexec(Engine):
563
+ def __init__(self, image: Union[str, FmImageConfig]):
564
+ super().__init__("unused")
565
+ self._engine = "runexec"
566
+
567
+ def image_from(self, containerfile: Path):
568
+ logging.info("Engine 'runexec' does not support building images. Continuing without image.")
569
+ raise NotImplementedError("runexec does not support building images.")
570
+
571
+ def benchexec_capabilities(self):
572
+ return []
573
+
574
+ def base_command(self):
575
+ return ["runexec"]
576
+
577
+ def setup_command(self):
578
+ return []
579
+
580
+ def _get_dir_modes(self):
581
+ from benchexec.container import DIR_HIDDEN, DIR_OVERLAY, DIR_READ_ONLY
582
+
583
+ dir_modes = {
584
+ "/": DIR_READ_ONLY,
585
+ "/home": DIR_HIDDEN,
586
+ os.getcwd(): DIR_OVERLAY,
587
+ }
588
+
589
+ if not Config().cache_location.resolve().is_relative_to(Path.cwd().resolve()):
590
+ dir_modes[str(Config().cache_location.resolve())] = DIR_OVERLAY
591
+
592
+ return dir_modes
593
+
594
+ def run(self, *command, timeout_sec: Optional[float] = None) -> RunResult:
595
+ import threading
596
+ from tempfile import TemporaryFile
597
+
598
+ from benchexec.containerexecutor import ContainerExecutor
599
+
600
+ executor = ContainerExecutor(
601
+ network_access=True, container_system_config=False, cgroup_access=True, dir_modes=self._get_dir_modes()
602
+ )
603
+
604
+ def signal_handler_kill(signum, frame):
605
+ executor.stop()
606
+
607
+ signal.signal(signal.SIGTERM, signal_handler_kill)
608
+ signal.signal(signal.SIGQUIT, signal_handler_kill)
609
+ signal.signal(signal.SIGINT, signal_handler_kill)
610
+
611
+ working_dir = Config().cache_location / self.overlay_tool_dir if self.overlay_tool_dir else Path.cwd()
612
+
613
+ log_creator = TemporaryFile
614
+ if self.log_file:
615
+ log_creator = self.log_file.open
616
+
617
+ log_str = ""
618
+
619
+ def stop_executor_after_timeout():
620
+ # If timeout is None, this waits indefinitely
621
+ threading.Event().wait(timeout_sec)
622
+ executor.stop()
623
+
624
+ eventual_stopping_thread = threading.Thread(target=stop_executor_after_timeout, daemon=True)
625
+
626
+ with log_creator("w+") as log, Capture(log), Capture(log, "stderr"):
627
+ eventual_stopping_thread.start()
628
+ exit_code = executor.execute_run(
629
+ args=command,
630
+ output_dir=self.output_dir,
631
+ workingDir=str(working_dir.absolute()),
632
+ )
633
+
634
+ log.seek(0)
635
+ log_str = log.read()
636
+
637
+ if self.print_output_to_stdout:
638
+ print(log_str)
639
+
640
+ return RunResult(command, exit_code.raw, log_str)
fm_weck/exceptions.py CHANGED
@@ -5,6 +5,64 @@
5
5
  #
6
6
  # SPDX-License-Identifier: Apache-2.0
7
7
 
8
+ import logging
9
+ import subprocess
10
+ import traceback
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
8
16
 
9
17
  class NoImageError(Exception):
10
18
  pass
19
+
20
+
21
+ class RunFailedError(Exception):
22
+ def __init__(self, exit_code, command, output=None):
23
+ super().__init__(f"Run failed with exit code {exit_code}: {output}")
24
+ self.exit_code = exit_code
25
+ self.command = command
26
+ self.output = output
27
+
28
+
29
+ @dataclass
30
+ class Failure:
31
+ kind: str # e.g., "IMAGE_NOT_FOUND", "CALLED_PROCESS_ERROR", "EXCEPTION"
32
+ message: str # short, user-facing
33
+ detail: Optional[str] = None # optional verbose info (stderr/traceback)
34
+
35
+
36
+ def failure_from_exception(e: BaseException) -> Failure:
37
+ # Special cases first
38
+ if isinstance(e, NoImageError):
39
+ failure = Failure("IMAGE_NOT_FOUND", str(e))
40
+ elif isinstance(e, RunFailedError):
41
+ failure = Failure("RUN_FAILED", f"Run failed with exit code {e.exit_code}", detail=e.output)
42
+ elif isinstance(e, subprocess.CalledProcessError):
43
+ # Try to surface stderr/stdout
44
+ stderr = e.stderr if hasattr(e, "stderr") else None
45
+ stdout = e.stdout if hasattr(e, "stdout") else None
46
+ text = stderr or stdout or str(e)
47
+ failure = Failure("CALLED_PROCESS_ERROR", f"Tool failed with exit code {e.returncode}", detail=text)
48
+ else:
49
+ # Generic fallback
50
+ failure = Failure("EXCEPTION", str(e), detail="".join(traceback.format_exception(type(e), e, e.__traceback__)))
51
+
52
+ logger.error(f"[failure] kind={failure.kind}: {failure.message}")
53
+ return failure
54
+
55
+
56
+ def failure_to_error_code(failure: "Failure"):
57
+ from fm_weck.grpc_service.proto.fm_weck_service_pb2 import ErrorCode
58
+
59
+ FAILURE_KIND_TO_ERROR_CODE = {
60
+ "RUN_NOT_TERMINATED": ErrorCode.EC_RUN_NOT_TERMINATED,
61
+ "RUN_NOT_FOUND": ErrorCode.EC_RUN_NOT_FOUND,
62
+ "RUN_CANCELLED": ErrorCode.EC_RUN_CANCELLED,
63
+ "RUN_FAILED": ErrorCode.EC_RUN_FAILED,
64
+ "CALLED_PROCESS_ERROR": ErrorCode.EC_RUN_FAILED,
65
+ "IMAGE_NOT_FOUND": ErrorCode.EC_RUN_FAILED,
66
+ "EXCEPTION": ErrorCode.EC_UNKNOWN_ERROR,
67
+ }
68
+ return FAILURE_KIND_TO_ERROR_CODE.get(failure.kind, ErrorCode.EC_RUN_FAILED)
fm_weck/file_util.py CHANGED
@@ -21,3 +21,8 @@ def copy_ensuring_unix_line_endings(src: Path, dst: Path) -> None:
21
21
 
22
22
  with open(dst, "wb") as dst_file:
23
23
  dst_file.write(content)
24
+
25
+
26
+ def ensure_linux_style(path: str) -> str:
27
+ """Ensure that the given path uses Linux-style forward slashes."""
28
+ return path.replace("\\", "/")
@@ -0,0 +1,9 @@
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
+ from .fm_weck_client import client_expert_run, client_get_run, client_run, query_files # noqa: F401
9
+ from .fm_weck_server import serve # noqa: F401