lint-roller 0.1.0__tar.gz

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.
Files changed (33) hide show
  1. lint_roller-0.1.0/LICENSE +17 -0
  2. lint_roller-0.1.0/PKG-INFO +31 -0
  3. lint_roller-0.1.0/README.rst +13 -0
  4. lint_roller-0.1.0/pyproject.toml +194 -0
  5. lint_roller-0.1.0/setup.cfg +4 -0
  6. lint_roller-0.1.0/src/lint_roller/__init__.py +0 -0
  7. lint_roller-0.1.0/src/lint_roller/cli.py +334 -0
  8. lint_roller-0.1.0/src/lint_roller/diff.py +83 -0
  9. lint_roller-0.1.0/src/lint_roller/flake8.py +240 -0
  10. lint_roller-0.1.0/src/lint_roller/git.py +162 -0
  11. lint_roller-0.1.0/src/lint_roller/lint.py +325 -0
  12. lint_roller-0.1.0/src/lint_roller/py.typed +0 -0
  13. lint_roller-0.1.0/src/lint_roller/ruff.py +616 -0
  14. lint_roller-0.1.0/src/lint_roller/tests/__init__.py +0 -0
  15. lint_roller-0.1.0/src/lint_roller/tests/conftest.py +78 -0
  16. lint_roller-0.1.0/src/lint_roller/tests/helpers.py +78 -0
  17. lint_roller-0.1.0/src/lint_roller/tests/test_cli.py +180 -0
  18. lint_roller-0.1.0/src/lint_roller/tests/test_diff.py +325 -0
  19. lint_roller-0.1.0/src/lint_roller/tests/test_flake8.py +108 -0
  20. lint_roller-0.1.0/src/lint_roller/tests/test_git.py +42 -0
  21. lint_roller-0.1.0/src/lint_roller/tests/test_lint.py +22 -0
  22. lint_roller-0.1.0/src/lint_roller/tests/test_ruff_check.py +523 -0
  23. lint_roller-0.1.0/src/lint_roller/tests/test_ruff_format.py +187 -0
  24. lint_roller-0.1.0/src/lint_roller/tests/test_toml.py +35 -0
  25. lint_roller-0.1.0/src/lint_roller/tests/test_workspace.py +22 -0
  26. lint_roller-0.1.0/src/lint_roller/toml.py +72 -0
  27. lint_roller-0.1.0/src/lint_roller/workspace.py +183 -0
  28. lint_roller-0.1.0/src/lint_roller.egg-info/PKG-INFO +31 -0
  29. lint_roller-0.1.0/src/lint_roller.egg-info/SOURCES.txt +31 -0
  30. lint_roller-0.1.0/src/lint_roller.egg-info/dependency_links.txt +1 -0
  31. lint_roller-0.1.0/src/lint_roller.egg-info/entry_points.txt +2 -0
  32. lint_roller-0.1.0/src/lint_roller.egg-info/requires.txt +11 -0
  33. lint_roller-0.1.0/src/lint_roller.egg-info/top_level.txt +1 -0
