1
0
Fork 0

Adding upstream version 0.5.0~b0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-08 16:25:10 +02:00
parent e1bbced648
commit c8516ea709
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
9 changed files with 988 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.idea
dunk/__pycache__
dist

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2025 Darren Burns
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# dunk
Pipe your `git diff` output into `dunk` to make it prettier!
![image](https://user-images.githubusercontent.com/5740731/162084469-718a8b48-a176-4657-961a-f45e157ff562.png)
> ⚠️ This project is **very** early stages - expect crashes, bugs, and confusing output!
## Quick Start
I recommend you install using `pipx`, which will allow you to use `dunk` from anywhere.
```
pipx install dunk
```
## Basic Usage
Pipe the output of `git diff` into `dunk`:
```
git diff | dunk
```
or add it to git as an alias:
```
git config --global alias.dunk '!git diff | dunk'
```
### Paging
You can pipe output from `dunk` into a pager such as `less`:
```
git diff | dunk | less -R
```

1
dunk/__init__.py Normal file
View file

@ -0,0 +1 @@
__version__ = "0.5.0b0"

520
dunk/dunk.py Normal file
View file

@ -0,0 +1,520 @@
import functools
import os
import sys
from collections import defaultdict
from difflib import SequenceMatcher
from pathlib import Path
from typing import Dict, List, cast, Iterable, Tuple, TypeVar, Optional, Set, NamedTuple
from rich.align import Align
from rich.color import blend_rgb, Color
from rich.color_triplet import ColorTriplet
from rich.console import Console
from rich.segment import Segment, SegmentLines
from rich.style import Style
from rich.syntax import Syntax
from rich.table import Table
from rich.text import Text
from rich.theme import Theme
from unidiff import PatchSet
from unidiff.patch import Hunk, Line, PatchedFile
import dunk
from dunk.renderables import (
PatchSetHeader,
RemovedFileBody,
BinaryFileBody,
PatchedFileHeader,
OnlyRenamedFileBody,
)
MONOKAI_LIGHT_ACCENT = Color.from_rgb(62, 64, 54).triplet.hex
MONOKAI_BACKGROUND = Color.from_rgb(red=39, green=40, blue=34)
DUNK_BG_HEX = "#0d0f0b"
MONOKAI_BG_HEX = MONOKAI_BACKGROUND.triplet.hex
T = TypeVar("T")
theme = Theme(
{
"hatched": f"{MONOKAI_BG_HEX} on {DUNK_BG_HEX}",
"renamed": f"cyan",
"border": MONOKAI_LIGHT_ACCENT,
}
)
force_width, _ = os.get_terminal_size(2)
console = Console(force_terminal=True, width=force_width, theme=theme)
def find_git_root() -> Path:
cwd = Path.cwd()
if (cwd / ".git").exists():
return Path.cwd()
for directory in cwd.parents:
if (directory / ".git").exists():
return directory
return cwd
#
class ContiguousStreak(NamedTuple):
"""A single hunk can have multiple streaks of additions/removals of different length"""
streak_row_start: int
streak_length: int
def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]:
"""Iterate and generate a tuple with a flag for first value."""
iter_values = iter(values)
try:
value = next(iter_values)
except StopIteration:
return
yield True, value
for value in iter_values:
yield False, value
def main():
try:
_run_dunk()
except BrokenPipeError:
# Python flushes standard streams on exit; redirect remaining output
# to devnull to avoid another BrokenPipeError at shutdown
devnull = os.open(os.devnull, os.O_WRONLY)
os.dup2(devnull, sys.stdout.fileno())
sys.exit(1)
except KeyboardInterrupt:
sys.exit(1)
def _run_dunk():
input = sys.stdin.readlines()
diff = "".join(input)
patch_set: PatchSet = PatchSet(diff)
project_root: Path = find_git_root()
console.print(
PatchSetHeader(
file_modifications=len(patch_set.modified_files),
file_additions=len(patch_set.added_files),
file_removals=len(patch_set.removed_files),
line_additions=patch_set.added,
line_removals=patch_set.removed,
)
)
for is_first, patch in loop_first(patch_set):
patch = cast(PatchedFile, patch)
console.print(PatchedFileHeader(patch))
if patch.is_removed_file:
console.print(RemovedFileBody())
continue
# The file wasn't removed, so we can open it.
target_file = project_root / patch.path
if patch.is_binary_file:
console.print(BinaryFileBody(size_in_bytes=target_file.stat().st_size))
continue
if patch.is_rename and not patch.added and not patch.removed:
console.print(OnlyRenamedFileBody(patch))
source_lineno = 1
target_lineno = 1
target_code = target_file.read_text()
target_lines = target_code.splitlines(keepends=True)
source_lineno_max = len(target_lines) - patch.added + patch.removed
source_hunk_cache: Dict[int, Hunk] = {hunk.source_start: hunk for hunk in patch}
source_reconstructed: List[str] = []
while source_lineno <= source_lineno_max:
hunk = source_hunk_cache.get(source_lineno)
if hunk:
# This line can be reconstructed in source from the hunk
lines = [line.value for line in hunk.source_lines()]
source_reconstructed.extend(lines)
source_lineno += hunk.source_length
target_lineno += hunk.target_length
else:
# The line isn't in the diff, pull over current target lines
target_line_index = target_lineno - 1
line = target_lines[target_line_index]
source_reconstructed.append(line)
source_lineno += 1
target_lineno += 1
source_code = "".join(source_reconstructed)
lexer = Syntax.guess_lexer(patch.path)
for is_first_hunk, hunk in loop_first(patch):
# Use difflib to examine differences between each line of the hunk
# Target essentially means the additions/green text in diff
target_line_range = (
hunk.target_start,
hunk.target_length + hunk.target_start - 1,
)
source_line_range = (
hunk.source_start,
hunk.source_length + hunk.source_start - 1,
)
source_syntax = Syntax(
source_code,
lexer=lexer,
line_range=source_line_range,
line_numbers=True,
indent_guides=True,
)
target_syntax = Syntax(
target_code,
lexer=lexer,
line_range=target_line_range,
line_numbers=True,
indent_guides=True,
)
source_removed_linenos = set()
target_added_linenos = set()
context_linenos = []
for line in hunk:
line = cast(Line, line)
if line.source_line_no and line.is_removed:
source_removed_linenos.add(line.source_line_no)
elif line.target_line_no and line.is_added:
target_added_linenos.add(line.target_line_no)
elif line.is_context:
context_linenos.append((line.source_line_no, line.target_line_no))
# To ensure that lines are aligned on the left and right in the split
# diff, we need to add some padding above the lines the amount of padding
# can be calculated by *changes* in the difference in offset between the
# source and target context line numbers. When a change occurs, we note
# how much the change was, and that's how much padding we need to add. If
# the change in source - target context line numbers is positive,
# we pad above the target. If it's negative, we pad above the source line.
source_lineno_to_padding = {}
target_lineno_to_padding = {}
first_source_context, first_target_context = next(
iter(context_linenos), (0, 0)
)
current_delta = first_source_context - first_target_context
for source_lineno, target_lineno in context_linenos:
delta = source_lineno - target_lineno
change_in_delta = current_delta - delta
pad_amount = abs(change_in_delta)
if change_in_delta > 0:
source_lineno_to_padding[source_lineno] = pad_amount
elif change_in_delta < 0:
target_lineno_to_padding[target_lineno] = pad_amount
current_delta = delta
# Track which source and target lines are aligned and should be intraline
# diffed Work out row number of lines in each side of the diff. Row
# number is how far from the top of the syntax snippet we are. A line in
# the source and target with the same row numbers will be aligned in the
# diff (their line numbers in the source code may be different, though).
# There can be gaps in row numbers too, since sometimes we add padding
# above rows to ensure the source and target diffs are aligned with each
# other.
# Map row numbers to lines
source_lines_by_row_index: Dict[int, Line] = {}
target_lines_by_row_index: Dict[int, Line] = {}
# We have to track the length of contiguous streaks of altered lines, as
# we can only provide intraline diffing to aligned streaks of identical
# length. If they are different lengths it is almost impossible to align
# the contiguous streaks without falling back to an expensive heuristic.
# If a source line and a target line map to equivalent ContiguousStreaks,
# then we can safely apply intraline highlighting to them.
source_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {}
accumulated_source_padding = 0
contiguous_streak_row_start = 0
contiguous_streak_length = 0
for i, line in enumerate(hunk.source_lines()):
if line.is_removed:
if contiguous_streak_length == 0:
contiguous_streak_row_start = i
contiguous_streak_length += 1
else:
# We've reached the end of the streak, so we'll associate all the
# lines in the streak with it for later lookup.
for row_index in range(
contiguous_streak_row_start,
contiguous_streak_row_start + contiguous_streak_length,
):
source_row_to_contiguous_streak_length[row_index] = (
ContiguousStreak(
streak_row_start=contiguous_streak_row_start,
streak_length=contiguous_streak_length,
)
)
contiguous_streak_length = 0
lineno = hunk.source_start + i
this_line_padding = source_lineno_to_padding.get(lineno, 0)
accumulated_source_padding += this_line_padding
row_number = i + accumulated_source_padding
source_lines_by_row_index[row_number] = line
# TODO: Factor out this code into a function, we're doing the same thing
# for all lines in both source and target hunks.
target_row_to_contiguous_streak_length: Dict[int, ContiguousStreak] = {}
accumulated_target_padding = 0
target_streak_row_start = 0
target_streak_length = 0
for i, line in enumerate(hunk.target_lines()):
if line.is_added:
if target_streak_length == 0:
target_streak_row_start = i
target_streak_length += 1
else:
for row_index in range(
target_streak_row_start,
target_streak_row_start + target_streak_length,
):
target_row_to_contiguous_streak_length[row_index] = (
ContiguousStreak(
streak_row_start=target_streak_row_start,
streak_length=target_streak_length,
)
)
target_streak_length = 0
lineno = hunk.target_start + i
this_line_padding = target_lineno_to_padding.get(lineno, 0)
accumulated_target_padding += this_line_padding
row_number = i + accumulated_target_padding
target_lines_by_row_index[row_number] = line
row_number_to_deletion_ranges = defaultdict(list)
row_number_to_insertion_ranges = defaultdict(list)
# Collect intraline diff info for highlighting
for row_number, source_line in source_lines_by_row_index.items():
source_streak = source_row_to_contiguous_streak_length.get(row_number)
target_streak = target_row_to_contiguous_streak_length.get(row_number)
# TODO: We need to work out the offsets to ensure that we look up
# the correct target and source row streaks to compare. Will probably
# need to append accumulated padding to row numbers
# print(padded_source_row, padded_target_row, source_line.value)
# if source_streak:
# print(f"sourcestreak {row_number}", source_streak)
# if target_streak:
# print(f"targetstreak {row_number}", target_streak)
intraline_enabled = (
source_streak is not None
and target_streak is not None
and source_streak.streak_length == target_streak.streak_length
)
if not intraline_enabled:
# print(f"skipping row {row_number}")
continue
target_line = target_lines_by_row_index.get(row_number)
are_diffable = (
source_line
and target_line
and source_line.is_removed
and target_line.is_added
)
if target_line and are_diffable:
matcher = SequenceMatcher(
None, source_line.value, target_line.value
)
opcodes = matcher.get_opcodes()
ratio = matcher.ratio()
if ratio > 0.5:
for tag, i1, i2, j1, j2 in opcodes:
if tag == "delete":
row_number_to_deletion_ranges[row_number].append(
(i1, i2)
)
elif tag == "insert":
row_number_to_insertion_ranges[row_number].append(
(j1, j2)
)
elif tag == "replace":
row_number_to_deletion_ranges[row_number].append(
(i1, i2)
)
row_number_to_insertion_ranges[row_number].append(
(j1, j2)
)
source_syntax_lines: List[List[Segment]] = console.render_lines(
source_syntax
)
target_syntax_lines = console.render_lines(target_syntax)
highlighted_source_lines = highlight_and_align_lines_in_hunk(
hunk.source_start,
source_removed_linenos,
source_syntax_lines,
ColorTriplet(255, 0, 0),
source_lineno_to_padding,
dict(row_number_to_deletion_ranges),
gutter_size=len(str(source_lineno_max)) + 2,
)
highlighted_target_lines = highlight_and_align_lines_in_hunk(
hunk.target_start,
target_added_linenos,
target_syntax_lines,
ColorTriplet(0, 255, 0),
target_lineno_to_padding,
dict(row_number_to_insertion_ranges),
gutter_size=len(str(len(target_lines) + 1)) + 2,
)
table = Table.grid()
table.add_column(style="on #0d0f0b")
table.add_column(style="on #0d0f0b")
table.add_row(
SegmentLines(highlighted_source_lines, new_lines=True),
SegmentLines(highlighted_target_lines, new_lines=True),
)
hunk_header_style = f"{MONOKAI_BACKGROUND.triplet.hex} on #0d0f0b"
hunk_header = (
f"[on #0d0f0b dim]@@ [red]-{hunk.source_start},{hunk.source_length}[/] "
f"[green]+{hunk.target_start},{hunk.target_length}[/] "
f"[dim]@@ {hunk.section_header or ''}[/]"
)
console.rule(hunk_header, characters="", style=hunk_header_style)
console.print(table)
# TODO: File name indicator at bottom of file, if diff is larger than terminal height.
console.rule(style="border", characters="")
console.print(
Align.right(
f"[blue]/[/][red]/[/][green]/[/] [dim]dunk {dunk.__version__}[/] "
)
)
# console.save_svg("dunk.svg", title="Diff output generated using Dunk")
def highlight_and_align_lines_in_hunk(
start_lineno: int,
highlight_linenos: Set[Optional[int]],
syntax_hunk_lines: List[List[Segment]],
blend_colour: ColorTriplet,
lines_to_pad_above: Dict[int, int],
highlight_ranges: Dict[int, Tuple[int, int]],
gutter_size: int,
):
highlighted_lines = []
# Apply diff-related highlighting to lines
for index, line in enumerate(syntax_hunk_lines):
lineno = index + start_lineno
if lineno in highlight_linenos:
new_line = []
segment_number = 0
for segment in line:
style: Style
text, style, control = segment
if style:
if style.bgcolor:
bgcolor_triplet = style.bgcolor.triplet
cross_fade = 0.85
new_bgcolour_triplet = blend_rgb_cached(
blend_colour, bgcolor_triplet, cross_fade=cross_fade
)
new_bgcolor = Color.from_triplet(new_bgcolour_triplet)
else:
new_bgcolor = None
if style.color and segment_number == 1:
new_triplet = blend_rgb_cached(
blend_rgb_cached(
blend_colour, style.color.triplet, cross_fade=0.5
),
ColorTriplet(255, 255, 255),
cross_fade=0.4,
)
new_color = Color.from_triplet(new_triplet)
else:
new_color = None
overlay_style = Style.from_color(
color=new_color, bgcolor=new_bgcolor
)
updated_style = style + overlay_style
new_line.append(Segment(text, updated_style, control))
else:
new_line.append(segment)
segment_number += 1
else:
new_line = line[:]
# Pad above the line if required
pad = lines_to_pad_above.get(lineno, 0)
for i in range(pad):
highlighted_lines.append(
[
Segment(
"" * console.width, Style.from_color(color=MONOKAI_BACKGROUND)
)
]
)
# Finally, apply the intraline diff highlighting for this line if possible
if index in highlight_ranges:
line_as_text = Text.assemble(
*((text, style) for text, style, control in new_line), end=""
)
intraline_bgcolor = Color.from_triplet(
blend_rgb_cached(
blend_colour, MONOKAI_BACKGROUND.triplet, cross_fade=0.6
)
)
intraline_color = Color.from_triplet(
blend_rgb_cached(
intraline_bgcolor.triplet,
Color.from_rgb(255, 255, 255).triplet,
cross_fade=0.8,
)
)
for start, end in highlight_ranges.get(index):
line_as_text.stylize(
Style.from_color(color=intraline_color, bgcolor=intraline_bgcolor),
start=start + gutter_size + 1,
end=end + gutter_size + 1,
)
new_line = list(console.render(line_as_text))
highlighted_lines.append(new_line)
return highlighted_lines
@functools.lru_cache(maxsize=128)
def blend_rgb_cached(
colour1: ColorTriplet, colour2: ColorTriplet, cross_fade: float = 0.6
) -> ColorTriplet:
return blend_rgb(colour1, colour2, cross_fade=cross_fade)
if __name__ == "__main__":
main()

