pants-backend-generate 0.1.0rc0__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.
- pants_backend_generate-0.1.0rc0/LICENSE-MIT.txt +21 -0
- pants_backend_generate-0.1.0rc0/PKG-INFO +150 -0
- pants_backend_generate-0.1.0rc0/README.md +113 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/__init__.py +0 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/file_generate.py +11 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/goals/__init__.py +0 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/goals/generate.py +102 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/goals/lint.py +60 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/register.py +19 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/specialization.py +127 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/target_types.py +173 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/util_rules/__init__.py +0 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/util_rules/formatting.py +35 -0
- pants_backend_generate-0.1.0rc0/pants_backend_generate/util_rules/generator.py +176 -0
- pants_backend_generate-0.1.0rc0/pyproject.toml +52 -0
|
@@ -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.0rc0
|
|
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.0rc0` 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.0rc0` 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`.
|
|
File without changes
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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.0rc0"
|
|
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"
|