Coverage for src/fsl_pipe/job.py: 84%

546 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-22 15:00 +0100

1"""Defines pipeline interface after it has been split into individual jobs based on a FileTree.""" 

2 

3from file_tree.file_tree import FileTree 

4from typing import Optional, Set, Dict, Collection, Any, Callable, Sequence, List, Tuple 

5from enum import Enum 

6from fsl.utils.fslsub import func_to_cmd 

7from loguru import logger 

8from pathlib import Path 

9from fsl_sub import submit, config 

10import re 

11import inspect 

12from contextlib import contextmanager 

13from wcmatch.glob import globmatch, GLOBSTAR 

14from warnings import warn 

15import numpy as np 

16 

17 

18class OutputMissing(IOError): 

19 """Raised if a job misses the output files.""" 

20 

21 pass 

22 

23 

24class InputMissingRun(IOError): 

25 """Raised if a job misses the input files when it is being launched.""" 

26 

27 pass 

28 

29 

30class InputMissingPipe(IOError): 

31 """Raised if a job misses input files while it is being added to the pipeline.""" 

32 

33 def __init__(self, target, missing, dependency_graph=None): 

34 self.target = target 

35 self.missing = missing 

36 if dependency_graph is None: 

37 dependency_graph = [] 

38 self.dependency_graph = dependency_graph 

39 super().__init__("") 

40 

41 def __str__( 

42 self, 

43 ): 

44 dependency = " -> ".join([str(d) for d in self.dependency_graph]) 

45 return f"{self.target} can not be added to pipeline as it is missing required input files: {self.missing} (dependency_graph: {dependency} )" 

46 

47 

48class RunMethod(Enum): 

49 """How to run the individual jobs.""" 

50 

51 local = 1 

52 submit = 2 

53 dask = 3 

54 

55 def default(): 

56 """Return default RunMethod, which is `submit` on a cluster and `local` otherwise.""" 

57 return RunMethod.submit if config.has_queues() else RunMethod.local 

58 

59 

60class JobList: 

61 """Pipeline with a concrete set of jobs. 

62 

63 Produced from a :class:`fsl_pipe.pipeline.Pipeline` based on the provided `FileTree`. 

64 

65 The default `JobList` produced by `Pipeline.generate_jobs` is unsorted and 

66 needs to be sorted by running `filter` before running. 

67 """ 

68 

69 def __init__( 

70 self, 

71 tree: FileTree, 

72 jobs: Sequence["SingleJob"], 

73 targets: Dict[str, "FileTarget"], 

74 ): 

75 """Create a new concrete set of jobs.""" 

76 self.tree = tree 

77 self.jobs = jobs 

78 self.targets = targets 

79 self._sort() 

80 

81 def __len__( 

82 self, 

83 ): 

84 return len(self.jobs) 

85 

86 def filter( 

87 self, 

88 templates: Collection[str] = None, 

89 overwrite=False, 

90 overwrite_dependencies=False, 

91 skip_missing=False, 

92 ) -> "JobList": 

93 """Filter out a subset of jobs. 

94 

95 This does a total of three things: 

96 - Only include jobs needed to create the templates or file patterns given in `templates`. 

97 - Exclude jobs for which output files already exist, unless requested by `overwrite` or `overwrite_dependencies`. 

98 - Sort the jobs, so that dependencies are run before the jobs that depend on them. 

99 

100 :param templates: target template keys (default: all templates); Any jobs required to produce those files will be kept in the returned pipeline. 

101 :param overwrite: if True overwrite the files matching the template even if they already exist, defaults to False 

102 :param overwrite_dependencies: if True rerun any dependent jobs even if the output of those jobs already exists, defaults to False 

103 :param skip_missing: if True remove any jobs missing input dependencies. If not set, an error is raised whenever inputs are missing. 

104 :return: Concrete pipeline with the jobs needed to produces `templates` (with optional overriding) 

105 """ 

106 if isinstance(templates, str): 

107 templates = [templates] 

108 

109 def add_target(target: "FileTarget", warn_msg=""): 

110 if target.exists() and (not overwrite or target.producer is None): 

111 return 

112 if target.producer is None: 

113 warn(f"No job found that produces {target.filename} {warn_msg}") 

114 return 

115 try: 

116 target.producer.add_to_jobs( 

117 all_jobs, 

118 overwrite=overwrite, 

119 overwrite_dependencies=overwrite_dependencies, 

120 ) 

121 except InputMissingPipe as e: 

122 if skip_missing: 

123 return 

124 e.dependency_graph.insert(0, e.target) 

125 e.target = target.filename 

126 raise 

127 

128 all_jobs: Tuple[List[SingleJob], Set[SingleJob]] = ([], set()) 

129 if templates is None: 

130 for target in self.targets.values(): 

131 if target.producer is None or target.intermediate: 

132 continue 

133 add_target(target) 

134 else: 

135 for template in templates: 

136 if template in self.tree.template_keys(): 

137 for filename in self.tree.get_mult(template).data.flatten(): 

138 target = get_target( 

139 filename, self.targets, from_template=template 

140 ) 

141 add_target(target, f"(part of template {template})") 

142 else: 

143 matches = get_matching_targets(template, self.targets) 

