|
23 | 23 | from sphinx.domains import Domain, Index, IndexEntry, ObjType |
24 | 24 | from sphinx.environment import BuildEnvironment |
25 | 25 | from sphinx.locale import _, __ |
| 26 | +from sphinx.pycode.parser import Token, TokenProcessor |
26 | 27 | from sphinx.roles import XRefRole |
27 | 28 | from sphinx.util import logging |
28 | 29 | from sphinx.util.docfields import Field, GroupedField, TypedField |
|
39 | 40 | logger = logging.getLogger(__name__) |
40 | 41 |
|
41 | 42 |
|
42 | | -# REs for Python signatures |
| 43 | +# REs for Python signatures (supports PEP 695) |
43 | 44 | py_sig_re = re.compile( |
44 | 45 | r'''^ ([\w.]*\.)? # class name(s) |
45 | 46 | (\w+) \s* # thing name |
| 47 | + (?: \[\s*(.*)\s*])? # optional: type parameters list (PEP 695) |
46 | 48 | (?: \(\s*(.*)\s*\) # optional: arguments |
47 | 49 | (?:\s* -> \s* (.*))? # return annotation |
48 | 50 | )? $ # and nothing more |
@@ -257,6 +259,146 @@ def _unparse_pep_604_annotation(node: ast.Subscript) -> list[Node]: |
257 | 259 | return [type_to_xref(annotation, env)] |
258 | 260 |
|
259 | 261 |
|
| 262 | +class _TypeParameterListParser(TokenProcessor): |
| 263 | + def __init__(self, sig: str) -> None: |
| 264 | + from token import ERRORTOKEN |
| 265 | + |
| 266 | + # By default, tokenizing "[T = dict[str, Any]]" gives "dict[str,Any]" |
| 267 | + # instead of dict[str, Any]. In particular, the default parameter value |
| 268 | + # will not be formatted properly. Therefore, whitespaces are replaced |
| 269 | + # by some sentinel (bad) value that can be detected as an ERRORTOKEN. |
| 270 | + self.virtual_ws = '\x00' |
| 271 | + self.virtual_ws_tok = [ERRORTOKEN, self.virtual_ws] |
| 272 | + |
| 273 | + signature = ''.join(sig.splitlines()).replace(' ', self.virtual_ws) |
| 274 | + super().__init__([signature]) |
| 275 | + # Each item is a tuple (name, kind, default, bound) mimicking |
| 276 | + # inspect.Parameter to allow default values on VAR_POSITIONAL |
| 277 | + # or VAR_KEYWORD parameters. |
| 278 | + self.tparams: list[tuple[str, int, Any, Any]] = [] |
| 279 | + # When true, (leading) whitespaces are dropped when fetching a token. |
| 280 | + # Set it to false when parsing a type bound or a default value so that |
| 281 | + # they are properly rendered. |
| 282 | + self.ignore_ws = False |
| 283 | + |
| 284 | + def fetch_token(self) -> Token | None: |
| 285 | + if not self.ignore_ws: |
| 286 | + return super().fetch_token() |
| 287 | + |
| 288 | + while super().fetch_token() == self.virtual_ws_tok: |
| 289 | + assert self.current |
| 290 | + return self.current |
| 291 | + |
| 292 | + def fetch_tparam_spec(self) -> list[Token]: |
| 293 | + from token import DEDENT, INDENT, OP |
| 294 | + |
| 295 | + tokens = [] |
| 296 | + self.ignore_ws = False |
| 297 | + while self.fetch_token(): |
| 298 | + tokens.append(self.current) |
| 299 | + for ldelim, rdelim in ['()', '{}', '[]']: |
| 300 | + if self.current == [OP, ldelim]: |
| 301 | + tokens += self.fetch_until([OP, rdelim]) |
| 302 | + break |
| 303 | + else: |
| 304 | + if self.current == INDENT: |
| 305 | + tokens += self.fetch_until(DEDENT) |
| 306 | + elif self.current.match([OP, ':'], [OP, '='], [OP, ',']): |
| 307 | + tokens.pop() |
| 308 | + break |
| 309 | + self.ignore_ws = True |
| 310 | + return tokens |
| 311 | + |
| 312 | + def parse(self) -> None: |
| 313 | + from token import NAME, OP |
| 314 | + |
| 315 | + def build_identifier(tokens: Iterable[Token]) -> str: |
| 316 | + ws = self.virtual_ws_tok |
| 317 | + ident = ''.join(' ' if tok == ws else tok.value for tok in tokens) |
| 318 | + return ident.strip() |
| 319 | + |
| 320 | + while self.fetch_token(): |
| 321 | + if self.current == NAME: |
| 322 | + tpname: list[str] = build_identifier([self.current]) |
| 323 | + if self.previous and self.previous.match([OP, '*'], [OP, '**']): |
| 324 | + if self.previous == [OP, '*']: |
| 325 | + tpkind = Parameter.VAR_POSITIONAL |
| 326 | + else: |
| 327 | + tpkind = Parameter.VAR_KEYWORD |
| 328 | + else: |
| 329 | + tpkind = Parameter.POSITIONAL_OR_KEYWORD |
| 330 | + |
| 331 | + tpbound, tpdefault = Parameter.empty, Parameter.empty |
| 332 | + |
| 333 | + self.fetch_token() # whitespaces (before) will be ignored |
| 334 | + if self.current and self.current.match([OP, ':'], [OP, '=']): |
| 335 | + if self.current == [OP, ':']: |
| 336 | + tpbound = build_identifier(self.fetch_tparam_spec()) |
| 337 | + if self.current == [OP, '=']: |
| 338 | + tpdefault = build_identifier(self.fetch_tparam_spec()) |
| 339 | + |
| 340 | + if tpkind != Parameter.POSITIONAL_OR_KEYWORD and tpbound != Parameter.empty: |
| 341 | + raise SyntaxError('type parameter bound or constraint is not allowed ' |
| 342 | + f'for {tpkind.description} parameters') |
| 343 | + |
| 344 | + tparam = (tpname, tpkind, tpdefault, tpbound) |
| 345 | + self.tparams.append(tparam) |
| 346 | + |
| 347 | + |
| 348 | +def _parse_tplist( |
| 349 | + tplist: str, env: BuildEnvironment | None = None, |
| 350 | + multi_line_parameter_list: bool = False, |
| 351 | +) -> addnodes.desc_tparameterlist: |
| 352 | + """Parse a list of type parameters according to PEP 695.""" |
| 353 | + tparams = addnodes.desc_tparameterlist(tplist) |
| 354 | + tparams['multi_line_parameter_list'] = multi_line_parameter_list |
| 355 | + # formal parameter names are interpreted as type parameter names and |
| 356 | + # type annotations are interpreted as type parameter bounds |
| 357 | + parser = _TypeParameterListParser(tplist) |
| 358 | + parser.parse() |
| 359 | + for (tpname, tpkind, tpdefault, tpbound) in parser.tparams: |
| 360 | + # no positional-only or keyword-only allowed in a type parameters list |
| 361 | + assert tpkind not in {Parameter.POSITIONAL_ONLY, Parameter.KEYWORD_ONLY} |
| 362 | + |
| 363 | + node = addnodes.desc_parameter() |
| 364 | + if tpkind == Parameter.VAR_POSITIONAL: |
| 365 | + node += addnodes.desc_sig_operator('', '*') |
| 366 | + elif tpkind == Parameter.VAR_KEYWORD: |
| 367 | + node += addnodes.desc_sig_operator('', '**') |
| 368 | + node += addnodes.desc_sig_name('', tpname) |
| 369 | + |
| 370 | + if tpbound is not Parameter.empty: |
| 371 | + type_bound = _parse_annotation(tpbound, env) |
| 372 | + if not type_bound: |
| 373 | + continue |
| 374 | + |
| 375 | + node += addnodes.desc_sig_punctuation('', ':') |
| 376 | + node += addnodes.desc_sig_space() |
| 377 | + |
| 378 | + type_bound_expr = addnodes.desc_sig_name('', '', *type_bound) # type: ignore |
| 379 | + |
| 380 | + # add delimiters around type bounds written as e.g., "(T1, T2)" |
| 381 | + if tpbound.startswith('(') and tpbound.endswith(')'): |
| 382 | + node += addnodes.desc_sig_punctuation('', '(') |
| 383 | + node += type_bound_expr |
| 384 | + node += addnodes.desc_sig_punctuation('', ')') |
| 385 | + else: |
| 386 | + node += type_bound_expr |
| 387 | + |
| 388 | + if tpdefault is not Parameter.empty: |
| 389 | + if tpbound is not Parameter.empty or tpkind != Parameter.POSITIONAL_OR_KEYWORD: |
| 390 | + node += addnodes.desc_sig_space() |
| 391 | + node += addnodes.desc_sig_operator('', '=') |
| 392 | + node += addnodes.desc_sig_space() |
| 393 | + else: |
| 394 | + node += addnodes.desc_sig_operator('', '=') |
| 395 | + node += nodes.inline('', tpdefault, classes=['default_value'], |
| 396 | + support_smartquotes=False) |
| 397 | + |
| 398 | + tparams += node |
| 399 | + return tparams |
| 400 | + |
| 401 | + |
260 | 402 | def _parse_arglist( |
261 | 403 | arglist: str, env: BuildEnvironment | None = None, multi_line_parameter_list: bool = False, |
262 | 404 | ) -> addnodes.desc_parameterlist: |
@@ -514,7 +656,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] |
514 | 656 | m = py_sig_re.match(sig) |
515 | 657 | if m is None: |
516 | 658 | raise ValueError |
517 | | - prefix, name, arglist, retann = m.groups() |
| 659 | + prefix, name, tplist, arglist, retann = m.groups() |
518 | 660 |
|
519 | 661 | # determine module and class name (if applicable), as well as full name |
520 | 662 | modname = self.options.get('module', self.env.ref_context.get('py:module')) |
@@ -570,6 +712,14 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] |
570 | 712 | signode += addnodes.desc_addname(nodetext, nodetext) |
571 | 713 |
|
572 | 714 | signode += addnodes.desc_name(name, name) |
| 715 | + |
| 716 | + if tplist: |
| 717 | + try: |
| 718 | + signode += _parse_tplist(tplist, self.env, multi_line_parameter_list) |
| 719 | + except Exception as exc: |
| 720 | + logger.warning("could not parse tplist (%r): %s", tplist, exc, |
| 721 | + location=signode) |
| 722 | + |
573 | 723 | if arglist: |
574 | 724 | try: |
575 | 725 | signode += _parse_arglist(arglist, self.env, multi_line_parameter_list) |
|
0 commit comments