Skip to content

Commit 076d9ca

Browse files
authored
Fix #646 pkcli.test support file::case (pytest node ID syntax) (#647)
- pkunit.test_path_to_work_dir: strip _test or test_, not both
1 parent 26d778b commit 076d9ca

11 files changed

Lines changed: 122 additions & 24 deletions

File tree

‎pykern/pkcli/test.py‎

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

9395
class _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:

‎pykern/pkio.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def read_text(filename):
226226
filename (str or py.path.Local): File to open
227227
228228
Returns:
229-
Str: contents of `filename`
229+
str: contents of `filename`
230230
"""
231231
try:
232232
with open_text(filename) as f:

‎pykern/pkunit.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,25 @@ def __exit__(self, *args):
522522
return False
523523

524524

525+
def test_path_to_work_dir(path):
526+
"""Convert a test file path to its work directory path.
527+
528+
Strips ``_test`` suffix or ``test_`` prefix from the basename and
529+
appends ``_work``.
530+
531+
Args:
532+
path (str or py.path.local): test file path ending in ``_test`` or starting with ``test_``
533+
534+
Returns:
535+
py.path.local: work directory path
536+
"""
537+
p = pkio.py_path(path)
538+
b = _strip_test_affix(p.purebasename)
539+
if b is None:
540+
pkfail("{}: path must be a test file (_test suffix or test_ prefix)", p)
541+
return p.new(basename=b + WORK_DIR_SUFFIX)
542+
543+
525544
def work_dir():
526545
"""Returns ephemeral work directory, created if necessary.
527546
@@ -538,7 +557,10 @@ def work_dir():
538557
Returns:
539558
py.path: directory name
540559
"""
541-
return _base_dir(WORK_DIR_SUFFIX).ensure(dir=True)
560+
f = _test_file()
561+
if not f:
562+
raise PKFail("unable to find test file path; not running in pykern.pkcli.test?")
563+
return test_path_to_work_dir(f).realpath().ensure(dir=True)
542564

543565

544566
class _FileEq:
@@ -747,8 +769,8 @@ def _base_dir(postfix):
747769
f = _test_file()
748770
if not f:
749771
raise PKFail("unable to find test file path; not running in pykern.pkcli.test?")
750-
b = re.sub(r"_test$|^test_", "", f.purebasename)
751-
assert b != f.purebasename, "{}: module name must end in _test".format(f)
772+
b = _strip_test_affix(f.purebasename)
773+
assert b is not None, "{}: module name must end in _test".format(f)
752774
return f.new(basename=b + postfix).realpath()
753775

754776

@@ -774,6 +796,13 @@ def _pkdlog(*args, **kwargs):
774796
pkdlog(*args, **kwargs)
775797

776798

799+
def _strip_test_affix(purebasename):
800+
b = re.sub(r"_test$", "", purebasename)
801+
if b == purebasename:
802+
b = re.sub(r"^test_", "", purebasename)
803+
return None if b == purebasename else b
804+
805+
777806
def _test_file():
778807
"""Various ways to initialize _test_file"""
779808
global _init_test_file, __test_file
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""fixture for test_case_test: one passing and one failing test
2+
3+
:copyright: Copyright (c) 2026 RadiaSoft LLC. All Rights Reserved.
4+
:license: http://www.apache.org/licenses/LICENSE-2.0.html
5+
"""
6+
7+
8+
def test_fail():
9+
x = 1 / 0
10+
11+
12+
def test_pass():
13+
pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1_test.py::test_pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
passed=1
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""fixture for test_case_test: one passing and one failing test
2+
3+
:copyright: Copyright (c) 2026 RadiaSoft LLC. All Rights Reserved.
4+
:license: http://www.apache.org/licenses/LICENSE-2.0.html
5+
"""
6+
7+
8+
def test_fail():
9+
x = 1 / 0
10+
11+
12+
def test_pass():
13+
pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1_test.py::test_fail 1_test.py::test_pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
FAILED=1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
FAILED=1 passed=1

0 commit comments

Comments
 (0)