Skip to content

migrate node fs paths to pathlib primary and reorg ctors/from_parent #8037

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/8037.deprecation.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Deprecate implying node ctor arguments.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if I read this sentence as a user I would not be able to grok what it's about, so some more details would be good here, like what exactly is deprecated, and how to fix.

Also we usually add a corresponding note in the docs/deprecations.rst file (can just be a copy/paste of the changelog entry mostly).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. 👍

1 change: 1 addition & 0 deletions changelog/8037.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Introduce ``Node.fs_path`` as pathlib replacement for ``Node.fspath``
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Introduce ``Node.fs_path`` as pathlib replacement for ``Node.fspath``
Introduce ``Node.fs_path`` as pathlib replacement for ``Node.fspath``.

Also I would add another paragraph:

This is part of our continuous effort of eventually phasing out pytest's dependency to `py <https://py.readthedocs.io/en/latest/>`__.

6 changes: 6 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@

PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.")

NODE_IMPLIED_ARG = UnformattedWarning(
PytestDeprecationWarning,
"implying {type.__name__}.{arg} from parent.{arg} has been deprecated\n"
"please use the Node.from_parent to have them be implied by the superclass named ctors",
Comment on lines +69 to +70
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"implying {type.__name__}.{arg} from parent.{arg} has been deprecated\n"
"please use the Node.from_parent to have them be implied by the superclass named ctors",
"Implying {type.__name__}.{arg} from parent.{arg} has been deprecated.\n"
"Please use Node.from_parent to have them be implied by the superclass named constructors.",

)


# You want to make some `__init__` or function "private".
#
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,9 @@ def __init__(
parent: "Union[DoctestTextfile, DoctestModule]",
runner: Optional["doctest.DocTestRunner"] = None,
dtest: Optional["doctest.DocTest"] = None,
**kw: Any,
) -> None:
super().__init__(name, parent)
super().__init__(name=name, parent=parent, **kw)
self.runner = runner
self.dtest = dtest
self.obj = None
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ def _compute_fixture_value(self, fixturedef: "FixtureDef[object]") -> None:
"\n\nRequested here:\n{}:{}".format(
funcitem.nodeid,
fixturedef.argname,
getlocation(fixturedef.func, funcitem.config.rootdir),
getlocation(fixturedef.func, str(funcitem.config.rootpath)),
source_path_str,
source_lineno,
)
Expand Down
19 changes: 12 additions & 7 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,12 @@ class Session(nodes.FSCollector):

def __init__(self, config: Config) -> None:
super().__init__(
config.rootdir, parent=None, config=config, session=self, nodeid=""
fs_path=config.rootpath,
name="",
parent=None,
config=config,
session=self,
nodeid="",
)
self.testsfailed = 0
self.testscollected = 0
Expand Down Expand Up @@ -688,7 +693,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
if col:
if isinstance(col[0], Package):
pkg_roots[str(parent)] = col[0]
node_cache1[Path(col[0].fspath)] = [col[0]]
node_cache1[col[0].fs_path] = [col[0]]

# If it's a directory argument, recurse and look for any Subpackages.
# Let the Package collector deal with subnodes, don't collect here.
Expand Down Expand Up @@ -717,7 +722,7 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
continue

for x in self._collectfile(path):
key2 = (type(x), Path(x.fspath))
key2 = (type(x), x.fs_path)
if key2 in node_cache2:
yield node_cache2[key2]
else:
Expand Down Expand Up @@ -749,12 +754,12 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
continue
if not isinstance(node, nodes.Collector):
continue
key = (type(node), node.nodeid)
if key in matchnodes_cache:
rep = matchnodes_cache[key]
key_matchnodes = (type(node), node.nodeid)
if key_matchnodes in matchnodes_cache:
rep = matchnodes_cache[key_matchnodes]
else:
rep = collect_one_node(node)
matchnodes_cache[key] = rep
matchnodes_cache[key_matchnodes] = rep
if rep.passed:
submatchnodes = []
for r in rep.result:
Expand Down
185 changes: 132 additions & 53 deletions src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from _pytest.config import Config
from _pytest.config import ConftestImportFailure
from _pytest.deprecated import FSCOLLECTOR_GETHOOKPROXY_ISINITPATH
from _pytest.deprecated import NODE_IMPLIED_ARG
from _pytest.mark.structures import Mark
from _pytest.mark.structures import MarkDecorator
from _pytest.mark.structures import NodeKeywords
Expand Down Expand Up @@ -110,45 +111,67 @@ class Node(metaclass=NodeMeta):
"parent",
"config",
"session",
"fspath",
"fs_path",
"_nodeid",
"_store",
"__dict__",
)

