|
| 1 | +"""Keep kbw's image properties and 'Missing Image' blocks in step with the files (replaces Jenkins CheckImagesExist). |
| 2 | +
|
| 3 | + python -m derive.kbsync --run-dir /out/runs/<run> [--edges edges.json.gz] [--neo4j http://kbw.kbw:7474] [--write] |
| 4 | +
|
| 5 | +Reads the run's scan (<run-dir>/folders/*.jsonl.gz) plus the files the derive step reported writing |
| 6 | +(<run-dir>/results/*.jsonl), and every alignment edge |
| 7 | + (i:Individual)<-[:depicts]-(:Individual)-[r:in_register_with]->(:Template) with r.folder (all datasets) |
| 8 | +either from --edges (the JSON of a tx/commit export) or live from --neo4j (NEO4J_USER / NEO4J_PASSWORD). |
| 9 | +
|
| 10 | +Per edge (folder http://www.virtualflybrain.org/data/VFB/i/... -> scanned dir VFB/i/...): |
| 11 | + nrrd volume.nrrd, swc volume.swc, thumbnail thumbnail.png, wlz volume.wlz, obj volume_man.obj else volume.obj: |
| 12 | + set to [folder + name] when the file exists, removed when it does not; only differences are written. |
| 13 | + block: thumbnail.png or thumbnailT.png missing -> r.block=['Missing Image'] (+ r.missing_since); |
| 14 | + both present and r.block=['Missing Image'] -> block removed. |
| 15 | + An Individual's ['New Images'] block is removed once all its edges were checked, so present alignments go live |
| 16 | + and only the missing ones stay blocked. |
| 17 | +Never touched: edges or individuals with any other block value (curator blocks), folders outside a scanned shard, |
| 18 | +edges with more than one folder (listed in the report). |
| 19 | +Without --write nothing is written: <run-dir>/kbsync/changes.json and summary.json show what would change. |
| 20 | +""" |
| 21 | +import argparse, base64, glob, gzip, json, os, sys, time, urllib.request |
| 22 | +from collections import Counter, defaultdict |
| 23 | + |
| 24 | +PREFIXES = ('http://www.virtualflybrain.org/data/', 'https://www.virtualflybrain.org/data/', |
| 25 | + 'http://virtualflybrain.org/data/', 'https://virtualflybrain.org/data/') |
| 26 | +PROPS = {'nrrd': 'volume.nrrd', 'swc': 'volume.swc', 'thumbnail': 'thumbnail.png', 'wlz': 'volume.wlz'} |
| 27 | +AUTO_EDGE = ['Missing Image'] |
| 28 | +AUTO_NODE = ['New Images'] |
| 29 | +EDGE_QUERY = ('MATCH (i:Individual)<-[:depicts]-(:Individual)-[r:in_register_with]->(:Template) WHERE exists(r.folder) ' |
| 30 | + 'RETURN id(r), i.short_form, r.folder, r.block, i.block, r.nrrd, r.swc, r.thumbnail, r.wlz, r.obj') |
| 31 | + |
| 32 | + |
| 33 | +def load_scan(run_dir): |
| 34 | + files, shards = {}, set() |
| 35 | + for p in glob.glob(os.path.join(run_dir, 'folders', '*.jsonl.gz')): |
| 36 | + with gzip.open(p, 'rt') as f: |
| 37 | + for line in f: |
| 38 | + r = json.loads(line) |
| 39 | + if r['root'] == 'data': |
| 40 | + files[r['dir']] = set(r['files']) |
| 41 | + shards.add('/'.join(r['dir'].split('/')[:3])) |
| 42 | + # outputs written by this run's derive step |
| 43 | + for p in glob.glob(os.path.join(run_dir, 'results', '*.jsonl')): |
| 44 | + for line in open(p): |
| 45 | + r = json.loads(line) |
| 46 | + root, _, d = r['key'].partition(':') |
| 47 | + if root == 'data' and d in files: |
| 48 | + for out, res in r.get('results', {}).items(): |
| 49 | + if str(res).startswith(('ok', 'written', 'linked')): |
| 50 | + files[d].update({'thumb': ['thumbnail.png', 'thumbnailT.png'], 'wlz': ['volume.wlz'], |
| 51 | + 'obj': ['volume.obj']}.get(out, [])) |
| 52 | + return files, shards |
| 53 | + |
| 54 | + |
| 55 | +class Neo: |
| 56 | + def __init__(self, url): |
| 57 | + self.url = url.rstrip('/') + '/db/neo4j/tx/commit' |
| 58 | + tok = base64.b64encode(f"{os.environ.get('NEO4J_USER', 'neo4j')}:{os.environ['NEO4J_PASSWORD']}".encode()).decode() |
| 59 | + self.h = {'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Basic ' + tok} |
| 60 | + |
| 61 | + def run(self, statements): |
| 62 | + body = json.dumps({'statements': [{'statement': s, 'parameters': p, 'resultDataContents': ['row']} |
| 63 | + for s, p in statements]}).encode() |
| 64 | + with urllib.request.urlopen(urllib.request.Request(self.url, body, self.h), timeout=3600) as r: |
| 65 | + d = json.load(r) |
| 66 | + if d.get('errors'): |
| 67 | + raise RuntimeError(d['errors']) |
| 68 | + return d['results'] |
| 69 | + |
| 70 | + |
| 71 | +def load_edges(a): |
| 72 | + if a.edges: |
| 73 | + d = json.load(gzip.open(a.edges, 'rt') if a.edges.endswith('.gz') else open(a.edges)) |
| 74 | + return [x['row'] for x in d['results'][0]['data']] |
| 75 | + return [x['row'] for x in Neo(a.neo4j).run([(EDGE_QUERY, {})])[0]['data']] |
| 76 | + |
| 77 | + |
| 78 | +def plan(edges, files, shards, today): |
| 79 | + c = Counter(); sets, removes, block_add, block_del = [], defaultdict(list), [], [] |
| 80 | + multi, unknown_ind = [], set() |
| 81 | + node_new = {} |
| 82 | + for rid, ind, folders, rblock, iblock, *cur in edges: |
| 83 | + c['edges'] += 1 |
| 84 | + if iblock and iblock != AUTO_NODE: |
| 85 | + c['skip_curator_node_block'] += 1; continue |
| 86 | + if iblock == AUTO_NODE: |
| 87 | + node_new.setdefault(ind, True) |
| 88 | + if rblock and rblock != AUTO_EDGE: |
| 89 | + c['skip_curator_edge_block'] += 1; continue |
| 90 | + if len(folders) != 1: |
| 91 | + c['skip_multi_folder'] += 1; multi.append([rid, ind, folders]); unknown_ind.add(ind); continue |
| 92 | + folder = folders[0] |
| 93 | + rel = next((folder[len(p):] for p in PREFIXES if folder.startswith(p)), None) |
| 94 | + rel = rel.rstrip('/') if rel else None |
| 95 | + if not rel or '/'.join(rel.split('/')[:3]) not in shards: |
| 96 | + c['skip_unscanned_folder'] += 1; unknown_ind.add(ind); continue |
| 97 | + have = files.get(rel) |
| 98 | + if have is None: |
| 99 | + c['folder_missing'] += 1; have = set() |
| 100 | + current = dict(zip(['nrrd', 'swc', 'thumbnail', 'wlz', 'obj'], cur)) |
| 101 | + want = {k: [folder + n] if n in have else None for k, n in PROPS.items()} |
| 102 | + want['obj'] = [folder + 'volume_man.obj'] if 'volume_man.obj' in have else [folder + 'volume.obj'] if 'volume.obj' in have else None |
| 103 | + new = {} |
| 104 | + for k, v in want.items(): |
| 105 | + if v != current[k]: |
| 106 | + if v is None: |
| 107 | + removes[k].append(rid); c[f'remove_{k}'] += 1 |
| 108 | + else: |
| 109 | + new[k] = v; c[f'set_{k}'] += 1 |
| 110 | + if new: |
| 111 | + sets.append({'id': rid, 'props': new}) |
| 112 | + present = 'thumbnail.png' in have and 'thumbnailT.png' in have |
| 113 | + if not present and not rblock: |
| 114 | + block_add.append(rid); c['block_add'] += 1 |
| 115 | + elif present and rblock == AUTO_EDGE: |
| 116 | + block_del.append(rid); c['block_remove'] += 1 |
| 117 | + elif not present: |
| 118 | + c['still_blocked'] += 1 |
| 119 | + unblock_nodes = sorted(i for i in node_new if i not in unknown_ind) |
| 120 | + c['node_new_images_remove'] = len(unblock_nodes) |
| 121 | + c['node_new_images_kept'] = len(node_new) - len(unblock_nodes) |
| 122 | + return c, {'set': sets, 'remove': dict(removes), 'block_add': block_add, 'block_remove': block_del, |
| 123 | + 'unblock_nodes': unblock_nodes, 'multi_folder': multi[:1000], 'today': today} |
| 124 | + |
| 125 | + |
| 126 | +def write(neo, ch, batch=5000): |
| 127 | + def chunks(xs): |
| 128 | + for i in range(0, len(xs), batch): |
| 129 | + yield xs[i:i + batch] |
| 130 | + for rows in chunks(ch['set']): |
| 131 | + neo.run([('UNWIND $rows AS row MATCH ()-[r:in_register_with]->() WHERE id(r) = row.id ' |
| 132 | + 'AND (r.block IS NULL OR r.block = $auto) SET r += row.props', {'rows': rows, 'auto': AUTO_EDGE})]) |
| 133 | + for k, ids in ch['remove'].items(): |
| 134 | + assert k in ('nrrd', 'swc', 'thumbnail', 'wlz', 'obj') |
| 135 | + for part in chunks(ids): |
| 136 | + neo.run([(f'UNWIND $ids AS rid MATCH ()-[r:in_register_with]->() WHERE id(r) = rid ' |
| 137 | + f'AND (r.block IS NULL OR r.block = $auto) REMOVE r.{k}', {'ids': part, 'auto': AUTO_EDGE})]) |
| 138 | + for part in chunks(ch['block_add']): |
| 139 | + neo.run([('UNWIND $ids AS rid MATCH ()-[r:in_register_with]->() WHERE id(r) = rid AND r.block IS NULL ' |
| 140 | + 'SET r.block = $auto, r.missing_since = [$today]', {'ids': part, 'auto': AUTO_EDGE, 'today': ch['today']})]) |
| 141 | + for part in chunks(ch['block_remove']): |
| 142 | + neo.run([('UNWIND $ids AS rid MATCH ()-[r:in_register_with]->() WHERE id(r) = rid AND r.block = $auto ' |
| 143 | + 'REMOVE r.block, r.missing_since', {'ids': part, 'auto': AUTO_EDGE})]) |
| 144 | + for part in chunks(ch['unblock_nodes']): |
| 145 | + neo.run([('UNWIND $ids AS sf MATCH (i:Individual {short_form: sf}) WHERE i.block = $auto REMOVE i.block', |
| 146 | + {'ids': part, 'auto': AUTO_NODE})]) |
| 147 | + |
| 148 | + |
| 149 | +def main(): |
| 150 | + ap = argparse.ArgumentParser() |
| 151 | + ap.add_argument('--run-dir', required=True) |
| 152 | + ap.add_argument('--edges', help='tx/commit JSON export of EDGE_QUERY (else read live from --neo4j)') |
| 153 | + ap.add_argument('--neo4j', default=os.environ.get('NEO4J_URL', 'http://kbw.kbw:7474')) |
| 154 | + ap.add_argument('--write', action='store_true') |
| 155 | + a = ap.parse_args() |
| 156 | + t0 = time.time() |
| 157 | + files, shards = load_scan(a.run_dir) |
| 158 | + edges = load_edges(a) |
| 159 | + c, ch = plan(edges, files, shards, time.strftime('%Y-%m-%d')) |
| 160 | + out = os.path.join(a.run_dir, 'kbsync'); os.makedirs(out, exist_ok=True) |
| 161 | + json.dump(ch, open(os.path.join(out, 'changes.json'), 'w')) |
| 162 | + if a.write: |
| 163 | + write(Neo(a.neo4j), ch); c['written'] = 1 |
| 164 | + c['seconds'] = round(time.time() - t0) |
| 165 | + json.dump(dict(c), open(os.path.join(out, 'summary.json'), 'w'), indent=1) |
| 166 | + print(json.dumps(dict(sorted(c.items())), indent=1)) |
| 167 | + |
| 168 | + |
| 169 | +if __name__ == '__main__': |
| 170 | + main() |
0 commit comments