#!/usr/bin/env python3
"""LingoLaboratory27 segmented local acquisition/replay worker.

No HTTP server, remote command execution, camera control or actuator output.
A browser disconnect does not affect this local process. NI-DAQmx requires a
compatible local driver/device and supervised physical commissioning.

python Lingo-Laboratory27-worker.py --config reviewed.json --out new-run
python Lingo-Laboratory27-worker.py --config reviewed.json --out interrupted-run --resume
python Lingo-Laboratory27-worker.py --verify completed-or-interrupted-run

Replay mode reads an existing exact numeric CSV. NI voltage mode logs actual
conditioned analog voltages; roles, units and calibration remain in metadata.
External camera software may export exact gray8 files and JSON frame metadata
into frameInbox. This is a file-ingest adapter, not a commissioned camera SDK.
Every frame metadata JSON needs id,counter,cameraSeconds,name,width,height,
rawFileSHA256,rawFileBytes,originalWidth,originalHeight,crop,decoding,grayFile.
The grayFile must be a same-folder basename with exactly width*height bytes.
Frame metadata and pixels are copied into immutable run storage and hashed.

Only committed segment files enter the manifest. A process interruption leaves
at most an uncommitted .partial file; resume verifies every committed byte and
replays from the original exact CSV cursor. NI acquisition cannot resume a
physical sample clock across process loss: begin a separately identified epoch.
Long originals remain on disk. A bounded source-bundle is emitted only when
complete sensor/image sources fit the native analysis selection limits.
"""
import argparse
import csv
import hashlib
import io
import json
import math
import os
import re
import signal
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

SCHEMA = 'LingoExperiment27LocalRun/1'
STOP = False


def digest(data):
    return hashlib.sha256(data).hexdigest()


def file_digest(path):
    h = hashlib.sha256()
    with path.open('rb') as stream:
        for part in iter(lambda: stream.read(1024 * 1024), b''):
            h.update(part)
    return h.hexdigest()


def canonical(value):
    return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(',', ':')).encode('utf-8')


def unique(pairs):
    out = {}
    for key, value in pairs:
        if key in out or key in ('__proto__', 'prototype', 'constructor'):
            raise ValueError('Duplicate or reserved JSON field: ' + key)
        out[key] = value
    return out


