Coverage for yuio / util.py: 98%
160 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« 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
8"""
9.. autofunction:: to_dash_case
11.. autofunction:: dedent
13.. autofunction:: find_docs
15.. autofunction:: commonprefix
17.. autoclass:: UserString
19 .. automethod:: _wrap
21.. autoclass:: ClosedIO
23"""
25from __future__ import annotations
27import io as _io
28import re as _re
29import textwrap as _textwrap
30import weakref
32from typing import TYPE_CHECKING
34if TYPE_CHECKING:
35 import typing_extensions as _t
36else:
37 from yuio import _typing as _t
39__all__ = [
40 "ClosedIO",
41 "UserString",
42 "commonprefix",
43 "dedent",
44 "find_docs",
45 "to_dash_case",
46]
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", "")) + "]"
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)
71def to_dash_case(msg: str, /) -> str:
72 """
73 Convert ``CamelCase`` or ``snake_case`` identifier to a ``dash-case`` one.
75 This function assumes ASCII input, and will not work correctly
76 with non-ASCII characters.
78 :param msg:
79 identifier to convert.
80 :returns:
81 identifier in ``dash-case``.
82 :example:
83 ::
85 >>> to_dash_case("SomeClass")
86 'some-class'
87 >>> to_dash_case("HTTP20XMLUberParser")
88 'http-20-xml-uber-parser'
90 """
92 return _TO_DASH_CASE_RE.sub("-", msg).lower()
95def dedent(msg: str, /):
96 """
97 Remove leading indentation from a message and normalize trailing newlines.
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.
104 :param msg:
105 message to dedent.
106 :returns:
107 normalized message.
108 :example:
109 ::
111 >>> def foo():
112 ... \"""Documentation for function ``foo``.
113 ...
114 ... Leading indent is stripped.
115 ... \"""
116 ...
117 ... ...
119 >>> dedent(foo.__doc__)
120 'Documentation for function ``foo``.\\n\\nLeading indent is stripped.\\n'
122 """
124 if not msg:
125 return msg
127 first, *rest = msg.splitlines(keepends=True)
128 return (first.rstrip() + "\n" + _textwrap.dedent("".join(rest))).strip() + "\n"
131_COMMENT_RE = _re.compile(r"^\s*#:(.*)\r?\n?$")
132_DOCS_CACHE: weakref.WeakKeyDictionary[_t.Any, dict[str, str]] = (
133 weakref.WeakKeyDictionary()
134)
137def find_docs(obj: _t.Any, /) -> dict[str, str]:
138 """
139 Find documentation for fields of a class.
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.
145 """
147 # Based on code from Sphinx, two clause BSD license.
148 # See https://github.com/sphinx-doc/sphinx/blob/master/LICENSE.rst.
150 try:
151 return _DOCS_CACHE[obj]
152 except KeyError:
153 pass
154 except TypeError:
155 return {}
157 import ast
158 import inspect
159 import itertools
161 if (qualname := getattr(obj, "__qualname__", None)) is None:
162 # Not a known object.
163 _DOCS_CACHE[obj] = {}
164 return {}
166 if "<locals>" in qualname:
167 # This will not work as expected!
168 _DOCS_CACHE[obj] = {}
169 return {}
171 try:
172 sourcelines, _ = inspect.getsourcelines(obj)
173 except TypeError:
174 _DOCS_CACHE[obj] = {}
175 return {}
177 docs: dict[str, str] = {}
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]
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 {}
214 for pos, name in fields:
215 if name in docs:
216 continue
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
225 if comment_lines:
226 docs[name] = dedent("\n".join(reversed(comment_lines))).removesuffix("\n")
228 _DOCS_CACHE[obj] = docs
229 return docs
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
243if TYPE_CHECKING:
245 class _FormatMapMapping(_t.Protocol):
246 def __getitem__(self, key: str, /) -> _t.Any: ...
248 class _TranslateTable(_t.Protocol):
249 def __getitem__(self, key: int, /) -> str | int | None: ...
252class UserString(str):
253 """
254 Base class for user string.
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).
261 .. tip::
263 When deriving from this class, add ``__slots__`` to avoid making a string
264 with a ``__dict__`` property.
266 """
268 __slots__ = ()
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`.
275 Override this method if you need to preserve some internal state during
276 operations.
278 By default, this simply creates an instance of ``self.__class__`` with the
279 given string.
281 """
283 return self.__class__(data)
285 def __add__(self, value: str, /) -> _t.Self:
286 return self._wrap(super().__add__(value))
288 def __format__(self, format_spec: str, /) -> _t.Self:
289 return self._wrap(super().__format__(format_spec))
291 def __getitem__(self, key: _t.SupportsIndex | slice, /) -> _t.Self:
292 return self._wrap(super().__getitem__(key))
294 def __mod__(self, value: _t.Any, /) -> _t.Self:
295 return self._wrap(super().__mod__(value))
297 def __mul__(self, value: _t.SupportsIndex, /) -> _t.Self:
298 return self._wrap(super().__mul__(value))
300 def __rmul__(self, value: _t.SupportsIndex, /) -> _t.Self:
301 return self._wrap(super().__rmul__(value))
303 def capitalize(self) -> _t.Self:
304 return self._wrap(super().capitalize())
306 def casefold(self) -> _t.Self:
307 return self._wrap(super().casefold())
309 def center(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self:
310 return self._wrap(super().center(width))
312 def expandtabs(self, tabsize: _t.SupportsIndex = 8) -> _t.Self:
313 return self._wrap(super().expandtabs(tabsize))
315 def format_map(self, mapping: _FormatMapMapping, /) -> _t.Self:
316 return self._wrap(super().format_map(mapping))
318 def format(self, *args: object, **kwargs: object) -> _t.Self:
319 return self._wrap(super().format(*args, **kwargs))
321 def join(self, iterable: _t.Iterable[str], /) -> _t.Self:
322 return self._wrap(super().join(iterable))
324 def ljust(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self:
325 return self._wrap(super().ljust(width, fillchar))
327 def lower(self) -> _t.Self:
328 return self._wrap(super().lower())
330 def lstrip(self, chars: str | None = None, /) -> _t.Self:
331 return self._wrap(super().lstrip(chars))
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)
337 def removeprefix(self, prefix: str, /) -> _t.Self:
338 return self._wrap(super().removeprefix(prefix))
340 def removesuffix(self, suffix: str, /) -> _t.Self:
341 return self._wrap(super().removesuffix(suffix))
343 def replace(self, old: str, new: str, count: _t.SupportsIndex = -1, /) -> _t.Self:
344 return self._wrap(super().replace(old, new, count))
346 def rjust(self, width: _t.SupportsIndex, fillchar: str = " ", /) -> _t.Self:
347 return self._wrap(super().rjust(width, fillchar))
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)
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)]
358 def rstrip(self, chars: str | None = None, /) -> _t.Self:
359 return self._wrap(super().rstrip(chars))
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)]
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)]
371 def strip(self, chars: str | None = None, /) -> _t.Self:
372 return self._wrap(super().strip(chars))
374 def swapcase(self) -> _t.Self:
375 return self._wrap(super().swapcase())
377 def title(self) -> _t.Self:
378 return self._wrap(super().title())
380 def translate(self, table: _TranslateTable, /) -> _t.Self:
381 return self._wrap(super().translate(table))
383 def upper(self) -> _t.Self:
384 return self._wrap(super().upper())
386 def zfill(self, width: _t.SupportsIndex, /) -> _t.Self:
387 return self._wrap(super().zfill(width))
390class ClosedIO(_io.TextIOBase, _t.TextIO): # type: ignore
391 """
392 Dummy stream that's always closed.
394 """
396 def __init__(self) -> None:
397 super().__init__()
398 self.close()