Coverage for yuio / dbg.py: 94%
217 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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__ package (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
46from yuio import _typing as _t
48if _t.TYPE_CHECKING:
49 import yuio.app
51__all__ = [
52 "EnvCollector",
53 "Report",
54 "ReportSettings",
55 "print_report",
56 "report_exc",
57]
60@dataclass(slots=True)
61class Report:
62 """
63 An environment report.
65 """
67 title: str
68 """
69 Report title.
71 """
73 items: list[str | tuple[str, str]] = dataclasses.field(default_factory=list)
74 """
75 Report items. Each item can be a string or a key-value pair of strings.
77 """
80EnvCollector: _t.TypeAlias = _t.Callable[[], Report]
81"""
82A callable that collects a report.
84"""
86_LOCK = threading.Lock()
87_ENV_COLLECTORS: list[tuple[str, EnvCollector | Report]] | None = None
90@dataclass(slots=True)
91class ReportSettings:
92 """
93 Settings for collecting debug data.
95 """
97 package: str | types.ModuleType | None = None
98 """
99 Root package. Used to collect dependencies.
101 """
103 dependencies: list[str | types.ModuleType] | None = None
104 """
105 List of additional dependencies to include to version report.
107 """
109 collectors: list[EnvCollector | Report] | None = None
110 """
111 List of additional env collectors to run.
113 """
116def _get_env_collectors():
117 global _ENV_COLLECTORS
118 if _ENV_COLLECTORS is None:
119 with _LOCK:
120 if _ENV_COLLECTORS is None:
121 _ENV_COLLECTORS = _load_env_collectors()
122 return _ENV_COLLECTORS
125def _load_env_collectors():
126 import importlib.metadata
128 collectors: list[tuple[str, EnvCollector | Report]] = []
130 for plugin in importlib.metadata.entry_points(group="yuio.dbg.env_collector"):
131 try:
132 collectors.append((plugin.name, plugin.load()))
133 except Exception:
134 msg = "Error when loading env collector:\n" + traceback.format_exc()
135 collectors.append((plugin.name, Report(plugin.name, [msg])))
137 return collectors
140@contextlib.contextmanager
141def report_exc(report: Report, key: str | None):
142 try:
143 yield
144 except Exception as e:
145 if key is not None:
146 report.items.append((key, f"can't collect this item: {e}"))
147 else:
148 report.items.append(f"Can't collect this item: {e}")
151def _system() -> Report:
152 import platform
154 report = Report("System", [])
156 now = datetime.datetime.now(datetime.timezone.utc)
157 report.items.append(("date", now.strftime("%Y-%m-%d %H:%M:%S")))
158 with report_exc(report, "platform"):
159 report.items.append(("platform", f"{sys.platform} ({platform.platform()})"))
160 with report_exc(report, "os"):
161 os = platform.system()
162 if os == "Linux":
163 data = platform.freedesktop_os_release()
164 report.items.append(("os", f"{data['NAME']} {data['VERSION_ID']}"))
165 elif os == "Windows":
166 report.items.append(("os", " ".join(platform.win32_ver())))
167 elif os == "Darwin":
168 report.items.append(("os", platform.mac_ver()[0]))
169 else: # pragma: no cover
170 report.items.append(("os", os))
171 with report_exc(report, "python"):
172 report.items.append(
173 ("python", f"{platform.python_implementation()} {sys.version}")
174 )
175 with report_exc(report, "machine"):
176 report.items.append(
177 ("machine", f"{platform.machine()} {platform.architecture()[0]}")
178 )
179 return report
182def _versions(
183 settings: ReportSettings, app: yuio.app.App[_t.Any] | None = None
184) -> Report:
185 report = Report("Versions")
187 package = settings.package
188 if app and app.version:
189 report.items.append(("__app__", str(app.version)))
190 if app and package is None:
191 package = app._command.__module__
193 dependencies: set[str]
194 if package is None:
195 dependencies = set()
196 elif isinstance(package, (str, types.ModuleType)):
197 dependencies = _get_dependencies(package)
198 else:
199 report.items.append(
200 "TypeError: expected str or ModuleType, "
201 f"got {_t.type_repr(type(package))}: {package!r}"
202 )
203 dependencies = set()
205 for dependency in settings.dependencies or []:
206 if isinstance(dependency, types.ModuleType):
207 dependencies.add(dependency.__name__)
208 elif isinstance(dependency, str):
209 dependencies.add(dependency)
210 else:
211 report.items.append(
212 "TypeError: expected str or ModuleType, "
213 f"got {_t.type_repr(type(dependency))}: {dependency!r}"
214 )
215 dependencies.add("yuio")
217 for dependency in dependencies:
218 with report_exc(report, dependency):
219 report.items.append((dependency, _find_package_version(dependency)))
221 return report
224def _get_dependencies(package: str | types.ModuleType) -> set[str]:
225 import importlib.metadata
227 if isinstance(package, types.ModuleType):
228 package = package.__name__
230 if "[" in package:
231 package = package.split("[", maxsplit=1)[0]
232 package = package.strip()
234 dependencies = {package}
235 try:
236 distribution = importlib.metadata.distribution(package)
237 except importlib.metadata.PackageNotFoundError:
238 return dependencies
239 requires = distribution.requires
240 if not requires:
241 return dependencies
242 for requirement in requires:
243 if match := re.match(r"^\s*([\w-]+)\s*(\[[\w,\s-]*\])?", requirement):
244 dependencies.add(match.group(0))
245 return dependencies
248def _find_package_version(package: str):
249 import importlib.metadata
251 if "[" in package:
252 package = package.split("[", maxsplit=1)[0]
253 package = package.strip()
255 try:
256 return importlib.metadata.version(package)
257 except importlib.metadata.PackageNotFoundError:
258 pass
260 module = importlib.import_module(package)
262 for v_string in ("__version__", "version"):
263 try:
264 version = getattr(module, v_string)
265 except AttributeError:
266 continue
267 if not version:
268 continue
269 if isinstance(version, str):
270 return version
271 elif isinstance(version, tuple):
272 return ".".join(map(str, version))
274 return "unknown"
277def _terminal() -> Report:
278 import subprocess
280 import yuio.io
281 import yuio.term
283 report = Report("Terminal and CLI")
285 term = yuio.io.get_term()
286 report.items.append(("interactive support", term.interactive_support.name))
287 report.items.append(("terminal colors", term.color_support.name))
288 report.items.append(("detected colors", str(term.terminal_theme is not None)))
289 report.items.append(("term", os.environ.get("TERM", "")))
290 report.items.append(("colorterm", os.environ.get("COLORTERM", "")))
291 report.items.append(("shell", os.environ.get("SHELL", "")))
292 report.items.append(("ci", str(yuio.term.detect_ci())))
294 if os.name != "nt": # pragma: no cover
295 with report_exc(report, "wsl"):
296 try:
297 wslinfo = subprocess.getoutput("wslinfo --version")
298 except FileNotFoundError:
299 wslinfo = "None"
300 report.items.append(("wsl", wslinfo))
302 return report
305def _collector_name(collector: Report | EnvCollector):
306 if isinstance(collector, Report):
307 return collector.title
308 else:
309 qualname = getattr(collector, "__qualname__", "unknown")
310 module = getattr(collector, "__module__", "unknown")
311 return f"{module}.{qualname}"
314def print_report(
315 *,
316 dest: _t.TextIO | None = None,
317 settings: ReportSettings | bool | None = None,
318 app: yuio.app.App[_t.Any] | None = None,
319):
320 """
321 Collect and print debug report to the given ``dest``.
323 :param dest:
324 destination stream for printing bug report, default is ``stdout``.
325 :param settings:
326 settings for bug report generation.
327 :param app:
328 main app of the project, used to extract project's version and dependencies.
330 .. note::
332 If your app defined in the ``__main__`` module, Yuio will not be able
333 to extract its dependencies and print their versions.
335 We recommend defining app in a separate file and importing it to the
336 ``__main__`` module.
338 """
339 if dest is None:
340 dest = sys.__stdout__
341 if settings is None or isinstance(settings, bool):
342 settings = ReportSettings()
344 all_collectors: list[tuple[str, EnvCollector | Report]] = [
345 ("System", _system),
346 ("Versions", lambda: _versions(settings, app)),
347 ("Terminal and CLI", _terminal),
348 ]
349 all_collectors.extend(
350 (
351 (_collector_name(collector), collector)
352 for collector in settings.collectors or []
353 )
354 )
355 all_collectors.extend(_get_env_collectors())
357 START = 0
358 AFTER_TITLE = 1
359 AFTER_ITEM = 2
360 AFTER_LONG_ITEM = 3
362 position = START
364 print("```", file=dest)
366 col_width = 20
367 indent = " " * (col_width + 2)
368 for name, collector in all_collectors:
369 printed_title = False
370 try:
371 if isinstance(collector, Report):
372 report = collector
373 else:
374 report = collector()
375 title = report.title or name
377 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
378 print("\n", file=dest)
379 print(indent + title, file=dest)
380 print(indent + "~" * len(title) + "\n", file=dest)
381 printed_title = True
382 position = AFTER_TITLE
384 for data in report.items or ["No data"]:
385 if isinstance(data, str):
386 key, value = "", data
387 else:
388 key, value = data
389 if key:
390 if position == AFTER_LONG_ITEM:
391 print("", file=dest)
392 print(f"{key:>{col_width}}: {value}", file=dest)
393 position = AFTER_ITEM
394 else:
395 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
396 print("", file=dest)
397 print(textwrap.indent(value, indent), file=dest)
398 position = AFTER_LONG_ITEM
399 except Exception:
400 if not printed_title:
401 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
402 print("\n", file=dest)
403 print(indent + name, file=dest)
404 print(indent + "~" * len(name) + "\n", file=dest)
405 position = AFTER_TITLE
406 if position in [AFTER_ITEM, AFTER_LONG_ITEM]:
407 print("", file=dest)
408 print(indent + "Error when collecting information:", file=dest)
409 print(
410 textwrap.indent(traceback.format_exc(), indent).strip("\n"), file=dest
411 )
412 position = AFTER_LONG_ITEM
414 print("```", file=dest)
417if __name__ == "__main__":
418 print_report()