fractal-server 2.5.0a1__py3-none-any.whl → 2.5.2__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.
@@ -1 +1 @@
1
- __VERSION__ = "2.5.0a1"
1
+ __VERSION__ = "2.5.2"
@@ -238,6 +238,18 @@ async def apply_workflow(
238
238
  await db.merge(job)
239
239
  await db.commit()
240
240
 
241
+ # User appropriate FractalSSH object
242
+ if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
243
+ ssh_credentials = dict(
244
+ user=settings.FRACTAL_SLURM_SSH_USER,
245
+ host=settings.FRACTAL_SLURM_SSH_HOST,
246
+ key_path=settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH,
247
+ )
248
+ fractal_ssh_list = request.app.state.fractal_ssh_list
249
+ fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
250
+ else:
251
+ fractal_ssh = None
252
+
241
253
  background_tasks.add_task(
242
254
  submit_workflow,
243
255
  workflow_id=workflow.id,
@@ -246,7 +258,7 @@ async def apply_workflow(
246
258
  worker_init=job.worker_init,
247
259
  slurm_user=user.slurm_user,
248
260
  user_cache_dir=user.cache_dir,
249
- fractal_ssh=request.app.state.fractal_ssh,
261
+ fractal_ssh=fractal_ssh,
250
262
  )
251
263
  request.app.state.jobsV2.append(job.id)
252
264
  logger.info(
@@ -124,11 +124,20 @@ async def collect_tasks_pip(
124
124
  db.add(state)
125
125
  await db.commit()
126
126
 
127
+ # User appropriate FractalSSH object
128
+ ssh_credentials = dict(
129
+ user=settings.FRACTAL_SLURM_SSH_USER,
130
+ host=settings.FRACTAL_SLURM_SSH_HOST,
131
+ key_path=settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH,
132
+ )
133
+ fractal_ssh_list = request.app.state.fractal_ssh_list
134
+ fractal_ssh = fractal_ssh_list.get(**ssh_credentials)
135
+
127
136
  background_tasks.add_task(
128
137
  background_collect_pip_ssh,
129
138
  state.id,
130
139
  task_pkg,
131
- request.app.state.fractal_ssh,
140
+ fractal_ssh,
132
141
  )
133
142
 
134
143
  response.status_code = status.HTTP_201_CREATED
@@ -20,12 +20,12 @@ from ....schemas.v2 import TaskCreateV2
20
20
  from ....schemas.v2 import TaskReadV2
21
21
  from fractal_server.app.models import UserOAuth
22
22
  from fractal_server.app.routes.auth import current_active_verified_user
23
+ from fractal_server.string_tools import validate_cmd
23
24
  from fractal_server.tasks.v2.background_operations import _insert_tasks
24
25
  from fractal_server.tasks.v2.background_operations import (
25
26
  _prepare_tasks_metadata,
26
27
  )
27
28
 
28
-
29
29
  router = APIRouter()
30
30
 
31
31
  logger = set_logger(__name__)
@@ -74,6 +74,7 @@ async def collect_task_custom(
74
74
  package_name_underscore = task_collect.package_name.replace("-", "_")
75
75
  # Note that python_command is then used as part of a subprocess.run
76
76
  # statement: be careful with mixing `'` and `"`.
77
+ validate_cmd(package_name_underscore)
77
78
  python_command = (
78
79
  "import importlib.util; "
79
80
  "from pathlib import Path; "
@@ -48,7 +48,7 @@ def create_tar_archive(
48
48
  "."
49
49
  )
50
50
  logger.debug(f"cmd tar:\n{cmd_tar}")
51
- run_subprocess(cmd=cmd_tar, logger_name=logger_name)
51
+ run_subprocess(cmd=cmd_tar, logger_name=logger_name, allow_char="*")
52
52
 
53
53
 
54
54
  def remove_temp_subfolder(subfolder_path_tmp_copy: Path, logger_name: str):
@@ -163,17 +163,34 @@ class FractalSlurmSSHExecutor(SlurmExecutor):
163
163
  settings = Inject(get_settings)
164
164
  self.python_remote = settings.FRACTAL_SLURM_WORKER_PYTHON
165
165
  if self.python_remote is None:
166
+ self._stop_and_join_wait_thread()
166
167
  raise ValueError("FRACTAL_SLURM_WORKER_PYTHON is not set. Exit.")
167
168
 
168
169
  # Initialize connection and perform handshake
169
170
  self.fractal_ssh = fractal_ssh
170
171
  logger.warning(self.fractal_ssh)
171
- self.handshake()
172
+ try:
173
+ self.handshake()
174
+ except Exception as e:
175
+ logger.warning(
176
+ "Stop/join waiting thread and then "
177
+ f"re-raise original error {str(e)}"
178
+ )
179
+ self._stop_and_join_wait_thread()
180
+ raise e
172
181
 
173
182
  # Set/validate parameters for SLURM submission scripts
174
183
  self.slurm_account = slurm_account
175
184
  self.common_script_lines = common_script_lines or []
176
- self._validate_common_script_lines()
185
+ try:
186
+ self._validate_common_script_lines()
187
+ except Exception as e:
188
+ logger.warning(
189
+ "Stop/join waiting thread and then "
190
+ f"re-raise original error {str(e)}"
191
+ )
192
+ self._stop_and_join_wait_thread()
193
+ raise e
177
194
 
178
195
  # Set/initialize some more options
179
196
  self.keep_pickle_files = keep_pickle_files
@@ -1385,6 +1402,10 @@ class FractalSlurmSSHExecutor(SlurmExecutor):
1385
1402
  self.fractal_ssh.run_command(cmd=scancel_command)
1386
1403
  logger.debug("Executor shutdown: end")
1387
1404
 
1405
+ def _stop_and_join_wait_thread(self):
1406
+ self.wait_thread.stop()
1407
+ self.wait_thread.join()
1408
+
1388
1409
  def __exit__(self, *args, **kwargs):
1389
1410
  """
1390
1411
  See
@@ -1393,8 +1414,7 @@ class FractalSlurmSSHExecutor(SlurmExecutor):
1393
1414
  logger.debug(
1394
1415
  "[FractalSlurmSSHExecutor.__exit__] Stop and join `wait_thread`"
1395
1416
  )
1396
- self.wait_thread.stop()
1397
- self.wait_thread.join()
1417
+ self._stop_and_join_wait_thread()
1398
1418
  logger.debug("[FractalSlurmSSHExecutor.__exit__] End")
1399
1419
 
1400
1420
  def run_squeue(self, job_ids):
@@ -20,6 +20,7 @@ import subprocess # nosec
20
20
  from typing import Optional
21
21
 
22
22
  from ......logger import set_logger
23
+ from fractal_server.string_tools import validate_cmd
23
24
 
24
25
  logger = set_logger(__name__)
25
26
 
@@ -47,6 +48,7 @@ def _run_command_as_user(
47
48
  Returns:
48
49
  res: The return value from `subprocess.run`.
49
50
  """
51
+ validate_cmd(cmd)
50
52
  logger.debug(f'[_run_command_as_user] {user=}, cmd="{cmd}"')
51
53
  if user:
52
54
  new_cmd = f"sudo --set-home --non-interactive -u {user} {cmd}"
@@ -47,6 +47,7 @@ from ._subprocess_run_as_user import _path_exists_as_user
47
47
  from ._subprocess_run_as_user import _run_command_as_user
48
48
  from fractal_server import __VERSION__
49
49
  from fractal_server.app.runner.components import _COMPONENT_KEY_
50
+ from fractal_server.string_tools import validate_cmd
50
51
 
51
52
 
52
53
  logger = set_logger(__name__)
@@ -65,6 +66,7 @@ def _subprocess_run_or_raise(full_command: str) -> Optional[CompletedProcess]:
65
66
  Returns:
66
67
  The actual `CompletedProcess` output of `subprocess.run`.
67
68
  """
69
+ validate_cmd(full_command)
68
70
  try:
69
71
  output = subprocess.run( # nosec
70
72
  shlex.split(full_command),
@@ -257,6 +259,7 @@ class FractalSlurmExecutor(SlurmExecutor):
257
259
  for line in self.common_script_lines
258
260
  if line.startswith("#SBATCH --account=")
259
261
  )
262
+ self._stop_and_join_wait_thread()
260
263
  raise RuntimeError(
261
264
  "Invalid line in `FractalSlurmExecutor.common_script_lines`: "
262
265
  f"'{invalid_line}'.\n"
@@ -1266,6 +1269,7 @@ class FractalSlurmExecutor(SlurmExecutor):
1266
1269
  pre_command = f"sudo --non-interactive -u {self.slurm_user}"
1267
1270
  submit_command = f"scancel {scancel_string}"
1268
1271
  full_command = f"{pre_command} {submit_command}"
1272
+ validate_cmd(full_command)
1269
1273
  logger.debug(f"Now execute `{full_command}`")
1270
1274
  try:
1271
1275
  subprocess.run( # nosec
@@ -1284,6 +1288,10 @@ class FractalSlurmExecutor(SlurmExecutor):
1284
1288
 
1285
1289
  logger.debug("Executor shutdown: end")
1286
1290
 
1291
+ def _stop_and_join_wait_thread(self):
1292
+ self.wait_thread.stop()
1293
+ self.wait_thread.join()
1294
+
1287
1295
  def __exit__(self, *args, **kwargs):
1288
1296
  """
1289
1297
  See
@@ -1292,6 +1300,5 @@ class FractalSlurmExecutor(SlurmExecutor):
1292
1300
  logger.debug(
1293
1301
  "[FractalSlurmExecutor.__exit__] Stop and join `wait_thread`"
1294
1302
  )
1295
- self.wait_thread.stop()
1296
- self.wait_thread.join()
1303
+ self._stop_and_join_wait_thread()
1297
1304
  logger.debug("[FractalSlurmExecutor.__exit__] End")
@@ -3,11 +3,15 @@ import subprocess # nosec
3
3
  from typing import Optional
4
4
 
5
5
  from fractal_server.logger import get_logger
6
+ from fractal_server.string_tools import validate_cmd
6
7
 
7
8
 
8
9
  def run_subprocess(
9
- cmd: str, logger_name: Optional[str] = None
10
+ cmd: str,
11
+ allow_char: Optional[str] = None,
12
+ logger_name: Optional[str] = None,
10
13
  ) -> subprocess.CompletedProcess:
14
+ validate_cmd(cmd, allow_char=allow_char)
11
15
  logger = get_logger(logger_name)
12
16
  try:
13
17
  res = subprocess.run( # nosec
@@ -31,6 +31,7 @@ from .common import write_args_file
31
31
  from fractal_server.app.runner.filenames import HISTORY_FILENAME
32
32
  from fractal_server.app.runner.filenames import METADATA_FILENAME
33
33
  from fractal_server.app.runner.task_files import get_task_file_paths
34
+ from fractal_server.string_tools import validate_cmd
34
35
 
35
36
 
36
37
  def no_op_submit_setup_call(
@@ -77,6 +78,7 @@ def _call_command_wrapper(cmd: str, stdout: Path, stderr: Path) -> None:
77
78
  TERM or KILL signal)
78
79
  """
79
80
 
81
+ validate_cmd(cmd)
80
82
  # Verify that task command is executable
81
83
  if shutil.which(shlex_split(cmd)[0]) is None:
82
84
  msg = (
@@ -12,6 +12,7 @@ from ..exceptions import JobExecutionError
12
12
  from ..exceptions import TaskExecutionError
13
13
  from fractal_server.app.models.v2 import WorkflowTaskV2
14
14
  from fractal_server.app.runner.task_files import get_task_file_paths
15
+ from fractal_server.string_tools import validate_cmd
15
16
 
16
17
 
17
18
  def _call_command_wrapper(cmd: str, log_path: Path) -> None:
@@ -25,6 +26,10 @@ def _call_command_wrapper(cmd: str, log_path: Path) -> None:
25
26
  exit code (e.g. due to the subprocess receiving a
26
27
  TERM or KILL signal)
27
28
  """
29
+ try:
30
+ validate_cmd(cmd)
31
+ except ValueError as e:
32
+ raise TaskExecutionError(f"Invalid command. Original error: {str(e)}")
28
33
 
29
34
  # Verify that task command is executable
30
35
  if shutil.which(shlex_split(cmd)[0]) is None:
@@ -10,7 +10,7 @@ from pydantic.types import StrictStr
10
10
  from ._validators import val_absolute_path
11
11
  from ._validators import val_unique_list
12
12
  from ._validators import valstr
13
-
13
+ from fractal_server.string_tools import validate_cmd
14
14
 
15
15
  __all__ = (
16
16
  "UserRead",
@@ -77,14 +77,16 @@ class UserUpdate(schemas.BaseUserUpdate):
77
77
  valstr("slurm_user")
78
78
  )
79
79
  _username = validator("username", allow_reuse=True)(valstr("username"))
80
- _cache_dir = validator("cache_dir", allow_reuse=True)(
81
- val_absolute_path("cache_dir")
82
- )
83
80
 
84
81
  _slurm_accounts = validator("slurm_accounts", allow_reuse=True)(
85
82
  val_unique_list("slurm_accounts")
86
83
  )
87
84
 
85
+ @validator("cache_dir")
86
+ def cache_dir_validator(cls, value):
87
+ validate_cmd(value)
88
+ return val_absolute_path("cache_dir")(value)
89
+
88
90
  @validator(
89
91
  "is_active",
90
92
  "is_verified",
@@ -115,9 +117,10 @@ class UserUpdateStrict(BaseModel, extra=Extra.forbid):
115
117
  val_unique_list("slurm_accounts")
116
118
  )
117
119
 
118
- _cache_dir = validator("cache_dir", allow_reuse=True)(
119
- val_absolute_path("cache_dir")
120
- )
120
+ @validator("cache_dir")
121
+ def cache_dir_validator(cls, value):
122
+ validate_cmd(value)
123
+ return val_absolute_path("cache_dir")(value)
121
124
 
122
125
 
123
126
  class UserUpdateWithNewGroupIds(UserUpdate):
@@ -157,6 +160,8 @@ class UserCreate(schemas.BaseUserCreate):
157
160
  valstr("slurm_user")
158
161
  )
159
162
  _username = validator("username", allow_reuse=True)(valstr("username"))
160
- _cache_dir = validator("cache_dir", allow_reuse=True)(
161
- val_absolute_path("cache_dir")
162
- )
163
+
164
+ @validator("cache_dir")
165
+ def cache_dir_validator(cls, value):
166
+ validate_cmd(value)
167
+ return val_absolute_path("cache_dir")(value)
@@ -39,14 +39,23 @@ class TaskDumpV2(BaseModel):
39
39
 
40
40
 
41
41
  class WorkflowTaskDumpV2(BaseModel):
42
+ """
43
+ Before v2.5.0, WorkflowTaskV2 could have `task_id=task=None` and
44
+ non-`None` `task_legacy_id` and `task_legacy`. Since these objects
45
+ may still exist in the database after version updates, we are setting
46
+ `task_id` and `task` to `Optional` to avoid response-validation errors
47
+ for the endpoints that GET datasets.
48
+ Ref issue #1783.
49
+ """
50
+
42
51
  id: int
43
52
  workflow_id: int
44
53
  order: Optional[int]
45
54
 
46
55
  input_filters: Filters
47
56
 
48
- task_id: int
49
- task: TaskDumpV2
57
+ task_id: Optional[int]
58
+ task: Optional[TaskDumpV2]
50
59
 
51
60
 
52
61
  class WorkflowDumpV2(BaseModel, extra=Extra.forbid):
@@ -11,6 +11,7 @@ from pydantic import validator
11
11
 
12
12
  from .._validators import valdictkeys
13
13
  from .._validators import valstr
14
+ from fractal_server.string_tools import validate_cmd
14
15
 
15
16
 
16
17
  class TaskCreateV2(BaseModel, extra=Extra.forbid):
@@ -43,6 +44,11 @@ class TaskCreateV2(BaseModel, extra=Extra.forbid):
43
44
  "Task must have at least one valid command "
44
45
  "(parallel and/or non_parallel)"
45
46
  )
47
+ if command_parallel is not None:
48
+ validate_cmd(command_parallel)
49
+ if command_non_parallel is not None:
50
+ validate_cmd(command_non_parallel)
51
+
46
52
  return values
47
53
 
48
54
  _name = validator("name", allow_reuse=True)(valstr("name"))
fractal_server/main.py CHANGED
@@ -92,32 +92,34 @@ async def lifespan(app: FastAPI):
92
92
  settings = Inject(get_settings)
93
93
 
94
94
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
95
- from fractal_server.ssh._fabric import get_ssh_connection
96
- from fractal_server.ssh._fabric import FractalSSH
97
95
 
98
- connection = get_ssh_connection()
99
- app.state.fractal_ssh = FractalSSH(connection=connection)
96
+ from fractal_server.ssh._fabric import FractalSSHList
97
+
98
+ app.state.fractal_ssh_list = FractalSSHList()
99
+
100
100
  logger.info(
101
- f"Created SSH connection "
102
- f"({app.state.fractal_ssh.is_connected=})."
101
+ "Added empty FractalSSHList to app.state "
102
+ f"(id={id(app.state.fractal_ssh_list)})."
103
103
  )
104
104
  else:
105
- app.state.fractal_ssh = None
105
+ app.state.fractal_ssh_list = None
106
106
 
107
107
  config_uvicorn_loggers()
108
108
  logger.info("End application startup")
109
109
  reset_logger_handlers(logger)
110
+
110
111
  yield
112
+
111
113
  logger = get_logger("fractal_server.lifespan")
112
114
  logger.info("Start application shutdown")
113
115
 
114
116
  if settings.FRACTAL_RUNNER_BACKEND == "slurm_ssh":
115
117
  logger.info(
116
- f"Closing SSH connection "
117
- f"(current: {app.state.fractal_ssh.is_connected=})."
118
+ "Close FractalSSH connections "
119
+ f"(current size: {app.state.fractal_ssh_list.size})."
118
120
  )
119
121
 
120
- app.state.fractal_ssh.close()
122
+ app.state.fractal_ssh_list.close_all()
121
123
 
122
124
  logger.info(
123
125
  f"Current worker with pid {os.getpid()} is shutting down. "
@@ -16,19 +16,21 @@ from paramiko.ssh_exception import NoValidConnectionsError
16
16
 
17
17
  from ..logger import get_logger
18
18
  from ..logger import set_logger
19
- from fractal_server.config import get_settings
20
- from fractal_server.syringe import Inject
19
+ from fractal_server.string_tools import validate_cmd
21
20
 
22
21
 
23
22
  class FractalSSHTimeoutError(RuntimeError):
24
23
  pass
25
24
 
26
25
 
26
+ class FractalSSHListTimeoutError(RuntimeError):
27
+ pass
28
+
29
+
27
30
  logger = set_logger(__name__)
28
31
 
29
32
 
30
33
  class FractalSSH(object):
31
-
32
34
  """
33
35
  FIXME SSH: Fix docstring
34
36
 
@@ -110,7 +112,6 @@ class FractalSSH(object):
110
112
  def run(
111
113
  self, *args, lock_timeout: Optional[float] = None, **kwargs
112
114
  ) -> Any:
113
-
114
115
  actual_lock_timeout = self.default_lock_timeout
115
116
  if lock_timeout is not None:
116
117
  actual_lock_timeout = lock_timeout
@@ -137,12 +138,25 @@ class FractalSSH(object):
137
138
  )
138
139
 
139
140
  def close(self) -> None:
140
- return self._connection.close()
141
+ """
142
+ Aggressively close `self._connection`.
143
+
144
+ When `Connection.is_connected` is `False`, `Connection.close()` does
145
+ not call `Connection.client.close()`. Thus we do this explicitly here,
146
+ because we observed cases where `is_connected=False` but the underlying
147
+ `Transport` object was not closed.
148
+ """
149
+
150
+ self._connection.close()
151
+
152
+ if self._connection.client is not None:
153
+ self._connection.client.close()
141
154
 
142
155
  def run_command(
143
156
  self,
144
157
  *,
145
158
  cmd: str,
159
+ allow_char: Optional[str] = None,
146
160
  max_attempts: Optional[int] = None,
147
161
  base_interval: Optional[int] = None,
148
162
  lock_timeout: Optional[int] = None,
@@ -152,6 +166,7 @@ class FractalSSH(object):
152
166
 
153
167
  Args:
154
168
  cmd: Command to be run
169
+ allow_char: Forbidden chars to allow for this command
155
170
  max_attempts:
156
171
  base_interval:
157
172
  lock_timeout:
@@ -159,6 +174,9 @@ class FractalSSH(object):
159
174
  Returns:
160
175
  Standard output of the command, if successful.
161
176
  """
177
+
178
+ validate_cmd(cmd, allow_char=allow_char)
179
+
162
180
  actual_max_attempts = self.default_max_attempts
163
181
  if max_attempts is not None:
164
182
  actual_max_attempts = max_attempts
@@ -329,36 +347,169 @@ class FractalSSH(object):
329
347
  f.write(content)
330
348
 
331
349
 
332
- def get_ssh_connection(
333
- *,
334
- host: Optional[str] = None,
335
- user: Optional[str] = None,
336
- key_filename: Optional[str] = None,
337
- ) -> Connection:
350
+ class FractalSSHList(object):
338
351
  """
339
- Create a `fabric.Connection` object based on fractal-server settings
340
- or explicit arguments.
352
+ Collection of `FractalSSH` objects
341
353
 
342
- Args:
343
- host:
344
- user:
345
- key_filename:
354
+ Attributes are all private, and access to this collection must be
355
+ through methods (mostly the `get` one).
346
356
 
347
- Returns:
348
- Fabric connection object
357
+ Attributes:
358
+ _data:
359
+ Mapping of unique keys (the SSH-credentials tuples) to
360
+ `FractalSSH` objects.
361
+ _lock:
362
+ A `threading.Lock object`, to be acquired when changing `_data`.
363
+ _timeout: Timeout for `_lock` acquisition.
364
+ _logger_name: Logger name.
349
365
  """
350
- settings = Inject(get_settings)
351
- if host is None:
352
- host = settings.FRACTAL_SLURM_SSH_HOST
353
- if user is None:
354
- user = settings.FRACTAL_SLURM_SSH_USER
355
- if key_filename is None:
356
- key_filename = settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH
357
-
358
- connection = Connection(
359
- host=host,
360
- user=user,
361
- forward_agent=False,
362
- connect_kwargs={"key_filename": key_filename},
363
- )
364
- return connection
366
+
367
+ _data: dict[tuple[str, str, str], FractalSSH]
368
+ _lock: Lock
369
+ _timeout: float
370
+ _logger_name: str
371
+
372
+ def __init__(
373
+ self,
374
+ *,
375
+ timeout: float = 5.0,
376
+ logger_name: str = "fractal_server.FractalSSHList",
377
+ ):
378
+ self._lock = Lock()
379
+ self._data = {}
380
+ self._timeout = timeout
381
+ self._logger_name = logger_name
382
+ set_logger(self._logger_name)
383
+
384
+ @property
385
+ def logger(self) -> logging.Logger:
386
+ """
387
+ This property exists so that we never have to propagate the
388
+ `Logger` object.
389
+ """
390
+ return get_logger(self._logger_name)
391
+
392
+ @property
393
+ def size(self) -> int:
394
+ """
395
+ Number of current key-value pairs in `self._data`.
396
+ """
397
+ return len(self._data.values())
398
+
399
+ def get(self, *, host: str, user: str, key_path: str) -> FractalSSH:
400
+ """
401
+ Get the `FractalSSH` for the current credentials, or create one.
402
+
403
+ Note: Changing `_data` requires acquiring `_lock`.
404
+
405
+ Arguments:
406
+ host:
407
+ user:
408
+ key_path:
409
+ """
410
+ key = (host, user, key_path)
411
+ fractal_ssh = self._data.get(key, None)
412
+ if fractal_ssh is not None:
413
+ self.logger.info(
414
+ f"Return existing FractalSSH object for {user}@{host}"
415
+ )
416
+ return fractal_ssh
417
+ else:
418
+ self.logger.info(f"Add new FractalSSH object for {user}@{host}")
419
+ connection = Connection(
420
+ host=host,
421
+ user=user,
422
+ forward_agent=False,
423
+ connect_kwargs={
424
+ "key_filename": key_path,
425
+ "look_for_keys": False,
426
+ },
427
+ )
428
+ with self.acquire_lock_with_timeout():
429
+ self._data[key] = FractalSSH(connection=connection)
430
+ return self._data[key]
431
+
432
+ def contains(
433
+ self,
434
+ *,
435
+ host: str,
436
+ user: str,
437
+ key_path: str,
438
+ ) -> bool:
439
+ """
440
+ Return whether a given key is present in the collection.
441
+
442
+ Arguments:
443
+ host:
444
+ user:
445
+ key_path:
446
+ """
447
+ key = (host, user, key_path)
448
+ return key in self._data.keys()
449
+
450
+ def remove(
451
+ self,
452
+ *,
453
+ host: str,
454
+ user: str,
455
+ key_path: str,
456
+ ) -> None:
457
+ """
458
+ Remove a key from `_data` and close the corresponding connection.
459
+
460
+ Note: Changing `_data` requires acquiring `_lock`.
461
+
462
+ Arguments:
463
+ host:
464
+ user:
465
+ key_path:
466
+ """
467
+ key = (host, user, key_path)
468
+ with self.acquire_lock_with_timeout():
469
+ self.logger.info(
470
+ f"Removing FractalSSH object for {user}@{host} "
471
+ "from collection."
472
+ )
473
+ fractal_ssh_obj = self._data.pop(key)
474
+ self.logger.info(
475
+ f"Closing FractalSSH object for {user}@{host} "
476
+ f"({fractal_ssh_obj.is_connected=})."
477
+ )
478
+ fractal_ssh_obj.close()
479
+
480
+ def close_all(self, *, timeout: float = 5.0):
481
+ """
482
+ Close all `FractalSSH` objects in the collection.
483
+
484
+ Arguments:
485
+ timeout:
486
+ Timeout for `FractalSSH._lock` acquisition, to be obtained
487
+ before closing.
488
+ """
489
+ for key, fractal_ssh_obj in self._data.items():
490
+ host, user, _ = key[:]
491
+ self.logger.info(
492
+ f"Closing FractalSSH object for {user}@{host} "
493
+ f"({fractal_ssh_obj.is_connected=})."
494
+ )
495
+ with fractal_ssh_obj.acquire_timeout(timeout=timeout):
496
+ fractal_ssh_obj.close()
497
+
498
+ @contextmanager
499
+ def acquire_lock_with_timeout(self) -> Generator[Literal[True], Any, None]:
500
+ self.logger.debug(
501
+ f"Trying to acquire lock, with timeout {self._timeout} s"
502
+ )
503
+ result = self._lock.acquire(timeout=self._timeout)
504
+ try:
505
+ if not result:
506
+ self.logger.error("Lock was *NOT* acquired.")
507
+ raise FractalSSHListTimeoutError(
508
+ f"Failed to acquire lock within {self._timeout} ss"
509
+ )
510
+ self.logger.debug("Lock was acquired.")
511
+ yield result
512
+ finally:
513
+ if result:
514
+ self._lock.release()
515
+ self.logger.debug("Lock was released")
@@ -1,7 +1,12 @@
1
1
  import string
2
+ from typing import Optional
2
3
 
3
4
  __SPECIAL_CHARACTERS__ = f"{string.punctuation}{string.whitespace}"
4
5
 
6
+ # List of invalid characters discussed here:
7
+ # https://github.com/fractal-analytics-platform/fractal-server/issues/1647
8
+ __NOT_ALLOWED_FOR_COMMANDS__ = r"`#$&*()\|[]{};<>?!"
9
+
5
10
 
6
11
  def sanitize_string(value: str) -> str:
7
12
  """
@@ -43,3 +48,23 @@ def slugify_task_name_for_source(task_name: str) -> str:
43
48
  Slug-ified task name.
44
49
  """
45
50
  return task_name.replace(" ", "_").lower()
51
+
52
+
53
+ def validate_cmd(command: str, allow_char: Optional[str] = None):
54
+ """
55
+ Assert that the provided `command` does not contain any of the forbidden
56
+ characters for commands
57
+ (fractal_server.string_tools.__NOT_ALLOWED_FOR_COMMANDS__)
58
+
59
+ Args:
60
+ command: command to validate.
61
+ allow_char: chars to accept among the forbidden ones
62
+ """
63
+ forbidden = set(__NOT_ALLOWED_FOR_COMMANDS__)
64
+ if allow_char is not None:
65
+ forbidden = forbidden - set(allow_char)
66
+ if not forbidden.isdisjoint(set(command)):
67
+ raise ValueError(
68
+ f"Command must not contain any of this characters: '{forbidden}'\n"
69
+ f"Provided command: '{command}'."
70
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fractal-server
3
- Version: 2.5.0a1
3
+ Version: 2.5.2
4
4
  Summary: Server component of the Fractal analytics platform
5
5
  Home-page: https://github.com/fractal-analytics-platform/fractal-server
6
6
  License: BSD-3-Clause
@@ -1,4 +1,4 @@
1
- fractal_server/__init__.py,sha256=1fM-hrX8GvZ3DcdEUtfHmLjQ5GtgW0uU1Sqd6Va9Mbo,24
1
+ fractal_server/__init__.py,sha256=zW_UoRr0gmuphO3yp_Nmzq1qV6ZNQIt_3zHJMXwFtIM,22
2
2
  fractal_server/__main__.py,sha256=upYBkGYrkBnkS1rp4D_nb_1LS37QT4j-wxGX1ZMvR4A,5704
3
3
  fractal_server/alembic.ini,sha256=MWwi7GzjzawI9cCAK1LW7NxIBQDUqD12-ptJoq5JpP0,3153
4
4
  fractal_server/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -43,10 +43,10 @@ fractal_server/app/routes/api/v2/images.py,sha256=JR1rR6qEs81nacjriOXAOBQjAbCXF4
43
43
  fractal_server/app/routes/api/v2/job.py,sha256=Bga2Kz1OjvDIdxZObWaaXVhNIhC_5JKhKRjEH2_ayEE,5157
44
44
  fractal_server/app/routes/api/v2/project.py,sha256=eWYFJ7F2ZYQcpi-_n-rhPF-Q4gJhzYBsVGYFhHZZXAE,6653
45
45
  fractal_server/app/routes/api/v2/status.py,sha256=6N9DSZ4iFqbZImorWfEAPoyoFUgEruo4Hweqo0x0xXU,6435
46
- fractal_server/app/routes/api/v2/submit.py,sha256=iTGCYbxiZNszHQa8r3gmAR4QcF6QhVrb8ktzste2Ovc,8775
46
+ fractal_server/app/routes/api/v2/submit.py,sha256=tyaeEpGMEkazdmltlnJxJYfD9Y9_t9mP2MUmx3s1Ato,9223
47
47
  fractal_server/app/routes/api/v2/task.py,sha256=XgRnGBvSoI9VNJHtWZQ2Ide99f6elo7a2FN3GQkf0dU,8376
48
- fractal_server/app/routes/api/v2/task_collection.py,sha256=wEwP8VfsxhKPZ6K3v1Bnput_Zw0Cjhlaal0-e50feIQ,12337
49
- fractal_server/app/routes/api/v2/task_collection_custom.py,sha256=6MW-l7xTCTbWKDSYDw8e_hnm5jFCJpgFL3UdvrHAaBk,6029
48
+ fractal_server/app/routes/api/v2/task_collection.py,sha256=SirU4yiE4pGfW68cyopMLgHSevIzaepQXLZJeIdaoDE,12697
49
+ fractal_server/app/routes/api/v2/task_collection_custom.py,sha256=CbeC7xYYF8K9JVOOunL3Y_3wXBEGGGoiJcoPa2hEftI,6127
50
50
  fractal_server/app/routes/api/v2/workflow.py,sha256=rMCcclz9aJAMSVLncUdSDGrgkKbn4KOCZTqZtqs2HDY,10428
51
51
  fractal_server/app/routes/api/v2/workflowtask.py,sha256=-3-c8DDnxGjMwWbX_h5V5OLaC_iCLXYzwWKBUaL-5wE,7060
52
52
  fractal_server/app/routes/auth/__init__.py,sha256=fao6CS0WiAjHDTvBzgBVV_bSXFpEAeDBF6Z6q7rRkPc,1658
@@ -66,7 +66,7 @@ fractal_server/app/runner/.gitignore,sha256=ytzN_oyHWXrGU7iFAtoHSTUbM6Rn6kG0Zkdd
66
66
  fractal_server/app/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
67
  fractal_server/app/runner/async_wrap.py,sha256=_O6f8jftKYXG_DozkmlrDBhoiK9QhE9MablOyECq2_M,829
68
68
  fractal_server/app/runner/components.py,sha256=ZF8ct_Ky5k8IAcrmpYOZ-bc6OBgdELEighYVqFDEbZg,119
69
- fractal_server/app/runner/compress_folder.py,sha256=zmxo2EFkSaO4h3GnMRi9DYaf62bxy4zznZZGfmq-n68,3975
69
+ fractal_server/app/runner/compress_folder.py,sha256=HSc1tv7x2DBjBoXwugZlC79rm9GNBIWtQKK9yWn5ZBI,3991
70
70
  fractal_server/app/runner/exceptions.py,sha256=_qZ_t8O4umAdJ1ikockiF5rDJuxnEskrGrLjZcnQl7A,4159
71
71
  fractal_server/app/runner/executors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
72
  fractal_server/app/runner/executors/slurm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -76,20 +76,20 @@ fractal_server/app/runner/executors/slurm/remote.py,sha256=wLziIsGdSMiO-jIXM8x77
76
76
  fractal_server/app/runner/executors/slurm/ssh/__init__.py,sha256=Cjn1rYvljddi96tAwS-qqGkNfOcfPzjChdaEZEObCcM,65
77
77
  fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py,sha256=bKo5Ja0IGxJWpPWyh9dN0AG-PwzTDZzD5LyaEHB3YU4,3742
78
78
  fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py,sha256=rwlqZzoGo4SAb4nSlFjsQJdaCgfM1J6YGcjb8yYxlqc,4506
79
- fractal_server/app/runner/executors/slurm/ssh/executor.py,sha256=oCc5cLZoNmJ3ENV0VaYRiIKayVClKDoEnjgZjHU864Y,57052
79
+ fractal_server/app/runner/executors/slurm/ssh/executor.py,sha256=K1lKsn40PqQb-GZKwJkzeJk2Q8OGqy6YR-IxcV0E0Pw,57705
80
80
  fractal_server/app/runner/executors/slurm/sudo/__init__.py,sha256=Cjn1rYvljddi96tAwS-qqGkNfOcfPzjChdaEZEObCcM,65
81
81
  fractal_server/app/runner/executors/slurm/sudo/_check_jobs_status.py,sha256=wAgwpVcr6JIslKHOuS0FhRa_6T1KCManyRJqA-fifzw,1909
82
82
  fractal_server/app/runner/executors/slurm/sudo/_executor_wait_thread.py,sha256=z5LlhaiqAb8pHsF1WwdzXN39C5anQmwjo1rSQgtRAYE,4422
83
- fractal_server/app/runner/executors/slurm/sudo/_subprocess_run_as_user.py,sha256=0wRA-hg0JxVrFw4rBXAu6P2L2dbwzx2N_kUmpil1ErU,5143
84
- fractal_server/app/runner/executors/slurm/sudo/executor.py,sha256=6D83o4BE5D-1LZ3BhfEmhWJ1DZrk3Vzg-b3tB5TWMAU,48471
83
+ fractal_server/app/runner/executors/slurm/sudo/_subprocess_run_as_user.py,sha256=g8wqUjSicN17UZVXlfaMomYZ-xOIbBu1oE7HdJTzfvw,5218
84
+ fractal_server/app/runner/executors/slurm/sudo/executor.py,sha256=6OPe9t70gLyuC2JhWt2o1f0e7zhQPBtrbMHQkDd6RAQ,48725
85
85
  fractal_server/app/runner/extract_archive.py,sha256=tLpjDrX47OjTNhhoWvm6iNukg8KoieWyTb7ZfvE9eWU,2483
86
86
  fractal_server/app/runner/filenames.py,sha256=9lwu3yB4C67yiijYw8XIKaLFn3mJUt6_TCyVFM_aZUQ,206
87
- fractal_server/app/runner/run_subprocess.py,sha256=KTkJnWLrLQdR2WRJ3jGu0RBu4330L3mtCAE_B0wDx3M,818
87
+ fractal_server/app/runner/run_subprocess.py,sha256=c3JbYXq3hX2aaflQU19qJ5Xs6J6oXGNvnTEoAfv2bxc,959
88
88
  fractal_server/app/runner/set_start_and_last_task_index.py,sha256=-q4zVybAj8ek2XlbENKlfOAJ39hT_zoJoZkqzDqiAMY,1254
89
89
  fractal_server/app/runner/shutdown.py,sha256=I_o2iYKJwzku0L3E85ETjrve3QPECygR5xhhsAo5huM,2910
90
90
  fractal_server/app/runner/task_files.py,sha256=sd_MpJ01C8c9QTO8GzGMidFGdlq_hXX_ARDRhd_YMnI,3762
91
91
  fractal_server/app/runner/v1/__init__.py,sha256=VvJFk4agX2X3fQfDcoNmOB2ouNCaQU7dAqaFmpcdP8I,15063
92
- fractal_server/app/runner/v1/_common.py,sha256=rF9IsOo_h_UyeCZ2hkHYPHsdgTjqLlGGig4wyrDS5CQ,21544
92
+ fractal_server/app/runner/v1/_common.py,sha256=EiSfp-PvhtTD3uijSec5CNKxe50ITts2DyGCFcjfVBw,21619
93
93
  fractal_server/app/runner/v1/_local/__init__.py,sha256=KlSML4LqF4p1IfhSd8tAkiu3aeDzifeanuNXjATDsYE,6929
94
94
  fractal_server/app/runner/v1/_local/_local_config.py,sha256=hM7SPxR07luXPcXdrWXRpEB2uOyjSSRUdqW3QBKJn9c,3147
95
95
  fractal_server/app/runner/v1/_local/_submit_setup.py,sha256=XyBDPb4IYdKEEnzLYdcYteIHWVWofJxKMmQCyRkn5Bc,1509
@@ -119,12 +119,12 @@ fractal_server/app/runner/v2/handle_failed_job.py,sha256=fipRJT5Y8UY0US4bXUX-4OR
119
119
  fractal_server/app/runner/v2/merge_outputs.py,sha256=IHuHqbKmk97K35BFvTrKVBs60z3e_--OzXTnsvmA02c,1281
120
120
  fractal_server/app/runner/v2/runner.py,sha256=nw9oYt3cFItHWVoevJyMI63K0kWHCTAriAQ_KINo_F8,13039
121
121
  fractal_server/app/runner/v2/runner_functions.py,sha256=BLREIcQaE6FSc2AEJyZuiYk6rGazEz_9gprUqUZDljs,9488
122
- fractal_server/app/runner/v2/runner_functions_low_level.py,sha256=hXRjaPjBVWQ2HK7s2yhvqKC5Uc_K41MvW9kUm4KajTA,3453
122
+ fractal_server/app/runner/v2/runner_functions_low_level.py,sha256=1fWvQ6YZUUnDhO_mipXC5hnaT-zK-GHxg8ayoxZX82k,3648
123
123
  fractal_server/app/runner/v2/task_interface.py,sha256=myS-kT0DsJ8xIJZBVEzgD8g54VbiwL6i7Im3e1zcVHQ,1866
124
124
  fractal_server/app/runner/versions.py,sha256=dSaPRWqmFPHjg20kTCHmi_dmGNcCETflDtDLronNanU,852
125
125
  fractal_server/app/schemas/__init__.py,sha256=jiIf54owztXupv3PO6Ilh0qcrkh2RUzKq4bcEFqEfc4,40
126
126
  fractal_server/app/schemas/_validators.py,sha256=1dTOYr1IZykrxuQSV2-zuEMZbKe_nGwrfS7iUrsh-sE,3461
127
- fractal_server/app/schemas/user.py,sha256=PP0xU42hKeOGQLjOw5DnjaIISXrlbf2CHzCqAtv2_Bk,3889
127
+ fractal_server/app/schemas/user.py,sha256=OJutfwMR1JPEmdFzqA4vHMZO-mhB4Mb9Yyx_G24XTCM,4081
128
128
  fractal_server/app/schemas/user_group.py,sha256=2f9XQ6kIar6NMY4UCN0yOnve6ZDHUVZaHv1dna1Vfjg,1446
129
129
  fractal_server/app/schemas/v1/__init__.py,sha256=CrBGgBhoemCvmZ70ZUchM-jfVAICnoa7AjZBAtL2UB0,1852
130
130
  fractal_server/app/schemas/v1/applyworkflow.py,sha256=uuIh7fHlHEL4yLqL-dePI6-nfCsqgBYATmht7w_KITw,4302
@@ -138,12 +138,12 @@ fractal_server/app/schemas/v1/task_collection.py,sha256=uvq9bcMaGD_qHsh7YtcpoSAk
138
138
  fractal_server/app/schemas/v1/workflow.py,sha256=tuOs5E5Q_ozA8if7YPZ07cQjzqB_QMkBS4u92qo4Ro0,4618
139
139
  fractal_server/app/schemas/v2/__init__.py,sha256=kmM4NfSGIL0I4xVtnmMST20kfVo3nBBG-Ssk8vJAvLs,1979
140
140
  fractal_server/app/schemas/v2/dataset.py,sha256=dLT52tV4dSf2HrFNak4vdQEn8PT_04IUrGnd2z-AXIU,2599
141
- fractal_server/app/schemas/v2/dumps.py,sha256=_iVgV98jb3gaKcOxX_halQFureKE81KTRIqbJsefw1s,1332
141
+ fractal_server/app/schemas/v2/dumps.py,sha256=ZrJCHTv9oU2QMNjPUSBO3DIPRO3qDvbxpAGpernpf-Q,1720
142
142
  fractal_server/app/schemas/v2/job.py,sha256=zfF9K3v4jWUJ7M482ta2CkqUJ4tVT4XfVt60p9IRhP0,3250
143
143
  fractal_server/app/schemas/v2/manifest.py,sha256=N37IWohcfO3_y2l8rVM0h_1nZq7m4Izxk9iL1vtwBJw,6243
144
144
  fractal_server/app/schemas/v2/project.py,sha256=u7S4B-bote1oGjzAGiZ-DuQIyeRAGqJsI71Tc1EtYE0,736
145
145
  fractal_server/app/schemas/v2/status.py,sha256=SQaUpQkjFq5c5k5J4rOjNhuQaDOEg8lksPhkKmPU5VU,332
146
- fractal_server/app/schemas/v2/task.py,sha256=xQfQxL2h-Vw0YL3yEiYvVIXTybE1lyRE0pPUu59nZes,4574
146
+ fractal_server/app/schemas/v2/task.py,sha256=XsN8w1Szs8BrxxRtKyWCHKjN4Od-Kmlhi769JEplL-M,4804
147
147
  fractal_server/app/schemas/v2/task_collection.py,sha256=8PG1bOqkfQqORMN0brWf6mHDmijt0bBW-mZsF7cSxUs,6129
148
148
  fractal_server/app/schemas/v2/workflow.py,sha256=Zzx3e-qgkH8le0FUmAx9UrV5PWd7bj14PPXUh_zgZXM,1827
149
149
  fractal_server/app/schemas/v2/workflowtask.py,sha256=TN-mdkuE_EWet9Wk-xFrUwIt_tXYcw88WOKMnUcchKk,5665
@@ -156,7 +156,7 @@ fractal_server/images/__init__.py,sha256=xO6jTLE4EZKO6cTDdJsBmK9cdeh9hFTaSbSuWgQ
156
156
  fractal_server/images/models.py,sha256=9ipU5h4N6ogBChoB-2vHoqtL0TXOHCv6kRR-fER3mkM,4167
157
157
  fractal_server/images/tools.py,sha256=gxeniYy4Z-cp_ToK2LHPJUTVVUUrdpogYdcBUvBuLiY,2209
158
158
  fractal_server/logger.py,sha256=56wfka6fHaa3Rx5qO009nEs_y8gx5wZ2NUNZZ1I-uvc,5130
159
- fractal_server/main.py,sha256=NUvMd8C8kosulAcQ8pCFLnOGdLw7j-6RzcHxoNvSB7k,5003
159
+ fractal_server/main.py,sha256=68rVybnviF28yFgRhfHZXwnf6LyzkcmeYYZZppboU4M,4925
160
160
  fractal_server/migrations/README,sha256=4rQvyDfqodGhpJw74VYijRmgFP49ji5chyEemWGHsuw,59
161
161
  fractal_server/migrations/env.py,sha256=mEiX0TRa_8KAYBrUGJTx1cFJ5YAq_oNHHsFCp1raegk,2543
162
162
  fractal_server/migrations/naming_convention.py,sha256=htbKrVdetx3pklowb_9Cdo5RqeF0fJ740DNecY5de_M,265
@@ -181,8 +181,8 @@ fractal_server/migrations/versions/efa89c30e0a4_add_project_timestamp_created.py
181
181
  fractal_server/migrations/versions/f384e1c0cf5d_drop_task_default_args_columns.py,sha256=9BwqUS9Gf7UW_KjrzHbtViC880qhD452KAytkHWWZyk,746
182
182
  fractal_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
183
183
  fractal_server/ssh/__init__.py,sha256=sVUmzxf7_DuXG1xoLQ1_00fo5NPhi2LJipSmU5EAkPs,124
184
- fractal_server/ssh/_fabric.py,sha256=9xcsOEwbCgbJtupkIeG8OOtT8ct8c7_ruIehhNmD4wc,11379
185
- fractal_server/string_tools.py,sha256=KThgTLn_FHNSuEUGLabryJAP6DaFd7bpi-hF5FgkBjw,1268
184
+ fractal_server/ssh/_fabric.py,sha256=B9C7Cj9ibrT1_OGlu38Jz94elNofLvkP1pxf0Tv8Eic,16140
185
+ fractal_server/string_tools.py,sha256=YyopB2ZZ8iL9JLDXUA8PI0hahivqLiohir2HsAlEzqE,2170
186
186
  fractal_server/syringe.py,sha256=3qSMW3YaMKKnLdgnooAINOPxnCOxP7y2jeAQYB21Gdo,2786
187
187
  fractal_server/tasks/__init__.py,sha256=kadmVUoIghl8s190_Tt-8f-WBqMi8u8oU4Pvw39NHE8,23
188
188
  fractal_server/tasks/utils.py,sha256=wucz57I7G0Vd8hvtmvonlryACx9zIVlqfxG5I87MJ80,1820
@@ -207,8 +207,8 @@ fractal_server/tasks/v2/utils.py,sha256=JOyCacb6MNvrwfLNTyLwcz8y79J29YuJeJ2MK5kq
207
207
  fractal_server/urls.py,sha256=5o_qq7PzKKbwq12NHSQZDmDitn5RAOeQ4xufu-2v9Zk,448
208
208
  fractal_server/utils.py,sha256=b7WwFdcFZ8unyT65mloFToYuEDXpQoHRcmRNqrhd_dQ,2115
209
209
  fractal_server/zip_tools.py,sha256=xYpzBshysD2nmxkD5WLYqMzPYUcCRM3kYy-7n9bJL-U,4426
210
- fractal_server-2.5.0a1.dist-info/LICENSE,sha256=QKAharUuhxL58kSoLizKJeZE3mTCBnX6ucmz8W0lxlk,1576
211
- fractal_server-2.5.0a1.dist-info/METADATA,sha256=qMNa2O2LVr-uNvS2djtZyio4-mk3FOHbIY_7N6Mh31k,4630
212
- fractal_server-2.5.0a1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
213
- fractal_server-2.5.0a1.dist-info/entry_points.txt,sha256=8tV2kynvFkjnhbtDnxAqImL6HMVKsopgGfew0DOp5UY,58
214
- fractal_server-2.5.0a1.dist-info/RECORD,,
210
+ fractal_server-2.5.2.dist-info/LICENSE,sha256=QKAharUuhxL58kSoLizKJeZE3mTCBnX6ucmz8W0lxlk,1576
211
+ fractal_server-2.5.2.dist-info/METADATA,sha256=Q_lBgfC8qNcsp8ll6WrxkYOvt8P0YAVJCaWPuOIgFTY,4628
212
+ fractal_server-2.5.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
213
+ fractal_server-2.5.2.dist-info/entry_points.txt,sha256=8tV2kynvFkjnhbtDnxAqImL6HMVKsopgGfew0DOp5UY,58
214
+ fractal_server-2.5.2.dist-info/RECORD,,