Skip to content

Commit 8f9ad04

Browse files
test(#1424): strengthen self.upstream cleanup/tripartite/rejection coverage
Addresses the review feedback from @ttngu207 and @MilagrosMarin on #1473: 1. test_upstream_cleared_after_make now captures the actual populate instance inside make() and asserts its _upstream is cleared afterward. The old test probed a fresh Derived().upstream (never set -> class default None), so it passed even if the finally-reset were deleted. Now it has teeth. 2. test_upstream_cleared_after_make_raises (new): forces make() to raise and asserts the populate instance's upstream is still cleared -- exercising the exception path the finally block exists for (previously zero coverage). 3. test_upstream_seen_across_tripartite_make now asserts, via id(self._upstream) grouped per key, that all three phases share one upstream object -- proving the docstring's 'constructed once, shared across phases' claim instead of only checking the result. 4. test_upstream_rejects_non_ancestor now exercises both the class-form and string-form Diagram.__getitem__ branches. Validated locally on MySQL and PostgreSQL (full test_autopopulate.py: 18 passed, 2 skipped). Upward Part-of-Part and isolated-secondary-FK coverage remain follow-ups (the diamond-OR and cross-schema gaps were closed at the trace layer in #1471).
1 parent 242d977 commit 8f9ad04

1 file changed

Lines changed: 80 additions & 10 deletions

File tree

tests/integration/test_autopopulate.py

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -419,16 +419,25 @@ class Bad(dj.Computed):
419419
"""
420420

421421
def make(self, key):
422+
# class-form lookup (Diagram.__getitem__ class branch)
422423
try:
423424
self.upstream[Unrelated]
424425
except DataJointError as exc:
425-
captured_errors.append(exc)
426+
captured_errors.append(("class", exc))
427+
# string-form lookup (the separate FreeTable/string branch)
428+
try:
429+
self.upstream[Unrelated.full_table_name]
430+
except DataJointError as exc:
431+
captured_errors.append(("string", exc))
426432
# Insert anyway so populate doesn't fail
427433
self.insert1({**key, "ok": 1})
428434

429435
Bad.populate()
430-
assert len(captured_errors) == 1
431-
assert "not in this trace" in str(captured_errors[0]).lower()
436+
# Both the class-form and string-form lookups must reject the non-ancestor.
437+
forms = {form for form, _ in captured_errors}
438+
assert forms == {"class", "string"}, f"expected both branches to raise, got {forms}"
439+
class_err = next(exc for form, exc in captured_errors if form == "class")
440+
assert "not in this trace" in str(class_err).lower()
432441

433442

434443
def test_upstream_unset_outside_make(prefix, connection_test):
@@ -458,7 +467,10 @@ def make(self, key):
458467

459468

460469
def test_upstream_cleared_after_make(prefix, connection_test):
461-
"""After a make() call completes, self.upstream is reset (no stale state)."""
470+
"""After make() completes, the SAME instance that ran make() has its
471+
self.upstream cleared. Capturing the populate instance is what gives this
472+
test teeth: it would FAIL if the `finally: self._upstream = None` line were
473+
removed (a fresh-instance probe would pass regardless)."""
462474
schema = dj.Schema(f"{prefix}_upstream_cleared", connection=connection_test)
463475

464476
@schema
@@ -468,6 +480,8 @@ class Source(dj.Lookup):
468480
"""
469481
contents = [(1,)]
470482

483+
captured = []
484+
471485
@schema
472486
class Derived(dj.Computed):
473487
definition = """
@@ -477,19 +491,61 @@ class Derived(dj.Computed):
477491
"""
478492

479493
def make(self, key):
494+
captured.append(self) # the actual populate instance
495+
assert self._upstream is not None # set for the duration of make()
480496
self.insert1({**key, "val": 0})
481497

482498
Derived.populate()
483-
# The class attribute defaults to None; the per-instance _upstream
484-
# set during make() must have been cleared by the finally block.
485-
# Probe via the public property — should raise the "outside make" error.
499+
assert captured, "make() did not run"
500+
inst = captured[0]
501+
# The finally block must have cleared _upstream on this very instance.
502+
assert inst._upstream is None
486503
with pytest.raises(DataJointError, match="only available inside make"):
487-
Derived().upstream
504+
inst.upstream
505+
506+
507+
def test_upstream_cleared_after_make_raises(prefix, connection_test):
508+
"""The reset lives in `finally` specifically so it survives an exception in
509+
make(). Force make() to raise and assert the populate instance's
510+
self.upstream is still cleared."""
511+
schema = dj.Schema(f"{prefix}_upstream_exc", connection=connection_test)
512+
513+
@schema
514+
class Source(dj.Lookup):
515+
definition = """
516+
source_id : int32
517+
"""
518+
contents = [(1,)]
519+
520+
captured = []
521+
522+
@schema
523+
class Boom(dj.Computed):
524+
definition = """
525+
-> Source
526+
---
527+
val : int32
528+
"""
529+
530+
def make(self, key):
531+
captured.append(self)
532+
assert self._upstream is not None
533+
raise RuntimeError("make failed on purpose")
534+
535+
with pytest.raises(RuntimeError, match="make failed on purpose"):
536+
Boom.populate(suppress_errors=False)
537+
assert captured, "make() did not run"
538+
inst = captured[0]
539+
# Cleared by the finally block even though make() raised.
540+
assert inst._upstream is None
541+
with pytest.raises(DataJointError, match="only available inside make"):
542+
inst.upstream
488543

489544

490545
def test_upstream_seen_across_tripartite_make(prefix, connection_test):
491-
"""The tripartite make() invocation pattern sees the same self.upstream
492-
across all three phases (fetch / compute / insert)."""
546+
"""The tripartite make() sees the SAME self.upstream object across all three
547+
phases (fetch / compute / insert) for a given key — constructed once,
548+
shared. Asserted via object identity, not just a correct result."""
493549
schema = dj.Schema(f"{prefix}_upstream_tripartite", connection=connection_test)
494550

495551
@schema
@@ -501,6 +557,8 @@ class Source(dj.Lookup):
501557
"""
502558
contents = [(1, 100), (2, 200)]
503559

560+
seen = [] # (source_id, phase, id(self._upstream))
561+
504562
@schema
505563
class TriComputed(dj.Computed):
506564
definition = """
@@ -510,18 +568,30 @@ class TriComputed(dj.Computed):
510568
"""
511569

512570
def make_fetch(self, key):
571+
seen.append((key["source_id"], "fetch", id(self._upstream)))
513572
return (self.upstream[Source].fetch1("value"),)
514573

515574
def make_compute(self, key, value):
575+
seen.append((key["source_id"], "compute", id(self._upstream)))
516576
return (value * 2,)
517577

518578
def make_insert(self, key, doubled):
579+
seen.append((key["source_id"], "insert", id(self._upstream)))
519580
self.insert1({**key, "result": doubled})
520581

521582
TriComputed.populate()
522583
assert (TriComputed & {"source_id": 1}).fetch1("result") == 200
523584
assert (TriComputed & {"source_id": 2}).fetch1("result") == 400
524585

586+
# Every phase that ran for a given key must have observed one and the same
587+
# self.upstream object (not None, not rebuilt per phase).
588+
ids_by_key = {}
589+
for sid, _phase, uid in seen:
590+
ids_by_key.setdefault(sid, set()).add(uid)
591+
assert ids_by_key, "tripartite make did not run"
592+
for sid, ids in ids_by_key.items():
593+
assert len(ids) == 1, f"source_id={sid}: self.upstream differed across phases: {ids}"
594+
525595

526596
def test_populate_reserve_jobs_respects_restrictions(clean_autopopulate, subject, experiment):
527597
"""Regression test for #1413: populate() with reserve_jobs=True must honour restrictions.

0 commit comments

Comments
 (0)