Coverage for src/parsers/gitlab.py: 100.00%

508 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-18 15:02 +0000

1#!/usr/bin/env python3 

2 

3# Standard libraries 

4from argparse import Namespace 

5from collections import OrderedDict 

6from itertools import product 

7from os import environ 

8from pathlib import Path 

9from re import match 

10from time import sleep 

11from typing import Any, Dict, List, NamedTuple, Optional 

12 

13# Modules libraries 

14from packaging.version import parse as parse_version 

15 

16# Components 

17from ..models.flags import Flags 

18from ..models.images import Image, Images 

19from ..models.jobs import Job, JobData 

20from ..models.pipelines import Pipeline 

21from ..models.services import Service 

22from ..models.variables import VariablesParser 

23from ..package.bundle import Bundle 

24from ..package.updates import Updates 

25from ..package.version import Version 

26from ..prints.colors import Colors 

27from ..system.platform import Platform 

28from ..types.environment import Environment 

29from ..types.paths import Paths 

30from ..types.volumes import Volumes 

31from ..types.yaml import YAML 

32 

33# GitLab class, pylint: disable=too-many-lines 

34class GitLab: 

35 

36 # Constants 

37 LOCAL_NODE: str = '.local' 

38 

39 # Specifications 

40 JOB_IMAGE_DEFAULT: str = 'ruby:3.1' 

41 JOB_STAGE_DEFAULT: str = 'test' 

42 STAGE_POST: str = '.post' 

43 STAGE_PRE: str = '.pre' 

44 STAGES_DEFAULT: Dict[str, int] = { 

45 STAGE_PRE: 1, 

46 'build': 2, 

47 'test': 3, 

48 'deploy': 4, 

49 STAGE_POST: 5, 

50 } 

51 

52 # Environment 

53 ENV_BUILDS_DIR: str = 'CI_BUILDS_DIR' 

54 ENV_GIT_CLONE_PATH: str = 'GIT_CLONE_PATH' 

55 ENV_JOB_NAME: str = 'CI_JOB_NAME' 

56 ENV_JOB_NAME_SLUG: str = 'CI_JOB_NAME_SLUG' 

57 ENV_PROJECT_DIR: str = 'CI_PROJECT_DIR' 

58 

59 # Variant type 

60 class Variant(NamedTuple): 

61 

62 # Properties 

63 name: str 

64 variables: Dict[str, str] 

65 

66 # Members 

67 __options: Namespace 

68 

69 # Constructor 

70 def __init__( 

71 self, 

72 options: Namespace, 

73 ) -> None: 

74 

75 # Initialize members 

76 self.__options = options 

77 

78 # Merges 

79 @staticmethod 

80 def __merges( 

81 data: YAML.Data, 

82 additions: YAML.Data, 

83 ) -> None: 

84 

85 # Validate additions 

86 if not data or (not isinstance(data, dict) 

87 and not isinstance(data, list)): # pragma: no cover 

88 return 

89 

90 # Validate additions 

91 if not additions or not isinstance(additions, dict): # pragma: no cover 

92 return 

93 

94 # Agregate data 

95 base = data.copy() 

96 data.clear() 

97 

98 # Merge data additions 

99 for key in additions: 

100 

101 # Handle included expanding dict data 

102 if key in base and isinstance(additions[key], dict) and key in [ 

103 'variables', 

104 ]: 

105 data[key] = base[key] 

106 data[key].update(additions[key]) 

107 

108 # Handle included expanding list data 

109 elif key in base and isinstance(additions[key], list) and key in [ 

110 'volumes', 

111 ]: 

112 data[key] = list(set(base[key] + additions[key])) 

113 

114 # Handle included expanding dict data 

115 elif key in base and isinstance(additions[key], dict): 

116 data[key] = base[key] 

117 GitLab.__merges(data[key], additions[key]) 

118 

119 # Handle included overriding data 

120 else: 

121 data[key] = additions[key] 

122 

123 # Merge data base 

124 for key in base: 

125 

126 # Handle unique base data 

127 if key not in data: 

128 data[key] = base[key] 

129 

130 # Scripts 

131 @staticmethod 

132 def __scripts(items: str | List[str]) -> List[str]: 

133 

134 # Variables 

135 scripts = [] 

136 

137 # Parse scripts data 

138 if isinstance(items, str): 

139 scripts = [items] 

140 elif isinstance(items, list): 

141 scripts = [] 

142 for item in items: 

143 if isinstance(item, str): 

144 scripts += [item] 

145 elif isinstance(item, list): 

146 scripts += item[:] 

147 

148 # Result 

149 return scripts 

150 

151 # Pipeline, pylint: disable=too-many-branches 

152 @staticmethod 

153 def __pipeline( 

154 pipeline: Pipeline, 

155 data: YAML.Data, 

156 stages: Dict[str, int], 

157 ) -> None: 

158 

159 # Parse variables node 

160 if 'variables' in data: 

161 GitLab.__pipeline_variables(pipeline, data['variables']) 

162 

163 # Parse image node 

164 if 'image' in data: 

