1 import os

2 import warnings

3 from inspect import signature

4 from pathlib import Path

5 from typing import Any

6 from typing import Callable

7 from typing import cast

8 from typing import Iterable

9 from typing import Iterator

10 from typing import List

11 from typing import MutableMapping

12 from typing import Optional

13 from typing import overload

14 from typing import Set

15 from typing import Tuple

16 from typing import Type

17 from typing import TYPE_CHECKING

18 from typing import TypeVar

19 from typing import Union

20

21 import _pytest._code

22 from _pytest._code import getfslineno

23 from _pytest._code.code import ExceptionInfo

24 from _pytest._code.code import TerminalRepr

25 from _pytest.compat import cached_property

26 from _pytest.compat import LEGACY_PATH

27 from _pytest.config import Config

28 from _pytest.config import ConftestImportFailure

29 from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH

30 from _pytest.deprecated import NODE_CTOR_FSPATH_ARG

31 from _pytest.mark.structures import Mark

32 from _pytest.mark.structures import MarkDecorator

33 from _pytest.mark.structures import NodeKeywords

34 from _pytest.outcomes import fail

35 from _pytest.pathlib import absolutepath

36 from _pytest.pathlib import commonpath

37 from _pytest.stash import Stash

38 from _pytest.warning_types import PytestWarning

39

40 if TYPE_CHECKING:

41 # Imported here due to circular import.

42 from _pytest.main import Session

43 from _pytest._code.code import _TracebackStyle

44

45

46 SEP = "/"

47

48 tracebackcutdir = Path(_pytest.__file__).parent

49

50

51 def iterparentnodeids(nodeid: str) -> Iterator[str]:

52 """Return the parent node IDs of a given node ID, inclusive.

53

54 For the node ID

55

56 "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"

57

58 the result would be

59

60 ""

61 "testing"

62 "testing/code"

63 "testing/code/test_excinfo.py"

64 "testing/code/test_excinfo.py::TestFormattedExcinfo"

65 "testing/code/test_excinfo.py::TestFormattedExcinfo::test_repr_source"

66

67 Note that / components are only considered until the first ::.

68 """

69 pos = 0

70 first_colons: Optional[int] = nodeid.find("::")

71 if first_colons == -1:

72 first_colons = None

73 # The root Session node - always present.

74 yield ""

75 # Eagerly consume SEP parts until first colons.

76 while True:

77 at = nodeid.find(SEP, pos, first_colons)

78 if at == -1:

79 break

80 if at > 0:

81 yield nodeid[:at]

82 pos = at + len(SEP)

83 # Eagerly consume :: parts.

84 while True:

85 at = nodeid.find("::", pos)

86 if at == -1:

87 break

88 if at > 0:

89 yield nodeid[:at]

90 pos = at + len("::")

91 # The node ID itself.

92 if nodeid:

93 yield nodeid

94

95

96 def _check_path(path: Path, fspath: LEGACY_PATH) -> None:

97 if Path(fspath) != path:

98 raise ValueError(

99 f"Path({fspath!r}) != {path!r}\n"

100 "if both path and fspath are given they need to be equal"

101 )

102

103

104 def _imply_path(

105 node_type: Type["Node"],

106 path: Optional[Path],

107 fspath: Optional[LEGACY_PATH],

108) -> Path:

109 if fspath is not None:

110 warnings.warn(

111 NODE_CTOR_FSPATH_ARG.format(

112 node_type_name=node_type.__name__,

113 ),

114 stacklevel=6,

115 )

116 if path is not None:

117 if fspath is not None:

118 _check_path(path, fspath)

119 return path

120 else:

121 assert fspath is not None

122 return Path(fspath)

123

124

125_NodeType = TypeVar("_NodeType", bound="Node")

126

127

128 class NodeMeta(type):

129 def __call__(self, *k, **kw):

130 msg = (

131 "Direct construction of {name} has been deprecated, please use {name}.from_parent.\n"

132 "See "

133 "https://docs.pytest.org/en/stable/deprecations.html#node-construction-changed-to-node-from-parent"

134 " for more details."

135 ).format(name=f"{self.__module__}.{self.__name__}")

136 fail(msg, pytrace=False)

137