134
dunk/renderables.py Normal file
View file

@ -0,0 +1,134 @@
from dataclasses import dataclass
from pathlib import Path
from rich.align import Align
from rich.console import Console
from rich.console import ConsoleOptions, RenderResult
from rich.markup import escape
from rich.rule import Rule
from rich.segment import Segment
from rich.table import Table
from rich.text import Text
from unidiff import PatchedFile
from dunk.underline_bar import UnderlineBar
def simple_pluralise(word: str, number: int) -> str:
if number == 1:
return word
else:
return word + "s"
@dataclass
class PatchSetHeader:
file_modifications: int
file_additions: int
file_removals: int
line_additions: int
line_removals: int
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
if self.file_modifications:
yield Align.center(
f"[blue]{self.file_modifications} {simple_pluralise('file', self.file_modifications)} changed"
)
if self.file_additions:
yield Align.center(
f"[green]{self.file_additions} {simple_pluralise('file', self.file_additions)} added"
)
if self.file_removals:
yield Align.center(
f"[red]{self.file_removals} {simple_pluralise('file', self.file_removals)} removed"
)
bar_width = console.width // 5
changed_lines = max(1, self.line_additions + self.line_removals)
added_lines_ratio = self.line_additions / changed_lines
line_changes_summary = Table.grid()
line_changes_summary.add_column()
line_changes_summary.add_column()
line_changes_summary.add_column()
line_changes_summary.add_row(
f"[bold green]+{self.line_additions} ",
UnderlineBar(
highlight_range=(0, added_lines_ratio * bar_width),
highlight_style="green",
background_style="red",
width=bar_width,
),
f" [bold red]-{self.line_removals}",
)
bar_hpad = len(str(self.line_additions)) + len(str(self.line_removals)) + 4
yield Align.center(line_changes_summary, width=bar_width + bar_hpad)
yield Segment.line()
class RemovedFileBody:
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield Rule(characters="", style="hatched")
yield Rule(" [red]File was removed ", characters="", style="hatched")
yield Rule(characters="", style="hatched")
yield Rule(style="border", characters="")
@dataclass
class BinaryFileBody:
size_in_bytes: int
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield Rule(characters="", style="hatched")
yield Rule(
Text(f" File is binary · {self.size_in_bytes} bytes ", style="blue"),
characters="",
style="hatched",
)
yield Rule(characters="", style="hatched")
yield Rule(style="border", characters="")
class PatchedFileHeader:
def __init__(self, patch: PatchedFile):
self.patch = patch
if patch.is_rename:
self.path_prefix = (
f"[dim][s]{escape(Path(patch.source_file).name)}[/] → [/]"
)
elif patch.is_added_file:
self.path_prefix = f"[bold green]Added [/]"
else:
self.path_prefix = ""
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield Rule(
f"{self.path_prefix}[b]{escape(self.patch.path)}[/] ([green]{self.patch.added} additions[/], "
f"[red]{self.patch.removed} removals[/])",
style="border",
characters="",
)
class OnlyRenamedFileBody:
"""Represents a file that was renamed but the content was not changed."""
def __init__(self, patch: PatchedFile):
self.patch = patch
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield Rule(characters="", style="hatched")
yield Rule(" [blue]File was only renamed ", characters="", style="hatched")
yield Rule(characters="", style="hatched")
yield Rule(style="border", characters="")