165 GitLab.__pipeline_image(data['image'], pipeline) 

166 

167 # Parse before_script node 

168 if 'before_script' in data: 

169 pipeline.before_script = GitLab.__scripts(data['before_script']) 

170 

171 # Parse after_script node 

172 if 'after_script' in data: 

173 pipeline.after_script = GitLab.__scripts(data['after_script']) 

174 

175 # Parse services node 

176 if 'services' in data and isinstance(data['services'], list): 

177 GitLab.__pipeline_services(data['services'], pipeline) 

178 

179 # Parse stages node 

180 if 'stages' in data: 

181 stages.clear() 

182 stages[GitLab.STAGE_PRE] = len(stages) + 1 

183 for _, stage in enumerate(data['stages']): 

184 if stage is not GitLab.STAGE_PRE and stage is not GitLab.STAGE_POST: 

185 stages[stage] = len(stages) + 1 

186 stages[GitLab.STAGE_POST] = len(stages) + 1 

187 

188 # Parse default node 

189 if 'default' in data: 

190 

191 # Parse default image node 

192 if 'image' in data['default']: 

193 if 'image' in data: 

194 raise SyntaxError( 

195 'image is defined in top-level and `default:` entry') 

196 GitLab.__pipeline_image(data['default']['image'], pipeline) 

197 

198 # Parse default before_script node 

199 if 'before_script' in data['default']: 

200 if 'before_script' in data: 

201 raise SyntaxError( 

202 'before_script is defined in top-level and `default:` entry') 

203 pipeline.before_script = GitLab.__scripts( 

204 data['default']['before_script']) 

205 

206 # Parse default after_script node 

207 if 'after_script' in data['default']: 

208 if 'after_script' in data: 

209 raise SyntaxError( 

210 'after_script is defined in top-level and `default:` entry') 

211 pipeline.after_script = GitLab.__scripts(data['default']['after_script']) 

212 

213 # Parse default services node 

214 if 'services' in data['default'] and isinstance(data['default']['services'], 

215 list): 

216 if 'services' in data: 

217 raise SyntaxError( 

218 'services is defined in top-level and `default:` entry') 

219 GitLab.__pipeline_services(data['default']['services'], pipeline) 

220 

221 # Pipeline image 

222 @staticmethod 

223 def __pipeline_image( 

224 image_data: Dict[str, Any] | str, 

225 pipeline: Pipeline, 

226 ) -> None: 

227 

228 # Parse image data 

229 if not pipeline.image: 

230 if isinstance(image_data, dict): 

231 pipeline.image = Image( 

232 pipeline.variables.expand( 

233 image_data['name'], types=[ 

234 VariablesParser.Types.PARAMETERS, 

235 VariablesParser.Types.LOCALS, 

236 VariablesParser.Types.GLOBALS, 

237 VariablesParser.Types.ENV_FILES, 

238 ])) 

239 if not pipeline.entrypoint: 

240 if 'entrypoint' in image_data and len(image_data['entrypoint']) > 0: 

241 pipeline.entrypoint = image_data['entrypoint'][:] 

242 else: 

243 pipeline.entrypoint = None 

244 else: 

245 pipeline.image = Image( 

246 pipeline.variables.expand( 

247 image_data, types=[ 

248 VariablesParser.Types.PARAMETERS, 

249 VariablesParser.Types.LOCALS, 

250 VariablesParser.Types.GLOBALS, 

251 VariablesParser.Types.ENV_FILES, 

252 ])) 

253 if not pipeline.entrypoint: 

254 pipeline.entrypoint = None 

255 

256 # Pipeline services 

257 @staticmethod 

258 def __pipeline_services( 

259 services_data: List[Any], 

260 pipeline: Pipeline, 

261 ) -> None: 

262 

263 # Parse services data 

264 pipeline.services = [] 

265 for item in services_data: 

266 if isinstance(item, dict): 

267 pipeline.services += [ 

268 Service( 

269 image=Environment.expand(item.get('name', '')), 

270 alias=item.get('alias', ''), 

271 ) 

272 ] 

273 elif isinstance(item, str): 

274 pipeline.services += [ 

275 Service( 

276 image=Environment.expand(item), 

277 alias='', 

278 ) 

279 ] 

280 

281 # Pipeline variables 

282 @staticmethod 

283 def __pipeline_variables( 

284 pipeline: Pipeline, 

285 variables_data: YAML.Data, 

286 ) -> None: 

287 

288 # Parse variables data 

289 for variable in variables_data: 

290 if variable not in pipeline.variables.globals: 

291 if variables_data[variable] is None: 

292 pipeline.variables.globals[variable] = '' 

293 else: 

294 variable_data = variables_data[variable] 

295 if isinstance(variable_data, dict): 

296 variable_value = str(variable_data['value']) 

297 else: 

298 variable_value = str(variable_data) 

299 pipeline.variables.globals[variable] = variable_value 

300 

301 # Include 

302 @staticmethod 

303 def __include( 

304 data: YAML.Data, 

305 stack: List[str], 

306 root_directory: Path, 

307 working_directory: Path, 

308 parent_path: str, 

309 ) -> None: 

310 

311 # Parse nested include 

312 if data and 'include' in data and data['include']: 

313 

314 # Prepare includes nodes 

315 data_include_list = [] 

316 if isinstance(data['include'], dict): 

317 data_include_list = [data['include']] 

318 elif isinstance(data['include'], list): 

319 data_include_list = data['include'] 

320 elif isinstance(data['include'], str): 

321 data_include_list = [{'local': data['include']}] 

322 

323 # Iterate through includes nodes 

324 for include_node in data_include_list: 

325 

326 # Adapt include nodes 

327 include_dict: dict = {} # type: ignore[type-arg] 

328 if isinstance(include_node, dict): 

329 include_dict = include_node 

330 elif isinstance(include_node, str): 

331 include_dict = {'local': include_node} 

332 

333 # Parse local nodes 

334 if 'local' in include_dict: 

335 GitLab.__include_local( 

336 data, 

337 stack, 

338 root_directory, 

339 working_directory, 

340 include_dict, 

341 parent_path, 

342 ) 

343 

344 # Parse project node 

345 elif 'project' in include_dict: 

346 GitLab.__include_project( 

347 data, 

348 stack, 

349 working_directory, 

350 include_dict, 

351 parent_path, 

352 ) 

353 

354 # Parse component node 

355 elif 'component' in include_dict: 

356 GitLab.__include_component( 

357 data, 

358 stack, 

359 working_directory, 

360 include_dict, 

361 parent_path, 

362 ) 

363 

364 # Include local, pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments 

365 @staticmethod 

366 def __include_local( 

367 data: YAML.Data, 

368 stack: List[str], 

369 root_directory: Path, 

370 working_directory: Path, 

371 include_dict: dict, # type: ignore[type-arg] 

372 parent_path: str, 

373 ) -> None: 

374 

375 # Variables 

376 include_path: str = include_dict['local'] 

377 

378 # Handle include relative paths 

379 if include_path.startswith('/'): 

380 include_path = include_path.lstrip('/') 

381 resolved_path = Paths.resolve(root_directory / include_path) 

382 

383 # Handle include relative paths 

384 else: 

385 resolved_path = Paths.resolve(working_directory / include_path) 

386 

387 # Already included file 

388 if resolved_path in stack: 

389 return 

390 

391 # Parse inputs 

392 file_inputs: YAML.Data = {} 

393 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict): 

394 file_inputs = include_dict['inputs'] 

395 

396 # Existing file inclusion 

397 file_paths: Path = Path(working_directory) / resolved_path 

398 for file_path in Paths.wildcard(str(file_paths), strict=True): 

399 if file_path.is_file(): 

400 

401 # Register included file 

402 stack.append(resolved_path) 

403 

404 # Load included file 

405 with open(file_path, encoding='utf8', mode='r') as include_data: 

406 data_additions: Optional[YAML.Data] = YAML.load( 

407 include_data, 

408 inputs=file_inputs, 

409 configure=False, 

410 ) 

411 if data_additions: 

412 

413 # Nested includes (follow working directory) 

414 GitLab.__include( 

415 data_additions, 

416 stack, 

417 root_directory, 

418 file_path.parent, 

419 resolved_path, 

420 ) 

421 

422 # Agregate data 

423 GitLab.__merges(data_additions, data) 

424 data.clear() 

425 data.update(data_additions) 

426 

427 # Empty included file 

428 else: 

429 raise SyntaxError( 

430 f'Empty "{file_path}" file included in "{parent_path}"') 

431 

432 # Missing file failure 

433 else: 

434 raise FileNotFoundError( 

435 f'Missing "{file_path}" file included in "{parent_path}"') 

436 

437 # Include project 

438 @staticmethod 

439 def __include_project( 

440 data: YAML.Data, 

441 stack: List[str], 

442 working_directory: Path, 

443 include_dict: dict, # type: ignore[type-arg] 

444 parent_path: str, 

445 ) -> None: 

446 

447 # Variables 

448 project_path: str = '' 

449 project_url: str = include_dict['project'] 

450 

451 # Acquire local node 

452 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]: 

453 local = data[GitLab.LOCAL_NODE] 

454 

455 # Acquire local include node 

456 if 'include' in local and project_url in local['include'] and isinstance( 

457 local['include'][project_url], str): 

458 project_path = Paths.expand( 

459 local['include'][project_url], 

460 env=True, 

461 home=True, 

462 ) 

463 

464 # Exclude missing include node 

465 if not project_path: 

466 return 

467 

468 # Parse file paths 

469 file_items: List[str] = [] 

470 if 'file' in include_dict: 

471 if isinstance(include_dict['file'], list): 

472 file_items = include_dict['file'][:] 

473 elif isinstance(include_dict['file'], str): 

474 file_items = [include_dict['file']] 

475 

476 # Parse inputs 

477 file_inputs: YAML.Data = {} 

478 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict): 

479 file_inputs = include_dict['inputs'] 

480 

481 # Iterate through file paths 

482 for file_item in file_items: 

483 

484 # Already included file 

