Skip to content

Commit 27f1361

Browse files
Robbie1977claude
andcommitted
kbsync: keep kbw image properties and 'Missing Image' blocks in step with the files (replaces Jenkins CheckImagesExist); dry-run by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9Bfk6vb4qtaVZwyDzzL22
1 parent 386a258 commit 27f1361

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

‎derive/kbsync.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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

Comments
 (0)