def read_json(path):
    return json.loads(path.read_text(encoding='utf-8-sig'), object_pairs_hook=unique, parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Nonfinite JSON: ' + value)))


def exact(obj, fields):
    if not isinstance(obj, dict) or set(obj) != set(fields):
        raise ValueError('Missing or unsupported fields: ' + ', '.join(fields))


def number(value, low, high, integer=False):
    if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not low <= value <= high or integer and int(value) != value:
        raise ValueError('Invalid finite bounded number')
    return value


def stamp():
    return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')


def atomic(path, data):
    temporary = path.with_name(path.name + '.partial')
    with temporary.open('wb') as stream:
        stream.write(data)
        stream.flush()
        os.fsync(stream.fileno())
    os.replace(temporary, path)
    try:
        fd = os.open(str(path.parent), os.O_RDONLY)
        os.fsync(fd)
        os.close(fd)
    except OSError:
        pass


def stop(_signum, _frame):
    global STOP
    STOP = True


def validate(config):
    exact(config, ('schema', 'illustrative', 'specimen', 'acquisition'))
    if not isinstance(config['illustrative'], bool):
        raise ValueError('Declare whether this source epoch contains invented software fixtures')
    if config['schema'] != SCHEMA:
        raise ValueError('Unsupported local worker schema')
    specimen = config['specimen']
    exact(specimen, ('id', 'label', 'material', 'gaugeLengthMm', 'areaMm2', 'synchronization', 'annotations'))
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}', specimen['id']):
        raise ValueError('Use a bounded specimen identifier')
    number(specimen['gaugeLengthMm'], 1e-9, 1e6)
    number(specimen['areaMm2'], 1e-9, 1e10)
    a = config['acquisition']
    exact(a, ('mode', 'sampleRateHz', 'chunkSamples', 'bufferSamples', 'durationSeconds', 'readTimeoutSeconds', 'maxDiskBytes', 'channels', 'replayCSV', 'device', 'clockSource', 'triggerSource', 'frameInbox'))
    if a['mode'] not in ('replay', 'ni-voltage'):
        raise ValueError('Choose replay or ni-voltage; no actuator adapter exists')
    number(a['sampleRateHz'], .01, 1e6)
    number(a['chunkSamples'], 1, 12000, True)
    number(a['bufferSamples'], 2 * a['chunkSamples'], 2_000_000, True)
    number(a['durationSeconds'], .001, 86400)
    number(a['readTimeoutSeconds'], .1, 120)
    number(a['maxDiskBytes'], 4096, 100_000_000_000, True)
    if not isinstance(a['channels'], list) or not 2 <= len(a['channels']) <= 8:
        raise ValueError('Provide 2–8 explicit voltage/calibration channels')
    ids = set()
    for ch in a['channels']:
        exact(ch, ('id', 'role', 'unit', 'scale', 'offset', 'physical', 'minV', 'maxV'))
        if not isinstance(ch['id'], str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]{0,119}', ch['id']) or ch['id'] in ids:
            raise ValueError('Channel identifiers must be unique')
        ids.add(ch['id'])
        if ch['role'] not in ('load', 'lvdt', 'aux') or ch['unit'] not in ('N', 'mm', 'V') or ch['role'] == 'load' and ch['unit'] != 'N' or ch['role'] == 'lvdt' and ch['unit'] != 'mm':
            raise ValueError('Use calibrated N/mm load/LVDT channel roles')
        number(ch['scale'], -1e9, 1e9)
        number(ch['offset'], -1e12, 1e12)
        number(ch['minV'], -100, 100)
        number(ch['maxV'], -100, 100)
        if ch['scale'] == 0 or ch['minV'] >= ch['maxV']:
            raise ValueError('Calibration scale and voltage range must be nonzero/increasing')
        if a['mode'] == 'ni-voltage' and (not re.fullmatch(r'[A-Za-z0-9_-]{1,180}', a['device']) or not re.fullmatch(re.escape(a['device']) + r'/ai\d+', ch['physical'])):
            raise ValueError('Use exact NI analog voltage input names')
    if sum(c['role'] == 'load' for c in a['channels']) != 1 or sum(c['role'] == 'lvdt' for c in a['channels']) != 1:
        raise ValueError('Exactly one load and LVDT channel are required')
    for field in ('clockSource', 'triggerSource'):
        if not isinstance(a[field], str) or not re.fullmatch(r'(?:|/[A-Za-z0-9_/-]+)', a[field]):
            raise ValueError('Use exact NI clock/trigger terminal paths')
    for field in ('replayCSV', 'frameInbox'):
        if a[field] is not None and (not isinstance(a[field], str) or not a[field] or len(a[field]) > 2048):
            raise ValueError('Local source paths must be explicit strings or null')
    if a['mode'] == 'replay' and not a['replayCSV']:
        raise ValueError('Replay requires an exact original numeric CSV')
    return config


