Repository navigation
Expand file tree
/
Copy pathci_graph.py
More file actions
257 lines (218 loc) · 9.34 KB
/
Copy pathci_graph.py
File metadata and controls
257 lines (218 loc) · 9.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
#!/usr/bin/env python3
"""The repo's package dependency graph, read from the manifests.
`ci_gate_scope.py` uses it so a change to a shared crate or package waits for the
workflows of everything that depends on it, and nothing else. It reads only
manifests (every `Cargo.toml`, `package.json` and `pnpm-workspace.yaml`), so the
gate needs no toolchain and no network. `.github/scripts/tests/test_ci_graph.py`
checks the Cargo edges against `cargo metadata`.
Nodes are package directories (repo-relative, no trailing slash):
* Cargo: every manifest with a `[package]`. An edge is a `path` dependency in
`[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` or any
`[target.*.*dependencies]`, including `workspace = true` entries resolved
through the workspace root's `[workspace.dependencies]`.
* JavaScript: every `package.json` with a `name`. An edge is a `workspace:`,
`link:` or `file:` dependency on another package in the repo.
A workspace is the directory of a `[workspace]` manifest or a
`pnpm-workspace.yaml`. Its lockfile, toolchain pin and root manifest are
workspace files: changing one affects every member.
"""
from __future__ import annotations
import json
import os
import posixpath
import sys
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
ROOT = Path(__file__).resolve().parents[2]
SKIP_DIRS = {'.git', 'node_modules', 'target', '.build', 'dist', '.next', '.venv', 'vendor'}
DEP_TABLES = ('dependencies', 'dev-dependencies', 'build-dependencies')
JS_DEP_TABLES = ('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')
# Files at a workspace root that every member builds with.
CARGO_WORKSPACE_FILES = ('Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', 'rust-toolchain', '.cargo/')
PNPM_WORKSPACE_FILES = ('pnpm-workspace.yaml', 'pnpm-lock.yaml', 'package.json', '.npmrc')
@dataclass
class Package:
dir: str
name: str
kind: str # 'cargo' or 'js'
workspace: str
deps: set[str] = field(default_factory=set) # dirs of packages this one depends on
@dataclass
class Graph:
packages: dict[str, Package]
workspaces: dict[str, str] # workspace dir -> 'cargo' or 'js'
# --- queries -------------------------------------------------------------
def owner(self, path: str) -> str | None:
"""The deepest package directory that contains `path`."""
d = posixpath.dirname(path)
while True:
if d in self.packages:
return d
if not d:
return None
d = posixpath.dirname(d)
def workspace_file(self, path: str) -> str | None:
"""The workspace whose root lockfile, manifest or toolchain pin `path` is."""
for ws, kind in self.workspaces.items():
rel = path[len(ws) + 1:] if ws and path.startswith(ws + '/') else (path if not ws else None)
if rel is None:
continue
names = CARGO_WORKSPACE_FILES if kind == 'cargo' else PNPM_WORKSPACE_FILES
if any(rel == n or (n.endswith('/') and rel.startswith(n)) for n in names):
return ws
return None
def members(self, ws: str) -> set[str]:
return {d for d, p in self.packages.items() if p.workspace == ws}
def dependents(self, dirs: set[str]) -> set[str]:
"""`dirs` plus every package that depends on one of them, transitively."""
rev: dict[str, set[str]] = {}
for d, p in self.packages.items():
for dep in p.deps:
rev.setdefault(dep, set()).add(d)
out, todo = set(dirs), list(dirs)
while todo:
for d in rev.get(todo.pop(), ()):
if d not in out:
out.add(d)
todo.append(d)
return out
def closure(self, dirs: set[str]) -> set[str]:
"""`dirs` plus everything they depend on, transitively."""
out, todo = set(dirs), list(dirs)
while todo:
p = self.packages.get(todo.pop())
for dep in (p.deps if p else ()):
if dep not in out:
out.add(dep)
todo.append(dep)
return out
def by_name(self, name: str) -> list[str]:
return sorted(d for d, p in self.packages.items() if p.name == name)
# --- loading -----------------------------------------------------------------
def _walk(root: Path, names: set[str]) -> list[str]:
found = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for f in filenames:
if f in names:
found.append(Path(dirpath, f).relative_to(root).as_posix())
return sorted(found)
def _norm(base: str, rel: str) -> str:
out = posixpath.normpath(posixpath.join(base, rel))
return '' if out == '.' else out
def _cargo_dep_specs(manifest: dict):
for table in DEP_TABLES:
yield from (manifest.get(table) or {}).items()
for target in (manifest.get('target') or {}).values():
for table in DEP_TABLES:
yield from (target.get(table) or {}).items()
def load(root: Path = ROOT) -> Graph:
files = _walk(root, {'Cargo.toml', 'package.json', 'pnpm-workspace.yaml'})
packages: dict[str, Package] = {}
workspaces: dict[str, str] = {}
ws_deps: dict[str, dict] = {}
cargo: dict[str, dict] = {}
for f in files:
d = posixpath.dirname(f)
name = posixpath.basename(f)
if name == 'Cargo.toml':
try:
data = tomllib.loads((root / f).read_text())
except (OSError, tomllib.TOMLDecodeError):
continue # templates and fixtures that are not real manifests
cargo[d] = data
if 'workspace' in data:
workspaces[d] = 'cargo'
ws_deps[d] = (data['workspace'].get('dependencies') or {})
elif name == 'pnpm-workspace.yaml':
workspaces[d] = 'js'
def nearest_ws(d: str, kind: str) -> str:
cur = d
while True:
if workspaces.get(cur) == kind:
return cur
if not cur:
return d
cur = posixpath.dirname(cur)
for d, data in cargo.items():
pkg = data.get('package')
if not isinstance(pkg, dict) or 'name' not in pkg:
continue
ws = nearest_ws(d, 'cargo')
p = Package(d, pkg['name'], 'cargo', ws)
for key, spec in _cargo_dep_specs(data):
if not isinstance(spec, dict):
continue
if spec.get('workspace') is True:
spec = ws_deps.get(ws, {}).get(key)
if isinstance(spec, dict) and 'path' in spec:
p.deps.add(_norm(ws, spec['path']))
elif 'path' in spec:
p.deps.add(_norm(d, spec['path']))
packages[d] = p
js: dict[str, dict] = {}
for f in files:
if posixpath.basename(f) != 'package.json':
continue
try:
data = json.loads((root / f).read_text())
except (OSError, ValueError):
continue
if isinstance(data, dict) and isinstance(data.get('name'), str):
js[posixpath.dirname(f)] = data
names: dict[tuple[str, str], str] = {}
for d, data in js.items():
ws = nearest_ws(d, 'js')
if d in packages: # a crate and a JS package in one directory: keep the crate
continue
packages[d] = Package(d, data['name'], 'js', ws)
names[(ws, data['name'])] = d
for d, data in js.items():
p = packages.get(d)
if not p or p.kind != 'js':
continue
for table in JS_DEP_TABLES:
for dep, spec in (data.get(table) or {}).items():
if not isinstance(spec, str):
continue
if spec.startswith('workspace:'):
target = names.get((p.workspace, dep))
if target:
p.deps.add(target)
elif spec.startswith(('link:', 'file:')):
target = _norm(d, spec.split(':', 1)[1])
if target in js:
p.deps.add(target)
for p in packages.values():
p.deps = {dep for dep in p.deps if dep in packages and dep != p.dir}
return Graph(packages, workspaces)
def union(graphs: list[Graph]) -> Graph:
"""One graph with every package, workspace and edge of `graphs` (base and head trees)."""
packages: dict[str, Package] = {}
workspaces: dict[str, str] = {}
for g in graphs:
workspaces.update(g.workspaces)
for d, p in g.packages.items():
if d in packages:
packages[d].deps |= p.deps
else:
packages[d] = Package(p.dir, p.name, p.kind, p.workspace, set(p.deps))
return Graph(packages, workspaces)
def main() -> int:
g = load()
if len(sys.argv) > 1:
for name in sys.argv[1:]:
for d in g.by_name(name) or [name]:
deps = sorted(g.dependents({d}) - {d})
print(f'{d} ({len(deps)} dependents)')
for dep in deps:
print(f' {dep}')
return 0
print(json.dumps({d: sorted(p.deps) for d, p in sorted(g.packages.items())}, indent=1))
return 0
if __name__ == '__main__':
sys.exit(main())