From 4646aa6b9ba67532ce7e8743ce16d7bd4369ad3d Mon Sep 17 00:00:00 2001 From: cpaulin Date: Wed, 23 Jun 2021 16:43:56 -0700 Subject: [PATCH 1/2] Handle race condition when a test has errored but is not finalized. (#983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add partial test record upload with a background uploader thread. Cleanup Add missing typing.Optional type annotations to function parameters. Fixed parsing of default arguments provided to an OpenHTF phase. Print the OpenHTF result instead of logging it. Replace marginal field in mfg-i facing protos with new MARGINAL_PASS test status. Fix bug causing data to not be uploaded when a ThreadTerminationError occurs. Marginal pass - Propagate marginal determination to the test run level. Fix a few bugs with AllInRange and add unit tests Fix bug with is_marginal check on AllInRange validator Remove circular dependency between diagnoses_lib and phase_descriptor Move check_for_duplicate_results to phase_descriptor Fix and update type annotations in diagnoses_lib Update protos to output marginal determination upstream and add console coloring for marginal output cases. Add marginal measurements. See go/openhtf-marginal-pass. Added useful debugging messages to OpenHTF unit test results when they don't pass. Add a typing.overload to execute_phase_or_test Move openhtf.measures to the phase_descriptor module Added a library to convert OpenHTF objects to strings. Update built in validators to display the spec limits in the database. Fix bug where plugs were being updated twice, resulting in tearDown being called. Update unit test docs to cover TestCase.execute_phase_or_test. Retry setsockopt when starting up the multicast thread Add a decorator-free way to write unit tests. Add capturing of instantiated plugs as an attribute on the test case. Add get_attachment_or_die method to TestApi Regenerate units with the latest UNECE publication (rec20_Rev15e-2020.xls). Raise a clear Error message when a DeviceWrappingPlug is not fully initialized Fix DUT input phase hung w/ ctrl+c (sigint). Timeout when getting multicast.send() responses from queue Add force_repeat option to force a repeat of phase up to the repeat_limit. Adding the phase name to the phase outcome logging statements. Fix type of conf when accessed as openhtf.conf Give 3 retries for timeout phase as default; Add repeat_on_timeout option for phase Replace phase_group member with either phase_sequence or phases when appropriate. Add workaround for when AbortTest plug is not initialized (this happens sometimes, but is not easily reproducible). PiperOrigin-RevId: 381093144 --- CONTRIBUTORS | 1 + examples/all_the_things.py | 19 + examples/measurements.py | 12 + openhtf/__init__.py | 8 +- openhtf/core/diagnoses_lib.py | 65 +-- openhtf/core/measurements.py | 88 +--- openhtf/core/monitors.py | 2 +- openhtf/core/phase_descriptor.py | 151 ++++++- openhtf/core/phase_executor.py | 27 +- openhtf/core/phase_group.py | 2 +- openhtf/core/test_descriptor.py | 37 +- openhtf/core/test_record.py | 4 + openhtf/core/test_state.py | 56 ++- openhtf/output/callbacks/__init__.py | 2 +- openhtf/output/callbacks/mfg_inspector.py | 102 ++++- openhtf/output/proto/mfg_event_converter.py | 71 +++- openhtf/output/proto/test_runs_converter.py | 8 +- openhtf/output/servers/station_server.py | 2 +- openhtf/plugs/__init__.py | 2 +- openhtf/plugs/device_wrapping.py | 13 + openhtf/util/console_output.py | 2 +- openhtf/util/data.py | 7 +- openhtf/util/multicast.py | 50 ++- openhtf/util/test.py | 250 +++++++++-- openhtf/util/text.py | 304 ++++++++++++++ openhtf/util/units.py | 232 ++++++---- openhtf/util/validators.py | 237 ++++++++++- test/core/diagnoses_test.py | 113 ----- test/core/measurements_test.py | 18 + .../callbacks/mfg_event_converter_test.py | 22 +- test/phase_descriptor_test.py | 213 +++++++++- test/test_state_test.py | 37 +- test/util/test_test.py | 118 +++++- test/util/text_test.py | 397 ++++++++++++++++++ test/util/validators_test.py | 122 ++++++ 35 files changed, 2288 insertions(+), 506 deletions(-) create mode 100644 openhtf/util/text.py create mode 100644 test/util/text_test.py diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e3718a83e..225413614 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -17,3 +17,4 @@ Joe Ethier John Hawley Keith Suda-Cederquist Kenneth Schiller +Christian Paulin diff --git a/examples/all_the_things.py b/examples/all_the_things.py index 0a1595382..9485cd921 100644 --- a/examples/all_the_things.py +++ b/examples/all_the_things.py @@ -112,6 +112,23 @@ def measures_with_args(test, minimum, maximum): test.measurements.replaced_min_max = 1 +@htf.measures( + htf.Measurement('replaced_marginal_min_only').in_range( + 0, 10, '{marginal_minimum}', 8, type=int), + htf.Measurement('replaced_marginal_max_only').in_range( + 0, 10, 2, '{marginal_maximum}', type=int), + htf.Measurement('replaced_marginal_min_max').in_range( + 0, 10, '{marginal_minimum}', '{marginal_maximum}', type=int), +) +def measures_with_marginal_args(test, marginal_minimum, marginal_maximum): + """Phase with measurement with marginal arguments.""" + del marginal_minimum # Unused. + del marginal_maximum # Unused. + test.measurements.replaced_marginal_min_only = 3 + test.measurements.replaced_marginal_max_only = 3 + test.measurements.replaced_marginal_min_max = 3 + + def attachments(test): test.attach('test_attachment', 'This is test attachment data.'.encode('utf-8')) @@ -156,6 +173,8 @@ def main(): attachments, skip_phase, measures_with_args.with_args(minimum=1, maximum=4), + measures_with_marginal_args.with_args( + marginal_minimum=4, marginal_maximum=6), analysis, ), # Some metadata fields, these in particular are used by mfg-inspector, diff --git a/examples/measurements.py b/examples/measurements.py index 23b044041..660fbec7e 100644 --- a/examples/measurements.py +++ b/examples/measurements.py @@ -162,6 +162,18 @@ def multdim_measurements(test): test.measurements['average_current']) +# Marginal measurements can be used to obtain a finer granularity of by how much +# a measurement is passing. Marginal measurements have stricter minimum and +# maximum limits, which are used to flag measurements/phase/test records as +# marginal without affecting the overall phase outcome. +@htf.measures( + htf.Measurement('resistance').with_units('ohm').in_range( + minimum=5, maximum=17, marginal_minimum=9, marginal_maximum=11)) +def marginal_measurements(test): + """Phase with a marginal measurement.""" + test.measurements.resistance = 13 + + def main(): # We instantiate our OpenHTF test with the phases we want to run as args. test = htf.Test(hello_phase, again_phase, lots_of_measurements, diff --git a/openhtf/__init__.py b/openhtf/__init__.py index b148e72ff..0e8d96271 100644 --- a/openhtf/__init__.py +++ b/openhtf/__init__.py @@ -14,12 +14,12 @@ """The main OpenHTF entry point.""" import signal +import typing from openhtf import plugs from openhtf.core import phase_executor from openhtf.core import test_record from openhtf.core.base_plugs import BasePlug -from openhtf.core.diagnoses_lib import diagnose from openhtf.core.diagnoses_lib import DiagnosesStore from openhtf.core.diagnoses_lib import Diagnosis from openhtf.core.diagnoses_lib import DiagnosisComponent @@ -30,7 +30,6 @@ from openhtf.core.measurements import Dimension from openhtf.core.measurements import Measurement -from openhtf.core.measurements import measures from openhtf.core.monitors import monitors from openhtf.core.phase_branches import BranchSequence from openhtf.core.phase_branches import DiagnosisCheckpoint @@ -38,6 +37,8 @@ from openhtf.core.phase_branches import PhaseFailureCheckpoint from openhtf.core.phase_collections import PhaseSequence from openhtf.core.phase_collections import Subtest +from openhtf.core.phase_descriptor import diagnose +from openhtf.core.phase_descriptor import measures from openhtf.core.phase_descriptor import PhaseDescriptor from openhtf.core.phase_descriptor import PhaseOptions from openhtf.core.phase_descriptor import PhaseResult @@ -57,6 +58,9 @@ from openhtf.util import units import pkg_resources +if typing.TYPE_CHECKING: + conf: conf.Configuration # Configuration is only available here in typing. + def get_version(): """Returns the version string of the 'openhtf' package. diff --git a/openhtf/core/diagnoses_lib.py b/openhtf/core/diagnoses_lib.py index 8b29dc227..884f9113f 100644 --- a/openhtf/core/diagnoses_lib.py +++ b/openhtf/core/diagnoses_lib.py @@ -123,13 +123,11 @@ def main(): """ import abc -import collections import logging -from typing import Any, Callable, DefaultDict, Dict, Iterable, Iterator, List, Optional, Sequence, Set, Text, Type, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Text, Type, TYPE_CHECKING, Union import attr import enum # pylint: disable=g-bad-import-order -from openhtf.core import phase_descriptor from openhtf.core import test_record from openhtf.util import data import six @@ -147,10 +145,6 @@ class InvalidDiagnosisError(Exception): """A Diagnosis was constructed incorrectly.""" -class DuplicateResultError(Exception): - """Different DiagResultEnum instances define the same value.""" - - @attr.s(slots=True) class DiagnosesStore(object): """Storage and lookup of diagnoses.""" @@ -265,42 +259,6 @@ def execute_test_diagnoser(self, diagnoser: 'BaseTestDiagnoser', self._add_diagnosis(diag) -def check_for_duplicate_results( - phase_iterator: Iterator[phase_descriptor.PhaseDescriptor], - test_diagnosers: Sequence['BaseTestDiagnoser']) -> None: - """Check for any results with the same enum value in different ResultTypes. - - Args: - phase_iterator: iterator over the phases to check. - test_diagnosers: list of test level diagnosers. - - Raises: - DuplicateResultError: when duplicate enum values are found. - """ - all_result_enums = set() # type: Set[Type['DiagResultEnum']] - for phase in phase_iterator: - for phase_diag in phase.diagnosers: - all_result_enums.add(phase_diag.result_type) - for test_diag in test_diagnosers: - all_result_enums.add(test_diag.result_type) - - values_to_enums = collections.defaultdict( - list) # type: DefaultDict[str, Type['DiagResultEnum']] - for enum_cls in all_result_enums: - for entry in enum_cls: - values_to_enums[entry.value].append(enum_cls) - - duplicates = [] # type: List[str] - for result_value, enum_classes in sorted(values_to_enums.items()): - if len(enum_classes) > 1: - duplicates.append('Value "{}" defined by {}'.format( - result_value, enum_classes)) - if not duplicates: - return - raise DuplicateResultError('Duplicate DiagResultEnum values: {}'.format( - '\n'.join(duplicates))) - - def _check_diagnoser(diagnoser: '_BaseDiagnoser', diagnoser_cls: Type['_BaseDiagnoser']) -> None: """Check that a diagnoser is properly created.""" @@ -377,8 +335,7 @@ class BasePhaseDiagnoser(six.with_metaclass(abc.ABCMeta, _BaseDiagnoser)): __slots__ = () @abc.abstractmethod - def run(self, - phase_record: phase_descriptor.PhaseDescriptor) -> DiagnoserReturnT: + def run(self, phase_record: test_record.PhaseRecord) -> DiagnoserReturnT: """Must be implemented to return list of Diagnoses instances. Args: @@ -547,21 +504,3 @@ def __attrs_post_init__(self) -> None: def as_base_types(self) -> Dict[Text, Any]: return data.convert_to_base_types( attr.asdict(self, filter=_diagnosis_serialize_filter)) - - -def diagnose( - *diagnosers: BasePhaseDiagnoser -) -> Callable[[phase_descriptor.PhaseT], phase_descriptor.PhaseDescriptor]: - """Decorator to add diagnosers to a PhaseDescriptor.""" - check_diagnosers(diagnosers, BasePhaseDiagnoser) - diags = list(diagnosers) - - def decorate( - wrapped_phase: phase_descriptor.PhaseT - ) -> phase_descriptor.PhaseDescriptor: - """Phase decorator to be returned.""" - phase = phase_descriptor.PhaseDescriptor.wrap_or_copy(wrapped_phase) - phase.diagnosers.extend(diags) - return phase - - return decorate diff --git a/openhtf/core/measurements.py b/openhtf/core/measurements.py index efc878db9..6774ef1b7 100644 --- a/openhtf/core/measurements.py +++ b/openhtf/core/measurements.py @@ -22,7 +22,7 @@ Measurements are described by the measurements.Measurement class. Essentially, the Measurement class is used by test authors to declare measurements by name, and to optionally provide unit, type, and validation information. Measurements -are attached to Test Phases using the @measurements.measures() decorator. +are attached to Test Phases using the @openhtf.measures() decorator. When measurements are output by the OpenHTF framework, the Measurement objects are serialized into the 'measurements' field on the PhaseRecord, which contain @@ -45,11 +45,11 @@ Examples: - @measurements.measures( + @openhtf.measures( measurements.Measurement( 'number_widgets').in_range(5, 10).doc( '''This phase parameter tracks the number of widgets.''')) - @measurements.measures( + @openhtf.measures( *(measurements.Measurement('level_%s' % lvl) for lvl in ('none', 'some', 'all'))) def WidgetTestPhase(test): @@ -62,17 +62,18 @@ def WidgetTestPhase(test): import enum import functools import logging +import typing from typing import Any, Callable, Dict, Iterator, List, Optional, Text, Tuple, Union import attr from openhtf import util -from openhtf.core import diagnoses_lib -from openhtf.core import phase_descriptor from openhtf.util import data from openhtf.util import units as util_units from openhtf.util import validators import six +if typing.TYPE_CHECKING: + from openhtf.core import diagnoses_lib try: # pylint: disable=g-import-not-at-top @@ -119,7 +120,7 @@ class _ConditionalValidator(object): """Conditional validator declaration.""" # The diagnosis result required for the validator to be used. - result = attr.ib(type=diagnoses_lib.DiagResultEnum) + result = attr.ib(type='diagnoses_lib.DiagResultEnum') # The validator to use when the result is present. validator = attr.ib(type=Callable[[Any], bool]) @@ -187,6 +188,8 @@ class Measurement(object): notification_cb: An optional function to be called when the measurement is set. outcome: One of the Outcome() enumeration values, starting at UNSET. + marginal: A bool flag indicating if this measurement is marginal if the + outcome is PASS. _cached: A cached dict representation of this measurement created initially during as_base_types and updated in place to save allocation time. """ @@ -211,6 +214,7 @@ class Measurement(object): type=Union['MeasuredValue', 'DimensionedMeasuredValue'], default=None) _notification_cb = attr.ib(type=Optional[Callable[[], None]], default=None) outcome = attr.ib(type=Outcome, default=Outcome.UNSET) + marginal = attr.ib(type=bool, default=False) # Runtime cache to speed up conversions. _cached = attr.ib(type=Optional[Dict[Text, Any]], default=None) @@ -341,7 +345,7 @@ def with_validator(self, validator: Callable[[Any], bool]) -> 'Measurement': return self def validate_on( - self, result_to_validator_mapping: Dict[diagnoses_lib.DiagResultEnum, + self, result_to_validator_mapping: Dict['diagnoses_lib.DiagResultEnum', Callable[[Any], bool]] ) -> 'Measurement': """Adds conditional validators. @@ -414,11 +418,17 @@ def _with_validator(*args, **kwargs): return _with_validator def validate(self) -> 'Measurement': - """Validate this measurement and update its 'outcome' field.""" + """Validate this measurement and update 'outcome' and 'marginal' fields.""" # PASS if all our validators return True, otherwise FAIL. try: if all(v(self._measured_value.value) for v in self.validators): self.outcome = Outcome.PASS + + # Only check marginality for passing measurements. + if any( + hasattr(v, 'is_marginal') and + v.is_marginal(self._measured_value.value) for v in self.validators): + self.marginal = True else: self.outcome = Outcome.FAIL return self @@ -811,69 +821,9 @@ def __getitem__(self, name: Text) -> Any: # Return the MeasuredValue's value, MeasuredValue will raise if not set. return m.measured_value.value + # Work around for attrs bug in 20.1.0; after the next release, this can be # removed and `Collection._custom_setattr` can be renamed to `__setattr__`. # https://github.com/python-attrs/attrs/issues/680 Collection.__setattr__ = Collection._custom_setattr # pylint: disable=protected-access del Collection._custom_setattr - - -def measures( - *measurements: Union[Text, Measurement], **kwargs: Any -) -> Callable[[phase_descriptor.PhaseT], phase_descriptor.PhaseDescriptor]: - """Decorator-maker used to declare measurements for phases. - - See the measurements module docstring for examples of usage. - - Args: - *measurements: Measurement objects to declare, or a string name from which - to create a Measurement. - **kwargs: Keyword arguments to pass to Measurement constructor if we're - constructing one. Note that if kwargs are provided, the length of - measurements must be 1, and that value must be a string containing the - measurement name. For valid kwargs, see the definition of the Measurement - class. - - Raises: - InvalidMeasurementTypeError: When the measurement is not defined correctly. - - Returns: - A decorator that declares the measurement(s) for the decorated phase. - """ - - def _maybe_make(meas: Union[Text, Measurement]) -> Measurement: - """Turn strings into Measurement objects if necessary.""" - if isinstance(meas, Measurement): - return meas - elif isinstance(meas, six.string_types): - return Measurement(meas, **kwargs) - raise InvalidMeasurementTypeError('Expected Measurement or string', meas) - - # In case we're declaring a measurement inline, we can only declare one. - if kwargs and len(measurements) != 1: - raise InvalidMeasurementTypeError( - 'If @measures kwargs are provided, a single measurement name must be ' - 'provided as a positional arg first.') - - # Unlikely, but let's make sure we don't allow overriding initial outcome. - if 'outcome' in kwargs: - raise ValueError('Cannot specify outcome in measurement declaration!') - - measurements = [_maybe_make(meas) for meas in measurements] - - # 'measurements' is guaranteed to be a list of Measurement objects here. - def decorate( - wrapped_phase: phase_descriptor.PhaseT - ) -> phase_descriptor.PhaseDescriptor: - """Phase decorator to be returned.""" - phase = phase_descriptor.PhaseDescriptor.wrap_or_copy(wrapped_phase) - duplicate_names = ( - set(m.name for m in measurements) - & set(m.name for m in phase.measurements)) - if duplicate_names: - raise DuplicateNameError('Measurement names duplicated', duplicate_names) - - phase.measurements.extend(measurements) - return phase - - return decorate diff --git a/openhtf/core/monitors.py b/openhtf/core/monitors.py index 2e301b1cf..a9a7ff256 100644 --- a/openhtf/core/monitors.py +++ b/openhtf/core/monitors.py @@ -155,7 +155,7 @@ def wrapper( @openhtf.PhaseOptions(requires_state=True) @plugs.plug(update_kwargs=False, **monitor_plugs) - @measurements.measures( + @openhtf.measures( measurements.Measurement(measurement_name).with_units( units).with_dimensions(uom.MILLISECOND)) @functools.wraps(phase_desc.func) diff --git a/openhtf/core/phase_descriptor.py b/openhtf/core/phase_descriptor.py index cad849bd4..93b3f0575 100644 --- a/openhtf/core/phase_descriptor.py +++ b/openhtf/core/phase_descriptor.py @@ -18,16 +18,19 @@ """ +import collections import enum import inspect import pdb -from typing import Any, Callable, Dict, List, Optional, Text, TYPE_CHECKING, Type, Union +from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Set, Text, TYPE_CHECKING, Type, Union import attr import openhtf from openhtf import util from openhtf.core import base_plugs +from openhtf.core import diagnoses_lib +from openhtf.core import measurements as core_measurements from openhtf.core import phase_nodes from openhtf.core import test_record import openhtf.plugs @@ -36,8 +39,6 @@ import six if TYPE_CHECKING: - from openhtf.core import diagnoses_lib # pylint: disable=g-import-not-at-top - from openhtf.core import measurements as core_measurements # pylint: disable=g-import-not-at-top from openhtf.core import test_state # pylint: disable=g-import-not-at-top @@ -93,6 +94,8 @@ class PhaseOptions(object): otherwise only the TestApi will be passed in. This is useful if a phase needs to wrap another phase for some reason, as PhaseDescriptors can only be invoked with a TestState instance. + force_repeat: If True, force the phase to repeat up to repeat_limit times. + repeat_on_timeout: If consider repeat on phase timeout, default is No. repeat_limit: Maximum number of repeats. None indicates a phase will be repeated infinitely as long as PhaseResult.REPEAT is returned. run_under_pdb: If True, run the phase under the Python Debugger (pdb). When @@ -107,6 +110,8 @@ def PhaseFunc(test, port, other_info): pass timeout_s = attr.ib(type=Optional[TimeoutT], default=None) run_if = attr.ib(type=Optional[Callable[[], bool]], default=None) requires_state = attr.ib(type=bool, default=False) + force_repeat = attr.ib(type=bool, default=False) + repeat_on_timeout = attr.ib(type=bool, default=False) repeat_limit = attr.ib(type=Optional[int], default=None) run_under_pdb = attr.ib(type=bool, default=False) @@ -128,6 +133,8 @@ def __call__(self, phase_func: PhaseT) -> 'PhaseDescriptor': phase.options.run_if = self.run_if if self.requires_state: phase.options.requires_state = self.requires_state + if self.repeat_on_timeout: + phase.options.repeat_on_timeout = self.repeat_on_timeout if self.repeat_limit is not None: phase.options.repeat_limit = self.repeat_limit if self.run_under_pdb: @@ -157,8 +164,7 @@ class PhaseDescriptor(phase_nodes.PhaseNode): func = attr.ib(type=PhaseCallableT) options = attr.ib(type=PhaseOptions, factory=PhaseOptions) plugs = attr.ib(type=List[base_plugs.PhasePlug], factory=list) - measurements = attr.ib( - type=List['core_measurements.Measurement'], factory=list) + measurements = attr.ib(type=List[core_measurements.Measurement], factory=list) diagnosers = attr.ib( type=List['diagnoses_lib.BasePhaseDiagnoser'], factory=list) extra_kwargs = attr.ib(type=Dict[Text, Any], factory=dict) @@ -315,17 +321,21 @@ def __call__(self, Returns: The return value from calling the underlying function. """ - kwargs = dict(self.extra_kwargs) - kwargs.update( - running_test_state.plug_manager.provide_plugs( - (plug.name, plug.cls) for plug in self.plugs if plug.update_kwargs)) - + kwargs = {} if six.PY3: arg_info = inspect.getfullargspec(self.func) keywords = arg_info.varkw else: arg_info = inspect.getargspec(self.func) # pylint: disable=deprecated-method keywords = arg_info.keywords + if arg_info.defaults is not None: + for arg_name, arg_value in zip(arg_info.args[-len(arg_info.defaults):], + arg_info.defaults): + kwargs[arg_name] = arg_value + kwargs.update(self.extra_kwargs) + kwargs.update( + running_test_state.plug_manager.provide_plugs( + (plug.name, plug.cls) for plug in self.plugs if plug.update_kwargs)) # Pass in test_api if the phase takes *args, or **kwargs with at least 1 # positional, or more positional args than we have keyword args. if arg_info.varargs or (keywords and len(arg_info.args) >= 1) or (len( @@ -344,3 +354,124 @@ def __call__(self, return pdb.runcall(self.func, **kwargs) else: return self.func(**kwargs) + + +def measures(*measurements: Union[Text, core_measurements.Measurement], + **kwargs: Any) -> Callable[[PhaseT], PhaseDescriptor]: + """Creates decorators to declare measurements for phases. + + See the measurements module docstring for examples of usage. + + Args: + *measurements: Measurement objects to declare, or a string name from which + to create a Measurement. + **kwargs: Keyword arguments to pass to Measurement constructor if we're + constructing one. Note that if kwargs are provided, the length of + measurements must be 1, and that value must be a string containing the + measurement name. For valid kwargs, see the definition of the Measurement + class. + + Raises: + InvalidMeasurementTypeError: When the measurement is not defined correctly. + ValueError: If a measurement already has an outcome. + DuplicateNameError: If a measurement's name is already in use. + + Returns: + A decorator that declares the measurement(s) for the decorated phase. + """ + + def _maybe_make( + meas: Union[Text, core_measurements.Measurement] + ) -> core_measurements.Measurement: + """Turn strings into Measurement objects if necessary.""" + if isinstance(meas, core_measurements.Measurement): + return meas + elif isinstance(meas, six.string_types): + return core_measurements.Measurement(meas, **kwargs) + raise core_measurements.InvalidMeasurementTypeError( + 'Expected Measurement or string', meas) + + # In case we're declaring a measurement inline, we can only declare one. + if kwargs and len(measurements) != 1: + raise core_measurements.InvalidMeasurementTypeError( + 'If @measures kwargs are provided, a single measurement name must be ' + 'provided as a positional arg first.') + + # Unlikely, but let's make sure we don't allow overriding initial outcome. + if 'outcome' in kwargs: + raise ValueError('Cannot specify outcome in measurement declaration!') + + measurements = [_maybe_make(meas) for meas in measurements] + + # 'measurements' is guaranteed to be a list of Measurement objects here. + def decorate(wrapped_phase: PhaseT) -> PhaseDescriptor: + """Phase decorator to be returned.""" + phase = PhaseDescriptor.wrap_or_copy(wrapped_phase) + duplicate_names = ( + set(m.name for m in measurements) + & set(m.name for m in phase.measurements)) + if duplicate_names: + raise core_measurements.DuplicateNameError('Measurement names duplicated', + duplicate_names) + + phase.measurements.extend(measurements) + return phase + + return decorate + + +class DuplicateResultError(Exception): + """Different DiagResultEnum instances define the same value.""" + + +def check_for_duplicate_results( + phase_iterator: Iterator[PhaseDescriptor], + test_diagnosers: Sequence[diagnoses_lib.BaseTestDiagnoser]) -> None: + """Check for any results with the same enum value in different ResultTypes. + + Args: + phase_iterator: iterator over the phases to check. + test_diagnosers: list of test level diagnosers. + + Raises: + DuplicateResultError: when duplicate enum values are found. + """ + all_result_enums: Set[Type[diagnoses_lib.DiagResultEnum]] = set() + for phase in phase_iterator: + for phase_diag in phase.diagnosers: + all_result_enums.add(phase_diag.result_type) + for test_diag in test_diagnosers: + all_result_enums.add(test_diag.result_type) + + values_to_enums = collections.defaultdict(list) + for enum_cls in all_result_enums: + # pytype incorrectly determines that the enum cannot be iterated over. Using + # __members__.values() allows direct type inference. + for entry in enum_cls.__members__.values(): + values_to_enums[entry.value].append(enum_cls) + + duplicates: List[str] = [] + for result_value, enum_classes in sorted(values_to_enums.items()): + if len(enum_classes) > 1: + duplicates.append('Value "{}" defined by {}'.format( + result_value, enum_classes)) + if not duplicates: + return + raise DuplicateResultError('Duplicate DiagResultEnum values: {}'.format( + '\n'.join(duplicates))) + + +def diagnose( + *diagnosers: diagnoses_lib.BasePhaseDiagnoser +) -> Callable[[PhaseT], PhaseDescriptor]: + """Decorator to add diagnosers to a PhaseDescriptor.""" + diagnoses_lib.check_diagnosers(diagnosers, diagnoses_lib.BasePhaseDiagnoser) + diags = list(diagnosers) + + def decorate(wrapped_phase: PhaseT) -> PhaseDescriptor: + """Phase decorator to be returned.""" + phase = PhaseDescriptor.wrap_or_copy(wrapped_phase) + phase.diagnosers.extend(diags) + return phase + + return decorate diff --git a/openhtf/core/phase_executor.py b/openhtf/core/phase_executor.py index de3f401cc..766e4e10a 100644 --- a/openhtf/core/phase_executor.py +++ b/openhtf/core/phase_executor.py @@ -52,6 +52,8 @@ from openhtf.core import test_state as htf_test_state # pylint: disable=g-import-not-at-top DEFAULT_PHASE_TIMEOUT_S = 3 * 60 +DEFAULT_RETRIES = 3 +_JOIN_TRY_INTERVAL_SECONDS = 3 ARG_PARSER = argv.module_parser() ARG_PARSER.add_argument( @@ -69,8 +71,8 @@ class ExceptionInfo(object): """Wrap the description of a raised exception and its traceback.""" - exc_type = attr.ib(type=Type[Exception]) - exc_val = attr.ib(type=Exception) + exc_type = attr.ib(type=Type[BaseException]) + exc_val = attr.ib(type=BaseException) exc_tb = attr.ib(type=types.TracebackType) def as_base_types(self) -> Dict[Text, Text]: @@ -200,10 +202,15 @@ def _thread_exception(self, *args) -> bool: def join_or_die(self) -> PhaseExecutionOutcome: """Wait for thread to finish, returning a PhaseExecutionOutcome instance.""" + deadline = time.monotonic() + DEFAULT_PHASE_TIMEOUT_S if self._phase_desc.options.timeout_s is not None: - self.join(self._phase_desc.options.timeout_s) - else: - self.join(DEFAULT_PHASE_TIMEOUT_S) + deadline = time.monotonic() + self._phase_desc.options.timeout_s + while time.monotonic() < deadline: + # Using exception to kill thread is not honored when thread is busy, + # so we leave the thread behind, and move on teardown. + self.join(_JOIN_TRY_INTERVAL_SECONDS) + if not self.is_alive() or self._killed.is_set(): + break # We got a return value or an exception and handled it. if self._phase_execution_outcome: @@ -260,13 +267,19 @@ def execute_phase( requested and successfully ran for this phase execution. """ repeat_count = 1 - repeat_limit = phase.options.repeat_limit or sys.maxsize + repeat_limit = (phase.options.repeat_limit or + DEFAULT_RETRIES) while not self._stopping.is_set(): is_last_repeat = repeat_count >= repeat_limit phase_execution_outcome, profile_stats = self._execute_phase_once( phase, is_last_repeat, run_with_profiling, subtest_rec) - if phase_execution_outcome.is_repeat and not is_last_repeat: + # Give 3 default retries for timeout phase. + # Force repeat up to the repeat limit if force_repeat is set. + if ((phase_execution_outcome.is_timeout and + phase.options.repeat_on_timeout) or + phase_execution_outcome.is_repeat or + phase.options.force_repeat) and not is_last_repeat: repeat_count += 1 continue diff --git a/openhtf/core/phase_group.py b/openhtf/core/phase_group.py index 1694ab1fb..c62357434 100644 --- a/openhtf/core/phase_group.py +++ b/openhtf/core/phase_group.py @@ -141,7 +141,7 @@ def combine(self, def wrap(self, main_phases: phase_collections.SequenceInitializerT, - name: Text = None) -> 'PhaseGroup': + name: Optional[Text] = None) -> 'PhaseGroup': """Returns PhaseGroup with additional main phases.""" other = PhaseGroup(main=main_phases) return self.combine(other, name=name) diff --git a/openhtf/core/test_descriptor.py b/openhtf/core/test_descriptor.py index 50183b6e5..43e7e3278 100644 --- a/openhtf/core/test_descriptor.py +++ b/openhtf/core/test_descriptor.py @@ -65,6 +65,10 @@ default_value=False) +class AttachmentNotFoundError(Exception): + """Raised when test attachment not found.""" + + class UnrecognizedTestUidError(Exception): """Raised when information is requested about an unknown Test UID.""" @@ -284,7 +288,7 @@ def execute(self, Raises: InvalidTestStateError: if this test is already being executed. """ - diagnoses_lib.check_for_duplicate_results( + phase_descriptor.check_for_duplicate_results( self._test_desc.phase_sequence.all_phases(), self._test_options.diagnosers) phase_collections.check_for_duplicate_subtest_names( @@ -362,12 +366,17 @@ def trigger_phase(test): (colorama.Style.BRIGHT, colorama.Fore.GREEN)) # pytype: disable=wrong-arg-types colors[htf_test_record.Outcome.FAIL] = ''.join( (colorama.Style.BRIGHT, colorama.Fore.RED)) # pytype: disable=wrong-arg-types - msg_template = 'test: {name} outcome: {color}{outcome}{rst}' + msg_template = ( + 'test: {name} outcome: {color}{outcome}{marginal}{rst}') console_output.banner_print( msg_template.format( name=final_state.test_record.metadata['test_name'], - color=colors[final_state.test_record.outcome], + color=(colorama.Fore.YELLOW + if final_state.test_record.marginal else + colors[final_state.test_record.outcome]), outcome=final_state.test_record.outcome.name, + marginal=(' (MARGINAL)' + if final_state.test_record.marginal else ''), rst=colorama.Style.RESET_ALL)) finally: del self.TEST_INSTANCES[self.uid] @@ -573,6 +582,9 @@ def get_attachment( self, attachment_name: Text) -> Optional[htf_test_record.Attachment]: """Get a copy of an attachment from current or previous phases. + This method will return None when test attachment is not found. Please use + get_attachment_strict method if exception is expected to be raised. + Args: attachment_name: str of the attachment name @@ -581,6 +593,25 @@ def get_attachment( """ return self._running_test_state.get_attachment(attachment_name) + def get_attachment_strict( + self, attachment_name: Text) -> htf_test_record.Attachment: + """Gets a copy of an attachment or dies when attachment not found. + + Args: + attachment_name: An attachment name. + + Returns: + A copy of the attachment. + + Raises: + AttachmentNotFoundError: Raised when attachment is not found. + """ + attachment = self.get_attachment(attachment_name) + if attachment is None: + raise AttachmentNotFoundError('Failed to find test attachment: ' + f'{attachment_name}') + return attachment + def notify_update(self) -> None: """Notify any update events that there was an update.""" self._running_test_state.notify_update() diff --git a/openhtf/core/test_record.py b/openhtf/core/test_record.py index 8bb768f21..047eede28 100644 --- a/openhtf/core/test_record.py +++ b/openhtf/core/test_record.py @@ -178,6 +178,7 @@ class TestRecord(object): type=List['diagnoses_lib.BaseTestDiagnoser'], factory=list) diagnoses = attr.ib(type=List['diagnoses_lib.Diagnosis'], factory=list) log_records = attr.ib(type=List[logs.LogRecord], factory=list) + marginal = attr.ib(type=Optional[bool], default=None) # Cache fields to reduce repeated base type conversions. _cached_record = attr.ib(type=Dict[Text, Any], factory=dict) @@ -249,6 +250,7 @@ def as_base_types(self) -> Dict[Text, Any]: 'end_time_millis': self.end_time_millis, 'outcome': data.convert_to_base_types(self.outcome), 'outcome_details': data.convert_to_base_types(self.outcome_details), + 'marginal': self.marginal, 'metadata': metadata, 'phases': self._cached_phases, 'subtests': self._cached_subtests, @@ -366,6 +368,7 @@ class PhaseRecord(object): result = attr.ib( type=Optional['phase_executor.PhaseExecutionOutcome'], default=None) outcome = attr.ib(type=Optional[PhaseOutcome], default=None) + marginal = attr.ib(type=Optional[bool], default=None) @classmethod def from_descriptor( @@ -411,6 +414,7 @@ class SubtestRecord(object): start_time_millis = attr.ib(type=int, default=0) end_time_millis = attr.ib(type=Optional[int], default=None) outcome = attr.ib(type=Optional[SubtestOutcome], default=None) + marginal = attr.ib(type=Optional[bool], default=None) @property def is_fail(self) -> bool: diff --git a/openhtf/core/test_state.py b/openhtf/core/test_state.py index 780a347b0..015dd9bbb 100644 --- a/openhtf/core/test_state.py +++ b/openhtf/core/test_state.py @@ -408,13 +408,19 @@ def finalize_from_phase_outcome( 'Finishing test execution early due to an exception raised during ' 'phase execution; outcome ERROR.') # Enable CLI printing of the full traceback with the -v flag. - self.state_logger.critical( - 'Traceback:%s%s%s%s', - os.linesep, - phase_execution_outcome.phase_result.get_traceback_string(), - os.linesep, - description, - ) + if isinstance(result, phase_executor.ExceptionInfo): + self.state_logger.critical( + 'Traceback:%s%s%s%s', + os.linesep, + phase_execution_outcome.phase_result.get_traceback_string(), + os.linesep, + description, + ) + else: + self.state_logger.critical( + 'Description:%s', + description, + ) self._finalize(test_record.Outcome.ERROR) elif phase_execution_outcome.is_timeout: self.state_logger.error('Finishing test execution early due to ' @@ -461,6 +467,7 @@ def finalize_normally(self) -> None: self._finalize(test_record.Outcome.FAIL) else: # Otherwise, the test run was successful. + self.test_record.marginal = any(phase.marginal for phase in phases) self._finalize(test_record.Outcome.PASS) self.state_logger.debug( @@ -535,6 +542,7 @@ class PhaseState(object): place to save allocation time. attachments: Convenience accessor for phase_record.attachments. result: Convenience getter/setter for phase_record.result. + marginal: Convenience getter/setter for phase_record.marginal. """ name = attr.ib(type=Text) @@ -627,6 +635,14 @@ def set_subtest_name(self, subtest_name: Text) -> None: self.phase_record.subtest_name = subtest_name self._cached['subtest_name'] = subtest_name + @property + def marginal(self) -> Optional[phase_executor.PhaseExecutionOutcome]: + return self.phase_record.marginal + + @marginal.setter + def marginal(self, marginal: bool): + self.phase_record.marginal = marginal + @property def attachments(self) -> Dict[Text, test_record.Attachment]: return self.phase_record.attachments @@ -730,27 +746,35 @@ def _measurements_pass(self) -> bool: return all(meas.outcome in allowed_outcomes for meas in self.phase_record.measurements.values()) + def _measurements_marginal(self) -> bool: + return any( + meas.marginal for meas in self.phase_record.measurements.values()) + def _set_prediagnosis_phase_outcome(self) -> None: """Set the phase outcome before running diagnosers.""" result = self.result if result is None or result.is_terminal or self.hit_repeat_limit: - self.logger.debug('Phase outcome is ERROR.') + self.logger.debug('Phase outcome of %s is ERROR.', self.name) outcome = test_record.PhaseOutcome.ERROR elif result.is_repeat or result.is_skip: - self.logger.debug('Phase outcome is SKIP.') + self.logger.debug('Phase outcome of %s is SKIP.', self.name) outcome = test_record.PhaseOutcome.SKIP elif result.is_fail_subtest: - self.logger.debug('Phase outcome is FAIL due to subtest failure.') + self.logger.debug('Phase outcome of %s is FAIL due to subtest failure.', + self.name) outcome = test_record.PhaseOutcome.FAIL elif result.is_fail_and_continue: - self.logger.debug('Phase outcome is FAIL due to phase result.') + self.logger.debug('Phase outcome of %s is FAIL due to phase result.', + self.name) outcome = test_record.PhaseOutcome.FAIL elif not self._measurements_pass(): - self.logger.debug('Phase outcome is FAIL due to measurement outcome.') + self.logger.debug( + 'Phase outcome of %s is FAIL due to measurement outcome.', self.name) outcome = test_record.PhaseOutcome.FAIL else: - self.logger.debug('Phase outcome is PASS.') + self.logger.debug('Phase outcome of %s is PASS.', self.name) outcome = test_record.PhaseOutcome.PASS + self.phase_record.marginal = self._measurements_marginal() self.phase_record.outcome = outcome def _set_postdiagnosis_phase_outcome(self) -> None: @@ -759,13 +783,15 @@ def _set_postdiagnosis_phase_outcome(self) -> None: return # Check for errors during diagnoser execution. if self.result is None or self.result.is_terminal: - self.logger.debug('Phase outcome is ERROR due to diagnoses.') + self.logger.debug('Phase outcome of %s is ERROR due to diagnoses.', + self.name) self.phase_record.outcome = test_record.PhaseOutcome.ERROR return if self.phase_record.outcome != test_record.PhaseOutcome.PASS: return if self.phase_record.failure_diagnosis_results: - self.logger.debug('Phase outcome is FAIL due to diagnoses.') + self.logger.debug('Phase outcome of %s is FAIL due to diagnoses.', + self.name) self.phase_record.outcome = test_record.PhaseOutcome.FAIL def _execute_phase_diagnoser( diff --git a/openhtf/output/callbacks/__init__.py b/openhtf/output/callbacks/__init__.py index d83ea1e02..0002a9ac6 100644 --- a/openhtf/output/callbacks/__init__.py +++ b/openhtf/output/callbacks/__init__.py @@ -83,7 +83,7 @@ def __init__(self, filename_pattern_or_file: Union[Text, Callable[..., Text], self.output_file = None # type: Optional[BinaryIO] if (isinstance(filename_pattern_or_file, six.string_types) or callable(filename_pattern_or_file)): - self.filename_pattern = filename_pattern_or_file + self.filename_pattern = filename_pattern_or_file # pytype: disable=annotation-type-mismatch else: self.output_file = filename_pattern_or_file diff --git a/openhtf/output/callbacks/mfg_inspector.py b/openhtf/output/callbacks/mfg_inspector.py index 28ad52803..900785961 100644 --- a/openhtf/output/callbacks/mfg_inspector.py +++ b/openhtf/output/callbacks/mfg_inspector.py @@ -1,19 +1,23 @@ """Output and/or upload a TestRun or MfgEvent proto for mfg-inspector.com. """ +import copy import json import logging import threading import time +from typing import Any, Dict import zlib import httplib2 import oauth2client.client +from openhtf import util +from openhtf.core import test_record from openhtf.output import callbacks from openhtf.output.proto import guzzle_pb2 +from openhtf.output.proto import mfg_event_pb2 from openhtf.output.proto import test_runs_converter - import six from six.moves import range @@ -138,6 +142,12 @@ class MfgInspector(object): # saving to disk via save_to_disk. _default_filename_pattern = None + # Cached last partial upload of the run's MfgEvent. + _cached_partial_proto = None + + # Partial proto fully uploaded. + _partial_proto_upload_complete = False + def __init__(self, user=None, keydata=None, @@ -199,6 +209,44 @@ def _convert(self, test_record_obj): self._cached_params[param] = getattr(test_record_obj, param) return self._cached_proto + def _get_blobref_from_cache(self, attachment_name: str): + """Gets the existing_blobref if attachment was already uploaded.""" + if not self._cached_partial_proto: + return None + + for attachment in self._cached_partial_proto.attachment: + if (attachment.name == attachment_name and + attachment.HasField('existing_blobref')): + return attachment.existing_blobref + + def _get_blobref_from_reply(self, reply: Dict[str, Any], + attachment_name: str): + """Gets the existing_blobref if attachment was already uploaded.""" + for item in reply['extendedParameters']: + if (item['name'] == attachment_name and 'blobRef' in item): + return item['blobRef'] + + def _update_attachments_from_cache(self, proto: mfg_event_pb2.MfgEvent): + """Replaces attachments binary values with blobrefs when applicable.""" + for attachment in proto.attachment: + if attachment.HasField('value_binary'): + blobref = self._get_blobref_from_cache(attachment.name) + if blobref: + attachment.ClearField('value') + attachment.existing_blobref = blobref + + def _update_attachments_from_reply(self, proto: mfg_event_pb2.MfgEvent): + """Replaces attachments binary values with blorrefs when applicable.""" + reply = json.loads(self.upload_result['lite_test_run']) + for attachment in proto.attachment: + if attachment.HasField('value_binary'): + literun_blobref = self._get_blobref_from_reply(reply, attachment.name) + if literun_blobref: + attachment.ClearField('value') + attachment.existing_blobref.blob_id = str.encode( + literun_blobref['BlobID']) + attachment.existing_blobref.size = int(literun_blobref['Size']) + def save_to_disk(self, filename_pattern=None): """Returns a callback to convert test record to proto and save to disk.""" if not self._converter: @@ -238,6 +286,58 @@ def upload_callback(test_record_obj): return upload_callback + def partial_upload(self, payload_type: int = guzzle_pb2.COMPRESSED_TEST_RUN): + """Returns a callback to partially upload a test record as a MfgEvent.""" + if not self._converter: + raise RuntimeError( + 'Must set _converter on subclass or via set_converter before calling ' + 'partial_upload.') + + if not self.credentials: + raise RuntimeError('Must provide credentials to use partial_upload ' + 'callback.') + + def partial_upload_callback(test_record_obj: test_record.TestRecord): + if not test_record_obj.end_time_millis: + # We cannot mutate the test_record_obj, so we copy it to add a + # fake end_time_millis which is needed for MfgEvent construction. + try: + tmp_test_record = copy.deepcopy(test_record_obj) + except TypeError: + # This happens when test has errored but the partial_uploader got a + # hold of the test record before it is finalized. We force an errored + # test to be processed with zero deepcopy thus only after + # end_time_mills is set in the test record. + print('Skipping this upload cycle, waiting for test to be finalized') + return {} + tmp_test_record.end_time_millis = util.time_millis() + # Also fake a PASS outcome for now. + tmp_test_record.outcome = test_record.Outcome.PASS + proto = self._convert(tmp_test_record) + proto.test_run_type = mfg_event_pb2.TEST_RUN_PARTIAL + else: + proto = self._convert(test_record_obj) + proto.test_run_type = mfg_event_pb2.TEST_RUN_COMPLETE + # Replaces the attachment payloads already uploaded with their blob_refs. + if (self._cached_partial_proto and + self._cached_partial_proto.start_time_ms == proto.start_time_ms): + # Reads the attachments in the _cached_partial_proto and merge them into + # the proto. + self._update_attachments_from_cache(proto) + # Avoids timing issue whereby last complete upload performed twice. + # This is only for projects that use a partial uploader to mfg-inspector. + if not self._partial_proto_upload_complete: + self.upload_result = send_mfg_inspector_data( + proto, self.credentials, self.destination_url, payload_type) + # Reads the upload_result (a lite_test_run proto) and update the + # attachments blob_refs. + self._update_attachments_from_reply(proto) + if proto.test_run_type == mfg_event_pb2.TEST_RUN_COMPLETE: + self._partial_proto_upload_complete = True + return self.upload_result + + return partial_upload_callback + def set_converter(self, converter): """Set converter callable to convert a OpenHTF tester_record to a proto. diff --git a/openhtf/output/proto/mfg_event_converter.py b/openhtf/output/proto/mfg_event_converter.py index ae4613a9e..89ce78253 100644 --- a/openhtf/output/proto/mfg_event_converter.py +++ b/openhtf/output/proto/mfg_event_converter.py @@ -14,6 +14,7 @@ import numbers import os import sys +from typing import Tuple from openhtf.core import measurements from openhtf.core import test_record as htf_test_record @@ -23,12 +24,9 @@ from openhtf.util import data as htf_data from openhtf.util import units from openhtf.util import validators - - from past.builtins import unicode import six - TEST_RECORD_ATTACHMENT_NAME = 'OpenHTF_record.json' # To be lazy loaded by _LazyLoadUnitsByCode when needed. @@ -37,6 +35,7 @@ # Map test run Status (proto) name to measurement Outcome (python) enum's and # the reverse. Note: there is data lost in converting an UNSET/PARTIALLY_SET to # an ERROR so we can't completely reverse the transformation. + MEASUREMENT_OUTCOME_TO_TEST_RUN_STATUS_NAME = { measurements.Outcome.PASS: 'PASS', measurements.Outcome.FAIL: 'FAIL', @@ -45,11 +44,25 @@ } TEST_RUN_STATUS_NAME_TO_MEASUREMENT_OUTCOME = { 'PASS': measurements.Outcome.PASS, + 'MARGINAL_PASS': measurements.Outcome.PASS, 'FAIL': measurements.Outcome.FAIL, 'ERROR': measurements.Outcome.UNSET } +def _measurement_outcome_to_test_run_status_name(outcome: measurements.Outcome, + marginal: bool) -> str: + """Returns the test run status name given the outcome and marginal args.""" + return ('MARGINAL_PASS' + if marginal else MEASUREMENT_OUTCOME_TO_TEST_RUN_STATUS_NAME[outcome]) + + +def _test_run_status_name_to_measurement_outcome_and_marginal( + name: str) -> Tuple[measurements.Outcome, bool]: + """Returns the outcome and marginal args given the test run status name.""" + return TEST_RUN_STATUS_NAME_TO_MEASUREMENT_OUTCOME[name], 'MARGINAL' in name + + def _lazy_load_units_by_code(): """Populate dict of units by code iff UNITS_BY_CODE is empty.""" if UNITS_BY_CODE: @@ -103,7 +116,8 @@ def mfg_event_from_test_record(record): return mfg_event -def _populate_basic_data(mfg_event, record): +def _populate_basic_data(mfg_event: mfg_event_pb2.MfgEvent, + record: htf_test_record.TestRecord) -> None: """Copies data from the OpenHTF TestRecord to the MfgEvent proto.""" # TODO(openhtf-team): # * Missing in proto: set run name from metadata. @@ -117,10 +131,12 @@ def _populate_basic_data(mfg_event, record): mfg_event.end_time_ms = record.end_time_millis mfg_event.tester_name = record.station_id mfg_event.test_name = record.metadata.get('test_name') or record.station_id - mfg_event.test_status = test_runs_converter.OUTCOME_MAP[record.outcome] mfg_event.operator_name = record.metadata.get('operator_name', '') mfg_event.test_version = str(record.metadata.get('test_version', '')) mfg_event.test_description = record.metadata.get('test_description', '') + mfg_event.test_status = ( + test_runs_pb2.MARGINAL_PASS + if record.marginal else test_runs_converter.OUTCOME_MAP[record.outcome]) # Populate part_tags. mfg_event.part_tags.extend(record.metadata.get('part_tags', [])) @@ -284,9 +300,11 @@ def multidim_measurement_to_attachment(name, measurement): }) # Refer to the module docstring for the expected schema. dimensioned_measured_value = measurement.measured_value - value = (sorted(dimensioned_measured_value.value, key=lambda x: x[0]) - if dimensioned_measured_value.is_value_set else None) - outcome_str = MEASUREMENT_OUTCOME_TO_TEST_RUN_STATUS_NAME[measurement.outcome] + value = ( + sorted(dimensioned_measured_value.value, key=lambda x: x[0]) + if dimensioned_measured_value.is_value_set else None) + outcome_str = _measurement_outcome_to_test_run_status_name( + measurement.outcome, measurement.marginal) data = _convert_object_to_json({ 'outcome': outcome_str, 'name': name, @@ -333,11 +351,11 @@ def copy_measurements(self, mfg_event): for name, measurement in sorted(phase.measurements.items()): # Multi-dim measurements should already have been removed. assert measurement.dimensions is None - self._copy_unidimensional_measurement( - phase, name, measurement, mfg_event) + self._copy_unidimensional_measurement(phase, name, measurement, + mfg_event) - def _copy_unidimensional_measurement( - self, phase, name, measurement, mfg_event): + def _copy_unidimensional_measurement(self, phase, name, measurement, + mfg_event): """Copy uni-dimensional measurements to the MfgEvent.""" mfg_measurement = mfg_event.measurement.add() @@ -361,8 +379,8 @@ def _copy_unidimensional_measurement( # Copy measurement value. measured_value = measurement.measured_value - status_str = MEASUREMENT_OUTCOME_TO_TEST_RUN_STATUS_NAME[ - measurement.outcome] + status_str = _measurement_outcome_to_test_run_status_name( + measurement.outcome, measurement.marginal) mfg_measurement.status = test_runs_pb2.Status.Value(status_str) if not measured_value.is_value_set: return @@ -388,6 +406,12 @@ def _copy_unidimensional_measurement( mfg_measurement.numeric_minimum = float(validator.minimum) if validator.maximum is not None: mfg_measurement.numeric_maximum = float(validator.maximum) + if validator.marginal_minimum is not None: + mfg_measurement.numeric_marginal_minimum = float( + validator.marginal_minimum) + if validator.marginal_maximum is not None: + mfg_measurement.numeric_marginal_maximum = float( + validator.marginal_maximum) elif isinstance(validator, validators.RegexMatcher): mfg_measurement.expected_text = validator.regex else: @@ -430,7 +454,7 @@ def attachment_to_multidim_measurement(attachment, name=None): Args: attachment: an `openhtf.test_record.Attachment` from a multi-dim. name: an optional name for the measurement. If not provided will use the - name included in the attachment. + name included in the attachment. Returns: An multi-dim `openhtf.Measurement`. @@ -453,8 +477,13 @@ def attachment_to_multidim_measurement(attachment, name=None): attachment_outcome_str = None # Convert test status outcome str to measurement outcome - outcome = TEST_RUN_STATUS_NAME_TO_MEASUREMENT_OUTCOME.get( - attachment_outcome_str) + if attachment_outcome_str: + outcome, marginal = ( + _test_run_status_name_to_measurement_outcome_and_marginal( + attachment_outcome_str)) + else: + outcome = None + marginal = False # convert dimensions into htf.Dimensions _lazy_load_units_by_code() @@ -476,9 +505,7 @@ def attachment_to_multidim_measurement(attachment, name=None): # created dimensioned_measured_value and populate with values. measured_value = measurements.DimensionedMeasuredValue( - name=name, - num_dimensions=len(dimensions) - ) + name=name, num_dimensions=len(dimensions)) for row in attachment_values: coordinates = tuple(row[:-1]) val = row[-1] @@ -489,6 +516,6 @@ def attachment_to_multidim_measurement(attachment, name=None): units=units_, dimensions=tuple(dimensions), measured_value=measured_value, - outcome=outcome - ) + outcome=outcome, + marginal=marginal) return measurement diff --git a/openhtf/output/proto/test_runs_converter.py b/openhtf/output/proto/test_runs_converter.py index f12a18aed..467985f7d 100644 --- a/openhtf/output/proto/test_runs_converter.py +++ b/openhtf/output/proto/test_runs_converter.py @@ -92,7 +92,9 @@ def _populate_header(record, testrun): testrun.test_info.description = record.metadata['test_description'] if 'test_version' in record.metadata: testrun.test_info.version_string = record.metadata['test_version'] - testrun.test_status = OUTCOME_MAP[record.outcome] + testrun.test_status = ( + test_runs_pb2.MARGINAL_PASS + if record.marginal else OUTCOME_MAP[record.outcome]) testrun.start_time_millis = record.start_time_millis testrun.end_time_millis = record.end_time_millis if 'run_name' in record.metadata: @@ -214,7 +216,9 @@ def _extract_parameters(record, testrun, used_parameter_names): if measurement.units and measurement.units.code in UOM_CODE_MAP: testrun_param.unit_code = UOM_CODE_MAP[measurement.units.code] - if measurement.outcome == measurements.Outcome.PASS: + if measurement.marginal: + testrun_param.status = test_runs_pb2.MARGINAL_PASS + elif measurement.outcome == measurements.Outcome.PASS: testrun_param.status = test_runs_pb2.PASS elif (not measurement.measured_value or not measurement.measured_value.is_value_set): diff --git a/openhtf/output/servers/station_server.py b/openhtf/output/servers/station_server.py index 117b3ba6a..899cff6a0 100644 --- a/openhtf/output/servers/station_server.py +++ b/openhtf/output/servers/station_server.py @@ -359,7 +359,7 @@ def get(self, test_uid): phase_descriptors = [ dict(id=id(phase), **data.convert_to_base_types(phase)) - for phase in test.descriptor.phase_group + for phase in test.descriptor.phase_sequence.all_phases() ] # Wrap value in a dict because writing a list directly is prohibited. diff --git a/openhtf/plugs/__init__.py b/openhtf/plugs/__init__.py index 7ba7dc320..81d6ab23e 100644 --- a/openhtf/plugs/__init__.py +++ b/openhtf/plugs/__init__.py @@ -166,7 +166,7 @@ class PlugManager(object): """ def __init__(self, - plug_types: Set[Type[base_plugs.BasePlug]] = None, + plug_types: Optional[Set[Type[base_plugs.BasePlug]]] = None, record_logger: Optional[logging.Logger] = None): self._plug_types = plug_types or set() for plug_type in self._plug_types: diff --git a/openhtf/plugs/device_wrapping.py b/openhtf/plugs/device_wrapping.py index 01472e536..91cb0a9aa 100644 --- a/openhtf/plugs/device_wrapping.py +++ b/openhtf/plugs/device_wrapping.py @@ -39,6 +39,13 @@ def short_repr(obj, max_len=40): return '<{} of length {}>'.format(type(obj).__name__, len(obj_repr)) +class DeviceWrappingPlugNotFullyInitializedError(base_plugs.InvalidPlugError): + """Raised if DeviceWrappingPlug instances do not have _device set. + + Generally a by a subclass __init__ failing to call the superclass __init__. + """ + + class DeviceWrappingPlug(base_plugs.BasePlug): """A base plug for wrapping existing device abstractions. @@ -89,6 +96,12 @@ def __setattr__(self, name, value): setattr(self._device, name, value) def __getattr__(self, attr): + if attr == '_device': + # _device was not found in the instance attributes. + raise DeviceWrappingPlugNotFullyInitializedError( + f'{type(self)} must set _device. This is genally done in __init__ by ' + 'calling super().__init__(device)') + if self._device is None: raise base_plugs.InvalidPlugError( 'DeviceWrappingPlug instances must set the _device attribute.') diff --git a/openhtf/util/console_output.py b/openhtf/util/console_output.py index dd1d7d781..56ee5d0c8 100644 --- a/openhtf/util/console_output.py +++ b/openhtf/util/console_output.py @@ -74,7 +74,7 @@ def _linesep_for_file(file): return '\n' -def banner_print(msg, color='', width=60, file=sys.stdout, logger=_LOG): +def banner_print(msg, color='', width=80, file=sys.stdout, logger=_LOG): """Print the message as a banner with a fixed width. Also logs the message (un-bannered) to the given logger at the debug level. diff --git a/openhtf/util/data.py b/openhtf/util/data.py index ce929b0aa..e49c08339 100644 --- a/openhtf/util/data.py +++ b/openhtf/util/data.py @@ -169,7 +169,12 @@ def convert_to_base_types(obj, if hasattr(obj, 'as_base_types'): return obj.as_base_types() if hasattr(obj, '_asdict'): - obj = obj._asdict() + try: + obj = obj._asdict() + except TypeError as e: + # This happens if the object is an uninitialized class. + logging.warning( + 'Object %s is not initialized, got error %s', obj, e) elif isinstance(obj, records.RecordClass): new_obj = {} for a in type(obj).all_attribute_names: diff --git a/openhtf/util/multicast.py b/openhtf/util/multicast.py index c66c0d542..7b4c0b84a 100644 --- a/openhtf/util/multicast.py +++ b/openhtf/util/multicast.py @@ -23,6 +23,8 @@ import struct import sys import threading +import time + from six.moves import queue _LOG = logging.getLogger(__name__) @@ -36,6 +38,7 @@ DEFAULT_TTL = 1 LOCALHOST_ADDRESS = 0x7f000001 # 127.0.0.1 MAX_MESSAGE_BYTES = 1024 # Maximum allowable message length in bytes. +_SOCKOPT_RETRY_SECONDS = 10 # Short delay before retrying socket registration. class MulticastListener(threading.Thread): @@ -82,6 +85,24 @@ def stop(self, timeout_s=None): pass self.join(timeout_s) + def _add_multicast_membership(self, interface_ip: int) -> bool: + """Returns True if the interface is successfully added for multicast.""" + try: + # IP_ADD_MEMBERSHIP takes the 8-byte group address followed by the + # IP assigned to the interface on which to listen. + self._sock.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, + struct.pack('!4sL', socket.inet_aton(self.address), interface_ip)) # pylint: disable=g-socket-inet-aton + except OSError: + multicast_type = ('local' + if interface_ip == LOCALHOST_ADDRESS else 'default') + _LOG.debug( + 'Failed setsockopt for %s multicast. Will retry. Traceback:', + multicast_type, + exc_info=True) + return False + return True + def run(self): """Listen for pings until stopped.""" self._live = True @@ -91,12 +112,10 @@ def run(self): # The localhost address is used to receive messages sent in "local_only" # mode and the default address is used to receive all other messages. for interface_ip in (socket.INADDR_ANY, LOCALHOST_ADDRESS): - self._sock.setsockopt( - socket.IPPROTO_IP, - socket.IP_ADD_MEMBERSHIP, - # IP_ADD_MEMBERSHIP takes the 8-byte group address followed by the IP - # assigned to the interface on which to listen. - struct.pack('!4sL', socket.inet_aton(self.address), interface_ip)) # pylint: disable=g-socket-inet-aton + while not self._add_multicast_membership(interface_ip): + if not self._live: + return + time.sleep(_SOCKOPT_RETRY_SECONDS) if sys.platform == 'darwin': self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, @@ -162,24 +181,23 @@ def send(query, recv_queue = queue.Queue() def _handle_responses(): + """Collects responses in the receive queue as soon as they are received.""" while True: try: data, address = sock.recvfrom(MAX_MESSAGE_BYTES) - data = data.decode('utf-8') except socket.timeout: - recv_queue.put(None) - break + break # Give up, all stations have likely responded. else: + data = data.decode('utf-8') _LOG.debug('Multicast response to query "%s": %s:%s', query, address[0], data) - recv_queue.put((address[0], str(data))) + recv_queue.put((address[0], data)) - # Yield responses as they come in, giving up once timeout expires. response_thread = threading.Thread(target=_handle_responses) response_thread.start() - while response_thread.is_alive(): - recv_tuple = recv_queue.get() - if not recv_tuple: - break + while not recv_queue.empty() or response_thread.is_alive(): + try: + recv_tuple = recv_queue.get(block=True, timeout=timeout_s) + except queue.Empty: + continue # Retry until the thread is no longer alive. yield recv_tuple - response_thread.join() diff --git a/openhtf/util/test.py b/openhtf/util/test.py index 355b7d0f9..25d173161 100644 --- a/openhtf/util/test.py +++ b/openhtf/util/test.py @@ -14,22 +14,20 @@ """Unit test helpers for OpenHTF tests and phases. This module provides some utility for unit testing OpenHTF test phases and -whole tests. Primarily, otherwise difficult to mock mechanisms are mocked -for you, and there are a handful of convenience assertions that may be used -to write more readable (and less fragile against output format change) tests. +whole tests. Primarily, there are: +1. Mechanisms to aid in running phases and tests. +2. Convenience methods to mock plugs. +3. Assertions to validate phase/test output. The primary class in this module is the TestCase class, which is a subclass of unittest.TestCase that provides some extra utility. Use it the same way you would use unittest.TestCase. See below for examples. -OpenHTF plugs are somewhat difficult to mock (because references are grabbed -at import time, you end up having to poke at framework internals to do this), -so there's a utility decorator for doing just this, @patch_plugs. See below -for examples of how to use it. Note that plugs are initialized once and torn -down once for a single method decorated with @patch_plugs (regardless of how -many phases or Test objects are yielded). If you need new plug instances, -separate your test into separate decorated test* methods in your test case -(this is good practice anyway). +Since the test executor manages the plugs, TestCase.plugs and +TestCase.auto_mock_plugs maybe be used to set or access plug instances. Also +available is a test method decorator, @patch_plugs, but it is less flexible and +should be avoided in new code. In both cases, limit yourself to one phase/test +execution per test method to avoid surprises with plug lifetimes. Lastly, while not implemented here, it's common to need to temporarily alter configuration values for individual tests. This can be accomplished with the @@ -44,6 +42,20 @@ class PhasesTest(test.TestCase): + # Using TestCase.execute_phase_or_test, which allows more flexibility. + def test_using_execute_phase_or_test(self): + self.auto_mock_plugs(PlugA) + # Use below stub object instead of PlugB. + self.plugs[PlugB] = PlugBStub() + self.plugs[PlugA].read_something.return_value = 1234 + + # Run your OpenHTF phase/test, returning phase record. Do only one of + # these per test method to avoid unexpected behavior with plugs. + phase_record = self.execute_phase_or_test(mytest.first_phase) + self.plugs[PlugA].read_something.assert_called_once_with() + # assert* methods for checking phase/test records are defined in TestCase. + self.assertPhaseContinue(phase_record) + # Decorate with conf.save_and_restore to temporarily set conf values. # NOTE: This must come before yields_phases. @conf.save_and_restore(phase_variance='test_phase_variance') @@ -75,8 +87,10 @@ def test_second_phase(self, mock_my_plug): # arg must match keyword above. # You can apply any assertions on the mocked plug here. mock_my_plug.measure_voltage.assert_called_once_with() - # You can yield multiple phases/Test instances, but it's generally - # cleaner and more readable to limit to a single yield per test case. + # If you want to patch the plugs yourself, use mock.patch(.object) on the + # plug class; plug instances are available in the `plugs` attribute once + # the phase/test has been run: + self.plugs[my_plug.MyPlug].measure_voltage.assert_called_once_with() @test.patch_plugs(mock_my_plug='my_plug.MyPlug') def test_multiple(self, mock_my_plug): @@ -115,13 +129,23 @@ def test_multiple(self, mock_my_plug): import logging import sys import types -from typing import Any, Callable, Dict, Iterable, List, Text, Tuple, Type +import typing +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Text, + Tuple, + Type, +) import unittest import attr import mock -import openhtf from openhtf import plugs from openhtf import util from openhtf.core import base_plugs @@ -136,12 +160,18 @@ def test_multiple(self, mock_my_plug): from openhtf.core import test_state from openhtf.plugs import device_wrapping from openhtf.util import logs +from openhtf.util import text import six from six.moves import collections_abc logs.CLI_LOGGING_VERBOSITY = 2 +# Maximum number of measurements per phase to be printed to the assertion +# error message for test failures. +_MAXIMUM_NUM_MEASUREMENTS_PER_PHASE = 10 + + class InvalidTestError(Exception): """Raised when there's something invalid about a test.""" @@ -242,7 +272,7 @@ def __eq__(self, other: phase_nodes.PhaseNode) -> bool: self.kwargs == other.kwargs) -class FakeTestApi(openhtf.TestApi): +class FakeTestApi(test_descriptor.TestApi): """A fake TestApi used to test non-phase helper functions.""" def __init__(self): @@ -325,19 +355,23 @@ def _initialize_plugs(self, plug_types): plug_cls for plug_cls in plug_types if plug_cls not in self.mock_plugs) for plug_type, plug_value in six.iteritems(self.mock_plugs): self.plug_manager.update_plug(plug_type, plug_value) + for plug_type in plug_types: + self.test_case.plugs[plug_type] = ( + self.plug_manager.get_plug_by_class_path( + self.plug_manager.get_plug_name(plug_type))) def _handle_phase(self, phase_desc): """Handle execution of a single test phase.""" - diagnoses_lib.check_for_duplicate_results(iter([phase_desc]), []) + phase_descriptor.check_for_duplicate_results(iter([phase_desc]), []) logs.configure_logging() self._initialize_plugs(phase_plug.cls for phase_plug in phase_desc.plugs) # Cobble together a fake TestState to pass to the test phase. test_options = test_descriptor.TestOptions() - with mock.patch( - 'openhtf.plugs.PlugManager', new=lambda _, __: self.plug_manager): + with mock.patch.object( + plugs, 'PlugManager', new=lambda _, __: self.plug_manager): test_state_ = test_state.TestState( - openhtf.TestDescriptor( + test_descriptor.TestDescriptor( phase_collections.PhaseSequence((phase_desc,)), phase_desc.code_info, {}), 'Unittest:StubTest:UID', test_options) test_state_.mark_test_started() @@ -373,9 +407,6 @@ def _handle_phase(self, phase_desc): def _handle_test(self, test): self._initialize_plugs(test.descriptor.plug_types) - # Make sure we inject our mock plug instances. - for plug_type, plug_value in six.iteritems(self.mock_plugs): - self.plug_manager.update_plug(plug_type, plug_value) # We'll need a place to stash the resulting TestRecord. record_saver = util.NonLocalResult() @@ -383,8 +414,8 @@ def _handle_test(self, test): lambda record: setattr(record_saver, 'result', record)) # Mock the PlugManager to use ours instead, and execute the test. - with mock.patch( - 'openhtf.plugs.PlugManager', new=lambda _, __: self.plug_manager): + with mock.patch.object( + plugs, 'PlugManager', new=lambda _, __: self.plug_manager): test.execute(test_start=self.test_case.test_start_function) test_record_ = record_saver.result @@ -400,7 +431,7 @@ def _handle_test(self, test): def __next__(self): phase_or_test = self.iterator.send(self.last_result) - if isinstance(phase_or_test, openhtf.Test): + if isinstance(phase_or_test, test_descriptor.Test): self.last_result, failure_message = self._handle_test(phase_or_test) elif not isinstance(phase_or_test, collections_abc.Callable): raise InvalidTestError( @@ -413,7 +444,7 @@ def __next__(self): def next(self): phase_or_test = self.iterator.send(self.last_result) - if isinstance(phase_or_test, openhtf.Test): + if isinstance(phase_or_test, test_descriptor.Test): self.last_result, failure_message = self._handle_test(phase_or_test) elif not isinstance(phase_or_test, collections_abc.Callable): raise InvalidTestError( @@ -538,8 +569,10 @@ def wrapped_test(self): TestCase, msg='Must derive from openhtf.util.test.TestCase ' 'to use yields_phases/patch_plugs.') + plug_mocks = dict(self.plugs) + plug_mocks.update(plug_typemap) for phase_or_test, result, failure_message in PhaseOrTestIterator( - self, test_func(self, **plug_kwargs), plug_typemap, + self, test_func(self, **plug_kwargs), plug_mocks, phase_user_defined_state, phase_diagnoses): logging.info('Ran %s, result: %s', phase_or_test, result) if failure_message: @@ -608,21 +641,110 @@ def setUp(self): # When a test is yielded, this function is provided to as the test_start # argument to test.execute. self.test_start_function = lambda: 'TestDutId' + # Dictionary mapping plug class (type, not instance) to plug instance. + # Prior to executing a phase or test, plug instances can be added here. + # When a OpenHTF phase or test is run in this suite, any instantiated plugs + # will be available here. + # "Any" hint below needed because pytype doesn't like heterogeneous values. + self.plugs = {} # type: Any + + def auto_mock_plugs(self, *plug_types: Type[plugs.BasePlug]): + """Specifies plugs that may be automatically mocked if needed. + + Can be called from setUp, or from inside a test case. + + Plug mocks created by this method will not be used if set directly in the + `plug` attribute in this instance. Mocks use autospec and spec_set, and so + this method should not be used for plugs where this isn't desired. + + Args: + *plug_types: Plug classes for which mocks should be used. + """ + for plug_type in plug_types: + if plug_type in self.plugs: + logging.info( + 'Plug "%s" already has mock in self.plugs; skipping ' + 'automatic mock', plug_type.__name__) + continue + self.plugs[plug_type] = mock.create_autospec( + plug_type, spec_set=True, instance=True) + + @typing.overload + def execute_phase_or_test( + self, + phase_or_test: test_descriptor.Test, + phase_user_defined_state: None = None, # Only supported for phases. + phase_diagnoses: None = None, # Only supported for phases. + ) -> test_record.TestRecord: + ... + + @typing.overload + def execute_phase_or_test( + self, + phase_or_test: phase_descriptor.PhaseT, + # Pytype does not correctly support heterogeneous dict values, hence Any. + phase_user_defined_state: Optional[Any] = None, + phase_diagnoses: Optional[Iterable[diagnoses_lib.Diagnosis]] = None, + ) -> test_record.PhaseRecord: + ... + + def execute_phase_or_test(self, + phase_or_test, + phase_user_defined_state=None, + phase_diagnoses=None): + """Executes the provided Test or Phase, returning corresponding record. + + Args: + phase_or_test: The Test or phase to execute. + phase_user_defined_state: If specified, a dictionary that will be added to + the test_state.user_defined_state when handling phases. This is only + supported when executing a phase. + phase_diagnoses: If specified, must be a list of Diagnosis instances; + these are added to the DiagnosesManager when handling phases. + + Returns: + Test or phase record for the execution. See various assert* methods in + this class for possible testing. + """ + + def phase_generator(): + phase_or_test_record = yield phase_or_test + return phase_or_test_record + + for phase_or_test, result, failure_message in PhaseOrTestIterator( + self, phase_generator(), self.plugs, phase_user_defined_state, + phase_diagnoses): + logging.info('Ran %s, result: %s', phase_or_test, result) + if failure_message: + logging.error('Reported error:\n%s', failure_message) + # Pylint cannot determine that the loop above executes for exactly one + # iteration, in any path that would lead here. + return result # pylint: disable=undefined-loop-variable ##### TestRecord Assertions ##### def assertTestPass(self, test_rec): - self.assertEqual(test_record.Outcome.PASS, test_rec.outcome) + self.assertEqual( + test_record.Outcome.PASS, + test_rec.outcome, + msg='\n\n{}'.format( + text.StringFromTestRecord( + test_rec, + only_failures=True, + maximum_num_measurements=_MAXIMUM_NUM_MEASUREMENTS_PER_PHASE))) def assertTestFail(self, test_rec): - self.assertEqual(test_record.Outcome.FAIL, test_rec.outcome) + msg = None + if test_rec.outcome == test_record.Outcome.ERROR: + msg = text.StringFromOutcomeDetails(test_rec.outcome_details) + self.assertEqual(test_record.Outcome.FAIL, test_rec.outcome, msg=msg) def assertTestAborted(self, test_rec): self.assertEqual(test_record.Outcome.ABORTED, test_rec.outcome) def assertTestError(self, test_rec, exc_type=None): self.assertEqual(test_record.Outcome.ERROR, test_rec.outcome) - if exc_type: + if exc_type is not None: self.assertPhaseError(test_rec.phases[-1], exc_type) def assertTestOutcomeCode(self, test_rec, code): @@ -634,25 +756,46 @@ def assertTestOutcomeCode(self, test_rec, code): ##### PhaseRecord Assertions ##### def assertPhaseContinue(self, phase_record): - self.assertIs(openhtf.PhaseResult.CONTINUE, - phase_record.result.phase_result) + self.assertIs( + phase_descriptor.PhaseResult.CONTINUE, + phase_record.result.phase_result, + msg='\n\n{}'.format( + text.StringFromPhaseRecord( + phase_record, + only_failures=True, + maximum_num_measurements=_MAXIMUM_NUM_MEASUREMENTS_PER_PHASE))) def assertPhaseFailAndContinue(self, phase_record): - self.assertIs(openhtf.PhaseResult.FAIL_AND_CONTINUE, - phase_record.result.phase_result) + msg = None + if phase_record.result.raised_exception is not None: + msg = ('The following exception was raised: ' + f'{phase_record.result.phase_result}.') + self.assertIs( + phase_descriptor.PhaseResult.FAIL_AND_CONTINUE, + phase_record.result.phase_result, + msg=msg) def assertPhaseFailSubtest(self, phase_record): - self.assertIs(openhtf.PhaseResult.FAIL_SUBTEST, - phase_record.result.phase_result) + msg = None + if phase_record.result.raised_exception is not None: + msg = (f'The following exception was raised: ' + f'{phase_record.result.phase_result}.') + self.assertIs( + phase_descriptor.PhaseResult.FAIL_SUBTEST, + phase_record.result.phase_result, + msg=msg) def assertPhaseRepeat(self, phase_record): - self.assertIs(openhtf.PhaseResult.REPEAT, phase_record.result.phase_result) + self.assertIs(phase_descriptor.PhaseResult.REPEAT, + phase_record.result.phase_result) def assertPhaseSkip(self, phase_record): - self.assertIs(openhtf.PhaseResult.SKIP, phase_record.result.phase_result) + self.assertIs(phase_descriptor.PhaseResult.SKIP, + phase_record.result.phase_result) def assertPhaseStop(self, phase_record): - self.assertIs(openhtf.PhaseResult.STOP, phase_record.result.phase_result) + self.assertIs(phase_descriptor.PhaseResult.STOP, + phase_record.result.phase_result) def assertPhaseError(self, phase_record, exc_type=None): self.assertTrue(phase_record.result.raised_exception, @@ -667,10 +810,21 @@ def assertPhaseTimeout(self, phase_record): self.assertTrue(phase_record.result.is_timeout) def assertPhaseOutcomePass(self, phase_record): - self.assertIs(test_record.PhaseOutcome.PASS, phase_record.outcome) + self.assertIs( + test_record.PhaseOutcome.PASS, + phase_record.outcome, + msg='\n\n{}'.format( + text.StringFromPhaseRecord( + phase_record, + only_failures=True, + maximum_num_measurements=_MAXIMUM_NUM_MEASUREMENTS_PER_PHASE))) def assertPhaseOutcomeFail(self, phase_record): - self.assertIs(test_record.PhaseOutcome.FAIL, phase_record.outcome) + msg = None + if phase_record.result.raised_exception is not None: + msg = ('The following exception was raised: ' + f'{phase_record.result.phase_result}.') + self.assertIs(test_record.PhaseOutcome.FAIL, phase_record.outcome, msg=msg) def assertPhaseOutcomeSkip(self, phase_record): self.assertIs(test_record.PhaseOutcome.SKIP, phase_record.outcome) @@ -682,7 +836,7 @@ def assertPhasesOutcomeByName(self, expected_outcome: test_record.PhaseOutcome, test_rec: test_record.TestRecord, *phase_names: Text): - errors = [] # type: List[Text] + errors: List[Text] = [] for phase_rec in filter_phases_by_names(test_rec.phases, *phase_names): if phase_rec.outcome is not expected_outcome: errors.append('Phase "{}" outcome: {}'.format(phase_rec.name, @@ -741,6 +895,16 @@ def assertMeasurementFail(self, phase_record, measurement): self.assertIs(measurements.Outcome.FAIL, phase_record.measurements[measurement].outcome) + @_assert_phase_or_test_record + def assertMeasurementMarginal(self, phase_record, measurement): + self.assertMeasured(phase_record, measurement) + self.assertTrue(phase_record.measurements[measurement].marginal) + + @_assert_phase_or_test_record + def assertMeasurementNotMarginal(self, phase_record, measurement): + self.assertMeasured(phase_record, measurement) + self.assertFalse(phase_record.measurements[measurement].marginal) + @_assert_phase_or_test_record def assertAttachment(self, phase_record, diff --git a/openhtf/util/text.py b/openhtf/util/text.py new file mode 100644 index 000000000..941e2913f --- /dev/null +++ b/openhtf/util/text.py @@ -0,0 +1,304 @@ +# Copyright 2021 Google Inc. All Rights Reserved. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Helper functions to convert OpenHTF objects to text-based outputs. + +Provides a convenient way to convert OpenHTF objects such as phases or test +records to a string. This library has been designed in a way such that the +outputted strings can be styled for a terminal console. + + Typical usage example: + + import openhtf + from openhtf.util import text + + test = openhtf.Test(*test_phases) + + # Logs the test record to the terminal output when the OpenHTF test finishes + # executing. + test.add_output_callbacks(text.PrintTestRecord) + test.configure(**configure_kwargs) + test.execute() +""" + +import enum +import sys +from typing import List, Optional + +import colorama +import openhtf +from openhtf.core import measurements +from openhtf.core import phase_descriptor +from openhtf.core import phase_executor +from openhtf.core import test_record +from openhtf.util import threads + +_ColorFromTestOutcome = enum.Enum( + '_ColorFromTestOutcome', [ + (test_record.Outcome.PASS.name, colorama.Fore.GREEN), + (test_record.Outcome.FAIL.name, colorama.Fore.RED), + (test_record.Outcome.ERROR.name, colorama.Fore.YELLOW), + (test_record.Outcome.TIMEOUT.name, colorama.Fore.CYAN), + (test_record.Outcome.ABORTED.name, colorama.Fore.YELLOW), + ], + module=__name__) + +_HeadlineFromTestOutcome = enum.Enum( + '_HeadlineFromTestOutcome', [ + (test_record.Outcome.PASS.name, + f'Test finished with a {test_record.Outcome.PASS.name}!'), + (test_record.Outcome.FAIL.name, + f'Test finished with a {test_record.Outcome.FAIL.name} :('), + (test_record.Outcome.ERROR.name, + f'Test encountered an {test_record.Outcome.ERROR.name}!!!'), + (test_record.Outcome.TIMEOUT.name, + f'Test hit a {test_record.Outcome.TIMEOUT.name}.'), + (test_record.Outcome.ABORTED.name, + f'Test was {test_record.Outcome.ABORTED.name}.'), + ], + module=__name__) + +_BRIGHT_RED_STYLE = f'{colorama.Style.BRIGHT}{colorama.Fore.RED}' + + +def _ColorText(text: str, ansi_color: str) -> str: + """Colors a text string for a terminal output. + + Note: Coloring will only work when the text is printed to a terminal. + + Args: + text: Text to be colored. + ansi_color: ANSCI escape character sequence for the color. + + Returns: + Colorized text string. + """ + return f'{ansi_color}{text}{colorama.Style.RESET_ALL}' + + +def _GetTestOutcomeHeadline(record: test_record.TestRecord, + colorize_text: bool = False) -> str: + """Returns a headline of the test result. + + Args: + record: OpenHTF test record to get the test result from. + colorize_text: Indicates whether the converted string should be colorized + for a terminal output. + + Returns: + Text headline of the test result. + """ + # TODO(b/70517332): Pytype currently doesn't properly support the functional + # API of enums: https://github.com/google/pytype/issues/459. Remove + # disabling pytype once fixed. + # pytype: disable=unsupported-operands + test_outcome_headline = _HeadlineFromTestOutcome[record.outcome.name].value + color = _ColorFromTestOutcome[record.outcome.name].value + # pytype: enable=unsupported-operands + # Alter headline if the record is marginal. + if record.marginal: + color = str(colorama.Fore.YELLOW) + test_outcome_headline += '(MARGINAL)' + return _ColorText(test_outcome_headline, + color) if colorize_text else test_outcome_headline + + +def StringFromMeasurement(measurement: openhtf.Measurement, + colorize_text: bool = False) -> str: + """Returns a text summary of the measurement. + + Args: + measurement: OpenHTF measurement to be converted to a string. + colorize_text: Indicates whether the converted string should be colorized + for a terminal output. + + Returns: + Text summary of the measurement. + """ + if not measurement.measured_value.is_value_set: + text = f'| {measurement.name} was not set' + return _ColorText(text, _BRIGHT_RED_STYLE) if colorize_text else text + elif measurement.outcome == measurements.Outcome.FAIL: + text = (f'| {measurement.name} failed because ' + f'{measurement.measured_value.value} failed these checks: ' + '{}'.format([str(v) for v in measurement.validators])) + return _ColorText(text, _BRIGHT_RED_STYLE) if colorize_text else text + elif measurement.marginal: + text = (f'| {measurement.name} is marginal because ' + f'{measurement.measured_value.value} is marginal in these checks: ' + '{}'.format([str(v) for v in measurement.validators])) + return (_ColorText(text, str(colorama.Fore.YELLOW)) + if colorize_text else text) + return f'| {measurement.name}: {measurement.measured_value.value}' + + +def StringFromAttachment(attachment: test_record.Attachment, name: str) -> str: + """Returns a text summary of the attachment. + + Args: + attachment: OpenHTF attachment to be converted to a string. + name: Name of the OpenHTF attachment. + + Returns: + Text summary of the measurement. + """ + return f'| attachment: {name} (mimetype={attachment.mimetype})' + + +def StringFromPhaseExecutionOutcome( + execution_outcome: phase_executor.PhaseExecutionOutcome) -> str: + """Returns a text representation of the phase execution outcome. + + Args: + execution_outcome: OpenHTF phase execution outcome. + + Returns: + Text summary of the measurement. + """ + if isinstance(execution_outcome.phase_result, phase_executor.ExceptionInfo): + return execution_outcome.phase_result.exc_type.__name__ + elif isinstance(execution_outcome.phase_result, phase_descriptor.PhaseResult): + return execution_outcome.phase_result.name + elif isinstance(execution_outcome.phase_result, + threads.ThreadTerminationError): + return type(execution_outcome.phase_result).__name__ + elif execution_outcome.phase_result is None: + return '' + raise TypeError( + f'{execution_outcome.phase_result.__name__} cannot be converted to a ' + 'string.') + + +def StringFromPhaseRecord( + phase: test_record.PhaseRecord, + only_failures: bool = False, + colorize_text: bool = False, + maximum_num_measurements: Optional[int] = None) -> str: + """Returns a text summary of the phase record that ran. + + Args: + phase: OpenHTF test record to be converted to a string. + only_failures: Indicated whether only failing measurements should be + converted to the string. + colorize_text: Indicates whether the converted string should be colorized + for a terminal output. + maximum_num_measurements: Maximum number of measurements to be printed. If + None, prints all the measurements. + + Returns: + Text summary of the phase record. + """ + output = [] + + text = 'Phase {}\n+ Outcome: {} Result: {}'.format( + phase.name, phase.outcome.name, + StringFromPhaseExecutionOutcome(phase.result)) + if (phase.outcome != test_record.PhaseOutcome.PASS and + phase.outcome != test_record.PhaseOutcome.SKIP and colorize_text): + text = _ColorText(text, _BRIGHT_RED_STYLE) + output.append(text) + sorted_measurement = sorted( + phase.measurements.values(), + key=lambda measurement: measurement.outcome == measurements.Outcome.PASS) + num_measurements_can_be_printed = maximum_num_measurements + for measurement in sorted_measurement: + if not only_failures or measurement.outcome == measurements.Outcome.FAIL: + if num_measurements_can_be_printed is not None: + num_measurements_can_be_printed -= 1 + if num_measurements_can_be_printed < 0: + if maximum_num_measurements: + output.append('...') + break + output.append( + StringFromMeasurement(measurement, colorize_text=colorize_text)) + + for name, attachment in phase.attachments.items(): + output.append(StringFromAttachment(attachment, name)) + return '\n'.join(output) + + +def StringFromOutcomeDetails( + outcome_details: List[test_record.OutcomeDetails]) -> str: + """Returns a text summary of the outcome details. + + Args: + outcome_details: OpenHTF list of outcome details. + + Returns: + Text summary of the outcome details. + """ + output = [] + plural_this = ('these', 'this')[len(outcome_details) == 1] + output.append(f'The test thinks {plural_this} may be the reason:') + for outcome_detail in outcome_details: + output.append(f'{outcome_detail.code}: {outcome_detail.description}') + return '\n'.join(output) + + +def StringFromTestRecord(record: test_record.TestRecord, + only_failures: bool = False, + colorize_text: bool = False, + maximum_num_measurements: Optional[int] = None) -> str: + """Returns a text summary of the test record that ran. + + Args: + record: OpenHTF test record to be converted to a string. + only_failures: Indicated whether only failing measurements should be + converted to the string. + colorize_text: Indicates whether the converted string should be colorized + for a terminal output. + maximum_num_measurements: Maximum number of measurements per phase to be + printed. If None, prints all the measurements. + + Returns: + Text summary of the test record that ran. + """ + output = [_GetTestOutcomeHeadline(record, colorize_text=colorize_text)] + if record.outcome == test_record.Outcome.PASS: + output.append('Woohoo!') + + for phase in record.phases: + if (not only_failures or (phase.outcome != test_record.PhaseOutcome.PASS and + phase.outcome != test_record.PhaseOutcome.SKIP)): + output.append( + StringFromPhaseRecord( + phase, + only_failures=only_failures, + colorize_text=colorize_text, + maximum_num_measurements=maximum_num_measurements)) + + # Check for top-level exceptions. + if record.outcome_details and record.outcome in { + test_record.Outcome.FAIL, test_record.Outcome.ERROR + }: + output.append(StringFromOutcomeDetails(record.outcome_details)) + + output.append(_GetTestOutcomeHeadline(record, colorize_text=colorize_text)) + # Generates the body itself now. + return '\n'.join(output) + + +def PrintTestRecord(record: test_record.TestRecord) -> None: + """Prints a summary of the test record. + + Args: + record: OpenHTF test record to be logged. + """ + # Checks if the logging will go to a file in which colors are likely to be + # only shown as ASCI characters. + colorize_text = sys.stdout.isatty() + # If the output contains too many characters then the logging module will + # automatically truncate the string when logging as the logging module has a + # maxmimum buffer size. Print instead of log to prevent reaching the logging + # limit. + print(StringFromTestRecord(record, colorize_text=colorize_text)) diff --git a/openhtf/util/units.py b/openhtf/util/units.py index c4bf4216a..0c1cd4bbc 100644 --- a/openhtf/util/units.py +++ b/openhtf/util/units.py @@ -31,7 +31,7 @@ OpenHTF uses UNECE unit codes internally because they are relatively complete and modern, and because they are recognized internationally. For full details -regarding where we get the codes from and which units are avaiable, see the +regarding where we get the codes from and which units are available, see the docstring at the top of openhtf/util/units/bin/units_from_xls.py. THIS FILE IS AUTOMATICALLY GENERATED. DO NOT EDIT. @@ -593,13 +593,15 @@ class UnitDescriptor( ALL_UNITS.append(CAPSULE) POWDER_FILLED_VIAL = UnitDescriptor('powder filled vial', 'AW', '''''') ALL_UNITS.append(POWDER_FILLED_VIAL) +AMERICAN_WIRE_GAUGE = UnitDescriptor('american wire gauge', 'AWG', '''AWG''') +ALL_UNITS.append(AMERICAN_WIRE_GAUGE) ASSEMBLY = UnitDescriptor('assembly', 'AY', '''''') ALL_UNITS.append(ASSEMBLY) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_POUND = UnitDescriptor('British thermal unit (international table) per pound', 'AZ', '''BtuIT/lb''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_POUND) BTU_PER_CUBIC_FOOT = UnitDescriptor('Btu per cubic foot', 'B0', '''BTU/ft³''') ALL_UNITS.append(BTU_PER_CUBIC_FOOT) -BARREL_US_PER_DAY = UnitDescriptor('barrel (US) per day', 'B1', '''barrel (US)/d''') +BARREL_US_PER_DAY = UnitDescriptor('barrel (US) per day', 'B1', '''barrel (US)/d''') ALL_UNITS.append(BARREL_US_PER_DAY) BIT_PER_SECOND = UnitDescriptor('bit per second', 'B10', '''bit/s''') ALL_UNITS.append(BIT_PER_SECOND) @@ -831,6 +833,8 @@ class UnitDescriptor( ALL_UNITS.append(BOTTLE) HUNDRED_BOARD_FOOT = UnitDescriptor('hundred board foot', 'BP', '''''') ALL_UNITS.append(HUNDRED_BOARD_FOOT) +BEATS_PER_MINUTE = UnitDescriptor('beats per minute', 'BPM', '''BPM''') +ALL_UNITS.append(BEATS_PER_MINUTE) BECQUEREL = UnitDescriptor('becquerel', 'BQL', '''Bq''') ALL_UNITS.append(BECQUEREL) BAR_UNIT_OF_PACKAGING = UnitDescriptor('bar [unit of packaging]', 'BR', '''''') @@ -1121,7 +1125,7 @@ class UnitDescriptor( ALL_UNITS.append(COMBO) KILOWATT_HOUR_PER_HOUR = UnitDescriptor('kilowatt hour per hour', 'D03', '''kW·h/h''') ALL_UNITS.append(KILOWATT_HOUR_PER_HOUR) -LOT_UNIT_OF_WEIGHT = UnitDescriptor('lot [unit of weight]', 'D04', '''''') +LOT_UNIT_OF_WEIGHT = UnitDescriptor('lot [unit of weight]', 'D04', '''''') ALL_UNITS.append(LOT_UNIT_OF_WEIGHT) RECIPROCAL_SECOND_PER_STERADIAN = UnitDescriptor('reciprocal second per steradian', 'D1', '''s⁻¹/sr''') ALL_UNITS.append(RECIPROCAL_SECOND_PER_STERADIAN) @@ -1253,8 +1257,8 @@ class UnitDescriptor( ALL_UNITS.append(INCH_TO_THE_FOURTH_POWER) SANDWICH = UnitDescriptor('sandwich', 'D7', '''''') ALL_UNITS.append(SANDWICH) -CALORIE_INTERNATIONAL_TABLE_ = UnitDescriptor('calorie (international table) ', 'D70', '''calIT''') -ALL_UNITS.append(CALORIE_INTERNATIONAL_TABLE_) +CALORIE_INTERNATIONAL_TABLE = UnitDescriptor('calorie (international table)', 'D70', '''calIT''') +ALL_UNITS.append(CALORIE_INTERNATIONAL_TABLE) CALORIE_INTERNATIONAL_TABLE_PER_SECOND_CENTIMETRE_KELVIN = UnitDescriptor('calorie (international table) per second centimetre kelvin', 'D71', '''calIT/(s·cm·K)''') ALL_UNITS.append(CALORIE_INTERNATIONAL_TABLE_PER_SECOND_CENTIMETRE_KELVIN) CALORIE_INTERNATIONAL_TABLE_PER_SECOND_SQUARE_CENTIMETRE_KELVIN = UnitDescriptor('calorie (international table) per second square centimetre kelvin', 'D72', '''calIT/(s·cm²·K)''') @@ -2377,6 +2381,8 @@ class UnitDescriptor( ALL_UNITS.append(COUNT_PER_CENTIMETRE) INCH_PER_SECOND = UnitDescriptor('inch per second', 'IU', '''in/s''') ALL_UNITS.append(INCH_PER_SECOND) +INTERNATIONAL_UNIT_PER_GRAM = UnitDescriptor('international unit per gram', 'IUG', '''''') +ALL_UNITS.append(INTERNATIONAL_UNIT_PER_GRAM) INCH_PER_SECOND_SQUARED = UnitDescriptor('inch per second squared', 'IV', '''in/s²''') ALL_UNITS.append(INCH_PER_SECOND_SQUARED) PERCENT_PER_MILLIMETRE = UnitDescriptor('percent per millimetre', 'J10', '''%/mm''') @@ -2437,11 +2443,11 @@ class UnitDescriptor( ALL_UNITS.append(BAUD) BRITISH_THERMAL_UNIT_MEAN = UnitDescriptor('British thermal unit (mean)', 'J39', '''Btu''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_MEAN) -BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) foot per hour square foot degree Fahrenheit', 'J40', '''BtuIT·ft/(h·ft²·°F)''') +BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) foot per hour square foot degree Fahrenheit', 'J40', '''BtuIT·ft/(h·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT) -BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) inch per hour square foot degree Fahrenheit', 'J41', '''BtuIT·in/(h·ft²·°F)''') +BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) inch per hour square foot degree Fahrenheit', 'J41', '''BtuIT·in/(h·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT) -BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) inch per second square foot degree Fahrenheit', 'J42', '''BtuIT·in/(s·ft²·°F)''') +BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) inch per second square foot degree Fahrenheit', 'J42', '''BtuIT·in/(s·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_POUND_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) per pound degree Fahrenheit', 'J43', '''BtuIT/(lb·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_POUND_DEGREE_FAHRENHEIT) @@ -2449,13 +2455,13 @@ class UnitDescriptor( ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_MINUTE) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SECOND = UnitDescriptor('British thermal unit (international table) per second', 'J45', '''BtuIT/s''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SECOND) -BRITISH_THERMAL_UNIT_THERMOCHEMICAL_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) foot per hour square foot degree Fahrenheit', 'J46', '''Btuth·ft/(h·ft²·°F)''') +BRITISH_THERMAL_UNIT_THERMOCHEMICAL_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) foot per hour square foot degree Fahrenheit', 'J46', '''Btuth·ft/(h·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_FOOT_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT) BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_HOUR = UnitDescriptor('British thermal unit (thermochemical) per hour', 'J47', '''Btuth/h''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_HOUR) -BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) inch per hour square foot degree Fahrenheit', 'J48', '''Btuth·in/(h·ft²·°F)''') +BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) inch per hour square foot degree Fahrenheit', 'J48', '''Btuth·in/(h·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_HOUR_SQUARE_FOOT_DEGREE_FAHRENHEIT) -BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) inch per second square foot degree Fahrenheit', 'J49', '''Btuth·in/(s·ft²·°F)''') +BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) inch per second square foot degree Fahrenheit', 'J49', '''Btuth·in/(s·ft²·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_INCH_PER_SECOND_SQUARE_FOOT_DEGREE_FAHRENHEIT) BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_POUND_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (thermochemical) per pound degree Fahrenheit', 'J50', '''Btuth/(lb·°F)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_POUND_DEGREE_FAHRENHEIT) @@ -2807,6 +2813,8 @@ class UnitDescriptor( ALL_UNITS.append(KILOMETRE) KILOGRAM_OF_NITROGEN = UnitDescriptor('kilogram of nitrogen', 'KNI', '''kg N''') ALL_UNITS.append(KILOGRAM_OF_NITROGEN) +KILONEWTON_PER_SQUARE_METRE = UnitDescriptor('kilonewton per square metre', 'KNM', '''kN/m2''') +ALL_UNITS.append(KILONEWTON_PER_SQUARE_METRE) KILOGRAM_NAMED_SUBSTANCE = UnitDescriptor('kilogram named substance', 'KNS', '''''') ALL_UNITS.append(KILOGRAM_NAMED_SUBSTANCE) KNOT = UnitDescriptor('knot', 'KNT', '''kn''') @@ -2845,8 +2853,14 @@ class UnitDescriptor( ALL_UNITS.append(KILOGRAM_PER_MILLIMETRE) KILOWATT_HOUR = UnitDescriptor('kilowatt hour', 'KWH', '''kW·h''') ALL_UNITS.append(KILOWATT_HOUR) +KILOWATT_YEAR = UnitDescriptor('kilowatt year', 'KWY', '''kW/year''') +ALL_UNITS.append(KILOWATT_YEAR) +KILOWATT_HOUR_PER_NORMALIZED_CUBIC_METRE = UnitDescriptor('Kilowatt hour per normalized cubic metre', 'KWN', '''''') +ALL_UNITS.append(KILOWATT_HOUR_PER_NORMALIZED_CUBIC_METRE) KILOGRAM_OF_TUNGSTEN_TRIOXIDE = UnitDescriptor('kilogram of tungsten trioxide', 'KWO', '''kg WO₃''') ALL_UNITS.append(KILOGRAM_OF_TUNGSTEN_TRIOXIDE) +KILOWATT_HOUR_PER_STANDARD_CUBIC_METRE = UnitDescriptor('Kilowatt hour per standard cubic metre', 'KWS', '''''') +ALL_UNITS.append(KILOWATT_HOUR_PER_STANDARD_CUBIC_METRE) KILOWATT = UnitDescriptor('kilowatt', 'KWT', '''kW''') ALL_UNITS.append(KILOWATT) MILLILITRE_PER_KILOGRAM = UnitDescriptor('millilitre per kilogram', 'KX', '''ml/kg''') @@ -3059,7 +3073,7 @@ class UnitDescriptor( ALL_UNITS.append(LINEAR_METRE) LENGTH = UnitDescriptor('length', 'LN', '''''') ALL_UNITS.append(LENGTH) -LOT_UNIT_OF_PROCUREMENT = UnitDescriptor('lot [unit of procurement]', 'LO', '''''') +LOT_UNIT_OF_PROCUREMENT = UnitDescriptor('lot [unit of procurement]', 'LO', '''''') ALL_UNITS.append(LOT_UNIT_OF_PROCUREMENT) LIQUID_POUND = UnitDescriptor('liquid pound', 'LP', '''''') ALL_UNITS.append(LIQUID_POUND) @@ -3159,40 +3173,40 @@ class UnitDescriptor( ALL_UNITS.append(REVOLUTION) DEGREE_UNIT_OF_ANGLE_PER_SECOND_SQUARED = UnitDescriptor('degree [unit of angle] per second squared', 'M45', '''°/s²''') ALL_UNITS.append(DEGREE_UNIT_OF_ANGLE_PER_SECOND_SQUARED) -REVOLUTION_PER_MINUTE_ = UnitDescriptor('revolution per minute ', 'M46', '''r/min''') -ALL_UNITS.append(REVOLUTION_PER_MINUTE_) -CIRCULAR_MIL_ = UnitDescriptor('circular mil ', 'M47', '''cmil''') -ALL_UNITS.append(CIRCULAR_MIL_) -SQUARE_MILE_BASED_ON_U_S_SURVEY_FOOT_ = UnitDescriptor('square mile (based on U.S. survey foot) ', 'M48', '''mi² (US survey)''') -ALL_UNITS.append(SQUARE_MILE_BASED_ON_U_S_SURVEY_FOOT_) +REVOLUTION_PER_MINUTE = UnitDescriptor('revolution per minute', 'M46', '''r/min''') +ALL_UNITS.append(REVOLUTION_PER_MINUTE) +CIRCULAR_MIL = UnitDescriptor('circular mil', 'M47', '''cmil''') +ALL_UNITS.append(CIRCULAR_MIL) +SQUARE_MILE_BASED_ON_U_S_SURVEY_FOOT = UnitDescriptor('square mile (based on U.S. survey foot)', 'M48', '''mi² (US survey)''') +ALL_UNITS.append(SQUARE_MILE_BASED_ON_U_S_SURVEY_FOOT) CHAIN_BASED_ON_U_S_SURVEY_FOOT = UnitDescriptor('chain (based on U.S. survey foot)', 'M49', '''ch (US survey) ''') ALL_UNITS.append(CHAIN_BASED_ON_U_S_SURVEY_FOOT) MICROCURIE = UnitDescriptor('microcurie', 'M5', '''µCi''') ALL_UNITS.append(MICROCURIE) FURLONG = UnitDescriptor('furlong', 'M50', '''fur''') ALL_UNITS.append(FURLONG) -FOOT_U_S_SURVEY_ = UnitDescriptor('foot (U.S. survey) ', 'M51', '''ft (US survey) ''') -ALL_UNITS.append(FOOT_U_S_SURVEY_) -MILE_BASED_ON_U_S_SURVEY_FOOT_ = UnitDescriptor('mile (based on U.S. survey foot) ', 'M52', '''mi (US survey) ''') -ALL_UNITS.append(MILE_BASED_ON_U_S_SURVEY_FOOT_) +FOOT_U_S_SURVEY = UnitDescriptor('foot (U.S. survey)', 'M51', '''ft (US survey) ''') +ALL_UNITS.append(FOOT_U_S_SURVEY) +MILE_BASED_ON_U_S_SURVEY_FOOT = UnitDescriptor('mile (based on U.S. survey foot)', 'M52', '''mi (US survey) ''') +ALL_UNITS.append(MILE_BASED_ON_U_S_SURVEY_FOOT) METRE_PER_PASCAL = UnitDescriptor('metre per pascal', 'M53', '''m/Pa''') ALL_UNITS.append(METRE_PER_PASCAL) METRE_PER_RADIANT = UnitDescriptor('metre per radiant', 'M55', '''m/rad''') ALL_UNITS.append(METRE_PER_RADIANT) SHAKE = UnitDescriptor('shake', 'M56', '''shake''') ALL_UNITS.append(SHAKE) -MILE_PER_MINUTE_ = UnitDescriptor('mile per minute ', 'M57', '''mi/min''') -ALL_UNITS.append(MILE_PER_MINUTE_) -MILE_PER_SECOND_ = UnitDescriptor('mile per second ', 'M58', '''mi/s''') -ALL_UNITS.append(MILE_PER_SECOND_) +MILE_PER_MINUTE = UnitDescriptor('mile per minute', 'M57', '''mi/min''') +ALL_UNITS.append(MILE_PER_MINUTE) +MILE_PER_SECOND = UnitDescriptor('mile per second', 'M58', '''mi/s''') +ALL_UNITS.append(MILE_PER_SECOND) METRE_PER_SECOND_PASCAL = UnitDescriptor('metre per second pascal', 'M59', '''(m/s)/Pa''') ALL_UNITS.append(METRE_PER_SECOND_PASCAL) METRE_PER_HOUR = UnitDescriptor('metre per hour', 'M60', '''m/h''') ALL_UNITS.append(METRE_PER_HOUR) INCH_PER_YEAR = UnitDescriptor('inch per year', 'M61', '''in/y''') ALL_UNITS.append(INCH_PER_YEAR) -KILOMETRE_PER_SECOND_ = UnitDescriptor('kilometre per second ', 'M62', '''km/s''') -ALL_UNITS.append(KILOMETRE_PER_SECOND_) +KILOMETRE_PER_SECOND = UnitDescriptor('kilometre per second', 'M62', '''km/s''') +ALL_UNITS.append(KILOMETRE_PER_SECOND) YARD_PER_SECOND = UnitDescriptor('yard per second', 'M64', '''yd/s''') ALL_UNITS.append(YARD_PER_SECOND) YARD_PER_MINUTE = UnitDescriptor('yard per minute', 'M65', '''yd/min''') @@ -3207,8 +3221,8 @@ class UnitDescriptor( ALL_UNITS.append(CUBIC_MILE_UK_STATUTE) MICRO_INCH = UnitDescriptor('micro-inch', 'M7', '''µin''') ALL_UNITS.append(MICRO_INCH) -TON_REGISTER_ = UnitDescriptor('ton, register ', 'M70', '''RT''') -ALL_UNITS.append(TON_REGISTER_) +TON_REGISTER = UnitDescriptor('ton, register', 'M70', '''RT''') +ALL_UNITS.append(TON_REGISTER) CUBIC_METRE_PER_PASCAL = UnitDescriptor('cubic metre per pascal', 'M71', '''m³/Pa''') ALL_UNITS.append(CUBIC_METRE_PER_PASCAL) BEL = UnitDescriptor('bel', 'M72', '''B''') @@ -3225,18 +3239,16 @@ class UnitDescriptor( ALL_UNITS.append(KILOGRAM_METRE_PER_SECOND_SQUARED) POND = UnitDescriptor('pond', 'M78', '''p''') ALL_UNITS.append(POND) -SQUARE_FOOT_PER_HOUR_ = UnitDescriptor('square foot per hour ', 'M79', '''ft²/h''') -ALL_UNITS.append(SQUARE_FOOT_PER_HOUR_) +SQUARE_FOOT_PER_HOUR = UnitDescriptor('square foot per hour', 'M79', '''ft²/h''') +ALL_UNITS.append(SQUARE_FOOT_PER_HOUR) STOKES_PER_PASCAL = UnitDescriptor('stokes per pascal', 'M80', '''St/Pa''') ALL_UNITS.append(STOKES_PER_PASCAL) SQUARE_CENTIMETRE_PER_SECOND = UnitDescriptor('square centimetre per second', 'M81', '''cm²/s''') ALL_UNITS.append(SQUARE_CENTIMETRE_PER_SECOND) SQUARE_METRE_PER_SECOND_PASCAL = UnitDescriptor('square metre per second pascal', 'M82', '''(m²/s)/Pa''') ALL_UNITS.append(SQUARE_METRE_PER_SECOND_PASCAL) -DENIER_ = UnitDescriptor('denier ', 'M83', '''den''') -ALL_UNITS.append(DENIER_) -POUND_PER_YARD_ = UnitDescriptor('pound per yard ', 'M84', '''lb/yd''') -ALL_UNITS.append(POUND_PER_YARD_) +POUND_PER_YARD = UnitDescriptor('pound per yard', 'M84', '''lb/yd''') +ALL_UNITS.append(POUND_PER_YARD) TON_ASSAY = UnitDescriptor('ton, assay', 'M85', '''''') ALL_UNITS.append(TON_ASSAY) PFUND = UnitDescriptor('pfund', 'M86', '''pfd''') @@ -3349,7 +3361,7 @@ class UnitDescriptor( ALL_UNITS.append(NUMBER_OF_MULTS) MEGAVOLT_AMPERE = UnitDescriptor('megavolt - ampere', 'MVA', '''MV·A''') ALL_UNITS.append(MEGAVOLT_AMPERE) -MEGAWATT_HOUR_1000_KW_H = UnitDescriptor('megawatt hour (1000 kW.h)', 'MWH', '''MW·h''') +MEGAWATT_HOUR_1000_KW_H = UnitDescriptor('megawatt hour (1000 kW.h)', 'MWH', '''MW·h''') ALL_UNITS.append(MEGAWATT_HOUR_1000_KW_H) PEN_CALORIE = UnitDescriptor('pen calorie', 'N1', '''''') ALL_UNITS.append(PEN_CALORIE) @@ -3377,10 +3389,10 @@ class UnitDescriptor( ALL_UNITS.append(NUMBER_OF_LINES) KIP_PER_SQUARE_INCH = UnitDescriptor('kip per square inch', 'N20', '''ksi''') ALL_UNITS.append(KIP_PER_SQUARE_INCH) -POUNDAL_PER_SQUARE_FOOT_ = UnitDescriptor('poundal per square foot ', 'N21', '''pdl/ft²''') -ALL_UNITS.append(POUNDAL_PER_SQUARE_FOOT_) -OUNCE_AVOIRDUPOIS_PER_SQUARE_INCH_ = UnitDescriptor('ounce (avoirdupois) per square inch ', 'N22', '''oz/in²''') -ALL_UNITS.append(OUNCE_AVOIRDUPOIS_PER_SQUARE_INCH_) +POUNDAL_PER_SQUARE_FOOT = UnitDescriptor('poundal per square foot', 'N21', '''pdl/ft²''') +ALL_UNITS.append(POUNDAL_PER_SQUARE_FOOT) +OUNCE_AVOIRDUPOIS_PER_SQUARE_INCH = UnitDescriptor('ounce (avoirdupois) per square inch', 'N22', '''oz/in²''') +ALL_UNITS.append(OUNCE_AVOIRDUPOIS_PER_SQUARE_INCH) CONVENTIONAL_METRE_OF_WATER = UnitDescriptor('conventional metre of water', 'N23', '''mH₂O''') ALL_UNITS.append(CONVENTIONAL_METRE_OF_WATER) GRAM_PER_SQUARE_MILLIMETRE = UnitDescriptor('gram per square millimetre', 'N24', '''g/mm²''') @@ -3389,8 +3401,8 @@ class UnitDescriptor( ALL_UNITS.append(POUND_PER_SQUARE_YARD) POUNDAL_PER_SQUARE_INCH = UnitDescriptor('poundal per square inch', 'N26', '''pdl/in²''') ALL_UNITS.append(POUNDAL_PER_SQUARE_INCH) -FOOT_TO_THE_FOURTH_POWER_ = UnitDescriptor('foot to the fourth power ', 'N27', '''ft⁴''') -ALL_UNITS.append(FOOT_TO_THE_FOURTH_POWER_) +FOOT_TO_THE_FOURTH_POWER = UnitDescriptor('foot to the fourth power', 'N27', '''ft⁴''') +ALL_UNITS.append(FOOT_TO_THE_FOURTH_POWER) CUBIC_DECIMETRE_PER_KILOGRAM = UnitDescriptor('cubic decimetre per kilogram', 'N28', '''dm³/kg''') ALL_UNITS.append(CUBIC_DECIMETRE_PER_KILOGRAM) CUBIC_FOOT_PER_POUND = UnitDescriptor('cubic foot per pound', 'N29', '''ft³/lb''') @@ -3405,8 +3417,8 @@ class UnitDescriptor( ALL_UNITS.append(POUNDAL_PER_INCH) POUND_FORCE_PER_YARD = UnitDescriptor('pound-force per yard', 'N33', '''lbf/yd''') ALL_UNITS.append(POUND_FORCE_PER_YARD) -POUNDAL_SECOND_PER_SQUARE_FOOT_ = UnitDescriptor('poundal second per square foot ', 'N34', '''(pdl/ft²)·s''') -ALL_UNITS.append(POUNDAL_SECOND_PER_SQUARE_FOOT_) +POUNDAL_SECOND_PER_SQUARE_FOOT = UnitDescriptor('poundal second per square foot', 'N34', '''(pdl/ft²)·s''') +ALL_UNITS.append(POUNDAL_SECOND_PER_SQUARE_FOOT) POISE_PER_PASCAL = UnitDescriptor('poise per pascal', 'N35', '''P/Pa''') ALL_UNITS.append(POISE_PER_PASCAL) NEWTON_SECOND_PER_SQUARE_METRE = UnitDescriptor('newton second per square metre', 'N36', '''(N/m²)·s''') @@ -3433,10 +3445,10 @@ class UnitDescriptor( ALL_UNITS.append(FOOT_POUNDAL) INCH_POUNDAL = UnitDescriptor('inch poundal', 'N47', '''in·pdl''') ALL_UNITS.append(INCH_POUNDAL) -WATT_PER_SQUARE_CENTIMETRE_ = UnitDescriptor('watt per square centimetre ', 'N48', '''W/cm²''') -ALL_UNITS.append(WATT_PER_SQUARE_CENTIMETRE_) -WATT_PER_SQUARE_INCH_ = UnitDescriptor('watt per square inch ', 'N49', '''W/in²''') -ALL_UNITS.append(WATT_PER_SQUARE_INCH_) +WATT_PER_SQUARE_CENTIMETRE = UnitDescriptor('watt per square centimetre', 'N48', '''W/cm²''') +ALL_UNITS.append(WATT_PER_SQUARE_CENTIMETRE) +WATT_PER_SQUARE_INCH = UnitDescriptor('watt per square inch', 'N49', '''W/in²''') +ALL_UNITS.append(WATT_PER_SQUARE_INCH) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SQUARE_FOOT_HOUR = UnitDescriptor('British thermal unit (international table) per square foot hour', 'N50', '''BtuIT/(ft²·h)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SQUARE_FOOT_HOUR) BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_SQUARE_FOOT_HOUR = UnitDescriptor('British thermal unit (thermochemical) per square foot hour', 'N51', '''Btuth/(ft²·h)''') @@ -3453,8 +3465,8 @@ class UnitDescriptor( ALL_UNITS.append(CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE_MINUTE) CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE_SECOND = UnitDescriptor('calorie (thermochemical) per square centimetre second', 'N57', '''calth/(cm²·s)''') ALL_UNITS.append(CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE_SECOND) -BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_CUBIC_FOOT_ = UnitDescriptor('British thermal unit (international table) per cubic foot ', 'N58', '''BtuIT/ft³''') -ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_CUBIC_FOOT_) +BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_CUBIC_FOOT = UnitDescriptor('British thermal unit (international table) per cubic foot', 'N58', '''BtuIT/ft³''') +ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_CUBIC_FOOT) BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_CUBIC_FOOT = UnitDescriptor('British thermal unit (thermochemical) per cubic foot', 'N59', '''Btuth/ft³''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_CUBIC_FOOT) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_DEGREE_FAHRENHEIT = UnitDescriptor('British thermal unit (international table) per degree Fahrenheit', 'N60', '''BtuIT/ºF''') @@ -3469,14 +3481,14 @@ class UnitDescriptor( ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_POUND_DEGREE_RANKINE) KILOCALORIE_INTERNATIONAL_TABLE_PER_GRAM_KELVIN = UnitDescriptor('kilocalorie (international table) per gram kelvin', 'N65', '''(kcalIT/K)/g''') ALL_UNITS.append(KILOCALORIE_INTERNATIONAL_TABLE_PER_GRAM_KELVIN) -BRITISH_THERMAL_UNIT_39_DEG_F_ = UnitDescriptor('British thermal unit (39 ºF) ', 'N66', '''Btu (39 ºF) ''') -ALL_UNITS.append(BRITISH_THERMAL_UNIT_39_DEG_F_) +BRITISH_THERMAL_UNIT_39_DEG_F = UnitDescriptor('British thermal unit (39 ºF)', 'N66', '''Btu (39 ºF) ''') +ALL_UNITS.append(BRITISH_THERMAL_UNIT_39_DEG_F) BRITISH_THERMAL_UNIT_59_DEG_F = UnitDescriptor('British thermal unit (59 ºF)', 'N67', '''Btu (59 ºF)''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_59_DEG_F) -BRITISH_THERMAL_UNIT_60_DEG_F_ = UnitDescriptor('British thermal unit (60 ºF) ', 'N68', '''Btu (60 ºF) ''') -ALL_UNITS.append(BRITISH_THERMAL_UNIT_60_DEG_F_) -CALORIE_20_DEG_C_ = UnitDescriptor('calorie (20 ºC) ', 'N69', '''cal₂₀''') -ALL_UNITS.append(CALORIE_20_DEG_C_) +BRITISH_THERMAL_UNIT_60_DEG_F = UnitDescriptor('British thermal unit (60 ºF)', 'N68', '''Btu (60 ºF) ''') +ALL_UNITS.append(BRITISH_THERMAL_UNIT_60_DEG_F) +CALORIE_20_DEG_C = UnitDescriptor('calorie (20 ºC)', 'N69', '''cal₂₀''') +ALL_UNITS.append(CALORIE_20_DEG_C) QUAD_10FIFTEEN_BTUIT = UnitDescriptor('quad (1015 BtuIT)', 'N70', '''quad''') ALL_UNITS.append(QUAD_10FIFTEEN_BTUIT) THERM_EC = UnitDescriptor('therm (EC)', 'N71', '''thm (EC)''') @@ -3571,6 +3583,8 @@ class UnitDescriptor( ALL_UNITS.append(NUMBER_OF_SCREENS) LOAD = UnitDescriptor('load', 'NL', '''''') ALL_UNITS.append(LOAD) +NORMALISED_CUBIC_METRE = UnitDescriptor('Normalised cubic metre', 'NM3', '''''') +ALL_UNITS.append(NORMALISED_CUBIC_METRE) NAUTICAL_MILE = UnitDescriptor('nautical mile', 'NMI', '''n mile''') ALL_UNITS.append(NAUTICAL_MILE) NUMBER_OF_PACKS = UnitDescriptor('number of packs', 'NMP', '''''') @@ -3605,6 +3619,12 @@ class UnitDescriptor( ALL_UNITS.append(PANEL) OZONE_DEPLETION_EQUIVALENT = UnitDescriptor('ozone depletion equivalent', 'ODE', '''''') ALL_UNITS.append(OZONE_DEPLETION_EQUIVALENT) +ODS_GRAMS = UnitDescriptor('ODS Grams', 'ODG', '''''') +ALL_UNITS.append(ODS_GRAMS) +ODS_KILOGRAMS = UnitDescriptor('ODS Kilograms', 'ODK', '''''') +ALL_UNITS.append(ODS_KILOGRAMS) +ODS_MILLIGRAMS = UnitDescriptor('ODS Milligrams', 'ODM', '''''') +ALL_UNITS.append(ODS_MILLIGRAMS) OHM = UnitDescriptor('ohm', 'OHM', '''Ω''') ALL_UNITS.append(OHM) OUNCE_PER_SQUARE_YARD = UnitDescriptor('ounce per square yard', 'ON', '''oz/yd²''') @@ -3613,6 +3633,8 @@ class UnitDescriptor( ALL_UNITS.append(OUNCE_AVOIRDUPOIS) TWO_PACK = UnitDescriptor('two pack', 'OP', '''''') ALL_UNITS.append(TWO_PACK) +OSCILLATIONS_PER_MINUTE = UnitDescriptor('oscillations per minute', 'OPM', '''o/min''') +ALL_UNITS.append(OSCILLATIONS_PER_MINUTE) OVERTIME_HOUR = UnitDescriptor('overtime hour', 'OT', '''''') ALL_UNITS.append(OVERTIME_HOUR) OUNCE_AV = UnitDescriptor('ounce av', 'OZ', '''''') @@ -3655,18 +3677,18 @@ class UnitDescriptor( ALL_UNITS.append(KILOJOULE_PER_DAY) NANOOHM = UnitDescriptor('nanoohm', 'P22', '''nΩ''') ALL_UNITS.append(NANOOHM) -OHM_CIRCULAR_MIL_PER_FOOT_ = UnitDescriptor('ohm circular-mil per foot ', 'P23', '''Ω·cmil/ft ''') -ALL_UNITS.append(OHM_CIRCULAR_MIL_PER_FOOT_) +OHM_CIRCULAR_MIL_PER_FOOT = UnitDescriptor('ohm circular-mil per foot', 'P23', '''Ω·cmil/ft ''') +ALL_UNITS.append(OHM_CIRCULAR_MIL_PER_FOOT) KILOHENRY = UnitDescriptor('kilohenry', 'P24', '''kH''') ALL_UNITS.append(KILOHENRY) -LUMEN_PER_SQUARE_FOOT_ = UnitDescriptor('lumen per square foot ', 'P25', '''lm/ft²''') -ALL_UNITS.append(LUMEN_PER_SQUARE_FOOT_) +LUMEN_PER_SQUARE_FOOT = UnitDescriptor('lumen per square foot', 'P25', '''lm/ft²''') +ALL_UNITS.append(LUMEN_PER_SQUARE_FOOT) PHOT = UnitDescriptor('phot', 'P26', '''ph''') ALL_UNITS.append(PHOT) FOOTCANDLE = UnitDescriptor('footcandle', 'P27', '''ftc''') ALL_UNITS.append(FOOTCANDLE) -CANDELA_PER_SQUARE_INCH_ = UnitDescriptor('candela per square inch ', 'P28', '''cd/in²''') -ALL_UNITS.append(CANDELA_PER_SQUARE_INCH_) +CANDELA_PER_SQUARE_INCH = UnitDescriptor('candela per square inch', 'P28', '''cd/in²''') +ALL_UNITS.append(CANDELA_PER_SQUARE_INCH) FOOTLAMBERT = UnitDescriptor('footlambert', 'P29', '''ftL''') ALL_UNITS.append(FOOTLAMBERT) THREE_PACK = UnitDescriptor('three pack', 'P3', '''''') @@ -3683,14 +3705,14 @@ class UnitDescriptor( ALL_UNITS.append(MILLICANDELA) HEFNER_KERZE = UnitDescriptor('Hefner-Kerze', 'P35', '''HK''') ALL_UNITS.append(HEFNER_KERZE) -INTERNATIONAL_CANDLE_ = UnitDescriptor('international candle ', 'P36', '''IK''') -ALL_UNITS.append(INTERNATIONAL_CANDLE_) +INTERNATIONAL_CANDLE = UnitDescriptor('international candle', 'P36', '''IK''') +ALL_UNITS.append(INTERNATIONAL_CANDLE) BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SQUARE_FOOT = UnitDescriptor('British thermal unit (international table) per square foot', 'P37', '''BtuIT/ft²''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_INTERNATIONAL_TABLE_PER_SQUARE_FOOT) BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_SQUARE_FOOT = UnitDescriptor('British thermal unit (thermochemical) per square foot', 'P38', '''Btuth/ft²''') ALL_UNITS.append(BRITISH_THERMAL_UNIT_THERMOCHEMICAL_PER_SQUARE_FOOT) -CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE_ = UnitDescriptor('calorie (thermochemical) per square centimetre ', 'P39', '''calth/cm²''') -ALL_UNITS.append(CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE_) +CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE = UnitDescriptor('calorie (thermochemical) per square centimetre', 'P39', '''calth/cm²''') +ALL_UNITS.append(CALORIE_THERMOCHEMICAL_PER_SQUARE_CENTIMETRE) FOUR_PACK = UnitDescriptor('four pack', 'P4', '''''') ALL_UNITS.append(FOUR_PACK) LANGLEY = UnitDescriptor('langley', 'P40', '''Ly''') @@ -3721,8 +3743,8 @@ class UnitDescriptor( ALL_UNITS.append(MOL_PER_KILOGRAM_PASCAL) MOL_PER_CUBIC_METRE_PASCAL = UnitDescriptor('mol per cubic metre pascal', 'P52', '''(mol/m³)/Pa''') ALL_UNITS.append(MOL_PER_CUBIC_METRE_PASCAL) -UNIT_POLE_ = UnitDescriptor('unit pole ', 'P53', '''unit pole ''') -ALL_UNITS.append(UNIT_POLE_) +UNIT_POLE = UnitDescriptor('unit pole', 'P53', '''unit pole ''') +ALL_UNITS.append(UNIT_POLE) MILLIGRAY_PER_SECOND = UnitDescriptor('milligray per second', 'P54', '''mGy/s''') ALL_UNITS.append(MILLIGRAY_PER_SECOND) MICROGRAY_PER_SECOND = UnitDescriptor('microgray per second', 'P55', '''µGy/s''') @@ -3803,12 +3825,12 @@ class UnitDescriptor( ALL_UNITS.append(POUND_FORCE_FOOT_PER_INCH) NINE_PACK = UnitDescriptor('nine pack', 'P9', '''''') ALL_UNITS.append(NINE_PACK) -POUND_FORCE_INCH_PER_INCH_ = UnitDescriptor('pound-force inch per inch ', 'P90', '''lbf·in/in''') -ALL_UNITS.append(POUND_FORCE_INCH_PER_INCH_) -PERM_0_DEG_C_ = UnitDescriptor('perm (0 ºC) ', 'P91', '''perm (0 ºC) ''') -ALL_UNITS.append(PERM_0_DEG_C_) -PERM_23_DEG_C_ = UnitDescriptor('perm (23 ºC) ', 'P92', '''perm (23 ºC) ''') -ALL_UNITS.append(PERM_23_DEG_C_) +POUND_FORCE_INCH_PER_INCH = UnitDescriptor('pound-force inch per inch', 'P90', '''lbf·in/in''') +ALL_UNITS.append(POUND_FORCE_INCH_PER_INCH) +PERM_0_DEG_C = UnitDescriptor('perm (0 ºC)', 'P91', '''perm (0 ºC) ''') +ALL_UNITS.append(PERM_0_DEG_C) +PERM_23_DEG_C = UnitDescriptor('perm (23 ºC)', 'P92', '''perm (23 ºC) ''') +ALL_UNITS.append(PERM_23_DEG_C) BYTE_PER_SECOND = UnitDescriptor('byte per second', 'P93', '''byte/s''') ALL_UNITS.append(BYTE_PER_SECOND) KILOBYTE_PER_SECOND = UnitDescriptor('kilobyte per second', 'P94', '''kbyte/s''') @@ -3869,6 +3891,8 @@ class UnitDescriptor( ALL_UNITS.append(PINT_UK) LIQUID_PINT_US = UnitDescriptor('liquid pint (US)', 'PTL', '''liq pt (US)''') ALL_UNITS.append(LIQUID_PINT_US) +PORTION = UnitDescriptor('portion', 'PTN', '''PTN''') +ALL_UNITS.append(PORTION) TRAY_PER_TRAY_PACK = UnitDescriptor('tray / tray pack', 'PU', '''''') ALL_UNITS.append(TRAY_PER_TRAY_PACK) HALF_PINT_US = UnitDescriptor('half pint (US)', 'PV', '''''') @@ -3923,6 +3947,28 @@ class UnitDescriptor( ALL_UNITS.append(PH_POTENTIAL_OF_HYDROGEN) KILOJOULE_PER_GRAM = UnitDescriptor('kilojoule per gram', 'Q31', '''kJ/g''') ALL_UNITS.append(KILOJOULE_PER_GRAM) +FEMTOLITRE = UnitDescriptor('femtolitre', 'Q32', '''fl''') +ALL_UNITS.append(FEMTOLITRE) +PICOLITRE = UnitDescriptor('picolitre', 'Q33', '''pl''') +ALL_UNITS.append(PICOLITRE) +NANOLITRE = UnitDescriptor('nanolitre', 'Q34', '''nl''') +ALL_UNITS.append(NANOLITRE) +MEGAWATTS_PER_MINUTE = UnitDescriptor('megawatts per minute', 'Q35', '''MW/min''') +ALL_UNITS.append(MEGAWATTS_PER_MINUTE) +SQUARE_METRE_PER_CUBIC_METRE = UnitDescriptor('square metre per cubic metre', 'Q36', '''m2/m3''') +ALL_UNITS.append(SQUARE_METRE_PER_CUBIC_METRE) +STANDARD_CUBIC_METRE_PER_DAY = UnitDescriptor('Standard cubic metre per day', 'Q37', '''''') +ALL_UNITS.append(STANDARD_CUBIC_METRE_PER_DAY) +STANDARD_CUBIC_METRE_PER_HOUR = UnitDescriptor('Standard cubic metre per hour', 'Q38', '''''') +ALL_UNITS.append(STANDARD_CUBIC_METRE_PER_HOUR) +NORMALIZED_CUBIC_METRE_PER_DAY = UnitDescriptor('Normalized cubic metre per day', 'Q39', '''''') +ALL_UNITS.append(NORMALIZED_CUBIC_METRE_PER_DAY) +NORMALIZED_CUBIC_METRE_PER_HOUR = UnitDescriptor('Normalized cubic metre per hour', 'Q40', '''''') +ALL_UNITS.append(NORMALIZED_CUBIC_METRE_PER_HOUR) +JOULE_PER_NORMALISED_CUBIC_METRE = UnitDescriptor('Joule per normalised cubic metre', 'Q41', '''''') +ALL_UNITS.append(JOULE_PER_NORMALISED_CUBIC_METRE) +JOULE_PER_STANDARD_CUBIC_METRE = UnitDescriptor('Joule per standard cubic metre', 'Q42', '''''') +ALL_UNITS.append(JOULE_PER_STANDARD_CUBIC_METRE) MEAL = UnitDescriptor('meal', 'Q3', '''''') ALL_UNITS.append(MEAL) PAGE_FACSIMILE = UnitDescriptor('page - facsimile', 'QA', '''''') @@ -4025,6 +4071,8 @@ class UnitDescriptor( ALL_UNITS.append(SPLIT_TANK_TRUCK) SLIPSHEET = UnitDescriptor('slipsheet', 'SL', '''''') ALL_UNITS.append(SLIPSHEET) +STANDARD_CUBIC_METRE = UnitDescriptor('Standard cubic metre', 'SM3', '''''') +ALL_UNITS.append(STANDARD_CUBIC_METRE) MILE_STATUTE_MILE = UnitDescriptor('mile (statute mile)', 'SMI', '''mile''') ALL_UNITS.append(MILE_STATUTE_MILE) SQUARE_ROD = UnitDescriptor('square rod', 'SN', '''rd²''') @@ -4117,6 +4165,8 @@ class UnitDescriptor( ALL_UNITS.append(TONNE_METRIC_TON) TEN_PACK = UnitDescriptor('ten pack', 'TP', '''''') ALL_UNITS.append(TEN_PACK) +TEETH_PER_INCH = UnitDescriptor('teeth per inch', 'TPI', '''TPI''') +ALL_UNITS.append(TEETH_PER_INCH) TEN_PAIR = UnitDescriptor('ten pair', 'TPR', '''''') ALL_UNITS.append(TEN_PAIR) THOUSAND_FOOT = UnitDescriptor('thousand foot', 'TQ', '''''') @@ -4245,6 +4295,38 @@ class UnitDescriptor( ALL_UNITS.append(PAGE) MUTUALLY_DEFINED = UnitDescriptor('mutually defined', 'ZZ', '''''') ALL_UNITS.append(MUTUALLY_DEFINED) +METRE_WEEK = UnitDescriptor('Metre Week', 'MRW', '''m·wk''') +ALL_UNITS.append(METRE_WEEK) +SQUARE_METRE_WEEK = UnitDescriptor('Square Metre Week', 'MKW', '''m²· wk''') +ALL_UNITS.append(SQUARE_METRE_WEEK) +CUBIC_METRE_WEEK = UnitDescriptor('Cubic Metre Week', 'MQW', '''m³·wk''') +ALL_UNITS.append(CUBIC_METRE_WEEK) +PIECE_WEEK = UnitDescriptor('Piece Week', 'HWE', '''piece·k''') +ALL_UNITS.append(PIECE_WEEK) +METRE_DAY = UnitDescriptor('Metre Day', 'MRD', '''m·day''') +ALL_UNITS.append(METRE_DAY) +SQUARE_METRE_DAY = UnitDescriptor('Square Metre Day', 'MKD', '''m²·d''') +ALL_UNITS.append(SQUARE_METRE_DAY) +CUBIC_METRE_DAY = UnitDescriptor('Cubic Metre Day', 'MQD', '''m³·d''') +ALL_UNITS.append(CUBIC_METRE_DAY) +PIECE_DAY = UnitDescriptor('Piece Day', 'HAD', '''piece·d''') +ALL_UNITS.append(PIECE_DAY) +METRE_MONTH = UnitDescriptor('Metre Month', 'MRM', '''m·mo''') +ALL_UNITS.append(METRE_MONTH) +SQUARE_METRE_MONTH = UnitDescriptor('Square Metre Month', 'MKM', '''m²·mo''') +ALL_UNITS.append(SQUARE_METRE_MONTH) +CUBIC_METRE_MONTH = UnitDescriptor('Cubic Metre Month', 'MQM', '''m³·mo''') +ALL_UNITS.append(CUBIC_METRE_MONTH) +PIECE_MONTH = UnitDescriptor('Piece Month', 'HMO', '''piece·mo''') +ALL_UNITS.append(PIECE_MONTH) +DECIBEL_WATT = UnitDescriptor('Decibel watt', 'DBW', '''dBW''') +ALL_UNITS.append(DECIBEL_WATT) +DECIBEL_MILLIWATTS = UnitDescriptor('Decibel-milliwatts', 'DBM', '''dBm''') +ALL_UNITS.append(DECIBEL_MILLIWATTS) +FORMAZIN_NEPHELOMETRIC_UNIT = UnitDescriptor('Formazin nephelometric unit', 'FNU', '''FNU''') +ALL_UNITS.append(FORMAZIN_NEPHELOMETRIC_UNIT) +NEPHELOMETRIC_TURBIDITY_UNIT = UnitDescriptor('Nephelometric turbidity unit', 'NTU', '''NTU''') +ALL_UNITS.append(NEPHELOMETRIC_TURBIDITY_UNIT) # Convenience aliases. MINUTE = MINUTE_UNIT_OF_TIME diff --git a/openhtf/util/validators.py b/openhtf/util/validators.py index 7b43fe187..5e6121a97 100644 --- a/openhtf/util/validators.py +++ b/openhtf/util/validators.py @@ -101,46 +101,181 @@ def minimum(self): def maximum(self): """Should return the maximum, inclusive value of the range.""" + @abc.abstractproperty + def marginal_minimum(self): + """Should return the marginal minimum, inclusive value of the range.""" -# Built-in validators below this line -class AllInRangeValidator(ValidatorBase): + @abc.abstractproperty + def marginal_maximum(self): + """Should return the marginal maximum, inclusive value of the range.""" + + @abc.abstractmethod + def is_marginal(self, value) -> bool: + """Validates the value using the marginal limits.""" - def __init__(self, min_value, max_value): + +# Built-in validators below this line +class AllInRangeValidator(RangeValidatorBase): + """Validator to verify a list of values are with in a range.""" + + def __init__(self, + minimum, + maximum, + marginal_minimum=None, + marginal_maximum=None): super(AllInRangeValidator, self).__init__() - self.min_value = min_value - self.max_value = max_value + if minimum is None and maximum is None: + raise ValueError('Must specify minimum, maximum, or both') + if (minimum is not None and maximum is not None and + isinstance(minimum, numbers.Number) and + isinstance(maximum, numbers.Number) and minimum > maximum): + raise ValueError('Minimum cannot be greater than maximum') + if marginal_minimum is not None and minimum is None: + raise ValueError('Marginal minimum was specified without a minimum') + if marginal_maximum is not None and maximum is None: + raise ValueError('Marginal maximum was specified without a maximum') + if (marginal_minimum is not None and isinstance(minimum, numbers.Number) and + isinstance(marginal_minimum, numbers.Number) and + minimum > marginal_minimum): + raise ValueError('Marginal minimum cannot be less than the minimum') + if (marginal_maximum is not None and isinstance(maximum, numbers.Number) and + isinstance(marginal_maximum, numbers.Number) and + maximum < marginal_maximum): + raise ValueError('Marginal maximum cannot be greater than the maximum') + if (marginal_minimum is not None and marginal_maximum is not None and + isinstance(marginal_minimum, numbers.Number) and + isinstance(marginal_maximum, numbers.Number) and + marginal_minimum > marginal_maximum): + raise ValueError( + 'Marginal minimum cannot be greater than the marginal maximum') + + self._minimum = minimum + self._maximum = maximum + self._marginal_minimum = marginal_minimum + self._marginal_maximum = marginal_maximum + + @property + def minimum(self): + return self._minimum + + @property + def maximum(self): + return self._maximum + + @property + def marginal_minimum(self): + return self._marginal_minimum + + @property + def marginal_maximum(self): + return self._marginal_maximum def __call__(self, values): - return all([self.min_value <= value <= self.max_value for value in values]) + within_maximum = self._maximum is None or all( + value <= self.maximum for value in values) + within_minimum = self._minimum is None or all( + value >= self.minimum for value in values) + return within_minimum and within_maximum + + def is_marginal(self, values) -> bool: + is_maximally_marginal = self._marginal_maximum is not None and any( + [self._marginal_maximum <= value <= self._maximum for value in values]) + is_minimally_marginal = self._marginal_minimum is not None and any( + [self._minimum <= value <= self._marginal_minimum for value in values]) + return is_maximally_marginal or is_minimally_marginal + + def __str__(self): + assert self._minimum is not None or self._maximum is not None + if (self._minimum is not None and self._maximum is not None and + self._minimum == self._maximum): + return 'x == {}'.format(self._minimum) + + string_repr = '' + if self._minimum is not None: + string_repr += '{} <= '.format(self._minimum) + if self._marginal_minimum is not None: + string_repr += 'Marginal:{} <= '.format(self._marginal_minimum) + string_repr += 'x' + if self._marginal_maximum is not None: + string_repr += ' <= Marginal:{}'.format(self._marginal_maximum) + if self._maximum is not None: + string_repr += ' <= {}'.format(self._maximum) + return string_repr class AllEqualsValidator(ValidatorBase): + """Validator to verify a list of values are equal to the expected value.""" def __init__(self, spec): super(AllEqualsValidator, self).__init__() - self.spec = spec + self._spec = spec + + @property + def spec(self): + return self._spec def __call__(self, values): return all([value == self.spec for value in values]) + def __str__(self): + return "'x' is equal to '%s'" % self._spec + register(AllInRangeValidator, name='all_in_range') -register(AllEqualsValidator, name='all_equals') + + +@register +def all_equals(value, type=None): # pylint: disable=redefined-builtin + if isinstance(value, numbers.Number): + return AllInRangeValidator(minimum=value, maximum=value) + elif isinstance(value, six.string_types): + assert type is None or issubclass(type, six.string_types), ( + 'Cannot use a non-string type when matching a string') + return matches_regex('^{}$'.format(re.escape(value))) + else: + return AllEqualsValidator(value) class InRange(RangeValidatorBase): """Validator to verify a numeric value is within a range.""" - def __init__(self, minimum=None, maximum=None, type=None): # pylint: disable=redefined-builtin + def __init__(self, + minimum=None, + maximum=None, + marginal_minimum=None, + marginal_maximum=None, + type=None): # pylint: disable=redefined-builtin super(InRange, self).__init__() + if minimum is None and maximum is None: raise ValueError('Must specify minimum, maximum, or both') if (minimum is not None and maximum is not None and isinstance(minimum, numbers.Number) and isinstance(maximum, numbers.Number) and minimum > maximum): raise ValueError('Minimum cannot be greater than maximum') + if marginal_minimum is not None and minimum is None: + raise ValueError('Marginal minimum was specified without a minimum') + if marginal_maximum is not None and maximum is None: + raise ValueError('Marginal maximum was specified without a maximum') + if (marginal_minimum is not None and isinstance(minimum, numbers.Number) and + isinstance(marginal_minimum, numbers.Number) and + minimum > marginal_minimum): + raise ValueError('Marginal minimum cannot be less than the minimum') + if (marginal_maximum is not None and isinstance(maximum, numbers.Number) and + isinstance(marginal_maximum, numbers.Number) and + maximum < marginal_maximum): + raise ValueError('Marginal maximum cannot be greater than the maximum') + if (marginal_minimum is not None and marginal_maximum is not None and + isinstance(marginal_minimum, numbers.Number) and + isinstance(marginal_maximum, numbers.Number) and + marginal_minimum > marginal_maximum): + raise ValueError( + 'Marginal minimum cannot be greater than the marginal maximum') + self._minimum = minimum self._maximum = maximum + self._marginal_minimum = marginal_minimum + self._marginal_maximum = marginal_maximum self._type = type @property @@ -153,10 +288,22 @@ def maximum(self): converter = self._type if self._type is not None else _identity return converter(self._maximum) + @property + def marginal_minimum(self): + converter = self._type if self._type is not None else _identity + return converter(self._marginal_minimum) + + @property + def marginal_maximum(self): + converter = self._type if self._type is not None else _identity + return converter(self._marginal_maximum) + def with_args(self, **kwargs): return type(self)( minimum=util.format_string(self._minimum, kwargs), maximum=util.format_string(self._maximum, kwargs), + marginal_minimum=util.format_string(self._marginal_minimum, kwargs), + marginal_maximum=util.format_string(self._marginal_maximum, kwargs), type=self._type, ) @@ -171,20 +318,42 @@ def __call__(self, value): return False return True + def is_marginal(self, value) -> bool: + if value is None: + return False + if math.isnan(value): + return False + if (self._marginal_minimum is not None and + self.minimum <= value <= self.marginal_minimum): + return True + if (self._marginal_maximum is not None and + self.maximum >= value >= self.marginal_maximum): + return True + return False + def __str__(self): assert self._minimum is not None or self._maximum is not None - if self._minimum is not None and self._maximum is not None: - if self._minimum == self._maximum: - return 'x == %s' % self._minimum - return '%s <= x <= %s' % (self._minimum, self._maximum) + if (self._minimum is not None and self._maximum is not None and + self._minimum == self._maximum): + return 'x == {}'.format(self._minimum) + + string_repr = '' if self._minimum is not None: - return '%s <= x' % self._minimum + string_repr += '{} <= '.format(self._minimum) + if self._marginal_minimum is not None: + string_repr += 'Marginal:{} <= '.format(self._marginal_minimum) + string_repr += 'x' + if self._marginal_maximum is not None: + string_repr += ' <= Marginal:{}'.format(self._marginal_maximum) if self._maximum is not None: - return 'x <= %s' % self._maximum + string_repr += ' <= {}'.format(self._maximum) + return string_repr def __eq__(self, other): return (isinstance(other, type(self)) and self.minimum == other.minimum and - self.maximum == other.maximum) + self.maximum == other.maximum and + self.marginal_minimum == other.marginal_minimum and + self.marginal_maximum == other.marginal_maximum) def __ne__(self, other): return not self == other @@ -259,17 +428,27 @@ def matches_regex(regex): class WithinPercent(RangeValidatorBase): """Validates that a number is within percent of a value.""" - def __init__(self, expected, percent): + def __init__(self, expected, percent, marginal_percent=None): super(WithinPercent, self).__init__() if percent < 0: raise ValueError('percent argument is {}, must be >0'.format(percent)) + if marginal_percent is not None and marginal_percent < percent: + raise ValueError( + 'marginal_percent argument is {}, must be < percent'.format( + marginal_percent)) self.expected = expected self.percent = percent + self.marginal_percent = marginal_percent @property def _applied_percent(self): return abs(self.expected * self.percent / 100.0) + @property + def _applied_marginal_percent(self): + return (abs(self.expected * self.marginal_percent / + 100.0) if self.marginal_percent else 0) + @property def minimum(self): return self.expected - self._applied_percent @@ -278,15 +457,35 @@ def minimum(self): def maximum(self): return self.expected + self._applied_percent + @property + def marginal_minimum(self): + return (self.expected - + self._applied_marginal_percent if self.marginal_percent else None) + + @property + def marginal_maximum(self): + return (self.expected - + self._applied_marginal_percent if self.marginal_percent else None) + def __call__(self, value): return self.minimum <= value <= self.maximum + def is_marginal(self, value) -> bool: + if self.marginal_percent is None: + return False + else: + return (self.minimum < value <= self.marginal_minimum or + self.marginal_maximum <= value < self.maximum) + def __str__(self): - return "'x' is within {}% of {}".format(self.percent, self.expected) + return "'x' is within {}% of {}. Marginal: {}% of {}".format( + self.percent, self.expected, self.marginal_percent, self.expected) def __eq__(self, other): return (isinstance(other, type(self)) and - self.expected == other.expected and self.percent == other.percent) + self.expected == other.expected and + self.percent == other.percent and + self.marginal_percent == other.marginal_percent) def __ne__(self, other): return not self == other diff --git a/test/core/diagnoses_test.py b/test/core/diagnoses_test.py index 76ac585de..fb1549f80 100644 --- a/test/core/diagnoses_test.py +++ b/test/core/diagnoses_test.py @@ -123,119 +123,6 @@ class DupeResultA(htf.DiagResultEnum): DUPE = 'dupe' -class DupeResultB(htf.DiagResultEnum): - DUPE = 'dupe' - - -@htf.PhaseDiagnoser(DupeResultA) -def dupe_a_phase_diag(phase_record): - del phase_record # Unused. - return DupeResultA.DUPE - - -@htf.PhaseDiagnoser(DupeResultA) -def dupe_a2_phase_diag(phase_record): - del phase_record # Unused. - return DupeResultA.DUPE - - -@htf.PhaseDiagnoser(DupeResultB) -def dupe_b_phase_diag(phase_record): - del phase_record # Unused. - return DupeResultB.DUPE - - -@htf.TestDiagnoser(DupeResultA) -def dupe_a_test_diag(test_record_, store): - del test_record_ # Unused. - del store # Unused. - return DupeResultA.DUPE - - -@htf.TestDiagnoser(DupeResultA) -def dupe_a2_test_diag(test_record_, store): - del test_record_ # Unused. - del store # Unused. - return DupeResultA.DUPE - - -@htf.TestDiagnoser(DupeResultB) -def dupe_b_test_diag(test_record_, store): - del test_record_ # Unused. - del store # Unused. - return DupeResultB.DUPE - - -class CheckForDuplicateResultsTest(unittest.TestCase): - - def test_phase_phase_dupe(self): - - @htf.diagnose(dupe_a_phase_diag) - def a1(): - pass - - @htf.diagnose(dupe_b_phase_diag) - def b2(): - pass - - with self.assertRaises(diagnoses_lib.DuplicateResultError): - diagnoses_lib.check_for_duplicate_results(iter([a1, b2]), []) - - def test_phase_phase_same_result(self): - - @htf.diagnose(dupe_a_phase_diag) - def a1(): - pass - - @htf.diagnose(dupe_a2_phase_diag) - def a2(): - pass - - diagnoses_lib.check_for_duplicate_results(iter([a1, a2]), []) - - def test_phase_phase_same_diagnoser(self): - - @htf.diagnose(dupe_a_phase_diag) - def a1(): - pass - - @htf.diagnose(dupe_a_phase_diag) - def a2(): - pass - - diagnoses_lib.check_for_duplicate_results(iter([a1, a2]), []) - - def test_phase_test_dupe(self): - - @htf.diagnose(dupe_a_phase_diag) - def a1(): - pass - - with self.assertRaises(diagnoses_lib.DuplicateResultError): - diagnoses_lib.check_for_duplicate_results(iter([a1]), [dupe_b_test_diag]) - - def test_phase_test_same_result(self): - - @htf.diagnose(dupe_a_phase_diag) - def a1(): - pass - - diagnoses_lib.check_for_duplicate_results(iter([a1]), [dupe_a2_test_diag]) - - def test_test_test_dupe(self): - with self.assertRaises(diagnoses_lib.DuplicateResultError): - diagnoses_lib.check_for_duplicate_results( - iter([]), [dupe_a_test_diag, dupe_b_test_diag]) - - def test_test_test_same_result(self): - diagnoses_lib.check_for_duplicate_results( - iter([]), [dupe_a_test_diag, dupe_a2_test_diag]) - - def test_test_test_same_diagnoser(self): - diagnoses_lib.check_for_duplicate_results( - iter([]), [dupe_a_test_diag, dupe_a_test_diag]) - - class CheckDiagnosersTest(unittest.TestCase): def test_invalid_class(self): diff --git a/test/core/measurements_test.py b/test/core/measurements_test.py index b9b0a7588..8cc1df087 100644 --- a/test/core/measurements_test.py +++ b/test/core/measurements_test.py @@ -175,6 +175,24 @@ def test_validator_replacement(self): self.assertMeasurementFail(record, 'replaced_max_only') self.assertMeasurementFail(record, 'replaced_min_max') + @htf_test.yields_phases + def test_validator_replacement_marginal(self): + record = yield all_the_things.measures_with_marginal_args.with_args( + marginal_minimum=4, marginal_maximum=6) + self.assertMeasurementMarginal(record, 'replaced_marginal_min_only') + self.assertMeasurementNotMarginal(record, 'replaced_marginal_max_only') + self.assertMeasurementMarginal(record, 'replaced_marginal_min_max') + record = yield all_the_things.measures_with_marginal_args.with_args( + marginal_minimum=1, marginal_maximum=2) + self.assertMeasurementNotMarginal(record, 'replaced_marginal_min_only') + self.assertMeasurementMarginal(record, 'replaced_marginal_max_only') + self.assertMeasurementMarginal(record, 'replaced_marginal_min_max') + record = yield all_the_things.measures_with_marginal_args.with_args( + marginal_minimum=2, marginal_maximum=4) + self.assertMeasurementNotMarginal(record, 'replaced_marginal_min_only') + self.assertMeasurementNotMarginal(record, 'replaced_marginal_max_only') + self.assertMeasurementNotMarginal(record, 'replaced_marginal_min_max') + @htf_test.yields_phases def test_measurement_order(self): record = yield all_the_things.dimensions diff --git a/test/output/callbacks/mfg_event_converter_test.py b/test/output/callbacks/mfg_event_converter_test.py index d16224fa0..5166c23a3 100644 --- a/test/output/callbacks/mfg_event_converter_test.py +++ b/test/output/callbacks/mfg_event_converter_test.py @@ -39,7 +39,7 @@ def test_mfg_event_from_test_record(self): end_time_millis=1, station_id='localhost', outcome=test_record.Outcome.PASS, - ) + marginal=False) record.outcome = test_record.Outcome.PASS record.metadata = { 'assembly_events': [assembly_event_pb2.AssemblyEvent()] * 2, @@ -54,6 +54,7 @@ def test_mfg_event_from_test_record(self): descriptor_id=idx, codeinfo=test_record.CodeInfo.uncaptured(), result=None, + marginal=False, attachments={}, start_time_millis=1, end_time_millis=1) for idx in range(1, 5) @@ -131,6 +132,7 @@ def test_populate_basic_data(self): start_time_millis=100, end_time_millis=500, outcome=test_record.Outcome.PASS, + marginal=True, outcome_details=[outcome_details], metadata={ 'test_name': 'mock-test-name', @@ -152,7 +154,7 @@ def test_populate_basic_data(self): self.assertEqual(mfg_event.test_name, 'mock-test-name') self.assertEqual(mfg_event.test_version, '1.0') self.assertEqual(mfg_event.test_description, 'mock-test-description') - self.assertEqual(mfg_event.test_status, test_runs_pb2.PASS) + self.assertEqual(mfg_event.test_status, test_runs_pb2.MARGINAL_PASS) # Phases. self.assertEqual(mfg_event.phases[0].name, 'mock-phase-name') @@ -186,9 +188,7 @@ def test_attach_record_as_json(self): def test_convert_object_to_json_with_bytes(self): input_object = {'foo': b'bar'} output_json = mfg_event_converter._convert_object_to_json(input_object) - expected_json = (b'{\n' - b' "foo": "bar"\n' - b'}') + expected_json = (b'{\n' b' "foo": "bar"\n' b'}') self.assertEqual(output_json, expected_json) def test_attach_config(self): @@ -218,7 +218,11 @@ def test_copy_measurements_from_phase(self): self._create_and_set_measurement( 'in-range', 5).doc('mock measurement in range docstring').with_units( - units.Unit('radian')).in_range(1, 10)) + units.Unit('radian')).in_range( + minimum=1, + maximum=10, + marginal_minimum=3, + marginal_maximum=7)) measurement_within_percent = ( self._create_and_set_measurement( @@ -285,8 +289,14 @@ def test_copy_measurements_from_phase(self): # Measurement validators. self.assertEqual(mock_measurement_in_range.numeric_minimum, 1.0) self.assertEqual(mock_measurement_in_range.numeric_maximum, 10.0) + self.assertEqual(mock_measurement_in_range.numeric_marginal_minimum, 3.0) + self.assertEqual(mock_measurement_in_range.numeric_marginal_maximum, 7.0) self.assertEqual(mock_measurement_within_percent.numeric_minimum, 8.0) self.assertEqual(mock_measurement_within_percent.numeric_maximum, 12.0) + self.assertEqual(mock_measurement_within_percent.numeric_marginal_minimum, + 0) + self.assertEqual(mock_measurement_within_percent.numeric_marginal_maximum, + 0) def testCopyAttachmentsFromPhase(self): attachment = test_record.Attachment(b'mock-data', 'text/plain') diff --git a/test/phase_descriptor_test.py b/test/phase_descriptor_test.py index ec545f5cf..ffdcf4933 100644 --- a/test/phase_descriptor_test.py +++ b/test/phase_descriptor_test.py @@ -14,12 +14,17 @@ import unittest -import attr -import mock +from absl import logging +import attr import openhtf from openhtf import plugs from openhtf.core import base_plugs +from openhtf.core import phase_collections +from openhtf.core import phase_descriptor +from openhtf.core import test_descriptor +from openhtf.core import test_record +from openhtf.core import test_state def plain_func(): @@ -87,8 +92,13 @@ class TestPhaseDescriptor(unittest.TestCase): def setUp(self): super(TestPhaseDescriptor, self).setUp() - self._phase_data = mock.Mock( - plug_manager=plugs.PlugManager(), execution_uid='01234567890') + self._test_state = test_state.TestState( + test_descriptor.TestDescriptor( + phase_sequence=phase_collections.PhaseSequence(), + code_info=test_record.CodeInfo.uncaptured(), + metadata={}), + execution_uid='', + test_options=test_descriptor.TestOptions()) def test_basics(self): phase = openhtf.PhaseDescriptor.wrap_or_copy(plain_func) @@ -96,11 +106,11 @@ def test_basics(self): self.assertEqual(0, len(phase.plugs)) self.assertEqual('plain_func', phase.name) self.assertEqual('Plain Docstring.', phase.doc) - phase(self._phase_data) + phase(self._test_state) test_phase = openhtf.PhaseDescriptor.wrap_or_copy(normal_test_phase) self.assertEqual('normal_test_phase', test_phase.name) - self.assertEqual('return value', test_phase(self._phase_data)) + self.assertEqual('return value', test_phase(self._test_state)) def test_multiple_phases(self): phase = openhtf.PhaseDescriptor.wrap_or_copy(plain_func) @@ -127,8 +137,8 @@ def custom_phase(one=None, two=None): def test_with_args(self): phase = extra_arg_func.with_args(input_value='input arg') - result = phase(self._phase_data) - first_result = phase(self._phase_data) + result = phase(self._test_state) + first_result = phase(self._test_state) self.assertIs(phase.func, extra_arg_func.func) self.assertEqual('input arg', result) self.assertEqual('func-name(i)', phase.name) @@ -137,7 +147,7 @@ def test_with_args(self): # Must do with_args() on the original phase, otherwise it has already been # formatted and the format-arg information is lost. second_phase = extra_arg_func.with_args(input_value='second input') - second_result = second_phase(self._phase_data) + second_result = second_phase(self._test_state) self.assertEqual('second input', second_result) self.assertEqual('func-name(s)', second_phase.name) @@ -154,15 +164,77 @@ def phase(test_api, **kwargs): updated = phase.with_args(arg_does_not_exist=1) self.assertEqual({'arg_does_not_exist': 1}, updated.extra_kwargs) + def test_call_test_api_with_default_args(self): + expected_arg_two = 3 + + @phase_descriptor.PhaseOptions() + def phase(test_api, arg_one=1, arg_two=2): + self.assertIsInstance(test_api, test_descriptor.TestApi) + self.assertEqual(arg_one, 1) + # We are changing the arg with the with_args statement when called. + self.assertEqual(arg_two, expected_arg_two) + + self._test_state.running_phase_state = ( + test_state.PhaseState.from_descriptor(phase, self._test_state, + logging.get_absl_logger())) + phase.with_args(arg_two=expected_arg_two)(self._test_state) + + def test_call_only_default_args(self): + expected_arg_two = 3 + + @phase_descriptor.PhaseOptions() + def phase(arg_one=1, arg_two=2): + self.assertEqual(arg_one, 1) + # We are changing the arg with the with_args statement when called. + self.assertEqual(arg_two, expected_arg_two) + + self._test_state.running_phase_state = ( + test_state.PhaseState.from_descriptor(phase, self._test_state, + logging.get_absl_logger())) + phase.with_args(arg_two=expected_arg_two)(self._test_state) + + def test_call_test_api_default_args_and_plug(self): + expected_arg_one = 5 + self._test_state.plug_manager.initialize_plugs([ExtraPlug]) + + @plugs.plug(custom_plug=ExtraPlug) + def phase(test_api, custom_plug, arg_one=1, arg_two=2): + self.assertIsInstance(test_api, test_descriptor.TestApi) + self.assertIsInstance(custom_plug, ExtraPlug) + # We are changing the arg with the with_args statement when called. + self.assertEqual(arg_one, expected_arg_one) + self.assertEqual(arg_two, 2) + + self._test_state.running_phase_state = ( + test_state.PhaseState.from_descriptor(phase, self._test_state, + logging.get_absl_logger())) + phase.with_args(arg_one=expected_arg_one)(self._test_state) + + def test_call_only_default_args_and_plug(self): + expected_arg_one = 5 + self._test_state.plug_manager.initialize_plugs([ExtraPlug]) + + @plugs.plug(custom_plug=ExtraPlug) + def phase(custom_plug, arg_one=1, arg_two=2): + self.assertIsInstance(custom_plug, ExtraPlug) + # We are changing the arg with the with_args statement when called. + self.assertEqual(arg_one, expected_arg_one) + self.assertEqual(arg_two, 2) + + self._test_state.running_phase_state = ( + test_state.PhaseState.from_descriptor(phase, self._test_state, + logging.get_absl_logger())) + phase.with_args(arg_one=expected_arg_one)(self._test_state) + def test_with_plugs(self): - self._phase_data.plug_manager.initialize_plugs([ExtraPlug]) + self._test_state.plug_manager.initialize_plugs([ExtraPlug]) phase = extra_plug_func.with_plugs(plug=ExtraPlug).with_args(phrase='hello') self.assertIs(phase.func, extra_plug_func.func) self.assertEqual(1, len(phase.plugs)) self.assertEqual('extra_plug_func[extra_plug_0][hello]', phase.options.name) self.assertEqual('extra_plug_func[extra_plug_0][hello]', phase.name) - result = phase(self._phase_data) + result = phase(self._test_state) self.assertEqual('extra_plug_0 says hello', result) def test_with_plugs_unknown_plug_name_ignored(self): @@ -187,3 +259,122 @@ def test_with_plugs_custom_placeholder_is_base_plug(self): self.assertIs(phase.func, custom_placeholder_phase.func) self.assertEqual([base_plugs.PhasePlug('custom', PlugVersionOfNonPlug)], phase.plugs) + + +class DupeResultA(openhtf.DiagResultEnum): + DUPE = 'dupe' + + +class DupeResultB(openhtf.DiagResultEnum): + DUPE = 'dupe' + + +@openhtf.PhaseDiagnoser(DupeResultA) +def dupe_a_phase_diag(phase_record): + del phase_record # Unused. + return DupeResultA.DUPE + + +@openhtf.PhaseDiagnoser(DupeResultA) +def dupe_a2_phase_diag(phase_record): + del phase_record # Unused. + return DupeResultA.DUPE + + +@openhtf.PhaseDiagnoser(DupeResultB) +def dupe_b_phase_diag(phase_record): + del phase_record # Unused. + return DupeResultB.DUPE + + +@openhtf.TestDiagnoser(DupeResultA) +def dupe_a_test_diag(test_record_, store): + del test_record_ # Unused. + del store # Unused. + return DupeResultA.DUPE + + +@openhtf.TestDiagnoser(DupeResultA) +def dupe_a2_test_diag(test_record_, store): + del test_record_ # Unused. + del store # Unused. + return DupeResultA.DUPE + + +@openhtf.TestDiagnoser(DupeResultB) +def dupe_b_test_diag(test_record_, store): + del test_record_ # Unused. + del store # Unused. + return DupeResultB.DUPE + + +class CheckForDuplicateResultsTest(unittest.TestCase): + + def test_phase_phase_dupe(self): + + @openhtf.diagnose(dupe_a_phase_diag) + def a1(): + pass + + @openhtf.diagnose(dupe_b_phase_diag) + def b2(): + pass + + with self.assertRaises(phase_descriptor.DuplicateResultError): + phase_descriptor.check_for_duplicate_results(iter([a1, b2]), []) + + def test_phase_phase_same_result(self): + + @openhtf.diagnose(dupe_a_phase_diag) + def a1(): + pass + + @openhtf.diagnose(dupe_a2_phase_diag) + def a2(): + pass + + phase_descriptor.check_for_duplicate_results(iter([a1, a2]), []) + + def test_phase_phase_same_diagnoser(self): + + @openhtf.diagnose(dupe_a_phase_diag) + def a1(): + pass + + @openhtf.diagnose(dupe_a_phase_diag) + def a2(): + pass + + phase_descriptor.check_for_duplicate_results(iter([a1, a2]), []) + + def test_phase_test_dupe(self): + + @openhtf.diagnose(dupe_a_phase_diag) + def a1(): + pass + + with self.assertRaises(phase_descriptor.DuplicateResultError): + phase_descriptor.check_for_duplicate_results( + iter([a1]), [dupe_b_test_diag]) + + def test_phase_test_same_result(self): + + @openhtf.diagnose(dupe_a_phase_diag) + def a1(): + pass + + phase_descriptor.check_for_duplicate_results( + iter([a1]), [dupe_a2_test_diag]) + + def test_test_test_dupe(self): + with self.assertRaises(phase_descriptor.DuplicateResultError): + phase_descriptor.check_for_duplicate_results( + iter([]), [dupe_a_test_diag, dupe_b_test_diag]) + + def test_test_test_same_result(self): + phase_descriptor.check_for_duplicate_results( + iter([]), [dupe_a_test_diag, dupe_a2_test_diag]) + + def test_test_test_same_diagnoser(self): + phase_descriptor.check_for_duplicate_results( + iter([]), [dupe_a_test_diag, dupe_a_test_diag]) diff --git a/test/test_state_test.py b/test/test_state_test.py index f03a21f38..74c858b9a 100644 --- a/test/test_state_test.py +++ b/test/test_state_test.py @@ -14,17 +14,21 @@ import copy import logging +import sys import tempfile import unittest +from absl.testing import parameterized import mock - import openhtf from openhtf.core import phase_collections +from openhtf.core import phase_descriptor +from openhtf.core import phase_executor from openhtf.core import test_descriptor from openhtf.core import test_record from openhtf.core import test_state from openhtf.util import conf +from openhtf.util import threads @openhtf.measures('test_measurement') @@ -60,6 +64,7 @@ def test_phase(): 'start_time_millis': 0, 'end_time_millis': None, 'outcome': None, + 'marginal': None, 'result': None, 'diagnosers': [], 'diagnosis_results': [], @@ -80,6 +85,7 @@ def test_phase(): 'end_time_millis': None, 'outcome': None, 'outcome_details': [], + 'marginal': None, 'metadata': { 'config': {} }, @@ -98,7 +104,7 @@ def test_phase(): } -class TestTestApi(unittest.TestCase): +class TestTestApi(parameterized.TestCase): def setUp(self): super(TestTestApi, self).setUp() @@ -131,6 +137,11 @@ def test_get_attachment(self): self.assertEqual(input_contents, output_attachment.data) self.assertEqual(mimetype, output_attachment.mimetype) + def test_get_attachment_strict(self): + attachment_name = 'attachment.txt' + with self.assertRaises(test_descriptor.AttachmentNotFoundError): + self.test_api.get_attachment_strict(attachment_name) + def test_get_measurement(self): measurement_val = [1, 2, 3] self.test_api.measurements['test_measurement'] = measurement_val @@ -216,3 +227,25 @@ def test_test_state_cache(self): self.assertEqual(expected_after_phase_record_basetypes, basetypes2['test_record']['phases'][0]) self.assertIsNone(basetypes2['running_phase_state']) + + @parameterized.parameters( + (phase_executor.PhaseExecutionOutcome(None), test_record.Outcome.TIMEOUT), + (phase_executor.PhaseExecutionOutcome( + phase_descriptor.PhaseResult.STOP), test_record.Outcome.FAIL), + (phase_executor.PhaseExecutionOutcome( + threads.ThreadTerminationError()), test_record.Outcome.ERROR)) + def test_test_state_finalize_from_phase_outcome( + self, phase_exe_outcome: phase_executor.PhaseExecutionOutcome, + test_record_outcome: test_record.Outcome): + self.test_state.finalize_from_phase_outcome(phase_exe_outcome) + self.assertEqual(self.test_state.test_record.outcome, test_record_outcome) + + def test_test_state_finalize_from_phase_outcome_exception_info(self): + try: + raise ValueError('Exception for unit testing.') + except ValueError: + phase_exe_outcome = phase_executor.PhaseExecutionOutcome( + phase_executor.ExceptionInfo(*sys.exc_info())) + self.test_state.finalize_from_phase_outcome(phase_exe_outcome) + self.assertEqual(self.test_state.test_record.outcome, + test_record.Outcome.ERROR) diff --git a/test/util/test_test.py b/test/util/test_test.py index a49a54459..39c42a688 100644 --- a/test/util/test_test.py +++ b/test/util/test_test.py @@ -12,13 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import unittest +from unittest import mock import openhtf from openhtf import plugs from openhtf.core import base_plugs -from openhtf.core import measurements from openhtf.util import test from openhtf.util import validators @@ -37,12 +38,35 @@ def do_stuff(self, unused): raise NotImplementedError('MyPlug not mocked correctly') -@plugs.plug(my_plug=MyPlug) -@measurements.measures('test_measurement', 'othr_measurement') -@measurements.measures('passes', validators=[validators.in_range(1, 10)]) -@measurements.measures('fails', validators=[validators.in_range(1, 10)]) -@measurements.measures('unset_measurement') -def test_phase(phase_data, my_plug): +class ShamelessPlug(base_plugs.BasePlug): + """Shamelessly plugs itself.""" + + def plug_away(self): + logging.info('%s is best plug.', self.__class__.__name__) + + +class ShamelessPlugStub(base_plugs.BasePlug): + """Stub/fake implementation for ShamelessPlug.""" + plug_away_call_counts: int + + def __init__(self): + super().__init__() + self.plug_away_call_counts = 0 + + def plug_away(self): + self.plug_away_call_counts += 1 + + +_DO_STUFF_RETVAL = 0xBEEF + + +@plugs.plug(my_plug=MyPlug, shameless_plug=ShamelessPlug) +@openhtf.measures('test_measurement', 'othr_measurement') +@openhtf.measures('passes', validators=[validators.in_range(1, 10)]) +@openhtf.measures('fails', validators=[validators.in_range(1, 10)]) +@openhtf.measures('unset_measurement') +def test_phase(phase_data, my_plug, shameless_plug: ShamelessPlug): + shameless_plug.plug_away() phase_data.logger.error('in phase_data %s', id(phase_data)) phase_data.logger.error('in measurements %s', id(phase_data.measurements)) phase_data.measurements.test_measurement = my_plug.do_stuff('stuff_args') @@ -52,6 +76,12 @@ def test_phase(phase_data, my_plug): phase_data.test_record.add_outcome_details(0xBED) +@plugs.plug(shameless_plug=ShamelessPlug) +def test_phase_with_shameless_plug(phase_data, shameless_plug: ShamelessPlug): + shameless_plug.plug_away() + phase_data.logger.info('Done using plug') + + def raising_phase(): raise DummyError('This Phase raises!') @@ -82,6 +112,61 @@ def test_bad(self): class TestTest(test.TestCase): + def test_execute_phase_or_test_phase_with_no_patched_plugs(self): + phase_record = self.execute_phase_or_test(test_phase_with_shameless_plug) + self.assertPhaseContinue(phase_record) + + def test_execute_phase_or_test_test_with_no_patched_plugs(self): + test_record = self.execute_phase_or_test( + openhtf.Test(test_phase_with_shameless_plug)) + self.assertTestPass(test_record) + + def test_execute_phase_or_test_phase_with_patched_plugs(self): + """Example of partial patching of plugs.""" + self.auto_mock_plugs(MyPlug) + shameless_plug = ShamelessPlug() + self.plugs[ShamelessPlug] = shameless_plug + with mock.patch.object( + shameless_plug, shameless_plug.plug_away.__name__, + autospec=True) as mocked_plug_away: + phase_record = self.execute_phase_or_test(test_phase) + mocked_plug_away.assert_called_once_with() + self.assertPhaseContinue(phase_record) + + def test_execute_phase_or_test_phase_with_stub_plugs(self): + """Example using stubs/fakes for plugs.""" + self.auto_mock_plugs(MyPlug) + # Tells the test executor to substitute ShamelessPlugStub for any phases + # using ShamelessPlug. + self.plugs[ShamelessPlug] = ShamelessPlugStub() + phase_record = self.execute_phase_or_test(test_phase) + self.assertEqual(self.plugs[ShamelessPlug].plug_away_call_counts, 1) + self.assertPhaseContinue(phase_record) + + def _run_my_phase_in_test_asserts(self, mock_my_plug, test_record): + mock_my_plug.do_stuff.assert_called_with('stuff_args') + # The test fails because the 'fails' measurement fails. + self.assertTestFail(test_record) + self.assertTestOutcomeCode(test_record, 0xBED) + self.assertNotMeasured(test_record, 'unset_measurement') + self.assertNotMeasured(test_record.phases[-1], 'unset_measurement') + self.assertMeasured(test_record, 'test_measurement', _DO_STUFF_RETVAL) + self.assertMeasured(test_record, 'othr_measurement', 0xDEAD) + self.assertMeasurementPass(test_record, 'passes') + self.assertMeasurementFail(test_record, 'fails') + + def test_execute_phase_or_test_test_with_patched_plugs(self): + self.auto_mock_plugs(MyPlug) + self.plugs[MyPlug].do_stuff.return_value = _DO_STUFF_RETVAL + shameless_plug = ShamelessPlug() + self.plugs[ShamelessPlug] = shameless_plug + with mock.patch.object( + shameless_plug, shameless_plug.plug_away.__name__, + autospec=True) as mocked_plug_away: + test_record = self.execute_phase_or_test(openhtf.Test(test_phase)) + mocked_plug_away.assert_called_once_with() + self._run_my_phase_in_test_asserts(self.plugs[MyPlug], test_record) + @test.yields_phases def test_phase_retvals(self): phase_record = yield phase_retval(openhtf.PhaseResult.CONTINUE) @@ -93,33 +178,26 @@ def test_phase_retvals(self): @test.patch_plugs(mock_plug='.'.join((MyPlug.__module__, MyPlug.__name__))) def test_patch_plugs_phase(self, mock_plug): - mock_plug.do_stuff.return_value = 0xBEEF + mock_plug.do_stuff.return_value = _DO_STUFF_RETVAL phase_record = yield test_phase mock_plug.do_stuff.assert_called_with('stuff_args') + self.assertIs(self.plugs[MyPlug], mock_plug) + self.assertIsInstance(self.plugs[ShamelessPlug], ShamelessPlug) self.assertPhaseContinue(phase_record) self.assertEqual('test_phase', phase_record.name) - self.assertMeasured(phase_record, 'test_measurement', 0xBEEF) + self.assertMeasured(phase_record, 'test_measurement', _DO_STUFF_RETVAL) self.assertMeasured(phase_record, 'othr_measurement', 0xDEAD) self.assertMeasurementPass(phase_record, 'passes') self.assertMeasurementFail(phase_record, 'fails') @test.patch_plugs(mock_plug='.'.join((MyPlug.__module__, MyPlug.__name__))) def test_patch_plugs_test(self, mock_plug): - mock_plug.do_stuff.return_value = 0xBEEF + mock_plug.do_stuff.return_value = _DO_STUFF_RETVAL test_record = yield openhtf.Test(phase_retval(None), test_phase) - mock_plug.do_stuff.assert_called_with('stuff_args') - # The test fails because the 'fails' measurement fails. - self.assertTestFail(test_record) - self.assertTestOutcomeCode(test_record, 0xBED) - self.assertNotMeasured(test_record, 'unset_measurement') - self.assertNotMeasured(test_record.phases[-1], 'unset_measurement') - self.assertMeasured(test_record, 'test_measurement', 0xBEEF) - self.assertMeasured(test_record, 'othr_measurement', 0xDEAD) - self.assertMeasurementPass(test_record, 'passes') - self.assertMeasurementFail(test_record, 'fails') + self._run_my_phase_in_test_asserts(mock_plug, test_record) @unittest.expectedFailure @test.yields_phases diff --git a/test/util/text_test.py b/test/util/text_test.py new file mode 100644 index 000000000..00f301ff5 --- /dev/null +++ b/test/util/text_test.py @@ -0,0 +1,397 @@ +# Copyright 2021 Google Inc. All Rights Reserved. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for the text module.""" + +import io +import sys +import types +import typing +import unittest +from unittest import mock + +from absl.testing import parameterized +import colorama +import openhtf +from openhtf.core import measurements +from openhtf.core import phase_descriptor +from openhtf.core import phase_executor +from openhtf.core import test_record +from openhtf.util import test +from openhtf.util import text +from openhtf.util import threads + +# colorama makes these strings at runtime but pytype cannot infer this. +_RED = typing.cast(str, colorama.Fore.RED) +_GREEN = typing.cast(str, colorama.Fore.GREEN) + + +@openhtf.measures( + openhtf.Measurement('text_measurement_a').equals('a'), + openhtf.Measurement('text_measurement_b').equals('b'), + openhtf.Measurement('text_measurement_c').equals('c')) +def PhaseWithFailure(test_api): + """Phase with measurement failures.""" + test_api.measurements.text_measurement_a = 'intentionally wrong measurement' + # text_measurement_b is intentionally not set. + test_api.measurements.text_measurement_c = 'c' + + +@openhtf.PhaseOptions() +def PhaseWithSkip(): + """Phase that is skipped.""" + return openhtf.PhaseResult.SKIP + + +@openhtf.measures( + openhtf.Measurement('text_measurement_a').equals('a'), + openhtf.Measurement('text_measurement_b').equals('b')) +def PhaseWithError(): + """Phase raising an error.""" + raise Exception('Intentional exception from test case.') + + +@openhtf.measures( + openhtf.Measurement('text_measurement_a').equals('a'), + openhtf.Measurement('text_measurement_b').equals('b')) +def PhaseThatSucceeds(test_api): + """Phase with passing measurements and attachments.""" + test_api.measurements.text_measurement_a = 'a' + test_api.measurements.text_measurement_b = 'b' + test_api.attach('attachment_a.txt', 'sample_attachment_a') + test_api.attach('attachment_b.json', '{}', mimetype='application/json') + + +class TextTest(test.TestCase, parameterized.TestCase): + + def testColorFromTestOutcome_HasCorrespondingTestOutcomeName(self): + """Catches OpenHTF test outcome not added in _ColorFromTestOutcome.""" + for member in test_record.Outcome.__members__: + self.assertIn(member, text._ColorFromTestOutcome.__members__) + + def testHeadlineFromTestOutcome_HasCorrespondingTestOutcomeName(self): + """Catches OpenHTF test outcome not added in _HeadlineFromTestOutcome.""" + for member in test_record.Outcome.__members__: + self.assertIn(member, text._HeadlineFromTestOutcome.__members__) + + def testColorText_GetsColorSuccessfully(self): + text_to_colorize = 'Foo Bar' + self.assertEqual( + text._ColorText(text_to_colorize, _GREEN), + f'{_GREEN}{text_to_colorize}{colorama.Style.RESET_ALL}') + + # TODO(b/70517332): Pytype currently doesn't properly support the functional + # API of enums: https://github.com/google/pytype/issues/459. Remove + # disabling pytype once fixed. + @parameterized.named_parameters( + (headline_member.name, headline_member.name, headline_member.value) + for headline_member in text._HeadlineFromTestOutcome.__iter__()) # pytype: disable=attribute-error + def testGetTestOutcomeHeadline_TestNotColorized(self, outcome, headline): + record = test_record.TestRecord( + dut_id='TestDutId', + station_id='test_station', + outcome=test_record.Outcome[outcome]) + self.assertEqual(text._GetTestOutcomeHeadline(record), headline) + + # TODO(b/70517332): Pytype currently doesn't properly support the functional + # API of enums: https://github.com/google/pytype/issues/459. Remove + # disabling pytype once fixed. + @parameterized.named_parameters( + (headline_member.name, headline_member.name, headline_member.value) + for headline_member in text._HeadlineFromTestOutcome.__iter__()) # pytype: disable=attribute-error + def testGetTestOutcomeHeadline_TestColorized(self, outcome, headline): + record = test_record.TestRecord( + dut_id='TestDutId', + station_id='test_station', + outcome=test_record.Outcome[outcome]) + # TODO(b/70517332): Pytype currently doesn't properly support the functional + # API of enums: https://github.com/google/pytype/issues/459. Remove + # disabling pytype once fixed. + self.assertEqual( + text._GetTestOutcomeHeadline(record, colorize_text=True), + f'{text._ColorFromTestOutcome[outcome].value}{headline}' # pytype: disable=unsupported-operands + f'{colorama.Style.RESET_ALL}') + + def testStringFromMeasurement_SuccessfullyConvertsUnsetMeasurement(self): + self.assertEqual( + text.StringFromMeasurement(openhtf.Measurement('text_measurement_a')), + '| text_measurement_a was not set') + + def testStringFromMeasurement_SuccessfullyConvertsPassMeasurement(self): + measurement = openhtf.Measurement('text_measurement_a') + measurement._measured_value = measurements.MeasuredValue( + 'text_measurement_a') + measurement._measured_value.set(10) + measurement.notify_value_set() + self.assertEqual( + text.StringFromMeasurement(measurement), '| text_measurement_a: 10') + + def testStringFromMeasurement_SuccessfullyConvertsFailMeasurement(self): + measurement = openhtf.Measurement('text_measurement_a').in_range(maximum=3) + measurement._measured_value = measurements.MeasuredValue( + 'text_measurement_a') + measurement._measured_value.set(5) + measurement.notify_value_set() + output = text.StringFromMeasurement(measurement) + self.assertEqual( + output, + "| text_measurement_a failed because 5 failed these checks: ['x <= 3']") + self.assertNotIn(text._BRIGHT_RED_STYLE, output) + + def testStringFromMeasurement_SuccessfullyConvertsFailMeasurementColorized( + self): + measurement = openhtf.Measurement('text_measurement_a').in_range(maximum=3) + measurement._measured_value = measurements.MeasuredValue( + 'text_measurement_a') + measurement._measured_value.set(5) + measurement.notify_value_set() + self.assertEqual( + text.StringFromMeasurement(measurement, colorize_text=True).count( + text._BRIGHT_RED_STYLE), 1) + + def testStringFromAttachment_SuccessfullyConvertsPassMeasurement(self): + attachment = test_record.Attachment('content', 'text/plain') + self.assertEqual( + text.StringFromAttachment(attachment, 'attachment_a.txt'), + '| attachment: attachment_a.txt (mimetype=text/plain)') + + @parameterized.named_parameters([ + { + 'testcase_name': 'None', + 'phase_result': None, + 'expected_str': '' + }, + { + 'testcase_name': 'PhaseResult', + 'phase_result': phase_descriptor.PhaseResult.CONTINUE, + 'expected_str': 'CONTINUE' + }, + { + 'testcase_name': + 'ExceptionInfo', + 'phase_result': + phase_executor.ExceptionInfo( + ValueError, ValueError('Invalid Value'), + mock.create_autospec(types.TracebackType, spec_set=True)), + 'expected_str': + 'ValueError' + }, + { + 'testcase_name': 'ThreadTerminationError', + 'phase_result': threads.ThreadTerminationError(), + 'expected_str': 'ThreadTerminationError' + }, + ]) + def testStringFromPhaseExecutionOutcome_SuccessfullyConvertsOutcome( + self, phase_result, expected_str): + self.assertEqual( + text.StringFromPhaseExecutionOutcome( + phase_executor.PhaseExecutionOutcome(phase_result)), expected_str) + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseRecordPassPhase(self): + record = self.execute_phase_or_test(PhaseThatSucceeds) + output = text.StringFromPhaseRecord(record) + self.assertEqual( + output, 'Phase PhaseThatSucceeds\n' + '+ Outcome: PASS Result: CONTINUE\n' + '| text_measurement_a: a\n' + '| text_measurement_b: b\n' + '| attachment: attachment_a.txt (mimetype=text/plain)\n' + '| attachment: attachment_b.json (mimetype=application/json)') + self.assertNotIn(text._BRIGHT_RED_STYLE, output) + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseRecordFailPhase(self): + record = self.execute_phase_or_test(PhaseWithFailure) + output = text.StringFromPhaseRecord(record) + self.assertEqual( + output, 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]\n' + '| text_measurement_b was not set\n' + '| text_measurement_c: c') + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseFailLimitPhase(self): + record = self.execute_phase_or_test(PhaseWithFailure) + output = text.StringFromPhaseRecord(record, maximum_num_measurements=2) + self.assertEqual( + output, 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]\n' + '| text_measurement_b was not set\n' + '...') + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseRecordOnlyFailPhase( + self): + record = self.execute_phase_or_test(PhaseWithFailure) + output = text.StringFromPhaseRecord(record, only_failures=True) + self.assertEqual( + output, 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]') + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseRecordFailPhaseColored( + self): + record = self.execute_phase_or_test(PhaseWithFailure) + self.assertEqual( + text.StringFromPhaseRecord(record, colorize_text=True).count(_RED), 3) + + def testStringFromPhaseRecord_SuccessfullyConvertsPhaseRecordSkipPhaseColored( + self): + record = self.execute_phase_or_test(PhaseWithSkip) + self.assertNotIn(text._BRIGHT_RED_STYLE, + text.StringFromPhaseRecord(record, colorize_text=True)) + + @parameterized.named_parameters([ + { + 'testcase_name': + 'OneOutcome', + 'outcome_details': [ + test_record.OutcomeDetails( + code=1, description='Unknown exception.') + ], + 'expected_str': ('The test thinks this may be the reason:\n' + '1: Unknown exception.'), + }, + { + 'testcase_name': + 'TwoOutcomes', + 'outcome_details': [ + test_record.OutcomeDetails( + code=1, description='Unknown exception.'), + test_record.OutcomeDetails( + code='FooError', description='Foo exception.') + ], + 'expected_str': ('The test thinks these may be the reason:\n' + '1: Unknown exception.\n' + 'FooError: Foo exception.'), + }, + ]) + def testStringFromOutcomeDetails_SuccessfullyConvertsOutcomeDetails( + self, outcome_details, expected_str): + self.assertEqual( + text.StringFromOutcomeDetails(outcome_details), expected_str) + + def testStringFromTestRecord_SuccessfullyConvertsTestRecordSinglePassPhase( + self): + record = self.execute_phase_or_test(openhtf.Test(PhaseThatSucceeds)) + self.assertEqual( + text.StringFromTestRecord(record), 'Test finished with a PASS!\n' + 'Woohoo!\n' + 'Phase trigger_phase\n' + '+ Outcome: PASS Result: CONTINUE\n' + 'Phase PhaseThatSucceeds\n' + '+ Outcome: PASS Result: CONTINUE\n' + '| text_measurement_a: a\n' + '| text_measurement_b: b\n' + '| attachment: attachment_a.txt (mimetype=text/plain)\n' + '| attachment: attachment_b.json (mimetype=application/json)\n' + 'Test finished with a PASS!') + + def testStringFromTestRecord_SuccessfullyConvertsTestErrorPhase(self): + record = self.execute_phase_or_test(openhtf.Test(PhaseWithError)) + self.assertEqual( + text.StringFromTestRecord(record), 'Test encountered an ERROR!!!\n' + 'Phase trigger_phase\n' + '+ Outcome: PASS Result: CONTINUE\n' + 'Phase PhaseWithError\n' + '+ Outcome: ERROR Result: Exception\n' + '| text_measurement_a was not set\n' + '| text_measurement_b was not set\n' + 'The test thinks this may be the reason:\n' + 'Exception: Intentional exception from test case.\n' + 'Test encountered an ERROR!!!') + + def testStringFromTestRecord_SuccessfullyConvertsTestFailurePhase(self): + record = self.execute_phase_or_test(openhtf.Test(PhaseWithFailure)) + output = text.StringFromTestRecord(record) + self.assertEqual( + output, 'Test finished with a FAIL :(\n' + 'Phase trigger_phase\n' + '+ Outcome: PASS Result: CONTINUE\n' + 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]\n' + '| text_measurement_b was not set\n' + '| text_measurement_c: c\n' + 'Test finished with a FAIL :(') + self.assertNotIn(text._BRIGHT_RED_STYLE, output) + + def testStringFromTestRecord_SuccessfullyConvertsTestOnlyFailurePhase(self): + record = self.execute_phase_or_test( + openhtf.Test(PhaseThatSucceeds, PhaseWithFailure)) + output = text.StringFromTestRecord(record, only_failures=True) + self.assertEqual( + output, 'Test finished with a FAIL :(\n' + 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]\n' + 'Test finished with a FAIL :(') + self.assertNotIn(text._BRIGHT_RED_STYLE, output) + + def testStringFromTestRecord_SuccessfullyConvertsTestFailurePhaseColored( + self): + record = self.execute_phase_or_test(openhtf.Test(PhaseWithFailure)) + self.assertEqual( + text.StringFromTestRecord(record, colorize_text=True).count(_RED), 5) + + def testStringFromTestRecord_SuccessfullyConvertsTestFailureMultiplePhases( + self): + record = self.execute_phase_or_test( + openhtf.Test(PhaseThatSucceeds, PhaseWithFailure)) + self.assertEqual( + text.StringFromTestRecord(record), 'Test finished with a FAIL :(\n' + 'Phase trigger_phase\n' + '+ Outcome: PASS Result: CONTINUE\n' + 'Phase PhaseThatSucceeds\n' + '+ Outcome: PASS Result: CONTINUE\n' + '| text_measurement_a: a\n' + '| text_measurement_b: b\n' + '| attachment: attachment_a.txt (mimetype=text/plain)\n' + '| attachment: attachment_b.json (mimetype=application/json)\n' + 'Phase PhaseWithFailure\n' + '+ Outcome: FAIL Result: CONTINUE\n' + '| text_measurement_a failed because intentionally wrong measurement ' + 'failed these checks: ["\'x\' matches /^a$/"]\n' + '| text_measurement_b was not set\n' + '| text_measurement_c: c\n' + 'Test finished with a FAIL :(') + + def testPrintTestRecord_SuccessfullyLogsNotColored(self): + record = self.execute_phase_or_test(openhtf.Test(PhaseThatSucceeds)) + with mock.patch.object(sys, 'stdout', new_callable=io.StringIO) as cm: + with mock.patch.object( + sys.stdout, + sys.stdout.isatty.__name__, + autospec=True, + spec_set=True, + return_value=False): + text.PrintTestRecord(record) + self.assertTrue(cm.getvalue()) + self.assertNotIn(_GREEN, cm.getvalue()) + + def testPrintTestRecord_SuccessfullyLogsColored(self): + record = self.execute_phase_or_test(openhtf.Test(PhaseThatSucceeds)) + with mock.patch.object(sys, 'stdout', new_callable=io.StringIO) as cm: + with mock.patch.object( + sys.stdout, + sys.stdout.isatty.__name__, + autospec=True, + spec_set=True, + return_value=True): + text.PrintTestRecord(record) + self.assertIn(_GREEN, cm.getvalue()) diff --git a/test/util/validators_test.py b/test/util/validators_test.py index ccba2a59f..890a9b700 100644 --- a/test/util/validators_test.py +++ b/test/util/validators_test.py @@ -66,6 +66,128 @@ def test_with_custom_type(self): self.assertEqual(test_validator.maximum, 0x12) +class TestAllInRange(unittest.TestCase): + + def setUp(self): + super().setUp() + self.minimum = -20.5 + self.maximum = 0.3 + self.marginal_minimum = -10.1 + self.marginal_maximum = -8.2 + self.validator = validators.AllInRangeValidator(self.minimum, self.maximum, + self.marginal_minimum, + self.marginal_maximum) + + def test_properties(self): + with self.subTest('minimum'): + self.assertAlmostEqual(self.validator.minimum, self.minimum) + with self.subTest('maximum'): + self.assertAlmostEqual(self.validator.maximum, self.maximum) + with self.subTest('marginal_minimum'): + self.assertAlmostEqual(self.validator.marginal_minimum, + self.marginal_minimum) + with self.subTest('marginal_maximum'): + self.assertAlmostEqual(self.validator.marginal_maximum, + self.marginal_maximum) + + def test_returns_false_for_out_of_range_values(self): + with self.subTest('beyond_minimum'): + self.assertFalse(self.validator([self.minimum - 1, self.maximum])) + with self.subTest('beyond_maximum'): + self.assertFalse(self.validator([self.minimum, self.maximum + 1])) + + def test_returns_true_within_bounds(self): + with self.subTest('with_all_values_set'): + self.assertTrue(self.validator([self.minimum, self.maximum])) + + def test_returns_true_within_bounds_without_maximum(self): + validator = validators.AllInRangeValidator(self.minimum, None) + self.assertTrue(validator([self.minimum, self.maximum])) + + def test_returns_true_within_bounds_without_minimum(self): + validator = validators.AllInRangeValidator(None, self.maximum) + self.assertTrue(validator([self.minimum, self.maximum])) + + def test_is_marginal_is_marginal_returns_true_for_out_of_range_values(self): + with self.subTest('beyond_marginal_minimum'): + self.assertTrue( + self.validator.is_marginal([self.minimum, self.marginal_maximum - 1])) + with self.subTest('beyond_marginal_maximum'): + self.assertTrue( + self.validator.is_marginal([self.marginal_minimum + 1, self.maximum])) + + def test_is_marginal_false_within_bounds(self): + self.assertFalse( + self.validator.is_marginal( + [self.marginal_minimum + 1, self.marginal_maximum - 1])) + + def test_is_marginal_false_fully_out_of_range(self): + self.assertFalse( + self.validator.is_marginal( + [self.minimum - 1, self.maximum + 1])) + + def test_is_marginal_false_without_marginal_bounds(self): + validator = validators.AllInRangeValidator(self.minimum, self.minimum) + self.assertFalse(validator.is_marginal([self.minimum, self.maximum])) + + def test_raises_for_unset_minimum_and_maximum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator(None, None) + self.assertIn('Must specify minimum, maximum, or both', + str(raises_context.exception)) + + def test_raises_for_minimum_above_maximum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator(minimum=self.maximum, maximum=self.minimum) + self.assertIn('Minimum cannot be greater than maximum', + str(raises_context.exception)) + + def test_raises_for_marginal_minimum_without_minimum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator(None, self.maximum, self.marginal_minimum, + self.marginal_maximum) + self.assertIn('Marginal minimum was specified without a minimum', + str(raises_context.exception)) + + def test_raises_for_marginal_maximum_without_maximum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator(self.minimum, None, self.marginal_minimum, + self.marginal_maximum) + self.assertIn('Marginal maximum was specified without a maximum', + str(raises_context.exception)) + + def test_raises_for_marginal_minimum_below_minimum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator( + minimum=self.marginal_minimum, + maximum=None, + marginal_minimum=self.minimum, + marginal_maximum=None) + self.assertIn('Marginal minimum cannot be less than the minimum', + str(raises_context.exception)) + + def test_raises_for_marginal_maximum_below_maximum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator( + minimum=None, + maximum=self.marginal_maximum, + marginal_minimum=None, + marginal_maximum=self.maximum) + self.assertIn('Marginal maximum cannot be greater than the maximum', + str(raises_context.exception)) + + def test_raises_for_marginal_minimum_above_marginal_maximum(self): + with self.assertRaises(ValueError) as raises_context: + validators.AllInRangeValidator( + self.minimum, + self.maximum, + marginal_minimum=self.marginal_maximum, + marginal_maximum=self.marginal_minimum) + self.assertIn( + 'Marginal minimum cannot be greater than the marginal maximum', + str(raises_context.exception)) + + class TestEqualsValidator(unittest.TestCase): def test_with_built_in_pods(self): From 5f424ca0e4b3c46bccf5dff6a820e1f7c7da5ec9 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 15 Sep 2021 15:28:58 +0200 Subject: [PATCH 2/2] Don't complain about uninitialized plugs in StationServer logs StationServer seems to be trying to represent (as base type) phase sequences that don't yet have their plugs initialized. This causes a warning to be logged like ``` WARNING:root:Object is not initialized, got error _asdict() missing 1 required positional argument: 'self' ``` But has no other effect. The call stack when that warning is reached is ``` convert_to_base_types (data.py:176) (data.py:199) convert_to_base_types (data.py:201) (data.py:204) convert_to_base_types (data.py:206) (data.py:199) convert_to_base_types (data.py:201) (station_server.py:364) get (station_server.py:364) _execute (web.py:1702) _run (events.py:145) _run_once (base_events.py:1451) run_forever (base_events.py:438) start (asyncio.py:199) run (web_gui_server.py:157) run (station_server.py:626) _bootstrap_inner (threading.py:916) _bootstrap (threading.py:884) ``` This PR removes that warning, behaviour remains the same. --- openhtf/util/data.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openhtf/util/data.py b/openhtf/util/data.py index e49c08339..d0bf9fa3f 100644 --- a/openhtf/util/data.py +++ b/openhtf/util/data.py @@ -21,6 +21,7 @@ import difflib import enum import itertools +import inspect import logging import math import numbers @@ -168,13 +169,8 @@ def convert_to_base_types(obj, if hasattr(obj, 'as_base_types'): return obj.as_base_types() - if hasattr(obj, '_asdict'): - try: - obj = obj._asdict() - except TypeError as e: - # This happens if the object is an uninitialized class. - logging.warning( - 'Object %s is not initialized, got error %s', obj, e) + if hasattr(obj, '_asdict') and not inspect.isclass(obj): + obj = obj._asdict() elif isinstance(obj, records.RecordClass): new_obj = {} for a in type(obj).all_attribute_names: