Skip to content

Commit e1a1f7f

Browse files
committed
build: install conditional dependencies
1 parent d906c3d commit e1a1f7f

File tree

4 files changed

+341
-9
lines changed

4 files changed

+341
-9
lines changed

.kokoro/build.sh

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@ fi
3636
if [[ -n "${BUILD_SUBDIR}" ]]
3737
then
3838
echo "Compiling and building all modules for ${BUILD_SUBDIR}"
39-
mvn clean install \
40-
-DskipTests \
41-
-Dclirr.skip \
42-
-Dflatten.skip \
43-
-Dcheckstyle.skip \
44-
-Djacoco.skip \
45-
-Denforcer.skip \
46-
--also-make \
47-
--projects "${BUILD_SUBDIR}"
39+
install_dependencies "${BUILD_SUBDIR}"
40+
# mvn clean install \
41+
# -DskipTests \
42+
# -Dclirr.skip \
43+
# -Dflatten.skip \
44+
# -Dcheckstyle.skip \
45+
# -Djacoco.skip \
46+
# -Denforcer.skip \
47+
# --also-make \
48+
# --projects "${BUILD_SUBDIR}"
4849
echo "Running in subdir: ${BUILD_SUBDIR}"
4950
pushd "${BUILD_SUBDIR}"
5051
fi

.kokoro/common.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,15 @@ function install_modules() {
347347
-T 1C
348348
fi
349349
}
350+
351+
scriptDir=$(realpath $(dirname "${BASH_SOURCE[0]}"))
352+
root_dir=$(realpath "$scriptDir/..")
353+
function install_dependencies() {
354+
target_module_path=$(realpath "$1")
355+
rel_target_path=$(realpath --relative-to="$root_dir" "$target_module_path")
356+
echo "Resolving dependencies for $rel_target_path..."
357+
dependencies=$(python3 "$scriptDir/determine_dependencies.py" "$1")
358+
echo "Found dependencies: $dependencies"
359+
360+
install_modules "${dependencies}"
361+
}

.kokoro/dependencies.sh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export MAVEN_OPTS=$(determineMavenOpts)
5151

5252
if [[ -n "${BUILD_SUBDIR}" ]]
5353
then
54+
echo "Compiling and building all modules for ${BUILD_SUBDIR}"
55+
install_dependencies "${BUILD_SUBDIR}"
5456
echo "Running in subdir: ${BUILD_SUBDIR}"
5557
pushd "${BUILD_SUBDIR}"
5658
fi

