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.
- fm_weck/__init__.py +1 -1
- fm_weck/capture.py +31 -0
- fm_weck/cli.py +279 -22
- fm_weck/engine.py +144 -12
- fm_weck/exceptions.py +58 -0
- fm_weck/file_util.py +5 -0
- fm_weck/grpc_service/__init__.py +9 -0
- fm_weck/grpc_service/fm_weck_client.py +148 -0
- fm_weck/grpc_service/fm_weck_server.py +175 -0
- fm_weck/grpc_service/proto/__init__.py +0 -0
- fm_weck/grpc_service/proto/fm_weck_service.proto +139 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2.py +73 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2.pyi +151 -0
- fm_weck/grpc_service/proto/fm_weck_service_pb2_grpc.py +331 -0
- fm_weck/grpc_service/proto/generate_protocol_files.sh +18 -0
- fm_weck/grpc_service/request_handling.py +332 -0
- fm_weck/grpc_service/run_store.py +38 -0
- fm_weck/grpc_service/server_utils.py +27 -0
- fm_weck/image_mgr.py +3 -1
- fm_weck/resources/Containerfile +1 -2
- fm_weck/resources/__init__.py +26 -8
- fm_weck/resources/c_program_example.c +627 -0
- fm_weck/run_result.py +1 -1
- fm_weck/serve.py +21 -14
- fm_weck/smoke_test_mode.py +82 -0
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/METADATA +3 -1
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/RECORD +29 -14
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/WHEEL +0 -0
- {fm_weck-1.4.8.dist-info → fm_weck-1.5.1.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
-
|
|
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)
|
fm_weck/resources/Containerfile
CHANGED
fm_weck/resources/__init__.py
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
31
|
-
with pkg_resources.
|
|
32
|
-
prop_path = 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"}
|