144 if len(matches) == 0: 

145 warn( 

146 f"No files were found that match {template}. It is not a template in the FileTree nor does it match any files that can be produced by this pipeline." 

147 ) 

148 for target in matches: 

149 add_target(target, f"(matches {template})") 

150 return JobList(self.tree, all_jobs[0], self.targets) 

151 

152 def _sort( 

153 self, 

154 ): 

155 """Sorts the job in the `jobs` list in place based on dependencies. 

156 

157 A job dependencies should always come before the job itself. 

158 This sorting is done purely based on the job inputs/outputs and does not depend on which files actually exist on disk. 

159 """ 

160 all_jobs: Tuple[List[SingleJob], Set[SingleJob]] = ([], set()) 

161 for job in self.jobs: 

162 job.add_to_jobs(all_jobs, only_sort=True) 

163 

164 # Remove jobs that were already filtered out 

165 sorted_jobs = [j for j in all_jobs[0] if j in self.jobs] 

166 assert len(self.jobs) == len(sorted_jobs) 

167 self.jobs = sorted_jobs 

168 

169 def batch( 

170 self, 

171 use_label=False, 

172 only_connected=False, 

173 split_on_ram=False, 

174 use_placeholders=True, 

175 batch_unlabeled=False, 

176 ): 

177 """Batches groups of jobs into a single job. 

178 

179 Two jobs will be batched if: 

180 

181 1. There are no intermediate jobs that need to run between the two and cannot be included in the same batch (because of #3 below). 

182 2. One of the jobs depend on the other. This is disabled by default. Enable it by setting `only_connected` to True. 

183 3. The jobs are similar. Jobs are similar if they match all of the (enabled) properties listed below: 

184 

185 a) They have the same batch label (set during pipeline creating using the `@pipe(batch=<batch label>)`. This can be enabled by setting the `use_label` keyword to True. If enabled, jobs without a batch label will never be merged. 

186 b) The jobs have the same submit parameters (e.g., "architecture", "coprocessor"), except for "jobtime", "name", and "export_vars". "jobram" can also be ignored by setting the `split_on_ram` keyword to True. 

187 c) The jobs have the same placeholder values. This will prevent jobs from different subjects to be merged with each other. It can be disabled by setting the `use_placeholders` to False. Alternatively, a subset of placeholder values could be considered by passing `use_placeholders` to a sequence of placeholders to consider (e.g., `use_placeholders=["subject", "session"]` will not merge jobs with different "subject" or "session" placeholder values, but will ignore any other placeholders). 

188 

189 A new `JobList` with the batched jobs will be returned. 

190 """ 

191 reference = self.copy() 

192 

193 all_flags = { 

194 key for job in reference.jobs for key in job.submit_params.keys() 

195 }.difference(["jobtime", "name", "export_vars"]) 

196 if not split_on_ram and "jobram" in all_flags: 

197 all_flags.remove("jobram") 

198 all_flags = sorted(all_flags) 

199 

200 job_labels = [] 

201 for job in reference.jobs: 

202 label = [] 

203 label.append(job.batch if use_label else 1) 

204 label.extend(job.submit_params.get(key, None) for key in all_flags) 

205 if use_placeholders: 

206 if use_placeholders is True: 

207 parameters = job.set_parameters 

208 else: 

209 parameters = { 

210 key: value 

211 for key, value in job.set_parameters.items() 

212 if key in use_placeholders 

213 } 

214 label.append(frozenset(parameters.items())) 

215 job_labels.append(tuple(label)) 

216 

217 hashable_job_labels = [ 

218 tuple(label) if isinstance(label, list) else label for label in job_labels 

219 ] 

220 

221 to_batch = { 

222 tb: set( 

223 job for (job, label) in zip(reference.jobs, job_labels) if label == tb 

224 ) 

225 for tb in set(hashable_job_labels) 

226 } 

227 jobs = set(reference.jobs) 

228 for to_batch, batching in to_batch.items(): 

229 if to_batch[0] is None and not batch_unlabeled: 

230 # do not batch jobs without a label set (if `use_label` is True) 

231 continue 

232 other_jobs = jobs.difference(batching) 

233 new_jobs = batch_connected_jobs(batching, other_jobs) 

234 if not only_connected: 

235 new_jobs = batch_unconnected_jobs(new_jobs, other_jobs) 

236 jobs = set(new_jobs).union(other_jobs) 

237 return JobList(reference.tree, jobs, reference.targets) 

238 

239 def split_pipeline(self, use_label=False, split_on_ram=False): 

240 """Split the pipeline into multiple stages that require different hardware. 

241 

242 This uses the same rules as :meth:`batch`, except that placeholder values are always ignored. 

243 """ 

244 batched_jobs = self.batch( 

245 use_label=use_label, 

246 split_on_ram=split_on_ram, 

247 only_connected=False, 

248 use_placeholders=False, 

249 batch_unlabeled=True, 

250 ) 

251 

252 return [stage.to_job_list(batched_jobs.tree) for stage in batched_jobs.jobs] 

253 

254 def copy( 

255 self, 

256 ): 

257 """Create a new, independent copy of the JobList.""" 

258 targets = dict() 

