bugyi-chops 0.7.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.
- bugyi_chops/__init__.py +5 -0
- bugyi_chops/_common.py +168 -0
- bugyi_chops/_report.py +72 -0
- bugyi_chops/ci_watch.py +2061 -0
- bugyi_chops/py.typed +0 -0
- bugyi_chops/toobig_split.py +572 -0
- bugyi_chops-0.7.0.dist-info/METADATA +317 -0
- bugyi_chops-0.7.0.dist-info/RECORD +11 -0
- bugyi_chops-0.7.0.dist-info/WHEEL +4 -0
- bugyi_chops-0.7.0.dist-info/entry_points.txt +3 -0
- bugyi_chops-0.7.0.dist-info/licenses/LICENSE +21 -0
bugyi_chops/__init__.py
ADDED
bugyi_chops/_common.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Shared result and target helpers for bugyi-chops."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import traceback
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sase.chops import (
|
|
14
|
+
ChopInvocation,
|
|
15
|
+
ChopReport,
|
|
16
|
+
ChopResultBuilder,
|
|
17
|
+
ChopResultStatus,
|
|
18
|
+
emit_summary,
|
|
19
|
+
load_chop_invocation,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
ChopBody = Callable[[ChopInvocation], ChopResultBuilder]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def first_nonblank(*values: object) -> str | None:
|
|
26
|
+
"""Return the first non-blank string from *values*."""
|
|
27
|
+
|
|
28
|
+
for value in values:
|
|
29
|
+
if isinstance(value, str) and value.strip():
|
|
30
|
+
return value.strip()
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def context_target(invocation: ChopInvocation) -> Mapping[str, Any]:
|
|
35
|
+
return invocation.context.target or {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def context_vars(invocation: ChopInvocation) -> Mapping[str, Any]:
|
|
39
|
+
return invocation.context.vars or {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def target_label(invocation: ChopInvocation, *, default: str = "sase") -> str:
|
|
43
|
+
target = context_target(invocation)
|
|
44
|
+
variables = context_vars(invocation)
|
|
45
|
+
return (
|
|
46
|
+
first_nonblank(
|
|
47
|
+
variables.get("project"),
|
|
48
|
+
target.get("name"),
|
|
49
|
+
target.get("project"),
|
|
50
|
+
os.getenv("BUGYI_CHOPS_PROJECT"),
|
|
51
|
+
)
|
|
52
|
+
or default
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_workspace(value: str) -> str:
|
|
57
|
+
"""Normalize a proposal workspace ref and reject malformed values."""
|
|
58
|
+
|
|
59
|
+
workspace = value.strip().removeprefix("#")
|
|
60
|
+
if not workspace or ":" not in workspace or any(char.isspace() for char in workspace):
|
|
61
|
+
raise ValueError(f"invalid workspace ref: {value!r}")
|
|
62
|
+
return workspace
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def proposal_workspace(
|
|
66
|
+
invocation: ChopInvocation,
|
|
67
|
+
*,
|
|
68
|
+
default: str | None = None,
|
|
69
|
+
) -> str:
|
|
70
|
+
target = context_target(invocation)
|
|
71
|
+
variables = context_vars(invocation)
|
|
72
|
+
value = first_nonblank(
|
|
73
|
+
variables.get("workspace"),
|
|
74
|
+
variables.get("launch_ref"),
|
|
75
|
+
target.get("workspace"),
|
|
76
|
+
os.getenv("BUGYI_CHOPS_WORKSPACE"),
|
|
77
|
+
default,
|
|
78
|
+
)
|
|
79
|
+
if value is None:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"a workspace ref is required in target.workspace, vars.workspace, "
|
|
82
|
+
"or BUGYI_CHOPS_WORKSPACE"
|
|
83
|
+
)
|
|
84
|
+
return normalize_workspace(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def target_workspace_dir(invocation: ChopInvocation) -> Path | None:
|
|
88
|
+
target = context_target(invocation)
|
|
89
|
+
variables = context_vars(invocation)
|
|
90
|
+
value = first_nonblank(
|
|
91
|
+
variables.get("repo_root"),
|
|
92
|
+
variables.get("workspace_dir"),
|
|
93
|
+
target.get("workspace_dir"),
|
|
94
|
+
)
|
|
95
|
+
if value is None:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
path = Path(value).expanduser().resolve(strict=True)
|
|
99
|
+
except OSError as error:
|
|
100
|
+
raise ValueError(f"failed to resolve workspace directory {value!r}: {error}") from error
|
|
101
|
+
if not path.is_dir():
|
|
102
|
+
raise ValueError(f"workspace directory is not a directory: {path}")
|
|
103
|
+
return path
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def safe_fragment(value: str, *, fallback: str = "repo") -> str:
|
|
107
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("._-") or fallback
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def git_head(repo_root: Path | None) -> tuple[str | None, str | None]:
|
|
111
|
+
"""Return full and abbreviated HEAD without making audit proposals depend on git."""
|
|
112
|
+
|
|
113
|
+
if repo_root is None:
|
|
114
|
+
return None, None
|
|
115
|
+
result = subprocess.run(
|
|
116
|
+
["git", "rev-parse", "HEAD"],
|
|
117
|
+
cwd=repo_root,
|
|
118
|
+
capture_output=True,
|
|
119
|
+
text=True,
|
|
120
|
+
check=False,
|
|
121
|
+
)
|
|
122
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
123
|
+
return None, None
|
|
124
|
+
head = result.stdout.strip()
|
|
125
|
+
return head, head[:12]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def result_with_summary(
|
|
129
|
+
invocation: ChopInvocation,
|
|
130
|
+
name: str,
|
|
131
|
+
counters: Mapping[str, int],
|
|
132
|
+
*,
|
|
133
|
+
status: ChopResultStatus = "ok",
|
|
134
|
+
reason: str | None = None,
|
|
135
|
+
report: ChopReport | Mapping[str, Any] | None = None,
|
|
136
|
+
) -> ChopResultBuilder:
|
|
137
|
+
line = emit_summary(name, counters, reason=reason, logger=invocation.logger)
|
|
138
|
+
return ChopResultBuilder(
|
|
139
|
+
status=status,
|
|
140
|
+
summary=line,
|
|
141
|
+
reason=reason,
|
|
142
|
+
counters=dict(counters),
|
|
143
|
+
report=report,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_chop(name: str, description: str, body: ChopBody) -> None:
|
|
148
|
+
"""Load a chop invocation, fail closed into a typed result, and write it."""
|
|
149
|
+
|
|
150
|
+
invocation = load_chop_invocation(description=description)
|
|
151
|
+
try:
|
|
152
|
+
result = body(invocation)
|
|
153
|
+
except Exception as error:
|
|
154
|
+
invocation.logger.error(f"{name} check failed: {error}")
|
|
155
|
+
invocation.logger.debug(traceback.format_exc().rstrip())
|
|
156
|
+
report = ChopReport(title=name.replace("_", " ").upper()).headline(
|
|
157
|
+
f"Check failed: {error}",
|
|
158
|
+
tone="error",
|
|
159
|
+
)
|
|
160
|
+
result = result_with_summary(
|
|
161
|
+
invocation,
|
|
162
|
+
name,
|
|
163
|
+
{"proposals": 0},
|
|
164
|
+
status="check_error",
|
|
165
|
+
reason="check_failed",
|
|
166
|
+
report=report,
|
|
167
|
+
)
|
|
168
|
+
result.write(context=invocation.context)
|
bugyi_chops/_report.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared presentation helpers for bugyi-chops reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
from sase.chops import ChopReport, Tone
|
|
9
|
+
|
|
10
|
+
SEVERITY_TONES: Mapping[str, Tone] = {
|
|
11
|
+
"violation": "error",
|
|
12
|
+
"warning": "warn",
|
|
13
|
+
"fyi": "info",
|
|
14
|
+
"neutral": "muted",
|
|
15
|
+
"unknown": "muted",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def start_report(title: str) -> ChopReport:
|
|
20
|
+
"""Start one report using the package-wide title convention."""
|
|
21
|
+
|
|
22
|
+
return ChopReport(title=title.upper())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def severity_tone(severity: str) -> Tone:
|
|
26
|
+
"""Map a package severity to the shared semantic tone vocabulary."""
|
|
27
|
+
|
|
28
|
+
return SEVERITY_TONES.get(severity, "neutral")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def elide_path(path: str, max_cells: int) -> str:
|
|
32
|
+
"""Elide a path from the left while respecting rendered cell width."""
|
|
33
|
+
|
|
34
|
+
if max_cells <= 0:
|
|
35
|
+
return ""
|
|
36
|
+
if cell_len(path) <= max_cells:
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
prefix = "…/"
|
|
40
|
+
if max_cells <= cell_len(prefix):
|
|
41
|
+
return prefix[:max_cells]
|
|
42
|
+
for start, character in enumerate(path):
|
|
43
|
+
if character != "/":
|
|
44
|
+
continue
|
|
45
|
+
candidate = prefix + path[start + 1 :]
|
|
46
|
+
if cell_len(candidate) <= max_cells:
|
|
47
|
+
return candidate
|
|
48
|
+
for start in range(1, len(path) + 1):
|
|
49
|
+
candidate = prefix + path[start:].lstrip("/")
|
|
50
|
+
if cell_len(candidate) <= max_cells:
|
|
51
|
+
return candidate
|
|
52
|
+
return prefix
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_facts_footer(
|
|
56
|
+
report: ChopReport,
|
|
57
|
+
facts: Mapping[str, str],
|
|
58
|
+
*,
|
|
59
|
+
tone: Tone = "muted",
|
|
60
|
+
) -> ChopReport:
|
|
61
|
+
"""Finish a report with the shared divider and factual key/value footer."""
|
|
62
|
+
|
|
63
|
+
return report.divider().kv(facts, tone=tone)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"SEVERITY_TONES",
|
|
68
|
+
"add_facts_footer",
|
|
69
|
+
"elide_path",
|
|
70
|
+
"severity_tone",
|
|
71
|
+
"start_report",
|
|
72
|
+
]
|