Coverage for yuio / util.py: 98%

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

9.. autofunction:: to_dash_case 

10 

11.. autofunction:: dedent 

12 

13.. autofunction:: find_docs 

14 

15.. autofunction:: commonprefix 

16 

17.. autoclass:: UserString 

18 

19 .. automethod:: _wrap 

20 

21.. autoclass:: ClosedIO 

22 

23""" 

24 

25from __future__ import annotations 

26 

27import io as _io 

28import re as _re 

29import textwrap as _textwrap 

30import weakref 

31 

32from typing import TYPE_CHECKING 

33 

34if TYPE_CHECKING: 

35 import typing_extensions as _t 

36else: 

37 from yuio import _typing as _t 

38 

39__all__ = [ 

40 "ClosedIO", 

41 "UserString", 

42 "commonprefix", 

43 "dedent", 

44 "find_docs", 

45 "to_dash_case", 

46] 

47 

48_UNPRINTABLE = "".join([chr(i) for i in range(32)]) + "\x7f" 

49_UNPRINTABLE_TRANS = str.maketrans(_UNPRINTABLE, " " * len(_UNPRINTABLE)) 

50_UNPRINTABLE_RE = r"[" + _re.escape(_UNPRINTABLE) + "]" 

51_UNPRINTABLE_RE_WITHOUT_NL = r"[" + _re.escape(_UNPRINTABLE.replace("\n", "")) + "]" 

52 

53_TO_DASH_CASE_RE = _re.compile( 

54 r""" 

55 # We will add a dash (bear with me here): 

56 [_\s] # 1. instead of underscore or space, 

57 | ( # 2. OR in the following case: 

58 (?<!^) # - not at the beginning of the string, 

59 ( # - AND EITHER: 

60 (?<=[A-Z])(?=[A-Z][a-z]) # - before case gets lower (`XMLTag` -> `XML-Tag`), 

61 | (?<=[a-zA-Z])(?![a-zA-Z_]) # - between a letter and a non-letter (`HTTP20` -> `HTTP-20`), 

62 | (?<![A-Z_])(?=[A-Z]) # - between non-uppercase and uppercase letter (`TagXML` -> `Tag-XML`), 

63 ) # - AND ALSO: 

64 (?!$) # - not at the end of the string. 

65 ) 

66 """, 

67 _re.VERBOSE | _re.MULTILINE, 

68) 

69 

70 

71def to_dash_case(msg: str, /) -> str: 

72 """ 

73 Convert ``CamelCase`` or ``snake_case`` identifier to a ``dash-case`` one. 

74 

75 This function assumes ASCII input, and will not work correctly 

76 with non-ASCII characters. 

77 

78 :param msg: 

79 identifier to convert. 

80 :returns: 

81 identifier in ``dash-case``. 

82 :example: 

83 :: 

84 

85 >>> to_dash_case("SomeClass") 

86 'some-class' 

87 >>> to_dash_case("HTTP20XMLUberParser") 

88 'http-20-xml-uber-parser' 

89 

90 """ 

91 

92 return _TO_DASH_CASE_RE.sub("-", msg).lower() 

93 

94 

95def dedent(msg: str, /): 

96 """ 

97 Remove leading indentation from a message and normalize trailing newlines. 

98 

99 This function is intended to be used with triple-quote string literals, 

100 such as docstrings. It will remove common indentation from second 

101 and subsequent lines, then it will strip any leading and trailing whitespaces 

102 and add a new line at the end. 

103 

104 :param msg: 

105 message to dedent. 

106 :returns: 

107 normalized message. 

108 :example: 

109 :: 

110 

111 >>> def foo(): 

112 ... \"""Documentation for function ``foo``. 

113 ... 

114 ... Leading indent is stripped. 

115 ... \""" 

116 ... 

117 ... ... 

118 

119 >>> dedent(foo.__doc__) 

120 'Documentation for function ``foo``.\\n\\nLeading indent is stripped.\\n' 

121 

122 """ 

123 

124 if not msg: 

125 return msg 

126 

127 first, *rest = msg.splitlines(keepends=True) 

128 return (first.rstrip() + "\n" + _textwrap.dedent("".join(rest))).strip() + "\n" 

129 

130 

131_COMMENT_RE = _re.compile(r"^\s*#:(.*)\r?\n?$") 

132_DOCS_CACHE: weakref.WeakKeyDictionary[_t.Any, dict[str, str]] = ( 

133 weakref.WeakKeyDictionary() 

134) 

135 

136 

137def find_docs(obj: _t.Any, /) -> dict[str, str]: 

138 """ 

139 Find documentation for fields of a class. 

140 

141 Inspects source code of a class and finds docstrings and doc comments (``#:``) 

142 for variables in its body. Doesn't inspect ``__init__``, doesn't return documentation 

143 for class methods. 

144 

145 """ 

