omlish 0.0.0.dev117__py3-none-any.whl → 0.0.0.dev119__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
omlish/__about__.py CHANGED
@@ -1,5 +1,5 @@
1
- __version__ = '0.0.0.dev117'
2
- __revision__ = '53d3fb51abb6f481bd836efe8a32c8da91113468'
1
+ __version__ = '0.0.0.dev119'
2
+ __revision__ = 'abf03cc06fe1610ddf750a1392aa9c9de00acfd3'
3
3
 
4
4
 
5
5
  #
@@ -27,21 +27,11 @@ class HashEq(lang.Abstract, ta.Generic[K]):
27
27
  raise NotImplementedError
28
28
 
29
29
 
30
- @lang.unabstract_class([
31
- ('hash', '_hash'),
32
- ('eq', '_eq'),
33
- ])
34
30
  @dc.dataclass(frozen=True)
35
31
  class HashEq_(ta.Generic[K]): # noqa
36
32
  hash: ta.Callable[[K], int]
37
33
  eq: ta.Callable[[K, K], bool]
38
34
 
39
- def _hash(self, k: K) -> int:
40
- return self.hash(k)
41
-
42
- def _eq(self, l: K, r: K) -> bool:
43
- return self.eq(l, r)
44
-
45
35
 
46
36
  class hash_eq(HashEq[K], lang.NotInstantiable, lang.Final): # noqa
47
37
  """Workaround for PEP 695 support."""
omlish/fnpairs.py CHANGED
@@ -94,21 +94,12 @@ class FnPair(ta.Generic[F, T], abc.ABC):
94
94
  ##
95
95
 
96
96
 
97
- @lang.unabstract_class([
98
- ('forward', '_forward'),
99
- ('backward', 'backward'),
100
- ])
97
+ @lang.unabstract_class(['forward', 'backward'])
101
98
  @dc.dataclass(frozen=True)
102
99
  class Simple(FnPair[F, T]):
103
100
  forward: ta.Callable[[F], T] # type: ignore
104
101
  backward: ta.Callable[[T], F] # type: ignore
105
102
 
106
- def _forward(self, f: F) -> T:
107
- return self.forward(f)
108
-
109
- def _backward(self, t: T) -> F:
110
- return self.backward(t)
111
-
112
103
 
113
104
  of = Simple
114
105
 
@@ -37,3 +37,8 @@ else:
37
37
  _lang.proxy_init(globals(), '.render', [
38
38
  'JsonRenderer',
39
39
  ])
