omlish 0.0.0.dev22__py3-none-any.whl → 0.0.0.dev24__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. omlish/__about__.py +10 -3
  2. omlish/asyncs/bridge.py +3 -0
  3. omlish/bootstrap/__init__.py +39 -0
  4. omlish/bootstrap/base.py +3 -1
  5. omlish/bootstrap/diag.py +48 -1
  6. omlish/bootstrap/main.py +2 -1
  7. omlish/bootstrap/marshal.py +18 -0
  8. omlish/check.py +305 -37
  9. omlish/collections/__init__.py +2 -2
  10. omlish/collections/utils.py +3 -3
  11. omlish/concurrent/threadlets.py +5 -0
  12. omlish/dataclasses/__init__.py +1 -0
  13. omlish/dataclasses/impl/init.py +10 -2
  14. omlish/dataclasses/impl/metadata.py +3 -3
  15. omlish/dataclasses/impl/reflect.py +1 -1
  16. omlish/dataclasses/utils.py +16 -3
  17. omlish/diag/asts.py +132 -0
  18. omlish/diag/pycharm.py +139 -0
  19. omlish/docker.py +19 -11
  20. omlish/genmachine.py +59 -0
  21. omlish/graphs/trees.py +1 -1
  22. omlish/lang/__init__.py +13 -1
  23. omlish/lang/cached.py +5 -2
  24. omlish/lang/descriptors.py +33 -16
  25. omlish/lang/resources.py +60 -0
  26. omlish/lite/logs.py +133 -4
  27. omlish/logs/__init__.py +17 -2
  28. omlish/logs/configs.py +13 -1
  29. omlish/logs/formatters.py +0 -1
  30. omlish/marshal/__init__.py +6 -0
  31. omlish/marshal/base64.py +4 -0
  32. omlish/marshal/helpers.py +27 -0
  33. omlish/marshal/primitives.py +6 -0
  34. omlish/marshal/standard.py +4 -0
  35. omlish/marshal/unions.py +101 -0
  36. omlish/matchfns.py +3 -3
  37. omlish/specs/jsonschema/keywords/base.py +2 -2
  38. omlish/specs/jsonschema/keywords/parse.py +1 -1
  39. omlish/sql/__init__.py +18 -0
  40. omlish/sql/qualifiedname.py +82 -0
  41. omlish/stats.py +1 -0
  42. {omlish-0.0.0.dev22.dist-info → omlish-0.0.0.dev24.dist-info}/METADATA +1 -1
  43. {omlish-0.0.0.dev22.dist-info → omlish-0.0.0.dev24.dist-info}/RECORD +46 -39
  44. {omlish-0.0.0.dev22.dist-info → omlish-0.0.0.dev24.dist-info}/LICENSE +0 -0
  45. {omlish-0.0.0.dev22.dist-info → omlish-0.0.0.dev24.dist-info}/WHEEL +0 -0
  46. {omlish-0.0.0.dev22.dist-info → omlish-0.0.0.dev24.dist-info}/top_level.txt +0 -0
@@ -95,12 +95,12 @@ from .utils import ( # noqa
95
95
  all_not_equal,
96
96
  indexes,
97
97
  key_cmp,
98
+ make_map,
99
+ make_map_by,
98
100
  multi_map,
99
101
  multi_map_by,
100
102
  mut_toposort,
101
103
  partition,
102
104
  toposort,
103
105
  unique,
104
- unique_map,
105
- unique_map_by,
106
106
  )
