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.
- eirmos-0.4.0/PKG-INFO +10 -0
- eirmos-0.4.0/README.md +288 -0
- eirmos-0.4.0/eirmos/__init__.py +70 -0
- eirmos-0.4.0/eirmos/__main__.py +7 -0
- eirmos-0.4.0/eirmos/_yaml.py +25 -0
- eirmos-0.4.0/eirmos/cli.py +181 -0
- eirmos-0.4.0/eirmos/colors.py +32 -0
- eirmos-0.4.0/eirmos/formatters/__init__.py +22 -0
- eirmos-0.4.0/eirmos/formatters/base.py +19 -0
- eirmos-0.4.0/eirmos/formatters/dot.py +59 -0
- eirmos-0.4.0/eirmos/formatters/mermaid.py +55 -0
- eirmos-0.4.0/eirmos/formatters/summary.py +79 -0
- eirmos-0.4.0/eirmos/formatters/tree.py +153 -0
- eirmos-0.4.0/eirmos/formatters/variables.py +65 -0
- eirmos-0.4.0/eirmos/graph.py +90 -0
- eirmos-0.4.0/eirmos/parsers/__init__.py +277 -0
- eirmos-0.4.0/eirmos/parsers/appveyor.py +161 -0
- eirmos-0.4.0/eirmos/parsers/azure.py +147 -0
- eirmos-0.4.0/eirmos/parsers/base.py +85 -0
- eirmos-0.4.0/eirmos/parsers/bitbucket.py +119 -0
- eirmos-0.4.0/eirmos/parsers/buildkite.py +128 -0
- eirmos-0.4.0/eirmos/parsers/circleci.py +111 -0
- eirmos-0.4.0/eirmos/parsers/codefresh.py +164 -0
- eirmos-0.4.0/eirmos/parsers/drone.py +120 -0
- eirmos-0.4.0/eirmos/parsers/github.py +46 -0
- eirmos-0.4.0/eirmos/parsers/gitlab.py +261 -0
- eirmos-0.4.0/eirmos/parsers/jenkins.py +139 -0
- eirmos-0.4.0/eirmos/parsers/registry.py +89 -0
- eirmos-0.4.0/eirmos/parsers/semaphore.py +72 -0
- eirmos-0.4.0/eirmos/parsers/travis.py +171 -0
- eirmos-0.4.0/eirmos.egg-info/PKG-INFO +10 -0
- eirmos-0.4.0/eirmos.egg-info/SOURCES.txt +52 -0
- eirmos-0.4.0/eirmos.egg-info/dependency_links.txt +1 -0
- eirmos-0.4.0/eirmos.egg-info/entry_points.txt +2 -0
- eirmos-0.4.0/eirmos.egg-info/requires.txt +6 -0
- eirmos-0.4.0/eirmos.egg-info/top_level.txt +1 -0
- eirmos-0.4.0/pyproject.toml +26 -0
- eirmos-0.4.0/setup.cfg +4 -0
- eirmos-0.4.0/tests/test_appveyor_parser.py +91 -0
- eirmos-0.4.0/tests/test_azure_parser.py +109 -0
- eirmos-0.4.0/tests/test_bitbucket_parser.py +88 -0
- eirmos-0.4.0/tests/test_buildkite_parser.py +98 -0
- eirmos-0.4.0/tests/test_cli.py +96 -0
- eirmos-0.4.0/tests/test_codefresh_parser.py +98 -0
- eirmos-0.4.0/tests/test_drone_woodpecker_parser.py +183 -0
- eirmos-0.4.0/tests/test_formatters.py +107 -0
- eirmos-0.4.0/tests/test_gitlab_parser.py +174 -0
- eirmos-0.4.0/tests/test_graph.py +85 -0
- eirmos-0.4.0/tests/test_graph_integration.py +136 -0
- eirmos-0.4.0/tests/test_new_parsers.py +94 -0
- eirmos-0.4.0/tests/test_parser.py +74 -0
- eirmos-0.4.0/tests/test_registry_extended.py +132 -0
- eirmos-0.4.0/tests/test_semaphore_parser.py +78 -0
- 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,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)
|