259 new_jobs = [job.copy(targets) for job in self.jobs] 

260 return JobList(self.tree, new_jobs, targets) 

261 

262 def report(self, console=None): 

263 """Produce tree reports with the relevant input/output templates.""" 

264 from rich import tree 

265 

266 if console is None: 

267 from rich.console import Console 

268 

269 console = Console() 

270 

271 if len(self.jobs) == 0: 

272 console.print("No jobs will be run.") 

273 return 

274 

275 all_inputs = set.union(*[set(j.input_targets) for j in self.jobs]) 

276 all_outputs = set.union(*[set(j.output_targets) for j in self.jobs]) 

277 templates = { 

278 template 

279 for fns in (all_inputs, all_outputs) 

280 for target in fns 

281 for template in target.from_templates 

282 } 

283 

284 def proc_line(tree_obj): 

285 for t in tree_obj.children: 

286 proc_line(t) 

287 line = tree_obj.label 

288 start = line.index("[cyan]") + 6 

289 end = line.index("[/cyan]") 

290 key = line[start:end] 

291 if key not in templates: 

292 return 

293 input_count = 0 

294 output_count = 0 

295 overwrite_count = 0 

296 exists_count = 0 

297 

298 for fn in self.tree.get_mult(key).data.flatten(): 

299 target = get_target(fn, self.targets) 

300 is_output = False 

301 is_input = False 

302 if target in all_outputs: 

303 is_output = True 

304 if target in all_inputs: 

305 is_input = True 

306 if target.exists() and is_output: 

307 overwrite_count += 1 

308 elif is_output: 

309 output_count += 1 

310 elif target.exists(): 

311 exists_count += 1 

312 if is_input: 

313 input_count += 1 

314 if (input_count + output_count + overwrite_count) == 0: 

315 return 

316 counter = "/".join( 

317 [ 

318 str(number) if color is None else f"[{color}]{number}[/{color}]" 

319 for number, color in [ 

320 (overwrite_count, "red"), 

321 (output_count, "yellow"), 

322 (input_count, "blue"), 

323 ] 

324 ] 

325 ) 

326 tree_obj.label = f"{line} [{counter}]" 

327 

328 for rich_obj in ( 

329 self.tree.filter_templates(templates).fill()._generate_rich_report() 

330 ): 

331 if isinstance(rich_obj, tree.Tree): 

332 proc_line(rich_obj) 

333 console.print(rich_obj) 

334 

335 def run_datalad( 

336 self, 

337 ): 

338 """Make sure we can run the pipeline. 

339 

340 Calls `datalad.get` on all input files and `datalad.unlock` on all output files. 

341 """ 

342 input_targets = set() 

343 output_targets = set() 

344 

345 for job_group in self.jobs.values(): 

346 for j in job_group: 

347 input_targets.update(j.exists_before) 

348 output_targets.update(j.exists_after) 

349 

350 input_targets.difference_update(output_targets) 

351 input_fns = [t.filename for t in input_targets] 

352 output_fns = [t.filename for t in output_targets if t.exists] 

353 

354 from .datalad import get_dataset 

355 

356 ds = get_dataset() 

357 ds.get(input_fns) 

358 ds.unlock(output_fns) 

359 

360 def run(self, method: RunMethod = None, wait_for=(), clean_script="on_success"): 

361 """Run all the jobs that are required to produce the given templates. 

362 

363 :param method: defines how to run the job 

364 :param wait_for: job IDs to wait for before running pipeline 

365 :param clean_script: Sets whether the script produced in the log directory when submitting a job to the cluster should be kept after the job finishes. Only used if `method` is "submit". Options: 

366 - "never": Script is kept 

367 - "on_success": (default) Only remove if script successfully finishes (i.e., no error is raised) 

368 - "always": Always remove the script, even if the script raises an error 

369 """ 

370 if method is None: 

371 method = RunMethod.default() 

372 elif not isinstance(method, RunMethod): 

373 method = RunMethod[method] 

374 if len(self.jobs) == 0: 

375 logger.info("No new jobs being run/submitted") 

376 return 

377 

378 prev_count = 0 

379 run_jobs: Dict[SingleJob, Any] = {} 

380 while len(run_jobs) < len(self.jobs): 

381 for job in self.jobs: 

382 if job in run_jobs or any( 

383 j in self.jobs and j not in run_jobs 

384 for _, j in job.dependencies(only_missing=False) 

385 ): 

386 continue 

387 dependencies = [ 

388 run_jobs[j] 

389 for _, j in job.dependencies(only_missing=False) 

390 if j in run_jobs 

391 ] 

392 if len(dependencies) == 0: 

393 dependencies = wait_for 

394 run_jobs[job] = job( 

395 method=method, 

396 wait_for=dependencies, 

397 clean_script=clean_script, 

398 ) 

399 if len(run_jobs) == prev_count: 

400 raise ValueError( 

401 "Unable to run/submit all jobs. Are there circular dependencies?" 

402 ) 

403 prev_count = len(run_jobs) 

404 

405 if method == RunMethod.dask: 

406 import dask 

407 

408 def last_dask_job(*all_jobs): 

409 if any(a != 0 for a in all_jobs): 

410 return 1 