138 def _create(self, *k, **kw):

139 try:

140 return super().__call__(*k, **kw)

141 except TypeError:

142 sig = signature(getattr(self, "__init__"))

143 known_kw = {k: v for k, v in kw.items() if k in sig.parameters}

144 from .warning_types import PytestDeprecationWarning

145

146 warnings.warn(

147 PytestDeprecationWarning(

148 f"{self} is not using a cooperative constructor and only takes {set(known_kw)}.\n"

149 "See https://docs.pytest.org/en/stable/deprecations.html"

150 "#constructors-of-custom-pytest-node-subclasses-should-take-kwargs "

151 "for more details."

152 )

153 )

154

155 return super().__call__(*k, **known_kw)

156

157

158 class Node(metaclass=NodeMeta):

159 """Base class for Collector and Item, the components of the test

160 collection tree.

161

162 Collector subclasses have children; Items are leaf nodes.

163 """

164

165 # Implemented in the legacypath plugin.

166 #: A ``LEGACY_PATH`` copy of the :attr:`path` attribute. Intended for usage

167 #: for methods not migrated to ``pathlib.Path`` yet, such as

168 #: :meth:`Item.reportinfo`. Will be deprecated in a future release, prefer

169 #: using :attr:`path` instead.

170 fspath: LEGACY_PATH

171

172 # Use __slots__ to make attribute access faster.

173 # Note that __dict__ is still available.

174 __slots__ = (

175 "name",

176 "parent",

177 "config",

178 "session",

179 "path",

180 "_nodeid",

181 "_store",

182 "__dict__",

183 )

184

185 def __init__(

186 self,

187 name: str,

188 parent: "Optional[Node]" = None,

189 config: Optional[Config] = None,

190 session: "Optional[Session]" = None,

191 fspath: Optional[LEGACY_PATH] = None,

192 path: Optional[Path] = None,

193 nodeid: Optional[str] = None,

194 ) -> None:

195 #: A unique name within the scope of the parent node.

196 self.name: str = name

197

198 #: The parent collector node.

199 self.parent = parent

200

201 if config:

202 #: The pytest config object.

203 self.config: Config = config

204 else:

205 if not parent:

206 raise TypeError("config or parent must be provided")

207 self.config = parent.config

208

209 if session:

210 #: The pytest session this node is part of.

211 self.session: Session = session

212 else:

213 if not parent:

214 raise TypeError("session or parent must be provided")

215 self.session = parent.session

216

217 if path is None and fspath is None:

218 path = getattr(parent, "path", None)

219 #: Filesystem path where this node was collected from (can be None).

220 self.path: Path = _imply_path(type(self), path, fspath=fspath)

221

222 # The explicit annotation is to avoid publicly exposing NodeKeywords.

223 #: Keywords/markers collected from all scopes.

224 self.keywords: MutableMapping[str, Any] = NodeKeywords(self)

225

226 #: The marker objects belonging to this node.

227 self.own_markers: List[Mark] = []

228

229 #: Allow adding of extra keywords to use for matching.

230 self.extra_keyword_matches: Set[str] = set()

231

232 if nodeid is not None:

233 assert "::()" not in nodeid

234 self._nodeid = nodeid

235 else:

236 if not self.parent:

237 raise TypeError("nodeid or parent must be provided")

238 self._nodeid = self.parent.nodeid + "::" + self.name

239

240 #: A place where plugins can store information on the node for their

241 #: own use.

242 self.stash: Stash = Stash()

243 # Deprecated alias. Was never public. Can be removed in a few releases.

244 self._store = self.stash

245

246 @classmethod

247 def from_parent(cls, parent: "Node", **kw):

248 """Public constructor for Nodes.

249

250 This indirection got introduced in order to enable removing

251 the fragile logic from the node constructors.

252

253 Subclasses can use ``super().from_parent(...)`` when overriding the

254 construction.

255

256 :param parent: The parent node of this Node.

257 """

258 if "config" in kw:

259 raise TypeError("config is not a valid argument for from_parent")

260 if "session" in kw:

261 raise TypeError("session is not a valid argument for from_parent")

262 return cls._create(parent=parent, **kw)

263

264 @property

265 def ihook(self):