def verify(directory):
    manifest = read_json(directory / 'manifest.json')
    if manifest.get('schema') != SCHEMA or manifest.get('configSHA256') != digest((directory / 'reviewed-config.json').read_bytes()):
        raise ValueError('Manifest/configuration binding failed')
    config = validate(read_json(directory / 'reviewed-config.json'))
    previous = None
    rows = 0
    last = None
    gaps = []
    for index, segment in enumerate(manifest['segments']):
        if segment['name'] != 'segment-%08d.csv' % index:
            raise ValueError('Unsupported committed segment path')
        path = directory / 'segments' / segment['name']
        raw = path.read_bytes()
        if segment['index'] != index or segment['previousSHA256'] != previous or digest(raw) != segment['sha256']:
            raise ValueError('Original committed segment chain failed')
        parsed = list(csv.reader(io.StringIO(raw.decode('utf-8'))))
        if parsed[0] != ['sample_counter'] + [c['id'] for c in config['acquisition']['channels']]:
            raise ValueError('Committed segment channel header changed')
        counters = [int(row[0]) for row in parsed[1:]]
        if not counters or len(counters) != segment['samples'] or counters[0] != segment['startCounter'] or counters[-1] != segment['endCounter'] or any(b <= a for a, b in zip(counters, counters[1:])) or last is not None and counters[0] <= last:
            raise ValueError('Committed segment counter metadata differs')
        for counter in counters:
            if last is not None and counter > last + 1:
                gaps.append({'after': last, 'before': counter, 'missing': counter - last - 1})
            last = counter
        rows += len(counters)
        previous = segment['sha256']
    if gaps != manifest['gaps']:
        raise ValueError('Committed sample gap accounting changed')
    for frame in manifest['frames']:
        if frame['pixelFile'] != 'frame-' + digest(frame['id'].encode())[:24] + '.gray8':
            raise ValueError('Unsupported committed frame path')
        raw = (directory / 'frames' / frame['pixelFile']).read_bytes()
        if len(raw) != frame['source']['width'] * frame['source']['height'] or digest(raw) != frame['source']['sha256']:
            raise ValueError('Committed original frame pixels changed')
    if manifest.get('sourceManifestSHA256') != digest(canonical({k: manifest[k] for k in ('configSHA256', 'segments', 'frames', 'samples')})):
        raise ValueError('Source inventory digest changed')
    if rows != manifest['samples']:
        raise ValueError('Committed sample count changed')
    copy = dict(manifest)
    fingerprint = copy.pop('manifestSHA256')
    if digest(canonical(copy)) != fingerprint:
        raise ValueError('Manifest fingerprint changed')
    return manifest


def save_manifest(directory, manifest):
    manifest['sourceManifestSHA256'] = digest(canonical({k: manifest[k] for k in ('configSHA256', 'segments', 'frames', 'samples')}))
    manifest['updatedAt'] = stamp()
    manifest.pop('manifestSHA256', None)
    manifest['manifestSHA256'] = digest(canonical(manifest))
    atomic(directory / 'manifest.json', canonical(manifest))


def byte_count(directory):
    return sum(p.stat().st_size for p in directory.rglob('*') if p.is_file())


def ingest_frames(directory, manifest, inbox, budget):
    if not inbox:
        return
    known = {f['id']: f for f in manifest['frames']}
    pending = [(path, read_json(path)) for path in Path(inbox).glob('*.json')]
    pending.sort(key=lambda pair: (pair[1].get('counter', -1), pair[1].get('cameraSeconds', -1)))
    for metadata, frame in pending:
        exact(frame, ('id', 'counter', 'cameraSeconds', 'name', 'width', 'height', 'rawFileSHA256', 'rawFileBytes', 'originalWidth', 'originalHeight', 'crop', 'decoding', 'grayFile'))
        if not isinstance(frame['id'], str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,119}', frame['id']) or not isinstance(frame['rawFileSHA256'], str) or not re.fullmatch(r'[a-f0-9]{64}', frame['rawFileSHA256']):
            raise ValueError('Frame identity and original encoded-file digest must be explicit bounded values')
        if not isinstance(frame['name'], str) or not 1 <= len(frame['name']) <= 180 or not isinstance(frame['decoding'], str) or not 1 <= len(frame['decoding']) <= 500:
            raise ValueError('Frame filename and exact decoding declaration must be bounded text')
        number(frame['rawFileBytes'], 1, 20_000_000, True)
        number(frame['originalWidth'], 1, 16384, True)
        number(frame['originalHeight'], 1, 16384, True)
        exact(frame['crop'], ('x', 'y'))
        number(frame['crop']['x'], 0, 16384, True)
        number(frame['crop']['y'], 0, 16384, True)
        number(frame['counter'], 0, 1e12, True)
        number(frame['cameraSeconds'], 0, 1e9)
        number(frame['width'], 1, 4096, True)
        number(frame['height'], 1, 4096, True)
        if frame['width'] * frame['height'] > 1_048_576 or Path(frame['grayFile']).name != frame['grayFile'] or not re.fullmatch(r'[A-Za-z0-9_.-]+', frame['grayFile']):
            raise ValueError('Frame pixel file must be a bounded same-folder basename')
        pixel_source = metadata.parent / frame['grayFile']
        if pixel_source.resolve().parent != metadata.parent.resolve() or frame['crop']['x'] + frame['width'] > frame['originalWidth'] or frame['crop']['y'] + frame['height'] > frame['originalHeight']:
            raise ValueError('Frame path/crop leaves its declared original source')
        raw = pixel_source.read_bytes()
        if len(raw) != frame['width'] * frame['height']:
            raise ValueError('Exact gray8 file length differs from declared image dimensions')
        source = {k: frame[k] for k in ('name', 'width', 'height', 'rawFileSHA256', 'rawFileBytes', 'originalWidth', 'originalHeight', 'crop', 'decoding')}
        source.update(channels=1, sha256=digest(raw))
        entry = {'id': frame['id'], 'counter': frame['counter'], 'cameraSeconds': frame['cameraSeconds'], 'source': source, 'right': None, 'pixelFile': 'frame-' + digest(frame['id'].encode())[:24] + '.gray8'}
        if frame['id'] in known:
            if canonical(entry) != canonical(known[frame['id']]):
                raise ValueError('A committed frame ID was reused with changed original data')
            continue
        if byte_count(directory) + len(raw) + 4096 > budget:
            raise ValueError('Disk budget reached before committing another frame')
        if manifest['frames'] and (entry['counter'] <= manifest['frames'][-1]['counter'] or entry['cameraSeconds'] <= manifest['frames'][-1]['cameraSeconds']):
            raise ValueError('Camera frame counters and times must arrive strictly in order')
        atomic(directory / 'frames' / entry['pixelFile'], raw)
        manifest['frames'].append(entry)
        save_manifest(directory, manifest)