411 return 0 

412 

413 if ( 

414 dask.delayed(last_dask_job, name="last_job")( 

415 *run_jobs.values() 

416 ).compute() 

417 == 1 

418 ): 

419 raise ValueError("One or more of the jobs have failed.") 

420 logger.info("Successfully finished running all jobs using Dask.") 

421 

422 def scale_jobtime(self, scaling): 

423 """ 

424 Scale the submit job times of all jobs by `scaling`. 

425 

426 This will only affect jobs submitted to the cluster. 

427 """ 

428 for job in self.jobs: 

429 job.scale_jobtime(scaling) 

430 return self 

431 

432 

433class JobParent: 

434 """Parent for `SingleJob` and `BatchJob`.""" 

435 

436 input_targets: Set["FileTarget"] 

437 output_targets: Set["FileTarget"] 

438 optional_targets: Set["FileTarget"] 

439 

440 # @lru_cache(None) 

441 def dependencies(self, only_missing=True) -> Set[Optional["SingleJob"]]: 

442 """Return jobs on which this job depends. 

443 

444 By default it only returns those related with missing input files. 

445 

446 :param only_missing: set to False to also return dependencies that produce files that already exist on disk 

447 """ 

448 jobs = set() 

449 for target in self.input_targets: 

450 if not (only_missing and target.exists()): 

451 jobs.add((target in self.optional_targets, target.producer)) 

452 return jobs 

453 

454 def missing_output(self, reset_cache=False): 

455 """ 

456 Create a list of filenames that do not exist on disk. 

457 

458 Optional outputs are not considered. 

459 

460 :param reset_cache: set to True to not rely on an existing cached existence check 

461 """ 

462 missing = set() 

463 for to_check in self.output_targets: 

464 if to_check in self.optional_targets: 

465 continue 

466 if reset_cache: 

467 to_check.reset_existence() 

468 if not to_check.exists(): 

469 missing.add(to_check) 

470 return missing 

471 

472 def missing_input(self, reset_cache=False, ignore_expected=False): 

473 """ 

474 Create a list of filenames that do not exist on disk. 

475 

476 Optional inputs are not considered. 

477 

478 :param reset_cache: set to True to not rely on an existing cached existence check 

479 :param ignore_expected: set to True to ignore any missing files that have a job that will produce them in the pipeline 

480 """ 

481 missing = set() 

482 for to_check in self.input_targets: 

483 if to_check in self.optional_targets: 

484 continue 

485 if reset_cache: 

486 to_check.reset_existence() 

487 if ignore_expected and to_check.producer is not None: 

488 continue 

489 if not to_check.exists(): 

490 missing.add(to_check) 

491 return missing 

492 

493 def add_to_jobs( 

494 self, all_jobs, overwrite=False, overwrite_dependencies=False, only_sort=False 

495 ): 

496 """Mark this job and all of its dependencies to run. 

497 

498 This job is marked to run, if any of the output does not yet exist or overwrite is True. 

499 The dependencies are marked to run, if this job runs and either their output does not exist or overwrite_dependencies is True. 

500 

501 :param all_jobs: list and set of individual jobs. This job and all required jobs are added to this list. 

502 :param overwrite: if True mark this job even if the output already exists, defaults to False 

503 :param overwrite_dependencies: if True mark the dependencies of this job even if their output already exists, defaults to False 

504 """ 

505 if self in all_jobs[1]: 

506 return 

507 if not only_sort: 

508 if not overwrite and len(self.missing_output()) == 0: 

509 return 

510 missing = self.missing_input(ignore_expected=True) 

511 if len(missing) > 0: 

512 raise InputMissingPipe(self, {m.filename for m in missing}) 

513 subjobs = ([], set(all_jobs[1])) 

514 for optional, job in self.dependencies( 

515 only_missing=not (overwrite_dependencies or only_sort) 

516 ): 

517 try: 

518 if job is not None: 

519 job.add_to_jobs( 

520 subjobs, 

521 overwrite_dependencies, 

522 overwrite_dependencies, 

523 only_sort, 

524 ) and not optional 

525 except InputMissingPipe as e: 

526 if optional: 

527 continue 

528 e.dependency_graph.insert(0, e.target) 

529 e.target = self 

530 raise 

531 all_jobs[0].extend(subjobs[0]) 

532 all_jobs[1].update(subjobs[0]) 

533 all_jobs[0].append(self) 

534 all_jobs[1].add(self) 

535 

536 def __call__(self, method: RunMethod, wait_for=(), clean_script="on_success"): 

537 """Run the job using the specified `method`.""" 

538 if method == RunMethod.local: 

539 self.prepare_run() 

540 missing = self.missing_input() 

541 if len(missing) > 0: 

542 raise InputMissingRun( 

543 f"{self} can not run as it misses required input files: {missing}" 

544 ) 

545 logger.info(f"running {self}") 

546 self.job = self.function(**self.kwargs) 

547 missing = self.missing_output(reset_cache=True) 

548 if len(missing) > 0: 

549 raise OutputMissing( 

550 f"{self} failed to produce required output files: {missing}" 

551 ) 

552 elif method == RunMethod.submit: 

553 self.prepare_run() 

