-
-
Notifications
You must be signed in to change notification settings - Fork 631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
An export goal for exporting data to be read by IDEs. #13415
Changes from all commits
1b72946
2dd03f4
44983f5
c80db6b
7228135
b549f8b
4b8ddf8
0a2477d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
import os | ||
from dataclasses import dataclass | ||
|
||
from pants.backend.python.subsystems.setup import PythonSetup | ||
from pants.backend.python.util_rules.interpreter_constraints import InterpreterConstraints | ||
from pants.backend.python.util_rules.pex import VenvPex, VenvPexProcess | ||
from pants.backend.python.util_rules.pex_environment import PexEnvironment | ||
from pants.backend.python.util_rules.pex_from_targets import PexFromTargetsRequest | ||
from pants.core.goals.export import ExportableData, ExportableDataRequest, ExportError, Symlink | ||
from pants.engine.internals.selectors import Get | ||
from pants.engine.process import ProcessResult | ||
from pants.engine.rules import collect_rules, rule | ||
from pants.engine.unions import UnionRule | ||
|
||
|
||
@dataclass(frozen=True) | ||
class ExportedVenvRequest(ExportableDataRequest): | ||
pass | ||
|
||
|
||
@rule | ||
async def export_venv( | ||
request: ExportedVenvRequest, python_setup: PythonSetup, pex_env: PexEnvironment | ||
) -> ExportableData: | ||
# Pick a single interpreter for the venv. | ||
interpreter_constraints = InterpreterConstraints.create_from_targets( | ||
request.targets, python_setup | ||
) | ||
if not interpreter_constraints: | ||
# If there were no targets that defined any constraints, fall back to the global ones. | ||
interpreter_constraints = InterpreterConstraints(python_setup.interpreter_constraints) | ||
min_interpreter = interpreter_constraints.snap_to_minimum(python_setup.interpreter_universe) | ||
if not min_interpreter: | ||
raise ExportError( | ||
"The following interpreter constraints were computed for all the targets for which " | ||
f"export was requested: {interpreter_constraints}. There is no python interpreter " | ||
"compatible with these constraints. Please restrict the target set to one that shares " | ||
"a compatible interpreter." | ||
) | ||
|
||
venv_pex = await Get( | ||
VenvPex, | ||
PexFromTargetsRequest, | ||
PexFromTargetsRequest.for_requirements( | ||
(tgt.address for tgt in request.targets), | ||
internal_only=True, | ||
hardcoded_interpreter_constraints=min_interpreter, | ||
), | ||
) | ||
|
||
complete_pex_env = pex_env.in_workspace() | ||
venv_abspath = os.path.join(complete_pex_env.pex_root, venv_pex.venv_rel_dir) | ||
|
||
# Run the venv_pex to get the full python version (including patch #), so we | ||
# can use it in the symlink name. | ||
res = await Get( | ||
ProcessResult, | ||
VenvPexProcess( | ||
venv_pex=venv_pex, | ||
description="Create virtualenv", | ||
argv=["-c", "import sys; print('.'.join(str(x) for x in sys.version_info[0:3]))"], | ||
input_digest=venv_pex.digest, | ||
), | ||
) | ||
py_version = res.stdout.strip().decode() | ||
|
||
return ExportableData( | ||
f"virtualenv for {min_interpreter}", | ||
os.path.join("python", "virtualenv"), | ||
symlinks=[Symlink(venv_abspath, py_version)], | ||
) | ||
|
||
|
||
def rules(): | ||
return [ | ||
*collect_rules(), | ||
UnionRule(ExportableDataRequest, ExportedVenvRequest), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
import sys | ||
|
||
import pytest | ||
|
||
from pants.backend.python import target_types_rules | ||
from pants.backend.python.goals import export | ||
from pants.backend.python.goals.export import ExportedVenvRequest | ||
from pants.backend.python.target_types import PythonRequirementTarget | ||
from pants.backend.python.util_rules import pex_from_targets | ||
from pants.base.specs import AddressSpecs, DescendantAddresses | ||
from pants.core.goals.export import ExportableData | ||
from pants.engine.rules import QueryRule | ||
from pants.engine.target import Targets | ||
from pants.testutil.rule_runner import RuleRunner | ||
|
||
|
||
@pytest.fixture | ||
def rule_runner() -> RuleRunner: | ||
return RuleRunner( | ||
rules=[ | ||
*export.rules(), | ||
*pex_from_targets.rules(), | ||
*target_types_rules.rules(), | ||
QueryRule(Targets, [AddressSpecs]), | ||
QueryRule(ExportableData, [ExportedVenvRequest]), | ||
], | ||
target_types=[PythonRequirementTarget], | ||
) | ||
|
||
|
||
def test_export_venv(rule_runner: RuleRunner) -> None: | ||
# We know that the current interpreter exists on the system. | ||
vinfo = sys.version_info | ||
current_interpreter = f"{vinfo.major}.{vinfo.minor}.{vinfo.micro}" | ||
|
||
rule_runner.set_options( | ||
[f"--python-interpreter-constraints=['=={current_interpreter}']"], | ||
env_inherit={"PATH", "PYENV_ROOT"}, | ||
) | ||
rule_runner.write_files( | ||
{"src/foo/BUILD": "python_requirement(name='req', requirements=['ansicolors==1.1.8'])"} | ||
) | ||
targets = rule_runner.request(Targets, [AddressSpecs([DescendantAddresses("src/foo")])]) | ||
data = rule_runner.request(ExportableData, [ExportedVenvRequest(targets)]) | ||
assert len(data.symlinks) == 1 | ||
symlink = data.symlinks[0] | ||
assert symlink.link_rel_path == current_interpreter | ||
assert "named_caches/pex_root/venvs/" in symlink.source_path |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
import os | ||
from dataclasses import dataclass | ||
from typing import Iterable, cast | ||
|
||
from pants.base.build_root import BuildRoot | ||
from pants.core.util_rules.distdir import DistDir | ||
from pants.engine.console import Console | ||
from pants.engine.fs import EMPTY_DIGEST, AddPrefix, Digest, MergeDigests, Workspace | ||
from pants.engine.goal import Goal, GoalSubsystem | ||
from pants.engine.internals.selectors import Get, MultiGet | ||
from pants.engine.rules import collect_rules, goal_rule | ||
from pants.engine.target import Targets | ||
from pants.engine.unions import UnionMembership, union | ||
from pants.util.dirutil import absolute_symlink | ||
from pants.util.meta import frozen_after_init | ||
|
||
|
||
class ExportError(Exception): | ||
pass | ||
|
||
|
||
@union | ||
@dataclass(frozen=True) | ||
class ExportableDataRequest: | ||
"""A union for exportable data provided by a backend. | ||
|
||
Subclass and install a member of this type to export data. | ||
""" | ||
|
||
targets: Targets | ||
|
||
|
||
@dataclass(frozen=True) | ||
class Symlink: | ||
"""A symlink from link_rel_path pointing to source_path. | ||
|
||
source_path may be absolute, or relative to the repo root. | ||
|
||
link_rel_path is relative to the enclosing ExportableData's reldir, and will be | ||
absolutized when a location for that dir is chosen. | ||
""" | ||
|
||
source_path: str | ||
link_rel_path: str | ||
|
||
|
||
@frozen_after_init | ||
@dataclass(unsafe_hash=True) | ||
class ExportableData: | ||
description: str | ||
# Materialize digests and create symlinks under this reldir. | ||
reldir: str | ||
# Materialize this digest. | ||
digest: Digest | ||
# Create these symlinks. Symlinks are created after the digest is materialized, | ||
# so may reference files/dirs in the digest. | ||
symlinks: tuple[Symlink, ...] | ||
|
||
def __init__( | ||
self, | ||
description: str, | ||
reldir: str, | ||
*, | ||
digest: Digest = EMPTY_DIGEST, | ||
symlinks: Iterable[Symlink] = tuple(), | ||
): | ||
self.description = description | ||
self.reldir = reldir | ||
self.digest = digest | ||
self.symlinks = tuple(symlinks) | ||
|
||
|
||
class ExportSubsystem(GoalSubsystem): | ||
name = "export" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the story with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can probably get rid of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm interested to see the evolution of I'm interested in possibly adding functionality to the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would this be consumed? That functionality would be awesome, but There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By humans I suppose. You're probably right, let me switch hats: ... and I've forgotten my other use-case. Ignore me for now 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tend to agree, that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, export is more about exporting internal information, for consumption by other tools, such as IDEs. We'd export codegen for that reason only - in normal use codegen is a build byproduct that is not visible to the end user. But since the user may want to manually inspect it, and may want to view it in their IDE, we'd export it. Documentation, though, is a build product, like a PEX file or a distribution or a docker image or whatever. It is an actual formal output of the system that you can request. So I don't think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree with @benjyw on this one... I expect that docs meet the bar for a dedicated goal. Likewise, I think that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah OK I agree. But now you have me wondering if we should There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good question! |
||
help = "Export Pants data for use in other tools, such as IDEs." | ||
|
||
|
||
class Export(Goal): | ||
subsystem_cls = ExportSubsystem | ||
|
||
|
||
@goal_rule | ||
async def export( | ||
console: Console, | ||
targets: Targets, | ||
export_subsystem: ExportSubsystem, | ||
workspace: Workspace, | ||
union_membership: UnionMembership, | ||
build_root: BuildRoot, | ||
dist_dir: DistDir, | ||
) -> Export: | ||
request_types = cast( | ||
"Iterable[type[ExportableDataRequest]]", union_membership.get(ExportableDataRequest) | ||
) | ||
requests = tuple(request_type(targets) for request_type in request_types) | ||
exportables = await MultiGet( | ||
Get(ExportableData, ExportableDataRequest, request) for request in requests | ||
) | ||
prefixed_digests = await MultiGet( | ||
Get(Digest, AddPrefix(exp.digest, exp.reldir)) for exp in exportables | ||
) | ||
output_dir = os.path.join(str(dist_dir.relpath), "export") | ||
merged_digest = await Get(Digest, MergeDigests(prefixed_digests)) | ||
dist_digest = await Get(Digest, AddPrefix(merged_digest, output_dir)) | ||
workspace.write_digest(dist_digest) | ||
for exp in exportables: | ||
for symlink in exp.symlinks: | ||
# Note that if symlink.source_path is an abspath, join returns it unchanged. | ||
source_abspath = os.path.join(build_root.path, symlink.source_path) | ||
link_abspath = os.path.abspath( | ||
os.path.join(output_dir, exp.reldir, symlink.link_rel_path) | ||
) | ||
absolute_symlink(source_abspath, link_abspath) | ||
console.print_stdout(f"Wrote {exp.description} to {os.path.join(output_dir, exp.reldir)}") | ||
return Export(exit_code=0) | ||
|
||
|
||
def rules(): | ||
return collect_rules() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
import os | ||
from pathlib import Path | ||
from typing import List, Tuple | ||
|
||
from pants.base.build_root import BuildRoot | ||
from pants.core.goals.export import ( | ||
Export, | ||
ExportableData, | ||
ExportableDataRequest, | ||
ExportSubsystem, | ||
Symlink, | ||
export, | ||
) | ||
from pants.core.util_rules.distdir import DistDir | ||
from pants.engine.addresses import Address | ||
from pants.engine.fs import AddPrefix, CreateDigest, Digest, FileContent, MergeDigests, Workspace | ||
from pants.engine.rules import QueryRule | ||
from pants.engine.target import Target, Targets | ||
from pants.engine.unions import UnionMembership, UnionRule | ||
from pants.testutil.option_util import create_goal_subsystem, create_options_bootstrapper | ||
from pants.testutil.rule_runner import MockGet, RuleRunner, mock_console, run_rule_with_mocks | ||
|
||
|
||
class MockTarget(Target): | ||
alias = "target" | ||
core_fields = () | ||
|
||
|
||
def make_target(path: str, target_name: str) -> Target: | ||
return MockTarget({}, Address(path, target_name=target_name)) | ||
|
||
|
||
class MockExportableDataRequest(ExportableDataRequest): | ||
pass | ||
|
||
|
||
def mock_export( | ||
edr: ExportableDataRequest, digest: Digest, symlinks: tuple[Symlink, ...] | ||
) -> ExportableData: | ||
return ExportableData( | ||
description=f"mock export for {','.join(t.address.spec for t in edr.targets)}", | ||
reldir="mock", | ||
digest=digest, | ||
symlinks=symlinks, | ||
) | ||
|
||
|
||
def run_export_rule(rule_runner: RuleRunner, targets: List[Target]) -> Tuple[int, str]: | ||
union_membership = UnionMembership({ExportableDataRequest: [MockExportableDataRequest]}) | ||
with open(os.path.join(rule_runner.build_root, "somefile"), "wb") as fp: | ||
fp.write(b"SOMEFILE") | ||
with mock_console(create_options_bootstrapper()) as (console, stdio_reader): | ||
digest = rule_runner.request(Digest, [CreateDigest([FileContent("foo/bar", b"BAR")])]) | ||
result: Export = run_rule_with_mocks( | ||
export, | ||
rule_args=[ | ||
console, | ||
Targets(targets), | ||
create_goal_subsystem(ExportSubsystem), | ||
Workspace(rule_runner.scheduler, _enforce_effects=False), | ||
union_membership, | ||
BuildRoot(), | ||
DistDir(relpath=Path("dist")), | ||
], | ||
mock_gets=[ | ||
MockGet( | ||
output_type=ExportableData, | ||
input_type=ExportableDataRequest, | ||
mock=lambda edr: mock_export( | ||
edr, digest, (Symlink("somefile", "link_to_somefile"),) | ||
), | ||
), | ||
MockGet( | ||
output_type=Digest, | ||
input_type=MergeDigests, | ||
mock=lambda md: rule_runner.request(Digest, [md]), | ||
), | ||
MockGet( | ||
output_type=Digest, | ||
input_type=AddPrefix, | ||
mock=lambda ap: rule_runner.request(Digest, [ap]), | ||
), | ||
], | ||
union_membership=union_membership, | ||
) | ||
return result.exit_code, stdio_reader.get_stdout() | ||
|
||
|
||
def test_run_export_rule() -> None: | ||
rule_runner = RuleRunner( | ||
rules=[ | ||
UnionRule(ExportableDataRequest, MockExportableDataRequest), | ||
QueryRule(Digest, [CreateDigest]), | ||
], | ||
target_types=[MockTarget], | ||
) | ||
exit_code, stdout = run_export_rule(rule_runner, [make_target("foo/bar", "baz")]) | ||
assert exit_code == 0 | ||
assert "Wrote mock export for foo/bar:baz to dist/export/mock" in stdout | ||
expected_dist_path = os.path.join(rule_runner.build_root, "dist/export/mock/foo/bar") | ||
assert os.path.isfile(expected_dist_path) | ||
with open(expected_dist_path, "rb") as fp: | ||
assert fp.read() == b"BAR" | ||
|
||
symlink = "dist/export/mock/link_to_somefile" | ||
assert os.path.islink(symlink) | ||
assert os.readlink(symlink) == os.path.join(rule_runner.build_root, "somefile") | ||
with open(symlink, "rb") as fp: | ||
assert fp.read() == b"SOMEFILE" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than creating a virtualenv in the dest, this is symlinking from PEX's private cache of venvs into a destination... that seems maybe a bit dangerous, as someone who activated this venv and then fiddled with it would cause reproducibility issues by mutating PEX's cache.
Should this actually create a brand new venv instead, using
pex-tool
's facilities for that?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a valid concern. We're walking a perf line here,
Note that the fiddling would have to be editing by hand since these venvs don't get Pip installed into them (unlike
PEX_TOOLS=1 ./my.pex venv here
venvs, which do by default.).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mm... that does alleviate the concern quite a bit. Not a blocker then probably.