name: str
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is mypy not able to figure these out on its own?

config: Config
session: "Session"
fs_path: Path
_nodeid: str

def __init__(
self,
*,
name: str,
parent: "Optional[Node]" = None,
parent: Optional["Node"],
config: Optional[Config] = None,
session: "Optional[Session]" = None,
fspath: Optional[py.path.local] = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By dropping fspath (the py.path.local variant) aren't we breaking the API? If so I think a better option would be to still support fspath, converting it internally to fs_path and issue a deprecation warning.

session: Optional["Session"] = None,
fs_path: Optional[Path] = None,
nodeid: Optional[str] = None,
) -> None:
#: A unique name within the scope of the parent node.
self.name = name

if nodeid is not None:
assert "::()" not in nodeid
else:
assert parent is not None
nodeid = parent.nodeid
if name != "()":
nodeid = f"{nodeid}::{name}"
warnings.warn(NODE_IMPLIED_ARG.format(type=type(self), arg="nodeid"))
self._nodeid = nodeid
#: The parent collector node.
self.parent = parent

#: The pytest config object.
if config:
self.config: Config = config
else:
if not parent:
raise TypeError("config or parent must be provided")
if config is None:
assert parent is not None
self.config = parent.config
warnings.warn(NODE_IMPLIED_ARG.format(type=type(self), arg="config"))
else:
self.config = config

#: The pytest session this node is part of.
if session:
self.session = session
else:
if not parent:
raise TypeError("session or parent must be provided")
if session is None:
assert parent is not None
self.session = parent.session

warnings.warn(NODE_IMPLIED_ARG.format(type=type(self), arg="session"))
else:
self.session = session

#: Filesystem path where this node was collected from (can be None).
self.fspath = fspath or getattr(parent, "fspath", None)
if fs_path is None:
assert parent is not None
self.fs_path = parent.fs_path
warnings.warn(NODE_IMPLIED_ARG.format(type=type(self), arg="fs_path"))
else:
self.fs_path = fs_path