554 local_submit = dict(self.submit_params) 

555 if "logdir" not in local_submit: 

556 local_submit["logdir"] = "log" 

557 Path(local_submit["logdir"]).mkdir(exist_ok=True, parents=True) 

558 if "name" not in local_submit: 

559 local_submit["name"] = self.job_name() 

560 cmd = func_to_cmd( 

561 self.function, 

562 (), 

563 self.kwargs, 

564 local_submit["logdir"], 

565 clean=clean_script, 

566 ) 

567 if len(wait_for) == 0: 

568 wait_for = None 

569 self.job = submit(cmd, jobhold=wait_for, **local_submit) 

570 logger.debug(f"submitted {self} with job ID {self.job}") 

571 elif method == RunMethod.dask: 

572 import dask 

573 

574 def dask_job(*other_jobs): 

575 if any(a != 0 for a in other_jobs): 

576 logger.debug(f"{self} skipped because dependencies failed") 

577 return 1 

578 try: 

579 logger.debug(f"Running {self} using dask") 

580 self(RunMethod.local) 

581 except Exception as e: 

582 logger.exception(f"{self} failed: {e}") 

583 return 1 

584 logger.debug(f"Running {self} using dask") 

585 return 0 

586 

587 self.job = dask.delayed(dask_job, name=str(self))(*wait_for) 

588 return self.job 

589 

590 def prepare_run(self): 

591 """ 

592 Prepare to run this job. 

593 

594 Steps: 

595 1. Creates output directory 

596 """ 

597 for target in self.output_targets: 

598 target.filename.parent.mkdir(parents=True, exist_ok=True) 

599 

600 def expected( 

601 self, 

602 ): 

603 """ 

604 Return true if this job is expected to be able to run. 

605 """ 

606 for target in self.input_targets: 

607 if not target.expected(): 

608 return False 

609 return True 

610 

611 

612class SingleJob(JobParent): 

613 """A single job within a larger pipeline.""" 

614 

615 def __init__( 

616 self, 

617 function: Callable, 

618 kwargs, 

619 submit_params, 

620 input_targets, 

621 output_targets, 

622 optionals, 

623 set_parameters=None, 

624 batch=None, 

625 ): 

626 """ 

627 Create a single job that can be run locally or submitted. 

628 

629 :param function: python function 

630 :param kwargs: keyword arguments 

631 :param submit_params: instructions to submit job to cluster using `fsl_sub` 

632 :param input_targets: set of all filenames expected to exist before this job runs 

633 :param output_targets: set of all filenames expected to exist after this job runs 

634 :param optionals: set of filenames that are used to generate the dependency graph and yet might not exist 

635 :param set_parameters: dictionary with placeholder values used to distinguish this SingleJob with all those produced from the same function 

636 :param batch: label used to batch multiple jobs into one when submitting to the cluster 

637 """ 

638 self.function = function 

639 if set_parameters is None: 

640 set_parameters = {} 

641 self.set_parameters = set_parameters 

642 self.kwargs = kwargs 

643 self.input_targets = input_targets 

644 self.output_targets = output_targets 

645 self.optional_targets = set(optionals) 

646 self.submit_params = dict(submit_params) 

647 self.batch = batch 

648 for target in self.input_targets: 

649 target.required_by.add(self) 

650 for target in self.output_targets: 

651 target.producer = self 

652 

653 def copy(self, targets: Dict[str, "FileTarget"]): 

654 """ 

655 Create a copy of the `SingleJob` to be included in a new :class:`JobList`. 

656 

657 `targets` contain the set of FileTargets recognised by this new `JobList`. 

658 This will be updated based on the input/output targets of this job. 

659 """ 

660 

661 def copy_targets(job_targets): 

662 new_targets = set() 

663 for target in job_targets: 

664 new_target = get_target(target.filename, targets) 

665 new_target.from_templates = target.from_templates 

666 new_targets.add(new_target) 

667 return new_targets 

668 

669 return SingleJob( 

670 self.function, 

671 self.kwargs, 

672 self.submit_params, 

673 copy_targets(self.input_targets), 

674 copy_targets(self.output_targets), 

675 copy_targets(self.optional_targets), 

676 self.set_parameters, 

677 self.batch, 

678 ) 

679 

680 @property 

681 def _function_name( 

682 self, 

683 ): 

684 return self.submit_params.get("name", self.function.__name__) 

685 

686 def job_name( 

687 self, 

688 ): 

689 """Return a string representation of this job.""" 

690 if len(self.set_parameters) > 0: 

691 parameter_string = "_".join( 

692 [f"{key}-{value}" for key, value in self.set_parameters.items()] 

693 ) 

694 name = f"{self._function_name}_{parameter_string}" 

695 else: 

696 name = self._function_name 

697 value = re.sub(r"[^\w\s-]", "", name).strip().lower() 

698 return re.sub(r"[-\s]+", "-", value) 

699 

700 def __repr__( 

701 self, 

702 ): 

703 """Print job as a function call.""" 

704 parameter_string = ", ".join( 

705 [f"{key}={value}" for key, value in self.set_parameters.items()] 

706 ) 

707 return f"{self._function_name}({parameter_string})" 

708 

