39
39
import argparse
40
40
import concurrent .futures
41
41
import enum
42
+ import fnmatch
42
43
import json
43
44
import math
44
45
import multiprocessing
59
60
import unittest .loader
60
61
from abc import abstractmethod
61
62
from collections import defaultdict
62
- from dataclasses import dataclass , field
63
+ from dataclasses import dataclass
63
64
from functools import lru_cache
64
65
from pathlib import Path
65
66
@@ -294,7 +295,8 @@ def emit(self, **data):
294
295
self .data .append (data )
295
296
296
297
297
- def test_path_to_module (path : Path ):
298
+ def test_path_to_module (test_file : 'TestFileConfig' ):
299
+ path = test_file .path .resolve ().relative_to (test_file .config .rootdir )
298
300
return str (path ).removesuffix ('.py' ).replace (os .sep , '.' )
299
301
300
302
@@ -415,14 +417,14 @@ def generate_tags(self, append=False):
415
417
for result in self .results :
416
418
by_file [result .test_id .test_file ].append (result )
417
419
for test_file , results in by_file .items ():
418
- config = config_for_file (test_file )
419
- tag_file = config .get_tag_file (test_file )
420
+ test_file = configure_test_file (test_file )
421
+ tag_file = test_file .get_tag_file ()
420
422
if not tag_file :
421
423
log (f"WARNNING: no tag directory for test file { test_file } " )
422
424
continue
423
425
tags = {result .test_id .test_name for result in results if result .status == TestStatus .SUCCESS }
424
426
if append :
425
- tags |= {test .test_name for test in read_tags (test_file , config )}
427
+ tags |= {test .test_name for test in read_tags (test_file )}
426
428
with open (tag_file , 'w' ) as f :
427
429
for test_name in sorted (tags ):
428
430
f .write (f'{ test_name } \n ' )
@@ -670,42 +672,63 @@ def filter_tree(test_file: Path, test_suite: unittest.TestSuite, specifiers: lis
670
672
return collected_tests , untagged_tests
671
673
672
674
675
+ class TestFileNameMatcher :
676
+ def __init__ (self , matcher : typing .Iterable [str ] = ()):
677
+ matcher = [name .removesuffix ('.py' ) for name in matcher ]
678
+ globs , exact_matches = partition_list (matcher , lambda x : '*' in x )
679
+ self .exact_matches = frozenset (exact_matches )
680
+ self .globs = [re .compile (fnmatch .translate (glob )) for glob in globs ]
681
+
682
+ def matches (self , name ):
683
+ return name in self .exact_matches or any (glob .match (name ) for glob in self .globs )
684
+
685
+
673
686
@dataclass
674
687
class Config :
675
688
configdir : Path = Path ('.' ).resolve ()
676
689
rootdir : Path = Path ('.' ).resolve ()
677
690
tags_dir : Path | None = None
678
691
run_top_level_functions : bool = False
679
692
new_worker_per_file : bool = False
680
- serial_tests : frozenset [ str ] = frozenset ()
681
- partial_splits_individual_tests : frozenset [ str ] = frozenset ()
682
- excludes : dict [ str , frozenset [ str ]] = field ( default_factory = dict )
693
+ serial_tests : TestFileNameMatcher = TestFileNameMatcher ()
694
+ partial_splits_individual_tests : TestFileNameMatcher = TestFileNameMatcher ()
695
+ excludes : TestFileNameMatcher = TestFileNameMatcher ( )
683
696
684
- def test_file_name (self , test_file ):
685
- resolved = test_file .resolve ().relative_to (self .configdir )
686
- return str (resolved ).removesuffix ('.py' )
687
697
688
- def is_serial_test (self , test_file : Path ):
689
- return self .test_file_name (test_file ) in self .serial_tests
698
+ @dataclass
699
+ class TestFileConfig :
700
+ path : Path
701
+ name : str
702
+ config : Config
690
703
691
- def is_partial_splits_individual_tests (self , test_file : Path ):
692
- return self .test_file_name (test_file ) in self .partial_splits_individual_tests
704
+ excluded : bool
705
+ serial : bool
706
+ partial_splits_individual_tests : bool
693
707
694
- def get_tag_file (self , test_file : Path ):
695
- if self .tags_dir :
696
- return self .tags_dir / (test_file .name .removesuffix ('.py' ) + '.txt' )
708
+ def __str__ (self ):
709
+ return str (self .path )
697
710
698
- def is_excluded (self , test_file : Path ):
699
- exclude_keys = [sys .platform ]
700
- if IS_GRAALPY :
701
- # noinspection PyUnresolvedReferences
702
- exclude_keys .append ('native_image' if __graalpython__ .is_native else 'jvm' )
703
- test_file_name = self .test_file_name (test_file )
704
- for key in exclude_keys :
705
- if excludes := self .excludes .get (key ):
706
- if test_file_name in excludes :
707
- return True
708
- return False
711
+ def __eq__ (self , other ):
712
+ return self .path == other .path
713
+
714
+ def get_tag_file (self ):
715
+ if self .config .tags_dir :
716
+ return self .config .tags_dir / (self .name .removesuffix ('.py' ) + '.txt' )
717
+
718
+
719
+ def configure_test_file (path : Path ) -> TestFileConfig :
720
+ config = config_for_file (path )
721
+ resolved = path .resolve ().relative_to (config .configdir )
722
+ name = str (resolved ).removesuffix ('.py' )
723
+
724
+ return TestFileConfig (
725
+ path = path ,
726
+ name = name ,
727
+ config = config ,
728
+ excluded = config .excludes .matches (name ),
729
+ serial = config .serial_tests .matches (name ),
730
+ partial_splits_individual_tests = config .partial_splits_individual_tests .matches (name ),
731
+ )
709
732
710
733
711
734
@lru_cache
@@ -725,28 +748,30 @@ def config_for_file(test_file: Path) -> Config:
725
748
return config_for_dir (path )
726
749
727
750
728
- def test_file_name_set (test_files : list [str ]):
729
- return frozenset ({test_file .removesuffix ('.py' ) for test_file in test_files })
730
-
731
-
732
751
@lru_cache
733
752
def parse_config (config_path , path ):
734
753
with open (config_path , 'rb' ) as f :
735
754
config_dict = tomllib .load (f )['tests' ]
736
755
tags_dir = None
737
756
if config_tags_dir := config_dict .get ('tags_dir' ):
738
757
tags_dir = (path / config_tags_dir ).resolve ()
758
+ exclude_keys = [sys .platform ]
759
+ if IS_GRAALPY :
760
+ # noinspection PyUnresolvedReferences
761
+ exclude_keys .append ('native_image' if __graalpython__ .is_native else 'jvm' )
762
+ excludes = []
763
+ if excludes_dict := config_dict .get ('excludes' ):
764
+ for key in exclude_keys :
765
+ excludes += excludes_dict .get (key , ())
739
766
return Config (
740
767
configdir = config_path .parent .resolve (),
741
768
rootdir = config_path .parent .parent .resolve (),
742
769
tags_dir = tags_dir ,
743
770
run_top_level_functions = config_dict .get ('run_top_level_functions' , Config .run_top_level_functions ),
744
771
new_worker_per_file = config_dict .get ('new_worker_per_file' , Config .new_worker_per_file ),
745
- serial_tests = test_file_name_set (config_dict .get ('serial_tests' , Config .serial_tests )),
746
- partial_splits_individual_tests = test_file_name_set (
747
- config_dict .get ('partial_splits_individual_tests' , Config .partial_splits_individual_tests )
748
- ),
749
- excludes = {key : test_file_name_set (excludes ) for key , excludes in config_dict .get ('excludes' , {}).items ()},
772
+ serial_tests = TestFileNameMatcher (config_dict .get ('serial_tests' , ())),
773
+ partial_splits_individual_tests = TestFileNameMatcher (config_dict .get ('partial_splits_individual_tests' , ())),
774
+ excludes = TestFileNameMatcher (excludes ),
750
775
)
751
776
752
777
@@ -768,13 +793,6 @@ def run(self, result):
768
793
sys .path [:] = saved_path
769
794
770
795
771
- def group_specifiers_by_file (specifiers : list [TestSpecifier ]) -> dict [Path , list [TestSpecifier ]]:
772
- by_file = defaultdict (list )
773
- for specifier in specifiers :
774
- by_file [specifier .test_file ].append (specifier )
775
- return by_file
776
-
777
-
778
796
def expand_specifier_paths (specifiers : list [TestSpecifier ]) -> list [TestSpecifier ]:
779
797
expanded_specifiers = []
780
798
for specifier in specifiers :
@@ -812,29 +830,30 @@ def expand_specifier_paths(specifiers: list[TestSpecifier]) -> list[TestSpecifie
812
830
return expanded_specifiers
813
831
814
832
815
- def collect_module (test_file : Path , specifiers : list [TestSpecifier ], use_tags = False , partial = None ) -> TestSuite | None :
816
- config = config_for_file (test_file )
833
+ def collect_module (test_file : TestFileConfig , specifiers : list [TestSpecifier ], use_tags = False ,
834
+ partial = None ) -> TestSuite | None :
835
+ config = test_file .config
817
836
saved_path = sys .path [:]
818
837
sys .path .insert (0 , str (config .rootdir ))
819
838
try :
820
839
loader = TopLevelFunctionLoader () if config .run_top_level_functions else unittest .TestLoader ()
821
840
tags = None
822
841
if use_tags and config .tags_dir :
823
- tags = read_tags (test_file , config )
842
+ tags = read_tags (test_file )
824
843
if not tags :
825
844
return None
845
+ test_module = test_path_to_module (test_file )
826
846
try :
827
- test_module = test_path_to_module (test_file .resolve ().relative_to (config .rootdir ))
828
847
test_suite = loader .loadTestsFromName (test_module )
829
848
except unittest .SkipTest as e :
830
849
log (f"Test file { test_file } skipped: { e } " )
831
850
return
832
- collected_tests , untagged_tests = filter_tree (test_file , test_suite , specifiers , tags )
833
- if partial and config . is_partial_splits_individual_tests ( test_file ) :
851
+ collected_tests , untagged_tests = filter_tree (test_file . path , test_suite , specifiers , tags )
852
+ if partial and test_file . partial_splits_individual_tests :
834
853
selected , total = partial
835
854
collected_tests = collected_tests [selected ::total ]
836
855
if collected_tests :
837
- return TestSuite (config , test_file , sys .path [:], test_suite , collected_tests , untagged_tests )
856
+ return TestSuite (config , test_file . path , sys .path [:], test_suite , collected_tests , untagged_tests )
838
857
finally :
839
858
sys .path [:] = saved_path
840
859
@@ -848,34 +867,35 @@ def collect(all_specifiers: list[TestSpecifier], *, use_tags=False, ignore=None,
848
867
continue_on_errors = False , no_excludes = False ) -> list [TestSuite ]:
849
868
to_run = []
850
869
all_specifiers = expand_specifier_paths (all_specifiers )
851
- specifiers_by_file = group_specifiers_by_file (all_specifiers )
870
+ test_files = []
871
+ for specifier in all_specifiers :
872
+ if not specifier .test_file .exists ():
873
+ sys .exit (f"File does not exist: { specifier .test_file } " )
874
+ test_files .append (configure_test_file (specifier .test_file ))
852
875
if ignore :
853
876
ignore = [path_for_comparison (i ) for i in ignore ]
854
- for test_file in set (specifiers_by_file ):
855
- if any (path_for_comparison (test_file ).is_relative_to (i ) for i in ignore ):
856
- del specifiers_by_file [test_file ]
877
+ test_files = [
878
+ test_file for test_file in test_files
879
+ if any (path_for_comparison (test_file .path ).is_relative_to (i ) for i in ignore )
880
+ ]
857
881
if not no_excludes :
858
- for test_file in set (specifiers_by_file ):
859
- config = config_for_file (test_file )
860
- if config .is_excluded (test_file ):
861
- log (f"Test file { test_file } is excluded on this platform/configuration, use --no-excludes to overrride" )
862
- del specifiers_by_file [test_file ]
882
+ excluded , test_files = partition_list (test_files , lambda f : f .excluded )
883
+ for file in excluded :
884
+ log (f"Test file { file } is excluded on this platform/configuration, use --no-excludes to overrride" )
863
885
if partial :
864
886
selected , total = partial
865
887
to_split = []
866
888
partial_files = set ()
867
889
# Always keep files that are split per-test
868
- for test_file in specifiers_by_file :
869
- config = config_for_file (test_file )
870
- if config .is_partial_splits_individual_tests (test_file ):
890
+ for test_file in test_files :
891
+ if test_file .partial_splits_individual_tests :
871
892
partial_files .add (test_file )
872
893
else :
873
894
to_split .append (test_file )
874
895
partial_files |= set (to_split [selected ::total ])
875
- specifiers_by_file = {f : s for f , s in specifiers_by_file .items () if f in partial_files }
876
- for test_file , specifiers in specifiers_by_file .items ():
877
- if not test_file .exists ():
878
- sys .exit (f"File does not exist: { test_file } " )
896
+ test_files = [f for f in test_files if f in partial_files ]
897
+ for test_file in test_files :
898
+ specifiers = [s for s in all_specifiers if s .test_file == test_file .path ]
879
899
try :
880
900
collected = collect_module (test_file , specifiers , use_tags = use_tags , partial = partial )
881
901
except Exception as e :
@@ -888,14 +908,14 @@ def collect(all_specifiers: list[TestSpecifier], *, use_tags=False, ignore=None,
888
908
return to_run
889
909
890
910
891
- def read_tags (test_file : Path , config : Config ) -> list [TestId ]:
892
- tag_file = config .get_tag_file (test_file )
911
+ def read_tags (test_file : TestFileConfig ) -> list [TestId ]:
912
+ tag_file = test_file .get_tag_file ()
893
913
tags = []
894
914
if tag_file .exists ():
895
915
with open (tag_file ) as f :
896
916
for line in f :
897
917
test = line .strip ()
898
- tags .append (TestId (test_file , test ))
918
+ tags .append (TestId (test_file . path , test ))
899
919
return tags
900
920
return tags
901
921
0 commit comments