266 """fspath-sensitive hook proxy used to call pytest hooks."""

267 return self.session.gethookproxy(self.path)

268

269 def __repr__(self) -> str:

270 return "<{} {}>".format(self.__class__.__name__, getattr(self, "name", None))

271

272 def warn(self, warning: Warning) -> None:

273 """Issue a warning for this Node.

274

275 Warnings will be displayed after the test session, unless explicitly suppressed.

276

277 :param Warning warning:

278 The warning instance to issue.

279

280 :raises ValueError: If ``warning`` instance is not a subclass of Warning.

281

282 Example usage:

283

284 .. code-block:: python

285

286 node.warn(PytestWarning("some message"))

287 node.warn(UserWarning("some message"))

288

289 .. versionchanged:: 6.2

290 Any subclass of :class:`Warning` is now accepted, rather than only

291 :class:`PytestWarning <pytest.PytestWarning>` subclasses.

292 """

293 # enforce type checks here to avoid getting a generic type error later otherwise.

294 if not isinstance(warning, Warning):

295 raise ValueError(

296 "warning must be an instance of Warning or subclass, got {!r}".format(

297 warning

298 )

299 )

300 path, lineno = get_fslocation_from_item(self)

301 assert lineno is not None

302 warnings.warn_explicit(

303 warning,

304 category=None,

305 filename=str(path),

306 lineno=lineno + 1,

307 )

308

309 # Methods for ordering nodes.

310

311 @property

312 def nodeid(self) -> str:

313 """A ::-separated string denoting its collection tree address."""

314 return self._nodeid

315

316 def __hash__(self) -> int:

317 return hash(self._nodeid)

318

319 def setup(self) -> None:

320 pass

321

322 def teardown(self) -> None:

323 pass

324

325 def listchain(self) -> List["Node"]:

326 """Return list of all parent collectors up to self, starting from

327 the root of collection tree.

328

329 :returns: The nodes.

330 """

331 chain = []

332 item: Optional[Node] = self

333 while item is not None:

334 chain.append(item)

335 item = item.parent

336 chain.reverse()

337 return chain

338

339 def add_marker(

340 self, marker: Union[str, MarkDecorator], append: bool = True

341 ) -> None:

342 """Dynamically add a marker object to the node.

343

344 :param marker:

345 The marker.

346 :param append:

347 Whether to append the marker, or prepend it.

348 """

349 from _pytest.mark import MARK_GEN

350

351 if isinstance(marker, MarkDecorator):

352 marker_ = marker

353 elif isinstance(marker, str):

354 marker_ = getattr(MARK_GEN, marker)

355 else:

356 raise ValueError("is not a string or pytest.mark.* Marker")

357 self.keywords[marker_.name] = marker_

358 if append:

359 self.own_markers.append(marker_.mark)

360 else:

361 self.own_markers.insert(0, marker_.mark)

362

363 def iter_markers(self, name: Optional[str] = None) -> Iterator[Mark]:

364 """Iterate over all markers of the node.

365

366 :param name: If given, filter the results by the name attribute.

367 :returns: An iterator of the markers of the node.

368 """

369 return (x[1] for x in self.iter_markers_with_node(name=name))

370

371 def iter_markers_with_node(

372 self, name: Optional[str] = None

373 ) -> Iterator[Tuple["Node", Mark]]:

374 """Iterate over all markers of the node.

375

376 :param name: If given, filter the results by the name attribute.

377 :returns: An iterator of (node, mark) tuples.

378 """

379 for node in reversed(self.listchain()):

380 for mark in node.own_markers:

381 if name is None or getattr(mark, "name", None) == name:

382 yield node, mark

383

384 @overload

385 def get_closest_marker(self, name: str) -> Optional[Mark]:

386 ...

387

388 @overload

389 def get_closest_marker(self, name: str, default: Mark) -> Mark:

390 ...

391

392 def get_closest_marker(

393 self, name: str, default: Optional[Mark] = None

394 ) -> Optional[Mark]:

395 """Return the first marker matching the name, from closest (for

396 example function) to farther level (for example module level).

397

398 :param default: Fallback return value if no marker was found.

399 :param name: Name to filter by.

400 """

