Coverage for pymend\file_parser.py: 94%

1"""Module for parsing input file and walking ast."""

2

3 import ast

4 import re

5 import sys

6 from typing import Optional, Union, get_args, overload

7

8 from typing_extensions import TypeGuard

9

10 from .const import DEFAULT_EXCEPTION

11 from .types import (

12 BodyTypes,

13 ClassDocstring,

14 DocstringInfo,

15 ElementDocstring,

16 FixerSettings,

17 FunctionBody,

18 FunctionDocstring,

19 FunctionSignature,

20 ModuleDocstring,

21 NodeOfInterest,

22 Parameter,

23 ReturnValue,

24)

25

26 __author__ = "J-E. Nitschke"

27 __copyright__ = "Copyright 2023-2024"

28 __licence__ = "GPL3"

29 __version__ = "1.0.0"

30 __maintainer__ = "J-E. Nitschke"

31

32

33@overload

34 def ast_unparse(node: None) -> None: ... 34 ↛ exitline 34 didn't return from function 'ast_unparse'

35

36

37@overload

38 def ast_unparse(node: ast.AST) -> str: ... 38 ↛ exitline 38 didn't return from function 'ast_unparse'

39

40

41 def ast_unparse(node: Optional[ast.AST]) -> Optional[str]:

42 """Convert the AST node to source code as a string.

43

44 Parameters

45 ----------

46 node : Optional[ast.AST]

47 Node to unparse.

48

49 Returns

50 -------

51 Optional[str]

52 `None` if `node` was `None`.

53 Otherwise the unparsed node.

54 """

55 if node is None:

56 return None

57 return ast.unparse(node)

58

59

60 class AstAnalyzer:

61 """Walk ast and extract module, class and function information."""

62

63 def __init__(self, file_content: str, *, settings: FixerSettings) -> None:

64 """Initialize the Analyzer with the file contents.

65

66 The only reason this is a class is to have the raw

67 file_contents available at any point of the analysis to double check

68 something. Currently used for the module docstring and docstring

69 modifiers.

70

71 Parameters

72 ----------

73 file_content : str

74 File contents to store.

75 settings : FixerSettings

76 Settings for what to fix and when.

77 """

78 self.file_content = file_content

79 self.settings = settings

80

81 def parse_from_ast(

82 self,

83 ) -> list[ElementDocstring]:

84 """Walk AST of the input file extract info about module, classes and functions.

85

86 For the module and classes, the raw docstring

87 and its line numbers are extracted.

88

89 For functions the raw docstring and its line numbers are extracted.

90 Additionally the signature is parsed for parameters and return value.

91

92 Returns

93 -------

94 list[ElementDocstring]

95 List of information about module, classes and functions.

96

97 Raises

98 ------

99 AssertionError

100 If the source file content could not be parsed into an ast.

101 """

102 nodes_of_interest: list[ElementDocstring] = []

103 try:

104 file_ast = ast.parse(self.file_content)

105 except Exception as exc: # noqa: BLE001

106 msg = f"Failed to parse source file AST: {exc}\n"

107 raise AssertionError(msg) from exc

108 for node in ast.walk(file_ast):

109 if isinstance(node, ast.Module):

110 nodes_of_interest.append(self.handle_module(node))

111 elif isinstance(node, ast.ClassDef):

112 if node.name in self.settings.ignored_classes:

113 continue

114 nodes_of_interest.append(self.handle_class(node))

115 elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):

116 if (

117 any(

118 name.id in self.settings.ignored_decorators

119 for name in node.decorator_list

120 if isinstance(name, ast.Name)

121 )

122 or node.name in self.settings.ignored_functions

123 ):

124 continue

125 nodes_of_interest.append(self.handle_function(node))

126 return nodes_of_interest

127

128 def handle_module(self, module: ast.Module) -> ModuleDocstring:

129 """Extract information about module.

130

131 Parameters

132 ----------

133 module : ast.Module

134 Node representing the full module.

135

136 Returns

137 -------

138 ModuleDocstring

139 Docstring representation for the module.

140 """

141 docstring_info = self.get_docstring_info(module)

142 if docstring_info is None:

143 docstring_line = self._get_docstring_line()

