diff --git a/.github/workflows/flake8.yaml b/.github/workflows/flake8.yaml index ecdc98c..5335fb8 100644 --- a/.github/workflows/flake8.yaml +++ b/.github/workflows/flake8.yaml @@ -17,4 +17,4 @@ jobs: python -m pip install --upgrade pip pip install flake8 - name: Run Flake8 - run: flake8 . --ignore=E266,E261,E265 --max-line-length=120 --count + run: flake8 . --ignore=E266,E261,E265,W503 --max-line-length=120 --count diff --git a/src/vse_sync_pp/analyze.py b/src/vse_sync_pp/analyze.py index bce6ffb..b490b70 100644 --- a/src/vse_sync_pp/analyze.py +++ b/src/vse_sync_pp/analyze.py @@ -16,6 +16,7 @@ Config, ) + def main(): """Analyze log messages from a single source. @@ -58,5 +59,6 @@ def main(): if not print_loj(dct): sys.exit(1) + if __name__ == '__main__': main() diff --git a/src/vse_sync_pp/analyzers/__init__.py b/src/vse_sync_pp/analyzers/__init__.py index e6043e6..4845fcc 100644 --- a/src/vse_sync_pp/analyzers/__init__.py +++ b/src/vse_sync_pp/analyzers/__init__.py @@ -2,8 +2,6 @@ """Analyzers""" -from .analyzer import Config - from . import ( gnss, ppsdpll, diff --git a/src/vse_sync_pp/analyzers/analyzer.py b/src/vse_sync_pp/analyzers/analyzer.py index 498e4c2..2c3a147 100644 --- a/src/vse_sync_pp/analyzers/analyzer.py +++ b/src/vse_sync_pp/analyzers/analyzer.py @@ -8,17 +8,20 @@ from ..requirements import REQUIREMENTS + class Config(): """Analyzer configuration""" def __init__(self, filename=None, requirements=None, parameters=None): self._filename = filename self._requirements = requirements self._parameters = parameters + def _reason(self, reason): """Return `reason`, extended if this config is from a file.""" if self._filename is None: return reason return reason + f' in config file {self._filename}' + def requirement(self, key): """Return the value at `key` in this configuration's requirements. @@ -34,6 +37,7 @@ def requirement(self, key): else: reason = f'unknown requirement {key} in {self._requirements}' raise KeyError(self._reason(reason)) from exc + def parameter(self, key): """Return the value at `key` in this configuration's parameters. @@ -47,6 +51,7 @@ def parameter(self, key): except KeyError as exc: reason = f'unknown parameter {key}' raise KeyError(self._reason(reason)) from exc + @classmethod def from_yaml(cls, filename, encoding='utf-8'): """Build configuration from YAML file at `filename`""" @@ -54,10 +59,12 @@ def from_yaml(cls, filename, encoding='utf-8'): dct = dict(yaml.safe_load(fid.read())) return cls(filename, dct.get('requirements'), dct.get('parameters')) + class CollectionIsClosed(Exception): """Data collection was closed while collecting data""" # empty + class Analyzer(): """A base class providing common analyzer functionality""" def __init__(self, config): @@ -69,12 +76,14 @@ def __init__(self, config): self._timestamp = None self._duration = None self._analysis = None + def collect(self, *rows): """Collect data from `rows`""" if self._rows is None: raise CollectionIsClosed() self._rows += rows - def prepare(self, rows): # pylint: disable=no-self-use + + def prepare(self, rows): """Return (columns, records) from collected data `rows` `columns` is a sequence of column names @@ -83,17 +92,20 @@ def prepare(self, rows): # pylint: disable=no-self-use If `records` is an empty sequence, then `columns` is also empty. """ return (rows[0]._fields, rows) if rows else ((), ()) + def close(self): """Close data collection""" if self._data is None: (columns, records) = self.prepare(self._rows) self._data = DataFrame.from_records(records, columns=columns) self._rows = None + def _test(self): """Close data collection and test collected data""" if self._result is None: self.close() (self._result, self._reason) = self.test(self._data) + def _explain(self): """Close data collection and explain collected data""" if self._analysis is None: @@ -101,6 +113,7 @@ def _explain(self): self._analysis = self.explain(self._data) self._timestamp = self._analysis.pop('timestamp', None) self._duration = self._analysis.pop('duration', None) + def _timestamp_from_dec(self, dec): """Return an absolute timestamp or decimal timestamp from `dec`. @@ -125,31 +138,37 @@ def _timestamp_from_dec(self, dec): return dtv.isoformat() # relative time return dec + @property def result(self): """The boolean result from this analyzer's test of the collected data""" self._test() return self._result + @property def reason(self): """A string qualifying :attr:`result` (or None if unqualified)""" self._test() return self._reason + @property def timestamp(self): """The ISO 8601 date-time timestamp, when the test started""" self._explain() return self._timestamp + @property def duration(self): """The test duration in seconds""" self._explain() return self._duration + @property def analysis(self): """A structured analysis of the collected data""" self._explain() return self._analysis + @staticmethod def _statistics(data, units, ndigits=3): """Return a dict of statistics for `data`, rounded to `ndigits`""" @@ -170,6 +189,7 @@ def _round(val): 'stddev': _round(data.std()), 'variance': _round(data.var()), } + def test(self, data): """This analyzer's test of the collected `data`. @@ -178,10 +198,12 @@ def test(self, data): if unqualified). """ raise NotImplementedError + def explain(self, data): """Return a structured analysis of the collected `data`""" raise NotImplementedError + class TimeErrorAnalyzerBase(Analyzer): """Analyze time error. @@ -189,6 +211,7 @@ class TimeErrorAnalyzerBase(Analyzer): frozenset of values representing locked states. """ locked = frozenset() + def __init__(self, config): super().__init__(config) # required system time output accuracy @@ -201,6 +224,7 @@ def __init__(self, config): self._transient = config.parameter('transient-period/s') # minimum test duration for a valid test self._duration_min = config.parameter('min-test-duration/s') + def prepare(self, rows): idx = 0 try: @@ -213,10 +237,11 @@ def prepare(self, rows): break idx += 1 return super().prepare(rows[idx:]) + def test(self, data): if len(data) == 0: return (False, "no data") - if frozenset(data.state.unique()).difference(self.locked): # pylint: disable=no-member + if frozenset(data.state.unique()).difference(self.locked): return (False, "loss of lock") terr_min = data.terror.min() terr_max = data.terror.max() @@ -227,6 +252,7 @@ def test(self, data): if len(data) - 1 < self._duration_min: return (False, "short test samples") return (True, None) + def explain(self, data): if len(data) == 0: return {} diff --git a/src/vse_sync_pp/analyzers/gnss.py b/src/vse_sync_pp/analyzers/gnss.py index a6b093d..eb0119c 100644 --- a/src/vse_sync_pp/analyzers/gnss.py +++ b/src/vse_sync_pp/analyzers/gnss.py @@ -4,6 +4,7 @@ from .analyzer import TimeErrorAnalyzerBase + class TimeErrorAnalyzer(TimeErrorAnalyzerBase): """Analyze time error""" id_ = 'gnss/time-error' diff --git a/src/vse_sync_pp/analyzers/phc2sys.py b/src/vse_sync_pp/analyzers/phc2sys.py index 0bed788..11b9864 100644 --- a/src/vse_sync_pp/analyzers/phc2sys.py +++ b/src/vse_sync_pp/analyzers/phc2sys.py @@ -4,6 +4,7 @@ from .analyzer import TimeErrorAnalyzerBase + class TimeErrorAnalyzer(TimeErrorAnalyzerBase): """Analyze time error""" id_ = 'phc2sys/time-error' diff --git a/src/vse_sync_pp/analyzers/ppsdpll.py b/src/vse_sync_pp/analyzers/ppsdpll.py index 237f5d9..a0ac399 100644 --- a/src/vse_sync_pp/analyzers/ppsdpll.py +++ b/src/vse_sync_pp/analyzers/ppsdpll.py @@ -4,6 +4,7 @@ from .analyzer import TimeErrorAnalyzerBase + class TimeErrorAnalyzer(TimeErrorAnalyzerBase): """Analyze DPLL time error""" id_ = 'ppsdpll/time-error' @@ -18,6 +19,7 @@ class TimeErrorAnalyzer(TimeErrorAnalyzerBase): # 'state' unlocked but operational # 4 = DPLL_HOLDOVER locked = frozenset({2, 3}) + def prepare(self, rows): return super().prepare([ r._replace(terror=float(r.terror)) for r in rows diff --git a/src/vse_sync_pp/analyzers/ts2phc.py b/src/vse_sync_pp/analyzers/ts2phc.py index e27084c..edab0a8 100644 --- a/src/vse_sync_pp/analyzers/ts2phc.py +++ b/src/vse_sync_pp/analyzers/ts2phc.py @@ -4,6 +4,7 @@ from .analyzer import TimeErrorAnalyzerBase + class TimeErrorAnalyzer(TimeErrorAnalyzerBase): """Analyze time error""" id_ = 'ts2phc/time-error' diff --git a/src/vse_sync_pp/common.py b/src/vse_sync_pp/common.py index 5a07b69..d16c200 100644 --- a/src/vse_sync_pp/common.py +++ b/src/vse_sync_pp/common.py @@ -8,6 +8,7 @@ import json from decimal import Decimal + def open_input(filename, encoding='utf-8', **kwargs): """Return a context manager for reading from `filename`. @@ -17,6 +18,7 @@ def open_input(filename, encoding='utf-8', **kwargs): return nullcontext(sys.stdin) return open(filename, encoding=encoding, **kwargs) + class JsonEncoder(json.JSONEncoder): """A JSON encoder accepting :class:`Decimal` values""" def default(self, o): @@ -25,6 +27,7 @@ def default(self, o): return float(o) return super().default(o) + def print_loj(val, encoder_cls=JsonEncoder, flush=True): """Print value `val` as a line of JSON and, optionally, `flush` stdout. diff --git a/src/vse_sync_pp/demux.py b/src/vse_sync_pp/demux.py index 1976cd7..5002dc9 100644 --- a/src/vse_sync_pp/demux.py +++ b/src/vse_sync_pp/demux.py @@ -13,6 +13,7 @@ from .parsers import PARSERS from .source import muxed + def main(): """Demultiplex log messages from a single multiplexed source. @@ -37,5 +38,6 @@ def main(): if not print_loj(data): sys.exit(1) + if __name__ == '__main__': main() diff --git a/src/vse_sync_pp/parse.py b/src/vse_sync_pp/parse.py index 8247a61..e8ba436 100644 --- a/src/vse_sync_pp/parse.py +++ b/src/vse_sync_pp/parse.py @@ -12,6 +12,7 @@ from .parsers import PARSERS + def main(): """Parse log messages from a single source. @@ -39,5 +40,6 @@ def main(): if not print_loj(data): sys.exit(1) + if __name__ == '__main__': main() diff --git a/src/vse_sync_pp/parsers/dpll.py b/src/vse_sync_pp/parsers/dpll.py index 9094a6c..d7f2bff 100644 --- a/src/vse_sync_pp/parsers/dpll.py +++ b/src/vse_sync_pp/parsers/dpll.py @@ -6,12 +6,14 @@ from .parser import (Parser, parse_timestamp, parse_decimal) + class TimeErrorParser(Parser): """Parse Time Error from a dpll CSV sample""" id_ = 'dpll/time-error' elems = ('timestamp', 'eecstate', 'state', 'terror') y_name = 'terror' parsed = namedtuple('Parsed', elems) + def make_parsed(self, elems): if len(elems) < len(self.elems): raise ValueError(elems) @@ -20,6 +22,7 @@ def make_parsed(self, elems): state = int(elems[2]) terror = parse_decimal(elems[3]) return self.parsed(timestamp, eecstate, state, terror) + def parse_line(self, line): # DPLL samples come from a fixed format CSV file return self.make_parsed(line.split(',')) diff --git a/src/vse_sync_pp/parsers/gnss.py b/src/vse_sync_pp/parsers/gnss.py index 1024e54..8cffbf2 100644 --- a/src/vse_sync_pp/parsers/gnss.py +++ b/src/vse_sync_pp/parsers/gnss.py @@ -6,6 +6,7 @@ from .parser import (Parser, parse_timestamp) + class TimeErrorParser(Parser): """Parse time error from a GNSS CSV sample""" id_ = 'gnss/time-error' @@ -19,6 +20,7 @@ class TimeErrorParser(Parser): elems = ('timestamp', 'state', 'terror') y_name = 'terror' parsed = namedtuple('Parsed', elems) + def make_parsed(self, elems): if len(elems) < len(self.elems): raise ValueError(elems) @@ -26,6 +28,7 @@ def make_parsed(self, elems): state = int(elems[1]) terror = int(elems[2]) return self.parsed(timestamp, state, terror) + def parse_line(self, line): # GNSS samples come from a fixed format CSV file return self.make_parsed(line.split(',')) diff --git a/src/vse_sync_pp/parsers/parser.py b/src/vse_sync_pp/parsers/parser.py index eefed51..1dddcde 100644 --- a/src/vse_sync_pp/parsers/parser.py +++ b/src/vse_sync_pp/parsers/parser.py @@ -12,6 +12,7 @@ r'^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.(\d+)(.*)$' ) + def parse_timestamp_abs(val): """Return a :class:`Decimal` from `val`, an absolute timestamp string. @@ -44,6 +45,7 @@ def parse_timestamp_abs(val): # `dtv` may truncate decimal fraction: use decimal fraction from `val` return Decimal(f'{int(dtv.timestamp())}.{match.group(2)}') + def parse_decimal(val): """Return a :class:`Decimal` from `val` or raise :class:`ValueError`""" try: @@ -51,10 +53,12 @@ def parse_decimal(val): except InvalidOperation as exc: raise ValueError(val) from exc + def parse_timestamp(val): """Return a :class:`Decimal` from absolute or relative timestamp `val`""" return parse_timestamp_abs(val) or parse_decimal(val) + def relative_timestamp(parsed, tzero): """Return relative timestamp with respect to `tzero` coming from `parsed`""" timestamp = getattr(parsed, 'timestamp', None) @@ -64,15 +68,17 @@ def relative_timestamp(parsed, tzero): parsed = parsed._replace(timestamp=timestamp - tzero) return tzero, parsed + class Parser(): """A base class providing common parser functionality""" - def make_parsed(self, elems): # pylint: disable=no-self-use + def make_parsed(self, elems): """Return a namedtuple value from parsed iterable `elems`. Raise :class:`ValueError` if a value cannot be formed from `elems`. """ raise ValueError(elems) - def parse_line(self, line): # pylint: disable=no-self-use,unused-argument + + def parse_line(self, line): """Parse `line`. If `line` is accepted, return a namedtuple value. @@ -80,6 +86,7 @@ def parse_line(self, line): # pylint: disable=no-self-use,unused-argument Otherwise the `line` is discarded, return None. """ return None + def parse(self, file, relative=False): """Parse lines from `file` object. @@ -89,11 +96,12 @@ def parse(self, file, relative=False): """ tzero = None for line in file: - parsed = self.parse_line(line) # pylint: disable=assignment-from-none + parsed = self.parse_line(line) if parsed is not None: if relative: tzero, parsed = relative_timestamp(parsed, tzero) yield parsed + def canonical(self, file, relative=False): """Parse canonical data from `file` object. diff --git a/src/vse_sync_pp/parsers/phc2sys.py b/src/vse_sync_pp/parsers/phc2sys.py index 85f8444..d7e4fa8 100644 --- a/src/vse_sync_pp/parsers/phc2sys.py +++ b/src/vse_sync_pp/parsers/phc2sys.py @@ -7,31 +7,33 @@ from .parser import (Parser, parse_decimal) + class TimeErrorParser(Parser): """Parse time error from a phc2sys log message""" id_ = 'phc2sys/time-error' elems = ('timestamp', 'terror', 'state', 'delay') y_name = 'terror' parsed = namedtuple('Parsed', elems) + @staticmethod def build_regexp(): """Return a regular expression string for parsing phc2sys log file lines""" - return r'\s'.join(( - r'^phc2sys' + - r'\[([1-9][0-9]*\.[0-9]{3})\]:'+ # timestamp - r'(?:\s\[ptp4l\.\d\..*\])?', # configuration file name - r'CLOCK_REALTIME phc offset\s*', - r'(-?[0-9]+)', # time error - r'(\S+)', # state - r'freq\s*', - r'([-+]?[0-9]+)', # frequency error - r'delay\s*', - r'(-?[0-9]+)' + # delay - r'\s*.*$', - )) + return r'\s'.join((r'^phc2sys' + + r'\[([1-9][0-9]*\.[0-9]{3})\]:' # timestamp + + r'(?:\s\[ptp4l\.\d\..*\])?', # configuration file name + r'CLOCK_REALTIME phc offset\s*', + r'(-?[0-9]+)', # time error + r'(\S+)', # state + r'freq\s*', + r'([-+]?[0-9]+)', # frequency error + r'delay\s*', + r'(-?[0-9]+)' # delay + + r'\s*.*$')) + def __init__(self): super().__init__() self._regexp = re.compile(self.build_regexp()) + def make_parsed(self, elems): if len(elems) < len(self.elems): raise ValueError(elems) @@ -40,6 +42,7 @@ def make_parsed(self, elems): state = str(elems[2]) delay = int(elems[3]) return self.parsed(timestamp, terror, state, delay) + def parse_line(self, line): matched = self._regexp.match(line) if matched: diff --git a/src/vse_sync_pp/parsers/pmc.py b/src/vse_sync_pp/parsers/pmc.py index 2525ace..5786217 100644 --- a/src/vse_sync_pp/parsers/pmc.py +++ b/src/vse_sync_pp/parsers/pmc.py @@ -6,18 +6,21 @@ from .parser import (Parser, parse_timestamp) + class ClockClassParser(Parser): """Parse clock class samples""" id_ = 'phc/gm-settings' elems = ('timestamp', 'clock_class') y_name = 'clock_class' parsed = namedtuple('Parsed', elems) + def make_parsed(self, elems): if len(elems) < len(self.elems): raise ValueError(elems) timestamp = parse_timestamp(elems[0]) clock_class = int(elems[1]) return self.parsed(timestamp, clock_class) + def parse_line(self, line): # PMC samples come from a CSV file return self.make_parsed(line.split(',')) diff --git a/src/vse_sync_pp/parsers/ts2phc.py b/src/vse_sync_pp/parsers/ts2phc.py index 0d0da2f..9182416 100644 --- a/src/vse_sync_pp/parsers/ts2phc.py +++ b/src/vse_sync_pp/parsers/ts2phc.py @@ -7,12 +7,14 @@ from .parser import (Parser, parse_decimal) + class TimeErrorParser(Parser): """Parse time error from a ts2phc log message""" id_ = 'ts2phc/time-error' elems = ('timestamp', 'interface', 'terror', 'state') y_name = 'terror' parsed = namedtuple('Parsed', elems) + @staticmethod def build_regexp(interface=None): """Return a regular expression string for parsing log file lines. @@ -20,8 +22,8 @@ def build_regexp(interface=None): If `interface` then only parse lines for the specified interface. """ return r''.join(( - r'^ts2phc' + - r'\[([1-9][0-9]*\.[0-9]{3})\]:', # timestamp + r'^ts2phc' + + r'\[([1-9][0-9]*\.[0-9]{3})\]:', # timestamp r'(?:\s\[ts2phc\.\d\..*\])?', # configuration file name fr'\s({interface})' if interface else r'\s(\S+)', # interface r'\smaster offset\s*', @@ -29,9 +31,11 @@ def build_regexp(interface=None): r'\s(\S+)', # state r'.*$', )) + def __init__(self, interface=None): super().__init__() self._regexp = re.compile(self.build_regexp(interface)) + def make_parsed(self, elems): if len(elems) < len(self.elems): raise ValueError(elems) @@ -40,6 +44,7 @@ def make_parsed(self, elems): terror = int(elems[2]) state = str(elems[3]) return self.parsed(timestamp, interface, terror, state) + def parse_line(self, line): matched = self._regexp.match(line) if matched: diff --git a/src/vse_sync_pp/plot.py b/src/vse_sync_pp/plot.py index 0ac39ed..28daaf9 100644 --- a/src/vse_sync_pp/plot.py +++ b/src/vse_sync_pp/plot.py @@ -11,23 +11,26 @@ from .parsers import PARSERS + class Plotter(): """Rudimentary plotter of data values against timestamp""" def __init__(self, y_name, y_desc=None): self._x_name = 'timestamp' self._y_name = y_name if y_desc is None: - self._y_desc = y_name + self._y_desc = y_name else: - self._y_desc = y_desc + self._y_desc = y_desc self._x_data = [] self._y_data = [] + def append(self, data): """Append x and y data points extracted from `data`""" x_val = getattr(data, self._x_name) self._x_data.append(x_val) y_val = getattr(data, self._y_name) self._y_data.append(y_val) + def plot(self, filename): """Plot data to `filename`""" fig, (ax1, ax2) = plt.subplots(2, constrained_layout=True) @@ -47,6 +50,7 @@ def plot(self, filename): ax2.set_title(f'Histogram of {self._y_desc}') plt.savefig(filename) + def main(): """Plot data parsed from log messages from a single source. @@ -78,5 +82,6 @@ def main(): plotter.append(parsed) plotter.plot(args.output) + if __name__ == '__main__': main() diff --git a/src/vse_sync_pp/sequence.py b/src/vse_sync_pp/sequence.py index c1b0053..0724bde 100644 --- a/src/vse_sync_pp/sequence.py +++ b/src/vse_sync_pp/sequence.py @@ -17,6 +17,7 @@ muxed, ) + def build_emit(available, include=(), exclude=()): """Return a frozenset of ids to emit. @@ -28,6 +29,7 @@ def build_emit(available, include=(), exclude=()): exc = frozenset(exclude) return ava.intersection(inc or ava).difference(exc) + def build_sources(parsers, filename, encoding='utf-8'): """Generator yielding (id_, data) generators for sources in `filename`""" parsers = {id_: cls() for (id_, cls) in parsers.items()} @@ -35,17 +37,18 @@ def build_sources(parsers, filename, encoding='utf-8'): for obj in yaml.safe_load_all(fid.read()): source = obj['source'] contains = obj['contains'] - # pylint: disable=consider-using-with file = stdin if source == '-' else open(source, encoding=encoding) if contains == 'muxed': yield muxed(file, parsers) else: yield logged(file, parsers[contains]) + # tuple of the most recent values generated by source # timestamp must be first and numeric Head = namedtuple('Head', ('timestamp', 'id_', 'data', 'source')) + def build_head(source): """Return a :class:`Head` value or None from the next item in `source`.""" try: @@ -54,6 +57,7 @@ def build_head(source): except StopIteration: return None + def build_heads(sources): """Return a list of :class:`Head` values from the first items in `sources`. @@ -66,6 +70,7 @@ def build_heads(sources): heads.append(head) return sorted(heads) + def insert_head(heads, head): """Return `heads` with `head` inserted in ascending timestamp order. @@ -81,6 +86,7 @@ def insert_head(heads, head): heads.insert(idx, head) return heads + def main(): """Sequence log messages from multiple sources to stdout. @@ -130,5 +136,6 @@ def main(): sys.exit(1) heads = insert_head(heads, build_head(first.source)) + if __name__ == '__main__': main() diff --git a/src/vse_sync_pp/source.py b/src/vse_sync_pp/source.py index 6f35567..a73bace 100644 --- a/src/vse_sync_pp/source.py +++ b/src/vse_sync_pp/source.py @@ -5,6 +5,7 @@ import json from decimal import Decimal + def logged(file, parser): """Generator yielding (id_, data) for lines in `file` parsed by `parser`. @@ -22,6 +23,7 @@ def logged(file, parser): if data is not None: yield (parser.id_, data) + def muxed(file, parsers): """Generator yielding (id_, data) for multiplexed content in `file`. @@ -30,9 +32,9 @@ def muxed(file, parsers): line is discarded: otherwise a pair is generated. `id_` is the value at 'id'; - `data` the values within data must be the equivalent to canonical data - produced by the parser at `id_` in `parsers` for the value at 'data'. - They can be in the form of a JSON array or a JSON object. + `data` the values within data must be the equivalent to canonical data + produced by the parser at `id_` in `parsers` for the value at 'data'. + They can be in the form of a JSON array or a JSON object. If the data is a JSON array then the ordering must match the parser output. If the data is a JSON object then the names must match the parser output. @@ -52,7 +54,7 @@ def muxed(file, parsers): pass else: if isinstance(obj['data'], dict): - data = tuple(obj['data'][name] for name in parser.elems) + data = tuple(obj['data'][name] for name in parser.elems) else: data = obj['data'] parsed = parser.make_parsed(data) diff --git a/tests/vse_sync_pp/__init__.py b/tests/vse_sync_pp/__init__.py index 9d7c343..cf2de07 100644 --- a/tests/vse_sync_pp/__init__.py +++ b/tests/vse_sync_pp/__init__.py @@ -2,6 +2,7 @@ """Common test functions""" + def make_fqname(subject): """Return the fully-qualified name of test `subject`.""" # subject is assumed to be a class diff --git a/tests/vse_sync_pp/analyzers/test_analyzer.py b/tests/vse_sync_pp/analyzers/test_analyzer.py index 2e6a43d..fd4c310 100644 --- a/tests/vse_sync_pp/analyzers/test_analyzer.py +++ b/tests/vse_sync_pp/analyzers/test_analyzer.py @@ -15,6 +15,7 @@ from .. import make_fqname + class TestConfig(TestCase): """Tests for vse_sync_pp.analyzers.analyzer.Config""" def test_requirement_errors(self): @@ -68,11 +69,13 @@ def test_requirement_errors(self): "'unknown requirement quod in G.8272/PRTC-A" " in config file foobarbaz'", ) + def test_requirement_success(self): """Test vse_sync_pp.analyzers.analyzer.Config.requirement success""" config = Config(requirements='G.8272/PRTC-A') key = 'time-error-in-locked-mode/ns' self.assertEqual(config.requirement(key), 100) + def test_parameter_errors(self): """Test vse_sync_pp.analyzers.analyzer.Config.parameter errors""" # no filename, no parameters @@ -107,10 +110,12 @@ def test_parameter_errors(self): str(ctx.exception), "'unknown parameter wibble in config file quuz'", ) + def test_parameter_success(self): """Test vse_sync_pp.analyzers.analyzer.Config.parameter success""" config = Config(parameters={'xxyyz': 'success'}) self.assertEqual(config.parameter('xxyyz'), 'success') + def test_yaml(self): """Test vse_sync_pp.analyzers.analyzer.Config.from_yaml""" filename = joinpath(dirname(__file__), 'config.yaml') @@ -122,6 +127,7 @@ def test_yaml(self): self.assertEqual(config.parameter('foo'), 'bar') self.assertEqual(config.parameter('baz'), 8) + class AnalyzerTestBuilder(type): """Build tests for vse_sync_pp.analyzers @@ -133,7 +139,7 @@ class AnalyzerTestBuilder(type): timestamp, duration, analysis giving test config, input data, expected outputs """ - def __new__(cls, name, bases, dct): # pylint: disable=bad-mcs-classmethod-argument + def __new__(cls, name, bases, dct): constructor = dct['constructor'] fqname = make_fqname(constructor) dct.update({ @@ -151,6 +157,7 @@ def __new__(cls, name, bases, dct): # pylint: disable=bad-mcs-classmethod-argume ), }) return super().__new__(cls, name, bases, dct) + # make functions for use as TestCase methods @staticmethod def make_test_id(constructor, fqname, id_): @@ -160,6 +167,7 @@ def method(self): self.assertEqual(constructor.id_, id_) method.__doc__ = f'Test {fqname} id_ class attribute value' return method + @staticmethod def make_test_parser(constructor, fqname, parser): """Make a function testing parser class attribute value""" @@ -168,10 +176,10 @@ def method(self): self.assertEqual(constructor.parser, parser) method.__doc__ = f'Test {fqname} parser class attribute value' return method + @staticmethod def make_test_result(constructor, fqname, expect): """Make a function testing analyzer test result and analysis""" - # pylint: disable=too-many-arguments @params(*expect) def method(self, dct): """Test analyzer test result and analysis""" diff --git a/tests/vse_sync_pp/analyzers/test_gnss.py b/tests/vse_sync_pp/analyzers/test_gnss.py index 915d44a..8a7a0a2 100644 --- a/tests/vse_sync_pp/analyzers/test_gnss.py +++ b/tests/vse_sync_pp/analyzers/test_gnss.py @@ -15,6 +15,7 @@ TERR = namedtuple('TERR', ('timestamp', 'terror', 'state')) + class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): """Test cases for vse_sync_pp.analyzers.gnss.TimeErrorAnalyzer""" constructor = TimeErrorAnalyzer @@ -96,13 +97,13 @@ class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): 'min-test-duration/s': 4, }, 'rows': ( - TERR(Decimal(0), 0, 5), - TERR(Decimal(1), 0, 5), - TERR(Decimal(2), 0, 5), + TERR(Decimal(0), 0, 5), + TERR(Decimal(1), 0, 5), + TERR(Decimal(2), 0, 5), # terror of 10 is unacceptable TERR(Decimal(3), 10, 5), - TERR(Decimal(4), 0, 5), - TERR(Decimal(5), 0, 5), + TERR(Decimal(4), 0, 5), + TERR(Decimal(5), 0, 5), ), 'result': False, 'reason': "unacceptable time error", diff --git a/tests/vse_sync_pp/analyzers/test_phc2sys.py b/tests/vse_sync_pp/analyzers/test_phc2sys.py index 008e137..ad77bbf 100644 --- a/tests/vse_sync_pp/analyzers/test_phc2sys.py +++ b/tests/vse_sync_pp/analyzers/test_phc2sys.py @@ -13,6 +13,7 @@ TERR = namedtuple('TERR', ('timestamp', 'terror', 'state', 'delay')) + class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): """Test cases for vse_sync_pp.analyzers.phc2sys.TimeErrorAnalyzer""" constructor = TimeErrorAnalyzer @@ -94,13 +95,13 @@ class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): 'min-test-duration/s': 4, }, 'rows': ( - TERR(Decimal(0), 0, 's2', 620), - TERR(Decimal(1), 0, 's2', 620), - TERR(Decimal(2), 0, 's2', 620), + TERR(Decimal(0), 0, 's2', 620), + TERR(Decimal(1), 0, 's2', 620), + TERR(Decimal(2), 0, 's2', 620), # terror of 10 is unacceptable TERR(Decimal(3), 10, 's2', 620), - TERR(Decimal(4), 0, 's2', 620), - TERR(Decimal(5), 0, 's2', 620), + TERR(Decimal(4), 0, 's2', 620), + TERR(Decimal(5), 0, 's2', 620), ), 'result': False, 'reason': "unacceptable time error", diff --git a/tests/vse_sync_pp/analyzers/test_ppsdpll.py b/tests/vse_sync_pp/analyzers/test_ppsdpll.py index 3cc5f66..fc2067e 100644 --- a/tests/vse_sync_pp/analyzers/test_ppsdpll.py +++ b/tests/vse_sync_pp/analyzers/test_ppsdpll.py @@ -12,7 +12,9 @@ from .test_analyzer import AnalyzerTestBuilder -DPLLS = namedtuple('DPLLS', ('timestamp','eecstate', 'state', 'terror',)) + +DPLLS = namedtuple('DPLLS', ('timestamp', 'eecstate', 'state', 'terror',)) + class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): """Test cases for vse_sync_pp.analyzers.ppsdpll.TimeErrorAnalyzer""" diff --git a/tests/vse_sync_pp/analyzers/test_ts2phc.py b/tests/vse_sync_pp/analyzers/test_ts2phc.py index 05e0d53..822c27c 100644 --- a/tests/vse_sync_pp/analyzers/test_ts2phc.py +++ b/tests/vse_sync_pp/analyzers/test_ts2phc.py @@ -15,6 +15,7 @@ TERR = namedtuple('TERR', ('timestamp', 'terror', 'state')) + class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): """Test cases for vse_sync_pp.analyzers.ts2phc.TimeErrorAnalyzer""" constructor = TimeErrorAnalyzer @@ -96,13 +97,13 @@ class TestTimeErrorAnalyzer(TestCase, metaclass=AnalyzerTestBuilder): 'min-test-duration/s': 4, }, 'rows': ( - TERR(Decimal(0), 0, 's2'), - TERR(Decimal(1), 0, 's2'), - TERR(Decimal(2), 0, 's2'), + TERR(Decimal(0), 0, 's2'), + TERR(Decimal(1), 0, 's2'), + TERR(Decimal(2), 0, 's2'), # terror of 10 is unacceptable TERR(Decimal(3), 10, 's2'), - TERR(Decimal(4), 0, 's2'), - TERR(Decimal(5), 0, 's2'), + TERR(Decimal(4), 0, 's2'), + TERR(Decimal(5), 0, 's2'), ), 'result': False, 'reason': "unacceptable time error", diff --git a/tests/vse_sync_pp/parsers/test_dpll.py b/tests/vse_sync_pp/parsers/test_dpll.py index 84f8e0b..16b25d2 100644 --- a/tests/vse_sync_pp/parsers/test_dpll.py +++ b/tests/vse_sync_pp/parsers/test_dpll.py @@ -11,6 +11,7 @@ from .test_parser import ParserTestBuilder + class TestTimeErrorParser(TestCase, metaclass=ParserTestBuilder): """Test cases for vse_sync_pp.parsers.dpll.TimeErrorParser""" constructor = TimeErrorParser diff --git a/tests/vse_sync_pp/parsers/test_gnss.py b/tests/vse_sync_pp/parsers/test_gnss.py index 0831aec..a2a4d42 100644 --- a/tests/vse_sync_pp/parsers/test_gnss.py +++ b/tests/vse_sync_pp/parsers/test_gnss.py @@ -11,30 +11,23 @@ from .test_parser import ParserTestBuilder + class TestTimeErrorParser(TestCase, metaclass=ParserTestBuilder): """Test cases for vse_sync_pp.parsers.gnss.TimeErrorParser""" constructor = TimeErrorParser id_ = 'gnss/time-error' elems = ('timestamp', 'state', 'terror') accept = ( - ( '681011.839,5,-3', - (Decimal('681011.839'), 5, -3), - ), - ( - '2023-06-16T17:01:11.131Z,1,400', - (Decimal('1686934871.131'), 1, 400), - ), - ( - '2023-06-16T17:01:11.131282-00:00,2,399', - (Decimal('1686934871.131282'), 2, 399), - ), - ( - '2023-06-16T17:01:11.131282269+00:00,3,398', - (Decimal('1686934871.131282269'), 3, 398), - ), - ( '681011.839,5,-3,-2.72', - (Decimal('681011.839'), 5, -3), - ), + ('681011.839,5,-3', + (Decimal('681011.839'), 5, -3)), + ('2023-06-16T17:01:11.131Z,1,400', + (Decimal('1686934871.131'), 1, 400)), + ('2023-06-16T17:01:11.131282-00:00,2,399', + (Decimal('1686934871.131282'), 2, 399)), + ('2023-06-16T17:01:11.131282269+00:00,3,398', + (Decimal('1686934871.131282269'), 3, 398)), + ('681011.839,5,-3,-2.72', + (Decimal('681011.839'), 5, -3)), ) reject = ( 'foo bar baz', diff --git a/tests/vse_sync_pp/parsers/test_parser.py b/tests/vse_sync_pp/parsers/test_parser.py index ca4353e..e47a9c0 100644 --- a/tests/vse_sync_pp/parsers/test_parser.py +++ b/tests/vse_sync_pp/parsers/test_parser.py @@ -13,16 +13,19 @@ from .. import make_fqname + class TestParser(TestCase): """Test cases for vse_sync_pp.parsers.parser.Parser""" def test_make_parsed(self): """Test vse_sync_pp.parsers.parser.Parser.make_parsed""" with self.assertRaises(ValueError): Parser().make_parsed(()) + def test_parse_line(self): """Test vse_sync_pp.parsers.parser.Parser.parse_line""" self.assertIsNone(Parser().parse_line('foo bar baz')) + class ParserTestBuilder(type): """Build tests for vse_sync_pp.parsers @@ -36,7 +39,7 @@ class ParserTestBuilder(type): `file` - a 2-tuple (lines, expect) the parser must parse `expect` from `lines` presented as a file object """ - def __new__(cls, name, bases, dct): # pylint: disable=bad-mcs-classmethod-argument + def __new__(cls, name, bases, dct): constructor = dct['constructor'] fqname = make_fqname(constructor) dct.update({ @@ -74,6 +77,7 @@ def __new__(cls, name, bases, dct): # pylint: disable=bad-mcs-classmethod-argume ), }) return super().__new__(cls, name, bases, dct) + # make functions for use as TestCase methods @staticmethod def make_test_id(constructor, fqname, id_): @@ -83,6 +87,7 @@ def method(self): self.assertEqual(constructor.id_, id_) method.__doc__ = f'Test {fqname} id_ class attribute value' return method + @staticmethod def make_test_elems(constructor, fqname, elems): """Make a function testing elems class attribute value""" @@ -93,6 +98,7 @@ def method(self): self.assertIn(constructor.y_name, elems) method.__doc__ = f'Test {fqname} elems class attribute value' return method + @staticmethod def make_test_make_parsed(constructor, fqname, expect): """Make a function testing parser makes parsed""" @@ -104,6 +110,7 @@ def method(self): parser.make_parsed(expect[:-1]) method.__doc__ = f'Test {fqname} make parsed' return method + @staticmethod def make_test_accept(constructor, fqname, elems, accept): """Make a function testing parser accepts line""" @@ -119,6 +126,7 @@ def method(self, line, expect): self.assertEqual(expect[idx], getattr(parsed, name)) method.__doc__ = f'Test {fqname} accepts line' return method + @staticmethod def make_test_reject(constructor, fqname, reject): """Make a function testing parser rejects line""" @@ -130,6 +138,7 @@ def method(self, line): parser.parse_line(line) method.__doc__ = f'Test {fqname} rejects line' return method + @staticmethod def make_test_discard(constructor, fqname, discard): """Make a function testing parser discards line""" @@ -141,6 +150,7 @@ def method(self, line): self.assertIsNone(parsed) method.__doc__ = f'Test {fqname} discards line' return method + @staticmethod def make_test_file(constructor, fqname, lines, expect): """Make a function testing parser parses `expect` from `lines`""" @@ -153,6 +163,7 @@ def method(self): self.assertEqual(pair[0], pair[1]) ### parse relative timestamps tidx = parser.elems.index('timestamp') + def relative(expect): """Generator yielding items with relative timestamps""" tzero = None @@ -168,6 +179,7 @@ def relative(expect): self.assertEqual(pair[0], pair[1]) method.__doc__ = f'Test {fqname} parses file' return method + @staticmethod def make_test_canonical(constructor, fqname, expect): """Make a function testing parser parses `expect` from `expect`""" diff --git a/tests/vse_sync_pp/parsers/test_phc2sys.py b/tests/vse_sync_pp/parsers/test_phc2sys.py index 16b382d..33ebc45 100644 --- a/tests/vse_sync_pp/parsers/test_phc2sys.py +++ b/tests/vse_sync_pp/parsers/test_phc2sys.py @@ -11,34 +11,28 @@ from .test_parser import ParserTestBuilder + class TestTimeErrorParser(TestCase, metaclass=ParserTestBuilder): """Test cases for vse_sync_pp.parsers.phc2sys.TimeErrorParser""" constructor = TimeErrorParser id_ = 'phc2sys/time-error' elems = ('timestamp', 'terror', 'state', 'delay') accept = ( - ( 'phc2sys[681011.839]: [ptp4l.0.config] ' - 'CLOCK_REALTIME phc offset 8 s2 freq +6339 delay 502', - (Decimal('681011.839'), 8, 's2', 502), - ), - ( - 'phc2sys[847916.839]: ' - 'CLOCK_REALTIME phc offset 4 s2 freq +639 delay 102', - (Decimal('847916.839'), 4, 's2', 102), - ), - ( 'phc2sys[681011.839]: [ptp4l.0.config] ' - 'CLOCK_REALTIME phc offset 8 s2 freq -6339 delay 502', - (Decimal('681011.839'), 8, 's2', 502), - ), - ( 'phc2sys[681012.839]: [ptp4l.1.config] ' - 'CLOCK_REALTIME phc offset 8 s2 freq -6339 delay 502', - (Decimal('681012.839'), 8, 's2', 502), - ), - ( - 'phc2sys[847916.839]: ' - 'CLOCK_REALTIME phc offset 4 s2 freq -639 delay 102', - (Decimal('847916.839'), 4, 's2', 102), - ), + ('phc2sys[681011.839]: [ptp4l.0.config] ' + 'CLOCK_REALTIME phc offset 8 s2 freq +6339 delay 502', + (Decimal('681011.839'), 8, 's2', 502),), + ('phc2sys[847916.839]: ' + 'CLOCK_REALTIME phc offset 4 s2 freq +639 delay 102', + (Decimal('847916.839'), 4, 's2', 102),), + ('phc2sys[681011.839]: [ptp4l.0.config] ' + 'CLOCK_REALTIME phc offset 8 s2 freq -6339 delay 502', + (Decimal('681011.839'), 8, 's2', 502),), + ('phc2sys[681012.839]: [ptp4l.1.config] ' + 'CLOCK_REALTIME phc offset 8 s2 freq -6339 delay 502', + (Decimal('681012.839'), 8, 's2', 502),), + ('phc2sys[847916.839]: ' + 'CLOCK_REALTIME phc offset 4 s2 freq -639 delay 102', + (Decimal('847916.839'), 4, 's2', 102),), ) reject = () discard = ( @@ -57,8 +51,8 @@ class TestTimeErrorParser(TestCase, metaclass=ParserTestBuilder): 'CLOCK_REALTIME phc offset 4 s2 freq -639 delay 102', )), ( - (Decimal('847914.839'), 1, 's2', 102), - (Decimal('847915.839'), 0, 's2', 102), - (Decimal('847916.839'), 4, 's2', 102), + (Decimal('847914.839'), 1, 's2', 102), + (Decimal('847915.839'), 0, 's2', 102), + (Decimal('847916.839'), 4, 's2', 102), ), ) diff --git a/tests/vse_sync_pp/parsers/test_pmc.py b/tests/vse_sync_pp/parsers/test_pmc.py index ce9c292..183f9e9 100644 --- a/tests/vse_sync_pp/parsers/test_pmc.py +++ b/tests/vse_sync_pp/parsers/test_pmc.py @@ -11,6 +11,7 @@ from .test_parser import ParserTestBuilder + class TestClockClassParser(TestCase, metaclass=ParserTestBuilder): """Test cases for vse_sync_pp.parsers.pmc.TestClockClassParser""" constructor = ClockClassParser @@ -18,29 +19,20 @@ class TestClockClassParser(TestCase, metaclass=ParserTestBuilder): elems = ('timestamp', 'clock_class') accept = ( #FreeRun class id: 248 - ( '681011.839,248,foo', - (Decimal('681011.839'), 248), - ), + ('681011.839,248,foo', + (Decimal('681011.839'), 248),), #Locked class id: 6 - ( - '2023-06-16T17:01:11.131Z,6,foo', - (Decimal('1686934871.131'), 6), - ), + ('2023-06-16T17:01:11.131Z,6,foo', + (Decimal('1686934871.131'), 6),), #Holdover class ids: 7,140,150,160 - ( - '2023-06-16T17:01:11.131282-00:00,7,foo', - (Decimal('1686934871.131282'), 7), - ), - ( - '2023-06-16T17:01:11.131282269+00:00,140,foo', - (Decimal('1686934871.131282269'), 140), - ), - ( '681011.839,150,foo', - (Decimal('681011.839'), 150), - ), - ( '681011.839,160,foo', - (Decimal('681011.839'), 160), - ), + ('2023-06-16T17:01:11.131282-00:00,7,foo', + (Decimal('1686934871.131282'), 7),), + ('2023-06-16T17:01:11.131282269+00:00,140,foo', + (Decimal('1686934871.131282269'), 140),), + ('681011.839,150,foo', + (Decimal('681011.839'), 150),), + ('681011.839,160,foo', + (Decimal('681011.839'), 160),), ) reject = ( 'foo bar baz', diff --git a/tests/vse_sync_pp/parsers/test_ts2phc.py b/tests/vse_sync_pp/parsers/test_ts2phc.py index d93e23a..5fb50f7 100644 --- a/tests/vse_sync_pp/parsers/test_ts2phc.py +++ b/tests/vse_sync_pp/parsers/test_ts2phc.py @@ -11,24 +11,22 @@ from .test_parser import ParserTestBuilder + class TestTimeErrorParser(TestCase, metaclass=ParserTestBuilder): """Test cases for vse_sync_pp.parsers.ts2phc.TimeErrorParser""" constructor = TimeErrorParser id_ = 'ts2phc/time-error' elems = ('timestamp', 'interface', 'terror', 'state') accept = ( - ( 'ts2phc[681011.839]: [ts2phc.0.config] ' - 'ens7f1 master offset 0 s2 freq -0', - (Decimal('681011.839'), 'ens7f1', 0, 's2'), - ), - ( 'ts2phc[681011.839]: [ts2phc.2.config] ' - 'ens7f1 master offset 0 s2 freq -0', - (Decimal('681011.839'), 'ens7f1', 0, 's2'), - ), - ( 'ts2phc[681011.839]: ' - 'ens7f1 master offset 0 s2 freq -0', - (Decimal('681011.839'), 'ens7f1', 0, 's2'), - ), + ('ts2phc[681011.839]: [ts2phc.0.config] ' + 'ens7f1 master offset 0 s2 freq -0', + (Decimal('681011.839'), 'ens7f1', 0, 's2'),), + ('ts2phc[681011.839]: [ts2phc.2.config] ' + 'ens7f1 master offset 0 s2 freq -0', + (Decimal('681011.839'), 'ens7f1', 0, 's2'),), + ('ts2phc[681011.839]: ' + 'ens7f1 master offset 0 s2 freq -0', + (Decimal('681011.839'), 'ens7f1', 0, 's2'),), ) reject = () discard = ( diff --git a/tests/vse_sync_pp/test_common.py b/tests/vse_sync_pp/test_common.py index 488fc43..26319a3 100644 --- a/tests/vse_sync_pp/test_common.py +++ b/tests/vse_sync_pp/test_common.py @@ -9,6 +9,7 @@ from vse_sync_pp.common import JsonEncoder + class TestJsonEncoder(TestCase): """Test cases for vse_sync_pp.common.JsonEncoder""" def test_success(self): @@ -17,6 +18,7 @@ def test_success(self): json.dumps(Decimal('123.456'), cls=JsonEncoder), '123.456', ) + def test_error(self): """Test vse_sync_pp.common.JsonEncoder rejects instance""" with self.assertRaises(TypeError): diff --git a/tests/vse_sync_pp/test_muxed.py b/tests/vse_sync_pp/test_muxed.py index c653126..148e02f 100644 --- a/tests/vse_sync_pp/test_muxed.py +++ b/tests/vse_sync_pp/test_muxed.py @@ -42,27 +42,25 @@ GNSS_LIST = CaseValue( { "id": "gnss/time-error", - "data": ["681011.839", "5", "2", "-3"], + "data": ["681011.839", "5", "2", "-3"], }, - ("gnss/time-error", (Decimal("681011.839"), 5, 2)), + ("gnss/time-error", (Decimal("681011.839"), 5, 2)), ) GNSS_DICT = CaseValue( { "id": "gnss/time-error", "data": { - "timestamp": "681011.839", + "timestamp": "681011.839", "state": "5", - "terror": "2", + "terror": "2", "ferror": "-3", - }, + }, }, ("gnss/time-error", (Decimal("681011.839"), 5, 2)), ) - - class TestMuxed(TestCase): def _test(self, *cases): file = StringIO("\n".join(json.dumps(c.input) for c in cases)) @@ -92,7 +90,7 @@ def test_dict(self): self._test(DPLL_DICT, GNSS_DICT) def test_mixed(self): - """Check that muxed can process a mixture of lines with json + """Check that muxed can process a mixture of lines with json arrays and json objects """ self._test(DPLL_DICT, GNSS_LIST) diff --git a/tests/vse_sync_pp/test_requirements.py b/tests/vse_sync_pp/test_requirements.py index 0704494..0153d35 100644 --- a/tests/vse_sync_pp/test_requirements.py +++ b/tests/vse_sync_pp/test_requirements.py @@ -8,6 +8,7 @@ from vse_sync_pp.requirements import REQUIREMENTS + class TestRequirements(TestCase): """Test cases for vse_sync_pp.requirements.REQUIREMENTS""" def test_g8272_prtc_a(self): @@ -18,6 +19,7 @@ def test_g8272_prtc_a(self): 'time-error-in-locked-mode/ns': 100, }, ) + def test_g8272_prtc_b(self): """Test G.8272/PRTC-B requirement values""" self.assertEqual(