eirmos 0.4.0__tar.gz

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.
Files changed (54) hide show
  1. eirmos-0.4.0/PKG-INFO +10 -0
  2. eirmos-0.4.0/README.md +288 -0
  3. eirmos-0.4.0/eirmos/__init__.py +70 -0
  4. eirmos-0.4.0/eirmos/__main__.py +7 -0
  5. eirmos-0.4.0/eirmos/_yaml.py +25 -0
  6. eirmos-0.4.0/eirmos/cli.py +181 -0
  7. eirmos-0.4.0/eirmos/colors.py +32 -0
  8. eirmos-0.4.0/eirmos/formatters/__init__.py +22 -0
  9. eirmos-0.4.0/eirmos/formatters/base.py +19 -0
  10. eirmos-0.4.0/eirmos/formatters/dot.py +59 -0
  11. eirmos-0.4.0/eirmos/formatters/mermaid.py +55 -0
  12. eirmos-0.4.0/eirmos/formatters/summary.py +79 -0
  13. eirmos-0.4.0/eirmos/formatters/tree.py +153 -0
  14. eirmos-0.4.0/eirmos/formatters/variables.py +65 -0
  15. eirmos-0.4.0/eirmos/graph.py +90 -0
  16. eirmos-0.4.0/eirmos/parsers/__init__.py +277 -0
  17. eirmos-0.4.0/eirmos/parsers/appveyor.py +161 -0
  18. eirmos-0.4.0/eirmos/parsers/azure.py +147 -0
  19. eirmos-0.4.0/eirmos/parsers/base.py +85 -0
  20. eirmos-0.4.0/eirmos/parsers/bitbucket.py +119 -0
  21. eirmos-0.4.0/eirmos/parsers/buildkite.py +128 -0
  22. eirmos-0.4.0/eirmos/parsers/circleci.py +111 -0
  23. eirmos-0.4.0/eirmos/parsers/codefresh.py +164 -0
  24. eirmos-0.4.0/eirmos/parsers/drone.py +120 -0
  25. eirmos-0.4.0/eirmos/parsers/github.py +46 -0
  26. eirmos-0.4.0/eirmos/parsers/gitlab.py +261 -0
  27. eirmos-0.4.0/eirmos/parsers/jenkins.py +139 -0
  28. eirmos-0.4.0/eirmos/parsers/registry.py +89 -0
  29. eirmos-0.4.0/eirmos/parsers/semaphore.py +72 -0
  30. eirmos-0.4.0/eirmos/parsers/travis.py +171 -0
  31. eirmos-0.4.0/eirmos.egg-info/PKG-INFO +10 -0
  32. eirmos-0.4.0/eirmos.egg-info/SOURCES.txt +52 -0
  33. eirmos-0.4.0/eirmos.egg-info/dependency_links.txt +1 -0
  34. eirmos-0.4.0/eirmos.egg-info/entry_points.txt +2 -0
  35. eirmos-0.4.0/eirmos.egg-info/requires.txt +6 -0
  36. eirmos-0.4.0/eirmos.egg-info/top_level.txt +1 -0
  37. eirmos-0.4.0/pyproject.toml +26 -0
  38. eirmos-0.4.0/setup.cfg +4 -0
  39. eirmos-0.4.0/tests/test_appveyor_parser.py +91 -0
  40. eirmos-0.4.0/tests/test_azure_parser.py +109 -0
  41. eirmos-0.4.0/tests/test_bitbucket_parser.py +88 -0
  42. eirmos-0.4.0/tests/test_buildkite_parser.py +98 -0
  43. eirmos-0.4.0/tests/test_cli.py +96 -0
  44. eirmos-0.4.0/tests/test_codefresh_parser.py +98 -0
  45. eirmos-0.4.0/tests/test_drone_woodpecker_parser.py +183 -0
  46. eirmos-0.4.0/tests/test_formatters.py +107 -0
  47. eirmos-0.4.0/tests/test_gitlab_parser.py +174 -0
  48. eirmos-0.4.0/tests/test_graph.py +85 -0
  49. eirmos-0.4.0/tests/test_graph_integration.py +136 -0
  50. eirmos-0.4.0/tests/test_new_parsers.py +94 -0
  51. eirmos-0.4.0/tests/test_parser.py +74 -0
  52. eirmos-0.4.0/tests/test_registry_extended.py +132 -0
  53. eirmos-0.4.0/tests/test_semaphore_parser.py +78 -0
  54. eirmos-0.4.0/tests/test_travis_parser.py +89 -0