485 resolved_path = Paths.resolve( 

486 working_directory / project_path / file_item.lstrip('/')) 

487 if resolved_path in stack: 

488 continue 

489 

490 # Existing file inclusion 

491 file_paths: Path = Path(working_directory) / resolved_path 

492 for file_path in Paths.wildcard(str(file_paths), strict=True): 

493 if file_path.is_file(): 

494 

495 # Register included file 

496 stack.append(resolved_path) 

497 

498 # Load included file 

499 with open(file_path, encoding='utf8', mode='r') as include_data: 

500 data_additions: Optional[YAML.Data] = YAML.load( 

501 include_data, 

502 inputs=file_inputs, 

503 configure=False, 

504 ) 

505 if data_additions: 

506 

507 # Nested includes (follow working directory) 

508 project_working_directory: Path = Path( 

509 working_directory) / Paths.resolve( 

510 working_directory / project_path) 

511 GitLab.__include( 

512 data_additions, 

513 stack, 

514 project_working_directory, 

515 project_working_directory, 

516 str(file_path), 

517 ) 

518 

519 # Agregate data 

520 GitLab.__merges(data_additions, data) 

521 data.clear() 

522 data.update(data_additions) 

523 

524 # Empty included file 

525 else: 

526 raise SyntaxError( 

527 f'Empty "{file_path}" file included in "{parent_path}"') 

528 

529 # Missing file failure 

530 else: 

531 raise FileNotFoundError( 

532 f'Missing "{file_path}" file included in "{parent_path}"') 

533 

534 # Include component 

535 @staticmethod 

536 def __include_component( 

537 data: YAML.Data, 

538 stack: List[str], 

539 working_directory: Path, 

540 include_dict: dict, # type: ignore[type-arg] 

541 parent_path: str, 

542 ) -> None: 

543 

544 # Variables 

545 component_path: str = '' 

546 component_url: str = include_dict['component'] 

547 

548 # Acquire local node 

549 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]: 

550 local = data[GitLab.LOCAL_NODE] 

551 

552 # Acquire local include node 

553 if 'include' in local and component_url in local['include'] and isinstance( 

554 local['include'][component_url], str): 

555 component_path = Paths.expand( 

556 local['include'][component_url], 

557 env=True, 

558 home=True, 

559 ) 

560 

561 # Exclude missing include node 

562 if not component_path: 

563 return 

564 

565 # Parse inputs 

566 file_inputs: YAML.Data = {} 

567 if 'inputs' in include_dict and isinstance(include_dict['inputs'], dict): 

568 file_inputs = include_dict['inputs'] 

569 

570 # Already included file 

571 resolved_path = Paths.resolve(working_directory / component_path) 

572 if resolved_path in stack: 

573 return 

574 

575 # Existing file inclusion 

576 file_paths: Path = Path(working_directory) / resolved_path 

577 for file_path in Paths.wildcard(str(file_paths), strict=True): 

578 if file_path.is_file(): 

579 

580 # Register included file 

581 stack.append(resolved_path) 

582 

583 # Load included file 

584 with open(file_path, encoding='utf8', mode='r') as include_data: 

585 data_additions: Optional[YAML.Data] = YAML.load( 

586 include_data, 

587 inputs=file_inputs, 

588 configure=False, 

589 ) 

590 if data_additions: 

591 

592 # Nested includes (follow working directory) 

593 component_working_directory: Path = Path( 

594 working_directory) / Paths.resolve( 

595 working_directory / component_path) 

596 GitLab.__include( 

597 data_additions, 

598 stack, 

599 component_working_directory, 

600 component_working_directory, 

601 str(file_path), 

602 ) 

603 

604 # Agregate data 

605 GitLab.__merges(data_additions, data) 

606 data.clear() 

607 data.update(data_additions) 

608 

609 # Empty included file 

610 else: 

611 raise SyntaxError( 

612 f'Empty "{file_path}" file included in "{parent_path}"') 

613 

614 # Missing file failure 

615 else: 

616 raise FileNotFoundError( 

617 f'Missing "{file_path}" file included in "{parent_path}"') 

618 

619 # Local, pylint: disable=too-many-branches,too-many-statements 

620 @staticmethod 

621 def __local( 

622 options: Namespace, 

623 pipeline: Pipeline, 

624 data: YAML.Data, 

625 ) -> None: 

626 

627 # Variables 

628 local_unsupported: List[str] 

629 names_local = False 

630 

631 # Filter local node, pylint: disable=too-many-nested-blocks 

632 if GitLab.LOCAL_NODE in data and data[GitLab.LOCAL_NODE]: 

633 local = data[GitLab.LOCAL_NODE] 

634 

635 # Parse local all 

636 if 'all' in local and not options.all: 

637 options.all = local['all'] 

638 

639 # Parse local env 

640 if 'env' in local: 

641 pipeline.variables.local_parse_env(local['env']) 

642 

643 # Parse local image 

644 if 'image' in local and not options.image: 

645 options.image = local['image'] 

646 

647 # Parse local names 

648 if 'names' in local and not options.names and not options.pipeline: 

