se-admin 0.2.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.
- se_admin/__init__.py +1 -0
- se_admin/__main__.py +6 -0
- se_admin/_version.py +24 -0
- se_admin/actions/__init__.py +35 -0
- se_admin/actions/copy_file.py +57 -0
- se_admin/actions/dependabot.py +58 -0
- se_admin/actions/git_pull.py +18 -0
- se_admin/actions/patch_markdown.py +108 -0
- se_admin/actions/patch_toml.py +149 -0
- se_admin/actions/replace_file.py +93 -0
- se_admin/actions/run_command.py +38 -0
- se_admin/app.py +374 -0
- se_admin/checks/__init__.py +83 -0
- se_admin/checks/exact_files.py +72 -0
- se_admin/checks/python_version.py +94 -0
- se_admin/checks/reference_files.py +63 -0
- se_admin/checks/required_paths.py +29 -0
- se_admin/checks/tags.py +54 -0
- se_admin/checks/workflows.py +29 -0
- se_admin/cli.py +89 -0
- se_admin/domain/__init__.py +1 -0
- se_admin/domain/capabilities.py +20 -0
- se_admin/domain/findings.py +34 -0
- se_admin/domain/operations.py +194 -0
- se_admin/domain/profiles.py +92 -0
- se_admin/domain/repos.py +77 -0
- se_admin/domain/selectors.py +38 -0
- se_admin/domain/tasks.py +72 -0
- se_admin/migrations/__init__.py +17 -0
- se_admin/migrations/python_package_profile.py +62 -0
- se_admin/migrations/python_tooling_profile.py +54 -0
- se_admin/migrations/python_version.py +65 -0
- se_admin/migrations/replace_mkdocs_with_zensical.py +108 -0
- se_admin/migrations/workflow_names.py +82 -0
- se_admin/observe/__init__.py +91 -0
- se_admin/observe/filesystem.py +41 -0
- se_admin/observe/git.py +74 -0
- se_admin/observe/github.py +80 -0
- se_admin/observe/pyproject.py +52 -0
- se_admin/observe/toml_files.py +54 -0
- se_admin/observe/workflows.py +43 -0
- se_admin/py.typed +0 -0
- se_admin/reports/__init__.py +13 -0
- se_admin/reports/json_report.py +62 -0
- se_admin/reports/markdown.py +78 -0
- se_admin/reports/summary.py +56 -0
- se_admin/utils/__init__.py +1 -0
- se_admin/utils/paths.py +41 -0
- se_admin/utils/subprocesses.py +23 -0
- se_admin/utils/text.py +55 -0
- se_admin-0.2.0.dist-info/METADATA +258 -0
- se_admin-0.2.0.dist-info/RECORD +55 -0
- se_admin-0.2.0.dist-info/WHEEL +4 -0
- se_admin-0.2.0.dist-info/entry_points.txt +2 -0
- se_admin-0.2.0.dist-info/licenses/LICENSE +21 -0
se_admin/app.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Application orchestration for se_admin."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from se_admin.actions import ActionResult
|
|
6
|
+
from se_admin.actions.copy_file import (
|
|
7
|
+
run_copy_file,
|
|
8
|
+
run_delete_file,
|
|
9
|
+
)
|
|
10
|
+
from se_admin.actions.patch_toml import run_patch_toml
|
|
11
|
+
from se_admin.actions.replace_file import (
|
|
12
|
+
run_ensure_workflow,
|
|
13
|
+
run_remove_workflow,
|
|
14
|
+
run_replace_workflow,
|
|
15
|
+
)
|
|
16
|
+
from se_admin.checks import run_profile_checks
|
|
17
|
+
from se_admin.domain.capabilities import CAPABILITY_LABELS, Capability
|
|
18
|
+
from se_admin.domain.findings import Finding
|
|
19
|
+
from se_admin.domain.operations import (
|
|
20
|
+
CopyFile,
|
|
21
|
+
DeleteFile,
|
|
22
|
+
EnsureWorkflow,
|
|
23
|
+
PatchToml,
|
|
24
|
+
RemoveWorkflow,
|
|
25
|
+
ReplaceWorkflow,
|
|
26
|
+
)
|
|
27
|
+
from se_admin.domain.profiles import ProfileRegistry
|
|
28
|
+
from se_admin.domain.repos import RepoEntry, RepoRegistry
|
|
29
|
+
from se_admin.domain.selectors import (
|
|
30
|
+
PatternSelector,
|
|
31
|
+
RepoNameSelector,
|
|
32
|
+
RepoSetSelector,
|
|
33
|
+
)
|
|
34
|
+
from se_admin.domain.tasks import Task
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# show
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_show(*, area: str = "all") -> int:
|
|
42
|
+
"""Show available admin capabilities."""
|
|
43
|
+
targets: list[Capability] = [*Capability] if area == "all" else [Capability(area)]
|
|
44
|
+
print("SE admin capabilities:")
|
|
45
|
+
for cap in targets:
|
|
46
|
+
print(f" {cap.value}: {CAPABILITY_LABELS[cap]}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# repos
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_repos(
|
|
56
|
+
*,
|
|
57
|
+
data: Path,
|
|
58
|
+
repo_set: str | None = None,
|
|
59
|
+
active_only: bool = False,
|
|
60
|
+
) -> int:
|
|
61
|
+
"""List repos from repos.toml."""
|
|
62
|
+
registry = RepoRegistry.from_toml(data / "repos.toml")
|
|
63
|
+
|
|
64
|
+
if repo_set:
|
|
65
|
+
repos = registry.repos_in_set(repo_set)
|
|
66
|
+
elif active_only:
|
|
67
|
+
repos = registry.active_repos()
|
|
68
|
+
else:
|
|
69
|
+
repos = list(registry.repos.values())
|
|
70
|
+
|
|
71
|
+
if not repos:
|
|
72
|
+
print("No repos matched.")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
print(f"{'name':<40} {'set':<16} {'profiles'}")
|
|
76
|
+
print("-" * 72)
|
|
77
|
+
for repo in sorted(repos, key=lambda r: r.name):
|
|
78
|
+
profiles = ", ".join(repo.profiles)
|
|
79
|
+
status = f" [{repo.status}]" if repo.status != "active" else ""
|
|
80
|
+
print(f"{repo.name + status:<40} {repo.repo_set:<16} {profiles}")
|
|
81
|
+
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# tasks
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_tasks(*, data: Path) -> int:
|
|
91
|
+
"""List available tasks from data/tasks/."""
|
|
92
|
+
tasks_dir = data / "tasks"
|
|
93
|
+
if not tasks_dir.exists():
|
|
94
|
+
print("No tasks directory found.")
|
|
95
|
+
return 1
|
|
96
|
+
|
|
97
|
+
tasks = Task.load_all(tasks_dir)
|
|
98
|
+
if not tasks:
|
|
99
|
+
print("No tasks found.")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
print(f"{'id':<48} label")
|
|
103
|
+
print("-" * 72)
|
|
104
|
+
for task_id, task in sorted(tasks.items()):
|
|
105
|
+
print(f"{task_id:<48} {task.label}")
|
|
106
|
+
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# check
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_check(
|
|
116
|
+
*,
|
|
117
|
+
data: Path,
|
|
118
|
+
repo: str | None = None,
|
|
119
|
+
repo_set: str | None = None,
|
|
120
|
+
profile: str | None = None,
|
|
121
|
+
) -> int:
|
|
122
|
+
"""Run profile checks against a repo or set."""
|
|
123
|
+
repo_registry = RepoRegistry.from_toml(data / "repos.toml")
|
|
124
|
+
profile_registry = ProfileRegistry.from_toml(data / "profiles.toml")
|
|
125
|
+
workspace = repo_registry.workspace_path()
|
|
126
|
+
|
|
127
|
+
if repo:
|
|
128
|
+
entry = repo_registry.repos.get(repo)
|
|
129
|
+
if entry is None:
|
|
130
|
+
print(f"Unknown repo: {repo!r}")
|
|
131
|
+
return 1
|
|
132
|
+
entries = [entry]
|
|
133
|
+
elif repo_set:
|
|
134
|
+
entries = repo_registry.repos_in_set(repo_set)
|
|
135
|
+
if not entries:
|
|
136
|
+
print(f"No repos in set: {repo_set!r}")
|
|
137
|
+
return 1
|
|
138
|
+
else:
|
|
139
|
+
print("Specify --repo or --set.")
|
|
140
|
+
return 2
|
|
141
|
+
|
|
142
|
+
all_findings: list[Finding] = []
|
|
143
|
+
|
|
144
|
+
for entry in entries:
|
|
145
|
+
profile_names = [profile] if profile else entry.profiles
|
|
146
|
+
profiles = profile_registry.resolve(profile_names)
|
|
147
|
+
for prof in profiles:
|
|
148
|
+
findings = run_profile_checks(
|
|
149
|
+
entry,
|
|
150
|
+
prof,
|
|
151
|
+
workspace_path=workspace,
|
|
152
|
+
profile_registry=profile_registry,
|
|
153
|
+
)
|
|
154
|
+
all_findings.extend(findings)
|
|
155
|
+
|
|
156
|
+
_print_findings(all_findings)
|
|
157
|
+
failures = [f for f in all_findings if f.failed]
|
|
158
|
+
return 1 if failures else 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _print_findings(findings: list[Finding]) -> None:
|
|
162
|
+
if not findings:
|
|
163
|
+
print("No findings.")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
by_repo: dict[str, list[Finding]] = {}
|
|
167
|
+
for f in findings:
|
|
168
|
+
by_repo.setdefault(f.repo, []).append(f)
|
|
169
|
+
|
|
170
|
+
for repo_name, repo_findings in sorted(by_repo.items()):
|
|
171
|
+
passed = sum(1 for f in repo_findings if f.passed)
|
|
172
|
+
failed = sum(1 for f in repo_findings if f.failed)
|
|
173
|
+
print(f"\n{repo_name} ({passed} pass, {failed} fail)")
|
|
174
|
+
for f in repo_findings:
|
|
175
|
+
icon = "✓" if f.passed else ("✗" if f.failed else "–")
|
|
176
|
+
path = f" {f.path}" if f.path else ""
|
|
177
|
+
msg = f" {f.message}" if f.message else ""
|
|
178
|
+
print(f" {icon} [{f.check}]{path}{msg}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# run (task executor)
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_task(
|
|
187
|
+
*,
|
|
188
|
+
data: Path,
|
|
189
|
+
task_id: str,
|
|
190
|
+
dry_run: bool = False,
|
|
191
|
+
) -> int:
|
|
192
|
+
"""Execute a task by id."""
|
|
193
|
+
tasks = Task.load_all(data / "tasks")
|
|
194
|
+
task = tasks.get(task_id)
|
|
195
|
+
if task is None:
|
|
196
|
+
available = ", ".join(sorted(tasks.keys()))
|
|
197
|
+
print(
|
|
198
|
+
f"Unknown task: {task_id!r}\n"
|
|
199
|
+
f" Add a task file: data/tasks/{task_id}.toml\n"
|
|
200
|
+
f" Available tasks: {available}"
|
|
201
|
+
)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
repo_registry = RepoRegistry.from_toml(data / "repos.toml")
|
|
205
|
+
workspace = repo_registry.workspace_path()
|
|
206
|
+
|
|
207
|
+
source_path: Path | None = None
|
|
208
|
+
if task.source:
|
|
209
|
+
source_path = workspace / task.source.repo
|
|
210
|
+
|
|
211
|
+
entries = _resolve_selector(task, repo_registry)
|
|
212
|
+
if not entries:
|
|
213
|
+
print(f"Task {task_id!r}: no repos matched selector.")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
label = "[dry-run] " if dry_run else ""
|
|
217
|
+
print(f"{label}Task: {task.label}")
|
|
218
|
+
|
|
219
|
+
total_changed = 0
|
|
220
|
+
total_errors = 0
|
|
221
|
+
|
|
222
|
+
for entry in entries:
|
|
223
|
+
target_path = workspace / entry.name
|
|
224
|
+
print(f"\n to {entry.name}")
|
|
225
|
+
for op_dict in task.operations:
|
|
226
|
+
result = _dispatch(
|
|
227
|
+
op_dict,
|
|
228
|
+
target_path=target_path,
|
|
229
|
+
source_path=source_path,
|
|
230
|
+
dry_run=dry_run,
|
|
231
|
+
)
|
|
232
|
+
icon = "✓" if result.ok else "✗"
|
|
233
|
+
changed = " (changed)" if result.changed else ""
|
|
234
|
+
msg = f": {result.message}" if result.message else ""
|
|
235
|
+
print(f" {icon} {op_dict.get('type', '?')}{changed}{msg}")
|
|
236
|
+
if result.changed:
|
|
237
|
+
total_changed += 1
|
|
238
|
+
if not result.ok:
|
|
239
|
+
total_errors += 1
|
|
240
|
+
|
|
241
|
+
print(f"\n{total_changed} change(s), {total_errors} error(s).")
|
|
242
|
+
return 1 if total_errors else 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _resolve_selector(task: Task, registry: RepoRegistry) -> list[RepoEntry]:
|
|
246
|
+
if task.selector is None:
|
|
247
|
+
return []
|
|
248
|
+
sel = task.selector
|
|
249
|
+
if isinstance(sel, RepoSetSelector):
|
|
250
|
+
entries: list[RepoEntry] = []
|
|
251
|
+
for set_name in sel.repo_sets:
|
|
252
|
+
entries.extend(registry.repos_in_set(set_name))
|
|
253
|
+
return entries
|
|
254
|
+
if isinstance(sel, RepoNameSelector):
|
|
255
|
+
return [registry.repos[n] for n in sel.repos if n in registry.repos]
|
|
256
|
+
if isinstance(sel, PatternSelector):
|
|
257
|
+
import re
|
|
258
|
+
|
|
259
|
+
pattern = re.compile(sel.pattern)
|
|
260
|
+
return [r for r in registry.repos.values() if pattern.search(r.name)]
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Operation interpreter
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _dispatch(
|
|
270
|
+
op: dict,
|
|
271
|
+
*,
|
|
272
|
+
target_path: Path,
|
|
273
|
+
source_path: Path | None,
|
|
274
|
+
dry_run: bool,
|
|
275
|
+
) -> ActionResult:
|
|
276
|
+
"""Map a raw TOML operation dict to an action and execute it."""
|
|
277
|
+
op_type = op.get("type", "")
|
|
278
|
+
|
|
279
|
+
if dry_run:
|
|
280
|
+
return ActionResult.noop(f"would run {op_type}")
|
|
281
|
+
|
|
282
|
+
if op_type == "ensure_exact_files":
|
|
283
|
+
results = [
|
|
284
|
+
run_copy_file(
|
|
285
|
+
CopyFile(src=p, dest=p),
|
|
286
|
+
target_path=target_path,
|
|
287
|
+
source_path=source_path or target_path,
|
|
288
|
+
)
|
|
289
|
+
for p in op.get("paths", [])
|
|
290
|
+
]
|
|
291
|
+
changed = any(r.changed for r in results)
|
|
292
|
+
errors = [r.message for r in results if not r.ok]
|
|
293
|
+
if errors:
|
|
294
|
+
return ActionResult.error("; ".join(e for e in errors if e))
|
|
295
|
+
return ActionResult(ok=True, changed=changed)
|
|
296
|
+
|
|
297
|
+
if op_type == "delete_files":
|
|
298
|
+
results = [
|
|
299
|
+
run_delete_file(DeleteFile(path=p), target_path=target_path)
|
|
300
|
+
for p in op.get("paths", [])
|
|
301
|
+
]
|
|
302
|
+
changed = any(r.changed for r in results)
|
|
303
|
+
return ActionResult(ok=True, changed=changed)
|
|
304
|
+
|
|
305
|
+
if op_type == "add_dependency":
|
|
306
|
+
return run_patch_toml(
|
|
307
|
+
PatchToml(
|
|
308
|
+
file="pyproject.toml",
|
|
309
|
+
operation="add_dependency",
|
|
310
|
+
group=op.get("group"),
|
|
311
|
+
name=op.get("name"),
|
|
312
|
+
),
|
|
313
|
+
target_path=target_path,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if op_type == "remove_dependency":
|
|
317
|
+
return run_patch_toml(
|
|
318
|
+
PatchToml(
|
|
319
|
+
file="pyproject.toml",
|
|
320
|
+
operation="remove_dependency",
|
|
321
|
+
group=op.get("group"),
|
|
322
|
+
name=op.get("name"),
|
|
323
|
+
),
|
|
324
|
+
target_path=target_path,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if op_type == "ensure_workflow":
|
|
328
|
+
return run_ensure_workflow(
|
|
329
|
+
EnsureWorkflow(name=op["name"], src=op.get("src", op["name"])),
|
|
330
|
+
target_path=target_path,
|
|
331
|
+
source_path=source_path or target_path,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if op_type == "replace_workflow":
|
|
335
|
+
return run_replace_workflow(
|
|
336
|
+
ReplaceWorkflow(name=op["name"], src=op.get("src", op["name"])),
|
|
337
|
+
target_path=target_path,
|
|
338
|
+
source_path=source_path or target_path,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if op_type == "remove_workflow":
|
|
342
|
+
return run_remove_workflow(
|
|
343
|
+
RemoveWorkflow(name=op["name"]),
|
|
344
|
+
target_path=target_path,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if op_type == "git_pull":
|
|
348
|
+
from se_admin.actions.git_pull import git_pull
|
|
349
|
+
|
|
350
|
+
ok, message = git_pull(target_path)
|
|
351
|
+
return ActionResult(ok=ok, changed=ok, message=message or None)
|
|
352
|
+
|
|
353
|
+
if op_type == "merge_dependabot_prs":
|
|
354
|
+
from se_admin.actions.dependabot import (
|
|
355
|
+
list_dependabot_prs,
|
|
356
|
+
merge_dependabot_pr,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
prs = list_dependabot_prs(target_path)
|
|
360
|
+
if not prs:
|
|
361
|
+
return ActionResult.noop("No open Dependabot PRs")
|
|
362
|
+
merged = 0
|
|
363
|
+
merge_errors: list[str] = []
|
|
364
|
+
for pr in prs:
|
|
365
|
+
ok, msg = merge_dependabot_pr(target_path, pr["number"])
|
|
366
|
+
if ok:
|
|
367
|
+
merged += 1
|
|
368
|
+
else:
|
|
369
|
+
merge_errors.append(f"#{pr['number']}: {msg}")
|
|
370
|
+
if merge_errors:
|
|
371
|
+
return ActionResult.error("; ".join(merge_errors))
|
|
372
|
+
return ActionResult.done(f"Merged {merged} Dependabot PR(s)")
|
|
373
|
+
|
|
374
|
+
return ActionResult.error(f"Unknown operation type: {op_type!r}")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""se_admin checks layer - pure comparisons, no side effects.
|
|
2
|
+
|
|
3
|
+
Each check reads actual state via observe/ and returns Finding objects.
|
|
4
|
+
Nothing in this layer writes to disk or calls external services
|
|
5
|
+
(except checks/tags.py which reads from the GitHub API).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from se_admin.checks.exact_files import check_exact_files
|
|
11
|
+
from se_admin.checks.python_version import check_python_version
|
|
12
|
+
from se_admin.checks.reference_files import check_reference_files
|
|
13
|
+
from se_admin.checks.required_paths import check_required_paths
|
|
14
|
+
from se_admin.checks.tags import check_tags
|
|
15
|
+
from se_admin.checks.workflows import check_required_workflows
|
|
16
|
+
from se_admin.domain.findings import Finding
|
|
17
|
+
from se_admin.domain.profiles import Profile, ProfileRegistry
|
|
18
|
+
from se_admin.domain.repos import RepoEntry
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_profile_checks(
|
|
22
|
+
repo: RepoEntry,
|
|
23
|
+
profile: Profile,
|
|
24
|
+
*,
|
|
25
|
+
workspace_path: Path,
|
|
26
|
+
source_path: Path | None = None,
|
|
27
|
+
profile_registry: ProfileRegistry | None = None,
|
|
28
|
+
) -> list[Finding]:
|
|
29
|
+
"""Run all checks implied by a profile against a repo.
|
|
30
|
+
|
|
31
|
+
Returns the combined list of Finding objects.
|
|
32
|
+
source_path is required only for exact_files checks.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
repo: the repository to check
|
|
36
|
+
profile: the profile whose checks to run
|
|
37
|
+
*: keyword-only arguments after this point
|
|
38
|
+
workspace_path: the base path where repositories are located on disk
|
|
39
|
+
source_path: optional path to use as source for exact_files checks
|
|
40
|
+
profile_registry: optional profile registry for resolving extended profiles
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
list of Finding objects for all checks that failed
|
|
44
|
+
"""
|
|
45
|
+
repo_path = workspace_path / repo.name
|
|
46
|
+
findings: list[Finding] = []
|
|
47
|
+
|
|
48
|
+
# Expand extended profiles first
|
|
49
|
+
if profile.extends and profile_registry is not None:
|
|
50
|
+
for extended_id in profile.extends:
|
|
51
|
+
extended = profile_registry.get(extended_id)
|
|
52
|
+
if extended is None:
|
|
53
|
+
raise KeyError(
|
|
54
|
+
f"Profile {profile.id!r} extends unknown profile: {extended_id!r}"
|
|
55
|
+
)
|
|
56
|
+
findings += run_profile_checks(
|
|
57
|
+
repo,
|
|
58
|
+
extended,
|
|
59
|
+
workspace_path=workspace_path,
|
|
60
|
+
source_path=source_path,
|
|
61
|
+
profile_registry=profile_registry,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if profile.required_paths:
|
|
65
|
+
findings += check_required_paths(repo.name, repo_path, profile.required_paths)
|
|
66
|
+
|
|
67
|
+
if profile.required_workflows:
|
|
68
|
+
findings += check_required_workflows(
|
|
69
|
+
repo.name, repo_path, profile.required_workflows
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"check_exact_files",
|
|
77
|
+
"check_python_version",
|
|
78
|
+
"check_reference_files",
|
|
79
|
+
"check_required_paths",
|
|
80
|
+
"check_required_workflows",
|
|
81
|
+
"check_tags",
|
|
82
|
+
"run_profile_checks",
|
|
83
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Check that files in a repo match the canonical source exactly."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from se_admin.domain.findings import Finding, FindingStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_exact_files(
|
|
9
|
+
repo: str,
|
|
10
|
+
repo_path: Path,
|
|
11
|
+
paths: list[str],
|
|
12
|
+
source_path: Path,
|
|
13
|
+
) -> list[Finding]:
|
|
14
|
+
"""Compare each path byte-for-byte against the canonical source.
|
|
15
|
+
|
|
16
|
+
Findings:
|
|
17
|
+
FAIL - file missing in target
|
|
18
|
+
FAIL - file present but differs from source
|
|
19
|
+
PASS - file matches source exactly
|
|
20
|
+
SKIP - source file missing (cannot compare)
|
|
21
|
+
"""
|
|
22
|
+
findings: list[Finding] = []
|
|
23
|
+
|
|
24
|
+
for rel in paths:
|
|
25
|
+
src = source_path / rel
|
|
26
|
+
dest = repo_path / rel
|
|
27
|
+
|
|
28
|
+
if not src.exists():
|
|
29
|
+
findings.append(
|
|
30
|
+
Finding(
|
|
31
|
+
repo=repo,
|
|
32
|
+
check="exact_files",
|
|
33
|
+
status=FindingStatus.SKIP,
|
|
34
|
+
path=rel,
|
|
35
|
+
message=f"Source file not found, cannot compare: {rel}",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
if not dest.exists():
|
|
41
|
+
findings.append(
|
|
42
|
+
Finding(
|
|
43
|
+
repo=repo,
|
|
44
|
+
check="exact_files",
|
|
45
|
+
status=FindingStatus.FAIL,
|
|
46
|
+
path=rel,
|
|
47
|
+
message=f"File missing in target: {rel}",
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if dest.read_bytes() == src.read_bytes():
|
|
53
|
+
findings.append(
|
|
54
|
+
Finding(
|
|
55
|
+
repo=repo,
|
|
56
|
+
check="exact_files",
|
|
57
|
+
status=FindingStatus.PASS,
|
|
58
|
+
path=rel,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
findings.append(
|
|
63
|
+
Finding(
|
|
64
|
+
repo=repo,
|
|
65
|
+
check="exact_files",
|
|
66
|
+
status=FindingStatus.FAIL,
|
|
67
|
+
path=rel,
|
|
68
|
+
message=f"File differs from canonical source: {rel}",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return findings
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Check Python version alignment in a repo.
|
|
2
|
+
|
|
3
|
+
Two distinct checks:
|
|
4
|
+
python_version_file - .python-version pin matches expected
|
|
5
|
+
requires_python - pyproject.toml requires-python specifier present
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from se_admin.domain.findings import Finding, FindingStatus
|
|
11
|
+
from se_admin.observe.pyproject import get_python_version_file, get_requires_python
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_python_version(
|
|
15
|
+
repo: str,
|
|
16
|
+
repo_path: Path,
|
|
17
|
+
expected_version: str,
|
|
18
|
+
) -> list[Finding]:
|
|
19
|
+
"""Check .python-version pin equals expected_version (e.g. '3.12')."""
|
|
20
|
+
actual = get_python_version_file(repo_path)
|
|
21
|
+
|
|
22
|
+
if actual is None:
|
|
23
|
+
return [
|
|
24
|
+
Finding(
|
|
25
|
+
repo=repo,
|
|
26
|
+
check="python_version_file",
|
|
27
|
+
status=FindingStatus.FAIL,
|
|
28
|
+
path=".python-version",
|
|
29
|
+
message=".python-version file missing",
|
|
30
|
+
)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
if actual == expected_version:
|
|
34
|
+
return [
|
|
35
|
+
Finding(
|
|
36
|
+
repo=repo,
|
|
37
|
+
check="python_version_file",
|
|
38
|
+
status=FindingStatus.PASS,
|
|
39
|
+
path=".python-version",
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
Finding(
|
|
45
|
+
repo=repo,
|
|
46
|
+
check="python_version_file",
|
|
47
|
+
status=FindingStatus.FAIL,
|
|
48
|
+
path=".python-version",
|
|
49
|
+
message=f"Expected {expected_version!r}, found {actual!r}",
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_requires_python(
|
|
55
|
+
repo: str,
|
|
56
|
+
repo_path: Path,
|
|
57
|
+
expected_specifier: str | None = None,
|
|
58
|
+
) -> list[Finding]:
|
|
59
|
+
"""Check that requires-python is set in pyproject.toml.
|
|
60
|
+
|
|
61
|
+
If expected_specifier is given, also assert it matches exactly.
|
|
62
|
+
"""
|
|
63
|
+
actual = get_requires_python(repo_path)
|
|
64
|
+
|
|
65
|
+
if actual is None:
|
|
66
|
+
return [
|
|
67
|
+
Finding(
|
|
68
|
+
repo=repo,
|
|
69
|
+
check="requires_python",
|
|
70
|
+
status=FindingStatus.FAIL,
|
|
71
|
+
path="pyproject.toml",
|
|
72
|
+
message="requires-python not set in [project]",
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
if expected_specifier is not None and actual != expected_specifier:
|
|
77
|
+
return [
|
|
78
|
+
Finding(
|
|
79
|
+
repo=repo,
|
|
80
|
+
check="requires_python",
|
|
81
|
+
status=FindingStatus.FAIL,
|
|
82
|
+
path="pyproject.toml",
|
|
83
|
+
message=f"Expected requires-python={expected_specifier!r}, found {actual!r}",
|
|
84
|
+
)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
Finding(
|
|
89
|
+
repo=repo,
|
|
90
|
+
check="requires_python",
|
|
91
|
+
status=FindingStatus.PASS,
|
|
92
|
+
path="pyproject.toml",
|
|
93
|
+
)
|
|
94
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Check that a repo's reference/ directory exists and contains expected files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from se_admin.domain.findings import Finding, FindingStatus
|
|
6
|
+
from se_admin.observe.filesystem import is_directory, path_exists
|
|
7
|
+
|
|
8
|
+
_REFERENCE_DIR = "reference"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_reference_files(
|
|
12
|
+
repo: str,
|
|
13
|
+
repo_path: Path,
|
|
14
|
+
expected_paths: list[str] | None = None,
|
|
15
|
+
) -> list[Finding]:
|
|
16
|
+
"""Check the reference/ directory and optionally specific files within it.
|
|
17
|
+
|
|
18
|
+
Without expected_paths: PASS if reference/ exists, FAIL if not.
|
|
19
|
+
With expected_paths: one Finding per path under reference/.
|
|
20
|
+
"""
|
|
21
|
+
findings: list[Finding] = []
|
|
22
|
+
|
|
23
|
+
dir_present = is_directory(repo_path, _REFERENCE_DIR)
|
|
24
|
+
|
|
25
|
+
if not dir_present:
|
|
26
|
+
findings.append(
|
|
27
|
+
Finding(
|
|
28
|
+
repo=repo,
|
|
29
|
+
check="reference_files",
|
|
30
|
+
status=FindingStatus.FAIL,
|
|
31
|
+
path=_REFERENCE_DIR,
|
|
32
|
+
message="reference/ directory missing",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
# No point checking contents if directory is absent
|
|
36
|
+
return findings
|
|
37
|
+
|
|
38
|
+
findings.append(
|
|
39
|
+
Finding(
|
|
40
|
+
repo=repo,
|
|
41
|
+
check="reference_files",
|
|
42
|
+
status=FindingStatus.PASS,
|
|
43
|
+
path=_REFERENCE_DIR,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if expected_paths:
|
|
48
|
+
for rel in expected_paths:
|
|
49
|
+
full_rel = f"{_REFERENCE_DIR}/{rel}"
|
|
50
|
+
exists = path_exists(repo_path, full_rel)
|
|
51
|
+
findings.append(
|
|
52
|
+
Finding(
|
|
53
|
+
repo=repo,
|
|
54
|
+
check="reference_files",
|
|
55
|
+
status=FindingStatus.PASS if exists else FindingStatus.FAIL,
|
|
56
|
+
path=full_rel,
|
|
57
|
+
message=None
|
|
58
|
+
if exists
|
|
59
|
+
else f"Expected reference file missing: {full_rel}",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return findings
|