Skip to content

Commit 0e000f5

Browse files
authored
feat: enable first-class user feedback (#79)
1 parent de5700b commit 0e000f5

File tree

7 files changed

+319
-186
lines changed

7 files changed

+319
-186
lines changed

poetry.lock

Lines changed: 31 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"jinja2 (>=3.1.5,<4)",
1111
"prettytable (>=3.15.1,<4)",
1212
"xdg-base-dirs (>=6.0.2,<7)",
13+
"yaspin (>=3.1.0,<4)",
1314
]
1415

1516
[project.optional-dependencies]

src/git_draft/__main__.py

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Sequence
65
import enum
76
import importlib.metadata
87
import logging
98
import optparse
10-
from pathlib import Path, PurePosixPath
9+
from pathlib import Path
1110
import sys
1211

1312
from .bots import load_bot
14-
from .common import PROGRAM, Config, UnreachableError, ensure_state_home
13+
from .common import (
14+
PROGRAM,
15+
Config,
16+
Feedback,
17+
UnreachableError,
18+
ensure_state_home,
19+
)
1520
from .drafter import Drafter, DraftMergeStrategy
1621
from .editor import open_editor
1722
from .git import Repo
1823
from .prompt import Template, TemplatedPrompt, find_template, templates_table
1924
from .store import Store
20-
from .toolbox import ToolVisitor
2125

2226

2327
_logger = logging.getLogger(__name__)
@@ -120,36 +124,6 @@ def merge_strategy(self) -> DraftMergeStrategy | None:
120124
raise UnreachableError()
121125

122126

123-
class ToolPrinter(ToolVisitor):
124-
"""Visitor implementation which prints invocations to stdout"""
125-
126-
def on_list_files(
127-
self, _paths: Sequence[PurePosixPath], _reason: str | None
128-
) -> None:
129-
print("Listing available files...")
130-
131-
def on_read_file(
132-
self, path: PurePosixPath, _contents: str | None, _reason: str | None
133-
) -> None:
134-
print(f"Reading {path}...")
135-
136-
def on_write_file(
137-
self, path: PurePosixPath, _contents: str, _reason: str | None
138-
) -> None:
139-
print(f"Wrote {path}.")
140-
141-
def on_delete_file(self, path: PurePosixPath, _reason: str | None) -> None:
142-
print(f"Deleted {path}.")
143-
144-
def on_rename_file(
145-
self,
146-
src_path: PurePosixPath,
147-
dst_path: PurePosixPath,
148-
_reason: str | None,
149-
) -> None:
150-
print(f"Renamed {src_path} to {dst_path}.")
151-
152-
153127
def edit(*, path: Path | None = None, text: str | None = None) -> str:
154128
if sys.stdin.isatty():
155129
return open_editor(text or "", path)
@@ -187,10 +161,11 @@ def main() -> None: # noqa: PLR0912 PLR0915
187161
datefmt="%m-%d %H:%M",
188162
)
189163

164+
feedback = Feedback.dynamic() if sys.stdin.isatty() else Feedback.static()
190165
repo = Repo.enclosing(Path(opts.root) if opts.root else Path.cwd())
191-
drafter = Drafter.create(repo, Store.persistent())
192-
match getattr(opts, "command", "generate"):
193-
case "generate":
166+
drafter = Drafter.create(repo, Store.persistent(), feedback)
167+
match getattr(opts, "command", "new"):
168+
case "new":
194169
bot_config = None
195170
bot_name = opts.bot or repo.default_bot()
196171
if bot_name:
@@ -214,29 +189,19 @@ def main() -> None: # noqa: PLR0912 PLR0915
214189
if not prompt or prompt == _PROMPT_PLACEHOLDER:
215190
raise ValueError("Aborting: empty or placeholder prompt")
216191
else:
192+
if sys.stdin.isatty():
193+
print("Reading prompt from stdin... (press C-D when done)")
217194
prompt = sys.stdin.read()
218195

