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.
@@ -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."
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ diffract = sheridan.diffract.cli:main
@@ -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.