Skip to content

Commit 58ecd6c

Browse files
authored
Handle dimensioned measurement validation errors (#770)
Exceptions in dimensioned measurement validations occur in the Test Executor thread, which measn they are not caught by the normal flow of tests. When those occur, the test will end and will be reported as a PASS. This commit changes that. Now, an exception during a dimensioned validation measurement validator function will set the phase result to the raise exception if the phase result is not terminal. The existing result will be left in place if it is terminal; in this case, the validator exception is logged with its stack dump to ensure that there is still a record.
1 parent 0cb4bcf commit 58ecd6c

4 files changed

Lines changed: 87 additions & 8 deletions

File tree

openhtf/core/measurements.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,17 @@ def _with_validator(*args, **kwargs): # pylint: disable=invalid-name
251251
def validate(self):
252252
"""Validate this measurement and update its 'outcome' field."""
253253
# PASS if all our validators return True, otherwise FAIL.
254-
if all(v(self.measured_value.value) for v in self.validators):
255-
self.outcome = Outcome.PASS
256-
else:
254+
try:
255+
if all(v(self.measured_value.value) for v in self.validators):
256+
self.outcome = Outcome.PASS
257+
else:
258+
self.outcome = Outcome.FAIL
259+
return self
260+
except Exception as e: # pylint: disable=bare-except
261+
_LOG.error('Validation for measurement %s raised an exception %s.',
262+
self.name, e)
257263
self.outcome = Outcome.FAIL
258-
return self
264+
raise
259265

260266
def _asdict(self):
261267
"""Convert this measurement to a dict of basic types."""

openhtf/core/phase_executor.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757

5858
class ExceptionInfo(collections.namedtuple(
5959
'ExceptionInfo', ['exc_type', 'exc_val', 'exc_tb'])):
60+
"""Wrap the description of a raised exception and its traceback."""
6061

6162
def _asdict(self):
6263
return {
@@ -235,9 +236,10 @@ def _execute_phase_once(self, phase_desc, is_last_repeat):
235236
# Check this before we create a PhaseState and PhaseRecord.
236237
if phase_desc.options.run_if and not phase_desc.options.run_if():
237238
_LOG.debug('Phase %s skipped due to run_if returning falsey.',
238-
phase_desc.name)
239+
phase_desc.name)
239240
return PhaseExecutionOutcome(openhtf.PhaseResult.SKIP)
240241

242+
override_result = None
241243
with self.test_state.running_phase_context(phase_desc) as phase_state:
242244
_LOG.debug('Executing phase %s', phase_desc.name)
243245
with self._current_phase_thread_lock:
@@ -256,13 +258,16 @@ def _execute_phase_once(self, phase_desc, is_last_repeat):
256258
phase_thread.start()
257259
self._current_phase_thread = phase_thread
258260

259-
result = phase_state.result = phase_thread.join_or_die()
261+
phase_state.result = phase_thread.join_or_die()
260262
if phase_state.result.is_repeat and is_last_repeat:
261263
_LOG.error('Phase returned REPEAT, exceeding repeat_limit.')
262264
phase_state.hit_repeat_limit = True
263-
result = PhaseExecutionOutcome(openhtf.PhaseResult.STOP)
265+
override_result = PhaseExecutionOutcome(openhtf.PhaseResult.STOP)
264266
self._current_phase_thread = None
265267

268+
# Refresh the result in case a validation for a partially set measurement
269+
# raised an exception.
270+
result = override_result or phase_state.result
266271
_LOG.debug('Phase %s finished with result %s', phase_desc.name,
267272
result.phase_result)
268273
return result

openhtf/core/test_state.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import mimetypes
3232
import os
3333
import socket
34+
import sys
3435
import traceback
3536

3637
from enum import Enum
@@ -519,7 +520,17 @@ def _finalize_measurements(self):
519520
measurement.set_notification_callback(None)
520521
# Validate multi-dimensional measurements now that we have all values.
521522
if measurement.outcome is measurements.Outcome.PARTIALLY_SET:
522-
measurement.validate()
523+
try:
524+
measurement.validate()
525+
except Exception: # pylint: disable=broad-except
526+
# Record the exception as the new result.
527+
if self.phase_record.result.is_terminal:
528+
_LOG.exception(
529+
'Measurement validation raised an exception, but phase result '
530+
'is already terminal; logging additional exception here.')
531+
else:
532+
self.phase_record.result = phase_executor.PhaseExecutionOutcome(
533+
phase_executor.ExceptionInfo(*sys.exc_info()))
523534

524535
# Set final values on the PhaseRecord.
525536
self.phase_record.measurements = self.measurements

test/core/measurements_test.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@
3434
'lineno', 'codeinfo', 'code_info', 'descriptor_id'}
3535

3636

37+
class BadValidatorError(Exception):
38+
pass
39+
40+
41+
class BadPhaseError(Exception):
42+
pass
43+
44+
45+
def bad_validator(value):
46+
del value # Unused.
47+
raise BadValidatorError('This is a bad validator.')
48+
49+
50+
@htf.measures(htf.Measurement('bad').with_dimensions('a').with_validator(
51+
bad_validator))
52+
def bad_validator_phase(test):
53+
test.measurements.bad[1] = 1
54+
test.measurements.bad[2] = 2
55+
56+
57+
@htf.measures(htf.Measurement('bad').with_dimensions('a').with_validator(
58+
bad_validator))
59+
def bad_validator_with_error(test):
60+
test.measurements.bad[2] = 2
61+
raise BadPhaseError('Bad phase.')
62+
63+
3764
class TestMeasurements(htf_test.TestCase):
3865

3966
def setUp(self):
@@ -91,6 +118,26 @@ def test_measurement_order(self):
91118
['replaced_min_only', 'replaced_max_only',
92119
'replaced_min_max'])
93120

121+
@htf_test.yields_phases
122+
def test_bad_validation(self):
123+
record = yield bad_validator_phase
124+
self.assertPhaseError(record, exc_type=BadValidatorError)
125+
self.assertMeasurementFail(record, 'bad')
126+
127+
@htf_test.yields_phases
128+
def test_bad_validation_with_other_phases(self):
129+
test_record = yield htf.Test(bad_validator_phase, all_the_things.dimensions)
130+
self.assertTestError(test_record, exc_type=BadValidatorError)
131+
# Start phase and the bad validator phase only.
132+
self.assertEqual(len(test_record.phases), 2)
133+
self.assertPhaseError(test_record.phases[1], exc_type=BadValidatorError)
134+
135+
@htf_test.yields_phases
136+
def test_bad_validation_with_error(self):
137+
record = yield bad_validator_with_error
138+
self.assertPhaseError(record, exc_type=BadPhaseError)
139+
self.assertMeasurementFail(record, 'bad')
140+
94141

95142
class TestMeasurement(htf_test.TestCase):
96143

@@ -130,3 +177,13 @@ def test_to_dataframe(self, units=True):
130177

131178
def test_to_dataframe__no_units(self):
132179
self.test_to_dataframe(units=False)
180+
181+
def test_bad_validator(self):
182+
measurement = htf.Measurement('bad_measure')
183+
measurement.with_dimensions('a')
184+
measurement.with_validator(bad_validator)
185+
measurement.measured_value['A'] = 1
186+
measurement.measured_value['B'] = 2
187+
with self.assertRaises(BadValidatorError):
188+
measurement.validate()
189+

0 commit comments

Comments
 (0)