219196
accept = Accept(opts.accept or 0)
220-
draft = drafter.generate_draft(
197+
_ = drafter.generate_draft(
221198
prompt,
222199
bot,
223200
prompt_transform=open_editor if editable else None,
224201
merge_strategy=accept.merge_strategy(),
225-
tool_visitors=[ToolPrinter()],
226202
)
227-
match accept:
228-
case Accept.MANUAL:
229-
print(f"Generated draft. [ref={draft.ref}].")
230-
case Accept.MERGE | Accept.MERGE_THEIRS:
231-
print(f"Generated and merged draft. [ref={draft.ref}]")
232-
case Accept.MERGE_THEN_QUIT:
233-
drafter.quit_folio()
234-
print(f"Generated and applied draft. [ref={draft.ref}]")
235-
case _:
236-
raise UnreachableError()
237203
case "quit":
238204
drafter.quit_folio()
239-
print("Applied draft.")
240205
case "templates":
241206
if args:
242207
name = args[0]

src/git_draft/common.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Mapping, Sequence
5+
from collections.abc import Iterator, Mapping, Sequence
6+
import contextlib
67
import dataclasses
78
import itertools
89
import logging
@@ -15,6 +16,11 @@
1516

1617
import prettytable
1718
import xdg_base_dirs
19+
import yaspin
20+
import yaspin.core
21+
22+
23+
_logger = logging.getLogger(__name__)
1824

1925

2026
PROGRAM = "git-draft"
@@ -123,3 +129,92 @@ def from_cursor(cls, cursor: sqlite3.Cursor) -> Self:
123129
table = prettytable.from_db_cursor(cursor, **cls._kwargs)
124130
assert table
125131
return cls(table)
132+
133+
134+
def _tagged(text: str, /, **kwargs) -> str:
135+
tags = [f"{key}={val}" for key, val in kwargs.items() if val is not None]
136+
return f"{text} [{', '.join(tags)}]" if tags else text
137+
138+
139+
class Feedback:
140+
"""User feedback interface"""
141+
142+
def report(self, text: str, **tags) -> None: # pragma: no cover
143+
raise NotImplementedError()
144+
145+
def spinner(
146+
self, text: str, **tags
147+
) -> contextlib.AbstractContextManager[
148+
FeedbackSpinner
149+
]: # pragma: no cover
150+
raise NotImplementedError()
151+
152+
@staticmethod
153+
def dynamic() -> Feedback:
154+
"""Feedback suitable for interactive terminals"""
155+
return _DynamicFeedback()
156+
157+
@staticmethod
158+
def static() -> Feedback:
159+
"""Feedback suitable for pipes, etc."""
160+
return _StaticFeedback()
161+
162+
163+
class FeedbackSpinner:
164+
"""Operation feedback tracker"""
165+
166+
def update(self, text: str, **tags) -> None: # pragma: no cover
167+
raise NotImplementedError()
168+
169+
170+
class _DynamicFeedback(Feedback):
171+
def __init__(self) -> None:
172+
self._spinner: _DynamicFeedbackSpinner | None = None
173+
174+
def report(self, text: str, **tags) -> None:
175+
message = f"☞ {_tagged(text, **tags)}"
176+
if self._spinner:
177+
self._spinner.yaspin.write(message)
178+
else:
179+
print(message) # noqa
180+
181+
@contextlib.contextmanager
182+
def spinner(self, text: str, **tags) -> Iterator[FeedbackSpinner]:
183+
assert not self._spinner
184+
with yaspin.yaspin(text=_tagged(text, **tags)) as spinner:
185+
self._spinner = _DynamicFeedbackSpinner(spinner)
186+
try:
187+
yield self._spinner
188+
except Exception:
189+
self._spinner.yaspin.fail("✗")
190+
raise
191+
else:
192+
self._spinner.yaspin.ok("✓")
193+
finally:
194+
self._spinner = None
195+
196+
197+
class _DynamicFeedbackSpinner(FeedbackSpinner):
198+
def __init__(self, yaspin: yaspin.core.Yaspin) -> None:
199+
self.yaspin = yaspin
200+
201+
def update(self, text: str, **tags) -> None:
202+
self.yaspin.text = _tagged(text, **tags)
203+
204+
205+
class _StaticFeedback(Feedback):
206+
def report(self, text: str, **tags) -> None:
207+
print(_tagged(text, **tags)) # noqa
208+
209+
@contextlib.contextmanager
210+
def spinner(self, text: str, **tags) -> Iterator[FeedbackSpinner]:
211+
self.report(text, **tags)
212+
yield _StaticFeedbackSpinner(self)
213+
214+
215+
class _StaticFeedbackSpinner(FeedbackSpinner):
216+
def __init__(self, feedback: _StaticFeedback) -> None:
217+
self._feedback = feedback
218+
219+
def update(self, text: str, **tags) -> None:
220+
self._feedback.report(text, **tags)

0 commit comments

Comments
 (0)