96
dunk/underline_bar.py Normal file
View file

@ -0,0 +1,96 @@
from __future__ import annotations
from rich.console import ConsoleOptions, Console, RenderResult
from rich.style import StyleType
from rich.text import Text
class UnderlineBar:
"""Thin horizontal bar with a portion highlighted.
Args:
highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight)
highlight_style (StyleType): The style of the highlighted range of the bar.
background_style (StyleType): The style of the non-highlighted range(s) of the bar.
width (int, optional): The width of the bar, or ``None`` to fill available width.
"""
def __init__(
self,
highlight_range: tuple[float, float] = (0, 0),
highlight_style: StyleType = "magenta",
background_style: StyleType = "grey37",
clickable_ranges: dict[str, tuple[int, int]] | None = None,
width: int | None = None,
) -> None:
self.highlight_range = highlight_range
self.highlight_style = highlight_style
self.background_style = background_style
self.clickable_ranges = clickable_ranges or {}
self.width = width
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
highlight_style = console.get_style(self.highlight_style)
background_style = console.get_style(self.background_style)
half_bar_right = ""
half_bar_left = ""
bar = ""
width = self.width or options.max_width
start, end = self.highlight_range
start = max(start, 0)
end = min(end, width)
output_bar = Text("", end="")
if start == end == 0 or end < 0 or start > end:
output_bar.append(Text(bar * width, style=background_style, end=""))
yield output_bar
return
# Round start and end to nearest half
start = round(start * 2) / 2
end = round(end * 2) / 2
# Check if we start/end on a number that rounds to a .5
half_start = start - int(start) > 0
half_end = end - int(end) > 0
# Initial non-highlighted portion of bar
output_bar.append(
Text(bar * (int(start - 0.5)), style=background_style, end="")
)
if not half_start and start > 0:
output_bar.append(Text(half_bar_right, style=background_style, end=""))
# The highlighted portion
bar_width = int(end) - int(start)
if half_start:
output_bar.append(
Text(
half_bar_left + bar * (bar_width - 1), style=highlight_style, end=""
)
)
else:
output_bar.append(Text(bar * bar_width, style=highlight_style, end=""))
if half_end:
output_bar.append(Text(half_bar_right, style=highlight_style, end=""))
# The non-highlighted tail
if not half_end and end - width != 0:
output_bar.append(Text(half_bar_left, style=background_style, end=""))
output_bar.append(
Text(bar * (int(width) - int(end) - 1), style=background_style, end="")
)
# Fire actions when certain ranges are clicked (e.g. for tabs)
for range_name, (start, end) in self.clickable_ranges.items():
output_bar.apply_meta(
{"@click": f"range_clicked('{range_name}')"}, start, end
)
yield output_bar