def replay_rows(a, manifest):
    source = Path(a['replayCSV'])
    fingerprint = file_digest(source)
    if manifest.get('replaySHA256') not in (None, fingerprint):
        raise ValueError('Original replay CSV changed after committed acquisition')
    manifest['replaySHA256'] = fingerprint
    expected = ['sample_counter'] + [c['id'] for c in a['channels']]
    previous = None
    skipped = 0
    resume_samples = manifest['samples']
    with source.open('r', encoding='utf-8-sig', newline='') as stream:
        reader = csv.reader(stream)
        if next(reader, None) != expected:
            raise ValueError('Replay CSV header differs from exact channel map')
        for row in reader:
            if len(row) != len(expected) or any(not re.fullmatch(r'[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?', v) for v in row):
                raise ValueError('Replay CSV requires exact numeric fields with no missing values')
            values = [float(v) for v in row]
            if any(not math.isfinite(v) or v == 0 and re.search('[1-9]', raw.split('e')[0].split('E')[0]) for raw, v in zip(row, values)) or values[0] < 0 or not values[0].is_integer() or previous is not None and values[0] <= previous:
                raise ValueError('Replay counters/values must be finite, non-underflowing and strictly chronological')
            previous = int(values[0])
            if skipped < resume_samples:
                skipped += 1
                continue
            yield [previous] + row[1:]
    if skipped != resume_samples:
        raise ValueError('Replay original no longer covers committed source cursor')


def ni_rows(a, manifest):
    if manifest['samples']:
        raise ValueError('A physical NI sample clock cannot resume across process loss; begin a new identified acquisition epoch')
    try:
        import nidaqmx
        from nidaqmx.constants import AcquisitionType
    except ImportError as error:
        raise RuntimeError('Install compatible NI-DAQmx driver and nidaqmx on the supervised local acquisition computer; no hardware execution was performed') from error
    with nidaqmx.Task() as task:
        for channel in a['channels']:
            task.ai_channels.add_ai_voltage_chan(channel['physical'], min_val=channel['minV'], max_val=channel['maxV'])
        timing = dict(rate=a['sampleRateHz'], sample_mode=AcquisitionType.CONTINUOUS, samps_per_chan=a['bufferSamples'])
        if a['clockSource']:
            timing['source'] = a['clockSource']
        task.timing.cfg_samp_clk_timing(**timing)
        if a['triggerSource']:
            task.triggers.start_trigger.cfg_dig_edge_start_trig(a['triggerSource'])
        manifest['actualSampleRateHz'] = task.timing.samp_clk_rate
        manifest['counterMeaning'] = 'Contiguous sample ordinal returned by the NI read task; not an absolute hardware timestamp. Overflow/read faults stop the epoch.'
        task.start()
        started = time.monotonic()
        counter = 0
        while not STOP and time.monotonic() - started < a['durationSeconds']:
            data = task.read(number_of_samples_per_channel=a['chunkSamples'], timeout=a['readTimeoutSeconds'])
            if len(data) != len(a['channels']) or any(len(v) != a['chunkSamples'] for v in data):
                raise RuntimeError('NI returned a malformed channel/sample batch')
            for i in range(a['chunkSamples']):
                values = [float(v[i]) for v in data]
                if any(not math.isfinite(v) for v in values):
                    raise RuntimeError('NI returned nonfinite voltage samples')
                yield [counter] + values
                counter += 1