.kokoro/determine_dependencies.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import argparse
17+
import os
18+
import sys
19+
import xml.etree.ElementTree as ET
20+
from collections import defaultdict
21+
from typing import Dict, List, Set, Tuple
22+
23+
# Maven XML namespace
24+
NS = {"mvn": "http://maven.apache.org/POM/4.0.0"}
25+
26+
27+
class Module:
28+
def __init__(
29+
self, path: str, group_id: str, artifact_id: str, parent: Tuple[str, str] = None
30+
):
31+
self.path = path
32+
self.group_id = group_id
33+
self.artifactId = artifact_id
34+
self.parent = parent
35+
self.dependencies: Set[Tuple[str, str]] = set()
36+
37+
@property
38+
def key(self) -> Tuple[str, str]:
39+
return (self.group_id, self.artifactId)
40+
41+
def __repr__(self):
42+
return f"{self.group_id}:{self.artifactId}"
43+
44+
45+
def parse_pom(path: str) -> Module:
46+
try:
47+
tree = ET.parse(path)
48+
root = tree.getroot()
49+
except ET.ParseError as e:
50+
print(f"Error parsing {path}: {e}", file=sys.stderr)
51+
return None
52+
53+
# Handle namespace if present
54+
# XML tags in ElementTree are {namespace}tag
55+
# We'll use find with namespaces for robustness, but simple logic for extraction
56+
57+
# Helper to clean tag name
58+
def local_name(tag):
59+
if "}" in tag:
60+
return tag.split("}", 1)[1]
61+
return tag
62+
63+
parent_elem = root.find("mvn:parent", NS)
64+
parent_coords = None
65+
parent_group_id = None
66+
if parent_elem is not None:
67+
p_group = parent_elem.find("mvn:groupId", NS).text
68+
p_artifact = parent_elem.find("mvn:artifactId", NS).text
69+
parent_coords = (p_group, p_artifact)
70+
parent_group_id = p_group
71+
72+
group_id_elem = root.find("mvn:groupId", NS)
73+
# Inherit groupId from parent if not specified
74+
if group_id_elem is not None:
75+
group_id = group_id_elem.text
76+
elif parent_group_id:
77+
group_id = parent_group_id
78+
else:
79+
# Fallback or error? For now, use artifactId as heuristic or empty
80+
group_id = "unknown"
81+
82+
artifact_id = root.find("mvn:artifactId", NS).text
83+
84+
module = Module(path, group_id, artifact_id, parent_coords)
85+
86+
# Dependencies
87+
def add_dependencies(section):
88+
if section is not None:
89+
for dep in section.findall("mvn:dependency", NS):
90+
d_group = dep.find("mvn:groupId", NS)
91+
d_artifact = dep.find("mvn:artifactId", NS)
92+
if d_group is not None and d_artifact is not None:
93+
module.dependencies.add((d_group.text, d_artifact.text))
94+
95+
add_dependencies(root.find("mvn:dependencies", NS))
96+
97+
dep_mgmt = root.find("mvn:dependencyManagement", NS)
98+
if dep_mgmt is not None:
99+
add_dependencies(dep_mgmt.find("mvn:dependencies", NS))
100+
101+
# Plugin dependencies
102+
build = root.find("mvn:build", NS)
103+
if build is not None:
104+
plugins = build.find("mvn:plugins", NS)
105+
if plugins is not None:
106+
for plugin in plugins.findall("mvn:plugin", NS):
107+
# Plugin itself
108+
p_group = plugin.find("mvn:groupId", NS)
109+
p_artifact = plugin.find("mvn:artifactId", NS)
110+
if p_group is not None and p_artifact is not None:
111+
module.dependencies.add((p_group.text, p_artifact.text))
112+
113+
# Plugin dependencies
114+
add_dependencies(plugin.find("mvn:dependencies", NS))
115+
116+
# Plugin Management
117+
plugin_mgmt = build.find("mvn:pluginManagement", NS)
118+
if plugin_mgmt is not None:
119+
plugins = plugin_mgmt.find("mvn:plugins", NS)
120+
if plugins is not None:
121+
for plugin in plugins.findall("mvn:plugin", NS):
122+
# Plugin itself
123+
p_group = plugin.find("mvn:groupId", NS)
124+
p_artifact = plugin.find("mvn:artifactId", NS)
125+
if p_group is not None and p_artifact is not None:
126+
module.dependencies.add((p_group.text, p_artifact.text))
127+
128+
add_dependencies(plugin.find("mvn:dependencies", NS))
129+
130+
return module
131+
132+
133+
def find_poms(root_dir: str) -> List[str]:
134+
pom_files = []
135+
for dirpath, dirnames, filenames in os.walk(root_dir):
136+
# Skip hidden directories and known non-module dirs
137+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
138+
139+
if "pom.xml" in filenames:
140+
pom_files.append(os.path.join(dirpath, "pom.xml"))
141+
return pom_files
142+
143+
144+
def build_graph(
145+
root_dir: str,
146+
) -> Tuple[Dict[Tuple[str, str], Module], Dict[Tuple[str, str], Set[Tuple[str, str]]]]:
147+
pom_paths = find_poms(root_dir)
148+
modules: Dict[Tuple[str, str], Module] = {}
149+
150+
# First pass: load all modules
151+
for path in pom_paths:
152+
module = parse_pom(path)
153+
if module:
154+
modules[module.key] = module
155+
156+
# Build adjacency list: dependent -> dependencies (upstream)
157+
# Only include dependencies that are present in the repo
158+
graph: Dict[Tuple[str, str], Set[Tuple[str, str]]] = defaultdict(set)
159+
160+
for key, module in modules.items():
161+
# Parent dependency
162+
if module.parent and module.parent in modules:
163+
graph[key].add(module.parent)
164+
165+
# Regular dependencies
166+
for dep_key in module.dependencies:
167+
if dep_key in modules:
168+
# Prevent cycle: If I am the parent of the dependency, ignore it.
169+
# (Parent manages Child -> Child depends on Parent)
170+
dep_module = modules[dep_key]
171+
if dep_module.parent == key:
172+
continue
173+
174+
graph[key].add(dep_key)
175+
176+
return modules, graph
177+
178+
179+
def get_transitive_dependencies(
180+
start_nodes: List[Tuple[str, str]],
181+
graph: Dict[Tuple[str, str], Set[Tuple[str, str]]],
182+
) -> Set[Tuple[str, str]]:
183+
visited = set()
184+
stack = list(start_nodes)
185+
186+
while stack:
187+
node = stack.pop()
188+
if node not in visited:
189+
visited.add(node)
190+
# Add upstream dependencies to stack
191+
if node in graph:
192+
for upstream in graph[node]:
193+
if upstream not in visited:
194+
stack.append(upstream)
195+
196+
return visited
197+
198+
199+
def resolve_modules_from_inputs(
200+
inputs: List[str],
201+
modules_by_path: Dict[str, Module],
202+
modules_by_key: Dict[Tuple[str, str], Module],
203+
) -> List[Tuple[str, str]]:
204+
resolved = set()
205+
for item in inputs:
206+
# Check if item is a path
207+
abs_item = os.path.abspath(item)
208+
209+
# If it's a file, try to find the nearest pom.xml
210+
if os.path.isfile(abs_item) or (
211+
not item.endswith("pom.xml") and os.path.isdir(abs_item)
212+
):
213+
# Heuristic: if it's a file, find containing pom
214+
# if it's a dir, look for pom.xml inside or check if it matches a module path
215+
candidate_path = abs_item
216+
if os.path.isfile(candidate_path) and not candidate_path.endswith(
217+
"pom.xml"
218+
):
219+
candidate_path = os.path.dirname(candidate_path)
220+
221+
# Traverse up to find pom.xml
222+
while candidate_path.startswith(os.getcwd()) and len(candidate_path) >= len(
223+
os.getcwd()
224+
):
225+
pom_path = os.path.join(candidate_path, "pom.xml")
226+
if pom_path in modules_by_path:
227+
resolved.add(modules_by_path[pom_path].key)
228+
break
229+
candidate_path = os.path.dirname(candidate_path)
230+
elif item.endswith("pom.xml") and os.path.abspath(item) in modules_by_path:
231+
resolved.add(modules_by_path[os.path.abspath(item)].key)
232+
else:
233+
# Try to match simple name (artifactId) or groupId:artifactId
234+
found = False
235+
for key, module in modules_by_key.items():
236+
if (
237+
item == module.artifactId
238+
or item == f"{module.group_id}:{module.artifactId}"
239+
):
240+
resolved.add(key)
241+
found = True
242+
break
243+
if not found:
244+
print(
245+
f"Warning: Could not resolve input '{item}' to a module.",
246+
file=sys.stderr,
247+
)
248+
249+
return list(resolved)
250+
251+
252+
def main():
253+
parser = argparse.ArgumentParser(
254+
description="Identify upstream dependencies for partial builds."
255+
)
256+
parser.add_argument(
257+
"modules", nargs="+", help="List of modified modules or file paths"
258+
)
259+
args = parser.parse_args()
260+
261+
root_dir = os.getcwd()
262+
modules_by_key, graph = build_graph(root_dir)
263+
modules_by_path = {m.path: m for m in modules_by_key.values()}
264+
265+
start_nodes = resolve_modules_from_inputs(
266+
args.modules, modules_by_path, modules_by_key
267+
)
268+
269+
if not start_nodes:
270+
print("No valid modules found from input.", file=sys.stderr)
271+
return
272+
273+
# Get transitive upstream dependencies
274+
# We include the start nodes themselves in the output set if they are dependencies of other start nodes?
275+
# Usually we want: Dependencies of (Start Nodes) NOT INCLUDING Start Nodes themselves, unless A depends on B and both are modified.
276+
# But for "installing dependencies", we generally want EVERYTHING upstream of the modified set.
277+
# If I modified A, and A depends on B, I want to install B.
278+
# If I modified A and B, and A depends on B, I want to install B (before A).
279+
# But usually the build system will build A and B if I say "build A and B".
280+
# The request is: "determine which modules will need to be compiled and installed to the local maven repository"
281+
# This implies we want the COMPLEMENT set of the modified modules, restricted to the upstream graph.
282+
283+
all_dependencies = get_transitive_dependencies(start_nodes, graph)
284+
285+
upstream_only = all_dependencies - set(start_nodes)
286+
287+
# Topological sort for installation order
288+
# (Install dependencies before dependents)
289+
sorted_upstream = []
290+
visited_sort = set()
291+
292+
def visit(node):
293+
if node in visited_sort:
294+
return
295+
visited_sort.add(node)
296+
# Visit dependencies first
297+
if node in graph:
298+
for dep in graph[node]:
299+
if dep in upstream_only:
300+
visit(dep)
301+
302+
sorted_upstream.append(node)
303+
304+
for node in upstream_only:
305+
visit(node)
306+
307+
results = []
308+
for key in sorted_upstream:
309+
module = modules_by_key[key]
310+
rel_path = os.path.relpath(os.path.dirname(module.path), root_dir)
311+
results.append(rel_path)
312+
313+
print(",".join(results))
314+
315+
316+
if __name__ == "__main__":
317+
main()

0 commit comments

Comments
 (0)