Skip to content

Commit 1c4f8e1

Browse files
author
Tom Dyas
authored
go: resolve go_module target into dependent modules (#12394)
Go plugin updates: - Adds a `ResolvedGoModule` type that represents a `go_module` with all relevant module and external module metadata hydrated. - Adds experimental `go-resolve` goal that updates `go.sum`. Next steps will be to use this support for dependency inference of `go_package` targets to `go_external_module` targets.
1 parent 02979ce commit 1c4f8e1

File tree

9 files changed

+294
-3
lines changed

9 files changed

+294
-3
lines changed

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ module = [
5555
"freezegun",
5656
"hdrh",
5757
"hdrh.histogram",
58+
"ijson",
5859
"pex.*",
5960
"psutil",
6061
"pystache",

src/python/pants/backend/experimental/go/register.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
from pants.backend.go import build, distribution, import_analysis, target_type_rules
4+
from pants.backend.go import build, distribution, import_analysis, module, target_type_rules
55
from pants.backend.go import target_types as go_target_types
66
from pants.backend.go.target_types import GoBinary, GoExternalModule, GoModule, GoPackage
77

@@ -17,4 +17,5 @@ def rules():
1717
*import_analysis.rules(),
1818
*go_target_types.rules(),
1919
*target_type_rules.rules(),
20+
*module.rules(),
2021
]

src/python/pants/backend/go/import_analysis.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from dataclasses import dataclass
77
from typing import Dict
88

9-
import ijson # type: ignore
9+
import ijson
1010

1111
from pants.backend.go.distribution import GoLangDistribution
1212
from pants.core.util_rules.external_tool import DownloadedExternalTool, ExternalToolRequest
@@ -69,6 +69,8 @@ async def analyze_imports_for_golang_distribution(
6969
goroot.get_request(platform),
7070
)
7171

72+
# Note: The `go` tool requires GOPATH to be an absolute path which can only be resolved from within the
73+
# execution sandbox. Thus, this code uses a bash script to be able to resolve that path.
7274
analyze_script_digest = await Get(
7375
Digest,
7476
CreateDigest(

src/python/pants/backend/go/module.py

+202
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
import logging
4+
import textwrap
5+
from dataclasses import dataclass
6+
from typing import List, Optional, Tuple
7+
8+
import ijson
9+
10+
from pants.backend.go.distribution import GoLangDistribution
11+
from pants.backend.go.target_types import GoModuleSources
12+
from pants.build_graph.address import Address
13+
from pants.core.util_rules.external_tool import DownloadedExternalTool, ExternalToolRequest
14+
from pants.core.util_rules.source_files import SourceFiles, SourceFilesRequest
15+
from pants.engine.addresses import Addresses
16+
from pants.engine.fs import (
17+
CreateDigest,
18+
Digest,
19+
DigestContents,
20+
FileContent,
21+
MergeDigests,
22+
RemovePrefix,
23+
Snapshot,
24+
Workspace,
25+
)
26+
from pants.engine.goal import Goal, GoalSubsystem
27+
from pants.engine.internals.selectors import Get
28+
from pants.engine.platform import Platform
29+
from pants.engine.process import BashBinary, Process, ProcessResult
30+
from pants.engine.rules import collect_rules, goal_rule, rule
31+
from pants.engine.target import UnexpandedTargets
32+
from pants.util.logging import LogLevel
33+
from pants.util.ordered_set import FrozenOrderedSet
34+
35+
_logger = logging.getLogger(__name__)
36+
37+
38+
@dataclass(frozen=True)
39+
class ModuleDescriptor:
40+
import_path: str
41+
module_path: str
42+
module_version: str
43+
44+
45+
@dataclass(frozen=True)
46+
class ResolvedGoModule:
47+
import_path: str
48+
minimum_go_version: Optional[str]
49+
modules: FrozenOrderedSet[ModuleDescriptor]
50+
digest: Digest
51+
52+
53+
@dataclass(frozen=True)
54+
class ResolveGoModuleRequest:
55+
address: Address
56+
57+
58+
# Perform a minimal parsing of go.mod for the `module` and `go` directives. Full resolution of go.mod is left to
59+
# the go toolchain. This could also probably be replaced by a go shim to make use of:
60+
# https://pkg.go.dev/golang.org/x/mod/modfile
61+
# TODO: Add full path to expections for applicable go.mod.
62+
def basic_parse_go_mod(raw_text: bytes) -> Tuple[Optional[str], Optional[str]]:
63+
module_path = None
64+
minimum_go_version = None
65+
for line in raw_text.decode("utf-8").splitlines():
66+
parts = line.strip().split()
67+
if len(parts) >= 2:
68+
if parts[0] == "module":
69+
if module_path is not None:
70+
raise ValueError("Multiple `module` directives found in go.mod file.")
71+
module_path = parts[1]
72+
elif parts[0] == "go":
73+
if minimum_go_version is not None:
74+
raise ValueError("Multiple `go` directives found in go.mod file.")
75+
minimum_go_version = parts[1]
76+
return module_path, minimum_go_version
77+
78+
79+
# Parse the output of `go mod download` into a list of module descriptors.
80+
def parse_module_descriptors(raw_json: bytes) -> List[ModuleDescriptor]:
81+
module_descriptors = []
82+
for raw_module_descriptor in ijson.items(raw_json, "", multiple_values=True):
83+
module_descriptor = ModuleDescriptor(
84+
import_path=raw_module_descriptor["Path"],
85+
module_path=raw_module_descriptor["Path"],
86+
module_version=raw_module_descriptor["Version"],
87+
)
88+
module_descriptors.append(module_descriptor)
89+
return module_descriptors
90+
91+
92+
@rule
93+
async def resolve_go_module(
94+
request: ResolveGoModuleRequest,
95+
goroot: GoLangDistribution,
96+
platform: Platform,
97+
bash: BashBinary,
98+
) -> ResolvedGoModule:
99+
downloaded_goroot = await Get(
100+
DownloadedExternalTool,
101+
ExternalToolRequest,
102+
goroot.get_request(platform),
103+
)
104+
105+
targets = await Get(UnexpandedTargets, Addresses([request.address]))
106+
if not targets:
107+
raise AssertionError(f"Address `{request.address}` did not resolve to any targets.")
108+
elif len(targets) > 1:
109+
raise AssertionError(f"Address `{request.address}` resolved to multiple targets.")
110+
target = targets[0]
111+
112+
sources = await Get(SourceFiles, SourceFilesRequest([target.get(GoModuleSources)]))
113+
flattened_sources_snapshot = await Get(
114+
Snapshot, RemovePrefix(sources.snapshot.digest, request.address.spec_path)
115+
)
116+
117+
# Note: The `go` tool requires GOPATH to be an absolute path which can only be resolved from within the
118+
# execution sandbox. Thus, this code uses a bash script to be able to resolve that path.
119+
analyze_script_digest = await Get(
120+
Digest,
121+
CreateDigest(
122+
[
123+
FileContent(
124+
"analyze.sh",
125+
textwrap.dedent(
126+
"""\
127+
export GOROOT="./go"
128+
export GOPATH="$(/bin/pwd)/gopath"
129+
export GOCACHE="$(/bin/pwd)/cache"
130+
mkdir -p "$GOPATH" "$GOCACHE"
131+
exec ./go/bin/go mod download -json all
132+
"""
133+
).encode("utf-8"),
134+
)
135+
]
136+
),
137+
)
138+
139+
input_root_digest = await Get(
140+
Digest,
141+
MergeDigests(
142+
[flattened_sources_snapshot.digest, downloaded_goroot.digest, analyze_script_digest]
143+
),
144+
)
145+
146+
process = Process(
147+
argv=[bash.path, "./analyze.sh"],
148+
input_digest=input_root_digest,
149+
description="Resolve go_module metadata.",
150+
output_files=["go.mod", "go.sum"],
151+
level=LogLevel.DEBUG,
152+
)
153+
154+
result = await Get(ProcessResult, Process, process)
155+
156+
# Parse the go.mod for the module path and minimum Go version.
157+
module_path = None
158+
minimum_go_version = None
159+
digest_contents = await Get(DigestContents, Digest, flattened_sources_snapshot.digest)
160+
for entry in digest_contents:
161+
if entry.path == "go.mod":
162+
module_path, minimum_go_version = basic_parse_go_mod(entry.content)
163+
164+
if module_path is None:
165+
raise ValueError("No `module` directive found in go.mod.")
166+
167+
return ResolvedGoModule(
168+
import_path=module_path,
169+
minimum_go_version=minimum_go_version,
170+
modules=FrozenOrderedSet(parse_module_descriptors(result.stdout)),
171+
digest=result.output_digest,
172+
)
173+
174+
175+
# TODO: Add integration tests for the `go-resolve` goal once we figure out its final form. For now, it is a debug
176+
# tool to help update go.sum while developing the Go plugin and will probably change.
177+
class GoResolveSubsystem(GoalSubsystem):
178+
name = "go-resolve"
179+
help = "Resolve a Go module's go.mod and update go.sum accordingly."
180+
181+
182+
class GoResolveGoal(Goal):
183+
subsystem_cls = GoResolveSubsystem
184+
185+
186+
@goal_rule
187+
async def run_go_resolve(targets: UnexpandedTargets, workspace: Workspace) -> GoResolveGoal:
188+
# TODO: Use MultiGet to resolve the go_module targets.
189+
# TODO: Combine all of the go.sum's into a single Digest to write.
190+
for target in targets:
191+
if target.has_field(GoModuleSources) and not target.address.is_file_target:
192+
resolved_go_module = await Get(ResolvedGoModule, ResolveGoModuleRequest(target.address))
193+
# TODO: Only update the files if they actually changed.
194+
workspace.write_digest(resolved_go_module.digest, path_prefix=target.address.spec_path)
195+
_logger.info(f"{target.address}: Updated go.mod and go.sum.\n")
196+
else:
197+
_logger.info(f"{target.address}: Skipping because target is not a `go_module`.\n")
198+
return GoResolveGoal(exit_code=0)
199+
200+
201+
def rules():
202+
return collect_rules()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
import pytest
4+
5+
from pants.backend.go import module
6+
from pants.backend.go.module import ResolvedGoModule, ResolveGoModuleRequest
7+
from pants.backend.go.target_types import GoExternalModule, GoModule, GoPackage
8+
from pants.build_graph.address import Address
9+
from pants.core.util_rules import external_tool, source_files
10+
from pants.engine.rules import QueryRule
11+
from pants.testutil.rule_runner import RuleRunner
12+
13+
14+
@pytest.fixture
15+
def rule_runner() -> RuleRunner:
16+
rule_runner = RuleRunner(
17+
rules=[
18+
*external_tool.rules(),
19+
*source_files.rules(),
20+
*module.rules(),
21+
QueryRule(ResolvedGoModule, [ResolveGoModuleRequest]),
22+
],
23+
target_types=[GoPackage, GoModule, GoExternalModule],
24+
)
25+
rule_runner.set_options(["--backend-packages=pants.backend.experimental.go"])
26+
return rule_runner
27+
28+
29+
def test_basic_parse_go_mod() -> None:
30+
content = b"module go.example.com/foo\ngo 1.16\nrequire github.com/golang/protobuf v1.4.2\n"
31+
module_path, minimum_go_version = module.basic_parse_go_mod(content)
32+
assert module_path == "go.example.com/foo"
33+
assert minimum_go_version == "1.16"
34+
35+
36+
def test_resolve_go_module(rule_runner: RuleRunner) -> None:
37+
rule_runner.add_to_build_file("foo", "go_module(name='mod')\ngo_package(name='pkg')\n")
38+
rule_runner.write_files(
39+
{
40+
"foo/pkg/foo.go": "package pkg\n",
41+
"foo/go.mod": "module go.example.com/foo\ngo 1.16\nrequire github.com/golang/protobuf v1.4.2\n",
42+
"foo/go.sum": "",
43+
"foo/main.go": "package main\nfunc main() { }\n",
44+
}
45+
)
46+
resolved_go_module = rule_runner.request(
47+
ResolvedGoModule, [ResolveGoModuleRequest(Address("foo", target_name="mod"))]
48+
)
49+
assert resolved_go_module.import_path == "go.example.com/foo"
50+
assert resolved_go_module.minimum_go_version == "1.16"
51+
assert len(resolved_go_module.modules) > 0
52+
found_protobuf_module = False
53+
for module_descriptor in resolved_go_module.modules:
54+
if module_descriptor.module_path == "github.com/golang/protobuf":
55+
found_protobuf_module = True
56+
assert found_protobuf_module

src/python/pants/backend/go/target_types.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
import os
4+
from typing import Sequence
5+
36
from pants.engine.rules import collect_rules
4-
from pants.engine.target import COMMON_TARGET_FIELDS, Dependencies, Sources, StringField, Target
7+
from pants.engine.target import (
8+
COMMON_TARGET_FIELDS,
9+
Dependencies,
10+
InvalidFieldException,
11+
Sources,
12+
StringField,
13+
Target,
14+
)
515

616

717
class GoSources(Sources):
@@ -34,6 +44,12 @@ class GoPackage(Target):
3444
class GoModuleSources(Sources):
3545
alias = "_sources"
3646
default = ("go.mod", "go.sum")
47+
expected_num_files = range(1, 3)
48+
49+
def validate_resolved_files(self, files: Sequence[str]) -> None:
50+
super().validate_resolved_files(files)
51+
if "go.mod" not in [os.path.basename(f) for f in files]:
52+
raise InvalidFieldException(f"""No go.mod file was found for target {self.address}.""")
3753

3854

3955
class GoModule(Target):

testprojects/src/go/BUILD

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2+
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3+
go_module()
4+

testprojects/src/go/go.mod

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/pantsbuild/pants/testprojects/src/go
2+
3+
go 1.16
4+
5+
require (
6+
github.com/google/uuid v1.2.0
7+
)

testprojects/src/go/go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
2+
github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

0 commit comments

Comments
 (0)