pants-backend-generate 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Tom Solberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.1
2
+ Name: pants_backend_generate
3
+ Version: 0.1.0
4
+ Summary: A Pants backend for code generators whose outputs are committed: typed codegen targets, a `generate` goal that writes the outputs and a linter that checks them.
5
+ Keywords: pantsbuild,pants,backend,codegen,generate
6
+ Author-Email: Tom Solberg <me@sbg.dev>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2022 Tom Solberg
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Programming Language :: Python
31
+ Classifier: Topic :: Software Development :: Build Tools
32
+ Project-URL: Repository, https://github.com/tgolsson/pants-backends
33
+ Project-URL: Code, https://github.com/tgolsson/pants-backends/tree/main/pants-plugins/generate
34
+ Project-URL: Changelog, https://github.com/tgolsson/pants-backends/tree/main/pants-plugins/generate/CHANGELOG.md
35
+ Requires-Python: >=3.11
36
+ Description-Content-Type: text/markdown
37
+
38
+ # pants-backend-generate
39
+
40
+ A [Pants](https://www.pantsbuild.org) backend for code generators whose outputs are committed
41
+ because something outside Pants reads them from git. Inside Pants a generator is ordinary
42
+ codegen, so consumers get fresh outputs in their sandboxes. `pants generate` writes the
43
+ committed copies and `pants lint` fails when one is stale. Both use the same cached codegen
44
+ rule, so the committed bytes are the bytes Pants consumers see.
45
+
46
+ This release targets Pants **2.32.x**. Install it with `pants-backend-generate==0.1.0` in
47
+ `[GLOBAL].plugins` in `pants.toml`, and enable `pants_backend_generate` in
48
+ `[GLOBAL].backend_packages`.
49
+
50
+ **Status: alpha.**
51
+
52
+ ## `file_generate`
53
+
54
+ Generated files (`FileSourceField`), kept as written. Language backends ship their own
55
+ specializations with a formatter, for example `cpp_generate` in pants-backend-cmake. Every
56
+ `*_generate` target has these fields:
57
+
58
+ - `runnable` (required): the generator, a target `pants run` can run in a sandbox (such as a
59
+ `pex_binary`), with `args`.
60
+ - `sources`: files the generator reads, relative to the BUILD file. Optional.
61
+ - `outputs` (required): every file the generator writes, as exact, normalised paths relative
62
+ to the build root.
63
+ - `execution_dependencies`: targets whose files are present in the sandbox.
64
+ - `runnable_dependencies`: runnable targets put on `PATH`.
65
+ - `extra_env_vars`, `timeout`, `environment`.
66
+
67
+ The generator runs at the build root with its `sources` and `execution_dependencies` at their
68
+ repo-relative paths, and the parent directory of every output already exists. The outputs are
69
+ exact. Each of these is an error:
70
+
71
+ - a declared file that is not written
72
+ - an undeclared file written next to a declared one
73
+ - an input in an output directory that the generator overwrites
74
+ - an output that is also an input (exclude the committed copy with a `!` glob)
75
+
76
+ Put outputs in a directory: files at the build root are not checked for extras.
77
+
78
+ The host environment is not inherited, `PATH` included. A generator that runs host tools needs
79
+ `extra_env_vars=["PATH"]` or the tools as `runnable_dependencies`.
80
+
81
+ The committed outputs belong to no target:
82
+
83
+ - If another target's `sources` glob matches a committed output, a consumer of both targets
84
+ gets two copies. Once the copy is stale, that fails with an opaque `MergeDigests` error.
85
+ Exclude the output from that glob with `!`.
86
+ - File-address specs and `--changed-since` do not reach the generating target, so run the
87
+ check as `pants lint ::`.
88
+
89
+ ## `pants generate`
90
+
91
+ Runs the generators of the given `*_generate` targets and writes their outputs into the
92
+ workspace. It prints one `<address>: wrote <files>` line per target. Two targets writing the
93
+ same file is an error, and then nothing is written.
94
+
95
+ ## `pants lint`
96
+
97
+ The `generate` linter reports each committed output that differs from the generated one
98
+ (`stale`) or does not exist (`missing`), followed by the `pants generate` command that
99
+ refreshes them. `--generate-skip` turns it off.
100
+
101
+ ## Specializing
102
+
103
+ Codegen dispatches on the class of the target's sources field, so each output type needs its
104
+ own target type. `GenerateSpecialization` makes one. In the sketch below, `MySourceField` is the
105
+ sources field class your consumers ask for:
106
+
107
+ ```python
108
+ from pants.engine.rules import collect_rules, rule
109
+ from pants.engine.unions import UnionRule
110
+
111
+ from pants_backend_generate.specialization import GenerateSpecialization
112
+ from pants_backend_generate.util_rules.formatting import FormatOutputsRequest, FormattedOutputs
113
+
114
+
115
+ class MyFormatOutputsRequest(FormatOutputsRequest):
116
+ pass
117
+
118
+
119
+ @rule
120
+ async def format_my_outputs(request: MyFormatOutputsRequest) -> FormattedOutputs:
121
+ ... # format request.snapshot; return exactly the same files
122
+
123
+
124
+ my_generate = GenerateSpecialization(
125
+ "my_generate",
126
+ output=MySourceField,
127
+ formatter=MyFormatOutputsRequest, # or None
128
+ help="Sources written by a generator.",
129
+ )
130
+
131
+
132
+ def target_types():
133
+ return my_generate.target_types()
134
+
135
+
136
+ def rules():
137
+ return [
138
+ *collect_rules(),
139
+ *my_generate.rules(),
140
+ UnionRule(FormatOutputsRequest, MyFormatOutputsRequest),
141
+ ]
142
+ ```
143
+
144
+ A specialization's `rules()` carries everything codegen needs, so another backend can register
145
+ it without `pants_backend_generate` in `backend_packages`. Codegen then works, and a one-time
146
+ warning says that `pants generate` and the linter need the backend enabled.
147
+
148
+ A formatter runs inside the codegen rule. It receives `snapshot` and `address` with
149
+ `EnvironmentName` in scope, and it must return the same set of files. Aliases must be unique.
150
+ Several specializations may share an `output`.
@@ -0,0 +1,113 @@
1
+ # pants-backend-generate
2
+
3
+ A [Pants](https://www.pantsbuild.org) backend for code generators whose outputs are committed
4
+ because something outside Pants reads them from git. Inside Pants a generator is ordinary
5
+ codegen, so consumers get fresh outputs in their sandboxes. `pants generate` writes the
6
+ committed copies and `pants lint` fails when one is stale. Both use the same cached codegen
7
+ rule, so the committed bytes are the bytes Pants consumers see.
8
+
9
+ This release targets Pants **2.32.x**. Install it with `pants-backend-generate==0.1.0` in
10
+ `[GLOBAL].plugins` in `pants.toml`, and enable `pants_backend_generate` in
11
+ `[GLOBAL].backend_packages`.
12
+
13
+ **Status: alpha.**
14
+
15
+ ## `file_generate`
16
+
17
+ Generated files (`FileSourceField`), kept as written. Language backends ship their own
18
+ specializations with a formatter, for example `cpp_generate` in pants-backend-cmake. Every
19
+ `*_generate` target has these fields:
20
+
21
+ - `runnable` (required): the generator, a target `pants run` can run in a sandbox (such as a
22
+ `pex_binary`), with `args`.
23
+ - `sources`: files the generator reads, relative to the BUILD file. Optional.
24
+ - `outputs` (required): every file the generator writes, as exact, normalised paths relative
25
+ to the build root.
26
+ - `execution_dependencies`: targets whose files are present in the sandbox.
27
+ - `runnable_dependencies`: runnable targets put on `PATH`.
28
+ - `extra_env_vars`, `timeout`, `environment`.
29
+
30
+ The generator runs at the build root with its `sources` and `execution_dependencies` at their
31
+ repo-relative paths, and the parent directory of every output already exists. The outputs are
32
+ exact. Each of these is an error:
33
+
34
+ - a declared file that is not written
35
+ - an undeclared file written next to a declared one
36
+ - an input in an output directory that the generator overwrites
37
+ - an output that is also an input (exclude the committed copy with a `!` glob)
38
+
39
+ Put outputs in a directory: files at the build root are not checked for extras.
40
+
41
+ The host environment is not inherited, `PATH` included. A generator that runs host tools needs
42
+ `extra_env_vars=["PATH"]` or the tools as `runnable_dependencies`.
43
+
44
+ The committed outputs belong to no target:
45
+
46
+ - If another target's `sources` glob matches a committed output, a consumer of both targets
47
+ gets two copies. Once the copy is stale, that fails with an opaque `MergeDigests` error.
48
+ Exclude the output from that glob with `!`.
49
+ - File-address specs and `--changed-since` do not reach the generating target, so run the
50
+ check as `pants lint ::`.
51
+
52
+ ## `pants generate`
53
+
54
+ Runs the generators of the given `*_generate` targets and writes their outputs into the
55
+ workspace. It prints one `<address>: wrote <files>` line per target. Two targets writing the
56
+ same file is an error, and then nothing is written.
57
+
58
+ ## `pants lint`
59
+
60
+ The `generate` linter reports each committed output that differs from the generated one
61
+ (`stale`) or does not exist (`missing`), followed by the `pants generate` command that
62
+ refreshes them. `--generate-skip` turns it off.
63
+
64
+ ## Specializing
65
+
66
+ Codegen dispatches on the class of the target's sources field, so each output type needs its
67
+ own target type. `GenerateSpecialization` makes one. In the sketch below, `MySourceField` is the
68
+ sources field class your consumers ask for:
69
+
70
+ ```python
71
+ from pants.engine.rules import collect_rules, rule
72
+ from pants.engine.unions import UnionRule
73
+
74
+ from pants_backend_generate.specialization import GenerateSpecialization
75
+ from pants_backend_generate.util_rules.formatting import FormatOutputsRequest, FormattedOutputs
76
+
77
+
78
+ class MyFormatOutputsRequest(FormatOutputsRequest):
79
+ pass
80
+
81
+
82
+ @rule
83
+ async def format_my_outputs(request: MyFormatOutputsRequest) -> FormattedOutputs:
84
+ ... # format request.snapshot; return exactly the same files
85
+
86
+
87
+ my_generate = GenerateSpecialization(
88
+ "my_generate",
89
+ output=MySourceField,
90
+ formatter=MyFormatOutputsRequest, # or None
91
+ help="Sources written by a generator.",
92
+ )
93
+
94
+
95
+ def target_types():
96
+ return my_generate.target_types()
97
+
98
+
99
+ def rules():
100
+ return [
101
+ *collect_rules(),
102
+ *my_generate.rules(),
103
+ UnionRule(FormatOutputsRequest, MyFormatOutputsRequest),
104
+ ]
105
+ ```
106
+
107
+ A specialization's `rules()` carries everything codegen needs, so another backend can register
108
+ it without `pants_backend_generate` in `backend_packages`. Codegen then works, and a one-time
109
+ warning says that `pants generate` and the linter need the backend enabled.
110
+
111
+ A formatter runs inside the codegen rule. It receives `snapshot` and `address` with
112
+ `EnvironmentName` in scope, and it must return the same set of files. Aliases must be unique.
113
+ Several specializations may share an `output`.
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from pants.core.target_types import FileSourceField
4
+
5
+ from pants_backend_generate.specialization import GenerateSpecialization
6
+
7
+ file_generate = GenerateSpecialization(
8
+ "file_generate",
9
+ output=FileSourceField,
10
+ help="Files written by a generator, kept as written.",
11
+ )
@@ -0,0 +1,102 @@
1
+ """`pants generate` writes the codegen outputs into the workspace: `fix` cannot create files and
2
+ `export-codegen` writes only under `dist/`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+
9
+ from pants.engine.addresses import Address
10
+ from pants.engine.console import Console
11
+ from pants.engine.fs import MergeDigests, Snapshot, Workspace
12
+ from pants.engine.goal import Goal, GoalSubsystem
13
+ from pants.engine.internals.graph import hydrate_sources
14
+ from pants.engine.internals.specs_rules import find_valid_field_sets_for_target_roots
15
+ from pants.engine.intrinsics import merge_digests
16
+ from pants.engine.rules import collect_rules, concurrently, goal_rule, implicitly
17
+ from pants.engine.target import (
18
+ HydrateSourcesRequest,
19
+ NoApplicableTargetsBehavior,
20
+ TargetRootsToFieldSetsRequest,
21
+ )
22
+ from pants.engine.unions import UnionMembership
23
+ from pants.option.option_types import SkipOption
24
+ from pants.util.strutil import bullet_list
25
+
26
+ from pants_backend_generate.target_types import GenerateFieldSet
27
+
28
+
29
+ class GenerateSubsystem(GoalSubsystem):
30
+ name = "generate"
31
+ help = "Run the generators of `*_generate` targets and write their outputs into the workspace."
32
+
33
+ # Also the linter's tool subsystem: two subsystems cannot share the `generate` scope.
34
+ skip = SkipOption("lint")
35
+
36
+ @classmethod
37
+ def activated(cls, union_membership: UnionMembership) -> bool:
38
+ return GenerateFieldSet in union_membership
39
+
40
+
41
+ class Generate(Goal):
42
+ subsystem_cls = GenerateSubsystem
43
+ environment_behavior = Goal.EnvironmentBehavior.LOCAL_ONLY
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class GeneratedOutputs:
48
+ address: Address
49
+ snapshot: Snapshot
50
+
51
+
52
+ async def generate_outputs(field_set: GenerateFieldSet) -> GeneratedOutputs:
53
+ hydrated = await hydrate_sources(
54
+ HydrateSourcesRequest(
55
+ field_set.sources,
56
+ for_sources_types=(field_set.output_type,),
57
+ enable_codegen=True,
58
+ ),
59
+ **implicitly(),
60
+ )
61
+ return GeneratedOutputs(field_set.address, hydrated.snapshot)
62
+
63
+
64
+ @goal_rule
65
+ async def generate(console: Console, workspace: Workspace) -> Generate:
66
+ targets_to_field_sets = await find_valid_field_sets_for_target_roots(
67
+ TargetRootsToFieldSetsRequest(
68
+ GenerateFieldSet,
69
+ goal_description=f"the `{GenerateSubsystem.name}` goal",
70
+ no_applicable_targets_behavior=NoApplicableTargetsBehavior.warn,
71
+ ),
72
+ **implicitly(),
73
+ )
74
+ results = await concurrently(
75
+ generate_outputs(field_set) for field_set in targets_to_field_sets.field_sets
76
+ )
77
+ results = sorted(results, key=lambda r: r.address)
78
+
79
+ writers: defaultdict[str, list[Address]] = defaultdict(list)
80
+ for result in results:
81
+ for path in result.snapshot.files:
82
+ writers[path].append(result.address)
83
+ clashes = [
84
+ f"{path}: {', '.join(str(a) for a in addresses)}"
85
+ for path, addresses in sorted(writers.items())
86
+ if len(addresses) > 1
87
+ ]
88
+ if clashes:
89
+ console.print_stderr(
90
+ f"More than one target writes the same file; nothing was written:\n\n{bullet_list(clashes)}"
91
+ )
92
+ return Generate(exit_code=1)
93
+
94
+ digest = await merge_digests(MergeDigests(result.snapshot.digest for result in results))
95
+ workspace.write_digest(digest)
96
+ for result in results:
97
+ console.print_stdout(f"{result.address}: wrote {', '.join(result.snapshot.files)}")
98
+ return Generate(exit_code=0)
99
+
100
+
101
+ def rules():
102
+ return collect_rules()
@@ -0,0 +1,60 @@
1
+ """The `generate` linter: committed outputs must match what their generator writes now."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pants.base.glob_match_error_behavior import GlobMatchErrorBehavior
6
+ from pants.core.goals.lint import LintResult, LintTargetsRequest
7
+ from pants.core.util_rules.partitions import PartitionerType
8
+ from pants.engine.fs import PathGlobs
9
+ from pants.engine.intrinsics import get_digest_contents, path_globs_to_digest
10
+ from pants.engine.rules import collect_rules, rule
11
+ from pants.util.logging import LogLevel
12
+
13
+ from pants_backend_generate.goals.generate import GenerateSubsystem, generate_outputs
14
+ from pants_backend_generate.target_types import GenerateFieldSet
15
+
16
+
17
+ class GenerateLintRequest(LintTargetsRequest):
18
+ field_set_type = GenerateFieldSet
19
+ tool_subsystem = GenerateSubsystem # type: ignore[assignment]
20
+ partitioner_type = PartitionerType.DEFAULT_ONE_PARTITION_PER_INPUT
21
+
22
+
23
+ async def _stale_outputs(field_set: GenerateFieldSet) -> list[str]:
24
+ generated = await generate_outputs(field_set)
25
+ committed_digest = await path_globs_to_digest(
26
+ PathGlobs(
27
+ field_set.outputs.value,
28
+ glob_match_error_behavior=GlobMatchErrorBehavior.ignore,
29
+ )
30
+ )
31
+ generated_contents = await get_digest_contents(generated.snapshot.digest)
32
+ committed_contents = await get_digest_contents(committed_digest)
33
+ expected = {fc.path: fc.content for fc in generated_contents}
34
+ committed = {fc.path: fc.content for fc in committed_contents}
35
+ return [
36
+ f"{field_set.address}: {path}: {'missing' if path not in committed else 'stale'}"
37
+ for path in sorted(expected)
38
+ if committed.get(path) != expected[path]
39
+ ]
40
+
41
+
42
+ @rule(desc="Check generated files are current", level=LogLevel.DEBUG)
43
+ async def lint_generated(request: GenerateLintRequest.Batch) -> LintResult:
44
+ lines: list[str] = []
45
+ for field_set in request.elements:
46
+ lines.extend(await _stale_outputs(field_set))
47
+ stderr = ""
48
+ if lines:
49
+ addresses = " ".join(sorted({str(field_set.address) for field_set in request.elements}))
50
+ stderr = "\n".join(lines) + f"\n\nRun `pants generate {addresses}` to refresh."
51
+ return LintResult(
52
+ exit_code=1 if lines else 0,
53
+ stdout="",
54
+ stderr=stderr,
55
+ linter_name=request.tool_name,
56
+ )
57
+
58
+
59
+ def rules():
60
+ return [*collect_rules(), *GenerateLintRequest.rules()]
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from pants_backend_generate.file_generate import file_generate
4
+ from pants_backend_generate.goals import generate, lint
5
+ from pants_backend_generate.util_rules import formatting, generator
6
+
7
+
8
+ def target_types():
9
+ return file_generate.target_types()
10
+
11
+
12
+ def rules():
13
+ return [
14
+ *generator.rules(),
15
+ *formatting.rules(),
16
+ *generate.rules(),
17
+ *lint.rules(),
18
+ *file_generate.rules(),
19
+ ]
@@ -0,0 +1,127 @@
1
+ """Codegen dispatches on the class of the target's sources field, so each output type needs its
2
+ own sources field, target type, request and field set; `GenerateSpecialization` builds all four."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from dataclasses import dataclass, field, make_dataclass
8
+
9
+ from pants.core.environments.target_types import EnvironmentField
10
+ from pants.engine.rules import collect_rules, rule
11
+ from pants.engine.target import (
12
+ COMMON_TARGET_FIELDS,
13
+ GeneratedSources,
14
+ GenerateSourcesRequest,
15
+ SourcesField,
16
+ Target,
17
+ )
18
+ from pants.engine.unions import UnionRule
19
+ from pants.option.global_options import GlobalOptions
20
+ from pants.util.memo import memoized_method
21
+
22
+ from pants_backend_generate.target_types import (
23
+ GENERATE_FIELDS,
24
+ GenerateFieldSet,
25
+ GenerateOutputsField,
26
+ GenerateSourcesField,
27
+ )
28
+ from pants_backend_generate.util_rules import formatting, generator
29
+ from pants_backend_generate.util_rules.formatting import FormatOutputsRequest
30
+ from pants_backend_generate.util_rules.generator import run_generator
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _ALIASES: set[str] = set()
35
+ _warned_without_goal_backend = False
36
+
37
+
38
+ def _warn_without_goal_backend(global_options: GlobalOptions) -> None:
39
+ global _warned_without_goal_backend
40
+ if _warned_without_goal_backend or "pants_backend_generate" in global_options.backend_packages:
41
+ return
42
+ _warned_without_goal_backend = True
43
+ logger.warning(
44
+ "`pants generate` and the `generate` linter are unavailable until `pants_backend_generate` "
45
+ "is added to `[GLOBAL].backend_packages`."
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class GenerateSpecialization:
51
+ """A `*_generate` target type whose outputs are `output` sources, formatted by `formatter`."""
52
+
53
+ alias: str
54
+ output: type[SourcesField]
55
+ formatter: type[FormatOutputsRequest] | None = None
56
+ help: str = ""
57
+
58
+ sources_field_type: type[GenerateSourcesField] = field(init=False, repr=False)
59
+ target_type: type[Target] = field(init=False, repr=False)
60
+ request_type: type[GenerateSourcesRequest] = field(init=False, repr=False)
61
+ field_set_type: type[GenerateFieldSet] = field(init=False, repr=False)
62
+
63
+ def __post_init__(self) -> None:
64
+ # Otherwise the second target type silently shadows the first.
65
+ if self.alias in _ALIASES:
66
+ raise ValueError(f"A `GenerateSpecialization` with alias {self.alias!r} already exists.")
67
+ _ALIASES.add(self.alias)
68
+
69
+ # As Pants' `wrap_source_rule_and_target` does: one class of each per specialization.
70
+ class SourcesField(GenerateSourcesField):
71
+ output_type = self.output
72
+ formatter_type = self.formatter
73
+
74
+ class Request(GenerateSourcesRequest):
75
+ input = SourcesField
76
+ output = self.output
77
+
78
+ class SpecializedTarget(Target):
79
+ alias = self.alias
80
+ core_fields = (*COMMON_TARGET_FIELDS, SourcesField, *GENERATE_FIELDS, EnvironmentField)
81
+ help = self.help
82
+
83
+ # `FieldSet.fields` uses `get_type_hints`: the annotation must be the class, not a name.
84
+ field_set_type = make_dataclass(
85
+ "FieldSet",
86
+ [("sources", SourcesField)],
87
+ bases=(GenerateFieldSet,),
88
+ frozen=True,
89
+ namespace={"required_fields": (SourcesField, GenerateOutputsField)},
90
+ )
91
+ camel = "".join(part.capitalize() for part in self.alias.split("_"))
92
+ for cls, suffix in (
93
+ (SourcesField, "SourcesField"),
94
+ (Request, "Request"),
95
+ (SpecializedTarget, "Target"),
96
+ (field_set_type, "FieldSet"),
97
+ ):
98
+ cls.__name__ = cls.__qualname__ = f"{camel}{suffix}"
99
+ object.__setattr__(self, "sources_field_type", SourcesField)
100
+ object.__setattr__(self, "target_type", SpecializedTarget)
101
+ object.__setattr__(self, "request_type", Request)
102
+ object.__setattr__(self, "field_set_type", field_set_type)
103
+
104
+ def target_types(self) -> tuple[type[Target], ...]:
105
+ return (self.target_type,)
106
+
107
+ # A second rule with the same canonical name would clash.
108
+ @memoized_method
109
+ def rules(self) -> tuple:
110
+ @rule(
111
+ canonical_name_suffix=self.alias,
112
+ _param_type_overrides={"request": self.request_type},
113
+ )
114
+ async def generate(
115
+ request: GenerateSourcesRequest, global_options: GlobalOptions
116
+ ) -> GeneratedSources:
117
+ _warn_without_goal_backend(global_options)
118
+ return await run_generator(request)
119
+
120
+ # The codegen path works without the goal backend enabled; the engine dedupes these.
121
+ return (
122
+ *generator.rules(),
123
+ *formatting.rules(),
124
+ *collect_rules({"generate": generate}),
125
+ UnionRule(GenerateSourcesRequest, self.request_type),
126
+ UnionRule(GenerateFieldSet, self.field_set_type),
127
+ )
@@ -0,0 +1,173 @@
1
+ """Fields shared by every `*_generate` target; only `sources` is subclassed per specialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from typing import ClassVar
9
+
10
+ from pants.engine.addresses import Address
11
+ from pants.engine.env_vars import EXTRA_ENV_VARS_USAGE_HELP
12
+ from pants.engine.target import (
13
+ FieldSet,
14
+ IntField,
15
+ InvalidFieldException,
16
+ MultipleSourcesField,
17
+ SourcesField,
18
+ SpecialCasedDependencies,
19
+ StringField,
20
+ StringSequenceField,
21
+ ValidNumbers,
22
+ )
23
+ from pants.engine.unions import union
24
+ from pants.util.strutil import help_text, softwrap
25
+
26
+ _GLOB_CHARACTERS = frozenset("*?[]{}!")
27
+
28
+
29
+ class GenerateRunnableField(StringField):
30
+ alias: ClassVar[str] = "runnable"
31
+ required = True
32
+ help = help_text(
33
+ """
34
+ Address of the generator, a target `pants run` can run in a sandbox (such as a
35
+ `pex_binary`). It runs at the build root.
36
+ """
37
+ )
38
+
39
+
40
+ class GenerateArgsField(StringSequenceField):
41
+ alias: ClassVar[str] = "args"
42
+ default = ()
43
+ help = "Arguments passed to the `runnable`."
44
+
45
+
46
+ class GenerateSourcesField(MultipleSourcesField):
47
+ alias: ClassVar[str] = "sources"
48
+ required = False
49
+ default = ()
50
+ help = help_text(
51
+ """
52
+ Files the generator reads, relative to the BUILD file; present in the sandbox at their
53
+ repo-relative paths.
54
+ """
55
+ )
56
+
57
+ output_type: ClassVar[type[SourcesField]]
58
+ formatter_type: ClassVar[type | None] = None
59
+
60
+
61
+ class GenerateOutputsField(StringSequenceField):
62
+ alias: ClassVar[str] = "outputs"
63
+ required = True
64
+ help = help_text(
65
+ """
66
+ Every file the generator writes, exactly: build-root-relative, normalised paths, each
67
+ once, no globs. A missing or an extra file is an error.
68
+ """
69
+ )
70
+
71
+ @classmethod
72
+ def compute_value(cls, raw_value: Iterable[str] | None, address: Address) -> tuple[str, ...]:
73
+ value = super().compute_value(raw_value, address)
74
+ if not value:
75
+ raise InvalidFieldException(f"The `{cls.alias}` field of {address} must name at least one file.")
76
+ seen: set[str] = set()
77
+ for path in value:
78
+ problem = _output_path_problem(path)
79
+ if problem:
80
+ raise InvalidFieldException(f"The `{cls.alias}` field of {address} has {path!r}: {problem}.")
81
+ if path in seen:
82
+ raise InvalidFieldException(
83
+ f"The `{cls.alias}` field of {address} lists {path!r} more than once."
84
+ )
85
+ seen.add(path)
86
+ return value
87
+
88
+
89
+ def _output_path_problem(path: str) -> str | None:
90
+ if not path:
91
+ return "outputs must not be empty"
92
+ if os.path.basename(path) in ("", "."):
93
+ return "outputs are files, not directories"
94
+ if os.path.isabs(path):
95
+ return "outputs are paths relative to the build root"
96
+ if _GLOB_CHARACTERS & set(path):
97
+ return "outputs are exact paths, not globs"
98
+ normalised = os.path.normpath(path)
99
+ if normalised == ".":
100
+ return "outputs are files, not directories"
101
+ if normalised.split("/")[0] == "..":
102
+ return "outputs must stay inside the build root"
103
+ if normalised != path:
104
+ return softwrap(
105
+ f"""
106
+ write it as {normalised!r} (outputs are compared as strings)
107
+ """
108
+ )
109
+ return None
110
+
111
+
112
+ class GenerateExecutionDependenciesField(SpecialCasedDependencies):
113
+ alias: ClassVar[str] = "execution_dependencies"
114
+ help = help_text(
115
+ """
116
+ Targets whose transitive files are present in the sandbox at their repo-relative
117
+ paths.
118
+ """
119
+ )
120
+
121
+
122
+ class GenerateRunnableDependenciesField(SpecialCasedDependencies):
123
+ alias: ClassVar[str] = "runnable_dependencies"
124
+ help = help_text(
125
+ """
126
+ Runnable targets put on the generator's `PATH` under their target name.
127
+ """
128
+ )
129
+
130
+
131
+ class GenerateExtraEnvVarsField(StringSequenceField):
132
+ alias: ClassVar[str] = "extra_env_vars"
133
+ help = help_text(
134
+ f"""
135
+ Additional environment variables for the generator.
136
+
137
+ {EXTRA_ENV_VARS_USAGE_HELP}
138
+ """
139
+ )
140
+
141
+
142
+ class GenerateTimeoutField(IntField):
143
+ alias: ClassVar[str] = "timeout"
144
+ default = None
145
+ valid_numbers = ValidNumbers.positive_only
146
+ help = "Seconds the generator may run before it is killed. No limit by default."
147
+
148
+
149
+ GENERATE_FIELDS = (
150
+ GenerateRunnableField,
151
+ GenerateArgsField,
152
+ GenerateOutputsField,
153
+ GenerateExecutionDependenciesField,
154
+ GenerateRunnableDependenciesField,
155
+ GenerateExtraEnvVarsField,
156
+ GenerateTimeoutField,
157
+ )
158
+
159
+
160
+ @union
161
+ @dataclass(frozen=True)
162
+ class GenerateFieldSet(FieldSet):
163
+ """The `lint` goal instantiates this base directly, so `output_type` reads the field
164
+ instance, not the annotation."""
165
+
166
+ required_fields = (GenerateSourcesField, GenerateOutputsField)
167
+
168
+ sources: GenerateSourcesField
169
+ outputs: GenerateOutputsField
170
+
171
+ @property
172
+ def output_type(self) -> type[SourcesField]:
173
+ return self.sources.output_type
@@ -0,0 +1,35 @@
1
+ """Formatting runs inside the codegen rule, because `fmt` never sees codegen output and `lint`
2
+ compares against the formatted bytes."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ from pants.engine.addresses import Address
9
+ from pants.engine.environment import EnvironmentName
10
+ from pants.engine.fs import Snapshot
11
+ from pants.engine.rules import collect_rules, rule
12
+ from pants.engine.unions import union
13
+
14
+
15
+ @union(in_scope_types=[EnvironmentName])
16
+ @dataclass(frozen=True)
17
+ class FormatOutputsRequest:
18
+ """Union base: a member's rule returns `FormattedOutputs` with exactly the files given."""
19
+
20
+ snapshot: Snapshot
21
+ address: Address
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FormattedOutputs:
26
+ snapshot: Snapshot
27
+
28
+
29
+ @rule(polymorphic=True)
30
+ async def format_outputs(request: FormatOutputsRequest, env_name: EnvironmentName) -> FormattedOutputs:
31
+ raise NotImplementedError()
32
+
33
+
34
+ def rules():
35
+ return collect_rules()
@@ -0,0 +1,176 @@
1
+ """The codegen rule body. Output directories are captured whole so that undeclared and overwritten
2
+ files are seen; outputs at the build root therefore get neither check."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+
8
+ from pants.core.environments.rules import (
9
+ EnvironmentNameRequest,
10
+ get_target_for_environment_name,
11
+ resolve_environment_name,
12
+ )
13
+ from pants.core.util_rules import adhoc_process_support
14
+ from pants.core.util_rules.adhoc_process_support import (
15
+ AdhocProcessRequest,
16
+ ToolRunnerRequest,
17
+ convert_fallible_adhoc_process_result,
18
+ create_tool_runner,
19
+ prepare_env_vars,
20
+ )
21
+ from pants.engine.environment import EnvironmentName
22
+ from pants.engine.fs import (
23
+ CreateDigest,
24
+ DigestSubset,
25
+ Directory,
26
+ FileEntry,
27
+ MergeDigests,
28
+ PathGlobs,
29
+ )
30
+ from pants.engine.intrinsics import (
31
+ create_digest,
32
+ digest_subset_to_digest,
33
+ digest_to_snapshot,
34
+ get_digest_entries,
35
+ merge_digests,
36
+ )
37
+ from pants.engine.rules import concurrently, implicitly
38
+ from pants.engine.target import GeneratedSources, GenerateSourcesRequest
39
+ from pants.util.frozendict import FrozenDict
40
+ from pants.util.strutil import bullet_list
41
+
42
+ from pants_backend_generate.target_types import (
43
+ GenerateArgsField,
44
+ GenerateExecutionDependenciesField,
45
+ GenerateExtraEnvVarsField,
46
+ GenerateOutputsField,
47
+ GenerateRunnableDependenciesField,
48
+ GenerateRunnableField,
49
+ GenerateTimeoutField,
50
+ )
51
+ from pants_backend_generate.util_rules.formatting import FormatOutputsRequest, format_outputs
52
+
53
+
54
+ class GenerateOutputsError(Exception):
55
+ pass
56
+
57
+
58
+ async def run_generator(request: GenerateSourcesRequest) -> GeneratedSources:
59
+ target = request.protocol_target
60
+ address = target.address
61
+ outputs = target[GenerateOutputsField].value
62
+ description = f"the `{target.alias}` at {address}"
63
+
64
+ environment_name = await resolve_environment_name(
65
+ EnvironmentNameRequest.from_target(target), **implicitly()
66
+ )
67
+ environment_target = await get_target_for_environment_name(environment_name, **implicitly())
68
+ tool_runner = await create_tool_runner(
69
+ ToolRunnerRequest(
70
+ runnable_address_str=target[GenerateRunnableField].value,
71
+ args=target[GenerateArgsField].value or (),
72
+ execution_dependencies=target[GenerateExecutionDependenciesField].value or (),
73
+ runnable_dependencies=target[GenerateRunnableDependenciesField].value or (),
74
+ target=target,
75
+ runnable_address_field_alias=GenerateRunnableField.alias,
76
+ )
77
+ )
78
+
79
+ output_directories = tuple(sorted({os.path.dirname(o) for o in outputs} - {""}))
80
+ directories = await create_digest(CreateDigest(Directory(d) for d in output_directories))
81
+ input_digest = await merge_digests(
82
+ MergeDigests([tool_runner.digest, request.protocol_sources.digest, directories])
83
+ )
84
+ input_snapshot = await digest_to_snapshot(input_digest)
85
+ overlap = sorted(set(outputs) & set(input_snapshot.files))
86
+ if overlap:
87
+ raise GenerateOutputsError(
88
+ f"{description} declares outputs that are also inputs (in `sources`, or from "
89
+ f"`execution_dependencies` or the runnable):\n\n{bullet_list(overlap)}\n\n"
90
+ "Exclude the committed copy from those inputs, for example with a `!` glob."
91
+ )
92
+
93
+ env_vars = await prepare_env_vars(
94
+ tool_runner.extra_env,
95
+ target[GenerateExtraEnvVarsField].value or (),
96
+ extra_paths=tool_runner.extra_paths,
97
+ description_of_origin=f"`{GenerateExtraEnvVarsField.alias}` of {description}",
98
+ )
99
+ process_request = AdhocProcessRequest(
100
+ description=description,
101
+ address=address,
102
+ working_directory="",
103
+ root_output_directory="",
104
+ argv=tool_runner.args,
105
+ timeout=target[GenerateTimeoutField].value,
106
+ input_digest=input_digest,
107
+ immutable_input_digests=FrozenDict.frozen(tool_runner.immutable_input_digests),
108
+ append_only_caches=FrozenDict(tool_runner.append_only_caches),
109
+ output_files=outputs,
110
+ output_directories=output_directories,
111
+ env_vars=env_vars,
112
+ log_on_process_errors=None,
113
+ log_output=False,
114
+ capture_stdout_file=None,
115
+ capture_stderr_file=None,
116
+ workspace_invalidation_globs=None,
117
+ cache_scope=environment_target.default_cache_scope,
118
+ # The exact-set check below replaces adhoc_tool's glob check.
119
+ use_working_directory_as_base_for_output_captures=False,
120
+ outputs_match_conjunction=None,
121
+ )
122
+ result = await convert_fallible_adhoc_process_result(
123
+ **implicitly({environment_name: EnvironmentName, process_request: AdhocProcessRequest})
124
+ )
125
+
126
+ input_entries, produced_entries = await concurrently(
127
+ get_digest_entries(input_digest), get_digest_entries(result.adjusted_digest)
128
+ )
129
+ inputs = {e.path: e.file_digest for e in input_entries if isinstance(e, FileEntry)}
130
+ produced = {e.path: e.file_digest for e in produced_entries if isinstance(e, FileEntry)}
131
+ missing = sorted(set(outputs) - set(produced))
132
+ # Unchanged inputs inside a captured output directory are not extras.
133
+ extra = sorted(set(produced) - set(outputs) - set(inputs))
134
+ overwritten = sorted(
135
+ path for path, digest in produced.items() if path in inputs and inputs[path] != digest
136
+ )
137
+ if missing or extra or overwritten:
138
+ problems = []
139
+ if missing:
140
+ problems.append(f"Declared in `outputs` but not written:\n\n{bullet_list(missing)}")
141
+ if extra:
142
+ problems.append(f"Written but not declared in `outputs`:\n\n{bullet_list(extra)}")
143
+ if overwritten:
144
+ problems.append(
145
+ "Written but not declared in `outputs` (these were inputs, which the generator "
146
+ f"overwrote):\n\n{bullet_list(overwritten)}"
147
+ )
148
+ raise GenerateOutputsError(
149
+ f"{description} did not write exactly its `outputs`.\n\n" + "\n\n".join(problems)
150
+ )
151
+
152
+ exact_digest = await digest_subset_to_digest(DigestSubset(result.adjusted_digest, PathGlobs(outputs)))
153
+ exact = await digest_to_snapshot(exact_digest)
154
+
155
+ formatter_type = request.input.formatter_type
156
+ if formatter_type is None:
157
+ return GeneratedSources(exact)
158
+
159
+ formatted = await format_outputs(
160
+ **implicitly(
161
+ {
162
+ formatter_type(snapshot=exact, address=address): FormatOutputsRequest,
163
+ environment_name: EnvironmentName,
164
+ }
165
+ )
166
+ )
167
+ if set(formatted.snapshot.files) != set(exact.files):
168
+ raise GenerateOutputsError(
169
+ f"The formatter {formatter_type.__name__} changed the files of {description}: "
170
+ f"it was given {sorted(exact.files)} and returned {sorted(formatted.snapshot.files)}."
171
+ )
172
+ return GeneratedSources(formatted.snapshot)
173
+
174
+
175
+ def rules():
176
+ return [*adhoc_process_support.rules()]
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "pants_backend_generate"
3
+ description = "A Pants backend for code generators whose outputs are committed: typed codegen targets, a `generate` goal that writes the outputs and a linter that checks them."
4
+ version = "0.1.0"
5
+ requires-python = ">=3.11"
6
+ authors = [
7
+ { name = "Tom Solberg", email = "me@sbg.dev" },
8
+ ]
9
+ dependencies = []
10
+ readme = "README.md"
11
+ keywords = [
12
+ "pantsbuild",
13
+ "pants",
14
+ "backend",
15
+ "codegen",
16
+ "generate",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Programming Language :: Python",
21
+ "Topic :: Software Development :: Build Tools",
22
+ ]
23
+
24
+ [project.license]
25
+ file = "LICENSE-MIT.txt"
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/tgolsson/pants-backends"
29
+ Code = "https://github.com/tgolsson/pants-backends/tree/main/pants-plugins/generate"
30
+ Changelog = "https://github.com/tgolsson/pants-backends/tree/main/pants-plugins/generate/CHANGELOG.md"
31
+
32
+ [tool.pdm.build]
33
+ includes = [
34
+ "pants_backend_generate",
35
+ ]
36
+
37
+ [tool.isort]
38
+ profile = "black"
39
+ known_first_party = [
40
+ "pants_backend_generate",
41
+ ]
42
+ line_length = 100
43
+ include_trailing_comma = true
44
+
45
+ [tool.black]
46
+ line-length = 100
47
+
48
+ [build-system]
49
+ requires = [
50
+ "pdm-backend>2.0",
51
+ ]
52
+ build-backend = "pdm.backend"