401 return next(self.iter_markers(name=name), default)

402

403 def listextrakeywords(self) -> Set[str]:

404 """Return a set of all extra keywords in self and any parents."""

405 extra_keywords: Set[str] = set()

406 for item in self.listchain():

407 extra_keywords.update(item.extra_keyword_matches)

408 return extra_keywords

409

410 def listnames(self) -> List[str]:

411 return [x.name for x in self.listchain()]

412

413 def addfinalizer(self, fin: Callable[[], object]) -> None:

414 """Register a function to be called without arguments when this node is

415 finalized.

416

417 This method can only be called when this node is active

418 in a setup chain, for example during self.setup().

419 """

420 self.session._setupstate.addfinalizer(fin, self)

421

422 def getparent(self, cls: Type[_NodeType]) -> Optional[_NodeType]:

423 """Get the next parent node (including self) which is an instance of

424 the given class.

425

426 :param cls: The node class to search for.

427 :returns: The node, if found.

428 """

429 current: Optional[Node] = self

430 while current and not isinstance(current, cls):

431 current = current.parent

432 assert current is None or isinstance(current, cls)

433 return current

434

435 def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None:

436 pass

437

438 def _repr_failure_py(

439 self,

440 excinfo: ExceptionInfo[BaseException],

441 style: "Optional[_TracebackStyle]" = None,

442 ) -> TerminalRepr:

443 from _pytest.fixtures import FixtureLookupError

444

445 if isinstance(excinfo.value, ConftestImportFailure):

446 excinfo = ExceptionInfo.from_exc_info(excinfo.value.excinfo)

447 if isinstance(excinfo.value, fail.Exception):

448 if not excinfo.value.pytrace:

449 style = "value"

450 if isinstance(excinfo.value, FixtureLookupError):

451 return excinfo.value.formatrepr()

452 if self.config.getoption("fulltrace", False):

453 style = "long"

454 else:

455 tb = _pytest._code.Traceback([excinfo.traceback[-1]])

456 self._prunetraceback(excinfo)

457 if len(excinfo.traceback) == 0:

458 excinfo.traceback = tb

459 if style == "auto":

460 style = "long"

461 # XXX should excinfo.getrepr record all data and toterminal() process it?

462 if style is None:

463 if self.config.getoption("tbstyle", "auto") == "short":

464 style = "short"

465 else:

466 style = "long"

467

468 if self.config.getoption("verbose", 0) > 1:

469 truncate_locals = False

470 else:

471 truncate_locals = True

472

473 # excinfo.getrepr() formats paths relative to the CWD if `abspath` is False.

474 # It is possible for a fixture/test to change the CWD while this code runs, which

475 # would then result in the user seeing confusing paths in the failure message.

476 # To fix this, if the CWD changed, always display the full absolute path.

477 # It will be better to just always display paths relative to invocation_dir, but

478 # this requires a lot of plumbing (#6428).

479 try:

480 abspath = Path(os.getcwd()) != self.config.invocation_params.dir

481 except OSError:

482 abspath = True

483

484 return excinfo.getrepr(

485 funcargs=True,

486 abspath=abspath,

487 showlocals=self.config.getoption("showlocals", False),

488 style=style,

489 tbfilter=False, # pruned already, or in --fulltrace mode.

490 truncate_locals=truncate_locals,

491 )

492

493 def repr_failure(

494 self,

495 excinfo: ExceptionInfo[BaseException],

496 style: "Optional[_TracebackStyle]" = None,

497 ) -> Union[str, TerminalRepr]:

498 """Return a representation of a collection or test failure.

499

500 .. seealso:: :ref:`non-python tests`

501

502 :param excinfo: Exception information for the failure.

503 """

504 return self._repr_failure_py(excinfo, style)

505

506

507 def get_fslocation_from_item(node: "Node") -> Tuple[Union[str, Path], Optional[int]]:

508 """Try to extract the actual location from a node, depending on available attributes:

509

510 * "location": a pair (path, lineno)

511 * "obj": a Python object that the node wraps.

512 * "fspath": just a path

513

514 :rtype: A tuple of (str|Path, int) with filename and line number.

515 """

516 # See Item.location.

517 location: Optional[Tuple[str, Optional[int], str]] = getattr(node, "location", None)

