-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathupdate_fiji_deps.py
More file actions
1200 lines (1043 loc) · 52.8 KB
/
Copy pathupdate_fiji_deps.py
File metadata and controls
1200 lines (1043 loc) · 52.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
update_fiji_deps.py — make a Fiji install carry the dependency versions that the
five Preibisch-lab projects (multiview-simulation, multiview-reconstruction,
BigStitcher, Stitching, Descriptor_based_registration) build against, then drop in
the freshly built project plugin jars.
Scope = the union of the *direct* `<dependencies>` each project's pom explicitly
declares, at the version that project resolves them to. For each such dependency:
- present in Fiji but older -> UPGRADE
- present in Fiji but newer -> KEPT (never downgraded)
- absent from Fiji -> ADD (install), so Fiji is self-contained
Libraries the projects don't declare are left untouched.
Standard library only. Requires `mvn` on PATH; uses your local ~/.m2 and downloads
only what's missing. Modifies the Fiji install IN PLACE — to get the original back,
re-extract your Fiji ZIP (the script keeps no backup).
Example — build/refresh the test Fiji in one reproducible call (run on a freshly
re-extracted Fiji), then clear macOS quarantine on the new native jars:
python3 update_fiji_deps.py ~/Downloads/Fiji --apply --transitive closure \\
--exclude pyramidio,ijp-kheops,generic-archiver \\
--remove aws-java-sdk-core,aws-java-sdk-s3,aws-java-sdk-kms,jmespath-java,SPIM_Registration
xattr -dr com.apple.quarantine ~/Downloads/Fiji
"""
from __future__ import annotations
import argparse
import functools
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #
POM_NS = "http://maven.apache.org/POM/4.0.0"
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
M2 = Path.home() / ".m2" / "repository"
SCIJAVA_REPO = "https://maven.scijava.org/content/groups/public"
POM_SCIJAVA_METADATA = f"{SCIJAVA_REPO}/org/scijava/pom-scijava/maven-metadata.xml"
# The zarrv3 / imaging stack shown in the BOM comparison tables (display order).
MIGRATION_ARTIFACTS = [
("n5", "org.janelia.saalfeldlab", "n5"),
("n5-zarr", "org.janelia.saalfeldlab", "n5-zarr"),
("n5-universe", "org.janelia.saalfeldlab", "n5-universe"),
("n5-aws-s3", "org.janelia.saalfeldlab", "n5-aws-s3"),
("n5-imglib2", "org.janelia.saalfeldlab", "n5-imglib2"),
("n5-blosc", "org.janelia.saalfeldlab", "n5-blosc"),
("n5-hdf5", "org.janelia.saalfeldlab", "n5-hdf5"),
("n5-google-cloud", "org.janelia.saalfeldlab", "n5-google-cloud"),
("n5-zstandard", "org.janelia", "n5-zstandard"),
("imglib2", "net.imglib2", "imglib2"),
("imglib2-cache", "net.imglib2", "imglib2-cache"),
("imglib2-realtransform", "net.imglib2", "imglib2-realtransform"),
("imglib2-algorithm", "net.imglib2", "imglib2-algorithm"),
("bigdataviewer-core", "sc.fiji", "bigdataviewer-core"),
("mpicbg", "mpicbg", "mpicbg"),
]
# Temp settings.xml used only to resolve an unreleased pom-scijava SNAPSHOT parent
# (snapshots live on the scijava repo, not Maven Central).
_SNAP_SETTINGS = f"""<settings>
<profiles><profile><id>scijava</id>
<repositories><repository><id>scijava.public</id><url>{SCIJAVA_REPO}</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots></repository></repositories>
<pluginRepositories><pluginRepository><id>scijava.public</id><url>{SCIJAVA_REPO}</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots></pluginRepository></pluginRepositories>
</profile></profiles>
<activeProfiles><activeProfile>scijava</activeProfile></activeProfiles>
</settings>
"""
# --------------------------------------------------------------------------- #
# Small helpers
# --------------------------------------------------------------------------- #
def log(msg: str = "") -> None:
print(msg, flush=True)
def warn(msg: str) -> None:
print(f" ! {msg}", file=sys.stderr, flush=True)
def die(msg: str, code: int = 1) -> "None":
print(f"ERROR: {msg}", file=sys.stderr, flush=True)
sys.exit(code)
def run(cmd: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True)
def mvn_bin() -> str:
exe = shutil.which("mvn")
if not exe:
die("`mvn` not found on PATH (needed for help:effective-pom / dependency:get).")
return exe
def parse_pom_properties(text: str) -> dict:
out = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
return out
# Maven-style qualifier ordering (lower = older). Release/"" is the baseline; a
# numeric token outranks any qualifier at the same position.
_QUAL_RANK = {
"alpha": -6, "a": -6, "beta": -5, "b": -5, "milestone": -4, "m": -4,
"rc": -3, "cr": -3, "snapshot": -2, "": 0, "ga": 0, "final": 0,
"release": 0, "sp": 1,
}
def _ver_tokens(v: str) -> list:
v = v.lower().replace("-", ".").replace("_", ".")
toks = []
for seg in v.split("."):
toks.extend(re.findall(r"\d+|[a-z]+", seg))
return toks
def compare_versions(a: str, b: str) -> int:
"""Maven-ish version compare. Returns 1 if a>b, -1 if a<b, 0 if equal.
Good enough to distinguish upgrades from downgrades for our jars."""
ta, tb = _ver_tokens(a), _ver_tokens(b)
for i in range(max(len(ta), len(tb))):
x = ta[i] if i < len(ta) else "0"
y = tb[i] if i < len(tb) else "0"
xd, yd = x.isdigit(), y.isdigit()
if xd and yd:
c = (int(x) > int(y)) - (int(x) < int(y))
elif xd:
c = 1 # numeric outranks a qualifier
elif yd:
c = -1
else:
rx = _QUAL_RANK.get(x, 0.5) # unknown qualifier ranks above known ones
ry = _QUAL_RANK.get(y, 0.5)
c = (rx > ry) - (rx < ry) or (x > y) - (x < y)
if c:
return c
return 0
# --------------------------------------------------------------------------- #
# pom-scijava BOM resolution (used only for the comparison tables)
# --------------------------------------------------------------------------- #
def effective_pom_xml(mvn: str, pom: Path, offline: bool, soft: bool = False,
settings: Path | None = None):
cmd = [mvn, "-B"]
if offline:
cmd.append("-o")
if settings:
cmd += ["-s", str(settings)]
cmd += ["-f", str(pom), "help:effective-pom"]
cp = run(cmd)
out = cp.stdout
start = out.find("<project")
end = out.rfind("</project>")
if start == -1 or end == -1:
if soft:
return None
sys.stderr.write(cp.stdout[-2000:])
sys.stderr.write(cp.stderr[-2000:])
die(f"could not extract effective-pom XML from `mvn help:effective-pom -f {pom}` "
f"(offline={offline}). Is the project resolvable?")
return out[start:end + len("</project>")]
_MINIMAL_POM = """<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.scijava</groupId>
<artifactId>pom-scijava</artifactId>
<version>{v}</version>
<relativePath/>
</parent>
<groupId>tmp.fiji.deps</groupId>
<artifactId>base-probe</artifactId>
<version>0</version>
</project>
"""
def parent_version(pom: Path) -> str | None:
m = re.search(r"<parent>.*?<version>(.*?)</version>.*?</parent>",
pom.read_text(), flags=re.S)
return m.group(1).strip() if m else None
def base_effective_dm(mvn: str, scijava_version: str, offline: bool) -> dict:
"""pom-scijava default dependencyManagement (no project overrides), via a
throwaway minimal pom. Used only to attribute provenance of each version."""
with tempfile.TemporaryDirectory() as td:
pom = Path(td) / "pom.xml"
pom.write_text(_MINIMAL_POM.format(v=scijava_version))
xml = effective_pom_xml(mvn, pom, offline, soft=True)
return parse_dependency_management(xml) if xml else {}
def _ver_key(v: str) -> list:
return [int(p) if p.isdigit() else -1 for p in re.split(r"[.-]", v)]
def latest_snapshot_bom_version() -> str | None:
"""Newest pom-scijava *-SNAPSHOT from the scijava repo metadata (network).
Retries a couple of times to ride out a transient blip on a flaky connection."""
data = None
for _ in range(3):
try:
data = urllib.request.urlopen(POM_SCIJAVA_METADATA, timeout=10).read().decode()
break
except Exception:
continue
if data is None:
return None
snaps = [v for v in re.findall(r"<version>(.*?)</version>", data) if v.endswith("-SNAPSHOT")]
snaps.sort(key=_ver_key)
return snaps[-1] if snaps else None
def snapshot_bom_dm(mvn: str, version: str) -> dict:
"""Effective dependencyManagement of an unreleased pom-scijava SNAPSHOT,
resolved via a temp settings.xml that enables the scijava snapshots repo."""
with tempfile.TemporaryDirectory() as td:
td = Path(td)
(td / "settings.xml").write_text(_SNAP_SETTINGS)
(td / "pom.xml").write_text(_MINIMAL_POM.format(v=version))
xml = effective_pom_xml(mvn, td / "pom.xml", offline=False, soft=True,
settings=td / "settings.xml")
return parse_dependency_management(xml) if xml else {}
def print_bom_table(title: str, fiji: dict, bom: dict, projects: dict) -> None:
"""Compare the imaging/zarrv3 stack across Fiji, a pom-scijava BOM, and the
version the five projects resolve each dependency to."""
log(f"\n{title}")
log(f" {'artifact':24}{'Fiji':16}{'pom-scijava':16}{'your projects':18}note")
log(" " + "-" * 86)
for name, g, a in MIGRATION_ARTIFACTS:
ga = (g, a)
fv, pv, ov = fiji.get(ga, "-"), bom.get(ga, "-"), projects.get(ga, "-")
if ov == "-":
note = ""
elif ov == pv:
note = "matches pom-scijava (no override)"
else:
note = "<- override (pom-scijava still older)"
log(f" {name:24}{fv:16}{pv:16}{ov:18}{note}")
def parse_dependency_management(xml_text: str) -> dict:
"""Return {(groupId, artifactId): version} from <dependencyManagement> ONLY.
Classifier'd managed entries collapse to the same GA (we key on version)."""
root = ET.fromstring(xml_text)
def q(tag: str) -> str:
return f"{{{POM_NS}}}{tag}"
dm = root.find(q("dependencyManagement"))
result: dict = {}
if dm is None:
return result
deps = dm.find(q("dependencies"))
if deps is None:
return result
for dep in deps.findall(q("dependency")):
g = dep.findtext(q("groupId"))
a = dep.findtext(q("artifactId"))
v = dep.findtext(q("version"))
if not g or not a or not v:
continue
if "${" in v: # unresolved property — should not happen in an effective pom
continue
result[(g, a)] = v
return result
# --------------------------------------------------------------------------- #
# The target set: direct dependencies the projects explicitly declare
# --------------------------------------------------------------------------- #
def _ns_of(root) -> str:
return root.tag[1:root.tag.index("}")] if root.tag.startswith("{") else ""
def declared_direct_deps(pom: Path) -> dict:
"""{(groupId, artifactId): scope} for the pom's OWN top-level <dependencies>
(not dependencyManagement, not profiles/build). Test scope excluded."""
root = ET.fromstring(pom.read_text())
ns = _ns_of(root)
q = (lambda t: f"{{{ns}}}{t}") if ns else (lambda t: t)
deps = root.find(q("dependencies"))
out: dict = {}
if deps is None:
return out
for d in deps.findall(q("dependency")):
g, a = d.findtext(q("groupId")), d.findtext(q("artifactId"))
scope = (d.findtext(q("scope")) or "compile").strip()
if not g or not a or scope == "test":
continue
out[(g.strip(), a.strip())] = scope
return out
def _parse_dependency_list(text: str) -> dict:
"""Parse `mvn dependency:list -DoutputFile=` output -> {(groupId, artifactId): version}.
Lines look like `g:a:type[:classifier]:version:scope`; test scope is dropped."""
out: dict = {}
for line in text.splitlines():
line = _ANSI.sub("", line).split(" -- ")[0].strip()
if " " in line: # header / log lines
continue
parts = line.split(":")
if len(parts) < 5:
continue
g, a = parts[0], parts[1]
version, scope = (parts[4], parts[5]) if len(parts) >= 6 else (parts[3], parts[4])
if scope.strip() == "test":
continue
out[(g, a)] = version
return out
def resolved_direct_deps(mvn: str, repo: Path) -> dict:
"""{(groupId, artifactId): version} of the project's *direct* dependencies,
resolved by `mvn dependency:list -DexcludeTransitive=true` (compile+runtime)."""
with tempfile.TemporaryDirectory() as td:
outf = Path(td) / "deps.txt"
cmd = [mvn, "-B", "-o", "-DexcludeTransitive=true", "-DincludeScope=runtime",
f"-DoutputFile={outf}", "-DappendOutput=false",
"dependency:list", "-f", str(repo / "pom.xml")]
cp = run(cmd)
if not outf.is_file():
tail = cp.stdout.strip().splitlines()[-1:] or [str(cp.returncode)]
warn(f"dependency:list failed for {repo.name}: {tail[0]}")
return {}
return _parse_dependency_list(outf.read_text())
_CLOSURE_POM = """<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.scijava</groupId><artifactId>pom-scijava</artifactId>
<version>{sjv}</version><relativePath/></parent>
<groupId>tmp.fiji.deps</groupId><artifactId>closure-probe</artifactId><version>0</version>
<repositories><repository><id>scijava.public</id><url>{repo}</url></repository></repositories>
<dependencies>
{deps}
</dependencies>
</project>
"""
_TREE_LINE = re.compile(r"^([| +\\-]*)([A-Za-z0-9].*)$")
def transitive_frontier(mvn: str, scijava_version: str, deps: list,
present_gas: set, direct_gas: set, project_gas: set) -> dict:
"""Transitive deps hanging off the given (changed) deps -> {(g,a): version}, as
CANDIDATES the caller then splits into add / upgrade / leave.
Walks `mvn dependency:tree`. A dep Fiji already has is still EMITTED (so the caller
can upgrade it when the changed libs need a newer version than is installed — e.g.
n5 4.0.1 needs commons-compress 1.28.0 for GzipCompressorInputStream.builder()), but
its subtree is PRUNED so e.g. ijp-kheops -> bio-formats(present) -> OMERO is not
pulled in. Deps we handle elsewhere (declared-direct / project artifacts) are pruned
WITHOUT emitting. Absent deps are emitted and descended into. Offline-first."""
if not deps:
return {}
dep_xml = "\n".join(
f" <dependency><groupId>{g}</groupId><artifactId>{a}</artifactId>"
f"<version>{v}</version></dependency>" for g, a, v in deps)
pom_text = _CLOSURE_POM.format(sjv=scijava_version, repo=SCIJAVA_REPO, deps=dep_xml)
tree = None
for offline in (True, False):
with tempfile.TemporaryDirectory() as td:
(Path(td) / "pom.xml").write_text(pom_text)
outf = Path(td) / "tree.txt"
cmd = [mvn, "-B", f"-DoutputFile={outf}", "-DappendOutput=false",
"dependency:tree", "-f", str(Path(td) / "pom.xml")]
if offline:
cmd.insert(1, "-o")
run(cmd)
if outf.is_file():
tree = outf.read_text()
break
if tree is None:
warn("could not resolve transitive tree (offline and online both failed)")
return {}
frontier: dict = {}
prune_at = None # skip nodes deeper than this (a pruned subtree)
for line in tree.splitlines():
m = _TREE_LINE.match(_ANSI.sub("", line.rstrip()))
if not m:
continue
depth = len(m.group(1)) // 3
parts = m.group(2).split(":")
if len(parts) < 5: # root probe line, etc.
continue
if prune_at is not None and depth > prune_at:
continue
prune_at = None
if depth < 2: # root (0) and the changed deps (1): descend, don't add
continue
g, a, version, scope = parts[0], parts[1], parts[-2], parts[-1]
if scope in ("test", "provided", "system"):
prune_at = depth
continue
ga = (g, a)
if ga in direct_gas or ga in project_gas:
prune_at = depth # handled elsewhere -> prune its subtree, don't emit
elif ga in present_gas:
# Fiji has it, but the upgraded libraries may need a NEWER version than is
# installed (e.g. n5 4.0.1 needs commons-compress 1.28.0). Emit it so the
# caller can UPGRADE it when older (it is never downgraded); prune its subtree
# so we don't drag in the present dep's own dependencies.
frontier[ga] = version
prune_at = depth
else:
frontier[ga] = version # absent -> add, and descend into its children
return frontier
def full_runtime_closure(mvn: str, projects: list) -> dict:
"""Union of the projects' full runtime dependency lists (transitive), keeping
the newest version seen per (groupId, artifactId)."""
clo: dict = {}
for repo, _g, _a in projects:
with tempfile.TemporaryDirectory() as td:
outf = Path(td) / "deps.txt"
run([mvn, "-B", "-o", "-DincludeScope=runtime", f"-DoutputFile={outf}",
"-DappendOutput=false", "dependency:list", "-f", str(repo / "pom.xml")])
if not outf.is_file():
continue
for ga, v in _parse_dependency_list(outf.read_text()).items():
if ga not in clo or compare_versions(v, clo[ga]) > 0:
clo[ga] = v
return clo
def build_direct_targets(mvn: str, projects: list, project_gas: set) -> dict:
"""Union of the 5 projects' declared direct deps -> version (max across
projects). Excludes the project self-artifacts (installed separately)."""
per: dict = {} # GA -> {version: [repo, ...]}
for repo, _g, _a in projects:
declared = declared_direct_deps(repo / "pom.xml")
resolved = resolved_direct_deps(mvn, repo)
cnt = 0
for ga in declared:
if ga in project_gas:
continue
v = resolved.get(ga)
if not v:
continue
per.setdefault(ga, {}).setdefault(v, []).append(repo.name)
cnt += 1
log(f" {repo.name}: {cnt} declared direct deps")
targets: dict = {}
for ga, vmap in per.items():
best = max(vmap, key=functools.cmp_to_key(compare_versions))
targets[ga] = best
if len(vmap) > 1:
detail = ", ".join(f"{v} ({'/'.join(ps)})" for v, ps in vmap.items())
log(f" note: {ga[0]}:{ga[1]} declared at multiple versions [{detail}] "
f"-> using newest {best}")
return targets
# --------------------------------------------------------------------------- #
# Jar identity
# --------------------------------------------------------------------------- #
class JarId:
__slots__ = ("path", "group", "artifact", "version", "classifier", "via")
def __init__(self, path, group, artifact, version, classifier, via):
self.path = path
self.group = group
self.artifact = artifact
self.version = version
self.classifier = classifier # "" if none, None if unknown
self.via = via # "pom.properties" | "filename"
@property
def ga(self):
return (self.group, self.artifact)
def _classifier_from_name(stem: str, artifact: str, version: str):
base = f"{artifact}-{version}"
if stem == base:
return ""
if stem.startswith(base + "-"):
return stem[len(base) + 1:]
return None # filename version disagrees with metadata; classifier unknown
def read_jar_identity(jar: Path, known_artifacts: set | None = None) -> JarId | None:
"""Identify a jar. Authority order: embedded pom.properties, then a
filename heuristic disambiguated against known artifactIds."""
stem = jar.name[:-4] if jar.name.endswith(".jar") else jar.name
try:
with zipfile.ZipFile(jar) as zf:
candidates = []
for name in zf.namelist():
if re.match(r"META-INF/maven/[^/]+/[^/]+/pom\.properties$", name):
props = parse_pom_properties(zf.read(name).decode("utf-8", "replace"))
g, a, v = props.get("groupId"), props.get("artifactId"), props.get("version")
if g and a and v:
candidates.append((g, a, v))
# Prefer the embedded artifact whose id prefixes the filename
# (uber jars embed several pom.properties).
chosen = None
for g, a, v in candidates:
if stem == f"{a}-{v}" or stem.startswith(f"{a}-{v}-"):
if chosen is None or len(a) > len(chosen[1]):
chosen = (g, a, v)
if chosen is None and len(candidates) == 1:
chosen = candidates[0]
if chosen:
g, a, v = chosen
return JarId(jar, g, a, v, _classifier_from_name(stem, a, v), "pom.properties")
except zipfile.BadZipFile:
warn(f"not a valid zip, skipping: {jar.name}")
return None
# Fallback (no pom.properties): match the filename against known artifactIds,
# treating '-' and '_' as EQUIVALENT so an underscore-renamed plugin jar
# (multiview_reconstruction-8.1.2.jar) still maps to its canonical hyphenated
# artifactId and isn't missed -> wrongly re-added. The matched artifactId must
# be followed by a version digit, so "jna-platform-5.14.0" does NOT match "jna".
if known_artifacts:
norm_stem = stem.replace("_", "-")
best = None # (canonical_artifactId, normalized_len)
for a in known_artifacts:
na = a.replace("_", "-")
if norm_stem == na or (norm_stem.startswith(na + "-")
and norm_stem[len(na) + 1:len(na) + 2].isdigit()):
if best is None or len(na) > best[1]:
best = (a, len(na))
if best is not None:
rest = norm_stem[best[1]:].lstrip("-") # version (no classifier split here)
return JarId(jar, None, best[0], rest, "", "filename")
return None
def jar_groupid(jar: Path, artifact_id: str) -> str | None:
"""groupId recorded in the jar's pom.properties for this artifactId, or None
when the jar has no such metadata (groupId then simply unknown)."""
try:
with zipfile.ZipFile(jar) as zf:
for name in zf.namelist():
if re.match(r"META-INF/maven/[^/]+/[^/]+/pom\.properties$", name):
props = parse_pom_properties(zf.read(name).decode("utf-8", "replace"))
if props.get("artifactId") == artifact_id:
return props.get("groupId")
except (zipfile.BadZipFile, OSError):
pass
return None
def find_jars_by_artifact(fiji: Path, artifact_id: str, group_id: str | None = None) -> list:
"""Jars named `<artifactId>-<version…>.jar` in jars/ or plugins/ (filename-based,
so it also catches jars without pom.properties). When group_id is given, a jar
whose pom.properties reports a DIFFERENT groupId is skipped — so a generic name
like 'utils' doesn't match across groups (org.renjin:utils vs
software.amazon.awssdk:utils). Jars with unknown groupId still match."""
out = []
for sub in ("jars", "plugins"):
d = fiji / sub
if not d.is_dir():
continue
for p in sorted(d.glob(f"{artifact_id}-*.jar")):
if not p.name[len(artifact_id) + 1:len(artifact_id) + 2].isdigit():
continue
if group_id is not None:
g = jar_groupid(p, artifact_id)
if g is not None and g != group_id:
continue # same artifactId, different library -> not a match
out.append(p)
return out
def find_present_jar(fiji: Path, group_id: str | None, artifact_id: str) -> tuple | None:
"""First present jar for this (groupId, artifactId) as (path, version), else None."""
hits = find_jars_by_artifact(fiji, artifact_id, group_id)
if not hits:
return None
return (hits[0], hits[0].name[len(artifact_id) + 1:-4])
def scan_fiji_jars(fiji: Path, backup_root: Path, known_artifacts: set) -> list[JarId]:
jars: list[JarId] = []
for sub in ("jars", "plugins"):
d = fiji / sub
if not d.is_dir():
continue
for jar in d.rglob("*.jar"):
if backup_root in jar.parents:
continue
jid = read_jar_identity(jar, known_artifacts)
if jid is not None:
jars.append(jid)
return jars
# --------------------------------------------------------------------------- #
# Project coordinates + built jars
# --------------------------------------------------------------------------- #
def project_coords(pom: Path) -> tuple[str, str]:
"""(groupId, artifactId) of the project itself (parent block stripped)."""
text = pom.read_text()
text = re.sub(r"<parent>.*?</parent>", "", text, flags=re.S)
def first(tag):
m = re.search(rf"<{tag}>(.*?)</{tag}>", text, flags=re.S)
return m.group(1).strip() if m else None
return first("groupId"), first("artifactId")
def find_built_jar(repo: Path) -> Path | None:
"""Newest main artifact jar in target/ (excludes tests/sources/javadoc/original)."""
tdir = repo / "target"
if not tdir.is_dir():
return None
cands = [
p for p in tdir.glob("*.jar")
if not re.search(r"-(tests|sources|javadoc|original)\.jar$", p.name)
]
if not cands:
return None
return max(cands, key=lambda p: p.stat().st_mtime)
# --------------------------------------------------------------------------- #
# Artifact resolution (for dependency updates)
# --------------------------------------------------------------------------- #
def m2_file(g: str, a: str, v: str, classifier: str) -> Path:
fname = f"{a}-{v}" + (f"-{classifier}" if classifier else "") + ".jar"
return M2 / Path(*g.split(".")) / a / v / fname
def resolve_artifact(mvn: str, g: str, a: str, v: str, classifier: str) -> Path | None:
"""Path to the jar in ~/.m2 — used directly if already cached (offline), else
downloaded via `mvn dependency:get`."""
direct = m2_file(g, a, v, classifier)
if direct.is_file():
return direct
if v.endswith("-SNAPSHOT"):
# timestamped snapshots in ~/.m2 have non-literal filenames
hits = sorted((direct.parent).glob(f"{a}-*{('-' + classifier) if classifier else ''}.jar")) \
if direct.parent.is_dir() else []
return hits[-1] if hits else None
artifact = f"{g}:{a}:{v}" + (f":jar:{classifier}" if classifier else "")
cp = run([mvn, "-B", "dependency:get", f"-Dartifact={artifact}"])
if direct.is_file():
return direct
warn(f"could not resolve {artifact}: "
f"{cp.stdout.strip().splitlines()[-1] if cp.stdout.strip() else cp.returncode}")
return None
def needs_legacy_plugins_dir(jar: Path) -> bool:
"""True only for an ImageJ1 plugin — a jar shipping a root `plugins.config`,
which ImageJ1's legacy menu scan discovers from the plugins/ folder.
NOT used for ImageJ2/SciJava plugins (those carry @Plugin annotations indexed
at META-INF/json/org.scijava.plugin.Plugin, no plugins.config): SciJava finds
them anywhere on the classpath, so Fiji keeps them in jars/ alongside ordinary
libraries — verified here (imagej-ops, imagej-plugins-commands, scijava-common
all carry @Plugin, lack plugins.config, and live in jars/). Routing by the
@Plugin index instead would wrongly drag every @Plugin-bearing library into
plugins/."""
try:
with zipfile.ZipFile(jar) as zf:
return "plugins.config" in zf.namelist()
except (zipfile.BadZipFile, OSError):
return False
def install_dir_for(fiji: Path, src_jar: Path) -> Path:
"""Where a NOT-yet-present jar should be installed: plugins/ only for ImageJ1
plugins.config jars, jars/ for everything else (libraries AND ImageJ2 plugins,
which are discovered from the classpath regardless of folder)."""
return fiji / ("plugins" if needs_legacy_plugins_dir(src_jar) else "jars")
def dest_filename(artifact_id: str, version: str, classifier: str, in_plugins: bool) -> str:
"""The on-disk jar name. In plugins/, ImageJ1 only scans a jar if its filename
contains an underscore, so the artifactId is underscore-ified: hyphens become
underscores, or a trailing '_' is appended when it has neither
(multiview-reconstruction -> multiview_reconstruction, BigStitcher -> BigStitcher_).
In jars/ the Maven name is kept verbatim."""
base = artifact_id
if in_plugins and "_" not in base:
base = base.replace("-", "_") if "-" in base else base + "_"
cl = f"-{classifier}" if classifier else ""
return f"{base}-{version}{cl}.jar"
def upgraded_name(old_name: str, old_version: str, new_version: str) -> str | None:
"""The new filename for an EXISTING jar: the current name with only the version
token swapped. Guarantees it matches the current name apart from the version
(so Fiji recognizes it as a new version of the same file), preserving whatever
base name / classifier / rename convention Fiji already uses. None if the
version can't be located in the name (caller then derives a fresh name)."""
marker = f"-{old_version}"
idx = old_name.rfind(marker)
if idx == -1:
return None
return old_name[:idx] + f"-{new_version}" + old_name[idx + len(marker):]
def validate_jar(jar: Path, g: str, a: str, v: str) -> bool:
"""Confirm the resolved jar is a real zip whose embedded GA/version match."""
if not zipfile.is_zipfile(jar):
return False
try:
with zipfile.ZipFile(jar) as zf:
for name in zf.namelist():
if re.match(r"META-INF/maven/[^/]+/[^/]+/pom\.properties$", name):
props = parse_pom_properties(zf.read(name).decode("utf-8", "replace"))
if (props.get("groupId"), props.get("artifactId"), props.get("version")) == (g, a, v):
return True
except zipfile.BadZipFile:
return False
# No pom.properties to confirm against — accept if it is at least a valid zip.
return True
# --------------------------------------------------------------------------- #
# Apply
# --------------------------------------------------------------------------- #
def place_jar(new_src: Path, install_dir: Path, dest_name: str,
remove: list[Path] | None = None) -> None:
"""Copy new_src into install_dir as dest_name, then delete any old copies
(remove) that aren't the new file. No backup — restore from the Fiji ZIP."""
dest = install_dir / dest_name
install_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(new_src), str(dest))
for old in remove or []:
if old.is_file() and old.resolve() != dest.resolve():
old.unlink()
# --------------------------------------------------------------------------- #
# Main
# --------------------------------------------------------------------------- #
def main() -> None:
here = Path(__file__).resolve().parent # .../workspace/BigStitcher
ws = here.parent # .../workspace
default_projects = [
ws / "BigStitcher",
ws / "multiview-reconstruction",
ws / "multiview-simulation",
ws / "Stitching",
ws / "Descriptor_based_registration",
]
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("fiji", type=Path, help="path to the Fiji installation")
g = ap.add_mutually_exclusive_group()
g.add_argument("--dry-run", action="store_true", default=True,
help="(default) report only, change nothing")
g.add_argument("--apply", dest="apply", action="store_true",
help="perform the updates (restore from your Fiji ZIP to undo)")
ap.add_argument("--transitive", required=True,
choices=["direct", "direct-upgrade-only", "closure",
"closure-upgrade-only", "full", "full-upgrade-only"],
help="REQUIRED. Which dependencies to handle. SCOPE: direct = the projects' "
"direct deps only; closure = + the subtree pulled in by upgraded libs "
"(subtrees rooted at deps Fiji already has are pruned, but a present dep "
"OLDER than the upgraded libs need is still upgraded — e.g. n5 4.0.1 needs "
"commons-compress 1.28.0; needed for the new n5 S3/HDF5 backends); "
"full = + the projects' entire runtime closure. The '-upgrade-only' suffix "
"suppresses ALL new installs: it only version-bumps dependency jars already "
"present in Fiji (the project plugin jars are still installed).")
ap.add_argument("--exclude", default="", metavar="G[,G...]",
help="comma-separated artifactIds (or groupId:artifactId, or groupId) to "
"NEVER install or pull in transitively, e.g. "
"'pyramidio,ijp-kheops,generic-archiver' (OpenSeaDragon-export deps that "
"belong on the update site, not in core Fiji; code runs without them "
"unless that export is used). Excluding a closure root also prunes its "
"whole subtree.")
ap.add_argument("--remove", default="", metavar="A[,A...]",
help="comma-separated artifactIds of jars to DELETE from Fiji if present, "
"e.g. the obsolete AWS SDK v1 'aws-java-sdk-core,aws-java-sdk-s3,"
"aws-java-sdk-kms,jmespath-java'. Unlike --exclude this does not touch "
"dependency resolution — it just deletes matching jars (filename-based, "
"for reproducible cleanup of unrelated cruft).")
ap.add_argument("--add", default="", metavar="P[,P...]",
help="whitelist of deps to INSTALL when new (direct or transitive): when "
"given, a jar is added only if it matches a pattern. Patterns: "
"'groupId:artifactId', 'groupId:*', or bare 'groupId'/'artifactId'. E.g. "
"'software.amazon.awssdk:*,io.netty:*,software.amazon.eventstream' pulls just "
"the AWS SDK v2 stack. Upgrades of already-present jars and the project jars "
"are not affected.")
args = ap.parse_args()
dry_run = not args.apply
scope = args.transitive.replace("-upgrade-only", "") # direct | closure | full
upgrade_only = args.transitive.endswith("-upgrade-only") # no new installs, bumps only
fiji: Path = args.fiji.expanduser().resolve()
if not fiji.is_dir():
die(f"not a directory: {fiji}")
if not (fiji / "jars").is_dir():
die(f"{fiji} does not look like a Fiji install (no jars/ dir)")
mvn = mvn_bin()
project_repos = default_projects
# Project coordinates (for map exclusion + install) ---------------------- #
projects = [] # list of (repo, group, artifact)
project_gas = set()
for repo in project_repos:
pom = repo / "pom.xml"
if not pom.is_file():
warn(f"project repo has no pom.xml, skipping: {repo}")
continue
gco, aco = project_coords(pom)
if gco and aco:
projects.append((repo, gco, aco))
project_gas.add((gco, aco))
# Target set: explicitly-declared direct dependencies ------------------- #
log("Collecting explicitly-declared direct dependencies from the projects...")
direct_targets = build_direct_targets(mvn, projects, project_gas)
# --exclude: never install/upgrade these, and (by dropping them from the closure
# roots) never walk their transitive subtree either.
exclude_tokens = [t.strip() for t in args.exclude.split(",") if t.strip()]
remove_tokens = [t.strip().split(":")[-1] for t in args.remove.split(",") if t.strip()]
add_tokens = [t.strip() for t in args.add.split(",") if t.strip()]
def is_excluded(g_: str, a_: str) -> bool:
return any(t in (a_, g_, f"{g_}:{a_}") for t in exclude_tokens)
def add_allows(g_: str, a_: str) -> bool:
"""With --add given, a NEW install is allowed only if it matches a pattern
('g:*' = whole group, 'g:a' = exact, bare token = groupId or artifactId)."""
if not add_tokens:
return True
for p in add_tokens:
if p.endswith(":*"):
if g_ == p[:-2]:
return True
elif ":" in p:
if f"{g_}:{a_}" == p:
return True
elif g_ == p or a_ == p:
return True
return False
if exclude_tokens:
dropped = sorted(f"{g}:{a}" for g, a in direct_targets if is_excluded(g, a))
direct_targets = {ga: v for ga, v in direct_targets.items() if not is_excluded(*ga)}
log(f" excluding (per --exclude): {', '.join(dropped) if dropped else '(no direct dep matched)'}")
log(f" -> {len(direct_targets)} distinct direct dependencies across the projects\n")
known_artifacts = {a for (_g, a) in direct_targets} | {a for (_g, a) in project_gas}
# Scan Fiji -------------------------------------------------------------- #
log(f"Scanning {fiji}/jars and {fiji}/plugins ...")
jars = scan_fiji_jars(fiji, fiji / ".dep-update-backup", known_artifacts)
log(f" found {len(jars)} jars\n")
# Jars without pom.properties were identified by filename only (group=None).
# Recover their groupId from the target set by artifactId (unique ids only),
# so e.g. a bare jna-5.14.0.jar matches net.java.dev.jna:jna instead of
# looking absent and being wrongly re-added.
art_to_group: dict = {}
ambiguous = set()
for g_, a_ in list(direct_targets) + list(project_gas):
if a_ in art_to_group and art_to_group[a_] != g_:
ambiguous.add(a_)
art_to_group[a_] = g_
for a_ in ambiguous:
art_to_group.pop(a_, None)
for jid in jars:
if jid.group is None and jid.artifact in art_to_group:
jid.group = art_to_group[jid.artifact]
# --exclude also removes any matching jar already present (so the result is
# the same whether or not a prior run installed them).
excluded_present = [j for j in jars if exclude_tokens and is_excluded(j.group or "", j.artifact)]
remove_present = sorted({p for tok in remove_tokens for p in find_jars_by_artifact(fiji, tok)})
# Classify --------------------------------------------------------------- #
upgrades = [] # (JarId, target_version) present + project newer
kept = [] # (JarId, target_version) present + Fiji newer (kept)
present_gas = set()
for jid in jars:
present_gas.add(jid.ga)
if jid.ga in project_gas or jid.ga not in direct_targets:
continue
tv = direct_targets[jid.ga]
c = compare_versions(tv, jid.version)
if c > 0:
upgrades.append((jid, tv))
elif c < 0:
kept.append((jid, tv))
# c == 0 -> already current
adds = sorted((ga, v) for ga, v in direct_targets.items()
if ga not in present_gas and ga not in project_gas and add_allows(*ga))
# pom-scijava released baseline — reused for the BOM table below, and to find
# which direct deps the projects pin AWAY from the BOM (whose transitive subtree
# may bring jars Fiji lacks, e.g. n5-aws-s3's switch to AWS SDK v2).
released = parent_version(projects[0][0] / "pom.xml") if projects else None
bom_rel = ((base_effective_dm(mvn, released, offline=True)
or base_effective_dm(mvn, released, offline=False)) if released else {})
# Transitive deps the upgraded libraries pull in (--transitive). Defined against the
# BOM baseline (not the live Fiji), so re-runs are stable. Absent jars are ADDED;
# present jars OLDER than what the upgraded libs need are UPGRADED (never downgraded),
# e.g. n5 4.0.1 needs commons-compress 1.28.0 for GzipCompressorInputStream.builder().
trans_adds = [] # [((g, a), version)] truly absent -> install
trans_upgrades = [] # [((g, a), version, old_path, old_version)] present older -> replace
if scope != "direct":
log(f"Resolving transitive dependencies ({args.transitive}) ...")
if scope == "full":
clo = full_runtime_closure(mvn, projects)
# Keep present deps as candidates so present-but-too-old transitive deps get
# UPGRADED (the split below leaves present-equal/newer ones untouched).
raw = {ga: v for ga, v in clo.items()
if ga not in direct_targets
and ga not in project_gas and not is_excluded(*ga)}
else: # closure: subtree of the deps our projects override away from the BOM,
# pruned at any dep Fiji already has (avoids dragging in OMERO/Scala/etc.)
if bom_rel:
changed = [(g, a, v) for (g, a), v in direct_targets.items()
if bom_rel.get((g, a)) != v]
else: # baseline unavailable -> fall back to what differs from Fiji
changed = ([(j.group, j.artifact, t) for j, t in upgrades]
+ [(g, a, v) for (g, a), v in adds])
fr = transitive_frontier(mvn, released, changed, present_gas,
set(direct_targets), project_gas)
raw = {ga: v for ga, v in fr.items() if not is_excluded(*ga)}
# A "missing" GA may actually be present under a jar without pom.properties
# (so the scan didn't identify it) -> classify by FILENAME so we replace it
# instead of installing a duplicate.
for (g_, a_), v in sorted(raw.items()):
ex = find_present_jar(fiji, g_, a_)
if ex is None:
if add_allows(g_, a_): # --add whitelist (new installs only)
trans_adds.append(((g_, a_), v))
elif compare_versions(v, ex[1]) > 0:
trans_upgrades.append(((g_, a_), v, ex[0], ex[1]))
# else: already present at an equal/newer version -> leave it
log(f" -> {len(trans_adds)} new + {len(trans_upgrades)} upgrade(s) "
f"(of {len(raw)} transitive candidates)\n")
# Project jars ----------------------------------------------------------- #
# tuple: (group, artifact, version, src_jar, existing_list, install_dir, dest_name, note)
project_actions = []
by_ga = {}
for jid in jars:
by_ga.setdefault(jid.ga, []).append(jid)
for repo, gco, aco in projects:
src = find_built_jar(repo)
if src is None:
resolved_note = "no target/*.jar (build it: mvn -f %s/pom.xml install)" % repo.name
project_actions.append((gco, aco, None, None, [], None, None, resolved_note))
continue
sid = read_jar_identity(src)
ver = sid.version if sid else "?"
existing = by_ga.get((gco, aco), [])
# replace an existing copy in place; otherwise route by plugins.config
install_dir = existing[0].path.parent if existing else install_dir_for(fiji, src)
# reuse the existing filename (version-swapped) so it matches apart from
# the version; otherwise derive (underscore-ify for plugins/).
dn = None