def write_bundle(directory, manifest, config):
    if not 2 <= len(manifest['frames']) <= 16 or len(manifest['segments']) > 32 or manifest['samples'] > 12000 or sum(f['source']['width'] * f['source']['height'] for f in manifest['frames']) > 72000:
        manifest['sourceBundle'] = 'Unavailable: select 2–16 exact image crops and at most 12000 source rows/72000 pixel values for native analysis; long originals remain complete on disk.'
        return
    segments = [{k: entry[k] for k in ('index', 'startCounter', 'endCounter', 'samples', 'previousSHA256', 'sha256')} | {'rawCSV': (directory / 'segments' / entry['name']).read_text(encoding='utf-8')} for entry in manifest['segments']]
    frames = [{k: frame[k] for k in ('id', 'counter', 'cameraSeconds')} | {'right': None, 'source': frame['source'] | {'pixels': list((directory / 'frames' / frame['pixelFile']).read_bytes())}} for frame in manifest['frames']]
    specimen = config['specimen'] | {'sampleRateHz': manifest.get('actualSampleRateHz', config['acquisition']['sampleRateHz']), 'channels': [{k: c[k] for k in ('id', 'role', 'unit', 'scale', 'offset')} for c in config['acquisition']['channels']], 'segments': segments, 'frames': frames}
    bundle = {'format': 'LingoExperiment27SourceBundle', 'version': 1, 'illustrative': config['illustrative'], 'sourceManifestSHA256': manifest['sourceManifestSHA256'], 'specimen': specimen, 'originalScope': 'Exact committed sensor CSV and gray8 image planes; original encoded photo/video digests retained separately; no physical commissioning inferred.'}
    payload = canonical(bundle)
    if byte_count(directory) + len(payload) + 8192 > config['acquisition']['maxDiskBytes']:
        manifest['sourceBundle'] = 'Unavailable: native source bundle would exceed reviewed local disk budget; committed original sources remain on disk.'
        return
    atomic(directory / 'source-bundle.json', payload)
    manifest['sourceBundle'] = 'source-bundle.json'


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--config', type=Path)
    parser.add_argument('--out', type=Path)
    parser.add_argument('--resume', action='store_true')
    parser.add_argument('--verify', type=Path)
    args = parser.parse_args()
    if args.verify:
        manifest = verify(args.verify)
        print(json.dumps({'verified': True, 'samples': manifest['samples'], 'segments': len(manifest['segments']), 'frames': len(manifest['frames']), 'manifestSHA256': manifest['manifestSHA256']}))
        return
    if not args.config or not args.out:
        parser.error('--config and --out are required for a local run')
    config = validate(read_json(args.config))
    config_bytes = canonical(config)
    directory = args.out
    if args.resume:
        manifest = verify(directory)
        if manifest['configSHA256'] != digest(config_bytes):
            raise ValueError('Resume requires the exact original reviewed configuration')
        if manifest['state'] == 'completed':
            raise ValueError('Completed runs are immutable; use a new run directory')
        interruptions = manifest.setdefault('interruptions', [])
        if len(interruptions) >= 100:
            raise ValueError('This source epoch reached its bounded interruption history; export it and begin a new identified run')
        interruptions.append({'at': manifest['updatedAt'], 'samples': manifest['samples'], 'state': manifest['state'], 'error': manifest['error']})
        manifest['state'] = 'recording'
        manifest['error'] = None
        manifest['uncommittedSamples'] = 0
        manifest['lastUncommittedCounter'] = None
        save_manifest(directory, manifest)
    else:
        directory.mkdir(parents=True, exist_ok=False)
        (directory / 'segments').mkdir()
        (directory / 'frames').mkdir()
        atomic(directory / 'reviewed-config.json', config_bytes)
        manifest = {'schema': SCHEMA, 'configSHA256': digest(config_bytes), 'state': 'recording', 'startedAt': stamp(), 'updatedAt': stamp(), 'samples': 0, 'segments': [], 'frames': [], 'gaps': [], 'error': None, 'hardwareCommissioned': False, 'browserDependent': False}
        save_manifest(directory, manifest)
    a = config['acquisition']
    signal.signal(signal.SIGINT, stop)
    signal.signal(signal.SIGTERM, stop)
    pending = []
    start = time.monotonic()
    try:
        iterator = replay_rows(a, manifest) if a['mode'] == 'replay' else ni_rows(a, manifest)
        for row in iterator:
            if STOP or a['mode'] == 'replay' and time.monotonic() - start > a['durationSeconds']:
                break
            pending.append(row)
            if len(pending) >= a['chunkSamples']:
                commit(directory, manifest, a, pending)
                pending = []
                ingest_frames(directory, manifest, a['frameInbox'], a['maxDiskBytes'])
        if pending:
            commit(directory, manifest, a, pending)
            pending = []
        ingest_frames(directory, manifest, a['frameInbox'], a['maxDiskBytes'])
        manifest['state'] = 'interrupted' if STOP else 'completed'
    except Exception as error:
        manifest['state'] = 'interrupted'
        manifest['uncommittedSamples'] = len(pending)
        manifest['lastUncommittedCounter'] = pending[-1][0] if pending else None
        manifest['error'] = type(error).__name__ + ': ' + str(error)
    save_manifest(directory, manifest)
    write_bundle(directory, manifest, config)
    save_manifest(directory, manifest)
    print(json.dumps({'state': manifest['state'], 'samples': manifest['samples'], 'segments': len(manifest['segments']), 'frames': len(manifest['frames']), 'manifestSHA256': manifest['manifestSHA256'], 'hardwareCommissioned': False, 'error': manifest['error']}))
    if manifest['state'] != 'completed':
        return 2
    return 0


