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

pantsbuild / pants / 24145062409

08 Apr 2026 03:56PM UTC coverage: 52.369% (-40.5%) from 92.91%
24145062409

Pull #23233

github

web-flow
Merge 7a7652ebe into 9036734c9
Pull Request #23233: Introduce a LockfileFormat enum.

7 of 10 new or added lines in 3 files covered. (70.0%)

23048 existing lines in 605 files now uncovered.

31656 of 60448 relevant lines covered (52.37%)

0.52 hits per line

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

66.18
/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
1✔
4

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

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

57
# pants: infer-dep(hcl2.lock*)
58

59

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

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

67
    register_interpreter_constraints = True
1✔
68

69
    default_lockfile_resource = ("pants.backend.terraform", "hcl2.lock")
1✔
70

71

72
@dataclass(frozen=True)
1✔
73
class ParserSetup:
1✔
74
    pex: VenvPex
1✔
75

76

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

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

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

97

98
@dataclass(frozen=True)
1✔
99
class ParseTerraformModuleSources:
1✔
100
    sources_digest: Digest
1✔
101
    paths: tuple[str, ...]
1✔
102

103

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

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

122

123
@dataclass(frozen=True)
1✔
124
class TerraformModuleDependenciesInferenceFieldSet(FieldSet):
1✔
125
    required_fields = (TerraformModuleSourcesField, TerraformDependenciesField)
1✔
126

127
    sources: TerraformModuleSourcesField
1✔
128
    dependencies: TerraformDependenciesField
1✔
129

130

131
class InferTerraformModuleDependenciesRequest(InferDependenciesRequest):
1✔
132
    infer_from = TerraformModuleDependenciesInferenceFieldSet
1✔
133

134

135
@dataclass(frozen=True)
1✔
136
class TerraformDeploymentDependenciesInferenceFieldSet(TerraformDeploymentFieldSet):
1✔
137
    pass
1✔
138

139

140
class InferTerraformDeploymentDependenciesRequest(InferDependenciesRequest):
1✔
141
    infer_from = TerraformDeploymentDependenciesInferenceFieldSet
1✔
142

143

144
def find_targets_of_type(tgts, of_type) -> tuple:
1✔
UNCOV
145
    if tgts:
×
UNCOV
146
        return tuple(e for e in tgts if isinstance(e, of_type))
×
147
    else:
UNCOV
148
        return ()
×
149

150

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

155
    address: Address
1✔
156
    dependencies: TerraformDependenciesField
1✔
157

158

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

163
    backend_configs: tuple[TerraformBackendTarget, ...]
1✔
164
    vars_files: tuple[TerraformVarFileTarget, ...]
1✔
165
    lockfile: LockfileTarget | None
1✔
166

167

168
@rule
1✔
169
async def get_terraform_backend_and_vars(
1✔
170
    field_set: TerraformDeploymentInvocationFilesRequest,
171
) -> TerraformDeploymentInvocationFiles:
UNCOV
172
    this_address = field_set.address
×
173

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

190

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

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

212

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

UNCOV
223
    has_explicit_var = find_targets_of_type(explicit_deps, TerraformVarFileTarget)
×
UNCOV
224
    if not has_explicit_var:
×
UNCOV
225
        vars_targets = find_targets_of_type(tgts_in_dir, TerraformVarFileTarget)
×
226
    else:
UNCOV
227
        vars_targets = has_explicit_var
×
228

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

UNCOV
239
    return TerraformDeploymentInvocationFiles(backend_targets, vars_targets, lockfile)
×
240

241

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

278

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

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

294

295
@rule
1✔
296
async def infer_terraform_module_dependencies(
1✔
297
    request: InferTerraformModuleDependenciesRequest,
298
) -> InferredDependencies:
UNCOV
299
    terraform_module_addresses = await _infer_dependencies_from_sources(request)
×
UNCOV
300
    lockfile_address = await _infer_lockfile(request)
×
301

UNCOV
302
    return InferredDependencies([*terraform_module_addresses, *lockfile_address])
×
303

304

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

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

UNCOV
322
    return InferredDependencies(deps)
×
323

324

325
def rules():
1✔
326
    return [
1✔
327
        *collect_rules(),
328
        *pex_rules(),
329
        UnionRule(InferDependenciesRequest, InferTerraformModuleDependenciesRequest),
330
        UnionRule(InferDependenciesRequest, InferTerraformDeploymentDependenciesRequest),
331
    ]
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