709 def to_job_list(self, tree: FileTree): 

710 """Convert single job into its own :class:`JobList`.""" 

711 result = JobList(tree, [self], {}).copy() 

712 return self.batch, self.submit_params, result 

713 

714 def scale_jobtime(self, scaling): 

715 """ 

716 Scale the submit job time in place by `scaling`. 

717 

718 This will only affect jobs submitted to the cluster. 

719 """ 

720 if "jobtime" in self.submit_params.keys(): 

721 self.submit_params["jobtime"] = int( 

722 np.ceil(scaling * self.submit_params["jobtime"]) 

723 ) 

724 

725 

726def get_target(filename: Path, all_targets, from_template=None) -> "FileTarget": 

727 """ 

728 Return a :class:`FileTarget` matching the input `filename`. 

729 

730 If the `FileTarget` for `filename` is already in `all_targets`, it will be returned. 

731 Otherwise a new `FileTarget` will be added to `all_targets` and returned. 

732 

733 :param filename: path to the input/intermediate/output filename 

734 :param all_targets: dictionary of all FileTargets 

735 :param from_template: template key used to obtain the filename 

736 """ 

737 abs_path = Path(filename).absolute() 

738 if abs_path not in all_targets: 

739 all_targets[abs_path] = FileTarget(filename) 

740 if from_template is not None: 

741 all_targets[abs_path].from_templates.add(from_template) 

742 return all_targets[abs_path] 

743 

744 

745def get_matching_targets(pattern: str, all_targets) -> List["FileTarget"]: 

746 """ 

747 Return all :class:`FileTarget` that match the given input pattern. 

748 

749 :param pattern: filename definition supporting Unix shell-style wildcards 

750 :param all_targets: dictionary of all FileTargets 

751 """ 

752 abs_pattern = str(Path(pattern).absolute()) 

753 matches = [] 

754 for path, target in all_targets.items(): 

755 if globmatch(str(path), abs_pattern, flags=GLOBSTAR): 

756 matches.append(target) 

757 return matches 

758 

759 

760class FileTarget: 

761 """Input, intermediate, or output file. 

762 

763 See :func:`get_target` for instructions on creating a new `FileTarget`. 

764 

765 If a specific :class:`SingleJob` produces a filename, this can be indicated by setting :attr:`producer`: 

766 

767 .. code-block:: python 

768 

769 get_target(filename, all_targets).producer = job 

770 

771 This will raise a `ValueError` if the filename is already produced by another job. 

772 

773 If a specific :class:`SingleJob` requires a filename, this can be indicated by adding it to :attr:`required_by`: 

774 

775 .. code-block:: python 

776 

777 get_target(filename, all_targets).required_by.add(job) 

778 

779 Filename existence can be checked using :meth:`exists`. 

780 This method uses caching. To reset the cache run :meth:`reset_existence`. 

781 

782 To check if the filename can be created by this pipeline (or already exists) run :meth:`expected`. 

783 """ 

784 

785 def __init__(self, filename: Path): 

786 """ 

787 Create a new target based on the provided filename. 

788 

789 Do not call this method directly. 

790 Instead use :func:`get_target`. 

791 

792 :param filename: filename 

793 """ 

794 self.filename = Path(filename) 

795 self._producer = None 

796 self.required_by: Set[SingleJob] = set() 

797 self.from_templates: Set[str] = set() 

798 self.intermediate: bool = False 

799 

800 def exists(self) -> bool: 

801 """ 

802 Test whether file exists on disk. 

803 

804 This function is lazy; once it has been checked once it will keep returning the same result. 

805 

806 To reset use :meth:`reset_existence`. 

807 """ 

808 if not hasattr(self, "_exists"): 

809 self._exists = self.filename.is_symlink() or self.filename.exists() 

810 return self._exists 

811 

812 def reset_existence( 

813 self, 

814 ): 

815 """Ensure existence is checked again when running :meth:`exists`.""" 

816 if hasattr(self, "_exists"): 

817 del self._exists 

818 

819 def expected( 

820 self, 

821 ): 

822 """ 

823 Return whether the file can be produced by the pipeline (or already exists). 

824 

825 Returns False if the file does not exist and there is no way to produce it. Otherwise, True is returned 

826 """ 

827 if self.exists(): 

828 return True 

829 if self.producer is None: 

830 return False 

831 return self.producer.expected() 

832 

833 def mark_intermediate(self, intermediate=True): 

834 """ 

835 Mark this file as an intermediate file. 

836 

837 Intermediate files will not be reproduced if they are missing when checking which jobs need to be run. 

838 """ 

839 self.intermediate = intermediate 

840 

841 @property 

842 def producer( 

843 self, 

844 ) -> SingleJob: 

845 """Job that can produce this file.""" 

846 return self._producer 

847 

848 @producer.setter 

849 def producer(self, new_value): 

850 if self._producer is not None: 

851 if self._producer is new_value: 

852 return 

853 raise ValueError( 

854 f"{self} can be produced by both {self.producer} and {new_value}" 

855 ) 

856 self._producer = new_value 

857 

858 def __repr__( 

859 self, 

860 ): 

861 """Print filename of target.""" 

862 return f"FileTarget({str(self.filename)})" 