def commit(directory, manifest, a, rows):
    output = io.StringIO(newline='')
    writer = csv.writer(output, lineterminator='\n')
    writer.writerow(['sample_counter'] + [c['id'] for c in a['channels']])
    writer.writerows(rows)
    raw = output.getvalue().encode('utf-8')
    if byte_count(directory) + len(raw) + 8192 > a['maxDiskBytes']:
        raise ValueError('Disk budget reached before committing another immutable segment')
    entry = {'index': len(manifest['segments']), 'name': 'segment-%08d.csv' % len(manifest['segments']), 'startCounter': rows[0][0], 'endCounter': rows[-1][0], 'samples': len(rows), 'previousSHA256': manifest['segments'][-1]['sha256'] if manifest['segments'] else None, 'sha256': digest(raw), 'bytes': len(raw)}
    target = directory / 'segments' / entry['name']
    if target.exists() and digest(target.read_bytes()) != entry['sha256']:
        raise ValueError('Uncommitted segment bytes differ from exact replay; original file was preserved')
    atomic(target, raw)
    last = manifest['segments'][-1]['endCounter'] if manifest['segments'] else None
    for row in rows:
        if last is not None and row[0] > last + 1:
            manifest['gaps'].append({'after': last, 'before': row[0], 'missing': row[0] - last - 1})
        last = row[0]
    manifest['segments'].append(entry)
    manifest['samples'] += len(rows)
    save_manifest(directory, manifest)


if __name__ == '__main__':
    try:
        sys.exit(main() or 0)
    except Exception as error:
        print(json.dumps({'state': 'rejected', 'error': type(error).__name__ + ': ' + str(error), 'hardwareCommissioned': False}), file=sys.stderr)
        sys.exit(2)
