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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugpipe
3
- Version: 3.0.2
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
- <strong><i>Unofficial Python client for Buganizer; the Google Issue Tracking system.</i></strong>
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
- <strong><i>Unofficial Python client for Buganizer; the Google Issue Tracking system.</i></strong>
5
+ Unofficial Python client for Buganizer; the Google Issue Tracking system.
6
6
  </p>
7
7
 
8
8
  ## Quick Start
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bugpipe"
3
- version = "3.0.2"
3
+ version = "3.1.1"
4
4
  description = "Unofficial Python client for Buganizer; the Google Issue Tracking system"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 .cmd import dispatch_client, parse_args
11
- from .term import FAIL, INFO, WARN, console, print_out
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, as_raw=args.raw)
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 {update_checker.__version__[:3]}{overrides_text} "
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
- dispatch_client(args=args, status=status)
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 .term import FAIL, OK, console, export, print_out
10
- from .update_checker import __pkg__, __version__
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__ = ["dispatch_client", "parse_args"]
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=__pkg__,
29
- description="Unofficial Python client for Buganizer; the Google Issue Tracking system.",
30
- epilog=f"© {datetime.now().astimezone().year} Ritchie Mwewa",
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"{__pkg__} {__version__}",
61
+ version=f"{metadata.pkg_name} {metadata.version}",
57
62
  )
58
63
  # Shared by commands that return issue data.
59
- exportable = argparse.ArgumentParser(add_help=False)
60
- exportable.add_argument(
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=[exportable], help="search for issues"
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=[exportable], help="get a single issue"
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=[exportable], help="batch get issues"
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=[exportable], help="get comments on an issue"
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 the issue tracker backend is reachable",
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: Long option name (without dashes) to value.
153
+ :return: Option name as argparse stores it (``no_pager``) to value.
149
154
  """
150
155
 
151
156
  return {
152
- action.option_strings[-1].lstrip("-"): getattr(args, action.dest)
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, as_raw=args.raw)
204
+ print_out(output=issues, prettified=args.raw, no_pager=args.no_pager)
200
205
 
201
206
  if args.export:
202
- export(output=issues, formats=args.export)
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() # restore stdout so the pager works (Status redirects it)
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
- export(output=issue, formats=args.export)
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() # restore stdout so the pager works (Status redirects it)
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
- export(output=issues, formats=args.export)
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() # restore stdout so the pager works (Status redirects it)
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
- export(output=result.comments, formats=args.export)
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 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``).
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 issue tracker backend…[/dim]")
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
- __pkg__ = "bugpipe"
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 = __pkg__,
97
- package_version: str = __version__,
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 .term import console
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