Skip to content

Commit 051b576

Browse files
authored
Merge pull request #17 from fastai/master
Use `rich` library; add real-time meters; auto-adjust to screen-size; refactor/simplify event handling
2 parents cca8db6 + 838802b commit 051b576

File tree

8 files changed

+326
-796
lines changed

8 files changed

+326
-796
lines changed

‎README.md‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
`ghtop` provides a number of views of all current public activity from all users across the entire GitHub platform. (Note that GitHub delays all events by five minutes.)
66

7-
<img width="850" src="https://user-images.githubusercontent.com/56260/101270865-3f033780-3732-11eb-8dcc-97caf7cc58e6.png" style="max-width: 850px">
7+
<img width="650" src="https://user-images.githubusercontent.com/1483922/105071141-0c3ea580-5a39-11eb-8808-34952c0bf26d.gif" style="max-width: 650px">
88

99
## Install
1010

@@ -14,26 +14,27 @@ Either `pip install ghtop` or `conda install -c fastai ghtop`.
1414

1515
Run `ghtop -h` to view the help:
1616

17-
```bash
17+
```
1818
$ ghtop -h
19-
usage: ghtop [-h] [--include_bots] [--types TYPES] [--filt {user,repo,org}] [--filtval FILTVAL]
20-
{tail,quad,users,simple}
19+
usage: ghtop [-h] [--include_bots] [--types TYPES] [--pause PAUSE] [--filt {users,repo,org}] [--filtval FILTVAL] {tail,quad,users,simple}
2120
2221
positional arguments:
2322
{tail,quad,users,simple} Operation mode to run
2423
2524
optional arguments:
2625
-h, --help show this help message and exit
27-
--include_bots Include bots (there is a lot of them!) (default: False)
28-
--types TYPES Comma-separated types of event to include (e.g PushEvent)
29-
--filt {user,repo,org} Filtering method
26+
--include_bots Include bots (there's a lot of them!) (default: False)
27+
--types TYPES Comma-separated types of event to include (e.g PushEvent) (default: )
28+
--pause PAUSE Number of seconds to pause between requests to the GitHub api (default: 0.4)
29+
--filt {users,repo,org} Filtering method
3030
--filtval FILTVAL Value to filter by (for `repo` use format `owner/repo`)
3131
```
3232

3333
There are 4 views you can choose: `ghtop simple`, `ghtop tail`, `ghtop quad`, or `ghtop users`. Each are shown and described below. All views have the following options:
3434

3535
- `--include_bots`: By default events that appear to be from bots are excluded. Add this flag to include them
3636
- `--types TYPES`: Optional comma-separated list of event types to include (defaults to all types). For a full list of types, see the GitHub [event types docs](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/github-event-types)
37+
- `--pause PAUSE`: Number of seconds to pause between requests to the GitHub api (default: 0.4). It is helpful to adjust this number if you want to get events more or less frequently. For example, if you are filtering all events by an org, then you likely want to pause for a longer period of time than the default.
3738
- `--filt` and `--filtval`: Optionally filter events to just those from one of: `user`, `repo`, or `org`, depending on `filt`. `filtval` is the value to filter by. See the [GitHub docs](https://docs.github.com/en/free-pro-team@latest/rest/reference/activity#list-public-events) for details on the public event API calls used.
3839

3940
**Important note**: while running, `ghtop` will make about 5 API calls per second. GitHub has a quota of 5000 calls per hour. When there are 1000 calls left, `ghtop` will show a warning on every call.
@@ -42,25 +43,25 @@ There are 4 views you can choose: `ghtop simple`, `ghtop tail`, `ghtop quad`, or
4243

4344
A simple dump to your console of all events as they happen.
4445

45-
<img src="https://user-images.githubusercontent.com/346999/101861674-79e7df80-3b25-11eb-92d3-f888843f4aa2.png" width="500" style="max-width: 500px">
46+
<img src="https://user-images.githubusercontent.com/1483922/105074536-56298a80-5a3d-11eb-8e8c-32ba33e09405.png" width="650" style="max-width: 650px">
4647

4748
### ghtop tail
4849

49-
Like `simple`, but removes most bots, and only includes releases, issues and PRs (open, close, and comment events). A summary of the frequency of push events is also shown at the bottom of the screen.
50+
Like `simple`, but only includes releases, issues and PRs (open, close, and comment events). A summary of the frequency of different kind of events along with sparklines are shown at the top of the screen.
5051

51-
<img src="https://user-images.githubusercontent.com/346999/101861658-69376980-3b25-11eb-96ef-9d68f075abf7.png" width="700" style="max-width: 700px">
52+
<img src="https://user-images.githubusercontent.com/1483922/105074448-398d5280-5a3d-11eb-9603-3def521d87e5.png" width="650" style="max-width: 650px">
5253

5354
### ghtop quad
5455

55-
The same information as `tail`, but in a split window showing separately PRs, issues, pushes, and releases. This view does not remove bot activity.
56+
The same information as `tail`, but in a split window showing separately PRs, issues, pushes, and releases.
5657

57-
<img src="https://user-images.githubusercontent.com/346999/101861560-2ecdcc80-3b25-11eb-9fba-25382b2df65f.png" width="900" style="max-width: 900px">
58+
<img src="https://user-images.githubusercontent.com/1483922/105074862-cb955b00-5a3d-11eb-99c5-3125bb98910b.png" width="650" style="max-width: 650px">
5859

5960
### ghtop users
6061

6162
A summary of activity for the most active current users.
6263

63-
<img src="https://user-images.githubusercontent.com/346999/101861612-4b6a0480-3b25-11eb-8124-19bb2434c27e.png" width="500" style="max-width: 500px">
64+
<img src="https://user-images.githubusercontent.com/1483922/105075363-8887b780-5a3e-11eb-9f7a-627268ac465f.png" width="650" style="max-width: 650px">
6465

6566
----
6667

‎ghtop/all_rich.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from rich import print as pr
2+
from rich.align import *
3+
from rich.bar import *
4+
from rich.color import *
5+
from rich.columns import *
6+
from rich.console import *
7+
from rich.emoji import *
8+
from rich.highlighter import *
9+
from rich.live import *
10+
from rich.logging import *
11+
from rich.markdown import *
12+
from rich.markup import *
13+
from rich.measure import *
14+
from rich.padding import *
15+
from rich.panel import *
16+
from rich.pretty import *
17+
from rich.progress_bar import *
18+
from rich.progress import *
19+
from rich.prompt import *
20+
from rich.protocol import *
21+
from rich.rule import *
22+
from rich.segment import *
23+
from rich.spinner import *
24+
from rich.status import *
25+
from rich.style import *
26+
from rich.styled import *
27+
from rich.syntax import *
28+
from rich.table import *
29+
from rich.text import *
30+
from rich.theme import *
31+
from rich.traceback import *
32+
from fastcore.all import *
33+
34+
@delegates(Style)
35+
def text(s, maxlen=None, **kwargs):
36+
"Create a styled `Text` object"
37+
if maxlen: s = truncstr(s, maxlen=maxlen)
38+
return Text(s, style=Style(**kwargs))
39+
40+
@delegates(Style)
41+
def segment(s, maxlen=None, space=' ', **kwargs):
42+
"Create a styled `Segment` object"
43+
if maxlen: s = truncstr(s, maxlen=maxlen, space=space)
44+
return Segment(s, style=Style(**kwargs))
45+
46+
class Segments(list):
47+
def __init__(self, options): self.w = options.max_width
48+
49+
@property
50+
def chars(self): return sum(o.cell_length for o in self)
51+
def txtlen(self, pct): return min((self.w-self.chars)*pct, 999)
52+
53+
@delegates(segment)
54+
def add(self, x, maxlen=None, pct=None, **kwargs):
55+
if pct: maxlen = math.ceil(self.txtlen(pct))
56+
self.append(segment(x, maxlen=maxlen, **kwargs))
57+
58+
@delegates(Table)
59+
def _grid(box=None, padding=0, collapse_padding=True, pad_edge=False, expand=False, show_header=False, show_edge=False, **kwargs):
60+
return Table(padding=padding, pad_edge=pad_edge, expand=expand, collapse_padding=collapse_padding,
61+
box=box, show_header=show_header, show_edge=show_edge, **kwargs)
62+
63+
@delegates(_grid)
64+
def grid(items, expand=True, no_wrap=True, **kwargs):
65+
g = _grid(expand=expand, **kwargs)
66+
for c in items[0]: g.add_column(no_wrap=no_wrap, justify='center')
67+
for i in items: g.add_row(*i)
68+
return g
69+
70+
Color = str_enum('Color', *ANSI_COLOR_NAMES)
71+
72+
class Deque(deque):
73+
def __rich__(self): return RenderGroup(*(filter(None, self)))
74+
75+
@delegates()
76+
class FixedPanel(Panel, GetAttr):
77+
_default='renderable'
78+
def __init__(self, height, **kwargs):
79+
super().__init__(Deque([' ']*height, maxlen=height), **kwargs)
80+
81+
@delegates(Style)
82+
def add(self, s:str, **kwargs):
83+
"Add styled `s` to panel"
84+
self.append(text(s, **kwargs))

‎ghtop/ghtop.py‎

Lines changed: 96 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22

3-
__all__ = ['term', 'logfile', 'github_auth_device', 'limit_cb', 'api', 'Events', 'print_event', 'tail_events',
3+
__all__ = ['get_sparklines', 'ETYPES', 'term', 'tdim', 'limit_cb', 'Events', 'print_event', 'pct_comp', 'tail_events',
44
'watch_users', 'quad_logs', 'simple', 'main']
55

66

@@ -14,25 +14,27 @@
1414
from fastcore.foundation import *
1515
from fastcore.script import *
1616
from ghapi.all import *
17+
from .richext import *
18+
from .all_rich import (Console, Color, FixedPanel, box, Segments, Live,
19+
grid, ConsoleOptions, Progress, BarColumn, Spinner, Table)
1720

21+
ETYPES=PushEvent,PullRequestEvent,IssuesEvent,ReleaseEvent
1822

19-
term = Terminal()
20-
logfile = Path("log.txt")
23+
def get_sparklines():
24+
s1 = ESpark('Push', 'magenta', [PushEvent], mx=30)
25+
s2 = ESpark('PR', 'yellow', [PullRequestEvent, PullRequestReviewCommentEvent, PullRequestReviewEvent], mx=8)
26+
s3 = ESpark('Issues', 'green', [IssueCommentEvent,IssuesEvent], mx=6)
27+
s4 = ESpark('Releases', 'blue', [ReleaseEvent], mx=0.4)
28+
s5 = ESpark('All Events', 'orange', mx=45)
2129

30+
return Stats([s1,s2,s3,s4,s5], store=5, span=5, spn_lbl='5/s', show_freq=True)
2231

23-
def github_auth_device(wb='', n_polls=9999):
24-
"Authenticate with GitHub, polling up to `n_polls` times to wait for completion"
25-
auth = GhDeviceAuth()
26-
print(f"First copy your one-time code: {term.yellow}{auth.user_code}{term.normal}")
27-
print(f"Then visit {auth.verification_uri} in your browser, and paste the code when prompted.")
28-
if not wb: wb = input("Shall we try to open the link for you? [y/n] ")
29-
if wb[0].lower()=='y': auth.open_browser()
3032

31-
print("Waiting for authorization...", end='')
32-
token = auth.wait(lambda: print('.', end=''), n_polls=n_polls)
33-
if not token: return print('Authentication not complete!')
34-
print("Authenticated with GitHub")
35-
return token
33+
term = Terminal()
34+
35+
tdim = L(os.popen('stty size', 'r').read().split())
36+
if not tdim: theight,twidth = 15,15
37+
else: theight,twidth = tdim.map(lambda x: max(int(x)-4, 15))
3638

3739

3840
def _exit(msg):
@@ -46,18 +48,6 @@ def limit_cb(rem,quota):
4648
if rem < 1000: print(f"{w}\nRemaining calls: {rem} out of {quota}\n{w}", file=sys.stderr)
4749

4850

49-
def _get_api():
50-
path = Path.home()/".ghtop_token"
51-
if path.is_file():
52-
try: token = path.read_text().strip()
53-
except: _exit("Error reading token")
54-
else: token = github_auth_device()
55-
path.write_text(token)
56-
return GhApi(limit_cb=limit_cb, token=token)
57-
58-
api = _get_api()
59-
60-
6151
Events = dict(
6252
IssuesEvent_closed=('⭐', 'closed', noop),
6353
IssuesEvent_opened=('📫', 'opened', noop),
@@ -78,79 +68,120 @@ def _to_log(e):
7868
elif e.type == "ReleaseEvent": return f'🚀 {login} released {e.payload.release.tag_name} of {repo}'
7969

8070

81-
def print_event(e, commits_counter):
71+
def print_event(e, counter):
8272
res = _to_log(e)
8373
if res: print(res)
84-
elif e.type == "PushEvent": [commits_counter.update() for c in e.payload.commits]
74+
elif counter and e.type == "PushEvent": [counter.update() for c in e.payload.commits]
8575
elif e.type == "SecurityAdvisoryEvent": print(term.blink("SECURITY ADVISORY"))
8676

8777

88-
def tail_events(evt):
89-
"Print events from `fetch_events` along with a counter of push events"
90-
manager = enlighten.get_manager()
91-
commits = manager.counter(desc='Commits', unit='commits', color='green')
92-
for ev in evt: print_event(ev, commits)
78+
def pct_comp(api): return int(((5000-int(api.limit_rem)) / 5000) * 100)
9379

9480

95-
def _pr_row(*its): print(f"{its[0]: <30} {its[1]: <6} {its[2]: <5} {its[3]: <6} {its[4]: <7}")
96-
def watch_users(evts):
81+
def tail_events(evt, api):
82+
"Print events from `fetch_events` along with a counter of push events"
83+
p = FixedPanel(theight, box=box.HORIZONTALS, title='ghtop')
84+
s = get_sparklines()
85+
g = grid([[s], [p]])
86+
with Live(g):
87+
for e in evt:
88+
s.add_events(e)
89+
s.update_prog(pct_comp(api))
90+
p.append(e)
91+
g = grid([[s], [p]])
92+
93+
94+
def _user_grid():
95+
g = Table.grid(expand=True)
96+
g.add_column(justify="left")
97+
for i in range(4): g.add_column(justify="center")
98+
g.add_row("", "", "", "", "")
99+
g.add_row("User", "Events", "PRs", "Issues", "Pushes")
100+
return g
101+
102+
103+
def watch_users(evts, api):
97104
"Print a table of the users with the most events"
98105
users,users_events = defaultdict(int),defaultdict(lambda: defaultdict(int))
99-
while True:
100-
for x in islice(evts, 10):
101-
users[x.actor.login] += 1
102-
users_events[x.actor.login][x.type] += 1
103-
104-
print(term.clear())
105-
_pr_row("User", "Events", "PRs", "Issues", "Pushes")
106-
sorted_users = sorted(users.items(), key=lambda o: (o[1],o[0]), reverse=True)
107-
for u in sorted_users[:20]:
108-
_pr_row(*u, *itemgetter('PullRequestEvent','IssuesEvent','PushEvent')(users_events[u[0]]))
109106

107+
with Live() as live:
108+
s = get_sparklines()
109+
while True:
110+
for x in islice(evts, 10):
111+
users[x.actor.login] += 1
112+
users_events[x.actor.login][x.type] += 1
113+
s.add_events(x)
110114

111-
def _push_to_log(e): return f"{e.actor.login} pushed {len(e.payload.commits)} commits to repo {e.repo.name}"
112-
def _logwin(title,color): return Log(title=title,border_color=2,color=color)
115+
ig = _user_grid()
116+
sorted_users = sorted(users.items(), key=lambda o: (o[1],o[0]), reverse=True)
117+
for u in sorted_users[:theight]:
118+
data = (*u, *itemgetter('PullRequestEvent','IssuesEvent','PushEvent')(users_events[u[0]]))
119+
ig.add_row(*L(data).map(str))
113120

114-
def quad_logs(evts):
115-
"Print 4 panels, showing most recent issues, commits, PRs, and releases"
116-
term.enter_fullscreen()
117-
ui = HSplit(VSplit(_logwin('Issues', color=7), _logwin('Commits' , color=3)),
118-
VSplit(_logwin('Pull Requests', color=4), _logwin('Releases', color=5)))
121+
s.update_prog(pct_comp(api))
122+
g = grid([[s], [ig]])
123+
live.update(g)
119124

120-
issues,commits,prs,releases = all_items = ui.items[0].items+ui.items[1].items
121-
for o in all_items: o.append(" ")
122125

123-
d = dict(PushEvent=commits, IssuesEvent=issues, IssueCommentEvent=issues, PullRequestEvent=prs, ReleaseEvent=releases)
124-
while True:
125-
for x in islice(evts, 10):
126-
f = [_to_log,_push_to_log][x.type == 'PushEvent']
127-
if x.type in d: d[x.type].append(f(x)[:95])
128-
ui.display()
126+
def _panelDict2Grid(pd):
127+
ispush,ispr,isiss,isrel = pd.values()
128+
return grid([[ispush,ispr],[isiss,isrel]], width=twidth)
129129

130130

131-
def simple(evts):
131+
def quad_logs(evts, api):
132+
"Print 4 panels, showing most recent issues, commits, PRs, and releases"
133+
pd = {o:FixedPanel(height=(theight//2)-1,
134+
width=(twidth//2)-1,
135+
box=box.HORIZONTALS,
136+
title=camel2words(remove_suffix(o.__name__,'Event'))) for o in ETYPES}
137+
p = _panelDict2Grid(pd)
138+
s = get_sparklines()
139+
g = grid([[s], [p]])
140+
with Live(g):
141+
for e in evts:
142+
s.add_events(e)
143+
s.update_prog(pct_comp(api))
144+
typ = type(e)
145+
if typ in pd: pd[typ].append(e)
146+
p = _panelDict2Grid(pd)
147+
g = grid([[s], [p]])
148+
149+
150+
def simple(evts, api):
132151
for ev in evts: print(f"{ev.actor.login} {ev.type} {ev.repo.name}")
133152

134153

154+
def _get_token():
155+
path = Path.home()/".ghtop_token"
156+
if path.is_file():
157+
try: return path.read_text().strip()
158+
except: _exit("Error reading token")
159+
else: token = github_auth_device()
160+
path.write_text(token)
161+
return token
162+
163+
135164
def _signal_handler(sig, frame):
136165
if sig != signal.SIGINT: return
137166
print(term.exit_fullscreen(),term.clear(),term.normal)
138167
sys.exit(0)
139168

140169
_funcs = dict(tail=tail_events, quad=quad_logs, users=watch_users, simple=simple)
141-
_filts = str_enum('_filts', 'user', 'repo', 'org')
170+
_filts = str_enum('_filts', 'users', 'repo', 'org')
142171
_OpModes = str_enum('_OpModes', *_funcs)
143172

144173
@call_parse
145174
def main(mode: Param("Operation mode to run", _OpModes),
146175
include_bots: Param("Include bots (there's a lot of them!)", store_true)=False,
147176
types: Param("Comma-separated types of event to include (e.g PushEvent)", str)='',
177+
pause: Param("Number of seconds to pause between requests to the GitHub api", float)=0.4,
148178
filt: Param("Filtering method", _filts)=None,
149179
filtval: Param("Value to filter by (for `repo` use format `owner/repo`)", str)=None):
150180
signal.signal(signal.SIGINT, _signal_handler)
151181
types = types.split(',') if types else None
152182
if filt and not filtval: _exit("Must pass `filter_value` if passing `filter_type`")
153183
if filtval and not filt: _exit("Must pass `filter_type` if passing `filter_value`")
154184
kwargs = {filt:filtval} if filt else {}
155-
evts = api.fetch_events(types=types, incl_bot=include_bots, **kwargs)
156-
_funcs[mode](evts)
185+
api = GhApi(limit_cb=limit_cb, token=_get_token())
186+
evts = api.fetch_events(types=types, incl_bot=include_bots, pause=float(pause), **kwargs)
187+
_funcs[mode](evts, api)

0 commit comments

Comments
 (0)