bugpipe 3.0.2__tar.gz → 3.1.1__tar.gz
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.
- {bugpipe-3.0.2 → bugpipe-3.1.1}/PKG-INFO +2 -2
- {bugpipe-3.0.2 → bugpipe-3.1.1}/README.md +1 -1
- {bugpipe-3.0.2 → bugpipe-3.1.1}/pyproject.toml +1 -1
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/cli/__init__.py +25 -7
- bugpipe-3.0.2/src/bugpipe/cli/cmd.py → bugpipe-3.1.1/src/bugpipe/cli/commands.py +36 -48
- bugpipe-3.1.1/src/bugpipe/cli/metadata.py +12 -0
- bugpipe-3.1.1/src/bugpipe/cli/output.py +168 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/cli/update_checker.py +5 -8
- bugpipe-3.0.2/src/bugpipe/cli/term.py +0 -234
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/__init__.py +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/AUDIT.md +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/README.md +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/__init__.py +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/client.py +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/models.py +0 -0
- {bugpipe-3.0.2 → bugpipe-3.1.1}/src/bugpipe/api/parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugpipe
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Unofficial Python client for Buganizer; the Google Issue Tracking system
|
|
5
5
|
Keywords: buganizer,google-issue-tracker,issue-tracker,bug-tracker,bug-bounty,vulnerability,security,chromium,cve,vrp,google
|
|
6
6
|
Author: Ritchie Mwewa
|
|
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
<img alt="logo" src="https://www.gstatic.com/buganizer/img/v0/logo.svg" width="150" height="150">
|
|
29
29
|
<br>
|
|
30
30
|
<br>
|
|
31
|
-
|
|
31
|
+
Unofficial Python client for Buganizer; the Google Issue Tracking system.
|
|
32
32
|
</p>
|
|
33
33
|
|
|
34
34
|
## Quick Start
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img alt="logo" src="https://www.gstatic.com/buganizer/img/v0/logo.svg" width="150" height="150">
|
|
3
3
|
<br>
|
|
4
4
|
<br>
|
|
5
|
-
|
|
5
|
+
Unofficial Python client for Buganizer; the Google Issue Tracking system.
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
## Quick Start
|
|
@@ -1,14 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
1
2
|
import logging
|
|
2
3
|
import sys
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
6
7
|
from rich.logging import RichHandler
|
|
8
|
+
from rich.status import Status
|
|
7
9
|
|
|
8
|
-
from ..api.client import TRACKERS
|
|
9
|
-
from . import update_checker
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
10
|
+
from ..api.client import TRACKERS, Bugpipe
|
|
11
|
+
from . import metadata, update_checker
|
|
12
|
+
from .commands import parse_args
|
|
13
|
+
from .output import FAIL, INFO, WARN, console, print_out
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_client(args: argparse.Namespace, status: Status):
|
|
17
|
+
"""
|
|
18
|
+
Create a client and dispatch to the chosen subcommand.
|
|
19
|
+
|
|
20
|
+
:param args: Parsed arguments with ``.func`` set to the subcommand handler.
|
|
21
|
+
:param status: Rich status spinner for progress updates.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
with Bugpipe(
|
|
25
|
+
trackers=getattr(args, "tracker", None),
|
|
26
|
+
timeout=args.timeout,
|
|
27
|
+
proxy=args.proxy,
|
|
28
|
+
) as client:
|
|
29
|
+
args.func(client=client, args=args, status=status)
|
|
12
30
|
|
|
13
31
|
|
|
14
32
|
def format_option(value) -> str:
|
|
@@ -35,7 +53,7 @@ def start():
|
|
|
35
53
|
args = parse_args()
|
|
36
54
|
|
|
37
55
|
if args.command == "trackers":
|
|
38
|
-
print_out(output=TRACKERS,
|
|
56
|
+
print_out(output=TRACKERS, prettified=args.raw, no_pager=args.no_pager)
|
|
39
57
|
console.print(f"\n{len(TRACKERS)} trackers available")
|
|
40
58
|
return
|
|
41
59
|
|
|
@@ -51,12 +69,12 @@ def start():
|
|
|
51
69
|
)
|
|
52
70
|
overrides_text: str = f" ({overrides})" if overrides else ""
|
|
53
71
|
console.log(
|
|
54
|
-
f"{INFO} Started bugpipe CLI {
|
|
72
|
+
f"{INFO} Started bugpipe CLI {metadata.version[:3]}{overrides_text} "
|
|
55
73
|
f"at {datetime.now().astimezone().strftime('%x %X')}"
|
|
56
74
|
)
|
|
57
75
|
with console.status("[dim]Initialising…[/dim]") as status:
|
|
58
76
|
update_checker.check(status=status)
|
|
59
|
-
|
|
77
|
+
create_client(args=args, status=status)
|
|
60
78
|
except KeyboardInterrupt:
|
|
61
79
|
console.log(f"{WARN} User interrupted ([bold yellow]CTRL+C[/bold yellow])")
|
|
62
80
|
sys.exit(0)
|
|
@@ -6,15 +6,15 @@ from datetime import datetime
|
|
|
6
6
|
|
|
7
7
|
from ..api.client import TRACKERS, Bugpipe
|
|
8
8
|
from ..api.models import Results
|
|
9
|
-
from .
|
|
10
|
-
from .
|
|
9
|
+
from . import metadata
|
|
10
|
+
from .output import FAIL, OK, console, export_out, print_out
|
|
11
11
|
|
|
12
12
|
if t.TYPE_CHECKING:
|
|
13
13
|
from rich.status import Status
|
|
14
14
|
|
|
15
15
|
from ..api.models import Issue
|
|
16
16
|
|
|
17
|
-
__all__ = ["
|
|
17
|
+
__all__ = ["parse_args"]
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def parse_args() -> argparse.Namespace:
|
|
@@ -25,9 +25,9 @@ def parse_args() -> argparse.Namespace:
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
parser = argparse.ArgumentParser(
|
|
28
|
-
prog=
|
|
29
|
-
description="
|
|
30
|
-
epilog=f"© {datetime.now().astimezone().year}
|
|
28
|
+
prog=metadata.pkg_name,
|
|
29
|
+
description=f"{metadata.description}.",
|
|
30
|
+
epilog=f"{metadata.license} © {datetime.now().astimezone().year} {metadata.author}",
|
|
31
31
|
)
|
|
32
32
|
parser.add_argument(
|
|
33
33
|
"-r",
|
|
@@ -35,6 +35,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
35
35
|
action="store_true",
|
|
36
36
|
help="show raw output",
|
|
37
37
|
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--no-pager",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="print results straight to the terminal instead of paging them",
|
|
42
|
+
)
|
|
38
43
|
parser.add_argument(
|
|
39
44
|
"-p",
|
|
40
45
|
"--proxy",
|
|
@@ -53,11 +58,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
53
58
|
"-v",
|
|
54
59
|
"--version",
|
|
55
60
|
action="version",
|
|
56
|
-
version=f"{
|
|
61
|
+
version=f"{metadata.pkg_name} {metadata.version}",
|
|
57
62
|
)
|
|
58
63
|
# Shared by commands that return issue data.
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
export_parser = argparse.ArgumentParser(add_help=False)
|
|
65
|
+
export_parser.add_argument(
|
|
61
66
|
"-e",
|
|
62
67
|
"--export",
|
|
63
68
|
action="append",
|
|
@@ -69,7 +74,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
69
74
|
|
|
70
75
|
# search
|
|
71
76
|
search_parser = subparsers.add_parser(
|
|
72
|
-
"search", parents=[
|
|
77
|
+
"search", parents=[export_parser], help="search for issues"
|
|
73
78
|
)
|
|
74
79
|
search_parser.add_argument("query", help="search query")
|
|
75
80
|
search_parser.add_argument(
|
|
@@ -100,21 +105,21 @@ def parse_args() -> argparse.Namespace:
|
|
|
100
105
|
|
|
101
106
|
# get
|
|
102
107
|
issue_parser = subparsers.add_parser(
|
|
103
|
-
"issue", parents=[
|
|
108
|
+
"issue", parents=[export_parser], help="get a single issue"
|
|
104
109
|
)
|
|
105
110
|
issue_parser.add_argument("issue_id", type=int, help="issue ID")
|
|
106
111
|
issue_parser.set_defaults(func=cmd_issue)
|
|
107
112
|
|
|
108
113
|
# batch
|
|
109
114
|
issues_parser = subparsers.add_parser(
|
|
110
|
-
"issues", parents=[
|
|
115
|
+
"issues", parents=[export_parser], help="batch get issues"
|
|
111
116
|
)
|
|
112
117
|
issues_parser.add_argument("issue_ids", type=int, nargs="+", help="issue IDs")
|
|
113
118
|
issues_parser.set_defaults(func=cmd_issues)
|
|
114
119
|
|
|
115
120
|
# comments
|
|
116
121
|
comments_parser = subparsers.add_parser(
|
|
117
|
-
"comments", parents=[
|
|
122
|
+
"comments", parents=[export_parser], help="get comments on an issue"
|
|
118
123
|
)
|
|
119
124
|
comments_parser.add_argument("issue_id", type=int, help="issue ID")
|
|
120
125
|
comments_parser.set_defaults(func=cmd_comments)
|
|
@@ -125,7 +130,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
125
130
|
# echo (health check)
|
|
126
131
|
echo_parser = subparsers.add_parser(
|
|
127
132
|
"echo",
|
|
128
|
-
help="check whether
|
|
133
|
+
help="check whether buganizer is reachable",
|
|
129
134
|
description=(
|
|
130
135
|
"Ping the Buganizer backend (GET /action/yes) and print its "
|
|
131
136
|
"response. 'yes' means the backend is reachable and healthy; "
|
|
@@ -145,11 +150,11 @@ def _overrides(args: argparse.Namespace, *parsers: argparse.ArgumentParser) -> d
|
|
|
145
150
|
|
|
146
151
|
:param args: Parsed arguments.
|
|
147
152
|
:param parsers: The parsers that produced them.
|
|
148
|
-
:return:
|
|
153
|
+
:return: Option name as argparse stores it (``no_pager``) to value.
|
|
149
154
|
"""
|
|
150
155
|
|
|
151
156
|
return {
|
|
152
|
-
action.
|
|
157
|
+
action.dest: getattr(args, action.dest)
|
|
153
158
|
for parser in parsers
|
|
154
159
|
for action in parser._actions
|
|
155
160
|
if action.option_strings
|
|
@@ -196,10 +201,10 @@ def cmd_search(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
|
196
201
|
# Rich's Status redirects sys.stdout, which makes the pager (and the
|
|
197
202
|
# TTY check) see a non-tty. Stop it first so paging can take over.
|
|
198
203
|
status.stop()
|
|
199
|
-
print_out(output=issues,
|
|
204
|
+
print_out(output=issues, prettified=args.raw, no_pager=args.no_pager)
|
|
200
205
|
|
|
201
206
|
if args.export:
|
|
202
|
-
|
|
207
|
+
export_out(output=issues, formats=args.export)
|
|
203
208
|
|
|
204
209
|
if result.has_more:
|
|
205
210
|
print()
|
|
@@ -219,10 +224,10 @@ def cmd_issue(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
|
219
224
|
status.update(f"[dim]Getting issue {issue_id}…[/]")
|
|
220
225
|
issue = client.issue(issue_id=issue_id)
|
|
221
226
|
|
|
222
|
-
status.stop()
|
|
223
|
-
print_out(output=issue)
|
|
227
|
+
status.stop()
|
|
228
|
+
print_out(output=issue, prettified=args.raw, no_pager=args.no_pager)
|
|
224
229
|
if args.export:
|
|
225
|
-
|
|
230
|
+
export_out(output=issue, formats=args.export)
|
|
226
231
|
|
|
227
232
|
|
|
228
233
|
def cmd_issues(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
@@ -238,10 +243,10 @@ def cmd_issues(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
|
238
243
|
status.update(f"[dim]Getting issues {issue_ids}…[/]")
|
|
239
244
|
issues = client.issues(issue_ids=issue_ids)
|
|
240
245
|
|
|
241
|
-
status.stop()
|
|
242
|
-
print_out(output=issues)
|
|
246
|
+
status.stop()
|
|
247
|
+
print_out(output=issues, prettified=args.raw, no_pager=args.no_pager)
|
|
243
248
|
if args.export:
|
|
244
|
-
|
|
249
|
+
export_out(output=issues, formats=args.export)
|
|
245
250
|
|
|
246
251
|
|
|
247
252
|
def cmd_comments(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
@@ -258,11 +263,11 @@ def cmd_comments(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
|
258
263
|
status.update(status=f"[dim]Getting comments for issue {issue_id}…[/]")
|
|
259
264
|
result = client.comments(issue_id=issue_id)
|
|
260
265
|
|
|
261
|
-
status.stop()
|
|
266
|
+
status.stop()
|
|
262
267
|
console.print(f"Issue #{issue_id} — {len(result.comments)} comments\n")
|
|
263
|
-
print_out(output=result.comments)
|
|
268
|
+
print_out(output=result.comments, prettified=args.raw, no_pager=args.no_pager)
|
|
264
269
|
if args.export:
|
|
265
|
-
|
|
270
|
+
export_out(output=result.comments, formats=args.export)
|
|
266
271
|
|
|
267
272
|
|
|
268
273
|
# noinspection PyUnusedLocal
|
|
@@ -270,35 +275,18 @@ def cmd_echo(client: Bugpipe, args: argparse.Namespace, status: Status):
|
|
|
270
275
|
"""
|
|
271
276
|
Handle the 'echo' subcommand: ping the backend and print its response.
|
|
272
277
|
|
|
273
|
-
Prints
|
|
274
|
-
|
|
275
|
-
an error
|
|
278
|
+
Prints ``echo: yes`` when the backend is reachable and healthy
|
|
279
|
+
, or ``echo: no`` when it is unreachable or returned
|
|
280
|
+
an error.
|
|
276
281
|
|
|
277
282
|
:param client: Shared API client instance.
|
|
278
283
|
:param args: Parsed arguments (unused).
|
|
279
284
|
:param status: Rich status spinner for progress updates.
|
|
280
285
|
"""
|
|
281
286
|
|
|
282
|
-
status.update("[dim]Pinging
|
|
287
|
+
status.update("[dim]Pinging buganizer…[/dim]")
|
|
283
288
|
response = client.echo()
|
|
284
289
|
if response == "yes":
|
|
285
290
|
console.log(f"{OK} echo: {response}")
|
|
286
291
|
else:
|
|
287
292
|
console.log(f"{FAIL} echo: {response}")
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def dispatch_client(args: argparse.Namespace, status: Status):
|
|
291
|
-
"""
|
|
292
|
-
Check for updates, then create a single client and dispatch to the
|
|
293
|
-
chosen subcommand.
|
|
294
|
-
|
|
295
|
-
:param args: Parsed arguments with ``.func`` set to the subcommand handler.
|
|
296
|
-
:param status: Rich status spinner for progress updates.
|
|
297
|
-
"""
|
|
298
|
-
|
|
299
|
-
with Bugpipe(
|
|
300
|
-
trackers=getattr(args, "tracker", None),
|
|
301
|
-
timeout=args.timeout,
|
|
302
|
-
proxy=args.proxy,
|
|
303
|
-
) as client:
|
|
304
|
-
args.func(client=client, args=args, status=status)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Package metadata, read from the installed distribution.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import metadata
|
|
6
|
+
|
|
7
|
+
pkg_name = "bugpipe"
|
|
8
|
+
__metadata = metadata(pkg_name)
|
|
9
|
+
author = __metadata["Author"]
|
|
10
|
+
description = __metadata["Summary"]
|
|
11
|
+
license = __metadata["License-Expression"]
|
|
12
|
+
version = __metadata["Version"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console output for the CLI.
|
|
3
|
+
|
|
4
|
+
Results print as their dataclasses through a pager, so bulk output scrolls
|
|
5
|
+
instead of flooding the terminal. Writing them to file is left to the models'
|
|
6
|
+
own ``to_json()``/``to_csv()``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import typing as t
|
|
10
|
+
from contextlib import nullcontext
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from rich.box import ASCII
|
|
14
|
+
from rich.console import Console, Group
|
|
15
|
+
from rich.pretty import Pretty
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from ..api.models import Comment, Exportable, Issue, Results
|
|
19
|
+
|
|
20
|
+
__all__ = ["FAIL", "INFO", "OK", "WARN", "export_out", "print_out"]
|
|
21
|
+
|
|
22
|
+
#: Issue table columns: header -> field name. Int cells are right-aligned.
|
|
23
|
+
ISSUE_COLUMNS = {
|
|
24
|
+
"ID": "id",
|
|
25
|
+
"Title": "title",
|
|
26
|
+
"Type": "issue_type",
|
|
27
|
+
"Priority": "priority",
|
|
28
|
+
"Severity": "severity",
|
|
29
|
+
"Status": "status",
|
|
30
|
+
"24h Views": "views_24h",
|
|
31
|
+
"7d Views": "views_7d",
|
|
32
|
+
"30d Views": "views_30d",
|
|
33
|
+
"Created At": "created_at",
|
|
34
|
+
"Modified At": "modified_at",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#: Success marker (green ✔).
|
|
38
|
+
OK = "[bold green]✔[/bold green]"
|
|
39
|
+
|
|
40
|
+
#: Failure/error marker (red ✘).
|
|
41
|
+
FAIL = "[bold red]✘[/bold red]"
|
|
42
|
+
|
|
43
|
+
#: Warning/interrupted marker (yellow ✘).
|
|
44
|
+
WARN = "[bold yellow]✘[/bold yellow]"
|
|
45
|
+
|
|
46
|
+
#: Informational marker (blue *).
|
|
47
|
+
INFO = "[bold blue]*[/bold blue]"
|
|
48
|
+
|
|
49
|
+
console = Console(log_time=False, log_path=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def prettify(output: t.Any) -> Group | None:
|
|
53
|
+
"""
|
|
54
|
+
Build a pretty-printed view of a result, one item per block.
|
|
55
|
+
|
|
56
|
+
:param output: A single item, or a list of them.
|
|
57
|
+
:return: The renderable, or None when there is nothing to show.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
if not output:
|
|
61
|
+
return None
|
|
62
|
+
items = output if isinstance(output, list) else [output]
|
|
63
|
+
blocks = [block for item in items for block in (Pretty(item), "")]
|
|
64
|
+
return Group(*blocks[:-1], fit=False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def table(rows: t.Sequence[dict | Exportable] | Exportable) -> Table | None:
|
|
68
|
+
"""
|
|
69
|
+
Build a table.
|
|
70
|
+
|
|
71
|
+
A sequence gets one row per item; a single item gets one
|
|
72
|
+
row per dataclass field, as field name and value.
|
|
73
|
+
|
|
74
|
+
:param rows: The items to show, or one item.
|
|
75
|
+
:return: The table, or None when there is nothing to show.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
is_single_item: bool = isinstance(rows, Exportable)
|
|
79
|
+
if is_single_item:
|
|
80
|
+
dicts = [
|
|
81
|
+
{"field": key, "value": value} for key, value in rows.to_dict().items()
|
|
82
|
+
]
|
|
83
|
+
elif not rows:
|
|
84
|
+
return None
|
|
85
|
+
else:
|
|
86
|
+
dicts = [
|
|
87
|
+
(
|
|
88
|
+
{header: row.to_dict()[name] for header, name in ISSUE_COLUMNS.items()}
|
|
89
|
+
if isinstance(row, Issue)
|
|
90
|
+
else row.to_dict() if isinstance(row, Exportable) else row
|
|
91
|
+
)
|
|
92
|
+
for row in rows
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
table = Table(
|
|
96
|
+
box=ASCII, highlight=True, expand=not is_single_item, header_style="bold"
|
|
97
|
+
)
|
|
98
|
+
for header, value in dicts[0].items():
|
|
99
|
+
table.add_column(
|
|
100
|
+
str(header).title(),
|
|
101
|
+
justify="right" if isinstance(value, int) else "left",
|
|
102
|
+
overflow="fold",
|
|
103
|
+
)
|
|
104
|
+
for row in dicts:
|
|
105
|
+
table.add_row(
|
|
106
|
+
*("[dim]-[/dim]" if cell is None else str(cell) for cell in row.values())
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return table
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def print_out(
|
|
113
|
+
output: list[dict] | Results[Issue] | Results[Comment] | Issue | Comment,
|
|
114
|
+
no_pager: bool = False,
|
|
115
|
+
prettified: bool = False,
|
|
116
|
+
):
|
|
117
|
+
"""
|
|
118
|
+
Show a result, paging it in a terminal.
|
|
119
|
+
|
|
120
|
+
:param output: A single issue or comment, a list of issues or comments,
|
|
121
|
+
or a list of dicts.
|
|
122
|
+
:param no_pager: Print straight to the terminal instead of paging.
|
|
123
|
+
:param prettified: Print results as prettified dataclasses instead of a table.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if not (
|
|
127
|
+
isinstance(output, (Issue, Comment))
|
|
128
|
+
or isinstance(output, Results)
|
|
129
|
+
and all(isinstance(item, (Issue, Comment)) for item in output)
|
|
130
|
+
or isinstance(output, list)
|
|
131
|
+
and all(isinstance(item, dict) for item in output)
|
|
132
|
+
):
|
|
133
|
+
raise ValueError(f"Unexpected value type for param `output`: {type(output)}")
|
|
134
|
+
|
|
135
|
+
render = prettify(output=output) if prettified else table(rows=output)
|
|
136
|
+
if render is None:
|
|
137
|
+
console.log(f"{WARN} Nothing to print.")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if not no_pager and not console.is_terminal:
|
|
141
|
+
console.print(f"{WARN} Not a TTY — output won't be paged.")
|
|
142
|
+
with (
|
|
143
|
+
console.pager(styles=True)
|
|
144
|
+
if not no_pager and console.is_terminal
|
|
145
|
+
else nullcontext()
|
|
146
|
+
):
|
|
147
|
+
console.print(render)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def export_out(output: Exportable | Results[t.Any], formats: list[str]):
|
|
151
|
+
"""
|
|
152
|
+
Write a result to timestamped files, one per format.
|
|
153
|
+
|
|
154
|
+
:param output: A single item, or a list of them.
|
|
155
|
+
:param formats: Format strings, each one of ``"csv"`` or ``"json"``.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
if not output:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
base = f"bugpipe-{datetime.now().astimezone().strftime('%Y%m%d_%H%M%S')}"
|
|
162
|
+
for fmt in formats:
|
|
163
|
+
if fmt == "json":
|
|
164
|
+
console.print(f"{OK} JSON exported to {output.to_json(f'{base}.json')}")
|
|
165
|
+
elif fmt == "csv":
|
|
166
|
+
console.print(f"{OK} CSV exported to {output.to_csv(f'{base}.csv')}")
|
|
167
|
+
else:
|
|
168
|
+
continue
|
|
@@ -4,15 +4,13 @@ import json
|
|
|
4
4
|
import re
|
|
5
5
|
import time
|
|
6
6
|
import typing as t
|
|
7
|
-
from importlib.metadata import version
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from tempfile import gettempdir
|
|
10
9
|
|
|
11
10
|
import httpx
|
|
12
11
|
from rich.status import Status
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
__version__ = version(__pkg__)
|
|
13
|
+
from . import metadata
|
|
16
14
|
|
|
17
15
|
CACHE_FILE = Path(gettempdir()) / "bugpipe_update_check.json"
|
|
18
16
|
CACHE_TTL = 3600
|
|
@@ -93,8 +91,8 @@ def standard_release(version: str) -> bool:
|
|
|
93
91
|
|
|
94
92
|
|
|
95
93
|
def check(
|
|
96
|
-
package_name: str =
|
|
97
|
-
package_version: str =
|
|
94
|
+
package_name: str = metadata.pkg_name,
|
|
95
|
+
package_version: str = metadata.version,
|
|
98
96
|
status: Status | None = None,
|
|
99
97
|
):
|
|
100
98
|
"""Print a notice when PyPI has a newer version.
|
|
@@ -118,15 +116,14 @@ def check(
|
|
|
118
116
|
|
|
119
117
|
available = data["data"]["version"]
|
|
120
118
|
message = (
|
|
121
|
-
f"Version {package_version} of {package_name} is outdated. "
|
|
122
|
-
f"Version {available} "
|
|
119
|
+
f"Version {package_version} of {package_name} is outdated. Version {available} "
|
|
123
120
|
)
|
|
124
121
|
release_date = data["data"]["upload_time"]
|
|
125
122
|
message += (
|
|
126
123
|
f"was released on {release_date[:10]}." if release_date else "is available."
|
|
127
124
|
)
|
|
128
125
|
|
|
129
|
-
from .
|
|
126
|
+
from .output import console
|
|
130
127
|
|
|
131
128
|
console.log(f"[bold blue]⬆[/bold blue] {message}")
|
|
132
129
|
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Console output for the CLI.
|
|
3
|
-
|
|
4
|
-
Results print as their dataclasses through a pager, so bulk output scrolls
|
|
5
|
-
instead of flooding the terminal. Writing them to file is left to the models'
|
|
6
|
-
own ``to_json()``/``to_csv()``.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import enum
|
|
10
|
-
import typing as t
|
|
11
|
-
from contextlib import nullcontext
|
|
12
|
-
from datetime import datetime
|
|
13
|
-
|
|
14
|
-
from rich.box import ASCII
|
|
15
|
-
from rich.console import Console
|
|
16
|
-
from rich.pretty import Pretty
|
|
17
|
-
from rich.table import Table
|
|
18
|
-
from rich.text import Text
|
|
19
|
-
|
|
20
|
-
from ..api.models import (
|
|
21
|
-
Comment,
|
|
22
|
-
Exportable,
|
|
23
|
-
Issue,
|
|
24
|
-
IssueType,
|
|
25
|
-
Priority,
|
|
26
|
-
Results,
|
|
27
|
-
Severity,
|
|
28
|
-
Status,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
__all__ = ["FAIL", "INFO", "OK", "WARN", "export", "print_out"]
|
|
32
|
-
|
|
33
|
-
#: Style for values the API sent that the enum doesn't define (e.g. ``TYPE_7``).
|
|
34
|
-
UNKNOWN_STYLE = "magenta"
|
|
35
|
-
|
|
36
|
-
#: Priority cell styles. Other known values (P3, P4) are plain yellow.
|
|
37
|
-
PRIORITY_STYLES = {
|
|
38
|
-
Priority.P0: "bold red",
|
|
39
|
-
Priority.P1: "red",
|
|
40
|
-
Priority.P2: "bold yellow",
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
#: Severity cell styles. Other known values (S3, S4) are plain yellow.
|
|
44
|
-
SEVERITY_STYLES = {
|
|
45
|
-
Severity.S0: "bold red",
|
|
46
|
-
Severity.S1: "red",
|
|
47
|
-
Severity.S2: "bold yellow",
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
#: Issue type cell styles. Other known values (INTERNAL_CLEANUP, PROCESS) are unstyled.
|
|
51
|
-
ISSUE_TYPE_STYLES = {
|
|
52
|
-
IssueType.VULNERABILITY: "bold red",
|
|
53
|
-
IssueType.BUG: "red",
|
|
54
|
-
IssueType.CUSTOMER_ISSUE: "bold yellow",
|
|
55
|
-
IssueType.FEATURE_REQUEST: "bold green",
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
#: Status cell styles. Open statuses are blue, fixed ones green. Other closed
|
|
59
|
-
#: statuses (NOT_REPRODUCIBLE, INTENDED_BEHAVIOR, OBSOLETE, INFEASIBLE, DUPLICATE)
|
|
60
|
-
#: are dim.
|
|
61
|
-
STATUS_STYLES = {
|
|
62
|
-
Status.NEW: "bold blue",
|
|
63
|
-
Status.ASSIGNED: "cyan",
|
|
64
|
-
Status.ACCEPTED: "bold cyan",
|
|
65
|
-
Status.FIXED: "green",
|
|
66
|
-
Status.VERIFIED: "bold green",
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
#: Issue table columns: header -> cell builder. Int cells are right-aligned.
|
|
70
|
-
ISSUE_COLUMNS: dict[str, t.Callable[[Issue], t.Any]] = {
|
|
71
|
-
"ID": lambda issue: issue.id,
|
|
72
|
-
"Title": lambda issue: issue.title,
|
|
73
|
-
"Type": lambda issue: _style_cell(
|
|
74
|
-
value=issue.issue_type, styles=ISSUE_TYPE_STYLES, default="dim"
|
|
75
|
-
),
|
|
76
|
-
"Priority": lambda issue: _style_cell(
|
|
77
|
-
value=issue.priority, styles=PRIORITY_STYLES, default="yellow"
|
|
78
|
-
),
|
|
79
|
-
"Severity": lambda issue: _style_cell(
|
|
80
|
-
value=issue.severity, styles=SEVERITY_STYLES, default="green"
|
|
81
|
-
),
|
|
82
|
-
"Status": lambda issue: _style_cell(
|
|
83
|
-
value=issue.status, styles=STATUS_STYLES, default="dim"
|
|
84
|
-
),
|
|
85
|
-
"24h Views": lambda issue: issue.views_24h,
|
|
86
|
-
"7d Views": lambda issue: issue.views_7d,
|
|
87
|
-
"30d Views": lambda issue: issue.views_30d,
|
|
88
|
-
"Created At": lambda issue: issue.created_at,
|
|
89
|
-
"Modified At": lambda issue: issue.modified_at,
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
#: Success marker (green ✔).
|
|
93
|
-
OK = "[bold green]✔[/bold green]"
|
|
94
|
-
|
|
95
|
-
#: Failure/error marker (red ✘).
|
|
96
|
-
FAIL = "[bold red]✘[/bold red]"
|
|
97
|
-
|
|
98
|
-
#: Warning/interrupted marker (yellow ✘).
|
|
99
|
-
WARN = "[bold yellow]✘[/bold yellow]"
|
|
100
|
-
|
|
101
|
-
#: Informational marker (blue *).
|
|
102
|
-
INFO = "[bold blue]*[/bold blue]"
|
|
103
|
-
|
|
104
|
-
console = Console(log_time=False, log_path=False)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _style_cell(value: enum.Enum | None, styles: dict, default: str = "") -> Text | str:
|
|
108
|
-
"""
|
|
109
|
-
Build a table cell for an enum value, styled by its member.
|
|
110
|
-
|
|
111
|
-
:param value: The enum value, or None when the issue has none.
|
|
112
|
-
:param styles: Style per known member.
|
|
113
|
-
:param default: Style for known members missing from ``styles``.
|
|
114
|
-
:return: The styled name, or an empty string for None.
|
|
115
|
-
"""
|
|
116
|
-
|
|
117
|
-
if value is None:
|
|
118
|
-
return ""
|
|
119
|
-
if value.name not in type(value).__members__:
|
|
120
|
-
return Text(value.name, style=UNKNOWN_STYLE)
|
|
121
|
-
return Text(value.name, style=styles.get(value, default))
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _pretty_print(output: t.Any):
|
|
125
|
-
"""
|
|
126
|
-
Show a result, paging it.
|
|
127
|
-
|
|
128
|
-
:param output: A single item, or a list of them.
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
if not output:
|
|
132
|
-
console.log("No results.")
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
context = console.pager(styles=True) if console.is_terminal else nullcontext()
|
|
136
|
-
if not console.is_terminal:
|
|
137
|
-
console.print(f"{WARN} Not a TTY — output won't be paged.")
|
|
138
|
-
|
|
139
|
-
with context:
|
|
140
|
-
if isinstance(output, list):
|
|
141
|
-
for index, item in enumerate(output):
|
|
142
|
-
if index:
|
|
143
|
-
console.print()
|
|
144
|
-
console.print(Pretty(item))
|
|
145
|
-
else:
|
|
146
|
-
console.print(Pretty(output))
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def _print_table(rows: t.Sequence[dict | Exportable]):
|
|
150
|
-
"""
|
|
151
|
-
Print and page rows in a table. Headers come from the first row.
|
|
152
|
-
|
|
153
|
-
:param rows: Dicts (headers are the keys), issues (headers from
|
|
154
|
-
``ISSUE_COLUMNS``), or other exportables (headers from ``to_dict``).
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
|
-
if not rows:
|
|
158
|
-
return
|
|
159
|
-
dicts = [
|
|
160
|
-
(
|
|
161
|
-
{header: cell(row) for header, cell in ISSUE_COLUMNS.items()}
|
|
162
|
-
if isinstance(row, Issue)
|
|
163
|
-
else row.to_dict() if isinstance(row, Exportable) else row
|
|
164
|
-
)
|
|
165
|
-
for row in rows
|
|
166
|
-
]
|
|
167
|
-
|
|
168
|
-
table = Table(box=ASCII, highlight=True, expand=True, header_style="bold")
|
|
169
|
-
for header, value in dicts[0].items():
|
|
170
|
-
table.add_column(
|
|
171
|
-
str(header).title(),
|
|
172
|
-
justify="right" if isinstance(value, int) else "left",
|
|
173
|
-
overflow="fold",
|
|
174
|
-
)
|
|
175
|
-
for row in dicts:
|
|
176
|
-
table.add_row(
|
|
177
|
-
*(cell if isinstance(cell, Text) else str(cell) for cell in row.values())
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
if not console.is_terminal:
|
|
181
|
-
console.print(f"{WARN} Not a TTY — output won't be paged.")
|
|
182
|
-
with console.pager(styles=True) if console.is_terminal else nullcontext():
|
|
183
|
-
console.print(table)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def print_out(
|
|
187
|
-
output: list[dict] | Results[Issue] | Results[Comment] | Issue, as_raw: bool = False
|
|
188
|
-
):
|
|
189
|
-
"""
|
|
190
|
-
Show a result, paging it. Issues and dicts go in a table; a single issue
|
|
191
|
-
and comments print as their dataclasses.
|
|
192
|
-
|
|
193
|
-
:param output: A single issue, a list of issues or comments, or a list of dicts.
|
|
194
|
-
:param as_raw: Print tabular results as dataclasses instead of a table.
|
|
195
|
-
"""
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
isinstance(output, Issue)
|
|
199
|
-
or isinstance(output, Results)
|
|
200
|
-
and all(isinstance(item, Comment) for item in output)
|
|
201
|
-
):
|
|
202
|
-
_pretty_print(output=output)
|
|
203
|
-
|
|
204
|
-
elif (
|
|
205
|
-
isinstance(output, Results)
|
|
206
|
-
and all(isinstance(item, Issue) for item in output)
|
|
207
|
-
or isinstance(output, list)
|
|
208
|
-
and all(isinstance(item, dict) for item in output)
|
|
209
|
-
):
|
|
210
|
-
if as_raw:
|
|
211
|
-
_pretty_print(output=output)
|
|
212
|
-
else:
|
|
213
|
-
_print_table(rows=output)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def export(output: Exportable | Results[t.Any], formats: list[str]):
|
|
217
|
-
"""
|
|
218
|
-
Write a result to timestamped files, one per format.
|
|
219
|
-
|
|
220
|
-
:param output: A single item, or a list of them.
|
|
221
|
-
:param formats: Format strings, each one of ``"csv"`` or ``"json"``.
|
|
222
|
-
"""
|
|
223
|
-
|
|
224
|
-
if not output:
|
|
225
|
-
return
|
|
226
|
-
|
|
227
|
-
base = f"bugpipe-{datetime.now().astimezone().strftime('%Y%m%d_%H%M%S')}"
|
|
228
|
-
for fmt in formats:
|
|
229
|
-
if fmt == "json":
|
|
230
|
-
console.print(f"\n{OK} JSON exported to {output.to_json(f'{base}.json')}")
|
|
231
|
-
elif fmt == "csv":
|
|
232
|
-
console.print(f"\n{OK} CSV exported to {output.to_csv(f'{base}.csv')}")
|
|
233
|
-
else:
|
|
234
|
-
continue
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|