146 

147 # Based on code from Sphinx, two clause BSD license. 

148 # See https://github.com/sphinx-doc/sphinx/blob/master/LICENSE.rst. 

149 

150 try: 

151 return _DOCS_CACHE[obj] 

152 except KeyError: 

153 pass 

154 except TypeError: 

155 return {} 

156 

157 import ast 

158 import inspect 

159 import itertools 

160 

161 if (qualname := getattr(obj, "__qualname__", None)) is None: 

162 # Not a known object. 

163 _DOCS_CACHE[obj] = {} 

164 return {} 

165 

166 if "<locals>" in qualname: 

167 # This will not work as expected! 

168 _DOCS_CACHE[obj] = {} 

169 return {} 

170 

171 try: 

172 sourcelines, _ = inspect.getsourcelines(obj) 

173 except TypeError: 

174 _DOCS_CACHE[obj] = {} 

175 return {} 

176 

177 docs: dict[str, str] = {} 

178 

179 node = ast.parse(_textwrap.dedent("".join(sourcelines))) 

180 assert isinstance(node, ast.Module) 

181 assert len(node.body) == 1 

182 cdef = node.body[0] 

183 

184 if isinstance(cdef, ast.ClassDef): 

185 fields: list[tuple[int, str]] = [] 

186 last_field: str | None = None 

187 for stmt in cdef.body: 

188 if ( 

189 last_field 

190 and isinstance(stmt, ast.Expr) 

191 and isinstance(stmt.value, ast.Constant) 

192 and isinstance(stmt.value.value, str) 

193 ): 

194 docs[last_field] = dedent(stmt.value.value).removesuffix("\n") 

195 last_field = None 

196 if isinstance(stmt, ast.AnnAssign): 

197 target = stmt.target 

198 elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1: 

199 target = stmt.targets[0] 

200 else: 

201 continue 

202 if isinstance(target, ast.Name) and not target.id.startswith("_"): 

203 fields.append((stmt.lineno, target.id)) 

204 last_field = target.id 

205 elif isinstance(cdef, ast.FunctionDef): 

206 fields = [ 

207 (field.lineno, field.arg) 

208 for field in itertools.chain(cdef.args.args, cdef.args.kwonlyargs) 

209 ] 

210 else: # pragma: no cover 

211 _DOCS_CACHE[obj] = {} 

212 return {} 

213 

214 for pos, name in fields: 

215 if name in docs: 

216 continue 

217 

218 comment_lines: list[str] = [] 

219 for before_line in sourcelines[pos - 2 :: -1]: 

220 if match := _COMMENT_RE.match(before_line): 

221 comment_lines.append(match.group(1)) 

222 else: 

223 break 

224 

225 if comment_lines: 

226 docs[name] = dedent("\n".join(reversed(comment_lines))).removesuffix("\n") 

227 

228 _DOCS_CACHE[obj] = docs 

229 return docs 

230 

231 

232def commonprefix(m: _t.Collection[str]) -> str: 

233 if not m: 

234 return "" 

235 s1 = min(m) 

236 s2 = max(m) 

237 for i, c in enumerate(s1): 

238 if c != s2[i]: 

239 return s1[:i] 

240 return s1 

241 

242 

243if TYPE_CHECKING: 

244 

245 class _FormatMapMapping(_t.Protocol): 

246 def __getitem__(self, key: str, /) -> _t.Any: ... 

247 

248 class _TranslateTable(_t.Protocol): 

249 def __getitem__(self, key: int, /) -> str | int | None: ... 

250 

251 

252class UserString(str): 

253 """ 

254 Base class for user string. 

255 

256 This class is similar to :class:`collections.UserString`, but actually derived 

257 from string, with customizable wrapping semantics, and returns custom string 

258 instances from all string methods (:class:`collections.UserString` doesn't 

259 wrap strings returned from :meth:`str.split` and similar). 

260 

261 .. tip:: 

262 

263 When deriving from this class, add ``__slots__`` to avoid making a string 

264 with a ``__dict__`` property. 

265 

266 """ 

267 

268 __slots__ = () 

269 

270 def _wrap(self, data: str) -> _t.Self: 

271 """ 

272 Wrap raw string that resulted from an operation on this instance into another 

273 instance of :class:`UserString`. 

274 

275 Override this method if you need to preserve some internal state during 

276 operations. 

277 

278 By default, this simply creates an instance of ``self.__class__`` with the 

279 given string. 

280 

281 """ 

282 

283 return self.__class__(data) 

284 

285 def __add__(self, value: str, /) -> _t.Self: 

286 return self._wrap(super().__add__(value)) 

287 

288 def __format__(self, format_spec: str, /) -> _t.Self: 

289 return self._wrap(super().__format__(format_spec)) 

290 

