• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pantsbuild / pants / 22285099215

22 Feb 2026 08:52PM UTC coverage: 75.854% (-17.1%) from 92.936%
22285099215

Pull #23121

github

web-flow
Merge c7299df9c into ba8359840
Pull Request #23121: fix issue with optional fields in dependency validator

28 of 29 new or added lines in 2 files covered. (96.55%)

11174 existing lines in 400 files now uncovered.

53694 of 70786 relevant lines covered (75.85%)

1.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.97
/src/python/pants/backend/terraform/dependency_inference.py
1
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
2
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3
from __future__ import annotations
2✔
4

5
from collections.abc import Iterable, Sequence
2✔
6
from dataclasses import dataclass
2✔
7
from pathlib import PurePath
2✔
8

9
from pants.backend.python.subsystems.python_tool_base import PythonToolRequirementsBase
2✔
10
from pants.backend.python.target_types import EntryPoint
2✔
11
from pants.backend.python.util_rules.pex import (
2✔
12
    VenvPex,
13
    VenvPexProcess,
14
    create_venv_pex,
15
    setup_venv_pex_process,
16
)
17
from pants.backend.python.util_rules.pex import rules as pex_rules
2✔
18
from pants.backend.terraform.target_types import (
2✔
19
    TerraformBackendTarget,
20
    TerraformDependenciesField,
21
    TerraformDeploymentFieldSet,
22
    TerraformLockfileTarget,
23
    TerraformModuleSourcesField,
24
    TerraformVarFileTarget,
25
)
26
from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
2✔
27
from pants.base.specs import DirGlobSpec, DirLiteralSpec, RawSpecs
2✔
28
from pants.core.target_types import LockfileTarget
2✔
29
from pants.engine.addresses import Addresses
2✔
30
from pants.engine.fs import CreateDigest, Digest, FileContent
2✔
31
from pants.engine.internals.build_files import resolve_address
2✔
32
from pants.engine.internals.graph import (
2✔
33
    determine_explicitly_provided_dependencies,
34
    hydrate_sources,
35
    resolve_targets,
36
)
37
from pants.engine.internals.native_engine import Address, AddressInput
2✔
38
from pants.engine.internals.selectors import concurrently
2✔
39
from pants.engine.intrinsics import create_digest
2✔
40
from pants.engine.process import Process, execute_process_or_raise
2✔
41
from pants.engine.rules import collect_rules, implicitly, rule
2✔
42
from pants.engine.target import (
2✔
43
    DependenciesRequest,
44
    FieldSet,
45
    HydrateSourcesRequest,
46
    InferDependenciesRequest,
47
    InferredDependencies,
48
    Target,
49
)
50
from pants.engine.unions import UnionRule
2✔
51
from pants.util.dirutil import group_by_dir
2✔
52
from pants.util.logging import LogLevel
2✔
53
from pants.util.ordered_set import OrderedSet
2✔
54
from pants.util.resources import read_resource
2✔
55
from pants.util.strutil import bullet_list, softwrap
2✔
56

57

58
class TerraformHcl2Parser(PythonToolRequirementsBase):
2✔
59
    options_scope = "terraform-hcl2-parser"
2✔
60
    help_short = "Used to parse Terraform modules to infer their dependencies."
2✔
61

62
    # versions 4.3.2+ have parsing issues; bump once resolved
63
    default_requirements = ["python-hcl2>=3.0.5,<=4.3.0"]
2✔
64

65
    register_interpreter_constraints = True
2✔
66

67
    default_lockfile_resource = ("pants.backend.terraform", "hcl2.lock")
2✔
68

69

70
@dataclass(frozen=True)
2✔
71
class ParserSetup:
2✔
72
    pex: VenvPex
2✔
73

74

75
@rule
2✔
76
async def setup_parser(hcl2_parser: TerraformHcl2Parser) -> ParserSetup:
2✔
77
    parser_script_content = read_resource("pants.backend.terraform", "hcl2_parser.py")
2✔
78
    if not parser_script_content:
2✔
79
        raise ValueError("Unable to find source to hcl2_parser.py wrapper script.")
×
80

81
    parser_content = FileContent(
2✔
82
        path="__pants_tf_parser.py", content=parser_script_content, is_executable=True
83
    )
84
    parser_digest = await create_digest(CreateDigest([parser_content]))
2✔
85

86
    parser_pex = await create_venv_pex(
2✔
87
        **implicitly(
88
            hcl2_parser.to_pex_request(
89
                main=EntryPoint(PurePath(parser_content.path).stem), sources=parser_digest
90
            )
91
        )
92
    )
93
    return ParserSetup(parser_pex)
2✔
94

95

96
@dataclass(frozen=True)
2✔
97
class ParseTerraformModuleSources:
2✔
98
    sources_digest: Digest
2✔
99
    paths: tuple[str, ...]
2✔
100

101

102
@rule
2✔
103
async def setup_process_for_parse_terraform_module_sources(
2✔
104
    request: ParseTerraformModuleSources, parser: ParserSetup
105
) -> Process:
106
    dir_paths = ", ".join(sorted(group_by_dir(request.paths).keys()))