40
+
41
+ from .types import ( # noqa
42
+ SCALAR_TYPES,
43
+ Scalar,
44
+ )
@@ -0,0 +1,179 @@
1
+ # MIT License
2
+ # ===========
3
+ #
4
+ # Copyright (c) 2006 Bob Ippolito
5
+ #
6
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
7
+ # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
8
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
9
+ # persons to whom the Software is furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
12
+ # Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
15
+ # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
17
+ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
+ # https://github.com/simplejson/simplejson/blob/6932004966ab70ef47250a2b3152acd8c904e6b5/simplejson/scanner.py
19
+ import json
20
+ import re
21
+ import sys
22
+ import typing as ta
23
+
24
+
25
+ ##
26
+
27
+
28
+ _FOUR_DIGIT_HEX_PAT = re.compile(r'^[0-9a-fA-F]{4}$')
29
+
30
+
31
+ def _scan_four_digit_hex(
32
+ s: str,
33
+ end: int,
34
+ ):
35
+ """Scan a four digit hex number from s[end:end + 4]"""
36
+
37
+ msg = 'Invalid \\uXXXX escape sequence'
38
+ esc = s[end: end + 4]
39
+ if not _FOUR_DIGIT_HEX_PAT.match(esc):
40
+ raise json.JSONDecodeError(msg, s, end - 2)
41
+
42
+ try:
43
+ return int(esc, 16), end + 4
44
+ except ValueError:
45
+ raise json.JSONDecodeError(msg, s, end - 2) from None
46
+
47
+
48
+ _STRING_CHUNK_PAT = re.compile(r'(.*?)(["\\\x00-\x1f])', re.VERBOSE | re.MULTILINE | re.DOTALL)
49
+
50
+ _BACKSLASH_MAP = {
51
+ '"': '"',
52
+ '\\': '\\',
53
+ '/': '/',
54
+ 'b': '\b',
55
+ 'f': '\f',
56
+ 'n': '\n',
57
+ 'r': '\r',
58
+ 't': '\t',
59
+ }
60
+
61
+
62
+ def parse_string(
63
+ s: str,
64
+ idx: int = 0,
65
+ *,
66
+ strict: bool = True,
67
+ ) -> tuple[str, int]:
68
+ """
69
+ Scan the string s for a JSON string. Idx is the index of the quote that starts the JSON string. Unescapes all valid
70
+ JSON string escape sequences and raises ValueError on attempt to decode an invalid string. If strict is False then
71
+ literal control characters are allowed in the string.
72
+
73
+ Returns a tuple of the decoded string and the index of the character in s after the end quote.
74
+ """
75
+
76
+ if s[idx] != '"':
77
+ raise json.JSONDecodeError('No opening string quotes at', s, idx)
78
+
79
+ chunks: list[str] = []
80
+ end = idx + 1
81
+ while True:
82
+ chunk = _STRING_CHUNK_PAT.match(s, end)
83
+ if chunk is None:
84
+ raise json.JSONDecodeError('Unterminated string starting at', s, idx)
85
+
86
+ prev_end = end
87
+ end = chunk.end()
88
+ content, terminator = chunk.groups()
89
+ # Content is contains zero or more unescaped string characters
90
+ if content:
91
+ chunks.append(content)
92
+
93
+ # Terminator is the end of string, a literal control character, or a backslash denoting that an escape sequence
94
+ # follows
95
+ if terminator == '"':
96
+ break
97
+ elif terminator != '\\':
98
+ if strict:
99
+ msg = 'Invalid control character %r at'
100
+ raise json.JSONDecodeError(msg, s, prev_end)
101
+ else:
102
+ chunks.append(terminator)
103
+ continue
104
+
105
+ try:
106
+ esc = s[end]
107
+ except IndexError:
108
+ raise json.JSONDecodeError('Unterminated string starting at', s, idx) from None
109
+
110
+ # If not a unicode escape sequence, must be in the lookup table
111
+ if esc != 'u':
112
+ try:
113
+ char = _BACKSLASH_MAP[esc]
114
+ except KeyError:
115
+ msg = 'Invalid \\X escape sequence %r'
116
+ raise json.JSONDecodeError(msg, s, end) from None
117
+ end += 1
118
+
119
+ else:
120
+ # Unicode escape sequence
121
+ uni, end = _scan_four_digit_hex(s, end + 1)
122
+
123
+ # Check for surrogate pair on UCS-4 systems Note that this will join high/low surrogate pairs but will also
124
+ # pass unpaired surrogates through
125
+ if (
126
+ sys.maxunicode > 65535 and
127
+ uni & 0xFC00 == 0xD800 and
128
+ s[end: end + 2] == '\\u'
129
+ ):
130
+ uni2, end2 = _scan_four_digit_hex(s, end + 2)
131
+ if uni2 & 0xFC00 == 0xDC00:
132
+ uni = 0x10000 + (((uni - 0xD800) << 10) | (uni2 - 0xDC00))
133
+ end = end2
134
+
135
+ char = chr(uni)
136
+
137
+ # Append the unescaped character
138
+ chunks.append(char)
139
+
140
+ return ''.join(chunks), end
141
+
142
+
143
+ def try_parse_string(
144
+ s: str,
145
+ idx: int = 0,
146
+ *,
147
+ strict: bool = True,
148
+ ) -> tuple[str, int] | None:
149
+ if not s or s[idx] != '"':
150
+ return None
151
+
152
+ return parse_string(s, idx, strict=strict)
153
+
154
+
155
+ ##
156
+
157
+
158
+ _NUMBER_PAT = re.compile(
159
+ r'(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?',
160
+ (re.VERBOSE | re.MULTILINE | re.DOTALL),
161
+ )
162
+
163
+
164
+ def try_parse_number(
165
+ s: str,
166
+ idx: int = 0,
167
+ *,
168
+ parse_float: ta.Callable[[str], float] = float,
169
+ parse_int: ta.Callable[[str], int] = int,
170
+ ) -> tuple[int | float, int] | None:
171
+ if (m := _NUMBER_PAT.match(s, idx)) is None:
172
+ return None
173
+
174
+ integer, frac, exp = m.groups()
175
+ if frac or exp:
176
+ res = parse_float(integer + (frac or '') + (exp or ''))
177
+ else:
178
+ res = parse_int(integer)
179
+ return res, m.end()
@@ -6,12 +6,11 @@ import typing as ta
6
6
 