144 return ModuleDocstring(

145 "Module",

146 docstring="",

147 lines=(docstring_line, docstring_line),

148 modifier="",

149 issues=[],

150 )

151 return ModuleDocstring(

152 name=docstring_info.name,

153 docstring=docstring_info.docstring,

154 lines=docstring_info.lines,

155 modifier=docstring_info.modifier,

156 issues=docstring_info.issues,

157 )

158

159 def handle_class(self, cls: ast.ClassDef) -> ClassDocstring:

160 """Extract information about class docstring.

161

162 Parameters

163 ----------

164 cls : ast.ClassDef

165 Node representing a class definition.

166

167 Returns

168 -------

169 ClassDocstring

170 Docstring representation for a class.

171 """

172 docstring = self.handle_elem_docstring(cls)

173 attributes, methods = self.handle_class_body(cls)

174 return ClassDocstring(

175 name=docstring.name,

176 docstring=docstring.docstring,

177 lines=docstring.lines,

178 modifier=docstring.modifier,

179 issues=docstring.issues,

180 attributes=attributes,

181 methods=methods,

182 )

183

184 def handle_function(

185 self,

186 func: Union[ast.FunctionDef, ast.AsyncFunctionDef],

187 ) -> FunctionDocstring:

188 """Extract information from signature and docstring.

189

190 Parameters

191 ----------

192 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

193 Node representing a function definition.

194

195 Returns

196 -------

197 FunctionDocstring

198 Docstring representation of a function.

199 """

200 docstring = self.handle_elem_docstring(func)

201 signature = self.handle_function_signature(func)

202 body = self.handle_function_body(func)

203 # Minus one because the function counts the passed node itself

204 # Which is correct for each nested node but not the main one.

205 length = self._get_block_length(func) - 1

206 return FunctionDocstring(

207 name=docstring.name,

208 docstring=docstring.docstring,

209 lines=docstring.lines,

210 modifier=docstring.modifier,

211 issues=docstring.issues,

212 signature=signature,

213 body=body,

214 length=length,

215 )

216

217 def handle_elem_docstring(

218 self,

219 elem: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef],

220 ) -> DocstringInfo:

221 """Extract information about the docstring of the function.

222

223 Parameters

224 ----------

225 elem : Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]

226 Element representing a function or class definition.

227

228 Returns

229 -------

230 DocstringInfo

231 Return general information about the docstring of the element.

232

233 Raises

234 ------

235 ValueError

236 If the element did not have a body at all. This should not happen

237 for valid functions or classes.

238 """

239 docstring_info = self.get_docstring_info(elem)

240 if docstring_info is None:

241 if not elem.body: 241 ↛ 242line 241 didn't jump to line 242, because the condition on line 241 was never true

242 msg = "Function body was unexpectedly completely empty."

243 raise ValueError(msg)

244 lines = (elem.body[0].lineno, elem.body[0].lineno)

245 return DocstringInfo(

246 name=elem.name, docstring="", lines=lines, modifier="", issues=[]

247 )

248 return docstring_info

249

250 def get_docstring_info(self, node: NodeOfInterest) -> Optional[DocstringInfo]:

251 """Get docstring and line number if available.

252

253 Parameters

254 ----------

255 node : NodeOfInterest

256 Get general information about the docstring of any node

257 if interest.

258

259 Returns

260 -------

261 Optional[DocstringInfo]

262 Information about the docstring if the element contains one.

263 Or `None` if there was no docstring at all.

264

265 Raises

266 ------

267 ValueError

268 If the first element of the body is not a docstring after

269 `ast.get_docstring()` returned one.

270 """

271 if ast.get_docstring(node):

272 if not ( 272 ↛ 278line 272 didn't jump to line 278

273 node.body

274 and isinstance(first_element := node.body[0], ast.Expr)

275 and isinstance(docnode := first_element.value, ast.Constant)

276 and isinstance(docnode.value, str)

277 ):

278 msg = (

279 "Expected first entry in body to be the "

280 "docstring, but found nothing or something else."

281 )

282 raise ValueError(msg)

283 modifier = self._get_modifier(

284 self.file_content.splitlines()[docnode.lineno - 1]

285 )