2✔
107

108
    process = await setup_venv_pex_process(
2✔
109
        VenvPexProcess(
110
            parser.pex,
111
            argv=request.paths,
112
            input_digest=request.sources_digest,
113
            description=f"Parse Terraform module sources: {dir_paths}",
114
            level=LogLevel.DEBUG,
115
        ),
116
        **implicitly(),
117
    )
118
    return process
2✔
119

120

121
@dataclass(frozen=True)
2✔
122
class TerraformModuleDependenciesInferenceFieldSet(FieldSet):
2✔
123
    required_fields = (TerraformModuleSourcesField, TerraformDependenciesField)
2✔
124

125
    sources: TerraformModuleSourcesField
2✔
126
    dependencies: TerraformDependenciesField
2✔
127

128

129
class InferTerraformModuleDependenciesRequest(InferDependenciesRequest):
2✔
130
    infer_from = TerraformModuleDependenciesInferenceFieldSet
2✔
131

132

133
@dataclass(frozen=True)
2✔
134
class TerraformDeploymentDependenciesInferenceFieldSet(TerraformDeploymentFieldSet):
2✔
135
    pass
2✔
136

137

138
class InferTerraformDeploymentDependenciesRequest(InferDependenciesRequest):
2✔
139
    infer_from = TerraformDeploymentDependenciesInferenceFieldSet
2✔
140

141

142
def find_targets_of_type(tgts, of_type) -> tuple:
2✔
143
    if tgts:
1✔
144
        return tuple(e for e in tgts if isinstance(e, of_type))
1✔
145
    else:
146
        return ()
1✔
147

148

149
@dataclass(frozen=True)
2✔
150
class TerraformDeploymentInvocationFilesRequest:
2✔
151
    """TODO: is there a way to convert between FS? We could convert the inference FS to the deployment FS itself"""
152

153
    address: Address
2✔
154
    dependencies: TerraformDependenciesField
2✔
155

156

157
@dataclass(frozen=True)
2✔
158
class TerraformDeploymentInvocationFiles:
2✔
159
    """The files passed in to the invocation of `terraform`"""
160

161
    backend_configs: tuple[TerraformBackendTarget, ...]
2✔
162
    vars_files: tuple[TerraformVarFileTarget, ...]
2✔
163
    lockfile: LockfileTarget | None
2✔
164

165

166
@rule
2✔
167
async def get_terraform_backend_and_vars(
2✔
168
    field_set: TerraformDeploymentInvocationFilesRequest,
169
) -> TerraformDeploymentInvocationFiles:
170
    this_address = field_set.address
1✔
171

172
    explicit_deps = await determine_explicitly_provided_dependencies(
1✔
173
        **implicitly(DependenciesRequest(field_set.dependencies))
174
    )
175
    tgts_in_dir, explicit_deps_tgt = await concurrently(
1✔
176
        resolve_targets(
177
            **implicitly(
178
                RawSpecs(
179
                    description_of_origin="terraform infer deployment dependencies",
180
                    dir_literals=(DirLiteralSpec(this_address.spec_path),),
181
                )
182
            )
183
        ),
184
        resolve_targets(**implicitly(Addresses(explicit_deps.includes))),
185
    )
186
    return identify_terraform_backend_and_vars(explicit_deps_tgt, tgts_in_dir)
1✔
187

188

189
class InvalidLockfileException(Exception):
2✔
190
    @classmethod
2✔
191
    def too_many_lockfiles(
2✔
192
        cls, lockfiles: Iterable[TerraformLockfileTarget]
193
    ) -> InvalidLockfileException:
194
        addresses = sorted(tgt.address.spec for tgt in lockfiles)
×
195
        return cls(
×
196
            softwrap(
197
                f"""\
198
                A Terraform deployment has {len(addresses)} lockfiles supplied:
199
                {bullet_list(addresses)}
200
                Terraform requires at most 1 lockfile; it must be called `.terraform.lock.hcl`;
201
                and it must be in the same directory as the root module.
202

203
                Pants generates targets for Terraform lockfiles automatically.
204
                If you manually added `{TerraformLockfileTarget.alias}` targets, removing them should resolve this error.
205
                If you have not, please report this as a bug.
206
                """
207
            )
208
        )
209

210

211
def identify_terraform_backend_and_vars(
2✔
212
    explicit_deps: Sequence[Target], tgts_in_dir: Sequence[Target]
213
) -> TerraformDeploymentInvocationFiles:
214
    has_explicit_backend = find_targets_of_type(explicit_deps, TerraformBackendTarget)
1✔
215
    if not has_explicit_backend:
1✔
216
        # Note: Terraform does not support multiple backends, but dep inference isn't the place to enforce that
217
        backend_targets = find_targets_of_type(tgts_in_dir, TerraformBackendTarget)
1✔
218
    else:
UNCOV
219
        backend_targets = has_explicit_backend
×
220

