nit-cli 0.2.0__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.
nit/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("nit-cli")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0-dev"
nit/app.py ADDED
@@ -0,0 +1,656 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ from collections import Counter
7
+
8
+ from rich.text import Text
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical, VerticalScroll
12
+ from textual.reactive import reactive
13
+ from textual.widgets import Footer, Input, Label, Static, Tree
14
+
15
+ from . import comments as comments_mod
16
+ from . import git
17
+ from .cli import CLIArgs
18
+ from .diff_parser import parse_diff
19
+ from .models import DiffLine as DiffLineModel
20
+ from .models import FileDiff, ReviewComment
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ STATUS_COLORS = {
25
+ "added": ("A", "green"),
26
+ "deleted": ("D", "red"),
27
+ "renamed": ("R", "cyan"),
28
+ "modified": ("M", "yellow"),
29
+ }
30
+
31
+
32
+ def _file_label(fd: FileDiff, comment_count: int) -> Text:
33
+ letter, color = STATUS_COLORS.get(fd.status, ("M", "yellow"))
34
+ label = Text()
35
+ label.append(letter, style=f"bold {color}")
36
+ label.append(f" {os.path.basename(fd.path)}")
37
+ if comment_count:
38
+ label.append(f" ({comment_count})", style="dim")
39
+ return label
40
+
41
+
42
+ # --- Diff mode enum ---
43
+
44
+ DIFF_MODES = ["branch", "unstaged", "all"]
45
+ DIFF_MODE_LABELS = {
46
+ "branch": "branch diff",
47
+ "unstaged": "unstaged",
48
+ "all": "all uncommitted",
49
+ }
50
+
51
+
52
+ # --- Widgets ---
53
+
54
+
55
+ class FileTree(Tree):
56
+ """Sidebar file tree with visible cursor."""
57
+
58
+ SCOPED_CSS = False
59
+ DEFAULT_CSS = """
60
+ FileTree {
61
+ background: $background;
62
+ }
63
+ FileTree > .tree--cursor {
64
+ background: $panel;
65
+ text-style: bold;
66
+ }
67
+ FileTree > .tree--highlight {
68
+ background: $accent 50%;
69
+ }
70
+ FileTree > .tree--highlight-line {
71
+ background: $accent 20%;
72
+ }
73
+ """
74
+
75
+ ICON_NODE = ""
76
+ ICON_NODE_EXPANDED = ""
77
+
78
+
79
+ class DiffLineWidget(Static):
80
+ """A single line in the diff view."""
81
+
82
+ pass
83
+
84
+
85
+ class CommentBlock(Static):
86
+ """An inline comment block rendered between diff lines."""
87
+
88
+ DEFAULT_CSS = """
89
+ CommentBlock {
90
+ background: $surface;
91
+ color: $text;
92
+ margin: 0 2 0 6;
93
+ padding: 0 1;
94
+ border: round $warning;
95
+ width: 1fr;
96
+ }
97
+ """
98
+
99
+
100
+ class InlineCommentInput(Input):
101
+ """Inline input that appears below a diff line for entering comments."""
102
+
103
+ BINDINGS = [
104
+ Binding("escape", "cancel", "Cancel", priority=True),
105
+ ]
106
+
107
+ DEFAULT_CSS = """
108
+ InlineCommentInput {
109
+ margin: 0 2 0 6;
110
+ border: round $accent;
111
+ width: 1fr;
112
+ }
113
+ """
114
+
115
+ def action_cancel(self) -> None:
116
+ diff_view = next(a for a in self.ancestors if isinstance(a, DiffView))
117
+ diff_view.hide_comment_input()
118
+
119
+
120
+ class DiffView(VerticalScroll):
121
+ """Scrollable diff view with cursor tracking."""
122
+
123
+ DEFAULT_CSS = """
124
+ DiffView {
125
+ width: 1fr;
126
+ }
127
+ DiffLineWidget {
128
+ width: 1fr;
129
+ padding: 0 1;
130
+ }
131
+ DiffLineWidget.add {
132
+ background-tint: $success 25%;
133
+ color: $success;
134
+ }
135
+ DiffLineWidget.remove {
136
+ background-tint: $error 25%;
137
+ color: $error;
138
+ }
139
+ DiffLineWidget.context {
140
+ color: $text-muted;
141
+ }
142
+ DiffLineWidget.hunk-header {
143
+ color: $accent;
144
+ text-style: bold;
145
+ }
146
+ DiffLineWidget.cursor {
147
+ background: $panel;
148
+ }
149
+ DiffLineWidget.add.cursor {
150
+ background: $panel;
151
+ color: $success;
152
+ }
153
+ DiffLineWidget.remove.cursor {
154
+ background: $panel;
155
+ color: $error;
156
+ }
157
+ """
158
+
159
+ cursor_index: reactive[int] = reactive(0)
160
+
161
+ def __init__(self, *args, **kwargs) -> None:
162
+ super().__init__(*args, **kwargs)
163
+ self.diff_lines: list[DiffLineModel] = []
164
+ self._line_widgets: list[DiffLineWidget] = []
165
+ self._active_input: InlineCommentInput | None = None
166
+
167
+ def clear_diff(self) -> None:
168
+ self.diff_lines = []
169
+ self._line_widgets = []
170
+ self.cursor_index = 0
171
+ self.remove_children()
172
+
173
+ def load_file_diff(
174
+ self,
175
+ file_diff: FileDiff,
176
+ file_comments: list[ReviewComment],
177
+ restore_cursor: int = 0,
178
+ ) -> None:
179
+ self.remove_children()
180
+ self.diff_lines = []
181
+ self._line_widgets = []
182
+
183
+ # Build a lookup of comments by line number
184
+ comment_map: dict[int | None, list[ReviewComment]] = {}
185
+ for c in file_comments:
186
+ key = c.new_line_no if c.new_line_no is not None else c.old_line_no
187
+ comment_map.setdefault(key, []).append(c)
188
+
189
+ if file_diff.is_binary:
190
+ w = DiffLineWidget("Binary file")
191
+ self.mount(w)
192
+ return
193
+
194
+ for hunk in file_diff.hunks:
195
+ for dl in hunk.lines:
196
+ self.diff_lines.append(dl)
197
+ line_no_str = self._format_line_no(dl)
198
+ prefix = self._format_prefix(dl)
199
+ text = f"{line_no_str}{prefix}{dl.content}"
200
+ w = DiffLineWidget(text)
201
+ w.add_class(dl.line_type.replace("_", "-"))
202
+ self._line_widgets.append(w)
203
+ self.mount(w)
204
+
205
+ # Render inline comments for this line
206
+ line_key = dl.new_line_no if dl.new_line_no is not None else dl.old_line_no
207
+ for c in comment_map.get(line_key, []):
208
+ if comments_mod.comment_matches_line(c, dl):
209
+ block = CommentBlock(f"-- {c.comment}")
210
+ self.mount(block)
211
+
212
+ start = min(restore_cursor, len(self._line_widgets) - 1) if self._line_widgets else 0
213
+ self.cursor_index = start
214
+ if self._line_widgets:
215
+ self._line_widgets[start].add_class("cursor")
216
+
217
+ def _format_line_no(self, dl: DiffLineModel) -> str:
218
+ old = str(dl.old_line_no) if dl.old_line_no is not None else ""
219
+ new = str(dl.new_line_no) if dl.new_line_no is not None else ""
220
+ if dl.line_type == "hunk_header":
221
+ return ""
222
+ return f"{old:>4} {new:>4} \u2502 "
223
+
224
+ def _format_prefix(self, dl: DiffLineModel) -> str:
225
+ if dl.line_type == "add":
226
+ return "+"
227
+ elif dl.line_type == "remove":
228
+ return "-"
229
+ elif dl.line_type == "hunk_header":
230
+ return ""
231
+ return " "
232
+
233
+ def watch_cursor_index(self, old: int, new: int) -> None:
234
+ if not self._line_widgets:
235
+ return
236
+ if 0 <= old < len(self._line_widgets):
237
+ self._line_widgets[old].remove_class("cursor")
238
+ if 0 <= new < len(self._line_widgets):
239
+ self._line_widgets[new].add_class("cursor")
240
+ self._line_widgets[new].scroll_visible()
241
+
242
+ def move_cursor(self, delta: int) -> None:
243
+ if not self._line_widgets:
244
+ return
245
+ new_idx = max(0, min(len(self._line_widgets) - 1, self.cursor_index + delta))
246
+ self.cursor_index = new_idx
247
+
248
+ def jump_to_next_hunk(self, forward: bool = True) -> None:
249
+ if not self.diff_lines:
250
+ return
251
+ start = self.cursor_index
252
+ step = 1 if forward else -1
253
+ idx = start + step
254
+ while 0 <= idx < len(self.diff_lines):
255
+ if self.diff_lines[idx].line_type == "hunk_header":
256
+ self.cursor_index = idx
257
+ return
258
+ idx += step
259
+
260
+ def jump_to_next_comment(self, forward: bool = True) -> None:
261
+ """Jump to the next/previous line that has a comment."""
262
+ app: NitApp = self.app # type: ignore[assignment]
263
+ if not self.diff_lines or not app.current_file:
264
+ return
265
+ file_comments = [c for c in app.comments if c.file_path == app.current_file.path]
266
+ if not file_comments:
267
+ return
268
+
269
+ commented_lines: set[tuple[int | None, int | None]] = set()
270
+ for c in file_comments:
271
+ commented_lines.add((c.new_line_no, c.old_line_no))
272
+
273
+ start = self.cursor_index
274
+ step = 1 if forward else -1
275
+ idx = start + step
276
+ while 0 <= idx < len(self.diff_lines):
277
+ dl = self.diff_lines[idx]
278
+ if (dl.new_line_no, dl.old_line_no) in commented_lines:
279
+ self.cursor_index = idx
280
+ return
281
+ idx += step
282
+
283
+ def show_comment_input(self) -> None:
284
+ if self._active_input is not None:
285
+ return
286
+ if not self._line_widgets or self.cursor_index >= len(self._line_widgets):
287
+ return
288
+ widget = self._line_widgets[self.cursor_index]
289
+ self._active_input = InlineCommentInput(
290
+ placeholder="comment (enter to submit, esc to cancel)"
291
+ )
292
+ self.mount(self._active_input, after=widget)
293
+ self._active_input.focus()
294
+
295
+ def hide_comment_input(self) -> None:
296
+ if self._active_input is not None:
297
+ self._active_input.remove()
298
+ self._active_input = None
299
+ self.focus()
300
+
301
+ def get_current_line(self) -> DiffLineModel | None:
302
+ if 0 <= self.cursor_index < len(self.diff_lines):
303
+ return self.diff_lines[self.cursor_index]
304
+ return None
305
+
306
+ def get_hunk_context(self, center_idx: int, radius: int = 2) -> list[str]:
307
+ start = max(0, center_idx - radius)
308
+ end = min(len(self.diff_lines), center_idx + radius + 1)
309
+ return [self.diff_lines[i].content for i in range(start, end)]
310
+
311
+
312
+ # --- App ---
313
+
314
+
315
+ class NitApp(App):
316
+ TITLE = "nit"
317
+ COMMANDS = set()
318
+ ENABLE_COMMAND_PALETTE = False
319
+ theme = "nord"
320
+ CSS = """
321
+ #frame {
322
+ border: round $accent;
323
+ height: 1fr;
324
+ width: 1fr;
325
+ }
326
+ #status-bar {
327
+ dock: top;
328
+ height: 1;
329
+ }
330
+ .status-segment {
331
+ padding: 0 1;
332
+ text-style: bold;
333
+ width: 1fr;
334
+ }
335
+ #seg-branch {
336
+ background: $accent;
337
+ color: $text;
338
+ }
339
+ #seg-mode {
340
+ background: $secondary;
341
+ color: $text;
342
+ }
343
+ #seg-files {
344
+ background: $primary;
345
+ color: $text;
346
+ }
347
+ #seg-comments {
348
+ background: $warning;
349
+ color: $text;
350
+ }
351
+ #layout {
352
+ width: 100%;
353
+ height: 1fr;
354
+ }
355
+ #sidebar {
356
+ width: 40;
357
+ dock: left;
358
+ border: round $border;
359
+ overflow-x: hidden;
360
+ }
361
+ DiffView {
362
+ border: round $border;
363
+ }
364
+ """
365
+
366
+ BINDINGS = [
367
+ Binding("q", "quit", "Quit"),
368
+ Binding("j", "cursor_down", "Down", show=False),
369
+ Binding("k", "cursor_up", "Up", show=False),
370
+ Binding("J", "next_hunk", "Next hunk", show=False),
371
+ Binding("K", "prev_hunk", "Prev hunk", show=False),
372
+ Binding("n", "next_file", "Next file"),
373
+ Binding("p", "prev_file", "Prev file"),
374
+ Binding("c", "comment", "Comment"),
375
+ Binding("d", "delete_comment", "Delete comment"),
376
+ Binding("m", "cycle_mode", "Mode"),
377
+ Binding("r", "refresh", "Refresh"),
378
+ Binding("right_square_bracket", "next_comment", "]", show=False),
379
+ Binding("left_square_bracket", "prev_comment", "[", show=False),
380
+ ]
381
+
382
+ diff_mode: reactive[str] = reactive("branch")
383
+
384
+ def __init__(self, cli_args: CLIArgs | None = None, *args, **kwargs) -> None:
385
+ super().__init__(*args, **kwargs)
386
+ self._cli_args = cli_args or CLIArgs()
387
+ self.current_file: FileDiff | None = None
388
+ self.file_diffs: list[FileDiff] = []
389
+ self.comments: list[ReviewComment] = []
390
+ self.repo_root = None
391
+ self.branch = ""
392
+ self.base = ""
393
+ self._file_index: int = 0
394
+
395
+ def compose(self) -> ComposeResult:
396
+ with Vertical(id="frame"):
397
+ with Horizontal(id="status-bar"):
398
+ yield Label("", id="seg-branch", classes="status-segment")
399
+ yield Label("", id="seg-mode", classes="status-segment")
400
+ yield Label("", id="seg-files", classes="status-segment")
401
+ yield Label("", id="seg-comments", classes="status-segment")
402
+ with Horizontal(id="layout"):
403
+ sidebar_tree = FileTree("Files", id="sidebar")
404
+ sidebar_tree.guide_depth = 2
405
+ yield sidebar_tree
406
+ yield DiffView(id="diff-view")
407
+ yield Footer()
408
+
409
+ def on_mount(self) -> None:
410
+ try:
411
+ self.repo_root = git.get_repo_root()
412
+ except Exception:
413
+ self.notify("Not a git repository", severity="error")
414
+ self.exit()
415
+ return
416
+ try:
417
+ self.branch = git.get_current_branch(self.repo_root)
418
+ except Exception:
419
+ self.branch = ""
420
+ self.base = git.get_main_branch(self.repo_root)
421
+ if self._cli_args.mode:
422
+ self.diff_mode = self._cli_args.mode
423
+ try:
424
+ self.comments = comments_mod.load_comments(self.repo_root)
425
+ except Exception:
426
+ logger.warning("Failed to load comments, starting with empty list")
427
+ self.notify("Could not load .nit.json — starting with no comments", severity="warning")
428
+ self.comments = []
429
+ self._load_diff()
430
+
431
+ def _load_diff(self) -> None:
432
+ if self.repo_root is None:
433
+ return
434
+ cwd = self.repo_root
435
+ path_filter = self._cli_args.path_filter
436
+
437
+ try:
438
+ if self._cli_args.commit_range:
439
+ raw = git.get_commit_range_diff(self._cli_args.commit_range, cwd, path_filter)
440
+ elif self.diff_mode == "branch":
441
+ raw = git.get_branch_diff(cwd, path_filter)
442
+ elif self.diff_mode == "unstaged":
443
+ raw = git.get_unstaged_diff(cwd, path_filter)
444
+ else:
445
+ raw = git.get_all_uncommitted_diff(cwd, path_filter)
446
+ except subprocess.CalledProcessError as e:
447
+ msg = (e.stderr or "").strip() or "Failed to load diff"
448
+ logger.warning("Git diff failed: %s", msg)
449
+ self.notify(msg, severity="error")
450
+ raw = ""
451
+
452
+ self.file_diffs = parse_diff(raw)
453
+ self._update_file_list()
454
+ self._update_status()
455
+
456
+ def _build_file_tree(self) -> None:
457
+ """Populate the sidebar tree with files grouped by directory."""
458
+ tree = self.query_one("#sidebar", FileTree)
459
+ tree.clear()
460
+ tree.show_root = False
461
+
462
+ # Group files by directory
463
+ dirs: dict[str, list[tuple[int, FileDiff]]] = {}
464
+ for i, fd in enumerate(self.file_diffs):
465
+ dirname = os.path.dirname(fd.path) or "."
466
+ dirs.setdefault(dirname, []).append((i, fd))
467
+
468
+ comment_counts = Counter(c.file_path for c in self.comments)
469
+
470
+ dir_nodes: dict[str, object] = {}
471
+ for dirname in sorted(dirs):
472
+ parts = dirname.split("/")
473
+ current = tree.root
474
+ for depth, part in enumerate(parts):
475
+ key = "/".join(parts[: depth + 1])
476
+ if key not in dir_nodes:
477
+ label = Text(f"{part}/", style="dim")
478
+ node = current.add(label, expand=True)
479
+ node.data = None
480
+ dir_nodes[key] = node
481
+ current = dir_nodes[key]
482
+
483
+ for i, fd in dirs[dirname]:
484
+ label = _file_label(fd, comment_counts[fd.path])
485
+ leaf = current.add_leaf(label)
486
+ leaf.data = i
487
+
488
+ def _update_file_list(self) -> None:
489
+ self._build_file_tree()
490
+ if self.file_diffs:
491
+ restore_idx = 0
492
+ if self.current_file:
493
+ for i, fd in enumerate(self.file_diffs):
494
+ if fd.path == self.current_file.path:
495
+ restore_idx = i
496
+ break
497
+ self._file_index = restore_idx
498
+ self._select_file(restore_idx)
499
+ else:
500
+ self.current_file = None
501
+ self.query_one("#diff-view", DiffView).clear_diff()
502
+
503
+ def _refresh_file_labels(self) -> None:
504
+ """Rebuild tree labels without changing selection."""
505
+ self._build_file_tree()
506
+
507
+ def _update_status(self) -> None:
508
+ if self._cli_args.commit_range:
509
+ mode_label = self._cli_args.commit_range
510
+ else:
511
+ mode_label = DIFF_MODE_LABELS.get(self.diff_mode, self.diff_mode)
512
+ n_comments = len(self.comments)
513
+ n_files = len(self.file_diffs)
514
+ self.query_one("#seg-branch", Label).update(f"⎇ {self.branch}")
515
+ self.query_one("#seg-mode", Label).update(f"⇄ {mode_label}")
516
+ self.query_one("#seg-files", Label).update(f"▤ {n_files} files")
517
+ self.query_one("#seg-comments", Label).update(f"✎ {n_comments} comments")
518
+
519
+ def _highlight_tree_node(self, file_index: int) -> None:
520
+ """Move tree cursor to the node matching the given file index."""
521
+ tree = self.query_one("#sidebar", FileTree)
522
+ for line in range(tree.last_line + 1):
523
+ node = tree.get_node_at_line(line)
524
+ if node and node.data == file_index:
525
+ tree.cursor_line = line
526
+ tree.scroll_to_line(line)
527
+ break
528
+
529
+ def _select_file(self, index: int) -> None:
530
+ if 0 <= index < len(self.file_diffs):
531
+ self.current_file = self.file_diffs[index]
532
+ file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
533
+ self.query_one("#diff-view", DiffView).load_file_diff(self.current_file, file_comments)
534
+ self._highlight_tree_node(index)
535
+ self.query_one("#diff-view", DiffView).focus()
536
+
537
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
538
+ if event.node.data is not None:
539
+ self._file_index = event.node.data
540
+ self._select_file(event.node.data)
541
+
542
+ # --- Actions ---
543
+
544
+ def action_cursor_down(self) -> None:
545
+ self.query_one("#diff-view", DiffView).move_cursor(1)
546
+
547
+ def action_cursor_up(self) -> None:
548
+ self.query_one("#diff-view", DiffView).move_cursor(-1)
549
+
550
+ def action_next_hunk(self) -> None:
551
+ self.query_one("#diff-view", DiffView).jump_to_next_hunk(forward=True)
552
+
553
+ def action_prev_hunk(self) -> None:
554
+ self.query_one("#diff-view", DiffView).jump_to_next_hunk(forward=False)
555
+
556
+ def action_next_file(self) -> None:
557
+ if self._file_index < len(self.file_diffs) - 1:
558
+ self._file_index += 1
559
+ self._select_file(self._file_index)
560
+
561
+ def action_prev_file(self) -> None:
562
+ if self._file_index > 0:
563
+ self._file_index -= 1
564
+ self._select_file(self._file_index)
565
+
566
+ def action_next_comment(self) -> None:
567
+ self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=True)
568
+
569
+ def action_prev_comment(self) -> None:
570
+ self.query_one("#diff-view", DiffView).jump_to_next_comment(forward=False)
571
+
572
+ def action_cycle_mode(self) -> None:
573
+ if self._cli_args.commit_range:
574
+ return
575
+ idx = DIFF_MODES.index(self.diff_mode)
576
+ self.diff_mode = DIFF_MODES[(idx + 1) % len(DIFF_MODES)]
577
+ self._load_diff()
578
+
579
+ def action_refresh(self) -> None:
580
+ if self.repo_root:
581
+ self.comments = comments_mod.load_comments(self.repo_root)
582
+ self._load_diff()
583
+
584
+ def action_comment(self) -> None:
585
+ diff_view = self.query_one("#diff-view", DiffView)
586
+ line = diff_view.get_current_line()
587
+ if line is None or line.line_type == "hunk_header":
588
+ return
589
+ if self.current_file is None:
590
+ return
591
+ diff_view.show_comment_input()
592
+
593
+ def on_input_submitted(self, event: Input.Submitted) -> None:
594
+ if not isinstance(event.input, InlineCommentInput):
595
+ return
596
+ diff_view = self.query_one("#diff-view", DiffView)
597
+ text = event.value.strip()
598
+ if text and self.current_file and self.repo_root:
599
+ line = diff_view.get_current_line()
600
+ if line:
601
+ context = diff_view.get_hunk_context(diff_view.cursor_index)
602
+ comment = comments_mod.make_comment(
603
+ self.current_file.path,
604
+ line,
605
+ text,
606
+ context,
607
+ diff_mode=self.diff_mode,
608
+ )
609
+ self.comments.append(comment)
610
+ comments_mod.save_comments(self.repo_root, self.comments, self.branch, self.base)
611
+ saved_cursor = diff_view.cursor_index
612
+ diff_view.hide_comment_input()
613
+ if self.current_file:
614
+ file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
615
+ diff_view.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
616
+ self._update_status()
617
+ self._refresh_file_labels()
618
+
619
+ def action_delete_comment(self) -> None:
620
+ diff_view = self.query_one("#diff-view", DiffView)
621
+ line = diff_view.get_current_line()
622
+ if line is None or self.current_file is None or self.repo_root is None:
623
+ return
624
+ # Find and remove comments matching this line
625
+ before = len(self.comments)
626
+ self.comments = [
627
+ c
628
+ for c in self.comments
629
+ if not (
630
+ c.file_path == self.current_file.path and comments_mod.comment_matches_line(c, line)
631
+ )
632
+ ]
633
+ if len(self.comments) < before:
634
+ saved_cursor = diff_view.cursor_index
635
+ comments_mod.save_comments(self.repo_root, self.comments, self.branch, self.base)
636
+ file_comments = [c for c in self.comments if c.file_path == self.current_file.path]
637
+ diff_view.load_file_diff(self.current_file, file_comments, restore_cursor=saved_cursor)
638
+ self._update_status()
639
+ self._refresh_file_labels()
640
+
641
+
642
+ def main() -> None:
643
+ from .cli import parse_args
644
+
645
+ args = parse_args()
646
+ logging.basicConfig(
647
+ level=logging.DEBUG if args.verbose else logging.WARNING,
648
+ format="%(name)s: %(message)s",
649
+ stream=__import__("sys").stderr,
650
+ )
651
+ app = NitApp(cli_args=args)
652
+ app.run()
653
+
654
+
655
+ if __name__ == "__main__":
656
+ main()