518 if location is not None:

519 return location[:2]

520 obj = getattr(node, "obj", None)

521 if obj is not None:

522 return getfslineno(obj)

523 return getattr(node, "fspath", "unknown location"), -1

524

525

526 class Collector(Node):

527 """Collector instances create children through collect() and thus

528 iteratively build a tree."""

529

530 class CollectError(Exception):

531 """An error during collection, contains a custom message."""

532

533 def collect(self) -> Iterable[Union["Item", "Collector"]]:

534 """Return a list of children (items and collectors) for this

535 collection node."""

536 raise NotImplementedError("abstract")

537

538 # TODO: This omits the style= parameter which breaks Liskov Substitution.

539 def repr_failure( # type: ignore[override]

540 self, excinfo: ExceptionInfo[BaseException]

541 ) -> Union[str, TerminalRepr]:

542 """Return a representation of a collection failure.

543

544 :param excinfo: Exception information for the failure.

545 """

546 if isinstance(excinfo.value, self.CollectError) and not self.config.getoption(

547 "fulltrace", False

548 ):

549 exc = excinfo.value

550 return str(exc.args[0])

551

552 # Respect explicit tbstyle option, but default to "short"

553 # (_repr_failure_py uses "long" with "fulltrace" option always).

554 tbstyle = self.config.getoption("tbstyle", "auto")

555 if tbstyle == "auto":

556 tbstyle = "short"

557

558 return self._repr_failure_py(excinfo, style=tbstyle)

559

560 def _prunetraceback(self, excinfo: ExceptionInfo[BaseException]) -> None:

561 if hasattr(self, "path"):

562 traceback = excinfo.traceback

563 ntraceback = traceback.cut(path=self.path)

564 if ntraceback == traceback:

565 ntraceback = ntraceback.cut(excludepath=tracebackcutdir)

566 excinfo.traceback = ntraceback.filter()

567

568

569 def _check_initialpaths_for_relpath(session: "Session", path: Path) -> Optional[str]:

570 for initial_path in session._initialpaths:

571 if commonpath(path, initial_path) == initial_path:

572 rel = str(path.relative_to(initial_path))

573 return "" if rel == "." else rel

574 return None

575

576

577 class FSCollector(Collector):

578 def __init__(

579 self,

580 fspath: Optional[LEGACY_PATH] = None,

581 path_or_parent: Optional[Union[Path, Node]] = None,

582 path: Optional[Path] = None,

583 name: Optional[str] = None,

584 parent: Optional[Node] = None,

585 config: Optional[Config] = None,

586 session: Optional["Session"] = None,

587 nodeid: Optional[str] = None,

588 ) -> None:

589 if path_or_parent:

590 if isinstance(path_or_parent, Node):

591 assert parent is None

592 parent = cast(FSCollector, path_or_parent)

593 elif isinstance(path_or_parent, Path):

594 assert path is None

595 path = path_or_parent

596

597 path = _imply_path(type(self), path, fspath=fspath)

598 if name is None:

599 name = path.name

600 if parent is not None and parent.path != path:

601 try:

602 rel = path.relative_to(parent.path)

603 except ValueError:

604 pass

605 else:

606 name = str(rel)

607 name = name.replace(os.sep, SEP)

608 self.path = path

609

610 if session is None:

611 assert parent is not None

612 session = parent.session

613

614 if nodeid is None:

615 try:

616 nodeid = str(self.path.relative_to(session.config.rootpath))

617 except ValueError:

618 nodeid = _check_initialpaths_for_relpath(session, path)

619

620 if nodeid and os.sep != SEP:

621 nodeid = nodeid.replace(os.sep, SEP)

622

623 super().__init__(

624 name=name,

625 parent=parent,

626 config=config,

627 session=session,

628 nodeid=nodeid,

629 path=path,

630 )

631

632 @classmethod

633 def from_parent(

634 cls,

635 parent,

636 *,

637 fspath: Optional[LEGACY_PATH] = None,

638 path: Optional[Path] = None,

639 **kw,

640 ):

641 """The public constructor."""

642 return super().from_parent(parent=parent, fspath=fspath, path=path, **kw)

643