649 names_local = True 

650 options.names = local['names'] 

651 

652 # Parse local no_regex 

653 if 'no_regex' in local and not options.no_regex: 

654 options.no_regex = local['no_regex'] 

655 

656 # Parse local pipeline 

657 if 'pipeline' in local and not options.pipeline and (not options.names 

658 or names_local): 

659 options.pipeline = local['pipeline'] 

660 

661 # Parse local tags 

662 if 'tags' in local and options.tags_default: 

663 options.tags = local['tags'][:] 

664 options.tags_default = False 

665 

666 # Parse local variables 

667 if 'variables' in local: 

668 pipeline.variables.local_parse_variables(local['variables']) 

669 

670 # Parse local version 

671 if 'version' in local: 

672 version: str = str(local['version']) 

673 

674 # Newer local recommended version 

675 package_version: str = Version.get() 

676 if package_version != '0.0.0' and parse_version( 

677 package_version) < parse_version(version): 

678 Updates.message(name=Bundle.PACKAGE, recommended=version) 

679 sleep(2) 

680 

681 # Parse local volumes 

682 if 'volumes' in local: 

683 if not options.volume: 

684 options.volume = [] 

685 for volume in local['volumes']: 

686 options.volume += [Volumes.LOCAL_FLAG + volume] 

687 

688 # Detect unsupported local keys 

689 local_unsupported = [ 

690 key for key in local if key not in [ 

691 'all', 

692 'env', 

693 'image', 

694 'include', 

695 'names', 

696 'no_regex', 

697 'pipeline', 

698 'tags', 

699 'variables', 

700 'version', 

701 'volumes', 

702 ] 

703 ] 

704 if local_unsupported: 

705 print(' ') 

706 print( 

707 f' {Colors.YELLOW}{Colors.ARROW} WARNING: ' 

708 f'{Colors.RED}.local: {", ".join(local_unsupported)}: ' 

709 f'{Colors.BOLD}YAML configuration ' 

710 f'{"are" if len(local_unsupported) > 1 else "is"} unsupported, see: ' 

711 f'{Colors.GREEN}{Bundle.REPOSITORY}/-/issues/292' 

712 f'{Colors.RESET}') 

713 Platform.flush() 

714 if Platform.IS_TTY_STDIN: 

715 sleep(5) 

716 

717 # Variants, pylint: disable=too-many-nested-blocks 

718 @staticmethod 

719 def __variants( 

720 pipeline: Pipeline, 

721 data: YAML.Data, 

722 node: str, 

723 ) -> List[Variant]: 

724 

725 # Variables 

726 variants: List[GitLab.Variant] = [] 

727 

728 # Handle parallel variants integer 

729 if 'parallel' in data[node] and isinstance(data[node]['parallel'], int): 

730 

731 # Register all combinations 

732 for parallel_index in range(1, data[node]['parallel'] + 1): 

733 variants += [ 

734 GitLab.Variant( 

735 name=f"{node} {parallel_index}/{data[node]['parallel']}", 

736 variables={}, 

737 ) 

738 ] 

739 

740 # Handle matrix variants list 

741 elif 'parallel' in data[node] and isinstance(data[node]['parallel'], dict) \ 

742 and 'matrix' in data[node]['parallel']: 

743 

744 # Iterate through matrix items 

745 for matrix_item in data[node]['parallel']['matrix']: 

746 

747 # Prepare matrix map 

748 matrix_item_map: Dict[str, List[str]] = {} 

749 

750 # Iterate through matrix item 

751 for matrix_variable, matrix_values in matrix_item.items(): 

752 

753 # Already defined environment variable 

754 if matrix_variable in pipeline.variables.parameters: 

755 matrix_item_map[matrix_variable] = [ 

756 pipeline.variables.parameters[matrix_variable] 

757 ] 

758 

759 # Already defined environment variable 

760 elif matrix_variable in environ: 

761 matrix_item_map[matrix_variable] = [environ[matrix_variable]] 

762 

763 # Matrix defined environment variable 

764 else: 

765 matrix_item_map[matrix_variable] = [] 

766 if isinstance(matrix_values, str): 

767 matrix_item_map[matrix_variable] += [matrix_values] 

768 elif isinstance(matrix_values, list): 

769 for matrix_value in matrix_values: 

770 matrix_item_map[matrix_variable] += [matrix_value] 

771 

772 # Extract all combinations 

773 keys, values = zip(*matrix_item_map.items()) 

774 matrix_item_environments: List[Dict[str, str]] = [ 

775 dict(zip(keys, v)) for v in product(*values) 

776 ] 

777 

778 # Register all combinations 

779 for matrix_item_variables in matrix_item_environments: 

780 variants += [ 

781 GitLab.Variant( 

782 name= 

783 f"{node}: [{', '.join(list(matrix_item_variables.values()))}]", 

784 variables=matrix_item_variables, 

785 ) 

786 ] 

787 

788 # Prepare default variants list 

789 else: 

790 variants = [GitLab.Variant( 

791 name=node, 

792 variables={}, 

793 )] 

794 

795 # Result 

796 return variants 

797 

798 # Job, pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments,too-many-statements 

799 @staticmethod 

800 def job( 

801 options: Namespace, 

802 pipeline: Pipeline, 

803 job_node: str, 

804 job_name: str, 

805 data: YAML.Data, 

806 extend: bool = False, 

807 ) -> JobData: 

808 

809 # Variables 

810 job: JobData = JobData(name=job_name) 

811 job_data = data[job_node] 

812 

813 # Prepare options 

814 job.options.env_builds_path = GitLab.ENV_BUILDS_DIR 

815 job.options.env_job_name = GitLab.ENV_JOB_NAME 

816 job.options.env_job_name_slug = GitLab.ENV_JOB_NAME_SLUG 

817 job.options.env_job_path = GitLab.ENV_PROJECT_DIR 

818 

819 # Extract job extends 

820 if 'extends' in job_data and job_data['extends']: 

821 if isinstance(job_data['extends'], list): 

822 job_extends = job_data['extends'] 

823 else: 

824 job_extends = [job_data['extends']] 

825 

826 # Iterate through extended jobs 

827 for job_extend in reversed(job_extends): 

828 

829 # Validate extended job 

830 if job_extend not in data: 

831 job.options.extends_unknown += [f'{job_extend} unknown'] 

832 continue 

833 

834 # Parse extended job 

835 job_extended: JobData = GitLab.job( 

836 options, 

837 pipeline, 

838 job_extend, 

839 job_extend, 

840 data, 

841 True, 

842 ) 

843 

844 # List available extended job 

845 job.options.extends_available += [job_extend] 

846 

847 # Extract extended job 

848 if job.stage is None: 

849 job.stage = job_extended.stage 

850 if job.image is None: 

851 job.image = job_extended.image 

852 if job.entrypoint is None: 

853 job.entrypoint = job_extended.entrypoint 

854 if job_extended.variables: 

855 for variable, value in job_extended.variables.items(): 

856 if variable not in job.variables: 

857 job.variables[variable] = value 

858 if job.before_script is None: 

859 job.before_script = job_extended.before_script 

860 if job.script is None: 

861 job.script = job_extended.script 

862 if job.after_script is None: 

863 job.after_script = job_extended.after_script 

864 if job.retry is None: 

865 job.retry = job_extended.retry 

866 if job.when is None: 

867 job.when = job_extended.when 

868 if job.allow_failure is None: 

869 job.allow_failure = job_extended.allow_failure 

870 if job.services is None: 

871 job.services = job_extended.services 

872 if job.tags is None: 

873 job.tags = job_extended.tags 

874 if job.trigger is None: 

875 job.trigger = job_extended.trigger 

876 

877 # Apply pipeline values 

878 if not extend: 

879 if job.image is None: 

880 job.image = pipeline.image 

881 if job.entrypoint is None: 

882 job.entrypoint = pipeline.entrypoint[:] if pipeline.entrypoint else None 

883 if job.before_script is None: 

884 job.before_script = pipeline.before_script[:] 

885 if job.script is None: 

886 job.script = [] 

887 if job.after_script is None: 

888 job.after_script = pipeline.after_script[:] 

889 if job.retry is None: 

890 job.retry = 0 

891 if job.services is None: 

892 job.services = pipeline.services[:] 

893 if job.when is None: 

894 job.when = 'on_success' 

895 if job.allow_failure is None: 

896 job.allow_failure = False 

897 

898 # Extract job stage 

899 if 'stage' in job_data and job_data['stage']: 

900 job.stage = job_data['stage'] 

901 elif job.stage is None and not extend: 

902 job.stage = GitLab.JOB_STAGE_DEFAULT 

903 

904 # Extract job image 

905 if 'image' in job_data and job_data['image']: 

906 image_data = job_data['image'] 

907 if isinstance(image_data, dict): 

908 job.image = Environment.expand(image_data['name']) 

909 if 'entrypoint' in image_data and len(image_data['entrypoint']) > 0: 

910 job.entrypoint = image_data['entrypoint'][:] 

911 else: 

912 job.entrypoint = None 

913 else: 

914 job.image = Environment.expand(image_data) 

915 job.entrypoint = None 

916 

917 # Extract job variables 

918 if 'variables' in job_data and job_data['variables']: 

919 job.variables.update(job_data['variables']) 

920 

921 # Prepare job variables 

922 job.variables = pipeline.variables.evaluate_job(job.variables) 

923 

924 # Extract job before_script 

925 if 'before_script' in job_data: 

926 job.before_script = GitLab.__scripts(job_data['before_script']) 

927 

928 # Extract job script 

929 if 'script' in job_data: 

930 if options.commands: 

931 job.script = GitLab.__scripts(options.commands) 

932 else: 

933 job.script = GitLab.__scripts(job_data['script']) 

934 

935 # Extract job after_script 

936 if 'after_script' in job_data: 

937 job.after_script = GitLab.__scripts(job_data['after_script']) 

938 

939 # Extract job retry 

940 if 'retry' in job_data: 

941 retry_data = job_data['retry'] 

942 if isinstance(retry_data, dict): 

943 job.retry = int(retry_data['max']) 

944 else: 

945 job.retry = int(retry_data) 

946 

947 # Extract job when 

948 if 'when' in job_data and job_data['when'] in [ 

949 'on_success', 'on_failure', 'always', 'manual' 

950 ]: 

951 job.when = job_data['when'] 

952 

953 # Extract job allow_failure 

954 if 'allow_failure' in job_data and job_data['allow_failure'] in [True, False]: 

955 job.allow_failure = job_data['allow_failure'] 

956 

957 # Extract job services 

958 if 'services' in job_data and isinstance(job_data['services'], list): 

959 job.services = [] 

960 for item in job_data['services']: 

961 if isinstance(item, dict): 

962 job.services += [ 

963 Service( 

964 image=Environment.expand(item.get('name', '')), 

965 alias=item.get('alias', ''), 

966 ) 

967 ] 

968 elif isinstance(item, str): 

969 job.services += [Service( 

970 image=Environment.expand(item), 

971 alias='', 

972 )] 

973 

974 # Extract job tags 

975 if 'tags' in job_data and isinstance(job_data['tags'], list): 

976 job.tags = job_data['tags'][:] 

977 assert job.tags is not None 

978 for index, tag in enumerate(job.tags): 

979 job.tags[index] = Environment.expand(tag) 

980 

981 # Finalize job extends 

982 if 'extends' in job_data and job_data['extends']: 

983 

984 # Allowed incomplete extended job 

985 if Flags.enabled(Bundle.FLAG_EXTENDS_INCOMPLETE, job.variables): 

986 pass 

987 

988 # Detect incomplete extended job 

989 elif job.options.extends_unknown and \ 

990 (len(job_extends) == 1 or len(job.options.extends_available) == 0): 

991 job.options.disabled = ', '.join(job.options.extends_unknown) 

992 

993 # Extract job trigger 

994 if 'trigger' in job_data and job_data['trigger']: 

995 job.options.disabled = 'trigger only' 

996 if isinstance(job_data['trigger'], (dict, str)): 

997 job.trigger = job_data['trigger'] 

998 

999 # Finalize pipeline values 

1000 if not extend: 

1001 

1002 # Configure job tags 

1003 if job.tags and (set(job.tags) & set(options.tags)): 

1004 job.when = 'manual' 

1005 

1006 # Default GitLab image 

1007 if not job.image and not extend: 

1008 if environ.get(Bundle.ENV_IMAGE_DEFAULT, ''): 

1009 job.image = environ[Bundle.ENV_IMAGE_DEFAULT][:] 

1010 else: 

1011 job.image = GitLab.JOB_IMAGE_DEFAULT[:] 

1012 

1013 # Detect GIT_CLONE_PATH 

1014 if GitLab.ENV_GIT_CLONE_PATH in job.variables: 

1015 job.options.git_clone_path = job.variables[GitLab.ENV_GIT_CLONE_PATH] 

1016 

1017 # Detect host jobs 

1018 if Flags.check_bool( 

1019 option=options.host, 

1020 flag=Bundle.FLAG_HOST, 

1021 variables=job.variables, 

1022 default=False, 

1023 ): 

1024 job.options.host = True 

1025 elif job.image: 

1026 job.options.host = Images.host(job.image) 

1027 if Images.quiet(job.image): 

1028 job.variables[Bundle.FLAG_QUIET] = Flags.string(Images.quiet(job.image)) 

1029 job.options.silent = Images.silent(job.image) 

1030 

1031 # Apply verbose option 

1032 job.options.verbose = not Flags.check_bool( 

1033 option=options.no_verbose, 

1034 flag=Bundle.FLAG_NO_VERBOSE, 

1035 variables=job.variables, 

1036 default=False, 

1037 ) and not job.options.silent 

1038 

1039 # Detect sockets services 

1040 if job.services: 

1041 for service in job.services: 

1042 if match(Images.DOCKER_DIND_REGEX, service.image): 

1043 job.options.sockets = True 

1044 

1045 # Apply sockets option 

1046 if not job.options.sockets: 

1047 job.options.sockets = Flags.check_bool( 

1048 option=options.sockets, 

1049 flag=Bundle.FLAG_SOCKETS, 

1050 variables=job.variables, 

1051 default=False, 

1052 ) 

1053 

1054 # Apply ssh option 

1055 if not job.options.ssh: 

1056 job.options.ssh = Flags.check_strbool( 

1057 option=options.ssh, 

1058 variables=job.variables, 

1059 flag=Bundle.FLAG_SSH, 

1060 default='', 

1061 const=Bundle.ARGUMENT_SSH_USER_DEFAULT, 

1062 ) 

1063 

1064 # Result 

1065 return job 

1066 

1067 # Parse 

1068 def parse( 

1069 self, 

1070 data: YAML.Data, 

1071 ) -> Pipeline: 

1072 

1073 # Variables 

1074 pipeline: Pipeline = Pipeline(self.__options) 

1075 stages: Dict[str, int] = GitLab.STAGES_DEFAULT.copy() 