7
7
  from ... import lang
8
8
  from . import consts
9
+ from .types import SCALAR_TYPES
10
+ from .types import Scalar
9
11
 
10
12
 
11
13
  I = ta.TypeVar('I')
12
- Scalar: ta.TypeAlias = bool | int | float | str | None
13
-
14
- SCALAR_TYPES: tuple[type, ...] = (bool, int, float, str, type(None))
15
14
 
16
15
  MULTILINE_SEPARATORS = consts.Separators(',', ': ')
17
16
 
@@ -1,8 +1,8 @@
1
1
  import io
2
2
  import typing as ta
3
3
 
4
- from ..render import SCALAR_TYPES
5
4
  from ..render import AbstractJsonRenderer
5
+ from ..types import SCALAR_TYPES
6
6
  from .parse import BeginArray
7
7
  from .parse import BeginObject
8
8
  from .parse import EndArray
@@ -0,0 +1,6 @@
1
+ import typing as ta
2
+
3
+
4
+ Scalar: ta.TypeAlias = bool | int | float | str | None
5
+
6
+ SCALAR_TYPES: tuple[type, ...] = (bool, int, float, str, type(None))
@@ -7,6 +7,10 @@ T = ta.TypeVar('T')
7
7
 
8
8
  _DISABLE_CHECKS = False
9
9
 
10
+ _ABSTRACT_METHODS_ATTR = '__abstractmethods__'
11
+ _IS_ABSTRACT_METHOD_ATTR = '__isabstractmethod__'
12
+ _FORCE_ABSTRACT_ATTR = '__forceabstract__'
13
+
10
14
 
11
15
  def make_abstract(obj: T) -> T:
12
16
  if callable(obj):
@@ -29,13 +33,13 @@ class Abstract(abc.ABC): # noqa
29
33
  def __forceabstract__(self):
30
34
  raise TypeError
31
35
 
32
- setattr(__forceabstract__, '__isabstractmethod__', True)
36
+ setattr(__forceabstract__, _IS_ABSTRACT_METHOD_ATTR, True)
33
37
 
34
38
  def __init_subclass__(cls, **kwargs: ta.Any) -> None:
35
39
  if Abstract in cls.__bases__:
36
- cls.__forceabstract__ = Abstract.__forceabstract__ # type: ignore
40
+ setattr(cls, _FORCE_ABSTRACT_ATTR, getattr(Abstract, _FORCE_ABSTRACT_ATTR))
37
41
  else:
38
- cls.__forceabstract__ = False # type: ignore
42
+ setattr(cls, _FORCE_ABSTRACT_ATTR, False)
39
43
 
40
44
  super().__init_subclass__(**kwargs)
41
45
 
@@ -43,7 +47,7 @@ class Abstract(abc.ABC): # noqa
43
47
  ams = {a for a, o in cls.__dict__.items() if is_abstract_method(o)}
44
48
  seen = set(cls.__dict__)
45
49
  for b in cls.__bases__:
46
- ams.update(set(getattr(b, '__abstractmethods__', [])) - seen)
50
+ ams.update(set(getattr(b, _ABSTRACT_METHODS_ATTR, [])) - seen)
47
51
  seen.update(dir(b))
48
52
  if ams:
