AllGitStatus 0.14.9__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.
@@ -0,0 +1,442 @@
1
+ # noqa: D100
2
+ import contextlib
3
+ import textwrap
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import aiohttp
9
+ from rich.text import Text
10
+ from rich.traceback import Traceback
11
+ from textual.app import App, ComposeResult, ScreenStackError
12
+ from textual.containers import Horizontal, Vertical
13
+ from textual.coordinate import Coordinate
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import DataTable, Footer, Header, Label, RichLog
16
+
17
+ from AllGitStatus import __version__
18
+ from AllGitStatus.Repository import EnumerateRepositories, Repository
19
+ from AllGitStatus.Sources.GitHubSource import GitHubSource
20
+ from AllGitStatus.Sources.LocalGitSource import LocalGitSource
21
+ from AllGitStatus.Sources.Source import ErrorInfo, ResultInfo
22
+
23
+
24
+ # ----------------------------------------------------------------------
25
+ # ----------------------------------------------------------------------
26
+ @dataclass(frozen=True)
27
+ class Column:
28
+ """Column displayed within a data table."""
29
+
30
+ value: int
31
+ name: str
32
+ justify: str
33
+
34
+
35
+ # ----------------------------------------------------------------------
36
+ NameColumn = Column(0, "Name", "left")
37
+ BranchColumn = Column(1, "Branch", "center")
38
+ LocalColumn = Column(2, "Local", "center")
39
+ StashesColumn = Column(3, "Stashes", "center")
40
+ RemoteColumn = Column(4, "Remote", "center")
41
+ StarsColumn = Column(5, "Stars", "center")
42
+ ForksColumn = Column(6, "Forks", "center")
43
+ WatchersColumn = Column(7, "Watchers", "center")
44
+ IssuesColumn = Column(8, "Issues", "center")
45
+ PullRequestsColumn = Column(9, "PRs", "center")
46
+ SecurityAlertsColumn = Column(10, "Security", "center")
47
+ CICDStatusColumn = Column(11, "CI/CD", "center")
48
+ ReleaseColumn = Column(12, "Release", "center")
49
+ ArchivedColumn = Column(13, "Archived", "center")
50
+
51
+ COLUMN_MAP: dict[
52
+ tuple[
53
+ str, # source class name
54
+ str | None, # source key value
55
+ ],
56
+ Column,
57
+ ] = {
58
+ ("", ""): NameColumn,
59
+ (LocalGitSource.__name__, "current_branch"): BranchColumn,
60
+ (LocalGitSource.__name__, "local_status"): LocalColumn,
61
+ (LocalGitSource.__name__, "stashes"): StashesColumn,
62
+ (LocalGitSource.__name__, "remote_status"): RemoteColumn,
63
+ (GitHubSource.__name__, "stars"): StarsColumn,
64
+ (GitHubSource.__name__, "forks"): ForksColumn,
65
+ (GitHubSource.__name__, "watchers"): WatchersColumn,
66
+ (GitHubSource.__name__, "issues"): IssuesColumn,
67
+ (GitHubSource.__name__, "pull_requests"): PullRequestsColumn,
68
+ (GitHubSource.__name__, "security_alerts"): SecurityAlertsColumn,
69
+ (GitHubSource.__name__, "cicd_status"): CICDStatusColumn,
70
+ (GitHubSource.__name__, "release"): ReleaseColumn,
71
+ (GitHubSource.__name__, "archived"): ArchivedColumn,
72
+ }
73
+
74
+
75
+ # ----------------------------------------------------------------------
76
+ class MainApp(App):
77
+ """Main application."""
78
+
79
+ CSS_PATH = Path(__file__).with_suffix(".tcss")
80
+
81
+ BINDINGS = [ # noqa: RUF012
82
+ ("R", "RefreshAll", "Refresh All"),
83
+ ("r", "RefreshSelected", "Refresh"),
84
+ ("p", "PullSelected", "Pull"),
85
+ ("P", "PushSelected", "Push"),
86
+ ("q", "quit", "Quit"),
87
+ ]
88
+
89
+ # ----------------------------------------------------------------------
90
+ def __init__(
91
+ self,
92
+ working_dir: Path,
93
+ github_pat: str | None,
94
+ *args,
95
+ debug: bool = False,
96
+ **kwargs,
97
+ ) -> None:
98
+ super().__init__(*args, **kwargs)
99
+
100
+ self._working_dir = working_dir
101
+ self._github_pat = github_pat
102
+ self._debug = debug
103
+
104
+ self.title = "AllGitStatus{}".format(" [DEBUG]" if debug else "")
105
+
106
+ self._data_table: DataTable = DataTable(
107
+ cursor_type="cell",
108
+ zebra_stripes=True,
109
+ id="data_table",
110
+ )
111
+
112
+ self._data_table.border_title = "[1] Repositories"
113
+
114
+ self._additional_info = RichLog(id="additional_info", auto_scroll=False)
115
+ self._additional_info.border_title = "[2] Additional Info"
116
+
117
+ self._repositories: list[Repository] | None = None
118
+
119
+ self._additional_info_data: dict[
120
+ int, # row_index
121
+ dict[
122
+ int, # column_index
123
+ object,
124
+ ],
125
+ ] = {}
126
+
127
+ self._state_data: dict[
128
+ int, # row_index
129
+ dict[
130
+ int, # column_index
131
+ object,
132
+ ],
133
+ ] = {}
134
+
135
+ # The lifetime of this object is defined by `on_mount` and `on_unmount` as aiohttp.ClientSession
136
+ # requires an active event loop
137
+ self._github_session: aiohttp.ClientSession | None = None
138
+
139
+ # ----------------------------------------------------------------------
140
+ def compose(self) -> ComposeResult: # noqa: D102
141
+ yield Header()
142
+ yield Vertical(
143
+ self._data_table,
144
+ self._additional_info,
145
+ id="vertical_group",
146
+ )
147
+ yield Horizontal(Footer(), Label(__version__), id="footer")
148
+
149
+ # ----------------------------------------------------------------------
150
+ async def on_mount(self) -> None: # noqa: D102
151
+ assert self._github_session is None
152
+ self._github_session = aiohttp.ClientSession(
153
+ headers=GitHubSource.CreateGitHubHttpHeaders(self._github_pat)
154
+ )
155
+
156
+ for column in COLUMN_MAP.values():
157
+ self._data_table.add_column(Text(column.name, justify=column.justify)) # ty: ignore[invalid-argument-type]
158
+
159
+ await self._ResetAllRepositories()
160
+
161
+ # ----------------------------------------------------------------------
162
+ async def on_unmount(self) -> None: # noqa: D102
163
+ # Close the shared session when the app exits
164
+ if self._github_session is not None:
165
+ await self._github_session.close()
166
+ self._github_session = None
167
+
168
+ # ----------------------------------------------------------------------
169
+ async def on_data_table_cell_highlighted(self, message: DataTable.ColumnSelected) -> None: # noqa: ARG002, D102
170
+ await self._OnSelectionChanged()
171
+
172
+ # ----------------------------------------------------------------------
173
+ async def action_RefreshAll(self) -> None: # noqa: D102
174
+ await self._ResetAllRepositories()
175
+
176
+ # ----------------------------------------------------------------------
177
+ async def action_RefreshSelected(self) -> None: # noqa: D102
178
+ assert self._repositories is not None
179
+
180
+ await self._ResetRepository(
181
+ self._repositories[self._data_table.cursor_coordinate.row],
182
+ self._data_table.cursor_coordinate.row,
183
+ )
184
+
185
+ # ----------------------------------------------------------------------
186
+ async def action_PullSelected(self) -> None: # noqa: D102
187
+ assert self._repositories is not None
188
+
189
+ repository = self._repositories[self._data_table.cursor_coordinate.row]
190
+
191
+ await LocalGitSource.Pull(repository)
192
+ await self._ResetRepository(repository, self._data_table.cursor_coordinate.row)
193
+
194
+ # ----------------------------------------------------------------------
195
+ async def action_PushSelected(self) -> None: # noqa: D102
196
+ assert self._repositories is not None
197
+
198
+ repository = self._repositories[self._data_table.cursor_coordinate.row]
199
+
200
+ await LocalGitSource.Push(repository)
201
+ await self._ResetRepository(repository, self._data_table.cursor_coordinate.row)
202
+
203
+ # ----------------------------------------------------------------------
204
+ def key_1(self) -> None: # noqa: D102
205
+ self._data_table.focus()
206
+
207
+ # ----------------------------------------------------------------------
208
+ def key_2(self) -> None: # noqa: D102
209
+ self._additional_info.focus()
210
+
211
+ # ----------------------------------------------------------------------
212
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: # noqa: ARG002, D102
213
+ if action == "RefreshAll":
214
+ if self._repositories is not None:
215
+ return True
216
+
217
+ return None
218
+
219
+ if action == "RefreshSelected":
220
+ if self._repositories is not None:
221
+ return True
222
+
223
+ return None
224
+
225
+ if action == "PullSelected":
226
+ if self._repositories is not None:
227
+ state_data = self._state_data.get(self._data_table.cursor_coordinate.row, {}).get(
228
+ RemoteColumn.value, {}
229
+ )
230
+
231
+ if state_data and state_data["has_remote_changes"]: # ty: ignore[not-subscriptable]
232
+ return True
233
+
234
+ return None
235
+
236
+ if action == "PushSelected":
237
+ if self._repositories is not None:
238
+ state_data = self._state_data.get(self._data_table.cursor_coordinate.row, {}).get(
239
+ RemoteColumn.value, {}
240
+ )
241
+
242
+ if state_data and state_data["has_local_changes"]: # ty: ignore[not-subscriptable]
243
+ return True
244
+
245
+ return None
246
+
247
+ return True
248
+
249
+ # ----------------------------------------------------------------------
250
+ # |
251
+ # | Private Methods
252
+ # |
253
+ # ----------------------------------------------------------------------
254
+ async def _ResetAllRepositories(self) -> None:
255
+ self._repositories = None
256
+ self._additional_info_data.clear()
257
+ self._state_data.clear()
258
+ self._data_table.clear()
259
+
260
+ await self._OnSelectionChanged()
261
+
262
+ # Get the repositories
263
+
264
+ # ----------------------------------------------------------------------
265
+ async def OnRepositoriesComplete(repositories: list[Repository] | None) -> None:
266
+ if repositories is None:
267
+ return
268
+
269
+ assert self._repositories is None
270
+ self._repositories = repositories
271
+
272
+ for repository_index, repository in enumerate(repositories):
273
+ self._data_table.add_row()
274
+
275
+ await self._ResetRepository(repository, repository_index)
276
+
277
+ # ----------------------------------------------------------------------
278
+
279
+ self.push_screen(_GetRepositoriesModal(self._working_dir), OnRepositoriesComplete)
280
+
281
+ # ----------------------------------------------------------------------
282
+ async def _ResetRepository(self, repository: Repository, repository_index: int) -> None:
283
+ self._additional_info_data.pop(repository_index, None)
284
+ self._state_data.pop(repository_index, None)
285
+
286
+ for column in COLUMN_MAP.values():
287
+ self._data_table.update_cell_at(
288
+ Coordinate(repository_index, column.value),
289
+ "",
290
+ )
291
+
292
+ if repository_index == self._data_table.cursor_coordinate.row:
293
+ await self._OnSelectionChanged()
294
+ self._RefreshBindings()
295
+
296
+ # Create the repo name
297
+ if repository.path == self._working_dir:
298
+ repo_name = repository.path.name
299
+ else:
300
+ repo_name = str(repository.path.relative_to(self._working_dir))
301
+
302
+ self._data_table.update_cell_at(
303
+ Coordinate(repository_index, NameColumn.value),
304
+ Text(f"📂 {repo_name}", justify=NameColumn.justify), # ty: ignore[invalid-argument-type]
305
+ update_width=True,
306
+ )
307
+
308
+ self._additional_info_data.setdefault(repository_index, {})[NameColumn.value] = textwrap.dedent(
309
+ f"""\
310
+ Local: {repository.path}
311
+ Origin: {repository.remote_url or ""}
312
+ """,
313
+ )
314
+
315
+ # Load the content
316
+
317
+ # ----------------------------------------------------------------------
318
+ async def LoadCells() -> None:
319
+ assert self._github_session is not None
320
+
321
+ sources = [
322
+ LocalGitSource(),
323
+ GitHubSource(self._github_session),
324
+ ]
325
+
326
+ # Set all of the column values to pending
327
+ for source in sources:
328
+ if not source.Applies(repository):
329
+ continue
330
+
331
+ for column_key, column in COLUMN_MAP.items():
332
+ if not column_key[0] and not column_key[1]:
333
+ continue
334
+
335
+ if column_key[0] != source.__class__.__name__:
336
+ continue
337
+
338
+ self._data_table.update_cell_at(
339
+ Coordinate(repository_index, column.value),
340
+ Text("⏳", justify=column.justify), # ty: ignore[invalid-argument-type]
341
+ update_width=True,
342
+ )
343
+
344
+ # Get the actual values
345
+ for source in sources:
346
+ if not source.Applies(repository):
347
+ continue
348
+
349
+ async for info in source.Query(repository):
350
+ await self._PopulateCell(repository_index, info)
351
+
352
+ # ----------------------------------------------------------------------
353
+
354
+ self.run_worker(LoadCells())
355
+
356
+ # ----------------------------------------------------------------------
357
+ async def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) -> None:
358
+ column = COLUMN_MAP[info.key]
359
+
360
+ if isinstance(info, ErrorInfo):
361
+ display_value = "💥"
362
+ additional_info = Traceback.from_exception(
363
+ type(info.error),
364
+ info.error,
365
+ info.error.__traceback__ if self._debug else None,
366
+ )
367
+
368
+ elif isinstance(info, ResultInfo):
369
+ display_value = info.display_value
370
+ additional_info = info.additional_info
371
+
372
+ if info.state_data is not None:
373
+ self._state_data.setdefault(repository_index, {})[column.value] = info.state_data
374
+
375
+ if repository_index == self._data_table.cursor_coordinate.row:
376
+ self._RefreshBindings()
377
+
378
+ else:
379
+ assert False, info # noqa: B011, PT015 # pragma: no cover
380
+
381
+ self._data_table.update_cell_at(
382
+ Coordinate(repository_index, column.value),
383
+ Text(display_value, justify=column.justify), # ty: ignore[invalid-argument-type]
384
+ update_width=True,
385
+ )
386
+
387
+ self._additional_info_data.setdefault(repository_index, {})[column.value] = additional_info
388
+
389
+ if self._data_table.cursor_row == repository_index and self._data_table.cursor_column == column.value:
390
+ await self._OnSelectionChanged()
391
+
392
+ # ----------------------------------------------------------------------
393
+ async def _OnSelectionChanged(self) -> None:
394
+ self._additional_info.clear()
395
+ self._RefreshBindings()
396
+
397
+ row_index = self._data_table.cursor_coordinate.row
398
+ col_index = self._data_table.cursor_coordinate.column
399
+
400
+ additional_info = self._additional_info_data.get(row_index, {}).get(col_index)
401
+
402
+ if additional_info:
403
+ self._additional_info.write(additional_info)
404
+ self._additional_info.scroll_home()
405
+
406
+ # ----------------------------------------------------------------------
407
+ def _RefreshBindings(self) -> None:
408
+ # ScreenStackErrors are occasionally raised when testing
409
+ with contextlib.suppress(ScreenStackError):
410
+ self.refresh_bindings()
411
+
412
+
413
+ # ----------------------------------------------------------------------
414
+ # ----------------------------------------------------------------------
415
+ # ----------------------------------------------------------------------
416
+ class _GetRepositoriesModal(ModalScreen[list[Repository]]):
417
+ CSS = """
418
+ #GetRepositoriesModal {
419
+ background: $panel;
420
+ padding: 1;
421
+ }
422
+ """
423
+
424
+ # ----------------------------------------------------------------------
425
+ def __init__(self, working_dir: Path, *args, **kwargs) -> None:
426
+ self._working_dir = working_dir
427
+ super().__init__(*args, **kwargs)
428
+
429
+ # ----------------------------------------------------------------------
430
+ def compose(self) -> ComposeResult:
431
+ yield Label(f"Searching for repositories in '{self._working_dir}'...", id="GetRepositoriesModal")
432
+
433
+ # ----------------------------------------------------------------------
434
+ async def on_mount(self) -> None:
435
+ # ----------------------------------------------------------------------
436
+ async def Execute() -> None:
437
+ repos = [repository async for repository in EnumerateRepositories(self._working_dir)]
438
+ self.dismiss(repos)
439
+
440
+ # ----------------------------------------------------------------------
441
+
442
+ self.run_worker(Execute())
@@ -0,0 +1,36 @@
1
+ _GetRepositoriesModal {
2
+ align: center middle;
3
+ }
4
+
5
+ #vertical_group {
6
+ height: 100%;
7
+ width: 100%;
8
+
9
+ #data_table {
10
+ border: solid $primary-muted;
11
+ height: 2fr;
12
+ width: 100%;
13
+ }
14
+
15
+ #additional_info {
16
+ border: solid $primary-muted;
17
+ height: 1fr;
18
+ width: 100%;
19
+ }
20
+ }
21
+
22
+ #footer {
23
+ dock: bottom;
24
+ height: 1;
25
+
26
+ Footer {
27
+ width: 75%;
28
+ }
29
+
30
+ Label {
31
+ dock: right;
32
+ background: $footer-background;
33
+ text-align: center;
34
+ width: 25%;
35
+ }
36
+ }
@@ -0,0 +1,91 @@
1
+ # noqa: D100
2
+ import asyncio
3
+ import os
4
+ import re
5
+
6
+ from collections.abc import AsyncGenerator
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ # ----------------------------------------------------------------------
12
+ # |
13
+ # | Public Types
14
+ # |
15
+ # ----------------------------------------------------------------------
16
+ @dataclass(frozen=True)
17
+ class Repository:
18
+ """Represents a local git repository."""
19
+
20
+ # ----------------------------------------------------------------------
21
+ path: Path
22
+ remote_url: str | None = None
23
+ github_owner: str | None = None
24
+ github_repo: str | None = None
25
+
26
+ # ----------------------------------------------------------------------
27
+ @classmethod
28
+ async def FromDirectory(cls, path: Path) -> "Repository":
29
+ """Create a Repository object from a local directory containing a git repository."""
30
+
31
+ remote_url: str | None = None
32
+ github_owner: str | None = None
33
+ github_repo: str | None = None
34
+
35
+ # Get the remote url (if possible)
36
+ try:
37
+ proc = await asyncio.create_subprocess_exec(
38
+ "git",
39
+ "-C",
40
+ str(path),
41
+ "remote",
42
+ "get-url",
43
+ "origin",
44
+ stdout=asyncio.subprocess.PIPE,
45
+ stderr=asyncio.subprocess.STDOUT,
46
+ )
47
+
48
+ content, _ = await proc.communicate()
49
+ content = content.decode().strip()
50
+
51
+ if proc.returncode == 0:
52
+ remote_url = content
53
+
54
+ except Exception: # noqa: S110
55
+ pass
56
+
57
+ # Extract the owner and repo from the remote url
58
+ if remote_url is not None:
59
+ for pattern in [
60
+ r"github\.com[:/]([^/]+)/([^/.]+?)(?:\.git)?$",
61
+ r"github\.com/([^/]+)/([^/]+?)/?$",
62
+ ]:
63
+ match = re.search(pattern, remote_url)
64
+ if match is not None:
65
+ github_owner = match.group(1)
66
+ github_repo = match.group(2)
67
+
68
+ break
69
+
70
+ return cls(path, remote_url, github_owner, github_repo)
71
+
72
+
73
+ # ----------------------------------------------------------------------
74
+ # |
75
+ # | Public Functions
76
+ # |
77
+ # ----------------------------------------------------------------------
78
+ async def EnumerateRepositories(root_path: Path) -> AsyncGenerator[Repository]:
79
+ """Recursively enumerate all git repositories under the specified root path."""
80
+
81
+ if not root_path.is_dir(): # noqa: ASYNC240
82
+ return
83
+
84
+ for this_root_str, directories, _ in os.walk(root_path):
85
+ this_root = Path(this_root_str)
86
+
87
+ if ".git" in directories:
88
+ yield await Repository.FromDirectory(this_root)
89
+
90
+ # Do not spend any more time searching this directory and its descendants
91
+ directories[:] = []