sheridan-diffract 1.0.1__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.
- sheridan/diffract/__init__.py +32 -0
- sheridan/diffract/checker.py +106 -0
- sheridan/diffract/classifier.py +74 -0
- sheridan/diffract/cli.py +316 -0
- sheridan/diffract/config.py +100 -0
- sheridan/diffract/differ.py +48 -0
- sheridan/diffract/enums.py +36 -0
- sheridan/diffract/exceptions.py +17 -0
- sheridan/diffract/git_utils.py +172 -0
- sheridan/diffract/models.py +90 -0
- sheridan/diffract/py.typed +0 -0
- sheridan_diffract-1.0.1.dist-info/METADATA +228 -0
- sheridan_diffract-1.0.1.dist-info/RECORD +16 -0
- sheridan_diffract-1.0.1.dist-info/WHEEL +4 -0
- sheridan_diffract-1.0.1.dist-info/entry_points.txt +2 -0
- sheridan_diffract-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""sheridan-diffract: Detect public API changes between git refs and classify commits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ApiDiff",
|
|
7
|
+
"ChangeKind",
|
|
8
|
+
"CommitType",
|
|
9
|
+
"DiffractConfig",
|
|
10
|
+
"DiffractError",
|
|
11
|
+
"DiffractResult",
|
|
12
|
+
"GitError",
|
|
13
|
+
"NameChange",
|
|
14
|
+
"SurfaceError",
|
|
15
|
+
"__version__",
|
|
16
|
+
"check",
|
|
17
|
+
"check_staged",
|
|
18
|
+
"load_config",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
import importlib.metadata
|
|
22
|
+
|
|
23
|
+
from sheridan.diffract.checker import check, check_staged
|
|
24
|
+
from sheridan.diffract.config import DiffractConfig, load_config
|
|
25
|
+
from sheridan.diffract.enums import ChangeKind, CommitType
|
|
26
|
+
from sheridan.diffract.exceptions import DiffractError, GitError, SurfaceError
|
|
27
|
+
from sheridan.diffract.models import ApiDiff, DiffractResult, NameChange
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
__version__: str = importlib.metadata.version("sheridan-diffract")
|
|
31
|
+
except importlib.metadata.PackageNotFoundError:
|
|
32
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""High-level API entry point for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["check", "check_staged"]
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from sheridan.diffract.classifier import classify
|
|
10
|
+
from sheridan.diffract.differ import diff_surfaces
|
|
11
|
+
from sheridan.diffract.git_utils import (
|
|
12
|
+
get_api_at_index,
|
|
13
|
+
get_api_at_ref,
|
|
14
|
+
get_repo,
|
|
15
|
+
has_python_changes,
|
|
16
|
+
has_python_changes_index,
|
|
17
|
+
)
|
|
18
|
+
from sheridan.diffract.models import DiffractResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check(
|
|
22
|
+
base_ref: str = "HEAD~1",
|
|
23
|
+
head_ref: str = "HEAD",
|
|
24
|
+
repo_path: Path | None = None,
|
|
25
|
+
src_path: str = "src",
|
|
26
|
+
) -> DiffractResult:
|
|
27
|
+
"""Detect API changes between two git refs and classify the commit type.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
base_ref: The earlier git ref (default: ``HEAD~1``).
|
|
31
|
+
head_ref: The later git ref (default: ``HEAD``).
|
|
32
|
+
repo_path: Path to (or inside) the git repository. Defaults to the
|
|
33
|
+
current working directory.
|
|
34
|
+
src_path: Subdirectory within the repo to scan for Python packages.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A :class:`~sheridan.diffract.models.DiffractResult` describing the
|
|
38
|
+
commit type, a human-readable summary, and the full API diff.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
GitError: If the repository cannot be found or either ref is invalid.
|
|
42
|
+
SurfaceError: If API surface extraction fails for either ref.
|
|
43
|
+
"""
|
|
44
|
+
resolved_path = repo_path if repo_path is not None else Path.cwd()
|
|
45
|
+
repo = get_repo(resolved_path)
|
|
46
|
+
|
|
47
|
+
base_surface = get_api_at_ref(repo, base_ref, src_path=src_path)
|
|
48
|
+
head_surface = get_api_at_ref(repo, head_ref, src_path=src_path)
|
|
49
|
+
|
|
50
|
+
diff = diff_surfaces(base_surface, head_surface)
|
|
51
|
+
py_changed = has_python_changes(repo, base_ref, head_ref)
|
|
52
|
+
commit_type, summary = classify(diff, py_changed)
|
|
53
|
+
|
|
54
|
+
return DiffractResult(
|
|
55
|
+
commit_type=commit_type,
|
|
56
|
+
summary=summary,
|
|
57
|
+
diff=diff,
|
|
58
|
+
base_ref=base_ref,
|
|
59
|
+
head_ref=head_ref,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_staged(
|
|
64
|
+
repo_path: Path | None = None,
|
|
65
|
+
src_path: str = "src",
|
|
66
|
+
) -> DiffractResult:
|
|
67
|
+
"""Detect API changes between HEAD and the git staging area and classify the commit type.
|
|
68
|
+
|
|
69
|
+
Intended for use in commit-msg hooks, where the working tree is not yet
|
|
70
|
+
committed. Compares the public API at ``HEAD`` against the public API
|
|
71
|
+
materialised from the current index (staged files).
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
repo_path: Path to (or inside) the git repository. Defaults to the
|
|
75
|
+
current working directory.
|
|
76
|
+
src_path: Subdirectory within the repo to scan for Python packages.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A :class:`~sheridan.diffract.models.DiffractResult` describing the
|
|
80
|
+
commit type, a human-readable summary, and the full API diff.
|
|
81
|
+
The ``base_ref`` field is set to ``"HEAD"`` and ``head_ref`` is set
|
|
82
|
+
to ``":staged"``.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
GitError: If the repository cannot be found.
|
|
86
|
+
SurfaceError: If API surface extraction fails for the index.
|
|
87
|
+
"""
|
|
88
|
+
resolved_path = repo_path if repo_path is not None else Path.cwd()
|
|
89
|
+
repo = get_repo(resolved_path)
|
|
90
|
+
|
|
91
|
+
# initial commit — no prior surface
|
|
92
|
+
base_surface = get_api_at_ref(repo, "HEAD", src_path=src_path) if repo.head.is_valid() else {}
|
|
93
|
+
|
|
94
|
+
head_surface = get_api_at_index(repo, src_path=src_path)
|
|
95
|
+
|
|
96
|
+
diff = diff_surfaces(base_surface, head_surface)
|
|
97
|
+
py_changed = has_python_changes_index(repo)
|
|
98
|
+
commit_type, summary = classify(diff, py_changed)
|
|
99
|
+
|
|
100
|
+
return DiffractResult(
|
|
101
|
+
commit_type=commit_type,
|
|
102
|
+
summary=summary,
|
|
103
|
+
diff=diff,
|
|
104
|
+
base_ref="HEAD",
|
|
105
|
+
head_ref=":staged", # sentinel — not a real git ref
|
|
106
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Commit-type classification for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["classify"]
|
|
6
|
+
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
from sheridan.diffract.enums import CommitType
|
|
10
|
+
from sheridan.diffract.models import ApiDiff, NameChange
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _group_by_module(changes: tuple[NameChange, ...]) -> dict[str, list[str]]:
|
|
14
|
+
"""Group a sequence of NameChanges by their module name.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
changes: The name changes to group.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
A dict mapping module name to a sorted list of public names.
|
|
21
|
+
"""
|
|
22
|
+
grouped: dict[str, list[str]] = defaultdict(list)
|
|
23
|
+
for change in changes:
|
|
24
|
+
grouped[change.module].append(change.name)
|
|
25
|
+
return {mod: sorted(names) for mod, names in sorted(grouped.items())}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _format_grouped(changes: tuple[NameChange, ...]) -> str:
|
|
29
|
+
"""Format grouped name changes as a multi-line string.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
changes: The name changes to format.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A human-readable string listing each module and its changed names.
|
|
36
|
+
"""
|
|
37
|
+
grouped = _group_by_module(changes)
|
|
38
|
+
lines: list[str] = []
|
|
39
|
+
for module, names in grouped.items():
|
|
40
|
+
lines.append(f"{module}: {', '.join(names)}")
|
|
41
|
+
return "; ".join(lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify(diff: ApiDiff, has_py_changes: bool) -> tuple[CommitType, str]:
|
|
45
|
+
"""Map an ApiDiff to a conventional commit type and human-readable summary.
|
|
46
|
+
|
|
47
|
+
Rules applied in priority order:
|
|
48
|
+
|
|
49
|
+
1. Any removed names -> ``feat!`` (breaking change).
|
|
50
|
+
Summary lists removed names grouped by module.
|
|
51
|
+
2. Any added names -> ``feat``.
|
|
52
|
+
Summary lists added names grouped by module.
|
|
53
|
+
3. No name changes but Python files changed -> ``refactor``.
|
|
54
|
+
4. No changes at all -> ``fix``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
diff: The API diff produced by :func:`sheridan.diffract.differ.diff_surfaces`.
|
|
58
|
+
has_py_changes: Whether any ``.py`` files changed between the two refs.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A two-tuple of ``(CommitType, summary_string)``.
|
|
62
|
+
"""
|
|
63
|
+
if diff.is_breaking:
|
|
64
|
+
summary = f"Breaking: removed public names — {_format_grouped(diff.removed)}"
|
|
65
|
+
return CommitType.feat_breaking, summary
|
|
66
|
+
|
|
67
|
+
if diff.has_additions:
|
|
68
|
+
summary = f"Added public names — {_format_grouped(diff.added)}"
|
|
69
|
+
return CommitType.feat, summary
|
|
70
|
+
|
|
71
|
+
if has_py_changes:
|
|
72
|
+
return CommitType.refactor, "Internal Python changes; public API surface unchanged."
|
|
73
|
+
|
|
74
|
+
return CommitType.fix, "No public API or Python source changes detected."
|
sheridan/diffract/cli.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Command-line interface for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["main"]
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from sheridan.diffract import __version__
|
|
15
|
+
from sheridan.diffract.checker import check, check_staged
|
|
16
|
+
from sheridan.diffract.config import load_config
|
|
17
|
+
from sheridan.diffract.enums import CommitType
|
|
18
|
+
from sheridan.diffract.exceptions import DiffractError
|
|
19
|
+
from sheridan.diffract.git_utils import get_repo
|
|
20
|
+
from sheridan.diffract.models import DiffractResult, NameChange
|
|
21
|
+
|
|
22
|
+
_COMMIT_TYPE_RE: re.Pattern[str] = re.compile(r"^(feat|fix|refactor)(\([^)]*\))?(!)?:")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
"""Construct and return the argument parser for the diffract CLI.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
A fully-configured :class:`argparse.ArgumentParser` instance.
|
|
30
|
+
"""
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="diffract",
|
|
33
|
+
description="Detect public API changes between git refs and suggest a commit type.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--version",
|
|
37
|
+
action="version",
|
|
38
|
+
version=f"%(prog)s {__version__}",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"base_ref",
|
|
42
|
+
nargs="?",
|
|
43
|
+
default=None,
|
|
44
|
+
metavar="BASE_REF",
|
|
45
|
+
help=(
|
|
46
|
+
"The earlier git ref to compare (default: HEAD~1). "
|
|
47
|
+
"When --validate-msg-file is used without explicit refs, "
|
|
48
|
+
"diffract automatically compares HEAD vs the staging area."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"head_ref",
|
|
53
|
+
nargs="?",
|
|
54
|
+
default=None,
|
|
55
|
+
metavar="HEAD_REF",
|
|
56
|
+
help=(
|
|
57
|
+
"The later git ref to compare (default: HEAD). "
|
|
58
|
+
"If only BASE_REF is given, HEAD is used as HEAD_REF. "
|
|
59
|
+
"When --validate-msg-file is used without explicit refs, "
|
|
60
|
+
"diffract automatically compares HEAD vs the staging area."
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--src",
|
|
65
|
+
default=None,
|
|
66
|
+
metavar="SRC",
|
|
67
|
+
dest="src_path",
|
|
68
|
+
help="Source directory within the repository to scan (default: src).",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--json",
|
|
72
|
+
action="store_true",
|
|
73
|
+
dest="output_json",
|
|
74
|
+
help="Emit JSON to stdout instead of human-readable text.",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--exit-code",
|
|
78
|
+
action="store_true",
|
|
79
|
+
dest="exit_code",
|
|
80
|
+
help="Exit 1 for breaking changes, 2 for any API change, 0 otherwise.",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--validate-msg-file",
|
|
84
|
+
dest="validate_msg_file",
|
|
85
|
+
metavar="MSGFILE",
|
|
86
|
+
help="Path to commit message file; validates written type against detected API change.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return parser
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _group_changes(changes: tuple[NameChange, ...]) -> dict[str, list[str]]:
|
|
93
|
+
"""Group NameChange objects by module for display purposes.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
changes: The name changes to group.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
A dict mapping module name to sorted list of public names.
|
|
100
|
+
"""
|
|
101
|
+
grouped: dict[str, list[str]] = defaultdict(list)
|
|
102
|
+
for change in changes:
|
|
103
|
+
grouped[change.module].append(change.name)
|
|
104
|
+
return {mod: sorted(names) for mod, names in sorted(grouped.items())}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _format_human(result: DiffractResult, scope: str | None = None) -> str:
|
|
108
|
+
"""Format a DiffractResult as human-readable text.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
result: The diffract result to format.
|
|
112
|
+
scope: Optional conventional-commit scope (without parentheses) to
|
|
113
|
+
include in the suggested commit prefix, e.g. ``"api"`` produces
|
|
114
|
+
``"feat(api):"`` instead of ``"feat:"``.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
A multi-line string suitable for printing to a terminal.
|
|
118
|
+
"""
|
|
119
|
+
lines: list[str] = []
|
|
120
|
+
commit_label = str(result.commit_type)
|
|
121
|
+
is_breaking = result.commit_type == CommitType.feat_breaking
|
|
122
|
+
|
|
123
|
+
if is_breaking:
|
|
124
|
+
lines.append(f"Detected: {commit_label} (breaking change)")
|
|
125
|
+
else:
|
|
126
|
+
lines.append(f"Detected: {commit_label}")
|
|
127
|
+
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
130
|
+
if result.diff.is_empty:
|
|
131
|
+
lines.append("No public API changes detected.")
|
|
132
|
+
else:
|
|
133
|
+
if result.diff.removed:
|
|
134
|
+
lines.append("Removed public names:")
|
|
135
|
+
for module, names in _group_changes(result.diff.removed).items():
|
|
136
|
+
lines.append(f" {module}:")
|
|
137
|
+
for name in names:
|
|
138
|
+
lines.append(f" - {name}")
|
|
139
|
+
if result.diff.added:
|
|
140
|
+
lines.append("Added public names:")
|
|
141
|
+
for module, names in _group_changes(result.diff.added).items():
|
|
142
|
+
lines.append(f" {module}:")
|
|
143
|
+
for name in names:
|
|
144
|
+
lines.append(f" + {name}")
|
|
145
|
+
|
|
146
|
+
lines.append("")
|
|
147
|
+
lines.append(f"Suggested commit prefix: {_format_commit_type(result.commit_type, scope)}")
|
|
148
|
+
|
|
149
|
+
return "\n".join(lines)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _resolve_exit_code(result: DiffractResult, use_exit_code: bool) -> int:
|
|
153
|
+
"""Determine the process exit code based on the result and flag.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
result: The diffract result.
|
|
157
|
+
use_exit_code: Whether the ``--exit-code`` flag was passed.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
``0`` normally; ``1`` for breaking changes, ``2`` for any API change,
|
|
161
|
+
when ``use_exit_code`` is ``True``.
|
|
162
|
+
"""
|
|
163
|
+
if not use_exit_code:
|
|
164
|
+
return 0
|
|
165
|
+
if result.diff.is_breaking:
|
|
166
|
+
return 1
|
|
167
|
+
if not result.diff.is_empty:
|
|
168
|
+
return 2
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_commit_type(first_line: str) -> CommitType | None:
|
|
173
|
+
"""Parse the conventional commit type from the first line of a commit message.
|
|
174
|
+
|
|
175
|
+
Recognises ``feat``, ``feat!``, ``fix``, and ``refactor`` (with or without
|
|
176
|
+
a scope in parentheses). Any other prefix — including ``docs:``, ``chore:``,
|
|
177
|
+
``test:``, etc. — returns ``None`` so that non-conventional commits are
|
|
178
|
+
never blocked.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
first_line: The first line of the commit message.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The matching :class:`~sheridan.diffract.enums.CommitType`, or ``None``
|
|
185
|
+
if the line does not start with a recognised conventional-commit type.
|
|
186
|
+
"""
|
|
187
|
+
m = _COMMIT_TYPE_RE.match(first_line)
|
|
188
|
+
if m is None:
|
|
189
|
+
return None
|
|
190
|
+
base, _scope, bang = m.group(1), m.group(2), m.group(3)
|
|
191
|
+
match (base, bool(bang)):
|
|
192
|
+
case ("feat", True):
|
|
193
|
+
return CommitType.feat_breaking
|
|
194
|
+
case ("feat", False):
|
|
195
|
+
return CommitType.feat
|
|
196
|
+
case ("fix", _):
|
|
197
|
+
return CommitType.fix
|
|
198
|
+
case ("refactor", _):
|
|
199
|
+
return CommitType.refactor
|
|
200
|
+
case _:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_scope(first_line: str) -> str | None:
|
|
205
|
+
"""Extract the optional scope from the first line of a commit message.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
first_line: The first line of the commit message.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The scope string without parentheses (e.g. ``"parser"`` from
|
|
212
|
+
``"feat(parser): …"``), or ``None`` if no scope is present or
|
|
213
|
+
the line is not a recognised conventional-commit prefix.
|
|
214
|
+
"""
|
|
215
|
+
m = _COMMIT_TYPE_RE.match(first_line)
|
|
216
|
+
if m is None:
|
|
217
|
+
return None
|
|
218
|
+
raw = m.group(2) # e.g. "(parser)" or None
|
|
219
|
+
return raw[1:-1] if raw is not None else None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _format_commit_type(commit_type: CommitType, scope: str | None) -> str:
|
|
223
|
+
"""Render a commit type with an optional scope as a conventional-commit prefix.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
commit_type: The commit type to render.
|
|
227
|
+
scope: An optional scope string (without parentheses).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
A string such as ``"feat:"``, ``"feat(api):"`` or ``"feat(api)!:"``.
|
|
231
|
+
"""
|
|
232
|
+
if scope is None:
|
|
233
|
+
return f"{commit_type}:"
|
|
234
|
+
if commit_type == CommitType.feat_breaking:
|
|
235
|
+
return f"feat({scope})!:"
|
|
236
|
+
return f"{commit_type}({scope}):"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main() -> None:
|
|
240
|
+
"""CLI entrypoint for diffract.
|
|
241
|
+
|
|
242
|
+
Loads configuration from config files (if present), parses command-line arguments,
|
|
243
|
+
and invokes the check() or check_staged() function to detect API changes. Outputs
|
|
244
|
+
results as human-readable text or JSON, and optionally validates commit message types.
|
|
245
|
+
|
|
246
|
+
When ``--validate-msg-file`` is provided without explicit BASE_REF/HEAD_REF positional
|
|
247
|
+
arguments, diffract automatically compares ``HEAD`` against the current staging area
|
|
248
|
+
via :func:`~sheridan.diffract.checker.check_staged`.
|
|
249
|
+
|
|
250
|
+
Exits with code:
|
|
251
|
+
- 0: No changes detected (or --exit-code not set)
|
|
252
|
+
- 1: Breaking changes detected (with --exit-code), or commit message mismatch
|
|
253
|
+
- 2: Non-breaking API changes detected (with --exit-code)
|
|
254
|
+
- 3: Error during processing (git, file I/O, or surface detection)
|
|
255
|
+
"""
|
|
256
|
+
parser = _build_parser()
|
|
257
|
+
args = parser.parse_args()
|
|
258
|
+
|
|
259
|
+
repo_path = Path.cwd()
|
|
260
|
+
# Resolve the actual git repo root so load_config finds config files
|
|
261
|
+
# correctly even when diffract is invoked from a subdirectory.
|
|
262
|
+
try:
|
|
263
|
+
repo_root = Path(get_repo(repo_path).working_dir)
|
|
264
|
+
except DiffractError:
|
|
265
|
+
repo_root = repo_path # no repo found; check() will raise the proper error
|
|
266
|
+
config = load_config(repo_root)
|
|
267
|
+
# Priority: --src flag > config file > built-in default
|
|
268
|
+
src_path: str = args.src_path or config.src or "src"
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
if args.validate_msg_file is not None and not args.base_ref and not args.head_ref:
|
|
272
|
+
result = check_staged(repo_path=repo_path, src_path=src_path)
|
|
273
|
+
else:
|
|
274
|
+
result = check(
|
|
275
|
+
base_ref=args.base_ref or "HEAD~1",
|
|
276
|
+
head_ref=args.head_ref or "HEAD",
|
|
277
|
+
repo_path=repo_path,
|
|
278
|
+
src_path=src_path,
|
|
279
|
+
)
|
|
280
|
+
except DiffractError as exc:
|
|
281
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
282
|
+
sys.exit(3)
|
|
283
|
+
|
|
284
|
+
if args.validate_msg_file is not None:
|
|
285
|
+
msg_path = Path(args.validate_msg_file)
|
|
286
|
+
try:
|
|
287
|
+
raw = msg_path.read_text(encoding="utf-8")
|
|
288
|
+
except OSError as exc:
|
|
289
|
+
print(f"diffract: cannot read commit message file: {exc}", file=sys.stderr)
|
|
290
|
+
sys.exit(3)
|
|
291
|
+
|
|
292
|
+
_lines = raw.splitlines()
|
|
293
|
+
first_line = _lines[0] if _lines else ""
|
|
294
|
+
written_type = _parse_commit_type(first_line)
|
|
295
|
+
scope = _extract_scope(first_line)
|
|
296
|
+
|
|
297
|
+
if written_type is None:
|
|
298
|
+
sys.exit(0)
|
|
299
|
+
if written_type == result.commit_type:
|
|
300
|
+
sys.exit(0)
|
|
301
|
+
|
|
302
|
+
print(_format_human(result, scope=scope), file=sys.stderr)
|
|
303
|
+
print(
|
|
304
|
+
f"\ndiffract: commit type mismatch"
|
|
305
|
+
f"\n written: {_format_commit_type(written_type, scope)}"
|
|
306
|
+
f"\n detected: {_format_commit_type(result.commit_type, scope)}",
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
|
|
311
|
+
if args.output_json:
|
|
312
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
313
|
+
else:
|
|
314
|
+
print(_format_human(result))
|
|
315
|
+
|
|
316
|
+
sys.exit(_resolve_exit_code(result, args.exit_code))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""TOML config file support for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["DiffractConfig", "load_config"]
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sheridan.diffract.exceptions import DiffractError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DiffractConfig:
|
|
17
|
+
"""Configuration loaded from diffract.toml or pyproject.toml.
|
|
18
|
+
|
|
19
|
+
Fields are None if not specified in any config file — the caller
|
|
20
|
+
merges them with CLI flags and built-in defaults.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
src: The source directory within the repository to scan, or None
|
|
24
|
+
if not specified in any config file.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
src: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_src(data: dict[str, Any], config_file: Path) -> str | None:
|
|
31
|
+
"""Extract and validate the ``src`` key from a TOML data dict.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
data: The parsed TOML table to read from.
|
|
35
|
+
config_file: Path to the config file, used in error messages.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The value of ``src`` as a string, or ``None`` if not present.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
DiffractError: If ``src`` is present but is not a string.
|
|
42
|
+
"""
|
|
43
|
+
src = data.get("src")
|
|
44
|
+
if src is None:
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(src, str):
|
|
47
|
+
raise DiffractError(
|
|
48
|
+
f"Invalid diffract config in {config_file}: 'src' must be a string, got {type(src).__name__!r}"
|
|
49
|
+
)
|
|
50
|
+
return src
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
54
|
+
"""Read and parse a TOML file, converting parse errors to DiffractError.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: The TOML file to read.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The parsed TOML document as a dict.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
DiffractError: If the file contains invalid TOML.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
with path.open("rb") as f:
|
|
67
|
+
return tomllib.load(f)
|
|
68
|
+
except tomllib.TOMLDecodeError as exc:
|
|
69
|
+
raise DiffractError(f"Failed to parse {path}: {exc}") from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_config(repo_root: Path) -> DiffractConfig:
|
|
73
|
+
"""Load diffract configuration from the repo root.
|
|
74
|
+
|
|
75
|
+
Checks for ``diffract.toml`` first, then ``pyproject.toml [tool.diffract]``.
|
|
76
|
+
Returns a ``DiffractConfig`` with ``None`` for any field not found.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
repo_root: The root directory of the git repository.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A ``DiffractConfig`` populated from the first config file found,
|
|
83
|
+
or an empty ``DiffractConfig`` if neither file exists.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
DiffractError: If a config file is found but contains invalid TOML
|
|
87
|
+
or an ``src`` value that is not a string.
|
|
88
|
+
"""
|
|
89
|
+
diffract_toml = repo_root / "diffract.toml"
|
|
90
|
+
if diffract_toml.is_file():
|
|
91
|
+
data = _load_toml(diffract_toml)
|
|
92
|
+
return DiffractConfig(src=_parse_src(data, diffract_toml))
|
|
93
|
+
|
|
94
|
+
pyproject_toml = repo_root / "pyproject.toml"
|
|
95
|
+
if pyproject_toml.is_file():
|
|
96
|
+
data = _load_toml(pyproject_toml)
|
|
97
|
+
tool_diffract: dict[str, Any] = data.get("tool", {}).get("diffract", {})
|
|
98
|
+
return DiffractConfig(src=_parse_src(tool_diffract, pyproject_toml))
|
|
99
|
+
|
|
100
|
+
return DiffractConfig()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""API surface diffing for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["diff_surfaces"]
|
|
6
|
+
|
|
7
|
+
from sheridan.diffract.enums import ChangeKind
|
|
8
|
+
from sheridan.diffract.models import ApiDiff, NameChange
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def diff_surfaces(
|
|
12
|
+
base: dict[str, list[str]],
|
|
13
|
+
head: dict[str, list[str]],
|
|
14
|
+
) -> ApiDiff:
|
|
15
|
+
"""Compare two API surfaces and return added/removed NameChanges.
|
|
16
|
+
|
|
17
|
+
Iterates over every module present in either surface and computes the
|
|
18
|
+
symmetric difference of its public names. Results are sorted by
|
|
19
|
+
``(module, name)``.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
base: The API surface at the earlier ref, as returned by
|
|
23
|
+
``sheridan.iceberg.get_public_api``.
|
|
24
|
+
head: The API surface at the later ref, as returned by
|
|
25
|
+
``sheridan.iceberg.get_public_api``.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
An :class:`ApiDiff` containing tuples of :class:`NameChange` objects
|
|
29
|
+
for all names that were added or removed.
|
|
30
|
+
"""
|
|
31
|
+
all_modules = set(base) | set(head)
|
|
32
|
+
added: list[NameChange] = []
|
|
33
|
+
removed: list[NameChange] = []
|
|
34
|
+
|
|
35
|
+
for module in all_modules:
|
|
36
|
+
base_names = set(base.get(module, []))
|
|
37
|
+
head_names = set(head.get(module, []))
|
|
38
|
+
|
|
39
|
+
for name in sorted(head_names - base_names):
|
|
40
|
+
added.append(NameChange(module=module, name=name, kind=ChangeKind.added))
|
|
41
|
+
|
|
42
|
+
for name in sorted(base_names - head_names):
|
|
43
|
+
removed.append(NameChange(module=module, name=name, kind=ChangeKind.removed))
|
|
44
|
+
|
|
45
|
+
added.sort(key=lambda c: (c.module, c.name))
|
|
46
|
+
removed.sort(key=lambda c: (c.module, c.name))
|
|
47
|
+
|
|
48
|
+
return ApiDiff(added=tuple(added), removed=tuple(removed))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Enumerations for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"ChangeKind",
|
|
5
|
+
"CommitType",
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CommitType(StrEnum):
|
|
12
|
+
"""Conventional-commit type produced by diffract.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
feat: A backwards-compatible addition was detected.
|
|
16
|
+
feat_breaking: A breaking change (removal) was detected.
|
|
17
|
+
fix: No API surface changes were detected.
|
|
18
|
+
refactor: Internal Python changes only; public API surface unchanged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
feat = "feat"
|
|
22
|
+
feat_breaking = "feat!"
|
|
23
|
+
fix = "fix"
|
|
24
|
+
refactor = "refactor"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ChangeKind(StrEnum):
|
|
28
|
+
"""Whether a public name was added or removed.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
added: The name appeared in head but not in base.
|
|
32
|
+
removed: The name appeared in base but not in head.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
added = "added"
|
|
36
|
+
removed = "removed"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["DiffractError", "GitError", "SurfaceError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DiffractError(Exception):
|
|
9
|
+
"""Base exception for all sheridan-diffract errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitError(DiffractError):
|
|
13
|
+
"""Raised when a git operation fails (repo not found, bad ref, etc.)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SurfaceError(DiffractError):
|
|
17
|
+
"""Raised when iceberg fails to extract a public API surface."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Git interaction utilities for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"get_api_at_index",
|
|
7
|
+
"get_api_at_ref",
|
|
8
|
+
"get_repo",
|
|
9
|
+
"has_python_changes",
|
|
10
|
+
"has_python_changes_index",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from git import BadName, InvalidGitRepositoryError, Repo
|
|
18
|
+
|
|
19
|
+
from sheridan.diffract.exceptions import GitError, SurfaceError
|
|
20
|
+
from sheridan.iceberg import get_public_api
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_repo(path: Path) -> Repo:
|
|
24
|
+
"""Find and return the git Repo at or above path.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
path: A path inside (or equal to) the git repository root.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The ``git.Repo`` instance for the repository.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
GitError: If no git repository is found at or above ``path``.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
return Repo(str(path), search_parent_directories=True)
|
|
37
|
+
except InvalidGitRepositoryError as exc:
|
|
38
|
+
raise GitError(f"No git repository found at or above: {path}") from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_api_at_ref(repo: Repo, ref: str, src_path: str = "src") -> dict[str, list[str]]:
|
|
42
|
+
"""Extract the public API surface at a given git ref.
|
|
43
|
+
|
|
44
|
+
Uses ``git archive`` to extract the repository tree into a temporary
|
|
45
|
+
directory, then invokes ``sheridan.iceberg.get_public_api()`` on the
|
|
46
|
+
``src_path`` subdirectory (or the temp root if that subdirectory does
|
|
47
|
+
not exist).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
repo: The git repository to archive.
|
|
51
|
+
ref: The git ref (branch, tag, or commit SHA) to check out.
|
|
52
|
+
src_path: Relative path within the repo that contains Python packages.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
A mapping of fully-qualified module name to list of public names,
|
|
56
|
+
as returned by ``sheridan.iceberg.get_public_api``.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
GitError: If ``ref`` is not a valid ref in the repository.
|
|
60
|
+
SurfaceError: If ``get_public_api`` raises any exception.
|
|
61
|
+
"""
|
|
62
|
+
# Validate the ref early so we can give a clear error message.
|
|
63
|
+
try:
|
|
64
|
+
repo.commit(ref)
|
|
65
|
+
except BadName as exc:
|
|
66
|
+
raise GitError(f"Unknown ref: {ref}") from exc
|
|
67
|
+
|
|
68
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
69
|
+
proc = repo.git.archive(ref, format="tar", as_process=True)
|
|
70
|
+
with tarfile.open(fileobj=proc.stdout, mode="r|") as tar:
|
|
71
|
+
tar.extractall(tmp, filter="data")
|
|
72
|
+
proc.wait()
|
|
73
|
+
|
|
74
|
+
api_root = Path(tmp) / src_path
|
|
75
|
+
if not api_root.exists():
|
|
76
|
+
api_root = Path(tmp)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return get_public_api(api_root)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise SurfaceError(f"Failed to extract public API at ref {ref!r}: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def has_python_changes(repo: Repo, base_ref: str, head_ref: str) -> bool:
|
|
85
|
+
"""Return True if any ``.py`` files differ between base_ref and head_ref.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
repo: The git repository to inspect.
|
|
89
|
+
base_ref: The earlier git ref.
|
|
90
|
+
head_ref: The later git ref.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
``True`` if at least one ``.py`` file appears in the diff,
|
|
94
|
+
``False`` otherwise.
|
|
95
|
+
"""
|
|
96
|
+
base_commit = repo.commit(base_ref)
|
|
97
|
+
head_commit = repo.commit(head_ref)
|
|
98
|
+
diffs = base_commit.diff(head_commit)
|
|
99
|
+
for d in diffs:
|
|
100
|
+
a_path: str = d.a_path or ""
|
|
101
|
+
b_path: str = d.b_path or ""
|
|
102
|
+
if a_path.endswith(".py") or b_path.endswith(".py"):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_api_at_index(repo: Repo, src_path: str = "src") -> dict[str, list[str]]:
|
|
108
|
+
"""Extract the public API surface from the current git staging area (index).
|
|
109
|
+
|
|
110
|
+
Materialises the staged tree via ``repo.index.write_tree()``, then uses
|
|
111
|
+
``git archive`` on the resulting tree SHA to extract files into a temporary
|
|
112
|
+
directory before invoking ``sheridan.iceberg.get_public_api()``.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
repo: The git repository whose index (staging area) to inspect.
|
|
116
|
+
src_path: Relative path within the repo that contains Python packages.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A mapping of fully-qualified module name to list of public names,
|
|
120
|
+
as returned by ``sheridan.iceberg.get_public_api``.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
GitError: If tree materialisation or archive extraction fails.
|
|
124
|
+
SurfaceError: If ``get_public_api`` raises any exception.
|
|
125
|
+
|
|
126
|
+
Notes:
|
|
127
|
+
``write_tree()`` flushes the in-memory index to ``.git/index`` as a
|
|
128
|
+
side effect before returning the tree object.
|
|
129
|
+
"""
|
|
130
|
+
tree = repo.index.write_tree()
|
|
131
|
+
tree_sha: str = tree.hexsha
|
|
132
|
+
|
|
133
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
134
|
+
proc = repo.git.archive(tree_sha, format="tar", as_process=True)
|
|
135
|
+
with tarfile.open(fileobj=proc.stdout, mode="r|") as tar:
|
|
136
|
+
tar.extractall(tmp, filter="data")
|
|
137
|
+
proc.wait()
|
|
138
|
+
|
|
139
|
+
api_root = Path(tmp) / src_path
|
|
140
|
+
if not api_root.exists():
|
|
141
|
+
api_root = Path(tmp)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
return get_public_api(api_root)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
raise SurfaceError(f"Failed to extract public API from index: {exc}") from exc
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def has_python_changes_index(repo: Repo) -> bool:
|
|
150
|
+
"""Return True if any ``.py`` files differ between HEAD and the staging area.
|
|
151
|
+
|
|
152
|
+
Compares the current HEAD commit against the git index (staging area).
|
|
153
|
+
If the repository has no HEAD commit (initial commit edge case), returns
|
|
154
|
+
``True`` to conservatively assume changes exist.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
repo: The git repository to inspect.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
``True`` if at least one ``.py`` file appears in the diff between HEAD
|
|
161
|
+
and the index, or if no HEAD commit exists. ``False`` otherwise.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
diffs = repo.index.diff(repo.head.commit)
|
|
165
|
+
except ValueError: # repo.head.commit raises ValueError when HEAD is unborn (initial commit)
|
|
166
|
+
return True
|
|
167
|
+
for d in diffs:
|
|
168
|
+
a_path: str = d.a_path or ""
|
|
169
|
+
b_path: str = d.b_path or ""
|
|
170
|
+
if a_path.endswith(".py") or b_path.endswith(".py"):
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Data models for sheridan-diffract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["ApiDiff", "DiffractResult", "NameChange"]
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from sheridan.diffract.enums import ChangeKind, CommitType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class NameChange:
|
|
14
|
+
"""A single public name that was added or removed from a module.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
module: Qualified module name, e.g. ``"sheridan.diffract.enums"``.
|
|
18
|
+
name: The public identifier, e.g. ``"CommitType"``.
|
|
19
|
+
kind: Whether the name was added or removed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
module: str
|
|
23
|
+
name: str
|
|
24
|
+
kind: ChangeKind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ApiDiff:
|
|
29
|
+
"""The complete difference between two API surfaces.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
added: Public names present in head but not in base.
|
|
33
|
+
removed: Public names present in base but not in head.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
added: tuple[NameChange, ...]
|
|
37
|
+
removed: tuple[NameChange, ...]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_breaking(self) -> bool:
|
|
41
|
+
"""Return True if any public names were removed."""
|
|
42
|
+
return bool(self.removed)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def has_additions(self) -> bool:
|
|
46
|
+
"""Return True if any public names were added."""
|
|
47
|
+
return bool(self.added)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_empty(self) -> bool:
|
|
51
|
+
"""Return True if no public names were added or removed."""
|
|
52
|
+
return not self.added and not self.removed
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class DiffractResult:
|
|
57
|
+
"""The complete output of a diffract check.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
commit_type: The conventional-commit type inferred from the diff.
|
|
61
|
+
summary: A human-readable description of the changes.
|
|
62
|
+
diff: The full API diff between base and head.
|
|
63
|
+
base_ref: The earlier git ref that was compared.
|
|
64
|
+
head_ref: The later git ref that was compared.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
commit_type: CommitType
|
|
68
|
+
summary: str
|
|
69
|
+
diff: ApiDiff
|
|
70
|
+
base_ref: str
|
|
71
|
+
head_ref: str
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, object]:
|
|
74
|
+
"""Return a JSON-serialisable representation of this result.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A dict with keys ``commit_type``, ``summary``, ``base_ref``,
|
|
78
|
+
``head_ref``, and ``diff`` (which contains ``added`` and
|
|
79
|
+
``removed`` lists of ``{"module": ..., "name": ...}`` dicts).
|
|
80
|
+
"""
|
|
81
|
+
return {
|
|
82
|
+
"commit_type": str(self.commit_type),
|
|
83
|
+
"summary": self.summary,
|
|
84
|
+
"base_ref": self.base_ref,
|
|
85
|
+
"head_ref": self.head_ref,
|
|
86
|
+
"diff": {
|
|
87
|
+
"added": [{"module": c.module, "name": c.name} for c in self.diff.added],
|
|
88
|
+
"removed": [{"module": c.module, "name": c.name} for c in self.diff.removed],
|
|
89
|
+
},
|
|
90
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sheridan-diffract
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: A short description of your project
|
|
5
|
+
Project-URL: Repository, https://github.com/andrewasheridan/diffract
|
|
6
|
+
Project-URL: Issues, https://github.com/andrewasheridan/diffract/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/andrewasheridan/diffract/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: sheridan <andrewasheridan@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Requires-Dist: gitpython
|
|
13
|
+
Requires-Dist: sheridan-iceberg>=2.0.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: bandit[toml]>=1.9.4; extra == 'dev'
|
|
16
|
+
Requires-Dist: commitizen>=4.13.9; extra == 'dev'
|
|
17
|
+
Requires-Dist: mkdocstrings[python]>=1.0.3; extra == 'dev'
|
|
18
|
+
Requires-Dist: mypy>=1.19.1; extra == 'dev'
|
|
19
|
+
Requires-Dist: pre-commit>=4.5.1; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=9.0.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.15.7; extra == 'dev'
|
|
24
|
+
Requires-Dist: zensical; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# sheridan-diffract
|
|
28
|
+
|
|
29
|
+
**Detects how a Python package's public API changed between two git commits and classifies that change as a conventional commit type.**
|
|
30
|
+
|
|
31
|
+
Most semver tools trust the developer to classify their own changes correctly. `diffract` verifies that classification against the actual diff — catching the common case where someone writes `fix:` but actually removed a public function.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
1. Uses [sheridan-iceberg](https://github.com/andrewasheridan/iceberg) to extract the public API surface at two points in git history
|
|
36
|
+
2. Diffs those two surfaces to find what was added or removed
|
|
37
|
+
3. Maps the diff to a conventional commit classification:
|
|
38
|
+
|
|
39
|
+
| Change | Commit type |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Public name removed | `feat!` (breaking) |
|
|
42
|
+
| Public name added | `feat` |
|
|
43
|
+
| Only internal/private changes | `refactor` |
|
|
44
|
+
| No changes detected | `fix` |
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install sheridan-diffract
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### CLI
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Compare the last two commits (default)
|
|
58
|
+
diffract
|
|
59
|
+
|
|
60
|
+
# Compare specific refs
|
|
61
|
+
diffract HEAD~3 HEAD
|
|
62
|
+
|
|
63
|
+
# Custom source directory (default: src/)
|
|
64
|
+
diffract --src lib/
|
|
65
|
+
|
|
66
|
+
# Emit JSON for CI consumption
|
|
67
|
+
diffract --json
|
|
68
|
+
|
|
69
|
+
# Exit non-zero if a breaking change is detected (for CI gates)
|
|
70
|
+
diffract --exit-code
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Exit codes with `--exit-code`:**
|
|
74
|
+
- `0` — no API surface changes
|
|
75
|
+
- `1` — breaking change (public name removed)
|
|
76
|
+
- `2` — non-breaking API change (public name added)
|
|
77
|
+
- `3` — error (git or surface extraction failure)
|
|
78
|
+
|
|
79
|
+
**Example output:**
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
Detected: feat! (breaking change)
|
|
83
|
+
|
|
84
|
+
Removed public names:
|
|
85
|
+
sheridan.diffract.enums:
|
|
86
|
+
- OldHelper
|
|
87
|
+
|
|
88
|
+
Suggested commit prefix: feat!:
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Detected: fix
|
|
93
|
+
|
|
94
|
+
No public API changes detected.
|
|
95
|
+
|
|
96
|
+
Suggested commit prefix: fix:
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Scopes are passed through to the suggestion — if your commit message includes `(parser)`, the suggested prefix will too:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Suggested commit prefix: feat(parser):
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Python API
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from sheridan.diffract import check
|
|
109
|
+
|
|
110
|
+
result = check(base_ref="v1.2.0", head_ref="HEAD")
|
|
111
|
+
print(result.commit_type) # e.g. "feat!"
|
|
112
|
+
print(result.summary) # human-readable description
|
|
113
|
+
print(result.diff.removed) # tuple of NameChange objects
|
|
114
|
+
print(result.diff.added)
|
|
115
|
+
|
|
116
|
+
# JSON-serialisable dict for CI output
|
|
117
|
+
import json
|
|
118
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
To compare `HEAD` against the **staging area** (what the next commit will contain) — the same comparison the pre-commit hook uses:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from sheridan.diffract import check_staged
|
|
125
|
+
|
|
126
|
+
result = check_staged()
|
|
127
|
+
print(result.commit_type) # reflects what is staged, not the last commit
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Validate commit messages with pre-commit
|
|
131
|
+
|
|
132
|
+
`diffract` ships a `commit-msg` hook that rejects commits whose conventional commit type doesn't match the detected API change — catching `fix:` when you actually removed a public name.
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# .pre-commit-config.yaml
|
|
136
|
+
repos:
|
|
137
|
+
- repo: https://github.com/sheridan/diffract
|
|
138
|
+
rev: v<VERSION>
|
|
139
|
+
hooks:
|
|
140
|
+
- id: diffract-validate
|
|
141
|
+
```
|
|
142
|
+
- make sure to `pre-commit install --hook-type commit-msg` first
|
|
143
|
+
|
|
144
|
+
The hook compares `HEAD` against the **git staging area** (not `HEAD~1 → HEAD`), so it correctly detects what is about to be committed rather than what was committed last. Explicit `BASE`/`HEAD` refs can be added to override this (see the GitHub Actions example below).
|
|
145
|
+
|
|
146
|
+
Scopes are preserved in all output — if you write `fix(parser): …`, the mismatch message will show `fix(parser):` as written and `feat(parser):` as the suggested replacement:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
diffract: commit type mismatch
|
|
150
|
+
written: fix(parser):
|
|
151
|
+
detected: feat(parser):
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Non-conventional commit types (`docs:`, `chore:`, `test:`, etc.) are never blocked.
|
|
155
|
+
|
|
156
|
+
### Configuration
|
|
157
|
+
|
|
158
|
+
If your source code isn't in `src/`, tell diffract once in a config file rather than repeating it on every command:
|
|
159
|
+
|
|
160
|
+
**`diffract.toml`** (takes precedence):
|
|
161
|
+
```toml
|
|
162
|
+
src = "python/src"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**`pyproject.toml`**:
|
|
166
|
+
```toml
|
|
167
|
+
[tool.diffract]
|
|
168
|
+
src = "python/src"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Priority: explicit `--src` flag → `diffract.toml` → `pyproject.toml` → default (`src/`).
|
|
172
|
+
|
|
173
|
+
### As a GitHub Actions check
|
|
174
|
+
|
|
175
|
+
Validate the **PR title** against detected API changes — the CI equivalent of the pre-commit hook.
|
|
176
|
+
The refs to diff are the PR branch HEAD versus the target branch HEAD:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
# .github/workflows/diffract.yml
|
|
180
|
+
name: diffract
|
|
181
|
+
|
|
182
|
+
on:
|
|
183
|
+
pull_request:
|
|
184
|
+
types: [opened, synchronize, reopened, edited] # 'edited' catches title-only changes
|
|
185
|
+
|
|
186
|
+
jobs:
|
|
187
|
+
api-change-check:
|
|
188
|
+
name: Validate PR title against API changes
|
|
189
|
+
runs-on: ubuntu-latest
|
|
190
|
+
steps:
|
|
191
|
+
- uses: actions/checkout@v4
|
|
192
|
+
with:
|
|
193
|
+
fetch-depth: 0 # required to access both commit SHAs
|
|
194
|
+
|
|
195
|
+
- name: Install diffract
|
|
196
|
+
run: pip install sheridan-diffract
|
|
197
|
+
|
|
198
|
+
- name: Validate PR title type matches API change
|
|
199
|
+
env:
|
|
200
|
+
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
201
|
+
run: |
|
|
202
|
+
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
|
|
203
|
+
diffract \
|
|
204
|
+
${{ github.event.pull_request.base.sha }} \
|
|
205
|
+
${{ github.event.pull_request.head.sha }} \
|
|
206
|
+
--validate-msg-file /tmp/pr-title.txt
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `base.sha` — HEAD of the target branch (e.g. `main`) at the time of the PR event
|
|
210
|
+
- `head.sha` — HEAD of the PR branch
|
|
211
|
+
- Non-conventional title prefixes (`docs:`, `chore:`, `test:`, etc.) are never blocked
|
|
212
|
+
- Scopes are passed through: a title of `feat(parser): …` will suggest `feat(parser):` on mismatch
|
|
213
|
+
|
|
214
|
+
## What it does not do
|
|
215
|
+
|
|
216
|
+
- `diffract` has **no AST logic of its own**. It delegates all API surface extraction to `sheridan-iceberg`.
|
|
217
|
+
- It compares public names only (additions and removals). **Signature changes** (argument renames, return type changes) are not currently detected — iceberg returns name lists, not signatures.
|
|
218
|
+
- It does not modify commits or rewrite history. It only reports.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
task install # install all dependencies
|
|
224
|
+
task check # lint + format + typecheck + tests (must all pass)
|
|
225
|
+
task test # pytest with coverage (≥90% required)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
sheridan/diffract/__init__.py,sha256=W3_ros8is0HZCC923q0IYzBXLNznNbvRhHvUvJJM38A,903
|
|
2
|
+
sheridan/diffract/checker.py,sha256=eIfAke2J3JCS8tgPKeNWQPXO1s_65sxpSOM8AnDhFI0,3595
|
|
3
|
+
sheridan/diffract/classifier.py,sha256=dL32A1-vtLxF23fxXP1Y0d-nLuj99DqOg4WRYvbCwNE,2473
|
|
4
|
+
sheridan/diffract/cli.py,sha256=ntp5gOhsBA0bVHxYxVQ1EPdjmNndSGt9qd0XP5izf90,10762
|
|
5
|
+
sheridan/diffract/config.py,sha256=SVpXC5C_h2U1yXWOdQsGkA2dHlUxWXyXgnvagbplzCg,3046
|
|
6
|
+
sheridan/diffract/differ.py,sha256=LhoHUVuwJ1Hw4Ve7q1h_MJYU6XwR514m7ELgNoeQibU,1624
|
|
7
|
+
sheridan/diffract/enums.py,sha256=p8UZ0g8StcXPKCxPgByhXI4OGHP-fph9hVtcCuVjF0c,838
|
|
8
|
+
sheridan/diffract/exceptions.py,sha256=oFjWLIJNcCyChpIjE8rseEe5lS18b4X7qVOsFc_IX0w,442
|
|
9
|
+
sheridan/diffract/git_utils.py,sha256=UXED4NbH9HXYiFkzN_vHeLlUq4ek4k_xAzP3MDaX3yM,5867
|
|
10
|
+
sheridan/diffract/models.py,sha256=DHhP5ddl8LQCHZ4AYH8cFwkx0SXk8Bfy_8hdf6aJt2o,2672
|
|
11
|
+
sheridan/diffract/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
sheridan_diffract-1.0.1.dist-info/METADATA,sha256=W3wzr4YL4ni1w-RxX20CFKOIBi4v4CyUoeVF9e8Uu28,7132
|
|
13
|
+
sheridan_diffract-1.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
sheridan_diffract-1.0.1.dist-info/entry_points.txt,sha256=8h6QCl44rTMcNA0XvYf4lAWyg9ZJR2QG6vxvrFMURhY,56
|
|
15
|
+
sheridan_diffract-1.0.1.dist-info/licenses/LICENSE,sha256=1PPxoxjYTZ-Folpvd8k2L7IRTHqgjLGNxjpUEElePOo,1065
|
|
16
|
+
sheridan_diffract-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sheridan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|