Coverage for yuio / dbg.py: 94%

233 statements  

« 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 

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 if package and (index := package.find(".")) != -1: 

199 package = package[:index] 

200 

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

212 

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

224 

225 for dependency in sorted(dependencies): 

226 with report_exc(report, dependency): 

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

228 

229 return report 

230 

231 

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

233 import importlib.metadata 

234 

235 if isinstance(package, types.ModuleType): 

236 package = package.__name__ 

237 

238 if "[" in package: 

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

240 package = package.strip() 

241 

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 

254 

255 

256def _find_package_version(package: str): 

257 import importlib.metadata 

258 

259 if "[" in package: 

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

261 package = package.strip() 

262 

263 try: 

264 return importlib.metadata.version(package) 

265 except importlib.metadata.PackageNotFoundError: 

266 pass 

267 

268 module = importlib.import_module(package) 

269 

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

281 

282 return "unknown" 

283 

284 

285def _terminal() -> Report: 

286 import subprocess 

287 

288 import yuio.io 

289 import yuio.term 

290 

291 report = Report("Terminal and CLI") 

292 

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

297 

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

305 

306 report.items.append("Term") 

307 

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

315 

316 report.items.append("TTY") 

317 

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

325 

326 return report 

327 

328 

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

336 

337 

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

346 

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. 

353 

354 .. note:: 

355 

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

357 to extract its dependencies and print their versions. 

358 

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

360 ``__main__`` module. 

361 

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

369 

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

382 

383 START = 0 

384 AFTER_TITLE = 1 

385 AFTER_ITEM = 2 

386 AFTER_LONG_ITEM = 3 

387 

388 position = START 

389 

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

391 

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 

402 

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 

409 

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 

439 

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

441 

442 

443if __name__ == "__main__": 

444 print_report()