@@ -70,7 +70,9 @@ def default_command(*args):
7070 files.
7171
7272 An argument which is ``case=<pattern>``, is passed to pytest
73- as ``-k <pattern>``.
73+ as ``-k <pattern>``. An argument of the form ``<file>::<case>``
74+ runs that specific test by node ID. These two forms are mutually
75+ exclusive.
7476
7577 ``skip_past=<last_to_ignore>`` causes collection to ignore all
7678 files up to and including ``<last_to_ignore>``` (may be partial
@@ -91,11 +93,12 @@ def default_command(*args):
9193
9294
9395class _Case :
94- def __init__ (self , rel_path , runner ):
96+ def __init__ (self , entry , runner ):
9597 self .timed_out_secs = 0
9698 self .runner = runner
97- self .rel_path = rel_path
98- self .abs_path = pkio .py_path (rel_path )
99+ self .rel_path = entry .path
100+ self .case_func = entry .case_func
101+ self .abs_path = pkio .py_path (self .rel_path )
99102 self .tries = _MAX_RESTARTS if _cfg .restartable else 1
100103 self .run ()
101104
@@ -180,9 +183,7 @@ def _ignore_warnings():
180183 return rv
181184
182185 def _remove_work_dir ():
183- w = _TEST_PY .sub (pkunit .WORK_DIR_SUFFIX , self .rel_path )
184- if w != self .rel_path :
185- pkio .unchecked_remove (w )
186+ pkio .unchecked_remove (pkunit .test_path_to_work_dir (self .rel_path ))
186187
187188 def _process ():
188189 c = (
@@ -193,7 +194,7 @@ def _process():
193194 "-v" ,
194195 "-s" ,
195196 "-rs" ,
196- self .rel_path ,
197+ self .case_func or self . rel_path ,
197198 ]
198199 + self .runner .pytest_flags
199200 )
@@ -242,9 +243,9 @@ def _too_many_failures():
242243 self .failures = []
243244 self .cases = set ()
244245 with _SignalCascade () as self .signal_cascade :
245- for p in self .rel_paths :
246+ for v in self .rel_paths :
246247 c += 1
247- self ._run (p )
248+ self ._run (v )
248249 if a := _too_many_failures ():
249250 break
250251 while self ._wait_for_one (aborting = a ):
@@ -253,12 +254,18 @@ def _too_many_failures():
253254 self .result = f"passed={ c } "
254255
255256 def _args (self , tests ):
256- def _file (path ):
257+ def _case_funcs (case_funcs , cwd ):
258+ for p , c in case_funcs :
259+ if not (t := pkio .py_path (p )).exists ():
260+ pykern .pkcli .command_error ("not found test={}" , t )
261+ _file (str (cwd .bestrelpath (t )), case_func = c )
262+
263+ def _file (path , case_func = None ):
257264 if self .skip_past :
258265 if self .skip_past in path :
259266 self .skip_past = None
260267 return
261- self .rel_paths .append (path )
268+ self .rel_paths .append (PKDict ( path = path , case_func = case_func ) )
262269
263270 def _find (paths ):
264271 i = re .compile (r"(?:_work|_data)/" )
@@ -276,9 +283,11 @@ def _find(paths):
276283 _file (p )
277284
278285 def _flag (name , value ):
286+ rv = False
279287 if len (value ) <= 0 :
280288 pykern .pkcli .command_error (f"empty value for option={ name } " )
281289 elif name == "case" :
290+ rv = True
282291 self .pytest_flags .extend (("-k" , value ))
283292 elif name == "max_procs" :
284293 try :
@@ -297,6 +306,7 @@ def _flag(name, value):
297306 self .skip_past = value
298307 else :
299308 pykern .pkcli .command_error (f"unsupported option={ name } " )
309+ return rv
300310
301311 def _resolve_test_paths (paths , current_dir ):
302312 if not paths :
@@ -306,17 +316,27 @@ def _resolve_test_paths(paths, current_dir):
306316 paths = (p ,)
307317 return paths
308318
309- p = []
319+ case_flag = False
320+ paths = []
321+ case_funcs = []
310322 self .pytest_flags = []
311323 self .max_procs = _cfg .max_procs
312324 self .skip_past = None
313325 for t in tests :
314326 if "=" in t :
315- _flag (* (t .split ("=" )))
327+ case_flag = _flag (* (t .split ("=" )))
328+ elif "::" in t :
329+ v = t .split ("::" , 1 )
330+ case_funcs .append ((v [0 ], t ))
316331 else :
317- p .append (t )
332+ paths .append (t )
318333 self .rel_paths = []
319- _find (p )
334+ if case_funcs :
335+ if case_flag :
336+ pykern .pkcli .command_error ("use case= or test::case, not both" )
337+ _case_funcs (case_funcs , pkio .py_path ())
338+ if paths or not case_funcs :
339+ _find (paths )
320340
321341 def _assert_failures (self , failures , count ):
322342 if len (failures ) <= 0 :
@@ -343,15 +363,16 @@ def _info(self, case, lines):
343363 # other output on its own line, ensure newline at end
344364 lines [- 1 ] += "\n "
345365 else :
366+ v = case .case_func or case .rel_path
346367 if lines [0 ] == _FAIL_MSG :
347368 # add the failure context
348369 lines [0 ] += f" { case .output_path } "
349370 if self .max_procs > 1 :
350371 # line by line when multiprocess
351- lines [0 ] = case . rel_path + " " + lines [0 ]
372+ lines [0 ] = v + " " + lines [0 ]
352373 elif lines [0 ] == _START_MSG :
353374 # starting a case
354- lines [0 ] = case . rel_path
375+ lines [0 ] = v
355376 else :
356377 # completing a case
357378 lines [0 ] = " " + lines [0 ]
@@ -363,8 +384,8 @@ def _info(self, case, lines):
363384 # TODO(robnagler) is this necessary?
364385 sys .stdout .flush ()
365386
366- def _run (self , rel_path ):
367- c = _Case (rel_path , self )
387+ def _run (self , entry ):
388+ c = _Case (entry , self )
368389 self .cases .add (c )
369390 self ._info (c , [_START_MSG ])
370391 if len (self .cases ) >= self .max_procs :
0 commit comments