Skip to content

Commit 4402353

Browse files
ctruedenclaude
andcommitted
Add smelt support for multi-module projects
Specifically, detect when a particular component does not reside in the base directory of the repository, but rather a subdirectory thereof, and act accordingly to build it from there when smelting. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 279e00b commit 4402353

4 files changed

Lines changed: 224 additions & 15 deletions

File tree

‎src/pombast/core/_pipeline.py‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
write_dependency_tree_log,
2626
)
2727
from pombast.maven._pom_rewriter import patch_pom_urls, rewrite_pom_versions
28+
from pombast.maven._reactor import locate_module_dir
2829
from pombast.maven._scm import _guess_tag, resolve_scm
2930
from pombast.util._git import shallow_clone
3031

@@ -245,6 +246,7 @@ def run(self) -> ValidationReport:
245246
# which would otherwise cause pombast to reuse an old clone even after a
246247
# version bump in the BOM.
247248
sentinel = f"{component.version}:{tag}"
249+
fresh_clone = False
248250
if tag_file.exists() and tag_file.read_text().strip() == sentinel:
249251
_log.info("%s: reusing existing clone at %s", component.coordinate, tag)
250252
else:
@@ -277,20 +279,52 @@ def run(self) -> ValidationReport:
277279
)
278280
)
279281
continue
282+
fresh_clone = True
283+
284+
# Locate the module subdirectory that builds this component. For a
285+
# multi-module project (e.g. BoneJ2, scijava/scijava) the BOM manages
286+
# each reactor module as its own component; pombast builds in the
287+
# matching subdirectory and lets sibling modules resolve from the
288+
# repository as ordinary released dependencies. For a single-module
289+
# project this is the clone root itself.
290+
build_dir = locate_module_dir(source_dir, component)
291+
if build_dir is None:
292+
_log.warning(
293+
"%s: no module POM matched in checkout — using clone root",
294+
component.coordinate,
295+
)
296+
build_dir = source_dir
297+
elif build_dir != source_dir:
298+
_log.info(
299+
"%s: building module subdir %s",
300+
component.coordinate,
301+
build_dir.relative_to(source_dir),
302+
)
280303

281-
# Patch and rewrite POM only for a fresh clone; re-applying to an
282-
# already-patched POM would corrupt the pinned versions.
283-
pom_file = source_dir / "pom.xml"
284-
if pom_file.exists():
285-
patch_pom_urls(pom_file)
286-
rewrite_pom_versions(pom_file, dep_mgmt)
304+
if fresh_clone:
305+
# Patch and rewrite POMs only for a fresh clone; re-applying to an
306+
# already-patched POM would corrupt the pinned versions. Upgrade
307+
# http→https across every POM in the checkout so inherited
308+
# repository URLs in parent/aggregator POMs don't break the build,
309+
# but pin versions only in this module's own POM — its own
310+
# dependencyManagement takes precedence over anything inherited,
311+
# so that is sufficient even when the module's parent lies outside
312+
# the reactor (as with scijava/scijava's pom-scijava-parented
313+
# modules).
314+
for pom in source_dir.rglob("pom.xml"):
315+
if "target" in pom.relative_to(source_dir).parts:
316+
continue
317+
patch_pom_urls(pom)
318+
module_pom = build_dir / "pom.xml"
319+
if module_pom.exists():
320+
rewrite_pom_versions(module_pom, dep_mgmt)
287321

288322
tag_file.write_text(sentinel + "\n")
289323

290324
# Determine the build JDK from the rewritten (BOM-pinned) POM, so jgo
291325
# resolves exactly the dependency set Maven will build. A per-component
292326
# override wins; otherwise use the detected version, then the default.
293-
analysis = analyze_build_java(component, ctx, source_dir / "pom.xml")
327+
analysis = analyze_build_java(component, ctx, build_dir / "pom.xml")
294328
comp_override = self.config.config.component_overrides.get(component.ga)
295329
if comp_override and "java-version" in comp_override:
296330
java_ver = comp_override["java-version"]
@@ -316,12 +350,14 @@ def run(self) -> ValidationReport:
316350
# Record the resolved dependency tree (with Java-version rationale) next
317351
# to the build logs, mirroring mega-melt's dependency-tree.log.
318352
write_dependency_tree_log(
319-
analysis, component, source_dir / "dependency-tree.log"
353+
analysis, component, build_dir / "dependency-tree.log"
320354
)
321355

322356
# Build and test. The resolved closure from Java-version analysis
323357
# doubles as the success-cache key, so no second resolution is needed.
324-
source = ComponentSource(component=component, source_dir=source_dir)
358+
source = ComponentSource(
359+
component=component, source_dir=source_dir, build_dir=build_dir
360+
)
325361
result = builder.build_and_test(
326362
source, closure=analysis.closure, extra_properties=comp_properties
327363
)