286 return DocstringInfo(

287 # Can not use DefinitionNodes in isinstance checks before 3.10

288 name=(

289 node.name

290 if isinstance(

291 node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)

292 )

293 else "Module"

294 ),

295 docstring=str(docnode.value),

296 lines=(docnode.lineno, docnode.end_lineno),

297 modifier=modifier,

298 issues=[],

299 )

300 return None

301

302 def _get_modifier(self, line: str) -> str:

303 """Get the string modifier from the start of a docstring.

304

305 Parameters

306 ----------

307 line : str

308 Line to check

309

310 Returns

311 -------

312 str

313 Modifier of the string.

314 """

315 line = line.strip()

316 delimiters = ['"""', "'''"]

317 modifiers = ["r", "u"]

318 if not line: 318 ↛ 319line 318 didn't jump to line 319, because the condition on line 318 was never true

319 return ""

320 if line[:3] in delimiters:

321 return ""

322 if line[0].lower() in modifiers and line[1:4] in delimiters: 322 ↛ 324line 322 didn't jump to line 324, because the condition on line 322 was never false

323 return line[0]

324 return ""

325

326 def _get_docstring_line(self) -> int:

327 """Get the line where the module docstring should start.

328

329 Returns

330 -------

331 int

332 Starting line (starts at 1) of the docstring.

333 """

334 shebang_encoding_lines = 2

335 for index, line in enumerate(

336 self.file_content.splitlines()[:shebang_encoding_lines]

337 ):

338 if not self.is_shebang_or_pragma(line):

339 # List indices start at 0 but file lines are counted from 1

340 return index + 1

341 return shebang_encoding_lines + 1

342

343 def _has_body(self, node: ast.AST) -> TypeGuard[BodyTypes]:

344 """Check that the node is one of those that have a body."""

345 return isinstance(

346 node,

347 (get_args(BodyTypes)),

348 ) and hasattr(node, "body")

349

350 def _get_block_length(self, node: ast.AST) -> int:

351 """Get the number of statements in a block.

352

353 Recursively count the number of statements in a blocks body.

354

355 Parameters

356 ----------

357 node : ast.AST

358 Node representing to count the number of statements for.

359

360 Returns

361 -------

362 int

363 Total number of (nested) statements in the block.

364 """

365 # pylint: disable=no-member

366 if sys.version_info >= (3, 11): 366 ↛ 369line 366 didn't jump to line 369, because the condition on line 366 was never false

367 try_nodes = (ast.Try, ast.TryStar)

368 else:

369 try_nodes = (ast.Try,)

370 length = 1

371 if self._has_body(node) and node.body:

372 length += sum(self._get_block_length(child) for child in node.body)

373 # Decorators add complexity, so lets count them for now

374 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):

375 length += len(node.decorator_list)

376 elif isinstance(node, (ast.For, ast.AsyncFor, ast.While, ast.If, *try_nodes)):

377 length += sum(self._get_block_length(child) for child in node.orelse)

378 if isinstance(node, try_nodes):

379 length += sum(self._get_block_length(child) for child in node.finalbody)

380 length += sum(self._get_block_length(child) for child in node.handlers)

381 elif sys.version_info >= (3, 10) and isinstance(node, ast.Match): 381 ↛ 385line 381 didn't jump to line 385, because the condition on line 381 was never true

382 # Each case counts itself + its body.

383 # This is intended for now as compared to if/else there is a lot

384 # of logic actually still happening in the case matching.

385 length += sum(self._get_block_length(child) for child in node.cases)

386

387 # We do not want to count the docstring

388 if (

389 length

390 and isinstance(

391 node,

392 (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module),

393 )

394 and ast.get_docstring(node)

395 ):

396 length -= 1

397 return length

398

399 def handle_class_body(self, cls: ast.ClassDef) -> tuple[list[Parameter], list[str]]:

400 """Extract attributes and methods from class body.

401

402 Will walk the AST of the ClassDef node and add each function encountered

403 as a method.

404

405 If the `__init__` method is encountered walk its body for attribute

406 definitions.

407

408 Parameters

409 ----------

410 cls : ast.ClassDef

411 Node representing a class definition.

412

413 Returns

414 -------

415 attributes : list[Parameter]

416 List of the parameters that make up the classes attributes.

417 methods : list[str]

418 List of the method names in the class.

419 """