863 

864 

865@contextmanager 

866def update_closure(*dictionaries, **kwargs): 

867 """Add the provided dictionaries to the globals dictionary. 

868 

869 Inside the `with` block all the dictionary keys will be available in the local environment. 

870 After the `with` block ends the local environment will be cleaned up again. 

871 

872 Use like this: 

873 

874 .. code-block:: python 

875 

876 def my_func() 

877 with update_closure({'a': 3}): 

878 print(a) # prints 3 

879 print(a) # raises a NameError (or prints whatever `a` is set to in the global environment) 

880 """ 

881 func_globals = inspect.stack()[2].frame.f_globals 

882 

883 # merge input dictionaries 

884 new_kwargs = {} 

885 for d in dictionaries: 

886 new_kwargs.update(d) 

887 new_kwargs.update(kwargs) 

888 

889 to_restore = {} 

890 to_delete = set() 

891 for key in new_kwargs: 

892 if key in func_globals: 

893 to_restore[key] = func_globals[key] 

894 else: 

895 to_delete.add(key) 

896 

897 func_globals.update(new_kwargs) 

898 yield # return to run the code within the with-block 

899 

900 # clean up the environment 

901 func_globals.update(to_restore) 

902 for key in to_delete: 

903 del func_globals[key] 

904 del func_globals 

905 

906 

907def call_batched_jobs(funcs, kwargs): 

908 for f, k in zip(funcs, kwargs): 

909 f(**k) 

910 

911 

912class BatchJob(JobParent): 

913 """Batched combination of multiple `SingleJob` instances that will be submitted together.""" 

914 

915 function = staticmethod(call_batched_jobs) 

916 

917 def __init__(self, *sub_jobs): 

918 """Creates a new `BatchJob` from sub_jobs. 

919 

920 `sub_jobs` can be either `SingleJob` or `BatchJob`. In either case they will be run in the order in which they are supplied. 

921 """ 

922 self.torun: Sequence[SingleJob] = [] 

923 for job in sub_jobs: 

924 if isinstance(job, BatchJob): 

925 self.torun.extend(job.torun) 

926 else: 

927 assert isinstance(job, SingleJob) 

928 self.torun.append(job) 

929 self.torun_set = set(self.torun) 

930 

931 self.output_targets = {t for job in self.torun for t in job.output_targets} 

932 for t in self.output_targets: 

933 t._producer = self 

934 self.input_targets = { 

935 t 

936 for job in self.torun 

937 for t in job.input_targets 

938 if t not in self.output_targets 

939 } 

940 for t in self.input_targets: 

941 t.required_by.add(self) 

942 

943 self.optional_targets = set.union( 

944 { 

945 t 

946 for t in self.output_targets 

947 if not any( 

948 t in job.output_targets and t not in job.optional_targets 

949 for job in self.torun 

950 ) 

951 }, 

952 { 

953 t 

954 for t in self.input_targets 

955 if not any( 

956 t in job.input_targets and t not in job.optional_targets 

957 for job in self.torun 

958 ) 

959 }, 

960 ) 

961 

962 as_tuples = set.intersection( 

963 *[ 

964 {(key, value) for key, value in job.set_parameters.items()} 

965 for job in self.torun 

966 ] 

967 ) 

968 self.set_parameters = {key: value for key, value in as_tuples} 

969 

970 def copy(self, targets: Dict[str, "FileTarget"]): 

971 """ 

972 Create a copy of the `BatchJob` to be included in a new :class:`JobList`. 

973 

974 `targets` contain the set of FileTargets recognised by this new `JobList`. 

975 This will be updated based on the input/output targets of each job within this batch. 

976 """ 

977 return BatchJob(*[job.copy(targets) for job in self.torun]) 

978 

979 @property 

980 def batch( 

981 self, 

982 ): 

983 b = self.torun[0].batch 

984 if all(job.batch is not None and job.batch == b for job in self.torun): 

985 return b 

986 return None 

987 

988 @property 

989 def kwargs( 

990 self, 

991 ): 

992 return { 

993 "funcs": [job.function for job in self.torun], 

994 "kwargs": [job.kwargs for job in self.torun], 

995 } 

996 

997 @property 

998 def submit_params( 

999 self, 

1000 ): 

1001 def sum_value(name, *times): 

1002 total = sum(t for t in times if t is not None) 

1003 return None if total == 0 else total 

1004 

1005 def max_value(name, *rams): 

1006 if any(r is not None for r in rams): 

1007 return max(int(r) for r in rams if r is not None) 

1008 return None 

1009 

1010 def extend(name, *vars): 

1011 all_vars = set() 

1012 for v in vars: 

1013 if v is not None: 

1014 all_vars.update(v) 

1015 return list(all_vars) 

1016 

1017 def merge_name(_, *names): 

1018 if len(names) <= 3: 

1019 return "-".join(names) 

1020 return "batch_job" 

1021 

1022 def unique_param(name, *flags): 

1023 not_none = {f for f in flags if f is not None} 

1024 if len(not_none) == 0: 

1025 return None 

1026 elif len(not_none) == 1: 

1027 return not_none.pop() 

1028 else: 