221
    has_explicit_var = find_targets_of_type(explicit_deps, TerraformVarFileTarget)
1✔
222
    if not has_explicit_var:
1✔
223
        vars_targets = find_targets_of_type(tgts_in_dir, TerraformVarFileTarget)
1✔
224
    else:
UNCOV
225
        vars_targets = has_explicit_var
×
226

227
    lockfiles = find_targets_of_type(tgts_in_dir, TerraformLockfileTarget)
1✔
228
    if len(lockfiles) == 1:
1✔
UNCOV
229
        lockfile = lockfiles[0]
×
230
    elif len(lockfiles) > 1:
1✔
231
        # Unlikely, since we generate them based on a constant filename.
232
        # Indicates manual specification of targets
233
        raise InvalidLockfileException.too_many_lockfiles(lockfiles)
×
234
    else:
235
        lockfile = None
1✔
236

237
    return TerraformDeploymentInvocationFiles(backend_targets, vars_targets, lockfile)
1✔
238

239

240
async def _infer_dependencies_from_sources(
2✔
241
    request: InferTerraformModuleDependenciesRequest,
242
) -> list[Address]:
243
    """Parse the source code for references to other modules."""
244
    hydrated_sources = await hydrate_sources(
1✔
245
        HydrateSourcesRequest(request.field_set.sources), **implicitly()
246
    )
247
    paths = OrderedSet(
1✔
248
        filename for filename in hydrated_sources.snapshot.files if filename.endswith(".tf")
249
    )
250
    result = await execute_process_or_raise(
1✔
251
        **implicitly(
252
            ParseTerraformModuleSources(
253
                sources_digest=hydrated_sources.snapshot.digest,
254
                paths=tuple(paths),
255
            )
256
        )
257
    )
258
    candidate_spec_paths = [line for line in result.stdout.decode("utf-8").split("\n") if line]
1✔
259
    # For each path, see if there is a `terraform_module` target at the specified spec_path.
260
    candidate_targets = await resolve_targets(
1✔
261
        **implicitly(
262
            RawSpecs(
263
                dir_globs=tuple(DirGlobSpec(path) for path in candidate_spec_paths),
264
                unmatched_glob_behavior=GlobMatchErrorBehavior.ignore,
265
                description_of_origin="the `terraform_module` dependency inference rule",
266
            )
267
        )
268
    )
269
    # TODO: Need to either implement the standard ambiguous dependency logic or ban >1 terraform_module
270
    # per directory.
271
    terraform_module_addresses = [
1✔
272
        tgt.address for tgt in candidate_targets if tgt.has_field(TerraformModuleSourcesField)
273
    ]
274
    return terraform_module_addresses
1✔
275

276

277
async def _infer_lockfile(request: InferTerraformModuleDependenciesRequest) -> list[Address]:
2✔
278
    """Pull in the lockfile for a Terraform module.
279

280
    This is necessary for `terraform validate`.
281
    """
282
    invocation_files = await get_terraform_backend_and_vars(
1✔
283
        TerraformDeploymentInvocationFilesRequest(
284
            request.field_set.address, request.field_set.dependencies
285
        )
286
    )
287
    if invocation_files.lockfile:
1✔
UNCOV
288
        return [invocation_files.lockfile.address]
×
289
    else:
290
        return []
1✔
291

292

293
@rule
2✔
294
async def infer_terraform_module_dependencies(
2✔
295
    request: InferTerraformModuleDependenciesRequest,
296
) -> InferredDependencies:
297
    terraform_module_addresses = await _infer_dependencies_from_sources(request)
1✔
298
    lockfile_address = await _infer_lockfile(request)
1✔
299

300
    return InferredDependencies([*terraform_module_addresses, *lockfile_address])
1✔
301

302

303
@rule
2✔
304
async def infer_terraform_deployment_dependencies(
2✔
305
    request: InferTerraformDeploymentDependenciesRequest,
306
) -> InferredDependencies:
UNCOV
307
    root_module_address_input = request.field_set.root_module.to_address_input()
×
UNCOV
308
    root_module = await resolve_address(**implicitly({root_module_address_input: AddressInput}))
×
UNCOV
309
    deps = [root_module]
×
310

UNCOV
311
    invocation_files = await get_terraform_backend_and_vars(
×
312
        TerraformDeploymentInvocationFilesRequest(
313
            request.field_set.address, request.field_set.dependencies
314
        )
315
    )
UNCOV
316
    deps.extend(e.address for e in invocation_files.backend_configs)
×
UNCOV
317
    deps.extend(e.address for e in invocation_files.vars_files)
×
318
    # lockfile is attached to the module itself
319

UNCOV
320
    return InferredDependencies(deps)
×
321

322

323
def rules():
2✔
324
    return [
2✔
325
        *collect_rules(),
326
        *pex_rules(),
327
        UnionRule(InferDependenciesRequest, InferTerraformModuleDependenciesRequest),
328
        UnionRule(InferDependenciesRequest, InferTerraformDeploymentDependenciesRequest),
329
    ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc