eirmos 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eirmos/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """CI/CD pipeline visualiser.
2
+
3
+ A small library + CLI for parsing CI/CD pipeline definitions and
4
+ rendering them in various formats (tree, mermaid, dot, summary).
5
+
6
+ Supported systems: GitLab CI, GitHub Actions, Jenkins, CircleCI,
7
+ Azure Pipelines, Bitbucket Pipelines, Drone CI, Woodpecker CI,
8
+ Travis CI, AppVeyor, Buildkite, Codefresh, Semaphore.
9
+
10
+ The package is organised in clear architectural layers:
11
+
12
+ parsers/ - read pipeline definition files and produce a domain model
13
+ graph.py - build a dependency graph from a parser
14
+ formatters/ - render graphs to text/diagram formats
15
+ cli.py - thin command-line entry point
16
+ colors.py - ANSI color helpers (presentation only)
17
+
18
+ New CI systems can be supported by implementing
19
+ :class:`eirmos.parsers.base.BasePipelineParser` and
20
+ registering a :class:`eirmos.parsers.registry.ParserAdapter`.
21
+ """
22
+
23
+ from .colors import Colors
24
+ from .parsers.gitlab import GitLabCIParser
25
+ from .parsers.github import GitHubActionsParser
26
+ from .parsers.jenkins import JenkinsParser
27
+ from .parsers.circleci import CircleCIParser
28
+ from .parsers.azure import AzurePipelinesParser
29
+ from .parsers.bitbucket import BitbucketPipelinesParser
30
+ from .parsers.drone import DroneParser, WoodpeckerParser
31
+ from .parsers.travis import TravisCIParser
32
+ from .parsers.appveyor import AppVeyorParser
33
+ from .parsers.buildkite import BuildkiteParser
34
+ from .parsers.codefresh import CodefreshParser
35
+ from .parsers.semaphore import SemaphoreParser
36
+ from .parsers.base import BasePipelineParser
37
+ from .parsers.registry import ParserAdapter, REGISTRY, register_adapter
38
+ from .graph import DependencyGraph
39
+ from .formatters.tree import TreeFormatter
40
+ from .formatters.mermaid import MermaidFormatter
41
+ from .formatters.dot import DotFormatter
42
+ from .formatters.summary import SummaryFormatter
43
+ from .formatters.variables import VariableFormatter
44
+
45
+ __all__ = [
46
+ "Colors",
47
+ "BasePipelineParser",
48
+ "GitLabCIParser",
49
+ "GitHubActionsParser",
50
+ "JenkinsParser",
51
+ "CircleCIParser",
52
+ "AzurePipelinesParser",
53
+ "BitbucketPipelinesParser",
54
+ "DroneParser",
55
+ "WoodpeckerParser",
56
+ "TravisCIParser",
57
+ "AppVeyorParser",
58
+ "BuildkiteParser",
59
+ "CodefreshParser",
60
+ "SemaphoreParser",
61
+ "ParserAdapter",
62
+ "REGISTRY",
63
+ "register_adapter",
64
+ "DependencyGraph",
65
+ "TreeFormatter",
66
+ "MermaidFormatter",
67
+ "DotFormatter",
68
+ "SummaryFormatter",
69
+ "VariableFormatter",
70
+ ]
eirmos/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m eirmos``."""
2
+
3
+ import sys
4
+ from .cli import main
5
+
6
+ if __name__ == '__main__':
7
+ sys.exit(main())
eirmos/_yaml.py ADDED
@@ -0,0 +1,25 @@
1
+ """YAML loader configured for GitLab CI specifics.
2
+
3
+ Importing this module has the side effect of registering the
4
+ ``!reference`` constructor on ``yaml.SafeLoader``. Centralising
5
+ the import means PyYAML is loaded exactly once and there's a
6
+ single place to change the loader configuration.
7
+ """
8
+
9
+ import sys
10
+
11
+ try:
12
+ import yaml
13
+ except ImportError: # pragma: no cover - import-time guard
14
+ print("ERROR: PyYAML is required. Install with: pip install pyyaml")
15
+ sys.exit(1)
16
+
17
+
18
+ def _reference_constructor(loader, node):
19
+ """Handle GitLab CI ``!reference`` tags by returning the sequence as-is."""
20
+ return loader.construct_sequence(node)
21
+
22
+
23
+ yaml.add_constructor('!reference', _reference_constructor, Loader=yaml.SafeLoader)
24
+
25
+ __all__ = ["yaml"]
eirmos/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ """Command-line entry point.
2
+
3
+ Kept intentionally thin: argument parsing + parser/formatter
4
+ selection. All real logic lives in the dedicated modules.
5
+
6
+ Parser selection is driven by the
7
+ :mod:`eirmos.parsers.registry`, so adding a new CI/CD
8
+ system is a matter of registering a :class:`ParserAdapter`.
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from .colors import Colors
16
+ from .graph import DependencyGraph
17
+ from .parsers import detect as detect_adapter, REGISTRY
18
+ from .parsers.gitlab import GitLabCIParser
19
+ from .formatters.tree import TreeFormatter
20
+ from .formatters.mermaid import MermaidFormatter
21
+ from .formatters.dot import DotFormatter
22
+ from .formatters.summary import SummaryFormatter
23
+ from .formatters.variables import VariableFormatter
24
+
25
+
26
+ FORMATTERS = {
27
+ 'tree': TreeFormatter,
28
+ 'mermaid': MermaidFormatter,
29
+ 'dot': DotFormatter,
30
+ 'summary': SummaryFormatter,
31
+ 'variables': VariableFormatter,
32
+ }
33
+
34
+
35
+ def find_ci_files(base_path):
36
+ """Find all GitLab CI files in the repository.
37
+
38
+ Kept for backwards compatibility with previous releases of the
39
+ package; the CLI now relies on the parser registry.
40
+ """
41
+ patterns = ['**/*.gitlab-ci.yml', '**/*.gitlab-ci.yaml', '.gitlab-ci.yml']
42
+ files = set()
43
+ for pattern in patterns:
44
+ for f in Path(base_path).glob(pattern):
45
+ files.add(f)
46
+ return sorted(files)
47
+
48
+
49
+ def build_arg_parser():
50
+ supported = ', '.join(a.name for a in REGISTRY) or 'none registered'
51
+ arg_parser = argparse.ArgumentParser(
52
+ prog='eirmos',
53
+ description=(
54
+ 'CI/CD Pipeline Visualiser - Parse and visualise job dependencies. '
55
+ f'Supported systems: {supported}.'
56
+ ),
57
+ formatter_class=argparse.RawDescriptionHelpFormatter,
58
+ )
59
+ arg_parser.add_argument('path', nargs='?', default='.',
60
+ help='Path to repository root (default: current directory)')
61
+ arg_parser.add_argument('--format', '-f', choices=list(FORMATTERS.keys()),
62
+ default='tree', help='Output format (default: tree)')
63
+ arg_parser.add_argument('--stage', '-s', default=None,
64
+ help='Filter output to a specific stage')
65
+ arg_parser.add_argument('--job', '-j', default=None,
66
+ help='Show detailed dependencies for a specific job')
67
+ arg_parser.add_argument('--no-includes', action='store_true',
68
+ help='Do not follow local include directives (GitLab only)')
69
+ arg_parser.add_argument('--output', '-o', default=None,
70
+ help='Output file path (default: stdout)')
71
+ arg_parser.add_argument('--no-color', action='store_true',
72
+ help='Disable colored output')
73
+ arg_parser.add_argument('--list-stages', action='store_true',
74
+ help='List all stages and exit')
75
+ arg_parser.add_argument('--list-jobs', action='store_true',
76
+ help='List all jobs and exit')
77
+ arg_parser.add_argument('--ci', default=None,
78
+ choices=[a.name for a in REGISTRY],
79
+ help='Force a specific CI system instead of auto-detection')
80
+ return arg_parser
81
+
82
+
83
+ def _select_ci_file(base_path, forced_name=None):
84
+ """Return ``(adapter, main_ci_file)`` for ``base_path``.
85
+
86
+ If ``forced_name`` is given, only that adapter is consulted.
87
+ Otherwise the registry is queried in registration order and the
88
+ first hit wins. As a last-resort fallback we still recognise
89
+ misplaced ``*.gitlab-ci.yml`` files so existing behaviour is
90
+ preserved.
91
+ """
92
+ if forced_name:
93
+ for adapter in REGISTRY:
94
+ if adapter.name == forced_name:
95
+ main = adapter.detect(base_path)
96
+ if main is not None:
97
+ print(f"{Colors.DIM}Forced CI system: {adapter.name} "
98
+ f"({main.name}){Colors.RESET}", file=sys.stderr)
99
+ return adapter, main
100
+ return None, None
101
+
102
+ adapter, main_file = detect_adapter(base_path)
103
+ if adapter is not None:
104
+ print(f"{Colors.DIM}Detected {adapter.name}: {main_file.name}"
105
+ f"{Colors.RESET}", file=sys.stderr)
106
+ return adapter, main_file
107
+
108
+ # Legacy fallback: any stray *.gitlab-ci.yml
109
+ ci_files = find_ci_files(base_path)
110
+ if ci_files:
111
+ print(f"WARNING: No CI definition found at root. "
112
+ f"Found {len(ci_files)} GitLab CI files; using {ci_files[0].name}.",
113
+ file=sys.stderr)
114
+ # Build a synthetic adapter for legacy callers.
115
+ from .parsers.registry import ParserAdapter
116
+ legacy = ParserAdapter(
117
+ name="GitLab CI",
118
+ parser_class=GitLabCIParser,
119
+ detect=lambda _: ci_files[0],
120
+ parser_kwargs=lambda args: {
121
+ 'follow_includes': not getattr(args, 'no_includes', False),
122
+ },
123
+ )
124
+ return legacy, ci_files[0]
125
+
126
+ return None, None
127
+
128
+
129
+ def main(argv=None):
130
+ args = build_arg_parser().parse_args(argv)
131
+
132
+ if args.no_color or args.output or not sys.stdout.isatty():
133
+ Colors.disable()
134
+
135
+ base_path = Path(args.path).resolve()
136
+ if not base_path.exists():
137
+ print(f"ERROR: Path '{args.path}' does not exist.", file=sys.stderr)
138
+ return 1
139
+
140
+ adapter, main_ci_file = _select_ci_file(base_path, forced_name=args.ci)
141
+ if not main_ci_file or adapter is None:
142
+ supported = ', '.join(a.name for a in REGISTRY)
143
+ print(f"ERROR: No supported CI files found ({supported}).", file=sys.stderr)
144
+ return 1
145
+
146
+ print(f"{Colors.DIM}Parsing CI files from: {base_path}{Colors.RESET}", file=sys.stderr)
147
+
148
+ parser = adapter.parser_class(base_path=base_path, **adapter.parser_kwargs(args))
149
+ parser.parse(main_ci_file)
150
+
151
+ print(f"{Colors.DIM}Parsed {len(parser.parsed_files)} files, "
152
+ f"found {len(parser.jobs)} jobs.{Colors.RESET}\n", file=sys.stderr)
153
+
154
+ if args.list_stages:
155
+ graph = DependencyGraph(parser)
156
+ for stage in graph.get_ordered_stages():
157
+ count = len(graph.stage_jobs.get(stage, []))
158
+ print(f"{stage} ({count} jobs)")
159
+ return 0
160
+
161
+ if args.list_jobs:
162
+ for job_name in sorted(parser.jobs.keys()):
163
+ stage = parser.get_job_stage(job_name)
164
+ print(f"{job_name:<60} [{stage}]")
165
+ return 0
166
+
167
+ graph = DependencyGraph(parser)
168
+ formatter = FORMATTERS[args.format](parser, graph)
169
+ output = formatter.render(filter_stage=args.stage, filter_job=args.job)
170
+
171
+ if args.output:
172
+ with open(args.output, 'w') as f:
173
+ f.write(output)
174
+ print(f"Output written to: {args.output}", file=sys.stderr)
175
+ else:
176
+ print(output)
177
+ return 0
178
+
179
+
180
+ if __name__ == '__main__': # pragma: no cover
181
+ sys.exit(main())
eirmos/colors.py ADDED
@@ -0,0 +1,32 @@
1
+ """ANSI color codes used by the text formatters.
2
+
3
+ This module is purposely tiny — it is the only place that knows
4
+ about terminal escape sequences. Other layers should depend on
5
+ ``Colors`` rather than hard-coding escape codes so they can be
6
+ disabled centrally (e.g. when writing to a file or a non-TTY).
7
+ """
8
+
9
+
10
+ class Colors:
11
+ HEADER = '\033[95m'
12
+ BLUE = '\033[94m'
13
+ CYAN = '\033[96m'
14
+ GREEN = '\033[92m'
15
+ YELLOW = '\033[93m'
16
+ RED = '\033[91m'
17
+ BOLD = '\033[1m'
18
+ DIM = '\033[2m'
19
+ RESET = '\033[0m'
20
+
21
+ @classmethod
22
+ def disable(cls):
23
+ """Replace all color codes with empty strings (irreversible)."""
24
+ cls.HEADER = ''
25
+ cls.BLUE = ''
26
+ cls.CYAN = ''
27
+ cls.GREEN = ''
28
+ cls.YELLOW = ''
29
+ cls.RED = ''
30
+ cls.BOLD = ''
31
+ cls.DIM = ''
32
+ cls.RESET = ''
@@ -0,0 +1,22 @@
1
+ """Output formatters for ``DependencyGraph`` instances.
2
+
3
+ All formatters share the same constructor signature
4
+ ``Formatter(parser, graph)`` and a ``render(filter_stage=None,
5
+ filter_job=None) -> str`` method, making them interchangeable.
6
+ """
7
+
8
+ from .base import BaseFormatter
9
+ from .tree import TreeFormatter
10
+ from .mermaid import MermaidFormatter
11
+ from .dot import DotFormatter
12
+ from .summary import SummaryFormatter
13
+ from .variables import VariableFormatter
14
+
15
+ __all__ = [
16
+ "BaseFormatter",
17
+ "TreeFormatter",
18
+ "MermaidFormatter",
19
+ "DotFormatter",
20
+ "SummaryFormatter",
21
+ "VariableFormatter",
22
+ ]
@@ -0,0 +1,19 @@
1
+ """Common base class for formatters."""
2
+
3
+ import re
4
+
5
+
6
+ class BaseFormatter:
7
+ """Tiny shared base — keeps formatter API uniform."""
8
+
9
+ def __init__(self, parser, graph):
10
+ self.parser = parser
11
+ self.graph = graph
12
+
13
+ def render(self, filter_stage=None, filter_job=None): # pragma: no cover
14
+ raise NotImplementedError
15
+
16
+ @staticmethod
17
+ def sanitize_id(name):
18
+ """Sanitize a name for use as a Mermaid/DOT identifier."""
19
+ return re.sub(r'[^a-zA-Z0-9_]', '_', name)
@@ -0,0 +1,59 @@
1
+ """Graphviz DOT formatter."""
2
+
3
+ from .base import BaseFormatter
4
+
5
+
6
+ class DotFormatter(BaseFormatter):
7
+ """Outputs a Graphviz DOT diagram."""
8
+
9
+ def render(self, filter_stage=None, filter_job=None):
10
+ lines = [
11
+ "digraph pipeline {",
12
+ " rankdir=TB;",
13
+ " node [shape=box, style=rounded, fontname=\"Helvetica\"];",
14
+ " edge [fontname=\"Helvetica\", fontsize=10];",
15
+ "",
16
+ ]
17
+
18
+ stages = self.graph.get_ordered_stages()
19
+
20
+ for stage in stages:
21
+ if filter_stage and stage != filter_stage:
22
+ continue
23
+
24
+ jobs = self.graph.stage_jobs.get(stage, [])
25
+ if not jobs:
26
+ continue
27
+
28
+ lines.append(f" subgraph cluster_{self.sanitize_id(stage)} {{")
29
+ lines.append(f" label=\"{stage}\";")
30
+ lines.append(f" style=dashed;")
31
+ lines.append(f" color=\"#003087\";")
32
+
33
+ for job_name in sorted(jobs):
34
+ sid = self.sanitize_id(job_name)
35
+ trigger = self.parser.get_job_triggers(job_name)
36
+ color = '#00AEEF' if trigger else '#003087'
37
+ shape = 'doubleoctagon' if trigger else 'box'
38
+ lines.append(
39
+ f" {sid} [label=\"{job_name}\", "
40
+ f"color=\"{color}\", shape={shape}];"
41
+ )
42
+ lines.append(" }")
43
+ lines.append("")
44
+
45
+ for src, dst, etype in self.graph.edges:
46
+ src_id = self.sanitize_id(src)
47
+ dst_id = self.sanitize_id(dst)
48
+ if etype == 'needs':
49
+ lines.append(f" {src_id} -> {dst_id};")
50
+ elif etype == 'needs-optional':
51
+ lines.append(f" {src_id} -> {dst_id} [style=dashed, label=\"optional\"];")
52
+ elif etype == 'trigger':
53
+ lines.append(
54
+ f" {src_id} -> {dst_id} [style=bold, color=\"#FF6600\", "
55
+ f"label=\"trigger\"];"
56
+ )
57
+
58
+ lines.append("}")
59
+ return '\n'.join(lines)
@@ -0,0 +1,55 @@
1
+ """Mermaid flowchart formatter."""
2
+
3
+ from .base import BaseFormatter
4
+
5
+
6
+ class MermaidFormatter(BaseFormatter):
7
+ """Outputs a Mermaid diagram."""
8
+
9
+ def render(self, filter_stage=None, filter_job=None):
10
+ lines = ["```mermaid", "flowchart TD", ""]
11
+
12
+ stages = self.graph.get_ordered_stages()
13
+ rendered_jobs = set()
14
+
15
+ for stage in stages:
16
+ if filter_stage and stage != filter_stage:
17
+ continue
18
+
19
+ jobs = self.graph.stage_jobs.get(stage, [])
20
+ if not jobs:
21
+ continue
22
+
23
+ lines.append(f" subgraph {self.sanitize_id(stage)}[\"{stage}\"]")
24
+ for job_name in sorted(jobs):
25
+ if filter_job and filter_job != job_name:
26
+ preds = [src for src, dst, _ in self.graph.edges if dst == job_name]
27
+ succs = [dst for _, dst, _ in self.graph.edges if dst == job_name]
28
+ if filter_job not in preds and filter_job not in succs:
29
+ continue
30
+
31
+ sid = self.sanitize_id(job_name)
32
+ trigger = self.parser.get_job_triggers(job_name)
33
+ if trigger:
34
+ lines.append(f" {sid}[[\"{job_name}\"]]\n")
35
+ else:
36
+ lines.append(f" {sid}[\"{job_name}\"]\n")
37
+ rendered_jobs.add(job_name)
38
+ lines.append(" end")
39
+ lines.append("")
40
+
41
+ lines.append(" %% Dependencies")
42
+ for src, dst, etype in self.graph.edges:
43
+ if src not in rendered_jobs and dst not in rendered_jobs:
44
+ continue
45
+ src_id = self.sanitize_id(src)
46
+ dst_id = self.sanitize_id(dst)
47
+ if etype == 'needs':
48
+ lines.append(f" {src_id} --> {dst_id}")
49
+ elif etype == 'needs-optional':
50
+ lines.append(f" {src_id} -.-> {dst_id}")
51
+ elif etype == 'trigger':
52
+ lines.append(f" {src_id} ==> {dst_id}")
53
+
54
+ lines.append("```")
55
+ return '\n'.join(lines)
@@ -0,0 +1,79 @@
1
+ """Compact summary formatter."""
2
+
3
+ import re
4
+ from collections import defaultdict
5
+
6
+ from ..colors import Colors
7
+ from .base import BaseFormatter
8
+
9
+
10
+ class SummaryFormatter(BaseFormatter):
11
+ """Outputs a compact summary view."""
12
+
13
+ def render(self, filter_stage=None, filter_job=None):
14
+ lines = []
15
+ lines.append(f"\n{Colors.BOLD}Pipeline Summary{Colors.RESET}")
16
+ lines.append(f"{'─' * 60}")
17
+
18
+ templates = getattr(self.parser, 'templates', {}) or {}
19
+
20
+ lines.append(f"\n{Colors.BOLD}Statistics:{Colors.RESET}")
21
+ lines.append(f" Total jobs: {len(self.parser.jobs)}")
22
+ lines.append(f" Total templates: {len(templates)}")
23
+ lines.append(f" Total stages: {len(self.graph.get_ordered_stages())}")
24
+ lines.append(f" Total edges: {len(self.graph.edges)}")
25
+ lines.append(f" Files parsed: {len(self.parser.parsed_files)}")
26
+
27
+ lines.append(f"\n{Colors.BOLD}Stages:{Colors.RESET}")
28
+ for stage in self.graph.get_ordered_stages():
29
+ jobs = self.graph.stage_jobs.get(stage, [])
30
+ bar = '█' * min(len(jobs), 40)
31
+ lines.append(f" {stage:<20} {Colors.BLUE}{bar}{Colors.RESET} ({len(jobs)})")
32
+
33
+ lines.append(f"\n{Colors.BOLD}Most Connected Jobs (by needs):{Colors.RESET}")
34
+ job_deps = {}
35
+ for job_name in self.parser.jobs:
36
+ needs = self.parser.get_job_needs(job_name)
37
+ succs = self.graph.get_successors(job_name)
38
+ job_deps[job_name] = len(needs) + len(succs)
39
+
40
+ top_jobs = sorted(job_deps.items(), key=lambda x: x[1], reverse=True)[:15]
41
+ for job_name, count in top_jobs:
42
+ if count > 0:
43
+ lines.append(f" {job_name:<55} {Colors.GREEN}{count}{Colors.RESET}")
44
+
45
+ trigger_jobs = [j for j in self.parser.jobs if self.parser.get_job_triggers(j)]
46
+ if trigger_jobs:
47
+ lines.append(f"\n{Colors.BOLD}Child Pipeline Triggers:{Colors.RESET}")
48
+ for job_name in sorted(trigger_jobs):
49
+ trigger = self.parser.get_job_triggers(job_name)
50
+ inc = trigger.get('include', 'unknown')
51
+ lines.append(f" {Colors.YELLOW}{job_name}{Colors.RESET}")
52
+ lines.append(f" ⟶ {inc}")
53
+
54
+ roots = self.graph.get_roots()
55
+ if roots:
56
+ lines.append(f"\n{Colors.BOLD}Root Jobs (no 'needs' dependencies):{Colors.RESET}")
57
+ for job_name in sorted(roots)[:20]:
58
+ stage = self.parser.get_job_stage(job_name)
59
+ lines.append(f" {job_name:<50} {Colors.DIM}[{stage}]{Colors.RESET}")
60
+ if len(roots) > 20:
61
+ lines.append(f" ... and {len(roots) - 20} more")
62
+
63
+ # CUSTOM_BUILD options mapping (GitLab CI specific)
64
+ lines.append(f"\n{Colors.BOLD}CUSTOM_BUILD → Job Mapping:{Colors.RESET}")
65
+ custom_build_map = defaultdict(list)
66
+ for job_name, job in self.parser.jobs.items():
67
+ for rule in job.get('rules', []) or []:
68
+ if isinstance(rule, dict) and 'if' in rule:
69
+ matches = re.findall(r'CUSTOM_BUILD\s*==\s*"([^"]+)"', str(rule['if']))
70
+ for m in matches:
71
+ custom_build_map[m].append(job_name)
72
+
73
+ for cb_name in sorted(custom_build_map.keys()):
74
+ jobs = custom_build_map[cb_name]
75
+ lines.append(f"\n {Colors.CYAN}{cb_name}{Colors.RESET}:")
76
+ for j in sorted(set(jobs)):
77
+ lines.append(f" → {j}")
78
+
79
+ return '\n'.join(lines)