49
53
  raise TypeError(
@@ -53,18 +57,18 @@ class Abstract(abc.ABC): # noqa
53
57
 
54
58
 
55
59
  def is_abstract_method(obj: ta.Any) -> bool:
56
- return bool(getattr(obj, '__isabstractmethod__', False))
60
+ return bool(getattr(obj, _IS_ABSTRACT_METHOD_ATTR, False))
57
61
 
58
62
 
59
63
  def is_abstract_class(obj: ta.Any) -> bool:
60
- if bool(getattr(obj, '__abstractmethods__', [])):
64
+ if bool(getattr(obj, _ABSTRACT_METHODS_ATTR, [])):
61
65
  return True
62
66
  if isinstance(obj, type):
63
67
  if Abstract in obj.__bases__:
64
68
  return True
65
69
  if (
66
70
  Abstract in obj.__mro__
67
- and getattr(obj.__dict__.get('__forceabstract__', None), '__isabstractmethod__', False)
71
+ and getattr(obj.__dict__.get(_FORCE_ABSTRACT_ATTR, None), _IS_ABSTRACT_METHOD_ATTR, False)
68
72
  ):
69
73
  return True
70
74
  return False
@@ -75,13 +79,32 @@ def is_abstract(obj: ta.Any) -> bool:
75
79
 
76
80
 
77
81
  def unabstract_class(
78
- impls: ta.Iterable[tuple[str, ta.Any]],
79
- ) -> ta.Callable[[type[T]], type[T]]:
82
+ members: ta.Iterable[str | tuple[str, ta.Any]],
83
+ ): # -> ta.Callable[[type[T]], type[T]]:
80
84
  def inner(cls):
81
- for name, impl in impls:
82
- if isinstance(impl, str):
83
- impl = getattr(cls, impl)
84
- setattr(cls, name, impl)
85
- setattr(cls, '__abstractmethods__', frozenset())
85
+ if isinstance(members, str):
86
+ raise TypeError(members)
87
+
88
+ ams = getattr(cls, _ABSTRACT_METHODS_ATTR)
89
+
90
+ names: set[str] = set()
91
+ for m in members:
92
+ if isinstance(m, str):
93
+ if m not in ams:
94
+ raise NameError(m)
95
+ getattr(cls, m)
96
+ names.add(m)
97
+
98
+ elif isinstance(m, tuple):
99
+ name, impl = m
100
+ if name not in ams:
101
+ raise NameError(name)
102
+ if isinstance(impl, str):
103
+ impl = getattr(cls, impl)
104
+ setattr(cls, name, impl)
105
+ names.add(name)
106
+
107
+ setattr(cls, _ABSTRACT_METHODS_ATTR, ams - names)
86
108
  return cls
109
+
87
110
  return inner
omlish/lang/imports.py CHANGED
@@ -312,7 +312,7 @@ class _ProxyInit:
312
312
  try:
313
313
  mod = self._mods_by_pkgs[pkg]
314
314
  except KeyError:
315
- mod = importlib.import_module(pkg, package=self.name_package.package)
315
+ mod = importlib.import_module(pkg, package=self._name_package.package)
316
316
 
317
317
  val = getattr(mod, attr)
318
318
 
omlish/lang/maybes.py CHANGED
@@ -50,15 +50,15 @@ class Maybe(abc.ABC, ta.Generic[T]):
50
50
  raise NotImplementedError
51
51
 
52
52
  @abc.abstractmethod
53
- def or_else(self, other: T) -> 'Maybe[T]':
53
+ def or_else(self, other: T) -> T:
54
54
  raise NotImplementedError
55
55
 
56
56
  @abc.abstractmethod
57
- def or_else_get(self, supplier: ta.Callable[[], T]) -> 'Maybe[T]':
57
+ def or_else_get(self, supplier: ta.Callable[[], T]) -> T:
58
58
  raise NotImplementedError
59
59
 
60
60
  @abc.abstractmethod
61
- def or_else_raise(self, exception_supplier: ta.Callable[[], Exception]) -> 'Maybe[T]':
61
+ def or_else_raise(self, exception_supplier: ta.Callable[[], Exception]) -> T:
62
62
  raise NotImplementedError
63
63
 
64
64
 
@@ -98,9 +98,7 @@ class _Maybe(Maybe[T], tuple):
98
98
 
99
99
  def map(self, mapper: ta.Callable[[T], U]) -> Maybe[U]:
