Coverage for yuio / dbg.py: 94%
233 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +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__
198 if package and (index := package.find(".")) != -1:
199 package = package[:index]
201 dependencies: set[str]
202 if package is None:
203 dependencies = set()
204 elif isinstance(package, (str, types.ModuleType)):
205 dependencies = _get_dependencies(package)
206 else:
207 report.items.append(
208 "TypeError: expected str or ModuleType, "
209 f"got {_tx.type_repr(type(package))}: {package!r}"
210 )
211 dependencies = set()
213 for dependency in settings.dependencies or []:
214 if isinstance(dependency, types.ModuleType):
215 dependencies.add(dependency.__name__)
216 elif isinstance(dependency, str):
217 dependencies.add(dependency)
218 else:
219 report.items.append(
220 "TypeError: expected str or ModuleType, "
221 f"got {_tx.type_repr(type(dependency))}: {dependency!r}"
222 )
223 dependencies.add("yuio")
225 for dependency in sorted(dependencies):
226 with report_exc(report, dependency):
227 report.items.append((dependency, _find_package_version(dependency)))
229 return report
232def _get_dependencies(package: str | types.ModuleType) -> set[str]:
233 import importlib.metadata
235 if isinstance(package, types.ModuleType):
236 package = package.__name__
238 if "[" in package:
239 package = package.split("[", maxsplit=1)[0]
240 package = package.strip()
242 dependencies = {package}
243 try:
244 distribution = importlib.metadata.distribution(package)
245 except importlib.metadata.PackageNotFoundError:
246 return dependencies
247 requires = distribution.requires
248 if not requires:
249 return dependencies
250 for requirement in requires:
251 if match := re.match(r"^\s*([\w-]+)\s*(\[[\w,\s-]*\])?", requirement):
252 dependencies.add(match.group(0))
253 return dependencies
256def _find_package_version(package: str):
257 import importlib.metadata
259 if "[" in package:
260 package = package.split("[", maxsplit=1)[0]
261 package = package.strip()
263 try:
264 return importlib.metadata.version(package)
265 except importlib.metadata.PackageNotFoundError:
266 pass
268 module = importlib.import_module(package)
270 for v_string in ("__version__", "version"):
271 try:
272 version = getattr(module, v_string)
273 except AttributeError:
274 continue
275 if not version:
276 continue
277 if isinstance(version, str):
278 return version
279 elif isinstance(version, tuple):
280 return ".".join(map(str, version))
282 return "unknown"
285def _terminal() -> Report:
286 import subprocess
288 import yuio.io
289 import yuio.term
291 report = Report("Terminal and CLI")
293 report.items.append(("term", os.environ.get("TERM", "")))
294 report.items.append(("colorterm", os.environ.get("COLORTERM", "")))
295 report.items.append(("shell", os.environ.get("SHELL", "")))
296 report.items.append(("ci", repr(yuio.term.detect_ci())))
298 if os.name != "nt": # pragma: no cover
299 with report_exc(report, "wsl"):
300 try:
301 wslinfo = subprocess.getoutput("wslinfo --version")
302 except FileNotFoundError:
303 wslinfo = "None"
304 report.items.append(("wsl", wslinfo))
306 report.items.append("Term")
308 term = yuio.io.get_term()
309 report.items.append(("color support", repr(term.color_support)))
310 report.items.append(("ostream", repr(term.ostream)))
311 report.items.append(("ostream is tty", repr(term.ostream_is_tty)))
312 report.items.append(("istream", repr(term.istream)))
313 report.items.append(("istream is tty", repr(term.istream_is_tty)))
314 report.items.append(("has terminal theme", repr(term.terminal_theme is not None)))
316 report.items.append("TTY")
318 yuio.term.get_tty()
319 report.items.append(("tty output", repr(getattr(yuio.term, "_TTY_OUTPUT", None))))
320 report.items.append(("tty input", repr(getattr(yuio.term, "_TTY_INPUT", None))))
321 report.items.append(
322 ("explicit colors", repr(getattr(yuio.term, "_EXPLICIT_COLOR_SUPPORT", None)))
323 )
324 report.items.append(("colors", repr(getattr(yuio.term, "_COLOR_SUPPORT", None))))
326 return report
329def _collector_name(collector: Report | EnvCollector):
330 if isinstance(collector, Report):
331 return collector.title
332 else:
333 qualname = getattr(collector, "__qualname__", "unknown")
334 module = getattr(collector, "__module__", "unknown")
335 return f"{module}.{qualname}"
338def print_report(
339 *,
340 dest: _t.TextIO | None = None,
341 settings: ReportSettings | bool | None = None,
342 app: yuio.app.App[_t.Any] | None = None,
343):
344 """
345 Collect and print debug report to the given `dest`.
347 :param dest:
348 destination stream for printing bug report, default is ``stdout``.
349 :param settings:
350 settings for bug report generation.
351 :param app:
352 main app of the project, used to extract project's version and dependencies.
354 .. note::
356 If your app defined in the ``__main__`` module, Yuio will not be able
357 to extract its dependencies and print their versions.
359 We recommend defining app in a separate file and importing it to the
360 ``__main__`` module.
362 """
363 if dest is None:
364 dest = sys.__stdout__
365 if (settings is None or isinstance(settings, bool)) and app is not None:
366 settings = app.bug_report
367 if settings is None or isinstance(settings, bool):
368 settings = ReportSettings()
370 all_collectors: list[tuple[str, EnvCollector | Report]] = [
371 ("System", _system),
372 ("Versions", lambda: _versions(settings, app)),
373 ("Terminal and CLI", _terminal),
374 ]
375 all_collectors.extend(
376 (
377 (_collector_name(collector), collector)
378 for collector in settings.collectors or []
379 )
380 )
381 all_collectors.extend(_get_env_collectors())
383 START = 0
384 AFTER_TITLE = 1
385 AFTER_ITEM = 2
386 AFTER_LONG_ITEM = 3
388 position = START
390 print("```", file=dest)
392 col_width = 20
393 indent = " " * (col_width + 2)
394 for name, collector in all_collectors:
395 printed_title = False
396 try:
397 if isinstance(collector, Report):
398 report = collector
399 else:
400 report = collector()
401 title = report.title or name
403 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
404 print("\n", file=dest)
405 print(indent + title, file=dest)
406 print(indent + "~" * len(title) + "\n", file=dest)
407 printed_title = True
408 position = AFTER_TITLE
410 for data in report.items or ["No data"]:
411 if isinstance(data, str):
412 key, value = "", data
413 else:
414 key, value = data
415 if key:
416 if position == AFTER_LONG_ITEM:
417 print("", file=dest)
418 print(f"{key:>{col_width}}: {value}", file=dest)
419 position = AFTER_ITEM
420 else:
421 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
422 print("", file=dest)
423 print(textwrap.indent(value, indent), file=dest)
424 position = AFTER_LONG_ITEM
425 except Exception:
426 if not printed_title:
427 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
428 print("\n", file=dest)
429 print(indent + name, file=dest)
430 print(indent + "~" * len(name) + "\n", file=dest)
431 position = AFTER_TITLE
432 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
433 print("", file=dest)
434 print(indent + "Error when collecting information:", file=dest)
435 print(
436 textwrap.indent(traceback.format_exc(), indent).strip("\n"), file=dest
437 )
438 position = AFTER_LONG_ITEM
440 print("```", file=dest)
443if __name__ == "__main__":
444 print_report()