Coverage for yuio / dbg.py: 94%

217 statements  

« 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 

7 

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

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 

46from yuio import _typing as _t 

47 

48if _t.TYPE_CHECKING: 

49 import yuio.app 

50 

51__all__ = [ 

52 "EnvCollector", 

53 "Report", 

54 "ReportSettings", 

55 "print_report", 

56 "report_exc", 

57] 

58 

59 

60@dataclass(slots=True) 

61class Report: 

62 """ 

63 An environment report. 

64 

65 """ 

66 

67 title: str 

68 """ 

69 Report title. 

70 

71 """ 

72 

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. 

76 

77 """ 

78 

79 

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

81""" 

82A callable that collects a report. 

83 

84""" 

85 

86_LOCK = threading.Lock() 

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

88 

89 

90@dataclass(slots=True) 

91class ReportSettings: 

92 """ 

93 Settings for collecting debug data. 

94 

95 """ 

96 

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

98 """ 

99 Root package. Used to collect dependencies. 

100 

101 """ 

102 

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

104 """ 

105 List of additional dependencies to include to version report. 

106 

107 """ 

108 

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

110 """ 

111 List of additional env collectors to run. 

112 

113 """ 

114 

115 

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 

123 

124 

125def _load_env_collectors(): 

126 import importlib.metadata 

127 

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

129 

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

136 

137 return collectors 

138 

139 

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

149 

150 

151def _system() -> Report: 

152 import platform 

153 

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

155 

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 

180 

181 

182def _versions( 

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

184) -> Report: 

185 report = Report("Versions") 

186 

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__ 

192 

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

204 

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

216 

217 for dependency in dependencies: 

218 with report_exc(report, dependency): 

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

220 

221 return report 

222 

223 

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

225 import importlib.metadata 

226 

227 if isinstance(package, types.ModuleType): 

228 package = package.__name__ 

229 

230 if "[" in package: 

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

232 package = package.strip() 

233 

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 

246 

247 

248def _find_package_version(package: str): 

249 import importlib.metadata 

250 

251 if "[" in package: 

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

253 package = package.strip() 

254 

255 try: 

256 return importlib.metadata.version(package) 

257 except importlib.metadata.PackageNotFoundError: 

258 pass 

259 

260 module = importlib.import_module(package) 

261 

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

273 

274 return "unknown" 

275 

276 

277def _terminal() -> Report: 

278 import subprocess 

279 

280 import yuio.io 

281 import yuio.term 

282 

283 report = Report("Terminal and CLI") 

284 

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

293 

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

301 

302 return report 

303 

304 

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

312 

313 

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

322 

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. 

329 

330 .. note:: 

331 

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

333 to extract its dependencies and print their versions. 

334 

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

336 ``__main__`` module. 

337 

338 """ 

339 if dest is None: 

340 dest = sys.__stdout__ 

341 if settings is None or isinstance(settings, bool): 

342 settings = ReportSettings() 

343 

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

356 

357 START = 0 

358 AFTER_TITLE = 1 

359 AFTER_ITEM = 2 

360 AFTER_LONG_ITEM = 3 

361 

362 position = START 

363 

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

365 

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 

376 

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 

383 

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 

413 

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

415 

416 

417if __name__ == "__main__": 

418 print_report()