@@ -0,0 +1,17 @@
1
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
2
+ this software and associated documentation files (the "Software"), to deal in
3
+ the Software without restriction, including without limitation the rights to
4
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
5
+ of the Software, and to permit persons to whom the Software is furnished to do
6
+ so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all
9
+ copies or substantial portions of the Software.
10
+
11
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17
+ SOFTWARE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: lint-roller
3
+ Version: 0.1.0
4
+ Summary: Check changes in a code repository for linting violations to iteratively improve them.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.14
7
+ Description-Content-Type: text/x-rst
8
+ License-File: LICENSE
9
+ Requires-Dist: attrs
10
+ Requires-Dist: click
11
+ Requires-Dist: colorama; platform_system == "Windows"
12
+ Requires-Dist: match_diff_lines>=0.3.0
13
+ Requires-Dist: structlog
14
+ Requires-Dist: wcmatch
15
+ Provides-Extra: tracebacks
16
+ Requires-Dist: rich; extra == "tracebacks"
17
+ Dynamic: license-file
18
+
19
+ ===========
20
+ lint-roller
21
+ ===========
22
+
23
+
24
+ Changelog
25
+ =========
26
+
27
+ 0.1.0 - 2026-03-31
28
+ ------------------
29
+
30
+ * Initial release.
31
+ [fschulze]
@@ -0,0 +1,13 @@
1
+ ===========
2
+ lint-roller
3
+ ===========
4
+
5
+
6
+ Changelog
7
+ =========
8
+
9
+ 0.1.0 - 2026-03-31
10
+ ------------------
11
+
12
+ * Initial release.
13
+ [fschulze]
@@ -0,0 +1,194 @@
1
+ [project]
2
+ name = "lint-roller"
3
+ version = "0.1.0"
4
+ description = "Check changes in a code repository for linting violations to iteratively improve them."
5
+ readme = "README.rst"
6
+ requires-python = ">=3.14"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = [
10
+ "attrs",
11
+ "click",
12
+ "colorama;platform_system=='Windows'",
13
+ "match_diff_lines>=0.3.0",
14
+ "structlog",
15
+ "wcmatch",
16
+ ]
17
+
18
+ [project.scripts]
19
+ lint-roller = "lint_roller.cli:cli"
20
+
21
+ [project.optional-dependencies]
22
+ tracebacks = ["rich"]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "PdbEditorSupport",
27
+ "rich",
28
+ { include-group = "coverage" },
29
+ { include-group = "flake8" },
30
+ { include-group = "mypy" },
31
+ { include-group = "pytest" },
32
+ { include-group = "tombi" },
33
+ { include-group = "tox" },
34
+ ]
35
+ coverage = [
36
+ "coverage",
37
+ ]
38
+ flake8 = [
39
+ "flake8",
40
+ ]
41
+ mypy = [
42
+ "mypy",
43
+ { include-group = "pytest" },
44
+ ]
45
+ pytest = [
46
+ "pytest",
47
+ "pytest-asyncio",
48
+ "tomli-w",
49
+ ]
50
+ ruff = [
51
+ "ruff",
52
+ ]
53
+ tombi = [
54
+ "tombi",
55
+ ]
56
+ tox = [
57
+ "tox",
58
+ "tox-uv",
59
+ ]
60
+
61
+ [build-system]
62
+ requires = ["setuptools"]
63
+
64
+ [tool.coverage.run]
65
+ branch = true
66
+ parallel = true
67
+ relative_files = true
68
+ source_pkgs = ["lint_roller"]
69
+ source = [
70
+ "src/lint_roller/tests",
71
+ ]
72
+
73
+ [tool.lint-roller]
74
+ start-rev = "main"
75
+
76
+ [tool.pytest]
77
+ addopts = [
78
+ "-W error::DeprecationWarning",
79
+ "-W once::ResourceWarning",
80
+ "-r a",
81
+ ]
82
+ asyncio_mode = "auto"
83
+
84
+ [tool.ruff.lint]
85
+ select = ["ALL"]
86
+ ignore = [
87
+ "COM812", # missing-trailing-comma - disabled for ruff format
88
+ "D1", # undocumented-*
89
+ "D203", # incorrect-blank-line-before-class
90
+ "D213", # multi-line-summary-second-line
91
+ "E501", # line-too-long
92
+ "PLR2004", # magic-value-comparison
93
+ "SLF001", # private-member-access
94
+ "TID252", # flake8-tidy-imports.ban-relative-imports
95
+ ]
96
+
97
+ [tool.ruff.lint.per-file-ignores]
98
+ "src/lint_roller/tests/*.py" = [
99
+ "INP001", # implicit-namespace-package
100
+ "PLC0415", # import-outside-top-level
101
+ "PLR0913", # too-many-arguments
102
+ "PLR0915", # too-many-statements
103
+ "S101", # assert
104
+ ]
105
+
106
+ [tool.ruff.lint.isort]
107
+ case-sensitive = true
108
+ force-single-line = true
109
+ from-first = true
110
+ lines-after-imports = 2
111
+ no-sections = true
112
+ order-by-type = false
113
+
114
+ [tool.setuptools]
115
+ package-dir = { "" = "src" }
116
+
117
+ [tool.setuptools.packages.find]
118
+ where = ["src"]
119
+
120
+ [tool.tombi.format.rules]
121
+ indent-width = 4
122
+
123
+ [tool.tombi.lint.rules]
124
+ key-empty = "off"
125
+
126
+ [tool.tox]
127
+ env_list = [
128
+ "clean",
129
+ "coverage_combine",
130
+ "coverage_report",
131
+ "flake8",
132
+ "format",
133
+ "mypy",
134
+ { product = [{ prefix = "py3", start = 14 }] },
135
+ "ruff",
136
+ "tombi-check",
137
+ "tombi-format",
138
+ ]
139
+
140
+ [tool.tox.env_run_base]
141
+ base_python = ["python3.14"]
142
+ dependency_groups = ["coverage", "pytest"]
143
+ commands = [["coverage", "run", "-m", "pytest"]]
144
+ depends = ["clean"]
145
+
146
+ [tool.tox.env.clean]
147
+ dependency_groups = ["coverage"]
148
+ commands = [["coverage", "erase"]]
149
+ skip_install = true
150
+
151
+ [tool.tox.env.coverage_combine]
152
+ dependency_groups = ["coverage"]
153
+ commands = [["coverage", "combine"]]
154
+ depends = ["py3*"]
155
+ skip_install = true
156
+
157
+ [tool.tox.env.coverage_report]
158
+ dependency_groups = ["coverage"]
159
+ commands = [
160
+ ["coverage", "html"],
161
+ ["coverage", "report", "--fail-under=100"],
162
+ ]
163
+ depends = ["coverage_combine"]
164
+ parallel_show_output = true
165
+ skip_install = true
166
+
167
+ [tool.tox.env.flake8]
168
+ dependency_groups = ["flake8"]
169
+ commands = [["flake8"]]
170
+ skip_install = true
171
+
172
+ [tool.tox.env.format]
173
+ dependency_groups = ["ruff"]
174
+ commands = [["ruff", "format", "--check"]]
175
+ skip_install = true
176
+
177
+ [tool.tox.env.mypy]
178
+ dependency_groups = ["mypy"]
179
+ commands = [["mypy"]]
180
+
181
+ [tool.tox.env.ruff]
182
+ dependency_groups = ["ruff"]
183
+ commands = [["ruff", "check"]]
184
+ skip_install = true
185
+
186
+ [tool.tox.env.tombi-check]
187
+ dependency_groups = ["tombi"]
188
+ commands = [["tombi", "check", "--error-on-warnings", "--quiet"]]
189
+ skip_install = true
190
+
191
+ [tool.tox.env.tombi-format]
192
+ dependency_groups = ["tombi"]
193
+ commands = [["tombi", "format", "--check", "--diff", "--quiet"]]
194
+ skip_install = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ from .lint import BaseOutput
4
+ from .lint import LintResults
5
+ from .lint import LintResultsCoroutine
6
+ from .workspace import Workspace
7
+ from abc import abstractmethod
8
+ from attrs import define
9
+ from functools import cached_property
10
+ from functools import wraps
11
+ from pathlib import Path
12
+ from subprocess import CalledProcessError
13
+ from traceback import format_exception_only
14
+ from typing import TYPE_CHECKING
15
+ import asyncio
16
+ import click
17
+ import logging
18
+ import structlog
19
+ import sys
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Awaitable
24
+ from collections.abc import Callable
25
+ from collections.abc import Iterator
26
+ from typing import Any
27
+
28
+
29
+ log = structlog.stdlib.get_logger()
30
+
31
+
32
+ def checkers_options[**P, R](f: Callable[P, R]) -> Callable[P, R]:
33
+ @click.option(
34
+ "--default-checks/--no-default-checks",
35
+ "run_default_checks",
36
+ default=None,
37
+ help="Run/omit default checks.",
38
+ )
39
+ @click.option(
40
+ "--flake8/--no-flake8",
41
+ "run_flake8",
42
+ default=None,
43
+ help="Run/omit flake8 checks.",
44
+ )
45
+ @click.option(
46
+ "--ruff-check/--no-ruff-check",
47
+ "run_ruff_check",
48
+ default=None,
49
+ help="Run/omit ruff check checks.",
50
+ )
51
+ @click.option(
52
+ "--ruff-format/--no-ruff-format",
53
+ "run_ruff_format",
54
+ default=None,
55
+ help="Run/omit ruff format checks.",
56
+ )
57
+ @wraps(f)
58
+ def wrapper_checkers_options(*args: P.args, **kwargs: P.kwargs) -> R:
59
+ return f(*args, **kwargs)
60
+
61
+ return wrapper_checkers_options
62
+
63
+
64
+ def common_output_options[**P, R](f: Callable[P, R]) -> Callable[P, R]:
65
+ @click.option(
66
+ "--show-full-diff/--hide-full-diff",
67
+ default=False,
68
+ show_default=True,
69
+ help="Show the full diff",
70
+ )
71
+ @click.option(
72
+ "--show-full-diff-ranges/--hide-full-diff-ranges",
73
+ default=False,
74
+ show_default=True,
75
+ help="Show line ranges of diff",
76
+ )
77
+ @click.option(
78
+ "--show-start-rev/--hide-start-rev",
79
+ default=True,
80
+ show_default=True,
81
+ help="Show determined start rev",
82
+ )
83
+ @click.option(
84
+ "--show-tool-versions/--hide-tool-versions",
85
+ default=False,
86
+ show_default=True,
87
+ help="Show versions of used tools",
88
+ )
89
+ @click.option("-v", "--verbose", count=True, help="Enables verbose mode")
90
+ @wraps(f)
91
+ def wrapper_common_output_options(*args: P.args, **kwargs: P.kwargs) -> R:
92
+ return f(*args, **kwargs)
93
+
94
+ return wrapper_common_output_options
95
+
96
+
97
+ def start_rev_option[**P, R](f: Callable[P, R]) -> Callable[P, R]:
98
+ @click.option(
99
+ "--start-rev",
100
+ metavar="REV",
101
+ help="The commit or symbolic name to check against. [default: main]",
102
+ )
103
+ @wraps(f)
104
+ def wrapper_start_rev_option(*args: P.args, **kwargs: P.kwargs) -> R:
105
+ return f(*args, **kwargs)
106
+
107
+ return wrapper_start_rev_option
108
+
109
+
110
+ @click.group()
111
+ def cli() -> None: ...
112
+
113
+
114
+ @define(kw_only=True)
115
+ class Output(BaseOutput):
116
+ cli_show_full_diff: bool
117
+ cli_show_full_diff_ranges: bool
118
+ cli_show_start_rev: bool
119
+ cli_show_tool_versions: bool
120
+ verbose: int
121
+
122
+ def _iter_exceptions(self, e: BaseException) -> Iterator[BaseException]:
123
+ if isinstance(e, (BaseExceptionGroup, ExceptionGroup)):
124
+ for se in e.exceptions:
125
+ yield from self._iter_exceptions(se)
126
+ else:
127
+ yield e
128
+
129
+ def _log_exception(self, msg: str, e: BaseException) -> None:
130
+ if self.log_for(logging.INFO):
131
+ log.exception("%s:", msg, exc_info=e)
132
+ else:
133
+ log.error(
134
+ "%s (use -v for more details): %s",
135
+ msg,
136
+ "".join(format_exception_only(e)).rstrip(),
137
+ )
138
+ if isinstance(e, CalledProcessError):
139
+ if e.stdout: # pragma: no cover
140
+ click.echo(e.stdout)
141
+ if e.stderr: # pragma: no cover
142
+ click.echo(e.stderr)
143
+
144
+ def log_exception(self, e: BaseException) -> None:
145
+ for se in self._iter_exceptions(e):
146
+ msg = (
147
+ "A sub-process call failed"
148
+ if isinstance(se, CalledProcessError)
149
+ else "Exception"
150
+ )
151
+ self._log_exception(msg, se)
152
+
153
+ def log_for(self, level: int) -> bool:
154
+ return self.log_level <= level
155
+
156
+ @cached_property
157
+ def log_level(self) -> int:
158
+ if not self.verbose:
159
+ return logging.WARNING
160
+ if self.verbose > 1:
161
+ return logging.DEBUG
162
+ return logging.INFO
163
+
164
+ def log_start_rev(self, workspace: Workspace) -> None:
165
+ if self.show_start_rev():
166
+ click.echo(
167
+ f"Checking commits from {workspace.commit_hash} ({workspace.commit_names}) in {Path().absolute()}"
168
+ )
169
+
170
+ def log_versions(self, workspace: Workspace) -> None:
171
+ if self.show_tool_versions():
172
+ click.echo(f"git {workspace.git.version}")
173
+
174
+ async def log_full_python_diff(self, workspace: Workspace) -> None:
175
+ if self.show_full_diff():
176
+ click.echo((await workspace.full_python_diff_task).colored())
177
+ elif self.show_full_diff_ranges():
178
+ click.echo((await workspace.full_python_diff_task).format_ranges())
179
+
180
+ def show_fixes_for(self, _kind: str, /) -> bool:
181
+ return True
182
+
183
+ @property
184
+ def show_format_trigger_lines(self) -> bool:
185
+ return bool(self.verbose)
186
+
187
+ def show_full_diff(self) -> bool:
188
+ return self.cli_show_full_diff or self.log_for(logging.DEBUG)
189
+
190
+ def show_full_diff_ranges(self) -> bool:
191
+ return self.cli_show_full_diff_ranges or self.log_for(logging.INFO)
192
+
193
+ def show_start_rev(self) -> bool:
194
+ return self.cli_show_start_rev or self.log_for(logging.INFO)
195
+
196
+ def show_tool_versions(self) -> bool:
197
+ return self.cli_show_tool_versions or self.log_for(logging.INFO)
198
+
199
+
200
+ @define(kw_only=True)
201
+ class BaseCommand[C: Awaitable[Any], R]:
202
+ output: Output
203
+ flake8: bool
204
+ ruff_check: bool
205
+ ruff_format: bool
206
+ workspace: Workspace
207
+
208
+ @abstractmethod
209
+ def finish(self) -> None: ...
210
+
211
+ @abstractmethod
212
+ def get_tasks(self) -> list[C]: ...
213
+
214
+ @abstractmethod
215
+ def process_results(self, results: R) -> None: ...
216
+
217
+ async def run_tasks(self, tasks: list[C]) -> bool:
218
+ exceptions = []
219
+ task_failed = False
220
+ output = self.output
221
+ for task in tasks:
222
+ try:
223
+ results = await task
224
+ except CalledProcessError as e:
225
+ output._log_exception("Task failed", e)
226
+ task_failed = True
227
+ except BaseException as e: # noqa: BLE001
228
+ exceptions.append(e)
229
+ else:
230
+ self.process_results(results)
231
+ if exceptions:
232
+ msg = "Unhandled exceptions in run_tasks"
233
+ raise BaseExceptionGroup(msg, exceptions)
234
+ return task_failed
235
+
236
+ async def run(self) -> None:
237
+ workspace = self.workspace
238
+ output = self.output
239
+ await output.log_full_python_diff(workspace)
240
+ tasks = self.get_tasks()
241
+ if not tasks:
242
+ log.error("No tasks selected.")
243
+ sys.exit(5)
244
+ task_failed = await self.run_tasks(tasks)
245
+ if task_failed:
246
+ sys.exit(6)
247
+ self.finish()
248
+
249
+
250
+ def run_linter(*, linter: bool | None, default: bool | None) -> bool:
251
+ if (default or default is None) and linter is None:
252
+ return True
253
+ return bool(linter)
254
+
255
+
256
+ class CheckCommand(BaseCommand[LintResultsCoroutine, LintResults]):
257
+ failed = False
258
+
259
+ def finish(self) -> None:
260
+ if self.failed:
261
+ sys.exit(4)
262
+
263
+ def get_tasks(self) -> list[LintResultsCoroutine]:
264
+ tasks: list[LintResultsCoroutine] = []
265
+ if self.ruff_format:
266
+ tasks.extend(self.workspace.ruff_format.check_tasks())
267
+ if self.ruff_check:
268
+ tasks.extend(self.workspace.ruff_check.check_tasks())
269
+ if self.flake8:
270
+ tasks.extend(self.workspace.flake8.check_tasks())
271
+ return tasks
272
+
273
+ def process_results(self, results: LintResults) -> None:
274
+ self.failed = self.failed or results.failed
275
+ for result in results:
276
+ click.echo(result.format(self.output))
277
+
278
+
279
+ @cli.command()
280
+ @click.argument("rev", envvar="LINT_ROLLER_START_REV", required=False)
281
+ @checkers_options
282
+ @common_output_options
283
+ @start_rev_option
284
+ def check( # noqa: PLR0913
285
+ *,
286
+ rev: str | None,
287
+ show_full_diff: bool,
288
+ show_full_diff_ranges: bool,
289
+ show_start_rev: bool,
290
+ show_tool_versions: bool,
291
+ start_rev: str | None,
292
+ verbose: int,
293
+ run_default_checks: bool | None,
294
+ run_ruff_check: bool | None,
295
+ run_flake8: bool | None,
296
+ run_ruff_format: bool | None,
297
+ ) -> None:
298
+ """Check for any violations.
299
+
300
+ The optional REV argument is the commit hash or symbolic name from which the git diff is taken.
301
+ """
302
+ output = Output(
303
+ cli_show_full_diff=show_full_diff,
304
+ cli_show_full_diff_ranges=show_full_diff_ranges,
305
+ cli_show_start_rev=show_start_rev,
306
+ cli_show_tool_versions=show_tool_versions,
307
+ verbose=verbose,
308
+ )
309
+ workspace = Workspace(cli_rev=rev, cli_start_rev=start_rev)
310
+ structlog.configure(
311
+ wrapper_class=structlog.make_filtering_bound_logger(output.log_level),
312
+ )
313
+ try:
314
+ output.log_versions(workspace)
315
+ output.log_start_rev(workspace)
316
+ asyncio.run(
317
+ CheckCommand(
318
+ flake8=run_linter(linter=run_flake8, default=run_default_checks),
319
+ output=output,
320
+ ruff_check=run_linter(
321
+ linter=run_ruff_check, default=run_default_checks
322
+ ),
323
+ ruff_format=run_linter(
324
+ linter=run_ruff_format, default=run_default_checks
325
+ ),
326
+ workspace=workspace,
327
+ ).run()
328
+ )
329
+ except (CalledProcessError, ExceptionGroup) as e:
330
+ (se, *_oe) = output._iter_exceptions(e)
331
+ output.log_exception(se)
332
+ if isinstance(se, CalledProcessError):
333
+ sys.exit(3)
334
+ raise se from e
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from attrs import define
4
+ from click import style
5
+ from click import unstyle
6
+ from functools import cached_property
7
+ from match_diff_lines import parse_unified_diff as _parse_unified_diff
8
+ from typing import TYPE_CHECKING
9
+ import itertools
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterable
14
+ from collections.abc import Iterator
15
+ from pathlib import Path
16
+
17
+
18
+ @define(frozen=True)
19
+ class LineRange:
20
+ start: int
21
+ stop: int
22
+
23
+ def __iter__(self) -> Iterator[int]:
24
+ yield self.start
25
+ yield self.stop
26
+
27
+
28
+ def ranges(numbers: Iterable[int]) -> Iterator[LineRange]:
29
+ for _a, _b in itertools.groupby(
30
+ enumerate(sorted(numbers)), lambda pair: pair[1] - pair[0]
31
+ ):
32
+ b = list(_b)
33
+ yield LineRange(b[0][1], b[-1][1])
34
+
35
+
36
+ @define
37
+ class Diff:
38
+ base: Path
39
+ raw: str
40
+
41
+ def __str__(self) -> str:
42
+ return self.raw
43
+
44
+ def colored(self) -> str:
45
+ return "\n".join(
46
+ style(
47
+ line,
48
+ fg="green"
49
+ if line.startswith("+")
50
+ else "red"
51
+ if line.startswith("-")
52
+ else None,
53
+ )
54
+ for line in self.raw.splitlines()
55
+ )
56
+
57
+ def format_ranges(self) -> str:
58
+ base = self.base
59
+ result = []
60
+ for fn, _ranges in sorted(self.ranges.items()):
61
+ ranges = ", ".join(f"{s}" if s == e else f"{s}-{e}" for s, e in _ranges)
62
+ result.append(f"{style(fn.relative_to(base), bold=True)}: {ranges}")
63
+ return "\n".join(result)
64
+
65
+ @cached_property
66
+ def path_lines_map(self) -> dict[Path, frozenset[int]]:
67
+ parsed_diff = parse_unified_diff(self.raw.splitlines())
68
+ return {self.base / fn: line_nums for fn, line_nums in parsed_diff.items()}
69
+
70
+ @cached_property
71
+ def ranges(self) -> dict[Path, list[LineRange]]:
72
+ return {
73
+ fn: list(ranges(lines_nums))
74
+ for fn, lines_nums in self.path_lines_map.items()
75
+ if lines_nums
76
+ }
77
+
78
+
79
+ def parse_unified_diff(lines: Iterable[str]) -> dict[str, frozenset[int]]:
80
+ return {
81
+ k: frozenset(v)
82
+ for k, v in _parse_unified_diff(unstyle(line) for line in lines).items()
83
+ }