# The explicit annotation is to avoid publicly exposing NodeKeywords.
#: Keywords/markers collected from all scopes.
Expand All @@ -160,22 +183,29 @@ def __init__(
#: Allow adding of extra keywords to use for matching.
self.extra_keyword_matches: Set[str] = set()

if nodeid is not None:
assert "::()" not in nodeid
self._nodeid = nodeid
else:
if not self.parent:
raise TypeError("nodeid or parent must be provided")
self._nodeid = self.parent.nodeid
if self.name != "()":
self._nodeid += "::" + self.name

# A place where plugins can store information on the node for their
# own use. Currently only intended for internal plugins.
self._store = Store()

@property
def fspath(self) -> py.path.local:
"""Filesystem path where this node was collected from (can be None).

Since pytest 6.2, prefer to use `fs_path` instead which returns a `pathlib.Path`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Since pytest 6.2, prefer to use `fs_path` instead which returns a `pathlib.Path`
Since pytest 6.2, prefer to use `fs_path` instead which returns a :class:`pathlib.Path`

instead of a `py.path.local`.
"""
return py.path.local(self.fs_path)

@classmethod
def from_parent(cls, parent: "Node", **kw):
def from_parent(
cls,
parent: "Node",
*,
name: str,
fs_path: Optional[Path] = None,
nodeid: Optional[str] = None,
**kw: Any,
):
"""Public constructor for Nodes.

This indirection got introduced in order to enable removing
Expand All @@ -190,7 +220,26 @@ def from_parent(cls, parent: "Node", **kw):
raise TypeError("config is not a valid argument for from_parent")
if "session" in kw:
raise TypeError("session is not a valid argument for from_parent")
return cls._create(parent=parent, **kw)
if nodeid is not None:
assert "::()" not in nodeid
else:
nodeid = parent.nodeid
if name != "()":
nodeid = f"{nodeid}::{name}"

config = parent.config
session = parent.session
if fs_path is None:
fs_path = parent.fs_path
return cls._create(
parent=parent,
config=config,
session=session,
nodeid=nodeid,
name=name,
fs_path=fs_path,
**kw,
)

@property
def ihook(self):
Expand Down Expand Up @@ -495,38 +544,60 @@ def _check_initialpaths_for_relpath(


class FSCollector(Collector):
def __init__(
self,
fspath: py.path.local,
parent=None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
def __init__(self, **kw):

fs_path: Optional[Path] = kw.pop("fs_path", None)
fspath: Optional[py.path.local] = kw.pop("fspath", None)

if fspath is not None:
assert fs_path is None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert fs_path is None
assert fs_path is None, "Specify either fspath or fs_path, not both"

fs_path = Path(fspath)
kw["fs_path"] = fs_path
super().__init__(**kw)

@classmethod
def from_parent(
cls,
parent: Node,
*,
fspath: Optional[py.path.local] = None,
fs_path: Optional[Path] = None,
nodeid: Optional[str] = None,
) -> None:
name = fspath.basename
if parent is not None:
name: Optional[str] = None,
**kw,
):
"""The public constructor."""
if fspath is not None:
assert (
fs_path is None
), "fs_path and fspath cannot both be passed at the same time"
known_path = Path(fspath)
else:
assert fs_path is not None, "fs_path or fspath must be given"
known_path = fs_path
fspath = py.path.local(known_path)

if name is None:
name = known_path.name
rel = fspath.relto(parent.fspath)
if rel:
name = rel
name = name.replace(os.sep, SEP)
self.fspath = fspath

session = session or parent.session
name = str(rel)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will end up with a name containing \ on Windows because we are no longer doing name = name.replace(os.sep, SEP). Is that intentional?

Also str seems redudant:

>>> local("src/pytest").relto(local("."))
'src\\pytest'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relto is from py.path.local - relative pathlib paths are relative

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh I see, thanks, but does the comment about name.replace still apply?

>>> local("src/pytest").relto(local("."))
'src\\pytest'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh OK so this needs fixing then. 👍


if nodeid is None:
nodeid = self.fspath.relto(session.config.rootdir)
assert parent is not None
session = parent.session
relp = session._bestrelpathcache[known_path]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

if not relp.startswith(".." + os.sep):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we have a comment here explaining the purpose of this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gentle ping

nodeid = str(relp)

if not nodeid:
nodeid = _check_initialpaths_for_relpath(session, fspath)
if nodeid and os.sep != SEP:
nodeid = nodeid.replace(os.sep, SEP)

super().__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath)

@classmethod
def from_parent(cls, parent, *, fspath, **kw):
"""The public constructor."""
return super().from_parent(parent=parent, fspath=fspath, **kw)
return super().from_parent(
parent=parent, name=name, fs_path=known_path, nodeid=nodeid, **kw
)

def gethookproxy(self, fspath: "os.PathLike[str]"):
warnings.warn(FSCOLLECTOR_GETHOOKPROXY_ISINITPATH, stacklevel=2)
Expand Down Expand Up @@ -555,12 +626,20 @@ class Item(Node):
def __init__(
self,
name,
parent=None,
config: Optional[Config] = None,
session: Optional["Session"] = None,
nodeid: Optional[str] = None,
parent: Node,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one i missed while restoring ctor based implication

config: Config,
session: "Session",
nodeid: str,
fs_path: Path,
) -> None:
super().__init__(name, parent, config, session, nodeid=nodeid)
super().__init__(
name=name,
parent=parent,
config=config,
session=session,
nodeid=nodeid,
fs_path=fs_path,
)
self._report_sections: List[Tuple[str, str, str]] = []

#: A list of tuples (name, value) that holds user defined properties
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/pytester.py
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,7 @@ def copy_example(self, name: Optional[str] = None) -> Path:
example_dir = self._request.config.getini("pytester_example_dir")
if example_dir is None:
raise ValueError("pytester_example_dir is unset, can't copy examples")
example_dir = Path(str(self._request.config.rootdir)) / example_dir
example_dir = self._request.config.rootpath / example_dir

for extra_element in self._request.node.iter_markers("pytester_example_path"):
assert extra_element.args
Expand Down
Loading