Coverage for src/devboard/app.py: 55.88%
94 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-19 20:21 +0100
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-19 20:21 +0100
1"""The Textual application."""
3from __future__ import annotations
5import os
6import sys
7from importlib.util import module_from_spec, spec_from_file_location
8from multiprocessing import Pool
9from pathlib import Path
10from typing import Any, ClassVar, Iterable, Iterator
12from appdirs import user_config_dir
13from rich.markdown import Markdown
14from textual import work
15from textual.app import App, ComposeResult
16from textual.binding import Binding
17from textual.widgets import Footer
19from devboard.board import Column, DataTable
20from devboard.modal import Modal, ModalMixin
22# TODO: Remove once support for Python 3.10 is dropped.
23if sys.version_info >= (3, 11):
24 import tomllib
25else:
26 import tomli as tomllib
28DEBUG = os.getenv("DEBUG", "0") == "1"
31class Devboard(App, ModalMixin): # type: ignore[misc]
32 """The Devboard application."""
34 CSS_PATH = Path(__file__).parent / "devboard.tcss"
35 BINDINGS: ClassVar = [
36 Binding("F5, ctrl+r", "refresh", "Refresh"),
37 Binding("question_mark", "show_help", "Help"),
38 Binding("ctrl+q, q, escape", "exit", "Exit", key_display="Q"),
39 ]
41 # --------------------------------------------------
42 # Textual methods.
43 # --------------------------------------------------
44 def __init__(
45 self,
46 *args: Any,
47 board: str | Path | None = None,
48 background_tasks: bool = True,
49 **kwargs: Any,
50 ) -> None:
51 """Initialize the app."""
52 super().__init__(*args, **kwargs)
53 self._board = board
54 self._config_file = Path(user_config_dir(), "devboard", "config.toml")
55 self._background_tasks = background_tasks
57 def compose(self) -> ComposeResult:
58 """Compose the layout."""
59 for column in self._load_columns():
60 if isinstance(column, Column): 60 ↛ 61line 60 didn't jump to line 61, because the condition on line 60 was never true
61 yield column
62 else:
63 yield column()
64 yield Footer()
66 def on_mount(self) -> None:
67 """Run background tasks."""
68 if self._background_tasks: 68 ↛ exitline 68 didn't return from function 'on_mount', because the condition on line 68 was never false
69 self.fetch_all()
71 # --------------------------------------------------
72 # Binding actions.
73 # --------------------------------------------------
74 def action_show_help(self) -> None:
75 """Show help."""
76 lines = ["# Main keys\n\n"]
77 lines.extend(self._bindings_help(Devboard))
78 lines.extend(self._bindings_help(DataTable, search_up=True))
79 for column in self.query(Column):
80 lines.append(f"\n\n# {column.__class__.TITLE}\n\n")
81 lines.extend(self._bindings_help(column.__class__))
82 self.push_screen(Modal(text=Markdown("\n".join(lines))))
84 def action_refresh(self) -> None:
85 """Refresh all columns."""
86 for column in self.query(Column):
87 column.update()
89 def action_exit(self) -> None:
90 """Exit application."""
91 self.workers.cancel_all()
92 self.exit()
94 # --------------------------------------------------
95 # Additional methods/properties.
96 # --------------------------------------------------
97 @work(thread=True)
98 def fetch_all(self) -> None:
99 """Run `git fetch` in all projects, in background."""
100 projects = set()
101 for column in self.query(Column):
102 projects |= set(column.list_projects())
103 with Pool() as pool:
104 for project in projects:
105 pool.apply_async(project.fetch)
107 def _load_columns(self) -> Iterable[Column | type[Column]]:
108 board: str | Path
109 if self._board is None: 109 ↛ 120line 109 didn't jump to line 120, because the condition on line 109 was never false
110 try:
111 with self._config_file.open("rb") as config_file:
112 config = tomllib.load(config_file)
113 except FileNotFoundError:
114 self._config_file.parent.mkdir(parents=True, exist_ok=True)
115 self._config_file.write_text('board = "default"')
116 board = "default"
117 else:
118 board = config["board"]
119 else:
120 board = self._board
121 if isinstance(board, str): 121 ↛ 129line 121 didn't jump to line 129, because the condition on line 121 was never false
122 board_file = self._config_file.parent.joinpath(f"{board}.py")
123 if not board_file.exists(): 123 ↛ 124line 123 didn't jump to line 124, because the condition on line 123 was never true
124 if board == "default":
125 board_file.write_text(Path(__file__).parent.joinpath("default_board.py").read_text())
126 else:
127 board_file = Path(board)
128 else:
129 board_file = board
130 if not board_file.exists(): 130 ↛ 131line 130 didn't jump to line 131, because the condition on line 130 was never true
131 raise ValueError(f"devboard: error: Unknown board '{board}'")
132 module_path = "devboard.user_board"
133 spec = spec_from_file_location(module_path, str(board_file))
134 if spec is None or spec.loader is None: 134 ↛ 135line 134 didn't jump to line 135, because the condition on line 134 was never true
135 raise ImportError(f"Could not get import spec from '{module_path}'")
136 user_config = module_from_spec(spec)
137 sys.modules[module_path] = user_config
138 spec.loader.exec_module(user_config)
139 return user_config.columns
141 @staticmethod
142 def _bindings_help(cls: type, *, search_up: bool = False) -> Iterator[str]:
143 bindings = cls.BINDINGS if search_up else cls.__dict__.get("BINDINGS", []) # type: ignore[attr-defined]
144 for binding in bindings:
145 if isinstance(binding, tuple):
146 binding = Binding(*binding) # noqa: PLW2901
147 keys = "`, `".join(key.strip().upper().replace("+", "-") for key in binding.key.split(","))
148 yield f"- `{keys}`: {binding.description}"