omlish 0.0.0.dev468__py3-none-any.whl → 0.0.0.dev469__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.
@@ -4,14 +4,15 @@ TODO:
4
4
  """
5
5
  import contextlib
6
6
  import functools
7
- import io
8
7
  import typing as ta
9
8
 
10
9
  from ... import dataclasses as dc
11
10
  from ... import lang
11
+ from ...io.buffers import ReadableListBuffer
12
12
  from ..headers import HttpHeaders
13
13
  from .asyncs import AsyncHttpClient
14
14
  from .asyncs import AsyncStreamHttpResponse
15
+ from .base import HttpClientContext
15
16
  from .base import HttpClientError
16
17
  from .base import HttpRequest
17
18
  from .sync import HttpClient
@@ -31,18 +32,29 @@ class HttpxHttpClient(HttpClient):
31
32
  @dc.dataclass(frozen=True)
32
33
  class _StreamAdapter:
33
34
  it: ta.Iterator[bytes]
35
+ buf: ReadableListBuffer = dc.field(default_factory=ReadableListBuffer)
34
36
 
35
- def read(self, /, n: int = -1) -> bytes:
36
- # FIXME: lol n
37
+ def read1(self, /, n: int = -1) -> bytes:
37
38
  if n < 0:
38
- return b''.join(self.it)
39
- else:
39
+ if (b := self.buf.read(n)) is not None:
40
+ return b
40
41
  try:
41
42
  return next(self.it)
42
43
  except StopIteration:
43
44
  return b''
44
45
 
45
- def _stream_request(self, req: HttpRequest) -> StreamHttpResponse:
46
+ else:
47
+ while len(self.buf) < n:
48
+ try:
49
+ b = next(self.it)
50
+ except StopIteration:
51
+ b = b''
52
+ if not b:
53
+ return self.buf.read() or b''
54
+ self.buf.feed(b)
55
+ return self.buf.read(n) or b''
56
+
57
+ def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> StreamHttpResponse:
46
58
  try:
47
59
  resp_cm = httpx.stream(
48
60
  method=req.method_or_default,
@@ -84,21 +96,29 @@ class HttpxAsyncHttpClient(AsyncHttpClient):
84
96
  @dc.dataclass(frozen=True)
85
97
  class _StreamAdapter:
86
98
  it: ta.AsyncIterator[bytes]
99
+ buf: ReadableListBuffer = dc.field(default_factory=ReadableListBuffer)
87
100
 
88
- async def read(self, /, n: int = -1) -> bytes:
89
- # FIXME: lol n
101
+ async def read1(self, /, n: int = -1) -> bytes:
90
102
  if n < 0:
91
- buf = io.BytesIO()
92
- async for chunk in self.it:
93
- buf.write(chunk)
94
- return buf.getvalue()
95
- else:
103
+ if (b := self.buf.read(n)) is not None:
104
+ return b
96
105
  try:
97
106
  return await anext(self.it)
98
- except StopIteration:
107
+ except StopAsyncIteration:
99
108
  return b''
100
109
 
101
- async def _stream_request(self, req: HttpRequest) -> AsyncStreamHttpResponse:
110
+ else:
111
+ while len(self.buf) < n:
112
+ try:
113
+ b = await anext(self.it)
114
+ except StopAsyncIteration:
115
+ b = b''
116
+ if not b:
117
+ return self.buf.read() or b''
118
+ self.buf.feed(b)
119
+ return self.buf.read(n) or b''
120
+
121
+ async def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> AsyncStreamHttpResponse:
102
122
  es = contextlib.AsyncExitStack()
103
123
 
104
124
  try:
@@ -0,0 +1,178 @@
1
+ # ruff: noqa: UP007 UP043 UP045
2
+ # @omlish-lite
3
+ """
4
+ TODO:
5
+ - redirect
6
+ - referrer header?
7
+ - non-forwarded headers, host check, etc lol
8
+ - 'check' kw becomes StatusCheckingMiddleware?
9
+ """
10
+ import dataclasses as dc
11
+ import typing as ta
12
+ import urllib.parse
13
+
14
+ from ...lite.abstract import Abstract
15
+ from ...lite.check import check
16
+ from ..urls import parsed_url_replace
17
+ from .asyncs import AsyncHttpClient
18
+ from .asyncs import AsyncStreamHttpResponse
19
+ from .base import BaseHttpClient
20
+ from .base import BaseHttpResponse
21
+ from .base import HttpClientContext
22
+ from .base import HttpClientError
23
+ from .base import HttpRequest
24
+ from .sync import HttpClient
25
+ from .sync import StreamHttpResponse
26
+ from .sync import close_response
27
+
28
+
29
+ BaseHttpClientT = ta.TypeVar('BaseHttpClientT', bound=BaseHttpClient)
30
+
31
+
32
+ ##
33
+
34
+
35
+ class HttpClientMiddleware(Abstract):
36
+ def process_request(
37
+ self,
38
+ ctx: HttpClientContext,
39
+ req: HttpRequest,
40
+ ) -> HttpRequest:
41
+ return req
42
+
43
+ def process_response(
44
+ self,
45
+ ctx: HttpClientContext,
46
+ req: HttpRequest,
47
+ resp: BaseHttpResponse,
48
+ ) -> ta.Union[BaseHttpResponse, HttpRequest]:
49
+ return resp
50
+
51
+
52
+ class AbstractMiddlewareHttpClient(Abstract, ta.Generic[BaseHttpClientT]):
53
+ def __init__(
54
+ self,
55
+ client: BaseHttpClientT,
56
+ middlewares: ta.Iterable[HttpClientMiddleware],
57
+ ) -> None:
58
+ super().__init__()
59
+
60
+ self._client = client
61
+ self._middlewares = list(middlewares)
62
+
63
+ def _process_request(
64
+ self,
65
+ ctx: HttpClientContext,
66
+ req: HttpRequest,
67
+ ) -> HttpRequest:
68
+ for mw in self._middlewares:
69
+ req = mw.process_request(ctx, req)
70
+ return req
71
+
72
+ def _process_response(
73
+ self,
74
+ ctx: HttpClientContext,
75
+ req: HttpRequest,
76
+ resp: BaseHttpResponse,
77
+ ) -> ta.Union[BaseHttpResponse, HttpRequest]:
78
+ for mw in self._middlewares:
79
+ nxt = mw.process_response(ctx, req, resp)
80
+ if isinstance(nxt, HttpRequest):
81
+ return nxt
82
+ else:
83
+ resp = nxt
84
+ return resp
85
+
86
+
87
+ class MiddlewareHttpClient(AbstractMiddlewareHttpClient[HttpClient], HttpClient):
88
+ def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> StreamHttpResponse:
89
+ while True:
90
+ req = self._process_request(ctx, req)
91
+
92
+ resp = self._client.stream_request(req, context=ctx)
93
+
94
+ try:
95
+ out = self._process_response(ctx, req, resp)
96
+
97
+ if isinstance(out, HttpRequest):
98
+ close_response(resp)
99
+ req = out
100
+ continue
101
+
102
+ elif isinstance(out, StreamHttpResponse):
103
+ return out
104
+
105
+ else:
106
+ raise TypeError(out) # noqa
107
+
108
+ except Exception:
109
+ close_response(resp)
110
+ raise
111
+
112
+ raise RuntimeError
113
+
114
+
115
+ class MiddlewareAsyncHttpClient(AbstractMiddlewareHttpClient[AsyncHttpClient], AsyncHttpClient):
116
+ def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> ta.Awaitable[AsyncStreamHttpResponse]:
117
+ return self._client.stream_request(self._process_request(ctx, req))
118
+
119
+
120
+ ##
121
+
122
+
123
+ class TooManyRedirectsHttpClientError(HttpClientError):
124
+ pass
125
+
126
+
127
+ class RedirectHandlingHttpClientMiddleware(HttpClientMiddleware):
128
+ DEFAULT_MAX_REDIRECTS: ta.ClassVar[int] = 5
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ max_redirects: ta.Optional[int] = None,
134
+ ) -> None:
135
+ super().__init__()
136
+
137
+ if max_redirects is None:
138
+ max_redirects = self.DEFAULT_MAX_REDIRECTS
139
+ self._max_redirects = max_redirects
140
+
141
+ @dc.dataclass()
142
+ class _State:
143
+ num_redirects: int = 0
144
+
145
+ def _get_state(self, ctx: HttpClientContext) -> _State:
146
+ try:
147
+ return ctx._dct[self._State] # noqa
148
+ except KeyError:
149
+ ret = ctx._dct[self._State] = self._State() # noqa
150
+ return ret
151
+
152
+ def process_response(
153
+ self,
154
+ ctx: HttpClientContext,
155
+ req: HttpRequest,
156
+ resp: BaseHttpResponse,
157
+ ) -> ta.Union[BaseHttpResponse, HttpRequest]: # noqa
158
+ if resp.status == 302:
159
+ st = self._get_state(ctx)
160
+ if st.num_redirects >= self._max_redirects:
161
+ raise TooManyRedirectsHttpClientError
162
+ st.num_redirects += 1
163
+
164
+ rd_url = check.not_none(resp.headers).single_str_dct['location']
165
+
166
+ rd_purl = urllib.parse.urlparse(rd_url)
167
+ if not rd_purl.netloc:
168
+ rq_purl = urllib.parse.urlparse(req.url)
169
+ rd_purl = parsed_url_replace(
170
+ rd_purl,
171
+ scheme=rq_purl.scheme,
172
+ netloc=rq_purl.netloc,
173
+ )
174
+ rd_url = urllib.parse.urlunparse(rd_purl)
175
+
176
+ return dc.replace(req, url=rd_url)
177
+
178
+ return resp
@@ -3,13 +3,15 @@
3
3
  import abc
4
4
  import contextlib
5
5
  import dataclasses as dc
6
+ import io
6
7
  import typing as ta
7
8
 
8
9
  from ...lite.abstract import Abstract
9
- from ...lite.dataclasses import dataclass_maybe_post_init
10
10
  from ...lite.dataclasses import dataclass_shallow_asdict
11
+ from .base import BaseHttpClient
11
12
  from .base import BaseHttpResponse
12
13
  from .base import BaseHttpResponseT
14
+ from .base import HttpClientContext
13
15
  from .base import HttpRequest
14
16
  from .base import HttpResponse
15
17
  from .base import HttpStatusError
@@ -26,21 +28,26 @@ HttpClientT = ta.TypeVar('HttpClientT', bound='HttpClient')
26
28
  @dc.dataclass(frozen=True) # kw_only=True
27
29
  class StreamHttpResponse(BaseHttpResponse):
28
30
  class Stream(ta.Protocol):
29
- def read(self, /, n: int = -1) -> bytes: ...
31
+ def read1(self, /, n: int = -1) -> bytes: ...
30
32
 
31
33
  @ta.final
32
34
  class _NullStream:
33
- def read(self, /, n: int = -1) -> bytes:
35
+ def read1(self, /, n: int = -1) -> bytes:
34
36
  raise TypeError
35
37
 
36
38
  stream: Stream = _NullStream()
37
39
 
38
- _closer: ta.Optional[ta.Callable[[], None]] = None
40
+ @property
41
+ def has_data(self) -> bool:
42
+ return not isinstance(self.stream, StreamHttpResponse._NullStream)
43
+
44
+ def read_all(self) -> bytes:
45
+ buf = io.BytesIO()
46
+ while (b := self.stream.read1()):
47
+ buf.write(b)
48
+ return buf.getvalue()
39
49
 
40
- def __post_init__(self) -> None:
41
- dataclass_maybe_post_init(super())
42
- if isinstance(self.stream, StreamHttpResponse._NullStream):
43
- raise TypeError(self.stream)
50
+ _closer: ta.Optional[ta.Callable[[], None]] = None
44
51
 
45
52
  def __enter__(self: StreamHttpResponseT) -> StreamHttpResponseT:
46
53
  return self
@@ -86,10 +93,9 @@ def read_response(resp: BaseHttpResponse) -> HttpResponse:
86
93
  return resp
87
94
 
88
95
  elif isinstance(resp, StreamHttpResponse):
89
- data = resp.stream.read()
90
96
  return HttpResponse(**{
91
97
  **{k: v for k, v in dataclass_shallow_asdict(resp).items() if k not in ('stream', '_closer')},
92
- 'data': data,
98
+ **({'data': resp.read_all()} if resp.has_data else {}),
93
99
  })
94
100
 
95
101
  else:
@@ -99,7 +105,7 @@ def read_response(resp: BaseHttpResponse) -> HttpResponse:
99
105
  ##
100
106
 
101
107
 
102
- class HttpClient(Abstract):
108
+ class HttpClient(BaseHttpClient, Abstract):
103
109
  def __enter__(self: HttpClientT) -> HttpClientT:
104
110
  return self
105
111
 
@@ -110,10 +116,12 @@ class HttpClient(Abstract):
110
116
  self,
111
117
  req: HttpRequest,
112
118
  *,
119
+ context: ta.Optional[HttpClientContext] = None,
113
120
  check: bool = False,
114
121
  ) -> HttpResponse:
115
122
  with closing_response(self.stream_request(
116
123
  req,
124
+ context=context,
117
125
  check=check,
118
126
  )) as resp:
119
127
  return read_response(resp)
@@ -122,9 +130,13 @@ class HttpClient(Abstract):
122
130
  self,
123
131
  req: HttpRequest,
124
132
  *,
133
+ context: ta.Optional[HttpClientContext] = None,
125
134
  check: bool = False,
126
135
  ) -> StreamHttpResponse:
127
- resp = self._stream_request(req)
136
+ if context is None:
137
+ context = HttpClientContext()
138
+
139
+ resp = self._stream_request(context, req)
128
140
 
129
141
  try:
130
142
  if check and not resp.is_success:
@@ -141,5 +153,5 @@ class HttpClient(Abstract):
141
153
  return resp
142
154
 
143
155
  @abc.abstractmethod
144
- def _stream_request(self, req: HttpRequest) -> StreamHttpResponse:
156
+ def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> StreamHttpResponse:
145
157
  raise NotImplementedError
@@ -1,3 +1,5 @@
1
+ # ruff: noqa: UP043 UP045
2
+ # @omlish-lite
1
3
  import http.client
2
4
  import typing as ta
3
5
  import urllib.error
@@ -5,6 +7,7 @@ import urllib.request
5
7
 
6
8
  from ..headers import HttpHeaders
7
9
  from .base import DEFAULT_ENCODING
10
+ from .base import HttpClientContext
8
11
  from .base import HttpClientError
9
12
  from .base import HttpRequest
10
13
  from .sync import HttpClient
@@ -39,7 +42,7 @@ class UrllibHttpClient(HttpClient):
39
42
  data=d,
40
43
  )
41
44
 
42
- def _stream_request(self, req: HttpRequest) -> StreamHttpResponse:
45
+ def _stream_request(self, ctx: HttpClientContext, req: HttpRequest) -> StreamHttpResponse:
43
46
  try:
44
47
  resp = urllib.request.urlopen( # noqa
45
48
  self._build_request(req),
@@ -53,7 +56,6 @@ class UrllibHttpClient(HttpClient):
53
56
  headers=HttpHeaders(e.headers.items()),
54
57
  request=req,
55
58
  underlying=e,
56
- stream=e, # noqa
57
59
  _closer=e.close,
58
60
  )
59
61
 
@@ -116,10 +116,10 @@ class CoroHttpClientConnection:
116
116
  _http_version = 11
117
117
  _http_version_str = 'HTTP/1.1'
118
118
 
119
- http_port: ta.ClassVar[int] = 80
120
- https_port: ta.ClassVar[int] = 443
119
+ HTTP_PORT: ta.ClassVar[int] = 80
120
+ HTTPS_PORT: ta.ClassVar[int] = 443
121
121
 
122
- default_port = http_port
122
+ DEFAULT_PORT: ta.ClassVar[int] = HTTP_PORT
123
123
 
124
124
  class _NOT_SET: # noqa
125
125
  def __new__(cls, *args, **kwargs): # noqa
@@ -139,6 +139,7 @@ class CoroHttpClientConnection:
139
139
  source_address: ta.Optional[str] = None,
140
140
  block_size: int = 8192,
141
141
  auto_open: bool = True,
142
+ default_port: ta.Optional[int] = None,
142
143
  ) -> None:
143
144
  super().__init__()
144
145
 
@@ -146,6 +147,9 @@ class CoroHttpClientConnection:
146
147
  self._source_address = source_address
147
148
  self._block_size = block_size
148
149
  self._auto_open = auto_open
150
+ if default_port is None:
151
+ default_port = self.DEFAULT_PORT
152
+ self._default_port = default_port
149
153
 
150
154
  self._connected = False
151
155
  self._buffer: ta.List[bytes] = []
@@ -162,6 +166,10 @@ class CoroHttpClientConnection:
162
166
 
163
167
  CoroHttpClientValidation.validate_host(self._host)
164
168
 
169
+ @property
170
+ def http_version(self) -> int:
171
+ return self._http_version
172
+
165
173
  #
166
174
 
167
175
  def _get_hostport(self, host: str, port: ta.Optional[int]) -> ta.Tuple[str, int]:
@@ -173,12 +181,12 @@ class CoroHttpClientConnection:
173
181
  port = int(host[i + 1:])
174
182
  except ValueError:
175
183
  if host[i + 1:] == '': # http://foo.com:/ == http://foo.com/
176
- port = self.default_port
184
+ port = self._default_port
177
185
  else:
178
186
  raise CoroHttpClientErrors.InvalidUrlError(f"non-numeric port: '{host[i + 1:]}'") from None
179
187
  host = host[:i]
180
188
  else:
181
- port = self.default_port
189
+ port = self._default_port
182
190
 
183
191
  if host and host[0] == '[' and host[-1] == ']':
184
192
  host = host[1:-1]
@@ -286,6 +294,7 @@ class CoroHttpClientConnection:
286
294
  source_address=self._source_address,
287
295
  **(dict(timeout=self._timeout) if self._timeout is not self._NOT_SET else {}),
288
296
  ),
297
+ server_hostname=self._tunnel_host if self._tunnel_host else self._host,
289
298
  )))
290
299
 
291
300
  self._connected = True
@@ -526,7 +535,7 @@ class CoroHttpClientConnection:
526
535
  if ':' in host:
527
536
  host_enc = self._strip_ipv6_iface(host_enc)
528
537
 
529
- if port == self.default_port:
538
+ if port == self._default_port:
530
539
  self.put_header('Host', host_enc)
531
540
  else:
532
541
  self.put_header('Host', f"{host_enc.decode('ascii')}:{port}")
omlish/http/coro/io.py CHANGED
@@ -37,6 +37,8 @@ class CoroHttpIo:
37
37
  args: ta.Tuple[ta.Any, ...]
38
38
  kwargs: ta.Optional[ta.Dict[str, ta.Any]] = None
39
39
 
40
+ server_hostname: ta.Optional[str] = None
41
+
40
42
  #
41
43
 
42
44
  class CloseIo(Io):
omlish/http/urls.py ADDED
@@ -0,0 +1,67 @@
1
+ # ruff: noqa: UP006 UP007 UP045
2
+ # @omlish-lite
3
+ import re
4
+ import typing as ta
5
+ import urllib.parse
6
+
7
+ from ..lite.cached import cached_nullary
8
+
9
+
10
+ ##
11
+
12
+
13
+ @cached_nullary
14
+ def _url_split_host_pat() -> re.Pattern:
15
+ return re.compile('//([^/#?]*)(.*)', re.DOTALL)
16
+
17
+
18
+ def url_split_host(url: str) -> ta.Tuple[ta.Optional[str], str]:
19
+ """splithost('//host[:port]/path') --> 'host[:port]', '/path'."""
20
+
21
+ # https://github.com/python/cpython/blob/364ae607d8035db8ba92486ebebd8225446c1a90/Lib/urllib/parse.py#L1143
22
+ if not (m := _url_split_host_pat().match(url)):
23
+ return None, url
24
+
25
+ host_port, path = m.groups()
26
+ if path and path[0] != '/':
27
+ path = '/' + path
28
+ return host_port, path
29
+
30
+
31
+ ##
32
+
33
+
34
+ def unparse_url_request_path(url: ta.Union[str, urllib.parse.ParseResult]) -> str:
35
+ if isinstance(url, urllib.parse.ParseResult):
36
+ ups = url
37
+ else:
38
+ ups = urllib.parse.urlparse(url)
39
+
40
+ return urllib.parse.urlunparse((
41
+ '',
42
+ '',
43
+ ups.path,
44
+ ups.params,
45
+ ups.query,
46
+ ups.fragment,
47
+ ))
48
+
49
+
50
+ def parsed_url_replace(
51
+ url: urllib.parse.ParseResult,
52
+ *,
53
+ scheme: ta.Optional[str] = None,
54
+ netloc: ta.Optional[str] = None,
55
+ path: ta.Optional[str] = None,
56
+ params: ta.Optional[str] = None,
57
+ query: ta.Optional[str] = None,
58
+ fragment: ta.Optional[str] = None,
59
+ ) -> urllib.parse.ParseResult:
60
+ return urllib.parse.ParseResult(
61
+ scheme if scheme is not None else url.scheme,
62
+ netloc if netloc is not None else url.netloc,
63
+ path if path is not None else url.path,
64
+ params if params is not None else url.params,
65
+ query if query is not None else url.query,
66
+ fragment if fragment is not None else url.fragment,
67
+ )
omlish/io/buffers.py CHANGED
@@ -208,6 +208,9 @@ class ReadableListBuffer:
208
208
 
209
209
  def read(self, n: ta.Optional[int] = None) -> ta.Optional[bytes]:
210
210
  if n is None:
211
+ if not self._lst:
212
+ return b''
213
+
211
214
  o = b''.join(self._lst)
212
215
  self._lst = []
213
216
  return o
omlish/lang/__init__.py CHANGED
@@ -283,6 +283,7 @@ with _auto_proxy_init(globals(), update_exports=True):
283
283
 
284
284
  new_function,
285
285
  new_function_kwargs,
286
+ copy_function,
286
287
  )
287
288
 
288
289
  from .generators import ( # noqa
@@ -420,6 +421,8 @@ with _auto_proxy_init(globals(), update_exports=True):
420
421
  )
421
422
 
422
423
  from .params import ( # noqa
424
+ CanParamSpec,
425
+
423
426
  Param,
424
427
 
425
428
  VarParam,
omlish/lang/functions.py CHANGED
@@ -250,16 +250,17 @@ def new_function(
250
250
  # a tuple that supplies the bindings for free variables
251
251
  closure: tuple | None = None,
252
252
 
253
- # # a dictionary that specifies the default keyword argument values
254
- # kwdefaults: dict | None = None,
253
+ # a dictionary that specifies the default keyword argument values
254
+ kwdefaults: dict | None = None,
255
255
  ) -> types.FunctionType:
256
+ # https://github.com/python/cpython/blob/9c8eade20c6c6cc6f31dffb5e42472391d63bbf4/Objects/funcobject.c#L909
256
257
  return types.FunctionType(
257
258
  code=code,
258
259
  globals=globals,
259
260
  name=name,
260
261
  argdefs=argdefs,
261
262
  closure=closure,
262
- # kwdefaults=kwdefaults,
263
+ kwdefaults=kwdefaults,
263
264
  )
264
265
 
265
266
 
@@ -270,5 +271,9 @@ def new_function_kwargs(f: types.FunctionType) -> dict[str, ta.Any]:
270
271
  name=f.__name__,
271
272
  argdefs=f.__defaults__,
272
273
  closure=f.__closure__,
273
- # kwdefaults=f.__kwdefaults__,
274
+ kwdefaults=f.__kwdefaults__,
274
275
  )
276
+
277
+
278
+ def copy_function(f: types.FunctionType) -> types.FunctionType:
279
+ return new_function(**new_function_kwargs(f))
omlish/lang/params.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  TODO:
3
3
  - check validity
4
+ - signature vs getfullargspec - diff unwrapping + 'self' handling
4
5
  """
5
6
  import dataclasses as dc
6
7
  import enum
@@ -16,6 +17,13 @@ from .classes.restrict import Sealed
16
17
  T = ta.TypeVar('T')
17
18
 
18
19
 
20
+ CanParamSpec: ta.TypeAlias = ta.Union[
21
+ 'ParamSpec',
22
+ inspect.Signature,
23
+ ta.Callable,
24
+ ]
25
+
26
+
19
27
  ##
20
28
 
21
29
 
@@ -101,6 +109,15 @@ class ParamSpec(ta.Sequence[Param], Final):
101
109
 
102
110
  #
103
111
 
112
+ @classmethod
113
+ def of(cls, obj: CanParamSpec) -> 'ParamSpec':
114
+ if isinstance(obj, ParamSpec):
115
+ return obj
116
+ elif isinstance(obj, inspect.Signature):
117
+ return cls.of_signature(obj)
118
+ else:
119
+ return cls.inspect(obj)
120
+
104
121
  @classmethod
105
122
  def of_signature(
106
123
  cls,