@@ -72,7 +72,7 @@ def unique(
72
72
  return ret
73
73
 
74
74
 
75
- def unique_map(
75
+ def make_map(
76
76
  kvs: ta.Iterable[tuple[K, V]],
77
77
  *,
78
78
  identity: bool = False,
@@ -88,14 +88,14 @@ def unique_map(
88
88
  return d
89
89
 
90
90
 
91
- def unique_map_by(
91
+ def make_map_by(
92
92
  fn: ta.Callable[[V], K],
93
93
  vs: ta.Iterable[V],
94
94
  *,
95
95
  identity: bool = False,
96
96
  strict: bool = False,
97
97
  ) -> ta.MutableMapping[K, V]:
98
- return unique_map(
98
+ return make_map(
99
99
  ((fn(v), v) for v in vs),
100
100
  identity=identity,
101
101
  strict=strict,
@@ -1,3 +1,8 @@
1
+ """
2
+ An abstraction over greenlet's api. Greenlet doesn't currently support nogil but its functionality is needed for async
3
+ bridge code (both here and in sqlalchemy). This can be implemented with real threads at the expense of overhead, but
4
+ this code is only intended to be used in already fairly heavy situations (bootstrap, db calls).
5
+ """
1
6
  import abc
2
7
  import dataclasses as dc
3
8
  import typing as ta
@@ -94,6 +94,7 @@ from .utils import ( # noqa
94
94
  field_modifier,
95
95
  maybe_post_init,
96
96
  opt_repr,
97
+ update_class_metadata,
97
98
  update_field_extras,
98
99
  update_field_metadata,
99
100
  update_fields,
@@ -130,9 +130,17 @@ class InitBuilder:
130
130
  cas = ', '.join(p.name for p in csig.parameters.values())
131
131
  body_lines.append(f'if not {cn}({cas}): raise __dataclass_CheckError__')
132
132
 
133
- for i, fn in enumerate(self._info.merged_metadata.get(Init, [])):
133
+ inits = self._info.merged_metadata.get(Init, [])
134
+ mro_dct = lang.build_mro_dict(self._info.cls)
135
+ mro_v_ids = set(map(id, mro_dct.values()))
136
+ props_by_fget_id = {id(v.fget): v for v in mro_dct.values() if isinstance(v, property) and v.fget is not None}
137
+ for i, obj in enumerate(inits):
138
+ if (obj_id := id(obj)) not in mro_v_ids and obj_id in props_by_fget_id:
139
+ obj = props_by_fget_id[obj_id].__get__
140
+ elif isinstance(obj, property):
141
+ obj = obj.__get__
134
142
  cn = f'__dataclass_init_{i}__'
135
- locals[cn] = fn
143
+ locals[cn] = obj
136
144
  body_lines.append(f'{cn}({self._self_name})')
137
145
 
138
146
  if not body_lines:
@@ -70,6 +70,6 @@ class Init(lang.Marker):
70
70
  pass
71
71
 
72
72
 
73
- def init(fn: ta.Callable):
74
- _append_cls_md(Init, fn)
75
- return fn
73
+ def init(obj):
74
+ _append_cls_md(Init, obj)
75
+ return obj
@@ -149,7 +149,7 @@ class ClassInfo:
149
149
 
150
150
  @cached.property
151
151
  def generic_mro_lookup(self) -> ta.Mapping[type, rfl.Type]:
152
- return col.unique_map(((check.not_none(rfl.get_concrete_type(g)), g) for g in self.generic_mro), strict=True)
152
+ return col.make_map(((check.not_none(rfl.get_concrete_type(g)), g) for g in self.generic_mro), strict=True)
153
153
 
154
154
  @cached.property
155
155
  def generic_replaced_field_types(self) -> ta.Mapping[str, rfl.Type]:
@@ -4,6 +4,8 @@ import types
4
4
  import typing as ta
5
5
 
6
6
  from .. import check
7
+ from .impl.metadata import METADATA_ATTR
8
+ from .impl.metadata import UserMetadata
7
9
  from .impl.params import DEFAULT_FIELD_EXTRAS
8
10
  from .impl.params import FieldExtras
9
11
  from .impl.params import get_field_extras
@@ -50,6 +52,13 @@ def chain_metadata(*mds: ta.Mapping) -> types.MappingProxyType:
50
52
  return types.MappingProxyType(collections.ChainMap(*mds)) # type: ignore # noqa
51
53
 
52
54
 
55
+ def update_class_metadata(cls: type[T], *args: ta.Any) -> type[T]:
56
+ check.isinstance(cls, type)
57
+ setattr(cls, METADATA_ATTR, md := getattr(cls, METADATA_ATTR, {}))
58
+ md.setdefault(UserMetadata, []).extend(args)
59
+ return cls
60
+
61
+
53
62
  def update_field_metadata(f: dc.Field, nmd: ta.Mapping) -> dc.Field:
54
63
  check.isinstance(f, dc.Field)
55
64
  f.metadata = chain_metadata(nmd, f.metadata)
@@ -79,9 +88,13 @@ def update_fields(
79
88
 
80
89
  else:
81
90
  for a in fields:
82
- v = cls.__dict__[a]
83
- if not isinstance(v, dc.Field):
84
- v = dc.field(default=v)
91
+ try:
92
+ v = cls.__dict__[a]
93
+ except KeyError:
94
+ v = dc.field()
95
+ else:
96
+ if not isinstance(v, dc.Field):
97
+ v = dc.field(default=v)
85
98
  setattr(cls, a, fn(a, v))
86
99
 
87
100
  return cls
omlish/diag/asts.py ADDED
@@ -0,0 +1,132 @@
1
+ import ast
2
+ import dataclasses as dc
3
+ import inspect
4
+ import pprint
5
+ import textwrap
6
+ import types
7
+ import typing as ta
8
+
9
+ from .. import lang
10
+
11
+
12
+ if ta.TYPE_CHECKING:
13
+ import executing
14
+ else:
15
+ executing = lang.proxy_import('executing')
16
+
17
+
18
+ class ArgsRenderer:
19
+ """
20
+ TODO:
21
+ - kwargs
22
+ - recursion
23
+ - whatever pytest looks like
24
+ - make sure not leaking sensitive data
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ origin: types.TracebackType | types.FrameType | None = None,
30
+ back: int = 1,
31
+ ) -> None:
32
+ super().__init__()
33
+
34
+ self._origin = origin
35
+
36
+ self._frame: types.FrameType | None
37
+ if isinstance(origin, types.TracebackType):
38
+ self._frame = origin.tb_frame
39
+ elif isinstance(origin, types.FrameType):
40
+ self._frame = origin
41
+ elif origin is None:
42
+ frame = inspect.currentframe()
43
+ for _ in range(back + 1):
44
+ if frame is None:
45
+ break
46
+ frame = frame.f_back
47
+ self._frame = frame
48
+ else:
49
+ raise TypeError(origin)
50
+
51
+ def _get_indented_text(
52
+ self,
53
+ src: executing.Source,
54
+ node: ast.AST,
55
+ ) -> str:
56
+ result = src.asttokens().get_text(node)
57
+ if '\n' in result:
58
+ result = ' ' * node.first_token.start[1] + result # type: ignore
59
+ result = textwrap.dedent(result)
60
+ result = result.strip()
61
+ return result
62
+
63
+ def _val_to_string(self, obj: ta.Any) -> str:
64
+ s = pprint.pformat(obj)
65
+ s = s.replace('\\n', '\n')
66
+ return s
67
+
68
+ def _is_literal_expr(self, s: str) -> bool:
69
+ try:
70
+ ast.literal_eval(s)
71
+ except Exception: # noqa
72
+ return False
73
+ return True
74
+
75
+ @dc.dataclass(frozen=True)
76
+ class RenderedArg:
77
+ val: str
78
+ expr: str
79
+ is_literal_expr: bool
80
+
81
+ def __str__(self) -> str:
82
+ if self.is_literal_expr:
83
+ return self.val
84
+ else:
85
+ return f'({self.expr} = {self.val})'
86
+
87
+ def render_args(
88
+ self,
89
+ *vals: ta.Any,
90
+ ) -> ta.Sequence[RenderedArg] | None:
91
+ if self._frame is None:
92
+ return None
93
+
94
+ call_node = executing.Source.executing(self._frame).node
95
+ if not isinstance(call_node, ast.Call):
96
+ return None
97
+
98
+ source = executing.Source.for_frame(self._frame)
99
+
100
+ exprs = [
101
+ self._get_indented_text(source, arg) # noqa
102
+ for arg in call_node.args
103
+ ]
104
+ if len(exprs) != len(vals):
105
+ return None
106
+
107
+ return [
108
+ self.RenderedArg(
109
+ val=self._val_to_string(val),
110
+ expr=expr,
111
+ is_literal_expr=self._is_literal_expr(expr),
112
+ )
113
+ for val, expr in zip(vals, exprs)
114
+ ]
115
+
116
+ @classmethod
117
+ def smoketest(cls) -> bool:
118
+ def bar(z):
119
+ return z + 1
120
+
121
+ def foo(x, y):
122
+ return cls().render_args(x, y)
123
+
124
+ r = foo(1, bar(2))
125
+ if r is None:
126
+ return False
127
+
128
+ x, y = r
129
+ return (
130
+ x == cls.RenderedArg(val='1', expr='1', is_literal_expr=True) and
131
+ y == cls.RenderedArg(val='3', expr='bar(2)', is_literal_expr=False)
132
+ )
omlish/diag/pycharm.py ADDED
@@ -0,0 +1,139 @@
1
+ import os.path
2
+ import plistlib
3
+ import subprocess
4
+ import sys
5
+ import typing as ta
6
+
7
+ from .. import check
8
+ from .. import lang
9
+
10
+
11
+ if ta.TYPE_CHECKING:
12
+ docker = lang.proxy_import('omlish.docker')
13
+ else:
14
+ from omlish import docker
15
+
16
+
17
+ ##
18
+
19
+
20
+ PYCHARM_HOSTED_ENV_VAR = 'PYCHARM_HOSTED'
21
+
22
+
23
+ def is_pycharm_hosted() -> bool:
24
+ return PYCHARM_HOSTED_ENV_VAR in os.environ
25
+
26
+
27
+ ##
28
+
29
+
30
+ PYCHARM_HOME = '/Applications/PyCharm.app'
31
+
32
+
33
+ def read_pycharm_info_plist() -> ta.Mapping[str, ta.Any] | None:
34
+ plist_file = os.path.join(PYCHARM_HOME, 'Contents', 'Info.plist')
35
+ if not os.path.isfile(plist_file):
36
+ return None
37
+
38
+ with open(plist_file, 'rb') as f:
39
+ root = plistlib.load(f)
40
+
41
+ return root
42
+
43
+
44
+ @lang.cached_function
45
+ def get_pycharm_version() -> str | None:
46
+ plist = read_pycharm_info_plist()
47
+ if plist is None:
48
+ return None
49
+
50
+ ver = check.non_empty_str(plist['CFBundleVersion'])
51
+ check.state(ver.startswith('PY-'))
52
+ return ver[3:]
53
+
54
+
55
+ ##
56
+
57
+
58
+ def _import_pydevd_pycharm(*, version: str | None = None) -> ta.Any:
59
+ if (
60
+ 'pydevd_pycharm' in sys.modules or
61
+ (version is None and lang.can_import('pydevd_pycharm'))
62
+ ):
63
+ # Can't unload, nothing we can do
64
+ import pydevd_pycharm # noqa
65
+ return pydevd_pycharm
66
+
67
+ proc = subprocess.run([ # noqa
68
+ sys.executable,
69
+ '-m', 'pip',
70
+ 'show',
71
+ 'pydevd_pycharm',
72
+ ], stdout=subprocess.PIPE)
73
+
74
+ if not proc.returncode:
75
+ info = {
76
+ k: v.strip()
77
+ for l in proc.stdout.decode().splitlines()
78
+ if (s := l.strip())
79
+ for k, _, v in [s.partition(':')]
80
+ }
81
+
82
+ installed_version = info['Version']
83
+ if installed_version == version:
84
+ import pydevd_pycharm # noqa
85
+ return pydevd_pycharm
86
+
87
+ subprocess.check_call([
88
+ sys.executable,
89
+ '-m', 'pip',
90
+ 'uninstall', '-y',
91
+ 'pydevd_pycharm',
92
+ ])
93
+
94
+ subprocess.check_call([
95
+ sys.executable,
96
+ '-m', 'pip',
97
+ 'install',
98
+ 'pydevd_pycharm' + (f'=={version}' if version is not None else ''),
99
+ ])
100
+
101
+ import pydevd_pycharm # noqa
102
+ return pydevd_pycharm
103
+
104
+
105
+ def pycharm_remote_debugger_attach(
106
+ host: str | None,
107
+ port: int,
108
+ *,
109
+ version: str | None = None,
110
+ ) -> None:
111
+ # if version is None:
112
+ # version = get_pycharm_version()
113
+ # check.non_empty_str(version)
114
+
115
+ if host is None:
116
+ if (
117
+ sys.platform == 'linux' and
118
+ docker.is_likely_in_docker() and
119
+ docker.get_docker_host_platform() == 'darwin'
120
+ ):
121
+ host = docker.DOCKER_FOR_MAC_HOSTNAME
122
+ else:
123
+ host = 'localhost'
124
+
125
+ if ta.TYPE_CHECKING:
126
+ import pydevd_pycharm # noqa
127
+ else:
128
+ pydevd_pycharm = _import_pydevd_pycharm(version=version)
129
+
130
+ pydevd_pycharm.settrace(
131
+ host,
132
+ port=port,
133
+ stdoutToServer=True,
134
+ stderrToServer=True,
135
+ )
136
+
137
+
138
+ if __name__ == '__main__':
139
+ print(get_pycharm_version())
omlish/docker.py CHANGED
@@ -15,6 +15,7 @@ apil="application/vnd.docker.distribution.manifest.list.v2+json"
15
15
  curl -H "Accept: ${api}" -H "Accept: ${apil}" -H "Authorization: Bearer $token" -s "https://registry-1.docker.io/v2/${repo}/manifests/latest" | jq .
16
16
  """ # noqa
17
17
  import datetime
18
+ import os
18
19
  import re
19
20
  import shlex
20
21
  import subprocess
@@ -38,15 +39,12 @@ else:
38
39
 
39
40
 
40
41
  @dc.dataclass(frozen=True)
42
+ @msh.update_object_metadata(field_naming=msh.Naming.CAMEL, unknown_field='x')
43
+ @msh.update_fields_metadata(['id'], name='ID')
41
44
  class PsItem(lang.Final):
42
- dc.metadata(msh.ObjectMetadata(
43
- field_naming=msh.Naming.CAMEL,
44
- unknown_field='x',
45
- ))
46
-
47
45
  command: str
48
46
  created_at: datetime.datetime
49
- id: str = dc.field(metadata={msh.FieldMetadata: msh.FieldMetadata(name='ID')})
47
+ id: str
50
48
  image: str
51
49
  labels: str
52
50
  local_volumes: str
@@ -101,12 +99,8 @@ def cli_ps() -> list[PsItem]:
101
99
 
102
100
 
103
101
  @dc.dataclass(frozen=True)
102
+ @msh.update_object_metadata(field_naming=msh.Naming.CAMEL, unknown_field='x')
104
103
  class Inspect(lang.Final):
105
- dc.metadata(msh.ObjectMetadata(
106
- field_naming=msh.Naming.CAMEL,
107
- unknown_field='x',
108
- ))
109
-
110
104
  id: str
111
105
  created: datetime.datetime
112
106
 
@@ -174,6 +168,9 @@ def timebomb_payload(delay_s: float, name: str = 'omlish-docker-timebomb') -> st
174
168
  ##
175
169
 
176
170
 
171
+ DOCKER_FOR_MAC_HOSTNAME = 'docker.for.mac.localhost'
172
+
173
+
177
174
  _LIKELY_IN_DOCKER_PATTERN = re.compile(r'^overlay / .*/docker/')
178
175
 
179
176
 
@@ -183,3 +180,14 @@ def is_likely_in_docker() -> bool:
183
180
  with open('/proc/mounts') as f: # type: ignore
184
181
  ls = f.readlines()
185
182
  return any(_LIKELY_IN_DOCKER_PATTERN.match(l) for l in ls)
183
+
184
+
185
+ ##
186
+
187
+
188
+ # Set by pyproject, docker-dev script
189
+ DOCKER_HOST_PLATFORM_KEY = 'DOCKER_HOST_PLATFORM'
190
+
191
+
192
+ def get_docker_host_platform() -> str | None:
193
+ return os.environ.get(DOCKER_HOST_PLATFORM_KEY)
omlish/genmachine.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ See:
3
+ - https://github.com/pytransitions/transitions
4
+ """
5
+ import typing as ta
6
+
7
+
8
+ I = ta.TypeVar('I')
9
+ O = ta.TypeVar('O')
10
+
11
+ # MachineGen: ta.TypeAlias = ta.Generator[ta.Iterable[O] | None, I, ta.Optional[MachineGen[I, O]]]
12
+ MachineGen: ta.TypeAlias = ta.Generator[ta.Any, ta.Any, ta.Any]
13
+
14
+
15
+ ##
16
+
17
+
18
+ class IllegalStateError(Exception):
19
+ pass
20
+
21
+
22
+ class GenMachine(ta.Generic[I, O]):
23
+ """
24
+ Generator-powered state machine. Generators are sent an `I` object and yield any number of `O` objects in response,
25
+ until they yield a `None` by accepting new input. Generators may return a new generator to switch states, or return
26
+ `None` to terminate.
27
+ """
28
+
29
+ def __init__(self, initial: MachineGen) -> None:
30
+ super().__init__()
31
+ self._advance(initial)
32
+
33
+ @property
34
+ def state(self) -> str | None:
35
+ if self._gen is not None:
36
+ return self._gen.gi_code.co_qualname
37
+ return None
38
+
39
+ def __repr__(self) -> str:
40
+ return f'{self.__class__.__name__}@{hex(id(self))[2:]}<{self.state}>'
41
+
42
+ _gen: MachineGen | None
43
+
44
+ def _advance(self, gen: MachineGen) -> None:
45
+ self._gen = gen
46
+ if (n := next(self._gen)) is not None: # noqa
47
+ raise IllegalStateError
48
+
49
+ def __call__(self, i: I) -> ta.Iterable[O]:
50
+ if self._gen is None:
51
+ raise IllegalStateError
52
+ try:
53
+ while (o := self._gen.send(i)) is not None:
54
+ yield from o
55
+ except StopIteration as s:
56
+ if s.value is None:
57
+ self._gen = None
58
+ return None
59
+ self._advance(s.value)
omlish/graphs/trees.py CHANGED
@@ -190,7 +190,7 @@ class BasicTreeAnalysis(ta.Generic[NodeT]):
190
190
  e: ta.Any
191
191
  d: ta.Any
192
192
  if identity:
193
- e, d = id, col.unique_map(((id(n), n) for n, _ in pairs), strict=True)
193
+ e, d = id, col.make_map(((id(n), n) for n, _ in pairs), strict=True)
194
194
  else:
195
195
  e, d = lang.identity, lang.identity
196
196
  tsd = {e(n): {e(p)} for n, p in parents_by_node.items()}
omlish/lang/__init__.py CHANGED
@@ -79,7 +79,7 @@ from .descriptors import ( # noqa
79
79
  unwrap_func,
80
80
  unwrap_func_with_partials,
81
81
  unwrap_method_descriptors,
82
- update_wrapper_except_dict,
82
+ update_wrapper,
83
83
  )
84
84
 
85
85
  from .exceptions import ( # noqa
@@ -154,6 +154,18 @@ from .objects import ( # noqa
154
154
  super_meta,
155
155
  )
156
156
 
157
+ from .resolving import ( # noqa
158
+ Resolvable,
159
+ ResolvableClassNameError,
160
+ get_cls_fqcn,
161
+ get_fqcn_cls,
162
+ )
163
+
164
+ from .resources import ( # noqa
165
+ RelativeResource,
166
+ get_relative_resources,
167
+ )
168
+
157
169
  from .strings import ( # noqa
158
170
  BOOL_FALSE_STRINGS,
159
171
  BOOL_STRINGS,
omlish/lang/cached.py CHANGED
@@ -220,7 +220,7 @@ def cached_function(fn=None, **kwargs): # noqa
220
220
  ##
221
221
 
222
222
 
223
- class _CachedProperty:
223
+ class _CachedProperty(property):
224
224
  def __init__(
225
225
  self,
226
226
  fn,
@@ -229,9 +229,9 @@ class _CachedProperty:
229
229
  ignore_if=lambda _: False,
230
230
  clear_on_init=False,
231
231
  ):
232
- super().__init__()
233
232
  if isinstance(fn, property):
234
233
  fn = fn.fget
234
+ super().__init__(fn)
235
235
  self._fn = fn
236
236
  self._ignore_if = ignore_if
237
237
  self._name = name
@@ -265,6 +265,9 @@ class _CachedProperty:
265
265
  return
266
266
  raise TypeError(self._name)
267
267
 
268
+ def __delete__(self, instance):
269
+ raise TypeError
270
+
268
271
 
269
272
  def cached_property(fn=None, **kwargs): # noqa
270
273
  if fn is None:
@@ -91,21 +91,38 @@ def unwrap_func_with_partials(fn: ta.Callable) -> tuple[ta.Callable, list[functo
91
91
  ##
92
92
 
93
93
 
94
- WRAPPER_UPDATES_EXCEPT_DICT = tuple(a for a in functools.WRAPPER_UPDATES if a != '__dict__')
94
+ def update_wrapper(
95
+ wrapper: T,
96
+ wrapped: ta.Any,
97
+ assigned: ta.Iterable[str] = functools.WRAPPER_ASSIGNMENTS,
98
+ updated: ta.Iterable[str] = functools.WRAPPER_UPDATES,
99
+ *,
100
+ filter: ta.Iterable[str] | None = None, # noqa
101
+ getattr: ta.Callable = getattr, # noqa
102
+ setattr: ta.Callable = setattr, # noqa
103
+ ) -> T:
104
+ if filter:
105
+ if isinstance(filter, str):
106
+ filter = [filter] # noqa
107
+ assigned = tuple(a for a in assigned if a not in filter)
108
+ updated = tuple(a for a in updated if a not in filter)
109
+
110
+ for attr in assigned:
111
+ try:
112
+ value = getattr(wrapped, attr)
113
+ except AttributeError:
114
+ pass
115
+ else:
116
+ setattr(wrapper, attr, value)
117
+
118
+ for attr in updated:
119
+ getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
95
120
 
121
+ # Issue #17482: set __wrapped__ last so we don't inadvertently copy it from the wrapped function when updating
122
+ # __dict__
123
+ setattr(wrapper, '__wrapped__', wrapped)
96
124
 
97
- def update_wrapper_except_dict(
98
- wrapper,
99
- wrapped,
100
- assigned=functools.WRAPPER_ASSIGNMENTS,
101
- updated=WRAPPER_UPDATES_EXCEPT_DICT,
102
- ):
103
- return functools.update_wrapper(
104
- wrapper,
105
- wrapped,
106
- assigned=assigned,
107
- updated=updated,
108
- )
125
+ return wrapper
109
126
 
110
127
 
111
128
  ##
@@ -118,7 +135,7 @@ class _decorator_descriptor: # noqa
118
135
  if not _DECORATOR_HANDLES_UNBOUND_METHODS:
119
136
  def __init__(self, wrapper, fn):
120
137
  self._wrapper, self._fn = wrapper, fn
121
- update_wrapper_except_dict(self, fn)
138
+ update_wrapper(self, fn, filter='__dict__')
122
139
 
123
140
  def __get__(self, instance, owner=None):
124
141
  return functools.update_wrapper(functools.partial(self._wrapper, fn := self._fn.__get__(instance, owner)), fn) # noqa
@@ -127,7 +144,7 @@ class _decorator_descriptor: # noqa
127
144
  def __init__(self, wrapper, fn):
128
145
  self._wrapper, self._fn = wrapper, fn
129
146
  self._md = _has_method_descriptor(fn)
130
- update_wrapper_except_dict(self, fn)
147
+ update_wrapper(self, fn, filter='__dict__')
131
148
 
132
149
  def __get__(self, instance, owner=None):
133
150
  fn = self._fn.__get__(instance, owner)
@@ -157,7 +174,7 @@ class _decorator_descriptor: # noqa
157
174
  class _decorator: # noqa
158
175
  def __init__(self, wrapper):
159
176
  self._wrapper = wrapper
160
- update_wrapper_except_dict(self, wrapper)
177
+ update_wrapper(self, wrapper, filter='__dict__')
161
178
 
162
179
  def __repr__(self):
163
180
  return f'{self.__class__.__name__}<{self._wrapper}>'