420 attributes: list[Parameter] = []

421 methods: list[str] = []

422 for node in cls.body:

423 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):

424 continue

425 # Extract attributes from init method.

426 if node.name == "__init__":

427 attributes.extend(self._get_attributes_from_init(node))

428 # Skip dunder methods for method extraction

429 if node.name.startswith("__") and node.name.endswith("__"):

430 continue

431 # Optionally skip private methods.

432 if self.settings.ignore_privates and node.name.startswith("_"):

433 continue

434 # Handle properties as attributes

435 if "property" in {

436 decorator.id

437 for decorator in node.decorator_list

438 if isinstance(decorator, ast.Name)

439 }:

440 return_value = self.get_return_value_sig(node)

441 attributes.append(Parameter(node.name, return_value.type_name, None))

442 # Handle normal methods except for those with some specific decorators

443 # Like statismethod, classmethod, property or getters/setters.

444 elif not self._has_excluding_decorator(node):

445 methods.append(self._get_method_signature(node))

446 # Exclude some like staticmethods and properties

447

448 # Remove duplicates from attributes while maintaining order

449 return list(Parameter.uniquefy(attributes)), methods

450

451 def handle_function_signature(

452 self,

453 func: Union[ast.FunctionDef, ast.AsyncFunctionDef],

454 ) -> FunctionSignature:

455 """Extract information about the signature of the function.

456

457 Parameters

458 ----------

459 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

460 Node representing a function definition

461

462 Returns

463 -------

464 FunctionSignature

465 Information extracted from the function signature

466 """

467 parameters = self.get_parameters_sig(func)

468 if parameters and parameters[0].arg_name == "self":

469 parameters.pop(0)

470 return_value = self.get_return_value_sig(func)

471 return FunctionSignature(parameters, return_value)

472

473 def handle_function_body(

474 self,

475 func: Union[ast.FunctionDef, ast.AsyncFunctionDef],

476 ) -> FunctionBody:

477 """Check the function body for yields, raises and value returns.

478

479 Parameters

480 ----------

481 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

482 Node representing a function definition

483

484 Returns

485 -------

486 FunctionBody

487 Information extracted from the function body.

488 """

489 returns: set[tuple[str, ...]] = set()

490 returns_value = False

491 yields: set[tuple[str, ...]] = set()

492 yields_value = False

493 raises: list[str] = []

494 for node in ast.walk(func):

495 if isinstance(node, ast.Return) and node.value is not None:

496 returns_value = True

497 if isinstance(node.value, ast.Tuple) and all(

498 isinstance(value, ast.Name) for value in node.value.elts

499 ):

500 returns.add(self._get_ids_from_returns(node.value.elts))

501 elif isinstance(node, (ast.Yield, ast.YieldFrom)):

502 yields_value = True

503 if (

504 isinstance(node, ast.Yield)

505 and isinstance(node.value, ast.Tuple)

506 and all(isinstance(value, ast.Name) for value in node.value.elts)

507 ):

508 yields.add(self._get_ids_from_returns(node.value.elts))

509 elif isinstance(node, ast.Raise):

510 pascal_case_regex = r"^(?:[A-Z][a-z]+)+$"

511 if not node.exc:

512 raises.append(DEFAULT_EXCEPTION)

513 elif isinstance(node.exc, ast.Name) and re.match(

514 pascal_case_regex, node.exc.id

515 ):

516 raises.append(node.exc.id)

517 elif (

518 isinstance(node.exc, ast.Call)

519 and isinstance(node.exc.func, ast.Name)

520 and re.match(pascal_case_regex, node.exc.func.id)

521 ):

522 raises.append(node.exc.func.id)

523 else:

524 raises.append(DEFAULT_EXCEPTION)

525 return FunctionBody(

526 returns_value=returns_value,

527 returns=returns,

528 yields_value=yields_value,

529 yields=yields,

530 raises=raises,

531 )

532

533 def get_return_value_sig(

534 self, func: Union[ast.FunctionDef, ast.AsyncFunctionDef]

535 ) -> ReturnValue:

536 """Get information about return value from signature.

537

538 Parameters

539 ----------

540 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

541 Node representing a function definition

542

543 Returns

544 -------

545 ReturnValue

546 Return information extracted from the function signature.

547 """

548 return ReturnValue(type_name=ast_unparse(func.returns))

549

550 def get_parameters_sig(

551 self, func: Union[ast.FunctionDef, ast.AsyncFunctionDef]

552 ) -> list[Parameter]:

553 """Get information about function parameters.

554

555 Parameters

556 ----------

557 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

558 Node representing a function definition

559

560 Returns

561 -------

562 list[Parameter]

563 Parameter information from the function signature.

564 """

565 arguments: list[Parameter] = []

566 pos_defaults = self.get_padded_args_defaults(func)

567

568 pos_only_args = [

569 Parameter(arg.arg, ast_unparse(arg.annotation), None)

570 for arg in func.args.posonlyargs

571 ]

572 arguments += pos_only_args

573 general_args = [

574 Parameter(arg.arg, ast_unparse(arg.annotation), default)

575 for arg, default in zip(func.args.args, pos_defaults)

576 ]

577 arguments += general_args

578 if vararg := func.args.vararg:

579 arguments.append(

580 Parameter(f"*{vararg.arg}", ast_unparse(vararg.annotation), None)

581 )

582 kw_only_args = [

583 Parameter(

584 arg.arg,

585 ast_unparse(arg.annotation),

586 ast_unparse(default),

587 )

588 for arg, default in zip(func.args.kwonlyargs, func.args.kw_defaults)

589 ]

590 arguments += kw_only_args

591 if kwarg := func.args.kwarg:

592 arguments.append(

593 Parameter(f"**{kwarg.arg}", ast_unparse(kwarg.annotation), None)

594 )

595 # Filter out unused arguments.

596 return (

597 [

598 argument

599 for argument in arguments

600 if not argument.arg_name.startswith("_")

601 ]

602 if self.settings.ignore_unused_arguments

603 else arguments

604 )

605

606 @staticmethod

607 def is_shebang_or_pragma(line: str) -> bool:

608 """Check if a given line contains encoding or shebang.

609

610 Parameters

611 ----------

612 line : str

613 Line to check

614

615 Returns

616 -------

617 bool

618 Whether the given line contains encoding or shebang

619 """

620 shebang_regex = r"^#!(.*)"

621 if re.search(shebang_regex, line) is not None:

622 return True

623 pragma_regex = r"^#.*coding[=:]\s*([-\w.]+)"

624 return re.search(pragma_regex, line) is not None

625

626 def get_padded_args_defaults(

627 self,

628 func: Union[ast.FunctionDef, ast.AsyncFunctionDef],

629 ) -> list[Optional[str]]:

630 """Left-Pad the general args defaults to the length of the args.

631

632 Parameters

633 ----------

634 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

635 Node representing a function definition

636

637 Returns

638 -------

639 list[Optional[str]]

640 Left padded (with `None`) list of function arguments.

641 """

642 pos_defaults = [ast_unparse(default) for default in func.args.defaults]

643 return [None] * (len(func.args.args) - len(pos_defaults)) + pos_defaults

644

645 def _has_excluding_decorator(

646 self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]

647 ) -> bool:

648 """Exclude function with some decorators.

649

650 Currently excluded:

651 staticmethod

652 classmethod

653 property (and related)

654

655 Parameters

656 ----------

657 node : Union[ast.FunctionDef, ast.AsyncFunctionDef]

658 Node representing a function definition

659

660 Returns

661 -------

662 bool

663 Whether the function as any decorators that exclude it from

664 being recognized as a standard method.

665 """

666 decorators = node.decorator_list

667 excluded_decorators = {"staticmethod", "classmethod", "property"}

668 for decorator in decorators:

669 if isinstance(decorator, ast.Name) and decorator.id in excluded_decorators:

670 return True

671 # Handle property related decorators like in

672 # @x.setter

673 # def x(self, value):

674 # self._x = value # noqa: ERA001

675

676 # @x.deleter

677 # def x(self):

678 # del self._x