1029 raise ValueError( 

1030 f"Multiple different values for submit parameter flag '{name}' found. fsl-pipe does not know which one to choose out of: {not_none}" 

1031 ) 

1032 

1033 to_combine = { 

1034 "jobtime": sum_value, 

1035 "jobram": max_value, 

1036 "threads": max_value, 

1037 "export_vars": extend, 

1038 "name": merge_name, 

1039 } 

1040 

1041 all_flags = {flag for job in self.torun for flag in job.submit_params.keys()} 

1042 return { 

1043 flag: to_combine.get(flag, unique_param)( 

1044 flag, *[job.submit_params.get(flag, None) for job in self.torun] 

1045 ) 

1046 for flag in all_flags 

1047 } 

1048 

1049 def job_name( 

1050 self, 

1051 ): 

1052 """Return a string representation of this job.""" 

1053 base = "" if self.batch is None else self.batch + "_" 

1054 func_name = "-".join(sorted({job._function_name for job in self.torun})) 

1055 if len(self.set_parameters) > 0: 

1056 parameter_string = "_".join( 

1057 [f"{key}-{value}" for key, value in self.set_parameters.items()] 

1058 ) 

1059 name = f"{func_name}_{parameter_string}" 

1060 else: 

1061 name = func_name 

1062 value = re.sub(r"[^\w\s-]", "", name).strip().lower() 

1063 return base + re.sub(r"[-\s]+", "-", value) 

1064 

1065 def scale_jobtime(self, scaling): 

1066 """ 

1067 Scale the submit job time in place by `scaling`. 

1068 

1069 This will only affect jobs submitted to the cluster. 

1070 """ 

1071 for job in self.torun: 

1072 job.scale_jobtime(scaling) 

1073 

1074 def __repr__( 

1075 self, 

1076 ): 

1077 """Print job as a function call.""" 

1078 return f"Batch({self.torun})" 

1079 

1080 def to_job_list(self, tree: FileTree): 

1081 """Convert batched jobs back into a :class:`JobList`.""" 

1082 result = JobList(tree, self.torun, {}).copy() 

1083 return self.batch, self.submit_params, result 

1084 

1085 

1086def has_dependencies( 

1087 possible_parent: JobParent, possible_children: Set[JobParent], all_jobs 

1088): 

1089 if possible_parent in possible_children: 

1090 return True 

1091 for _, child in possible_parent.dependencies(only_missing=False): 

1092 if child in all_jobs and has_dependencies(child, possible_children, all_jobs): 

1093 return True 

1094 return False 

1095 

1096 

1097def batch_connected_jobs( 

1098 to_batch: Sequence[SingleJob], other_jobs: Collection[SingleJob] 

1099) -> List[JobParent]: 

1100 """Iteratively combines two jobs 

1101 

1102 Two jobs will be batched if: 

1103 1. They are both in `to_batch` 

1104 2. One of the jobs depends on the other 

1105 3. There is no other job that needs to run between these two jobs. 

1106 Iteration through the jobs will continue until no further batching is possible. 

1107 """ 

1108 other_jobs = set(other_jobs) 

1109 batches = set(to_batch) 

1110 nbatches_prev = len(batches) * 2 

1111 while len(batches) != nbatches_prev: 

1112 nbatches_prev = len(batches) 

1113 for job in set(batches): 

1114 if job not in batches: 

1115 continue 

1116 for _, dependency in job.dependencies(only_missing=False): 

1117 if job in batches and dependency in batches: 

1118 # properties 1 and 2 are met; now checking property 3 

1119 all_jobs = set.union(batches, other_jobs) 

1120 indirect_dependency = any( 

1121 has_dependencies(d, {dependency}, all_jobs) 

1122 for _, d in job.dependencies(only_missing=False) 

1123 if d in all_jobs and d != dependency 

1124 ) 

1125 if not indirect_dependency: 

1126 batches.remove(job) 

1127 batches.remove(dependency) 

1128 batches.add(BatchJob(dependency, job)) 

1129 break 

1130 return list(batches) 

1131 

1132 

1133def batch_unconnected_jobs( 

1134 to_batch: Sequence[JobParent], other_jobs: Collection[JobParent] 

1135) -> List[BatchJob]: 

1136 """Batch jobs into generational sets 

1137 

1138 All jobs that do not depend on any other job in `to_batch` will be added to the first generational batch. 

1139 All jobs that only depend on the first generational batch will be added to the second. 

1140 And so forth, until all jobs have been added to a generation. 

1141 

1142 Only dependencies through `other_jobs` will be considered. 

1143 """ 

1144 to_batch = set(to_batch) 

1145 all_jobs = set.union(to_batch, other_jobs) 

1146 

1147 generations = [] 

1148 

1149 while len(to_batch) > 0: 

1150 generations.append(set()) 

1151 for job in to_batch: 

1152 if not any( 

1153 has_dependencies(dependency, to_batch, all_jobs) 

1154 for _, dependency in job.dependencies(only_missing=False) 

1155 if dependency in all_jobs 

1156 ): 

1157 generations[-1].add(job) 

1158 to_batch.difference_update(generations[-1]) 

1159 return [jobs.pop() if len(jobs) == 1 else BatchJob(*jobs) for jobs in generations]