bugpipe 3.0.1__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,69 @@
1
+ import logging
2
+ import sys
3
+ from datetime import datetime
4
+
5
+ import httpx
6
+ from rich.logging import RichHandler
7
+
8
+ from ..api.client import TRACKERS
9
+ from . import update_checker
10
+ from .cmd import dispatch_client, parse_args
11
+ from .term import FAIL, INFO, WARN, console, print_out
12
+
13
+
14
+ def format_option(value) -> str:
15
+ """
16
+ Render an option value for the startup line: lists in brackets, strings
17
+ quoted so Rich's URL highlighting stops at the value.
18
+
19
+ :param value: The option value.
20
+ :return: Markup text.
21
+ """
22
+
23
+ if isinstance(value, list):
24
+ return f"[[italic]{', '.join(map(str, value))}[/italic]]"
25
+ if isinstance(value, str):
26
+ return f"'[italic]{value}[/italic]'"
27
+ return f"[italic]{value}[/italic]"
28
+
29
+
30
+ def start():
31
+ """
32
+ CLI entry point.
33
+ """
34
+
35
+ args = parse_args()
36
+
37
+ if args.command == "trackers":
38
+ print_out(output=TRACKERS, as_raw=args.raw)
39
+ console.print(f"\n{len(TRACKERS)} trackers available")
40
+ return
41
+
42
+ logging.basicConfig(
43
+ level=logging.WARNING,
44
+ handlers=[RichHandler(markup=True, show_level=True)],
45
+ )
46
+
47
+ start_time = datetime.now().astimezone()
48
+ try:
49
+ overrides: str = ", ".join(
50
+ f"{name}={format_option(value)}" for name, value in args.overrides.items()
51
+ )
52
+ overrides_text: str = f" ({overrides})" if overrides else ""
53
+ console.log(
54
+ f"{INFO} Started bugpipe CLI {update_checker.__version__[:3]}{overrides_text} "
55
+ f"at {datetime.now().astimezone().strftime('%x %X')}"
56
+ )
57
+ with console.status("[dim]Initialising…[/dim]") as status:
58
+ update_checker.check(status=status)
59
+ dispatch_client(args=args, status=status)
60
+ except KeyboardInterrupt:
61
+ console.log(f"{WARN} User interrupted ([bold yellow]CTRL+C[/bold yellow])")
62
+ sys.exit(0)
63
+ except httpx.ConnectError as err:
64
+ console.log(f"{FAIL} {err}")
65
+ except httpx.ConnectTimeout:
66
+ console.log(f"{WARN} Connection timed out")
67
+ finally:
68
+ elapsed = (datetime.now().astimezone() - start_time).total_seconds()
69
+ console.log(f"{INFO} Finished in {elapsed:.1f} seconds")
bugpipe/cli/cmd.py ADDED
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import typing as t
5
+ from datetime import datetime
6
+
7
+ from ..api.client import TRACKERS, Bugpipe
8
+ from ..api.models import Results
9
+ from .term import FAIL, OK, console, export, print_out
10
+ from .update_checker import __pkg__, __version__
11
+
12
+ if t.TYPE_CHECKING:
13
+ from rich.status import Status
14
+
15
+ from ..api.models import Issue
16
+
17
+ __all__ = ["dispatch_client", "parse_args"]
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ """
22
+ Parse command-line arguments and return the populated namespace.
23
+
24
+ :return: Parsed arguments with the selected subcommand function in ``.func``.
25
+ """
26
+
27
+ parser = argparse.ArgumentParser(
28
+ prog=__pkg__,
29
+ description="Unofficial Python client for Buganizer; the Google Issue Tracking system.",
30
+ epilog=f"© {datetime.now().astimezone().year} Ritchie Mwewa",
31
+ )
32
+ parser.add_argument(
33
+ "-r",
34
+ "--raw",
35
+ action="store_true",
36
+ help="show raw output",
37
+ )
38
+ parser.add_argument(
39
+ "-p",
40
+ "--proxy",
41
+ metavar="URL",
42
+ help="proxy URL for all requests (e.g. http://localhost:8080)",
43
+ )
44
+ parser.add_argument(
45
+ "-t",
46
+ "--timeout",
47
+ type=int,
48
+ default=30,
49
+ metavar="SECONDS",
50
+ help="request timeout (default %(default)s)",
51
+ )
52
+ parser.add_argument(
53
+ "-v",
54
+ "--version",
55
+ action="version",
56
+ version=f"{__pkg__} {__version__}",
57
+ )
58
+ # Shared by commands that return issue data.
59
+ exportable = argparse.ArgumentParser(add_help=False)
60
+ exportable.add_argument(
61
+ "-e",
62
+ "--export",
63
+ action="append",
64
+ choices=["csv", "json"],
65
+ help="export format (repeatable)",
66
+ )
67
+
68
+ subparsers = parser.add_subparsers(dest="command", required=True)
69
+
70
+ # search
71
+ search_parser = subparsers.add_parser(
72
+ "search", parents=[exportable], help="search for issues"
73
+ )
74
+ search_parser.add_argument("query", help="search query")
75
+ search_parser.add_argument(
76
+ "-t",
77
+ "--tracker",
78
+ action="append",
79
+ choices=[tracker["slug"] for tracker in TRACKERS],
80
+ help="tracker slug (repeatable, see `bugpipe trackers`). Defaults to all",
81
+ )
82
+ search_parser.add_argument(
83
+ "-n",
84
+ "--per-page",
85
+ type=int,
86
+ default=25,
87
+ choices=[25, 50, 100, 250],
88
+ help="results per page (default: 25)",
89
+ )
90
+ search_parser.add_argument(
91
+ "-l",
92
+ "--limit",
93
+ type=int,
94
+ default=None,
95
+ metavar="N",
96
+ help="total results to fetch, paginating as needed",
97
+ )
98
+
99
+ search_parser.set_defaults(func=cmd_search)
100
+
101
+ # get
102
+ issue_parser = subparsers.add_parser(
103
+ "issue", parents=[exportable], help="get a single issue"
104
+ )
105
+ issue_parser.add_argument("issue_id", type=int, help="issue ID")
106
+ issue_parser.set_defaults(func=cmd_issue)
107
+
108
+ # batch
109
+ issues_parser = subparsers.add_parser(
110
+ "issues", parents=[exportable], help="batch get issues"
111
+ )
112
+ issues_parser.add_argument("issue_ids", type=int, nargs="+", help="issue IDs")
113
+ issues_parser.set_defaults(func=cmd_issues)
114
+
115
+ # comments
116
+ comments_parser = subparsers.add_parser(
117
+ "comments", parents=[exportable], help="get comments on an issue"
118
+ )
119
+ comments_parser.add_argument("issue_id", type=int, help="issue ID")
120
+ comments_parser.set_defaults(func=cmd_comments)
121
+
122
+ # trackers
123
+ subparsers.add_parser("trackers", help="list available trackers")
124
+
125
+ # echo (health check)
126
+ echo_parser = subparsers.add_parser(
127
+ "echo",
128
+ help="check whether the issue tracker backend is reachable",
129
+ description=(
130
+ "Ping the Buganizer backend (GET /action/yes) and print its "
131
+ "response. 'yes' means the backend is reachable and healthy; "
132
+ "'no' means it is unreachable or returned an error."
133
+ ),
134
+ )
135
+ echo_parser.set_defaults(func=cmd_echo)
136
+
137
+ args = parser.parse_args()
138
+ args.overrides = _overrides(args, parser, subparsers.choices[args.command])
139
+ return args
140
+
141
+
142
+ def _overrides(args: argparse.Namespace, *parsers: argparse.ArgumentParser) -> dict:
143
+ """
144
+ Collect the options whose value differs from their default.
145
+
146
+ :param args: Parsed arguments.
147
+ :param parsers: The parsers that produced them.
148
+ :return: Long option name (without dashes) to value.
149
+ """
150
+
151
+ return {
152
+ action.option_strings[-1].lstrip("-"): getattr(args, action.dest)
153
+ for parser in parsers
154
+ for action in parser._actions
155
+ if action.option_strings
156
+ and action.dest in args
157
+ and getattr(args, action.dest) != action.default
158
+ }
159
+
160
+
161
+ def cmd_search(client: Bugpipe, args: argparse.Namespace, status: Status):
162
+ """
163
+ Handle the 'search' subcommand.
164
+
165
+ :param client: Shared API client instance.
166
+ :param args: Parsed arguments with ``.query``, ``.per_page``, and ``.limit``.
167
+ :param status: Rich status spinner for progress updates.
168
+ """
169
+
170
+ query = args.query
171
+ per_page = args.per_page
172
+ limit = args.limit
173
+ tracker_label = ", ".join(args.tracker) if args.tracker else "all"
174
+
175
+ status.update(
176
+ f"[dim][bold]Searching [italic]{tracker_label}[/] issues for [bold green]{query}[/bold green]…[/dim]"
177
+ )
178
+ result = client.search(query=query, page_size=per_page)
179
+ issues: Results[Issue] = Results(result.issues)
180
+
181
+ while limit is not None and result.has_more and len(issues) < limit:
182
+ status.update(
183
+ f"[dim]Collected [cyan]{len(issues)}[/] of [cyan]{limit}[/] issues…[/dim]"
184
+ )
185
+ page = client.next_page(result)
186
+ if page is None:
187
+ break
188
+ result = page
189
+ issues.extend(result.issues)
190
+ if limit is not None:
191
+ issues = Results(issues[:limit])
192
+
193
+ console.log(
194
+ f"{OK} Got {len(issues)} of ~{result.total_count}+ issues for '{query}'\n"
195
+ )
196
+ # Rich's Status redirects sys.stdout, which makes the pager (and the
197
+ # TTY check) see a non-tty. Stop it first so paging can take over.
198
+ status.stop()
199
+ print_out(output=issues, as_raw=args.raw)
200
+
201
+ if args.export:
202
+ export(output=issues, formats=args.export)
203
+
204
+ if result.has_more:
205
+ print()
206
+ console.log(f"~{result.total_count - len(issues)}+ more results available")
207
+
208
+
209
+ def cmd_issue(client: Bugpipe, args: argparse.Namespace, status: Status):
210
+ """
211
+ Handle the 'issue' subcommand.
212
+
213
+ :param client: Shared API client instance.
214
+ :param args: Parsed arguments with ``.issue_id``.
215
+ :param status: Rich status spinner for progress updates.
216
+ """
217
+
218
+ issue_id = args.issue_id
219
+ status.update(f"[dim]Getting issue {issue_id}…[/]")
220
+ issue = client.issue(issue_id=issue_id)
221
+
222
+ status.stop() # restore stdout so the pager works (Status redirects it)
223
+ print_out(output=issue)
224
+ if args.export:
225
+ export(output=issue, formats=args.export)
226
+
227
+
228
+ def cmd_issues(client: Bugpipe, args: argparse.Namespace, status: Status):
229
+ """
230
+ Handle the 'issues' subcommand.
231
+
232
+ :param client: Shared API client instance.
233
+ :param args: Parsed arguments with ``.issue_ids``.
234
+ :param status: Rich status spinner for progress updates.
235
+ """
236
+
237
+ issue_ids = args.issue_ids
238
+ status.update(f"[dim]Getting issues {issue_ids}…[/]")
239
+ issues = client.issues(issue_ids=issue_ids)
240
+
241
+ status.stop() # restore stdout so the pager works (Status redirects it)
242
+ print_out(output=issues)
243
+ if args.export:
244
+ export(output=issues, formats=args.export)
245
+
246
+
247
+ def cmd_comments(client: Bugpipe, args: argparse.Namespace, status: Status):
248
+ """
249
+ Handle the 'comments' subcommand.
250
+
251
+ :param client: Shared API client instance.
252
+ :param args: Parsed arguments with ``.issue_id``.
253
+ :param status: Rich status spinner for progress updates.
254
+ """
255
+
256
+ issue_id = args.issue_id
257
+
258
+ status.update(status=f"[dim]Getting comments for issue {issue_id}…[/]")
259
+ result = client.comments(issue_id=issue_id)
260
+
261
+ status.stop() # restore stdout so the pager works (Status redirects it)
262
+ console.print(f"Issue #{issue_id} — {len(result.comments)} comments\n")
263
+ print_out(output=result.comments)
264
+ if args.export:
265
+ export(output=result.comments, formats=args.export)
266
+
267
+
268
+ # noinspection PyUnusedLocal
269
+ def cmd_echo(client: Bugpipe, args: argparse.Namespace, status: Status):
270
+ """
271
+ Handle the 'echo' subcommand: ping the backend and print its response.
272
+
273
+ Prints a green ``✔`` when the backend is reachable and healthy
274
+ (``echo: yes``), or a red ``✘`` when it is unreachable or returned
275
+ an error (``echo: no``).
276
+
277
+ :param client: Shared API client instance.
278
+ :param args: Parsed arguments (unused).
279
+ :param status: Rich status spinner for progress updates.
280
+ """
281
+
282
+ status.update("[dim]Pinging issue tracker backend…[/dim]")
283
+ response = client.echo()
284
+ if response == "yes":
285
+ console.log(f"{OK} echo: {response}")
286
+ else:
287
+ 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)
bugpipe/cli/term.py ADDED
@@ -0,0 +1,234 @@
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