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

1"""The Textual application.""" 

2 

3from __future__ import annotations 

4 

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 

11 

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 

18 

19from devboard.board import Column, DataTable 

20from devboard.modal import Modal, ModalMixin 

21 

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 

27 

28DEBUG = os.getenv("DEBUG", "0") == "1" 

29 

30 

31class Devboard(App, ModalMixin): # type: ignore[misc] 

32 """The Devboard application.""" 

33 

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 ] 

40 

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 

56 

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() 

65 

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() 

70 

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)))) 

83 

84 def action_refresh(self) -> None: 

85 """Refresh all columns.""" 

86 for column in self.query(Column): 

87 column.update() 

88 

89 def action_exit(self) -> None: 

90 """Exit application.""" 

91 self.workers.cancel_all() 

92 self.exit() 

93 

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) 

106 

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 

140 

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}"