-
Notifications
You must be signed in to change notification settings - Fork 67
/
Copy path_state.py
274 lines (220 loc) · 8.54 KB
/
_state.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
"""
Interfaces for for propagating feedback from the API to provide responsive progress indicators as
well as a progress spinner implementation for use with CLI applications.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from collections.abc import Sequence
from logging.handlers import MemoryHandler
from typing import Any
from rich.align import StyleType
from rich.console import Console, Group, RenderableType
from rich.live import Live
from rich.panel import Panel
from rich.status import Spinner
class AuditState:
"""
An object that handles abstract "updates" to `pip-audit`'s state.
Non-UI consumers of `pip-audit` (via `pip_audit`) should have no need for
this class, and can leave it as a default construction in whatever signatures
it appears in. Its primary use is internal and UI-specific: it exists solely
to give the CLI enough state for a responsive progress indicator during
user requests.
"""
def __init__(self, *, members: Sequence[_StateActor] = []):
"""
Create a new `AuditState` with the given member list.
"""
self._members = members
def update_state(self, message: str, logs: str | None = None) -> None:
"""
Called whenever `pip_audit`'s internal state changes in a way that's meaningful to
expose to a user.
`message` is the message to present to the user.
"""
for member in self._members:
member.update_state(message, logs)
def initialize(self) -> None:
"""
Called when `pip-audit`'s state is initializing.
"""
for member in self._members:
member.initialize()
def finalize(self) -> None:
"""
Called when `pip_audit`'s state is "done" changing.
"""
for member in self._members:
member.finalize()
def __enter__(self) -> AuditState: # pragma: no cover
"""
Create an instance of the `pip-audit` state for usage within a `with` statement.
"""
self.initialize()
return self
def __exit__(
self, _exc_type: Any, _exc_value: Any, _exc_traceback: Any
) -> None: # pragma: no cover
"""
Helper to ensure `finalize` gets called when the `pip-audit` state falls out of scope of a
`with` statement.
"""
self.finalize()
class _StateActor(ABC):
@abstractmethod
def update_state(self, message: str, logs: str | None = None) -> None:
raise NotImplementedError # pragma: no cover
@abstractmethod
def initialize(self) -> None:
"""
Called when `pip-audit`'s state is initializing. Implementors should
override this to do nothing if their state management requires no
initialization step.
"""
raise NotImplementedError # pragma: no cover
@abstractmethod
def finalize(self) -> None:
"""
Called when the overlaying `AuditState` is "done," i.e. `pip-audit`'s
state is done changing. Implementors should override this to do nothing
if their state management requires no finalization step.
"""
raise NotImplementedError # pragma: no cover
class StatusLog: # pragma: no cover
"""
Displays a status indicator with an optional log panel to display logs
for external processes.
This code is based off of Rich's `Status` component:
https://github.com/Textualize/rich/blob/master/rich/status.py
"""
# NOTE(alex): We limit the panel to 10 characters high and display the last 10 log lines.
# However, the panel won't display all 10 of those lines if some of the lines are long enough
# to wrap in the panel.
LOG_PANEL_HEIGHT = 10
def __init__(
self,
status: str,
*,
console: Console | None = None,
spinner: str = "dots",
spinner_style: StyleType = "status.spinner",
speed: float = 1.0,
refresh_per_second: float = 12.5,
):
"""
Construct a new `StatusLog`.
`status` is the status message to display next to the spinner.
`console` is the Rich console to display the log status in.
`spinner` is the name of the spinner animation (see python -m rich.spinner). Defaults to `dots`.
`spinner_style` is the style of the spinner. Defaults to `status.spinner`.
`speed` is the speed factor for the spinner animation. Defaults to 1.0.
`refresh_per_second` is the number of refreshes per second. Defaults to 12.5.
"""
self._spinner = Spinner(spinner, text=status, style=spinner_style, speed=speed)
self._log_panel = Panel("", height=self.LOG_PANEL_HEIGHT)
self._live = Live(
self.renderable,
console=console,
refresh_per_second=refresh_per_second,
transient=True,
)
@property
def renderable(self) -> RenderableType:
"""
Create a Rich renderable type for the log panel.
If the log panel contains text, we should create a group and place the
log panel underneath the spinner.
"""
if self._log_panel.renderable:
return Group(self._spinner, self._log_panel)
return self._spinner
def update(
self,
status: str,
logs: str | None,
) -> None:
"""
Update status and logs.
"""
if logs is None:
logs = ""
else:
# Limit the logging output to the 10 most recent lines.
logs = "\n".join(logs.splitlines()[-self.LOG_PANEL_HEIGHT :])
self._spinner.update(text=status)
self._log_panel.renderable = logs
self._live.update(self.renderable, refresh=True)
def start(self) -> None:
"""
Start the status animation.
"""
self._live.start()
def stop(self) -> None:
"""
Stop the spinner animation.
"""
self._live.stop()
def __rich__(self) -> RenderableType:
"""
Convert to a Rich renderable type.
"""
return self.renderable
class AuditSpinner(_StateActor): # pragma: no cover
"""
A progress spinner for `pip-audit`, using `rich.status`'s spinner support
under the hood.
"""
def __init__(self, message: str = "") -> None:
"""
Initialize the `AuditSpinner`.
"""
self._console = Console()
# NOTE: audits can be quite fast, so we need a pretty high refresh rate here.
self._spinner = StatusLog(
message, console=self._console, spinner="line", refresh_per_second=30
)
# Keep the target set to `None` to ensure that the logs don't get written until the spinner
# has finished writing output, regardless of the capacity argument
self.log_handler = MemoryHandler(
0, flushLevel=logging.ERROR, target=None, flushOnClose=False
)
self.prev_handlers: list[logging.Handler] = []
def update_state(self, message: str, logs: str | None = None) -> None:
"""
Update the spinner's state.
"""
self._spinner.update(message, logs)
def initialize(self) -> None:
"""
Redirect logging to an in-memory log handler so that it doesn't get mixed in with the
spinner output.
"""
# Remove all existing log handlers
#
# We're recording them here since we'll want to restore them once the spinner falls out of
# scope
root_logger = logging.root
for handler in root_logger.handlers:
self.prev_handlers.append(handler)
for handler in self.prev_handlers:
root_logger.removeHandler(handler)
# Redirect logging to our in-memory handler that will buffer the log lines
root_logger.addHandler(self.log_handler)
self._spinner.start()
def finalize(self) -> None:
"""
Cleanup the spinner output so it doesn't get combined with subsequent `stderr` output and
flush any logs that were recorded while the spinner was active.
"""
self._spinner.stop()
# Now that the spinner is complete, flush the logs
root_logger = logging.root
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
self.log_handler.setTarget(stream_handler)
self.log_handler.flush()
# Restore the original log handlers
root_logger.removeHandler(self.log_handler)
for handler in self.prev_handlers:
root_logger.addHandler(handler)