Coverage for yuio / dbg.py: 95%

231 statements  

« 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 

7 

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). 

12 

13__ https://github.com/banesullivan/scooby/ 

14 

15Set :attr:`App.bug_report <yuio.app.App.bug_report>` to enable this functionality. 

16 

17.. autofunction:: print_report 

18 

19.. autoclass:: ReportSettings 

20 :members: 

21 

22.. autoclass:: Report 

23 :members: 

24 

25.. type:: EnvCollector 

26 :canonical: typing.Callable[[], Report] 

27 

28 Type alias for report collector. 

29 

30""" 

31 

32from __future__ import annotations 

33 

34import contextlib 

35import dataclasses 

36import datetime 

37import os 

38import re 

39import sys 

40import textwrap 

41import threading 

42import traceback 

43import types 

44from dataclasses import dataclass 

45 

46import yuio._typing_ext as _tx 

47from typing import TYPE_CHECKING 

48 

49if TYPE_CHECKING: 

50 import typing_extensions as _t 

51else: 

52 from yuio import _typing as _t 

53 

54if TYPE_CHECKING: 

55 import yuio.app 

56 

57__all__ = [ 

58 "EnvCollector", 

59 "Report", 

60 "ReportSettings", 

61 "print_report", 

62 "report_exc", 

63] 

64 

65 

66@dataclass(slots=True) 

67class Report: 

68 """ 

69 An environment report. 

70 

71 """ 

72 

73 title: str 

74 """ 

75 Report title. 

76 

77 """ 

78 

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. 

82 

83 """ 

84 

85 

86EnvCollector: _t.TypeAlias = _t.Callable[[], Report] 

87""" 

88A callable that collects a report. 

89 

90""" 

91 

92_LOCK = threading.Lock() 

93_ENV_COLLECTORS: list[tuple[str, EnvCollector | Report]] | None = None 

94 

95 

96@dataclass(slots=True) 

97class ReportSettings: 

98 """ 

99 Settings for collecting debug data. 

100 

101 """ 

102 

103 package: str | types.ModuleType | None = None 

104 """ 

105 Root package. Used to collect dependencies. 

106 

107 """ 

108 

109 dependencies: list[str | types.ModuleType] | None = None 

110 """ 

111 List of additional dependencies to include to version report. 

112 

113 """ 

114 

115 collectors: list[EnvCollector | Report] | None = None 

116 """ 

117 List of additional env collectors to run. 

118 

119 """ 

120 

121 

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 

129 

130 

131def _load_env_collectors(): 

132 import importlib.metadata 

133 

134 collectors: list[tuple[str, EnvCollector | Report]] = [] 

135 

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]))) 

142 

143 return collectors 

144 

145 

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}") 

155 

156 

157def _system() -> Report: 

158 import platform 

159 

160 report = Report("System", []) 

161 

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 

186 

187 

188def _versions( 

189 settings: ReportSettings, app: yuio.app.App[_t.Any] | None = None 

190) -> Report: 

191 report = Report("Versions") 

192 

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 

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() 

210 

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") 

222 

223 for dependency in sorted(dependencies): 

224 with report_exc(report, dependency): 

225 report.items.append((dependency, _find_package_version(dependency))) 

226 

227 return report 

228 

229 

230def _get_dependencies(package: str | types.ModuleType) -> set[str]: 

231 import importlib.metadata 

232 

233 if isinstance(package, types.ModuleType): 

234 package = package.__name__ 

235 

236 if "[" in package: 

237 package = package.split("[", maxsplit=1)[0] 

238 package = package.strip() 

239 

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 

252 

253 

254def _find_package_version(package: str): 

255 import importlib.metadata 

256 

257 if "[" in package: 

258 package = package.split("[", maxsplit=1)[0] 

259 package = package.strip() 

260 

261 try: 

262 return importlib.metadata.version(package) 

263 except importlib.metadata.PackageNotFoundError: 

264 pass 

265 

266 module = importlib.import_module(package) 

267 

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)) 

279 

280 return "unknown" 

281 

282 

283def _terminal() -> Report: 

284 import subprocess 

285 

286 import yuio.io 

287 import yuio.term 

288 

289 report = Report("Terminal and CLI") 

290 

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()))) 

295 

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)) 

303 

304 report.items.append("Term") 

305 

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))) 

313 

314 report.items.append("TTY") 

315 

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)))) 

323 

324 return report 

325 

326 

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}" 

334 

335 

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`. 

344 

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. 

351 

352 .. note:: 

353 

354 If your app defined in the ``__main__`` module, Yuio will not be able 

355 to extract its dependencies and print their versions. 

356 

357 We recommend defining app in a separate file and importing it to the 

358 ``__main__`` module. 

359 

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() 

367 

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()) 

380 

381 START = 0 

382 AFTER_TITLE = 1 

383 AFTER_ITEM = 2 

384 AFTER_LONG_ITEM = 3 

385 

386 position = START 

387 

388 print("```", file=dest) 

389 

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 

400 

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 

407 

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 

437 

438 print("```", file=dest) 

439 

440 

441if __name__ == "__main__": 

442 print_report()