100
100
  if self:
101
- value = mapper(self[0])
102
- if value is not None:
103
- return just(value)
101
+ return just(mapper(self[0]))
104
102
  return _empty # noqa
105
103
 
106
104
  def flat_map(self, mapper: ta.Callable[[T], Maybe[U]]) -> Maybe[U]:
@@ -111,15 +109,15 @@ class _Maybe(Maybe[T], tuple):
111
109
  return value
112
110
  return _empty # noqa
113
111
 
114
- def or_else(self, other: T) -> Maybe[T]:
115
- return self if self else just(other)
112
+ def or_else(self, other: T) -> T:
113
+ return self.must() if self else other
116
114
 
117
- def or_else_get(self, supplier: ta.Callable[[], T]) -> Maybe[T]:
118
- return self if self else just(supplier())
115
+ def or_else_get(self, supplier: ta.Callable[[], T]) -> T:
116
+ return self.must() if self else supplier()
119
117
 
120
- def or_else_raise(self, exception_supplier: ta.Callable[[], Exception]) -> Maybe[T]:
118
+ def or_else_raise(self, exception_supplier: ta.Callable[[], Exception]) -> T:
121
119
  if self:
122
- return self
120
+ return self.must()
123
121
  raise exception_supplier()
124
122
 
125
123
 