291 def __getitem__(self, key: _t.SupportsIndex | slice, /) -> _t.Self: 

292 return self._wrap(super().__getitem__(key)) 

293 

294 def __mod__(self, value: _t.Any, /) -> _t.Self: 

295 return self._wrap(super().__mod__(value)) 

296 

297 def __mul__(self, value: _t.SupportsIndex, /) -> _t.Self: 

298 return self._wrap(super().__mul__(value)) 

299 

300 def __rmul__(self, value: _t.SupportsIndex, /) -> _t.Self: 

301 return self._wrap(super().__rmul__(value)) 

302 

303 def capitalize(self) -> _t.Self: 

304 return self._wrap(super().capitalize()) 

305 

306 def casefold(self) -> _t.Self: 

307 return self._wrap(super().casefold()) 

308 

309 def center(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self: 

310 return self._wrap(super().center(width)) 

311 

312 def expandtabs(self, tabsize: _t.SupportsIndex = 8) -> _t.Self: 

313 return self._wrap(super().expandtabs(tabsize)) 

314 

315 def format_map(self, mapping: _FormatMapMapping, /) -> _t.Self: 

316 return self._wrap(super().format_map(mapping)) 

317 

318 def format(self, *args: object, **kwargs: object) -> _t.Self: 

319 return self._wrap(super().format(*args, **kwargs)) 

320 

321 def join(self, iterable: _t.Iterable[str], /) -> _t.Self: 

322 return self._wrap(super().join(iterable)) 

323 

324 def ljust(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self: 

325 return self._wrap(super().ljust(width, fillchar)) 

326 

327 def lower(self) -> _t.Self: 

328 return self._wrap(super().lower()) 

329 

330 def lstrip(self, chars: str | None = None, /) -> _t.Self: 

331 return self._wrap(super().lstrip(chars)) 

332 

333 def partition(self, sep: str, /) -> tuple[_t.Self, _t.Self, _t.Self]: 

334 l, c, r = super().partition(sep) 

335 return self._wrap(l), self._wrap(c), self._wrap(r) 

336 

337 def removeprefix(self, prefix: str, /) -> _t.Self: 

338 return self._wrap(super().removeprefix(prefix)) 

339 

340 def removesuffix(self, suffix: str, /) -> _t.Self: 

341 return self._wrap(super().removesuffix(suffix)) 

342 

343 def replace(self, old: str, new: str, count: _t.SupportsIndex = -1, /) -> _t.Self: 

344 return self._wrap(super().replace(old, new, count)) 

345 

346 def rjust(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self: 

347 return self._wrap(super().rjust(width, fillchar)) 

348 

349 def rpartition(self, sep: str, /) -> tuple[_t.Self, _t.Self, _t.Self]: 

350 l, c, r = super().rpartition(sep) 

351 return self._wrap(l), self._wrap(c), self._wrap(r) 

352 

353 def rsplit( # pyright: ignore[reportIncompatibleMethodOverride] 

354 self, sep: str | None = None, maxsplit: _t.SupportsIndex = -1 

355 ) -> list[_t.Self]: 

356 return [self._wrap(part) for part in super().rsplit(sep, maxsplit)] 

357 

358 def rstrip(self, chars: str | None = None, /) -> _t.Self: 

359 return self._wrap(super().rstrip(chars)) 

360 

361 def split( # pyright: ignore[reportIncompatibleMethodOverride] 

362 self, sep: str | None = None, maxsplit: _t.SupportsIndex = -1 

363 ) -> list[_t.Self]: 

364 return [self._wrap(part) for part in super().split(sep, maxsplit)] 

365 

366 def splitlines( # pyright: ignore[reportIncompatibleMethodOverride] 

367 self, keepends: bool = False 

368 ) -> list[_t.Self]: 

369 return [self._wrap(part) for part in super().splitlines(keepends)] 

370 

371 def strip(self, chars: str | None = None, /) -> _t.Self: 

372 return self._wrap(super().strip(chars)) 

373 

374 def swapcase(self) -> _t.Self: 

375 return self._wrap(super().swapcase()) 

376 

377 def title(self) -> _t.Self: 

378 return self._wrap(super().title()) 

379 

380 def translate(self, table: _TranslateTable, /) -> _t.Self: 

381 return self._wrap(super().translate(table)) 

382 

383 def upper(self) -> _t.Self: 

384 return self._wrap(super().upper()) 

385 

386 def zfill(self, width: _t.SupportsIndex, /) -> _t.Self: 

387 return self._wrap(super().zfill(width)) 

388 

389 

390class ClosedIO(_io.TextIOBase, _t.TextIO): # type: ignore 

391 """ 

392 Dummy stream that's always closed. 

393 

394 """ 

395 

396 def __init__(self) -> None: 

397 super().__init__() 

398 self.close()