679 if ( 679 ↛ 668line 679 didn't jump to line 668

680 isinstance(decorator, ast.Attribute)

681 and isinstance(decorator.value, ast.Name)

682 and decorator.value.id == node.name

683 ):

684 return True

685 return False

686

687 def _check_if_node_is_self_attributes(

688 self, node: ast.expr

689 ) -> TypeGuard[ast.Attribute]:

690 """Check whether the node represents a public attribute of self (self.abc).

691

692 Parameters

693 ----------

694 node : ast.expr

695 Node representing the expression to be checked.

696

697 Returns

698 -------

699 TypeGuard[ast.Attribute]

700 True if the node represents a public attribute of self.

701 """

702 return (

703 isinstance(node, ast.Attribute)

704 and isinstance(node.value, ast.Name)

705 and node.value.id == "self"

706 and not (self.settings.ignore_privates and node.attr.startswith("_"))

707 )

708

709 def _check_and_handle_assign_node(

710 self, target: ast.expr, attributes: list[Parameter]

711 ) -> None:

712 """Check if the assignment node contains assignments to self.X.

713

714 Add it to the list of attributes if that is the case.

715

716 Parameters

717 ----------

718 target : ast.expr

719 Node representing an assignment

720 attributes : list[Parameter]

721 List of attributes the node attribute should be added to.

722 """

723 if isinstance(target, (ast.Tuple, ast.List)):

724 for node in target.elts:

725 if self._check_if_node_is_self_attributes(node): 725 ↛ 724line 725 didn't jump to line 724, because the condition on line 725 was never false

726 attributes.append(Parameter(node.attr, "_type_", None))

727 elif self._check_if_node_is_self_attributes(target):

728 attributes.append(Parameter(target.attr, "_type_", None))

729

730 def _get_attributes_from_init(

731 self, init: Union[ast.FunctionDef, ast.AsyncFunctionDef]

732 ) -> list[Parameter]:

733 """Iterate over body and grab every assignment `self.abc = XYZ`.

734

735 Parameters

736 ----------

737 init : Union[ast.FunctionDef, ast.AsyncFunctionDef]

738 Init function node to extract attributes from.

739

740 Returns

741 -------

742 list[Parameter]

743 List of attributes extracted from the init function.

744 """

745 attributes: list[Parameter] = []

746 for node in init.body:

747 if isinstance(node, ast.Assign):

748 # Targets is a list in case of multiple assignent

749 # a = b = 3 # noqa: ERA001

750 for target in node.targets:

751 self._check_and_handle_assign_node(target, attributes)

752 # Also handle annotated assignments

753 # c: int = "Test" # noqa: ERA001

754 elif isinstance(node, ast.AnnAssign):

755 self._check_and_handle_assign_node(node.target, attributes)

756 return attributes

757

758 def _get_method_signature(

759 self, func: Union[ast.FunctionDef, ast.AsyncFunctionDef]

760 ) -> str:

761 """Remove self from signature and return the unparsed string.

762

763 Parameters

764 ----------

765 func : Union[ast.FunctionDef, ast.AsyncFunctionDef]

766 Node representing a function definition.

767

768 Returns

769 -------

770 str

771 String of the method signature with `self` removed.

772 """

773 arguments = func.args

774 if arguments.posonlyargs:

775 arguments.posonlyargs = [

776 arg for arg in arguments.posonlyargs if arg.arg != "self"

777 ]

778 elif arguments.args: 778 ↛ 780line 778 didn't jump to line 780, because the condition on line 778 was never false

779 arguments.args = [arg for arg in arguments.args if arg.arg != "self"]

780 return f"{func.name}({ast.unparse(arguments)})"

781

782 def _get_ids_from_returns(self, values: list[ast.expr]) -> tuple[str, ...]:

783 """Get the ids/names for all the expressions in the list.

784

785 Parameters

786 ----------

787 values : list[ast.expr]

788 List of expressions to extract the ids from.

789

790 Returns

791 -------

792 tuple[str, ...]

793 Tuple of ids of the original expressions.

794 """

795 return tuple(

796 value.id

797 for value in values

798 # Needed again for type checker

799 if isinstance(value, ast.Name)

800 )