1076 

1077 # Cache environment 

1078 pipeline.variables.environment_cache() 

1079 

1080 # Parse nested include 

1081 GitLab.__include( 

1082 data, 

1083 [Paths.resolve(self.__options.path / self.__options.configuration)], 

1084 self.__options.path, 

1085 self.__options.path, 

1086 Paths.resolve(self.__options.path / self.__options.configuration), 

1087 ) 

1088 

1089 # Resolve YAML nodes 

1090 YAML.resolve(data) 

1091 

1092 # Filter local node 

1093 self.__local(self.__options, pipeline, data) 

1094 

1095 # Apply variables 

1096 pipeline.variables.apply(types=[ 

1097 VariablesParser.Types.PARAMETERS, 

1098 VariablesParser.Types.LOCALS, 

1099 VariablesParser.Types.ENV_FILES, 

1100 ]) 

1101 

1102 # Prepare pipeline image 

1103 if self.__options.image: 

1104 if isinstance(self.__options.image, dict): 

1105 if 'name' in self.__options.image: 

1106 pipeline.image = Environment.expand(self.__options.image['name']) 

1107 if 'entrypoint' in self.__options.image and len( 

1108 self.__options.image['entrypoint']) > 0: 

1109 pipeline.entrypoint = self.__options.image['entrypoint'][:] 

1110 else: 

1111 pipeline.entrypoint = None 

1112 else: 

1113 pipeline.image = Environment.expand(self.__options.image) 

1114 pipeline.entrypoint = None 

1115 

1116 # Parse pipeline nodes 

1117 GitLab.__pipeline( 

1118 pipeline, 

1119 data, 

1120 stages, 

1121 ) 

1122 

1123 # Iterate through nodes 

1124 for node in data: 

1125 

1126 # Ignore pipeline nodes 

1127 if node in [ 

1128 'after_script', 

1129 'before_script', 

1130 'image', 

1131 'include', 

1132 'services', 

1133 'stages', 

1134 'variables', 

1135 ]: 

1136 continue 

1137 

1138 # Validate job node 

1139 if 'script' not in data[node] and 'extends' not in data[node]: 

1140 continue 

1141 

1142 # Ignore template stage 

1143 if node[0:1] == '.': 

1144 continue 

1145 

1146 # Nodes variants 

1147 variants = GitLab.__variants( 

1148 pipeline, 

1149 data, 

1150 node, 

1151 ) 

1152 

1153 # Iterate through node 

1154 for variant in variants: 

1155 

1156 # Restore environment 

1157 pipeline.variables.environment_restore() 

1158 

1159 # Apply variables 

1160 pipeline.variables.apply(types=[ 

1161 VariablesParser.Types.PARAMETERS, 

1162 VariablesParser.Types.LOCALS, 

1163 VariablesParser.Types.GLOBALS, 

1164 VariablesParser.Types.ENV_FILES, 

1165 ]) 

1166 

1167 # Acquire variant name 

1168 name = variant.name 

1169 

1170 # Prepare variant variables 

1171 if variant.variables: 

1172 VariablesParser.environment_update(variant.variables) 

1173 

1174 # Register job 

1175 pipeline.jobs[name] = Job( 

1176 self.job( 

1177 self.__options, 

1178 pipeline, 

1179 node, 

1180 name, 

1181 data, 

1182 )) 

1183 

1184 # Prepare variant variables 

1185 if variant.variables: 

1186 pipeline.jobs[name].variables.update(variant.variables) 

1187 

1188 # Validate job script 

1189 if not pipeline.jobs[name].options.disabled and not pipeline.jobs[ 

1190 name].script: 

1191 if self.__options.no_script_fail: 

1192 raise ValueError( 

1193 f"Missing \"script\" key for \"{pipeline.jobs[name].stage}" \ 

1194 f" / {pipeline.jobs[name].name}\"" 

1195 ) 

1196 pipeline.jobs[name].options.disabled = 'Missing "script" key' 

1197 

1198 # Append unknown stage if required 

1199 if pipeline.jobs[name].options.disabled \ 

1200 and pipeline.jobs[name].stage == GitLab.JOB_STAGE_DEFAULT \ 

1201 and GitLab.JOB_STAGE_DEFAULT not in stages: 

1202 stages[GitLab.JOB_STAGE_DEFAULT] = list(stages.values())[-1] + 1 

1203 

1204 # Validate job stage 

1205 if pipeline.jobs[name].stage not in stages: 

1206 raise ValueError( 

1207 f"Unknown stage \"{pipeline.jobs[name].stage}\"" \ 

1208 f" for \"{pipeline.jobs[name].name}\"" 

1209 ) 

1210 

1211 # Restore environment 

1212 pipeline.variables.environment_restore() 

1213 

1214 # Sort pipeline jobs based on stages 

1215 pipeline.jobs = OrderedDict( 

1216 sorted( 

1217 pipeline.jobs.items(), 

1218 key=lambda x: stages[x[1].stage], 

1219 )) 

1220 

1221 # Result 

1222 return pipeline