@@ -0,0 +1,163 @@
1
+ # ruff: noqa: UP007 UP012
2
+ import ctypes as ct
3
+ import logging
4
+ import sys
5
+ import syslog
6
+ import threading
7
+ import typing as ta
8
+
9
+ from .cached import cached_nullary
10
+
11
+
12
+ ##
13
+
14
+
15
+ class sd_iovec(ct.Structure): # noqa
16
+ pass
17
+
18
+
19
+ sd_iovec._fields_ = [
20
+ ('iov_base', ct.c_void_p), # Pointer to data.
21
+ ('iov_len', ct.c_size_t), # Length of data.
22
+ ]
23
+
24
+
25
+ ##
26
+
27
+
28
+ @cached_nullary
29
+ def sd_libsystemd() -> ta.Any:
30
+ lib = ct.CDLL('libsystemd.so.0')
31
+
32
+ lib.sd_journal_sendv = lib['sd_journal_sendv'] # type: ignore
33
+ lib.sd_journal_sendv.restype = ct.c_int
34
+ lib.sd_journal_sendv.argtypes = [ct.POINTER(sd_iovec), ct.c_int]
35
+
36
+ return lib
37
+
38
+
39
+ @cached_nullary
40
+ def sd_try_libsystemd() -> ta.Optional[ta.Any]:
41
+ try:
42
+ return sd_libsystemd()
43
+ except OSError: # noqa
44
+ return None
45
+
46
+
47
+ ##
48
+
49
+
50
+ def sd_journald_send(**fields: str) -> int:
51
+ lib = sd_libsystemd()
52
+
53
+ msgs = [
54
+ f'{k.upper()}={v}\0'.encode('utf-8')
55
+ for k, v in fields.items()
56
+ ]
57
+
58
+ vec = (sd_iovec * len(msgs))()
59
+ cl = (ct.c_char_p * len(msgs))() # noqa
60
+ for i in range(len(msgs)):
61
+ vec[i].iov_base = ct.cast(ct.c_char_p(msgs[i]), ct.c_void_p)
62
+ vec[i].iov_len = len(msgs[i]) - 1
63
+
64
+ return lib.sd_journal_sendv(vec, len(msgs))
65
+
66
+
67
+ ##
68
+
69
+
70
+ SD_LOG_LEVEL_MAP: ta.Mapping[int, int] = {
71
+ logging.FATAL: syslog.LOG_EMERG, # system is unusable
72
+ # LOG_ALERT ? # action must be taken immediately
73
+ logging.CRITICAL: syslog.LOG_CRIT,
74
+ logging.ERROR: syslog.LOG_ERR,
75
+ logging.WARNING: syslog.LOG_WARNING,
76
+ # LOG_NOTICE ? # normal but significant condition
77
+ logging.INFO: syslog.LOG_INFO,
78
+ logging.DEBUG: syslog.LOG_DEBUG,
79
+ }
80
+
81
+
82
+ class JournaldLogHandler(logging.Handler):
83
+ """
84
+ TODO:
85
+ - fallback handler for when this barfs
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ use_formatter_output: bool = False,
92
+ ) -> None:
93
+ super().__init__()
94
+
95
+ sd_libsystemd()
96
+
97
+ self._use_formatter_output = use_formatter_output
98
+
99
+ #
100
+
101
+ EXTRA_RECORD_ATTRS_BY_JOURNALD_FIELD: ta.ClassVar[ta.Mapping[str, str]] = {
102
+ 'name': 'name',
103
+ 'module': 'module',
104
+ 'exception': 'exc_text',
105
+ 'thread_name': 'threadName',
106
+ 'task_name': 'taskName',
107
+ }
108
+
109
+ def make_fields(self, record: logging.LogRecord) -> ta.Mapping[str, str]:
110
+ formatter_message = self.format(record)
111
+ if self._use_formatter_output:
112
+ message = formatter_message
113
+ else:
114
+ message = record.message
115
+
116
+ fields: dict[str, str] = {
117
+ 'message': message,
118
+ 'priority': str(SD_LOG_LEVEL_MAP[record.levelno]),
119
+ 'tid': str(threading.get_ident()),
120
+ }
121
+
122
+ if (pathname := record.pathname) is not None:
123
+ fields['code_file'] = pathname
124
+ if (lineno := record.lineno) is not None:
125
+ fields['code_lineno'] = str(lineno)
126
+ if (func_name := record.funcName) is not None:
127
+ fields['code_func'] = func_name
128
+
129
+ for f, a in self.EXTRA_RECORD_ATTRS_BY_JOURNALD_FIELD.items():
130
+ if (v := getattr(record, a, None)) is not None:
131
+ fields[f] = str(v)
132
+
133
+ return fields
134
+
135
+ #
136
+
137
+ def emit(self, record: logging.LogRecord) -> None:
138
+ try:
139
+ fields = self.make_fields(record)
140
+
141
+ if rc := sd_journald_send(**fields):
142
+ raise RuntimeError(f'{self.__class__.__name__}.emit failed: {rc=}') # noqa
143
+
144
+ except RecursionError: # See issue 36272
145
+ raise
146
+
147
+ except Exception: # noqa
148
+ self.handleError(record)
149
+
150
+
151
+ def journald_log_handler_factory(
152
+ *,
153
+ no_tty_check: bool = False,
154
+ no_fallback: bool = False,
155
+ ) -> logging.Handler:
156
+ if (
157
+ sys.platform == 'linux' and
158
+ (no_tty_check or not sys.stderr.isatty()) and
159
+ (no_fallback or sd_try_libsystemd() is not None)
160
+ ):
161
+ return JournaldLogHandler()
162
+
163
+ return logging.StreamHandler()
omlish/lite/logs.py CHANGED
@@ -91,7 +91,7 @@ class StandardLogFormatter(logging.Formatter):
91
91
  if datefmt:
92
92
  return ct.strftime(datefmt) # noqa
93
93
  else:
94
- t = ct.strftime("%Y-%m-%d %H:%M:%S") # noqa
94
+ t = ct.strftime('%Y-%m-%d %H:%M:%S')
95
95
  return '%s.%03d' % (t, record.msecs)
96
96
 
97
97
 
@@ -228,6 +228,7 @@ def configure_standard_logging(
228
228
  json: bool = False,
229
229
  target: ta.Optional[logging.Logger] = None,
230
230
  force: bool = False,
231
+ handler_factory: ta.Optional[ta.Callable[[], logging.Handler]] = None,
231
232
  ) -> ta.Optional[StandardLogHandler]:
232
233
  with _locking_logging_module_lock():
233
234
  if target is None:
@@ -241,7 +242,10 @@ def configure_standard_logging(
241
242
 
242
243
  #
243
244
 
244
- handler = logging.StreamHandler()
245
+ if handler_factory is not None:
246
+ handler = handler_factory()
247
+ else:
248
+ handler = logging.StreamHandler()
245
249
 
246
250
  #
247
251