eirmos-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: eirmos
3
+ Version: 0.4.0
4
+ Summary: eirmos — explore the mental graph of your CI/CD pipelines. Parse and visualise pipeline dependencies across 13 systems (GitLab CI, GitHub Actions, Jenkins, CircleCI, Azure Pipelines, Bitbucket, Drone/Woodpecker, Travis, AppVeyor, Buildkite, Codefresh, Semaphore).
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: PyYAML>=6.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: coverage>=7.0; extra == "dev"
9
+ Requires-Dist: shiv>=1.0; extra == "dev"
10
+ Requires-Dist: build>=1.0; extra == "dev"
eirmos-0.4.0/README.md ADDED
@@ -0,0 +1,288 @@
1
+ # eirmos
2
+
3
+ > **Explore the mental graph of your CI/CD pipelines.**
4
+
5
+ *eirmos* (from Greek **ειρμός**, *coherent train of thought*) parses
6
+ and visualises CI/CD pipeline dependencies across **13 systems** —
7
+ GitHub Actions, GitLab CI, Jenkins, CircleCI, Azure Pipelines, Bitbucket
8
+ Pipelines, Drone CI, Woodpecker CI, Travis CI, AppVeyor, Buildkite,
9
+ Codefresh, and Semaphore.
10
+
11
+ Point it at a repo and get a job-dependency graph rendered as a
12
+ terminal tree, Mermaid diagram, Graphviz dot, or text summary.
13
+ Everything runs on your machine — no telemetry, no remote upload, no
14
+ cloud account.
15
+
16
+ ```
17
+ ┌─────────────────────────────────────────────────────────────────┐
18
+ │ repo path ─► detect() ─► parse ─► DependencyGraph │
19
+ │ ▲ │
20
+ │ │ │
21
+ │ Tree / Mermaid / Dot │
22
+ │ Summary / Variables │
23
+ └─────────────────────────────────────────────────────────────────┘
24
+ ```
25
+
26
+ ## Supported systems
27
+
28
+ | System | Detection | Dependency model |
29
+ |---|---|---|
30
+ | GitHub Actions | `.github/workflows/*.yml` | `jobs[].needs` |
31
+ | GitLab CI | `.gitlab-ci.yml` (+ includes) | `needs:`, `extends:`, `rules:`, `trigger:` |
32
+ | Jenkins | `Jenkinsfile` (declarative DSL) | sequential `stage(...)`, `parallel { ... }` |
33
+ | CircleCI | `.circleci/config.yml` | workflow `jobs: requires:` |
34
+ | Azure Pipelines | `azure-pipelines.yml`, `.azure-pipelines/*.yml` | `stages[].dependsOn`, `jobs[].dependsOn`, implicit prev-stage |
35
+ | Bitbucket Pipelines | `bitbucket-pipelines.yml` | sequential steps; `parallel:` siblings |
36
+ | Drone CI | `.drone.yml` | `depends_on` (string/list); sequential fallback |
37
+ | Woodpecker CI | `.woodpecker.yml`, `.woodpecker/*.yml` | same model as Drone |
38
+ | Travis CI | `.travis.yml` | `jobs.include[].stage`; stages sequential, jobs-within parallel |
39
+ | AppVeyor | `appveyor.yml`, `.appveyor.yml` | phases × matrix (capped) |
40
+ | Buildkite | `.buildkite/pipeline*.yml` | `key`/`depends_on`; `wait` barriers; `group:` flatten |
41
+ | Codefresh | `codefresh.yml`, `.codefresh.yml` | `when.steps[]`; `type: parallel` flattening |
42
+ | Semaphore | `.semaphore/semaphore.yml` | `blocks[].dependencies` |
43
+
44
+ > Polyglot repos (e.g. mid-migration from Travis to GitHub Actions) are
45
+ > auto-detected: `eirmos` will print a warning naming all
46
+ > matched systems and use the first per registry order. Override with
47
+ > `--ci "GitHub Actions"`.
48
+
49
+ ## Install
50
+
51
+ Pick the path that matches your environment:
52
+
53
+ ```bash
54
+ # Recommended: uv tool (isolated, fast, no virtualenv juggling)
55
+ uv tool install eirmos
56
+
57
+ # One-shot, no install (uv ≥0.5)
58
+ uvx eirmos .
59
+
60
+ # Classic pipx (also isolated)
61
+ pipx install eirmos
62
+
63
+ # Single-file zipapp — runs anywhere with Python ≥3.9, no install
64
+ curl -L -o eirmos.pyz https://github.com/<you>/<repo>/releases/latest/download/eirmos.pyz
65
+ chmod +x eirmos.pyz
66
+ ./eirmos.pyz .
67
+
68
+ # Plain pip (last resort, pollutes site-packages)
69
+ pip install eirmos
70
+
71
+ # From source for development
72
+ git clone https://github.com/<you>/<repo>
73
+ cd <repo>
74
+ pip install -e ".[dev]"
75
+ make test # 172 tests
76
+ make coverage # ≥90% gate
77
+ make pyz # build the single-file zipapp
78
+ ```
79
+
80
+ ## CLI
81
+
82
+ ```bash
83
+ # Auto-detect and render as a terminal tree
84
+ eirmos .
85
+
86
+ # Explicit path
87
+ eirmos /path/to/repo
88
+
89
+ # Force a CI system instead of auto-detection
90
+ eirmos --ci "Buildkite" .
91
+
92
+ # Mermaid output (paste into GitHub / docs)
93
+ eirmos --format mermaid . > pipeline.mmd
94
+
95
+ # Graphviz dot for high-res rendering
96
+ eirmos --format dot . | dot -Tsvg -o pipeline.svg
97
+
98
+ # Filter to a single stage / job
99
+ eirmos --stage test .
100
+ eirmos --job deploy_prod .
101
+
102
+ # Inventory views
103
+ eirmos --list-stages .
104
+ eirmos --list-jobs .
105
+
106
+ # Skip GitLab includes
107
+ eirmos --no-includes .
108
+ ```
109
+
110
+ ## Library usage
111
+
112
+ Every parser implements the same protocol; you can use them directly:
113
+
114
+ ```python
115
+ from pathlib import Path
116
+ from eirmos import (
117
+ BuildkiteParser, DependencyGraph, MermaidFormatter,
118
+ )
119
+
120
+ parser = BuildkiteParser(base_path="my-repo").parse(
121
+ Path("my-repo/.buildkite/pipeline.yml")
122
+ )
123
+ graph = DependencyGraph(parser)
124
+ print(MermaidFormatter(parser, graph).render())
125
+ ```
126
+
127
+ Auto-detect at runtime:
128
+
129
+ ```python
130
+ from pathlib import Path
131
+ from eirmos.parsers import detect
132
+ from eirmos import DependencyGraph, TreeFormatter
133
+
134
+ adapter, main_file = detect(Path("my-repo"))
135
+ parser = adapter.parser_class(base_path="my-repo").parse(main_file)
136
+ graph = DependencyGraph(parser)
137
+ print(TreeFormatter(parser, graph).render())
138
+ ```
139
+
140
+ ## Output formats
141
+
142
+ | `--format` | Use case |
143
+ |---|---|
144
+ | `tree` | Default. Coloured terminal tree grouped by stage. |
145
+ | `mermaid` | GitHub / GitLab / docs (renders inline). |
146
+ | `dot` | Graphviz; pipe into `dot -Tsvg` for high-res. |
147
+ | `summary` | Statistics only (job counts, edge counts, roots). |
148
+ | `variables` | Lists global + per-job variables (where supported). |
149
+
150
+ ## Non-obvious per-system semantics
151
+
152
+ These are the bits that aren't immediately obvious from the YAML —
153
+ worth knowing when you read a generated graph.
154
+
155
+ ### Buildkite — `wait` is a cross-product barrier
156
+
157
+ ```
158
+ wait
159
+ step_a ──┐ ┌──► step_d
160
+ step_b ──┤ ├──► step_e
161
+ step_c ──┘ └──► step_f
162
+ ```
163
+
164
+ Every step *after* a `wait` implicitly depends on every step *before*
165
+ the same `wait`. If a post-wait step already declares `depends_on`,
166
+ the explicit list wins (no double-add).
167
+
168
+ ### Codefresh — `type: parallel` flattens to peers
169
+
170
+ ```
171
+ prev_step ──► [parallel block] ──► next_step
172
+ ├── child_1
173
+ ├── child_2 (peers, no edges between them)
174
+ └── child_3
175
+ ```
176
+
177
+ Children of a `type: parallel` block are exposed as peer jobs. Each
178
+ inherits the parallel block's predecessor (computed from `when.steps`
179
+ of the parent block, or sequential fallback).
180
+
181
+ ### Travis — stages sequential, jobs-in-stage parallel
182
+
183
+ ```
184
+ stage: build ── compile ◄──┐
185
+ ├── (predecessor of every "test" job)
186
+ stage: test ── unit ◄──┤
187
+ ── integ ◄──┘ (peers, no edge)
188
+ stage: deploy ── release ◄── unit AND integ
189
+ ```
190
+
191
+ ### AppVeyor — phases × matrix, capped
192
+
193
+ Phases run sequentially: `init → install → before_build → build →
194
+ after_build → before_test → test → after_test → deploy → after_deploy`.
195
+ For each combination in `environment.matrix` × `image`, every active
196
+ phase produces one job. Combinations are capped at `matrix_limit`
197
+ (default `200`) — passes that cap and the parser emits a warning.
198
+
199
+ ### Azure — implicit prev-stage default
200
+
201
+ A stage without `dependsOn:` implicitly depends on the previous stage
202
+ in declaration order. Stage-level `dependsOn` is mapped to the *last
203
+ jobs* of the predecessor stage so cross-stage edges always connect to
204
+ real nodes.
205
+
206
+ ### Polyglot detection
207
+
208
+ `detect()` walks the entire registry. If two or more adapters match
209
+ the same repo, a yellow warning is printed naming all matches and the
210
+ first one (per registry order) is used. Override with `--ci`.
211
+
212
+ ## Architecture
213
+
214
+ The codebase is organised in clear layers:
215
+
216
+ ```
217
+ ┌───────────────────────────────────┐
218
+ │ cli.py (argparse + glue) │
219
+ └───────┬───────────────────────────┘
220
+
221
+ ┌──────────────▼──────────────┐
222
+ │ parsers/registry.detect() │
223
+ │ first-match + multi warn │
224
+ └──────────────┬──────────────┘
225
+ │ ParserAdapter
226
+ ┌──────────────────▼─────────────────────┐
227
+ │ parsers/ (BasePipelineParser) │
228
+ │ GitHub GitLab CircleCI Jenkins │
229
+ │ Azure Bitbucket Drone Woodpecker │
230
+ │ Travis AppVeyor Buildkite │
231
+ │ Codefresh Semaphore │
232
+ └──────────────────┬─────────────────────┘
233
+ │ jobs / file_map / get_job_*
234
+
235
+ ┌──────────────────────┐
236
+ │ graph.py │
237
+ │ DependencyGraph │
238
+ └──────────┬───────────┘
239
+ │ edges / roots / has_cycle
240
+
241
+ ┌──────────────────────────────────────┐
242
+ │ formatters/ (BaseFormatter) │
243
+ │ Tree Mermaid Dot Summary Vars │
244
+ └──────────────────────────────────────┘
245
+ ```
246
+
247
+ A deeper write-up with class and sequence diagrams lives in
248
+ [`docs/architecture.md`](docs/architecture.md).
249
+
250
+ ## Adding a new CI system
251
+
252
+ 1. Implement `BasePipelineParser` in `eirmos/parsers/<system>.py`.
253
+ Populate `self.jobs`, `self.file_map`, `self.parsed_files`, and
254
+ override `get_job_stage` / `get_job_needs`.
255
+ 2. Reuse the shared YAML loader: `content = self._load_yaml(path)`.
256
+ 3. Register a `ParserAdapter` in `parsers/__init__.py` with a `detect()`
257
+ helper that returns the main pipeline file (or `None`).
258
+ 4. Add a fixture in `tests/examples/` and a `tests/test_<system>_parser.py`
259
+ covering happy path, malformed YAML, missing file, cycle, empty, and
260
+ single-job cases.
261
+ 5. Add the parser to `eirmos/__init__.py` exports.
262
+
263
+ The cross-cutting test in `tests/test_graph_integration.py` will
264
+ automatically smoke-test the new parser through every formatter once
265
+ it's added to the `PARSER_FIXTURES` list.
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ # Run the full suite (172 tests)
271
+ python -m unittest discover -s tests
272
+
273
+ # Coverage gate (≥90%)
274
+ python -m coverage run --source=eirmos -m unittest discover -s tests
275
+ python -m coverage report -m --fail-under=90
276
+ ```
277
+
278
+ ## Project status
279
+
280
+ - **172 tests, 91% coverage.**
281
+ - 13 supported CI systems.
282
+ - Deferred adapters (Spinnaker, TeamCity, Concourse, Argo, Tekton)
283
+ are tracked in [`TODOS.md`](TODOS.md) with per-system context and
284
+ start-here notes.
285
+
286
+ ## License
287
+
288
+ See `LICENSE` (or repository root) for licensing terms.
@@ -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
+ ]
@@ -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())
@@ -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"]
@@ -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())
@@ -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)