Coverage for yuio / dbg.py: 95%
231 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9This module provides functionality for collecting data about environment for including
10it to bug reports. It's inspired by Scooby__ (check it out if functionality
11of this module is not enough).
13__ https://github.com/banesullivan/scooby/
15Set :attr:`App.bug_report <yuio.app.App.bug_report>` to enable this functionality.
17.. autofunction:: print_report
19.. autoclass:: ReportSettings
20 :members:
22.. autoclass:: Report
23 :members:
25.. type:: EnvCollector
26 :canonical: typing.Callable[[], Report]
28 Type alias for report collector.
30"""
32from __future__ import annotations
34import contextlib
35import dataclasses
36import datetime
37import os
38import re
39import sys
40import textwrap
41import threading
42import traceback
43import types
44from dataclasses import dataclass
46import yuio._typing_ext as _tx
47from typing import TYPE_CHECKING
49if TYPE_CHECKING:
50 import typing_extensions as _t
51else:
52 from yuio import _typing as _t
54if TYPE_CHECKING:
55 import yuio.app
57__all__ = [
58 "EnvCollector",
59 "Report",
60 "ReportSettings",
61 "print_report",
62 "report_exc",
63]
66@dataclass(slots=True)
67class Report:
68 """
69 An environment report.
71 """
73 title: str
74 """
75 Report title.
77 """
79 items: list[str | tuple[str, str]] = dataclasses.field(default_factory=list)
80 """
81 Report items. Each item can be a string or a key-value pair of strings.
83 """
86EnvCollector: _t.TypeAlias = _t.Callable[[], Report]
87"""
88A callable that collects a report.
90"""
92_LOCK = threading.Lock()
93_ENV_COLLECTORS: list[tuple[str, EnvCollector | Report]] | None = None
96@dataclass(slots=True)
97class ReportSettings:
98 """
99 Settings for collecting debug data.
101 """
103 package: str | types.ModuleType | None = None
104 """
105 Root package. Used to collect dependencies.
107 """
109 dependencies: list[str | types.ModuleType] | None = None
110 """
111 List of additional dependencies to include to version report.
113 """
115 collectors: list[EnvCollector | Report] | None = None
116 """
117 List of additional env collectors to run.
119 """
122def _get_env_collectors():
123 global _ENV_COLLECTORS
124 if _ENV_COLLECTORS is None:
125 with _LOCK:
126 if _ENV_COLLECTORS is None:
127 _ENV_COLLECTORS = _load_env_collectors()
128 return _ENV_COLLECTORS
131def _load_env_collectors():
132 import importlib.metadata
134 collectors: list[tuple[str, EnvCollector | Report]] = []
136 for plugin in importlib.metadata.entry_points(group="yuio.dbg.env_collector"):
137 try:
138 collectors.append((plugin.name, plugin.load()))
139 except Exception:
140 msg = "Error when loading env collector:\n" + traceback.format_exc()
141 collectors.append((plugin.name, Report(plugin.name, [msg])))
143 return collectors
146@contextlib.contextmanager
147def report_exc(report: Report, key: str | None):
148 try:
149 yield
150 except Exception as e:
151 if key is not None:
152 report.items.append((key, f"can't collect this item: {e}"))
153 else:
154 report.items.append(f"Can't collect this item: {e}")
157def _system() -> Report:
158 import platform
160 report = Report("System", [])
162 now = datetime.datetime.now(datetime.timezone.utc)
163 report.items.append(("date", now.strftime("%Y-%m-%d %H:%M:%S")))
164 with report_exc(report, "platform"):
165 report.items.append(("platform", f"{sys.platform} ({platform.platform()})"))
166 with report_exc(report, "os"):
167 os = platform.system()
168 if os == "Linux":
169 data = platform.freedesktop_os_release()
170 report.items.append(("os", f"{data['NAME']} {data['VERSION_ID']}"))
171 elif os == "Windows":
172 report.items.append(("os", " ".join(platform.win32_ver())))
173 elif os == "Darwin":
174 report.items.append(("os", platform.mac_ver()[0]))
175 else: # pragma: no cover
176 report.items.append(("os", os))
177 with report_exc(report, "python"):
178 report.items.append(
179 ("python", f"{platform.python_implementation()} {sys.version}")
180 )
181 with report_exc(report, "machine"):
182 report.items.append(
183 ("machine", f"{platform.machine()} {platform.architecture()[0]}")
184 )
185 return report
188def _versions(
189 settings: ReportSettings, app: yuio.app.App[_t.Any] | None = None
190) -> Report:
191 report = Report("Versions")
193 package = settings.package
194 if app and app.version:
195 report.items.append(("__app__", str(app.version)))
196 if app and package is None:
197 package = app._command.__module__
199 dependencies: set[str]
200 if package is None:
201 dependencies = set()
202 elif isinstance(package, (str, types.ModuleType)):
203 dependencies = _get_dependencies(package)
204 else:
205 report.items.append(
206 "TypeError: expected str or ModuleType, "
207 f"got {_tx.type_repr(type(package))}: {package!r}"
208 )
209 dependencies = set()
211 for dependency in settings.dependencies or []:
212 if isinstance(dependency, types.ModuleType):
213 dependencies.add(dependency.__name__)
214 elif isinstance(dependency, str):
215 dependencies.add(dependency)
216 else:
217 report.items.append(
218 "TypeError: expected str or ModuleType, "
219 f"got {_tx.type_repr(type(dependency))}: {dependency!r}"
220 )
221 dependencies.add("yuio")
223 for dependency in sorted(dependencies):
224 with report_exc(report, dependency):
225 report.items.append((dependency, _find_package_version(dependency)))
227 return report
230def _get_dependencies(package: str | types.ModuleType) -> set[str]:
231 import importlib.metadata
233 if isinstance(package, types.ModuleType):
234 package = package.__name__
236 if "[" in package:
237 package = package.split("[", maxsplit=1)[0]
238 package = package.strip()
240 dependencies = {package}
241 try:
242 distribution = importlib.metadata.distribution(package)
243 except importlib.metadata.PackageNotFoundError:
244 return dependencies
245 requires = distribution.requires
246 if not requires:
247 return dependencies
248 for requirement in requires:
249 if match := re.match(r"^\s*([\w-]+)\s*(\[[\w,\s-]*\])?", requirement):
250 dependencies.add(match.group(0))
251 return dependencies
254def _find_package_version(package: str):
255 import importlib.metadata
257 if "[" in package:
258 package = package.split("[", maxsplit=1)[0]
259 package = package.strip()
261 try:
262 return importlib.metadata.version(package)
263 except importlib.metadata.PackageNotFoundError:
264 pass
266 module = importlib.import_module(package)
268 for v_string in ("__version__", "version"):
269 try:
270 version = getattr(module, v_string)
271 except AttributeError:
272 continue
273 if not version:
274 continue
275 if isinstance(version, str):
276 return version
277 elif isinstance(version, tuple):
278 return ".".join(map(str, version))
280 return "unknown"
283def _terminal() -> Report:
284 import subprocess
286 import yuio.io
287 import yuio.term
289 report = Report("Terminal and CLI")
291 report.items.append(("term", os.environ.get("TERM", "")))
292 report.items.append(("colorterm", os.environ.get("COLORTERM", "")))
293 report.items.append(("shell", os.environ.get("SHELL", "")))
294 report.items.append(("ci", repr(yuio.term.detect_ci())))
296 if os.name != "nt": # pragma: no cover
297 with report_exc(report, "wsl"):
298 try:
299 wslinfo = subprocess.getoutput("wslinfo --version")
300 except FileNotFoundError:
301 wslinfo = "None"
302 report.items.append(("wsl", wslinfo))
304 report.items.append("Term")
306 term = yuio.io.get_term()
307 report.items.append(("color support", repr(term.color_support)))
308 report.items.append(("ostream", repr(term.ostream)))
309 report.items.append(("ostream is tty", repr(term.ostream_is_tty)))
310 report.items.append(("istream", repr(term.istream)))
311 report.items.append(("istream is tty", repr(term.istream_is_tty)))
312 report.items.append(("has terminal theme", repr(term.terminal_theme is not None)))
314 report.items.append("TTY")
316 yuio.term.get_tty()
317 report.items.append(("tty output", repr(getattr(yuio.term, "_TTY_OUTPUT", None))))
318 report.items.append(("tty input", repr(getattr(yuio.term, "_TTY_INPUT", None))))
319 report.items.append(
320 ("explicit colors", repr(getattr(yuio.term, "_EXPLICIT_COLOR_SUPPORT", None)))
321 )
322 report.items.append(("colors", repr(getattr(yuio.term, "_COLOR_SUPPORT", None))))
324 return report
327def _collector_name(collector: Report | EnvCollector):
328 if isinstance(collector, Report):
329 return collector.title
330 else:
331 qualname = getattr(collector, "__qualname__", "unknown")
332 module = getattr(collector, "__module__", "unknown")
333 return f"{module}.{qualname}"
336def print_report(
337 *,
338 dest: _t.TextIO | None = None,
339 settings: ReportSettings | bool | None = None,
340 app: yuio.app.App[_t.Any] | None = None,
341):
342 """
343 Collect and print debug report to the given `dest`.
345 :param dest:
346 destination stream for printing bug report, default is ``stdout``.
347 :param settings:
348 settings for bug report generation.
349 :param app:
350 main app of the project, used to extract project's version and dependencies.
352 .. note::
354 If your app defined in the ``__main__`` module, Yuio will not be able
355 to extract its dependencies and print their versions.
357 We recommend defining app in a separate file and importing it to the
358 ``__main__`` module.
360 """
361 if dest is None:
362 dest = sys.__stdout__
363 if (settings is None or isinstance(settings, bool)) and app is not None:
364 settings = app.bug_report
365 if settings is None or isinstance(settings, bool):
366 settings = ReportSettings()
368 all_collectors: list[tuple[str, EnvCollector | Report]] = [
369 ("System", _system),
370 ("Versions", lambda: _versions(settings, app)),
371 ("Terminal and CLI", _terminal),
372 ]
373 all_collectors.extend(
374 (
375 (_collector_name(collector), collector)
376 for collector in settings.collectors or []
377 )
378 )
379 all_collectors.extend(_get_env_collectors())
381 START = 0
382 AFTER_TITLE = 1
383 AFTER_ITEM = 2
384 AFTER_LONG_ITEM = 3
386 position = START
388 print("```", file=dest)
390 col_width = 20
391 indent = " " * (col_width + 2)
392 for name, collector in all_collectors:
393 printed_title = False
394 try:
395 if isinstance(collector, Report):
396 report = collector
397 else:
398 report = collector()
399 title = report.title or name
401 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
402 print("\n", file=dest)
403 print(indent + title, file=dest)
404 print(indent + "~" * len(title) + "\n", file=dest)
405 printed_title = True
406 position = AFTER_TITLE
408 for data in report.items or ["No data"]:
409 if isinstance(data, str):
410 key, value = "", data
411 else:
412 key, value = data
413 if key:
414 if position == AFTER_LONG_ITEM:
415 print("", file=dest)
416 print(f"{key:>{col_width}}: {value}", file=dest)
417 position = AFTER_ITEM
418 else:
419 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
420 print("", file=dest)
421 print(textwrap.indent(value, indent), file=dest)
422 position = AFTER_LONG_ITEM
423 except Exception:
424 if not printed_title:
425 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
426 print("\n", file=dest)
427 print(indent + name, file=dest)
428 print(indent + "~" * len(name) + "\n", file=dest)
429 position = AFTER_TITLE
430 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
431 print("", file=dest)
432 print(indent + "Error when collecting information:", file=dest)
433 print(
434 textwrap.indent(traceback.format_exc(), indent).strip("\n"), file=dest
435 )
436 position = AFTER_LONG_ITEM
438 print("```", file=dest)
441if __name__ == "__main__":
442 print_report()