50
pyproject.toml Normal file
View file

@ -0,0 +1,50 @@
[project]
name = "dunk"
version = "0.5.0b0"
description = "Beautiful side-by-side diffs in your terminal"
authors = [
{name = "Darren Burns", email = "darrenb900@gmail.com"}
]
readme = "README.md"
license = {file = "LICENSE"}
keywords = ["diff", "terminal", "side-by-side", "git", "cli", "dev-tools"]
classifiers = [
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
requires-python = ">=3.11"
dependencies = [
"unidiff>=0.7",
"rich>=12.1.0"
]
[project.scripts]
dunk = "dunk.dunk:main"
[project.urls]
homepage = "https://github.com/darrenburns/dunk"
repository = "https://github.com/darrenburns/dunk"
issues = "https://github.com/darrenburns/dunk/issues"
documentation = "https://github.com/darrenburns/dunk/blob/main/README.md"
[tool.uv]
dev-dependencies = [
"pytest",
]
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["dunk"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

129
uv.lock generated Normal file
View file

@ -0,0 +1,129 @@
version = 1
revision = 1
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "dunk"
version = "0.5.0b0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
{ name = "unidiff" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "rich", specifier = ">=12.1.0" },
{ name = "unidiff", specifier = ">=0.7" },
]
[package.metadata.requires-dev]
dev = [{ name = "pytest" }]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "packaging"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
]
[[package]]
name = "pygments"
version = "2.19.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
[[package]]
name = "pytest"
version = "8.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
]
[[package]]
name = "rich"
version = "14.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 },
]
[[package]]
name = "unidiff"
version = "0.7.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/48/81be0ac96e423a877754153699731ef439fd7b80b4c8b5425c94ed079ebd/unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574", size = 20931 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/54/57c411a6e8f7bd7848c8b66e4dcaffa586bf4c02e63f2280db0327a4e6eb/unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8", size = 14386 },
]