644 def gethookproxy(self, fspath: "os.PathLike[str]"):

645 warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)

646 return self.session.gethookproxy(fspath)

647

648 def isinitpath(self, path: Union[str, "os.PathLike[str]"]) -> bool:

649 warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)

650 return self.session.isinitpath(path)

651

652

653 class File(FSCollector):

654 """Base class for collecting tests from a file.

655

656 :ref:`non-python tests`.

657 """

658

659

660 class Item(Node):

661 """A basic test invocation item.

662

663 Note that for a single function there might be multiple test invocation items.

664 """

665

666 nextitem = None

667

668 def __init__(

669 self,

670 name,

671 parent=None,

672 config: Optional[Config] = None,

673 session: Optional["Session"] = None,

674 nodeid: Optional[str] = None,

675 **kw,

676 ) -> None:

677 # The first two arguments are intentionally passed positionally,

678 # to keep plugins who define a node type which inherits from

679 # (pytest.Item, pytest.File) working (see issue #8435).

680 # They can be made kwargs when the deprecation above is done.

681 super().__init__(

682 name,

683 parent,

684 config=config,

685 session=session,

686 nodeid=nodeid,

687 **kw,

688 )

689 self._report_sections: List[Tuple[str, str, str]] = []

690

691 #: A list of tuples (name, value) that holds user defined properties

692 #: for this test.

693 self.user_properties: List[Tuple[str, object]] = []

694

695 self._check_item_and_collector_diamond_inheritance()

696

697 def _check_item_and_collector_diamond_inheritance(self) -> None:

698 """

699 Check if the current type inherits from both File and Collector

700 at the same time, emitting a warning accordingly (#8447).

701 """

702 cls = type(self)

703

704 # We inject an attribute in the type to avoid issuing this warning

705 # for the same class more than once, which is not helpful.

706 # It is a hack, but was deemed acceptable in order to avoid

707 # flooding the user in the common case.

708 attr_name = "_pytest_diamond_inheritance_warning_shown"

709 if getattr(cls, attr_name, False):

710 return

711 setattr(cls, attr_name, True)

712

713 problems = ", ".join(

714 base.__name__ for base in cls.__bases__ if issubclass(base, Collector)

715 )

716 if problems:

717 warnings.warn(

718 f"{cls.__name__} is an Item subclass and should not be a collector, "

719 f"however its bases {problems} are collectors.\n"

720 "Please split the Collectors and the Item into separate node types.\n"

721 "Pytest Doc example: https://docs.pytest.org/en/latest/example/nonpython.html\n"

722 "example pull request on a plugin: https://github.com/asmeurer/pytest-flakes/pull/40/",

723 PytestWarning,

724 )

725

726 def runtest(self) -> None:

727 """Run the test case for this item.

728

729 Must be implemented by subclasses.

730

731 .. seealso:: :ref:`non-python tests`

732 """

733 raise NotImplementedError("runtest must be implemented by Item subclass")

734

735 def add_report_section(self, when: str, key: str, content: str) -> None:

736 """Add a new report section, similar to what's done internally to add

737 stdout and stderr captured output::

738

739 item.add_report_section("call", "stdout", "report section contents")

740

741 :param str when:

742 One of the possible capture states, ``"setup"``, ``"call"``, ``"teardown"``.

743 :param str key:

744 Name of the section, can be customized at will. Pytest uses ``"stdout"`` and

745 ``"stderr"`` internally.

746 :param str content:

747 The full contents as a string.

748 """

749 if content:

750 self._report_sections.append((when, key, content))

751

752 def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:

753 """Get location information for this item for test reports.

754

755 Returns a tuple with three elements:

756

757 - The path of the test (default ``self.path``)

758 - The line number of the test (default ``None``)

759 - A name of the test to be shown (default ``""``)

760

761 .. seealso:: :ref:`non-python tests`

762 """

763 return self.path, None, ""

764

765 @cached_property

766 def location(self) -> Tuple[str, Optional[int], str]:

767 location = self.reportinfo()

768 path = absolutepath(os.fspath(location[0]))

769 relfspath = self.session._node_location_to_relpath(path)

770 assert type(location[2]) is str

771 return (relfspath, location[1], location[2])