5
5
import fnmatch
6
6
import functools
7
7
import importlib
8
+ import importlib .util
8
9
import os
9
10
from pathlib import Path
10
11
import sys
@@ -563,7 +564,7 @@ def __init__(self, config: Config) -> None:
563
564
self ._initialpaths : FrozenSet [Path ] = frozenset ()
564
565
self ._initialpaths_with_parents : FrozenSet [Path ] = frozenset ()
565
566
self ._notfound : List [Tuple [str , Sequence [nodes .Collector ]]] = []
566
- self ._initial_parts : List [Tuple [ Path , List [ str ]] ] = []
567
+ self ._initial_parts : List [CollectionArgument ] = []
567
568
self ._collection_cache : Dict [nodes .Collector , CollectReport ] = {}
568
569
self .items : List [nodes .Item ] = []
569
570
@@ -769,15 +770,15 @@ def perform_collect(
769
770
initialpaths : List [Path ] = []
770
771
initialpaths_with_parents : List [Path ] = []
771
772
for arg in args :
772
- fspath , parts = resolve_collection_argument (
773
+ collection_argument = resolve_collection_argument (
773
774
self .config .invocation_params .dir ,
774
775
arg ,
775
776
as_pypath = self .config .option .pyargs ,
776
777
)
777
- self ._initial_parts .append (( fspath , parts ) )
778
- initialpaths .append (fspath )
779
- initialpaths_with_parents .append (fspath )
780
- initialpaths_with_parents .extend (fspath .parents )
778
+ self ._initial_parts .append (collection_argument )
779
+ initialpaths .append (collection_argument . path )
780
+ initialpaths_with_parents .append (collection_argument . path )
781
+ initialpaths_with_parents .extend (collection_argument . path .parents )
781
782
self ._initialpaths = frozenset (initialpaths )
782
783
self ._initialpaths_with_parents = frozenset (initialpaths_with_parents )
783
784
@@ -839,29 +840,43 @@ def collect(self) -> Iterator[Union[nodes.Item, nodes.Collector]]:
839
840
840
841
pm = self .config .pluginmanager
841
842
842
- for argpath , names in self ._initial_parts :
843
- self .trace ("processing argument" , ( argpath , names ) )
843
+ for collection_argument in self ._initial_parts :
844
+ self .trace ("processing argument" , collection_argument )
844
845
self .trace .root .indent += 1
845
846
847
+ argpath = collection_argument .path
848
+ names = collection_argument .parts
849
+ module_name = collection_argument .module_name
850
+
846
851
# resolve_collection_argument() ensures this.
847
852
if argpath .is_dir ():
848
853
assert not names , f"invalid arg { (argpath , names )!r} "
849
854
850
- # Match the argpath from the root, e.g.
855
+ paths = [argpath ]
856
+ # Add relevant parents of the path, from the root, e.g.
851
857
# /a/b/c.py -> [/, /a, /a/b, /a/b/c.py]
852
- paths = [* reversed (argpath .parents ), argpath ]
853
- # Paths outside of the confcutdir should not be considered, unless
854
- # it's the argpath itself.
855
- while len (paths ) > 1 and not pm ._is_in_confcutdir (paths [0 ]):
856
- paths = paths [1 :]
858
+ if module_name is None :
859
+ # Paths outside of the confcutdir should not be considered.
860
+ for path in argpath .parents :
861
+ if not pm ._is_in_confcutdir (path ):
862
+ break
863
+ paths .insert (0 , path )
864
+ else :
865
+ # For --pyargs arguments, only consider paths matching the module
866
+ # name. Paths beyond the package hierarchy are not included.
867
+ module_name_parts = module_name .split ("." )
868
+ for i , path in enumerate (argpath .parents , 2 ):
869
+ if i > len (module_name_parts ) or path .stem != module_name_parts [- i ]:
870
+ break
871
+ paths .insert (0 , path )
857
872
858
873
# Start going over the parts from the root, collecting each level
859
874
# and discarding all nodes which don't match the level's part.
860
875
any_matched_in_initial_part = False
861
876
notfound_collectors = []
862
877
work : List [
863
878
Tuple [Union [nodes .Collector , nodes .Item ], List [Union [Path , str ]]]
864
- ] = [(self , paths + names )]
879
+ ] = [(self , [ * paths , * names ] )]
865
880
while work :
866
881
matchnode , matchparts = work .pop ()
867
882
@@ -953,44 +968,64 @@ def genitems(
953
968
node .ihook .pytest_collectreport (report = rep )
954
969
955
970
956
- def search_pypath (module_name : str ) -> str :
957
- """Search sys.path for the given a dotted module name, and return its file system path."""
971
+ def search_pypath (module_name : str ) -> Optional [str ]:
972
+ """Search sys.path for the given a dotted module name, and return its file
973
+ system path if found."""
958
974
try :
959
975
spec = importlib .util .find_spec (module_name )
960
976
# AttributeError: looks like package module, but actually filename
961
977
# ImportError: module does not exist
962
978
# ValueError: not a module name
963
979
except (AttributeError , ImportError , ValueError ):
964
- return module_name
980
+ return None
965
981
if spec is None or spec .origin is None or spec .origin == "namespace" :
966
- return module_name
982
+ return None
967
983
elif spec .submodule_search_locations :
968
984
return os .path .dirname (spec .origin )
969
985
else :
970
986
return spec .origin
971
987
972
988
989
+ @dataclasses .dataclass (frozen = True )
990
+ class CollectionArgument :
991
+ """A resolved collection argument."""
992
+
993
+ path : Path
994
+ parts : Sequence [str ]
995
+ module_name : Optional [str ]
996
+
997
+
973
998
def resolve_collection_argument (
974
999
invocation_path : Path , arg : str , * , as_pypath : bool = False
975
- ) -> Tuple [ Path , List [ str ]] :
1000
+ ) -> CollectionArgument :
976
1001
"""Parse path arguments optionally containing selection parts and return (fspath, names).
977
1002
978
1003
Command-line arguments can point to files and/or directories, and optionally contain
979
1004
parts for specific tests selection, for example:
980
1005
981
1006
"pkg/tests/test_foo.py::TestClass::test_foo"
982
1007
983
- This function ensures the path exists, and returns a tuple :
1008
+ This function ensures the path exists, and returns a resolved `CollectionArgument` :
984
1009
985
- (Path("/full/path/to/pkg/tests/test_foo.py"), ["TestClass", "test_foo"])
1010
+ CollectionArgument(
1011
+ path=Path("/full/path/to/pkg/tests/test_foo.py"),
1012
+ parts=["TestClass", "test_foo"],
1013
+ module_name=None,
1014
+ )
986
1015
987
1016
When as_pypath is True, expects that the command-line argument actually contains
988
1017
module paths instead of file-system paths:
989
1018
990
1019
"pkg.tests.test_foo::TestClass::test_foo"
991
1020
992
1021
In which case we search sys.path for a matching module, and then return the *path* to the
993
- found module.
1022
+ found module, which may look like this:
1023
+
1024
+ CollectionArgument(
1025
+ path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"),
1026
+ parts=["TestClass", "test_foo"],
1027
+ module_name="pkg.tests.test_foo",
1028
+ )
994
1029
995
1030
If the path doesn't exist, raise UsageError.
996
1031
If the path is a directory and selection parts are present, raise UsageError.
@@ -999,8 +1034,12 @@ def resolve_collection_argument(
999
1034
strpath , * parts = base .split ("::" )
1000
1035
if parts :
1001
1036
parts [- 1 ] = f"{ parts [- 1 ]} { squacket } { rest } "
1037
+ module_name = None
1002
1038
if as_pypath :
1003
- strpath = search_pypath (strpath )
1039
+ pyarg_strpath = search_pypath (strpath )
1040
+ if pyarg_strpath is not None :
1041
+ module_name = strpath
1042
+ strpath = pyarg_strpath
1004
1043
fspath = invocation_path / strpath
1005
1044
fspath = absolutepath (fspath )
1006
1045
if not safe_exists (fspath ):
@@ -1017,4 +1056,8 @@ def resolve_collection_argument(
1017
1056
else "directory argument cannot contain :: selection parts: {arg}"
1018
1057
)
1019
1058
raise UsageError (msg .format (arg = arg ))
1020
- return fspath , parts
1059
+ return CollectionArgument (
1060
+ path = fspath ,
1061
+ parts = parts ,
1062
+ module_name = module_name ,
1063
+ )
0 commit comments