‎src/pombast/maven/_builder.py‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,23 @@
2424

2525
@dataclass
2626
class ComponentSource:
27-
"""A checked-out source tree for a component."""
27+
"""A checked-out source tree for a component.
28+
29+
``source_dir`` is the repository checkout root. ``build_dir`` is the module
30+
subdirectory whose POM builds this component — they differ only for a
31+
multi-module project, where pombast builds in the matching subdirectory
32+
rather than from the reactor root. When ``build_dir`` is None it defaults to
33+
``source_dir`` (the single-module case).
34+
"""
2835

2936
component: Component
3037
source_dir: Path
38+
build_dir: Path | None = None
39+
40+
@property
41+
def work_dir(self) -> Path:
42+
"""The directory to build, test, and write per-component logs in."""
43+
return self.build_dir or self.source_dir
3144

3245

3346
class MavenComponentBuilder:
@@ -80,7 +93,7 @@ def build_and_test(
8093
BuildResult with status, log path, and duration.
8194
"""
8295
component = source.component
83-
log_dir = self.output_dir / component.group / component.name
96+
log_dir = source.work_dir
8497

8598
merged = {**self.extra_properties, **(extra_properties or {})}
8699

@@ -105,7 +118,7 @@ def build_and_test(
105118
try:
106119
result = run_maven(
107120
["clean", "test"],
108-
cwd=source.source_dir,
121+
cwd=source.work_dir,
109122
java_home=java_home,
110123
extra_properties=merged,
111124
log_path=source_log_path,
@@ -184,7 +197,7 @@ def _test_binary(
184197
# Step 3: Unpack JAR into target/classes.
185198
# Wipe the entire target/ dir first — generated sources, test-classes,
186199
# or other artifacts from a prior tag would otherwise linger.
187-
target_dir = source.source_dir / "target"
200+
target_dir = source.work_dir / "target"
188201
if target_dir.exists():
189202
shutil.rmtree(target_dir)
190203
classes_dir = target_dir / "classes"
@@ -197,7 +210,7 @@ def _test_binary(
197210
# -Dmaven.resources.skip=true skips resource processing
198211
test_result = run_maven(
199212
["test"],
200-
cwd=source.source_dir,
213+
cwd=source.work_dir,
201214
java_home=java_home,
202215
extra_properties={
203216
**(extra_properties or self.extra_properties),
@@ -279,7 +292,7 @@ def _write_smelt_script(
279292
]
280293
lines += ['exec mvn "${MVN_ARGS[@]}" clean test', ""]
281294

282-
script_path = source.source_dir / "smelt.sh"
295+
script_path = source.work_dir / "smelt.sh"
283296
try:
284297
script_path.write_text("\n".join(lines))
285298
script_path.chmod(0o755)

‎src/pombast/maven/_reactor.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Locate the module subdirectory that builds a given component.
2+
3+
A multi-module Maven project is one checkout that produces several published
4+
artifacts, each managed separately in a BOM. pombast clones such a repo per
5+
component (the clones are cheap and independent) and builds, tests, and pins
6+
each component *in its own module subdirectory* rather than from the reactor
7+
root. Sibling modules resolve from the repository as ordinary released
8+
dependencies, so no whole-reactor build is required. This module maps a
9+
component's G:A coordinate to the directory whose POM builds it.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import xml.etree.ElementTree as ET
16+
from typing import TYPE_CHECKING
17+
18+
if TYPE_CHECKING:
19+
from pathlib import Path
20+
21+
from pombast.core._component import Component
22+
23+
_log = logging.getLogger(__name__)
24+
25+
# Maven POM namespace.
26+
_NS = "http://maven.apache.org/POM/4.0.0"
27+
28+
29+
def _pom_ga(pom_path: Path) -> tuple[str, str] | None:
30+
"""Return the effective (groupId, artifactId) declared by a POM.
31+
32+
The groupId is frequently inherited rather than declared, so fall back to
33+
``<parent><groupId>`` when the module omits its own — true for every
34+
pom-scijava-parented module (groupId ``org.scijava`` comes from the parent).
35+
Returns None if the file cannot be parsed or lacks an artifactId.
36+
"""
37+
try:
38+
root = ET.parse(pom_path).getroot()
39+
except ET.ParseError:
40+
return None
41+
42+
artifact = root.find(f"{{{_NS}}}artifactId")
43+
if artifact is None or not artifact.text:
44+
return None
45+
46+
group = root.find(f"{{{_NS}}}groupId")
47+
if group is None or not group.text:
48+
parent = root.find(f"{{{_NS}}}parent")
49+
if parent is not None:
50+
group = parent.find(f"{{{_NS}}}groupId")
51+
if group is None or not group.text:
52+
return None
53+
54+
return group.text.strip(), artifact.text.strip()
55+
56+
57+
def locate_module_dir(repo_root: Path, component: Component) -> Path | None:
58+
"""Return the directory whose POM builds ``component``'s artifact.
59+
60+
Scans every ``pom.xml`` in the checkout (skipping build output under
61+
``target/``), matching on the effective groupId:artifactId. For a
62+
single-module project this is the repo root itself, which is checked first
63+
so the common case short-circuits. Returns None when no POM matches, leaving
64+
the caller to fall back to the repo root.
65+
"""
66+
target = (component.group, component.name)
67+
68+
root_pom = repo_root / "pom.xml"
69+
if root_pom.exists() and _pom_ga(root_pom) == target:
70+
return repo_root
71+
72+
for pom in sorted(repo_root.rglob("pom.xml")):
73+
if "target" in pom.relative_to(repo_root).parts:
74+
continue
75+
if _pom_ga(pom) == target:
76+
return pom.parent
77+
78+
return None

‎tests/unit/test_reactor.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Tests for locating the module subdirectory that builds a component."""
2+
3+
from pathlib import Path
4+
5+
from pombast.core._component import Component
6+
from pombast.maven._reactor import locate_module_dir
7+
8+
NS = "http://maven.apache.org/POM/4.0.0"
9+
10+
11+
def _pom(group: str | None, artifact: str, parent_group: str | None = None) -> str:
12+
group_elem = f" <groupId>{group}</groupId>\n" if group else ""
13+
parent = (
14+
f" <parent>\n <groupId>{parent_group}</groupId>\n"
15+
f" <artifactId>some-parent</artifactId>\n"
16+
f" <version>1.0.0</version>\n </parent>\n"
17+
if parent_group
18+
else ""
19+
)
20+
return (
21+
f'<?xml version="1.0" encoding="UTF-8"?>\n'
22+
f'<project xmlns="{NS}">\n'
23+
f" <modelVersion>4.0.0</modelVersion>\n"
24+
f"{parent}{group_elem}"
25+
f" <artifactId>{artifact}</artifactId>\n"
26+
f" <version>1.0.0</version>\n"
27+
f"</project>\n"
28+
)
29+
30+
31+
def _write(path: Path, text: str) -> None:
32+
path.parent.mkdir(parents=True, exist_ok=True)
33+
path.write_text(text)
34+
35+
36+
def test_single_module_returns_root(tmp_path: Path) -> None:
37+
_write(tmp_path / "pom.xml", _pom("org.example", "my-app"))
38+
component = Component(group="org.example", name="my-app", version="1.0.0")
39+
assert locate_module_dir(tmp_path, component) == tmp_path
40+
41+
42+
def test_nested_module_matched(tmp_path: Path) -> None:
43+
# Aggregator root with a different artifactId than any managed component.
44+
_write(tmp_path / "pom.xml", _pom("org.bonej", "pom-bonej"))
45+
_write(tmp_path / "Modern" / "ops" / "pom.xml", _pom("org.bonej", "bonej-ops"))
46+
_write(
47+
tmp_path / "Modern" / "wrapperPlugins" / "pom.xml",
48+
_pom("org.bonej", "bonej-plugins"),
49+
)
50+
component = Component(group="org.bonej", name="bonej-plugins", version="7.2.0")
51+
assert (
52+
locate_module_dir(tmp_path, component)
53+
== tmp_path / "Modern" / "wrapperPlugins"
54+
)
55+
56+
57+
def test_group_inherited_from_parent(tmp_path: Path) -> None:
58+
# scijava/scijava topology: module omits groupId; parent is pom-scijava.
59+
_write(tmp_path / "pom.xml", _pom("org.scijava", "scijava-aggregator"))
60+
_write(
61+
tmp_path / "scijava-common3" / "pom.xml",
62+
_pom(None, "scijava-common3", parent_group="org.scijava"),
63+
)
64+
component = Component(group="org.scijava", name="scijava-common3", version="1.0.0")
65+
assert locate_module_dir(tmp_path, component) == tmp_path / "scijava-common3"
66+
67+
68+
def test_no_match_returns_none(tmp_path: Path) -> None:
69+
_write(tmp_path / "pom.xml", _pom("org.example", "my-app"))
70+
component = Component(group="org.example", name="absent", version="1.0.0")
71+
assert locate_module_dir(tmp_path, component) is None
72+
73+
74+
def test_target_dirs_skipped(tmp_path: Path) -> None:
75+
_write(tmp_path / "pom.xml", _pom("org.example", "aggregator"))
76+
_write(tmp_path / "mod" / "pom.xml", _pom("org.example", "real"))
77+
# A stray POM under target/ must never win.
78+
_write(
79+
tmp_path / "mod" / "target" / "pom.xml", _pom("org.example", "real")
80+
)
81+
component = Component(group="org.example", name="real", version="1.0.0")
82+
assert locate_module_dir(tmp_path, component) == tmp_path / "mod"

0 commit comments

Comments
 (0)