def _handle_inbound_message(self, *, method_name: str, params: Any): # If the connection is not yet initialized and this is not the initialization request, BSP requires # returning an error for methods (and to discard all notifications). # # Concurrency: This method can be invoked from multiple threads (for each individual request). By returning # an error for all other requests, only the thread running the initialization RPC should be able to proceed. # This ensures that we can safely call `initialize_connection` on the BSPContext with the client-supplied # init parameters without worrying about multiple threads. (Not entirely true though as this does not handle # the client making multiple concurrent initialization RPCs, but which would violate the protocol in any case.) if (not self._context.is_connection_initialized and method_name != self._INITIALIZE_METHOD_NAME): return _make_error_future( JsonRpcException( code=-32002, message= f"Client must first call `{self._INITIALIZE_METHOD_NAME}`." )) # Handle the `build/shutdown` method and `build/exit` notification. if method_name == self._SHUTDOWN_METHOD_NAME: # Return no-op success for the `build/shutdown` method. This doesn't actually cause the server to # exit. That will occur once the client sends the `build/exit` notification. return None elif method_name == self._EXIT_NOTIFCATION_NAME: # The `build/exit` notification directs the BSP server to immediately exit. # The read-dispatch loop will exit once it notices that the inbound handle is closed. So close the # inbound handle (and outbound handle for completeness) and then return to the dispatch loop # to trigger the exit. self._inbound.close() self._outbound.close() return None method_mapping = self._handler_mappings.get(method_name) if not method_mapping: return _make_error_future(JsonRpcMethodNotFound.of(method_name)) try: request = method_mapping.request_type.from_json_dict(params) except Exception: return _make_error_future(JsonRpcInvalidRequest()) workspace = Workspace(self._scheduler_session) params = Params(request, workspace) execution_request = self._scheduler_session.execution_request( products=[method_mapping.response_type], subjects=[params], ) returns, throws = self._scheduler_session.execute(execution_request) if len(returns) == 1 and len(throws) == 0: # Initialize the BSPContext with the client-supplied init parameters. See earlier comment on why this # call to `BSPContext.initialize_connection` is safe. if method_name == self._INITIALIZE_METHOD_NAME: self._context.initialize_connection(request, self.notify_client) return returns[0][1].value.to_json_dict() elif len(returns) == 0 and len(throws) == 1: raise throws[0][1].exc else: raise AssertionError( f"Received unexpected result from engine: returns={returns}; throws={throws}" )
def run_goal_rule( self, goal: Type[Goal], *, global_args: Iterable[str] | None = None, args: Iterable[str] | None = None, env: Mapping[str, str] | None = None, env_inherit: set[str] | None = None, ) -> GoalRuleResult: merged_args = (*(global_args or []), goal.name, *(args or [])) self.set_options(merged_args, env=env, env_inherit=env_inherit) raw_specs = self.options_bootstrapper.full_options_for_scopes([ GlobalOptions.get_scope_info(), goal.subsystem_cls.get_scope_info() ]).specs specs = SpecsParser(self.build_root).parse_specs(raw_specs) stdout, stderr = StringIO(), StringIO() console = Console(stdout=stdout, stderr=stderr) exit_code = self.scheduler.run_goal_rule( goal, Params( specs, console, Workspace(self.scheduler), InteractiveRunner(self.scheduler), ), ) console.flush() return GoalRuleResult(exit_code, stdout.getvalue(), stderr.getvalue())
def run_goal_rule( self, goal: Type[Goal], *, global_args: Optional[Iterable[str]] = None, args: Optional[Iterable[str]] = None, env: Optional[Mapping[str, str]] = None, ) -> GoalRuleResult: options_bootstrapper = create_options_bootstrapper( args=(*(global_args or []), goal.name, *(args or [])), env=env, ) raw_specs = options_bootstrapper.get_full_options([ *GlobalOptions.known_scope_infos(), *goal.subsystem_cls.known_scope_infos() ]).specs specs = SpecsParser(self.build_root).parse_specs(raw_specs) stdout, stderr = StringIO(), StringIO() console = Console(stdout=stdout, stderr=stderr) exit_code = self.scheduler.run_goal_rule( goal, Params( specs, console, options_bootstrapper, Workspace(self.scheduler), InteractiveRunner(self.scheduler), ), ) console.flush() return GoalRuleResult(exit_code, stdout.getvalue(), stderr.getvalue())
def create( cls, options_bootstrapper: OptionsBootstrapper, options: Options, session: SchedulerSession, build_root: Optional[str] = None, ) -> Specs: specs = cls.parse_specs(raw_specs=options.specs, build_root=build_root) changed_options = ChangedOptions.from_options( options.for_scope("changed")) logger.debug("specs are: %s", specs) logger.debug("changed_options are: %s", changed_options) if specs.provided and changed_options.provided: changed_name = "--changed-since" if changed_options.since else "--changed-diffspec" if specs.filesystem_specs and specs.address_specs: specs_description = "target and file arguments" elif specs.filesystem_specs: specs_description = "file arguments" else: specs_description = "target arguments" raise InvalidSpecConstraint( f"You used `{changed_name}` at the same time as using {specs_description}. Please " "use only one.") if not changed_options.provided: return specs scm = get_scm() if not scm: raise InvalidSpecConstraint( "The `--changed-*` options are not available without a recognized SCM (usually " "Git).") changed_request = ChangedRequest( sources=tuple(changed_options.changed_files(scm=scm)), dependees=changed_options.dependees, ) (changed_addresses, ) = session.product_request( ChangedAddresses, [Params(changed_request, options_bootstrapper)]) logger.debug("changed addresses: %s", changed_addresses) address_specs = [] filesystem_specs = [] for address in cast(ChangedAddresses, changed_addresses): if not address.is_base_target: # TODO: Should adjust Specs parsing to support parsing the disambiguated file # Address, which would bypass-rediscovering owners. filesystem_specs.append(FilesystemLiteralSpec( address.filename)) else: address_specs.append( SingleAddress(address.spec_path, address.target_name)) return Specs( AddressSpecs(address_specs, filter_by_global_options=True), FilesystemSpecs(filesystem_specs), )
def get_expanded_specs(self) -> ExpandedSpecs: """Return a dict containing the canonicalized addresses of the specs for this run, and what files they expand to.""" (unexpanded_addresses, ) = self._scheduler.product_request( Addresses, [Params(self._specs, self._options_bootstrapper)]) expanded_targets = self._scheduler.product_request( Targets, [Params(Addresses([addr])) for addr in unexpanded_addresses]) targets_dict: Dict[str, List[TargetInfo]] = {} for addr, targets in zip(unexpanded_addresses, expanded_targets): targets_dict[addr.spec] = [ TargetInfo(filename=(tgt.address.filename if tgt.address. is_file_target else str(tgt.address))) for tgt in targets ] return ExpandedSpecs(targets=targets_dict)
def calculate_specs( options_bootstrapper: OptionsBootstrapper, options: Options, session: SchedulerSession, *, build_root: Optional[str] = None, ) -> Specs: """Determine the specs for a given Pants run.""" build_root = build_root or get_buildroot() specs = SpecsParser(build_root).parse_specs(options.specs) changed_options = ChangedOptions.from_options(options.for_scope("changed")) logger.debug("specs are: %s", specs) logger.debug("changed_options are: %s", changed_options) if specs.provided and changed_options.provided: changed_name = "--changed-since" if changed_options.since else "--changed-diffspec" if specs.filesystem_specs and specs.address_specs: specs_description = "target and file arguments" elif specs.filesystem_specs: specs_description = "file arguments" else: specs_description = "target arguments" raise InvalidSpecConstraint( f"You used `{changed_name}` at the same time as using {specs_description}. Please " "use only one.") if not changed_options.provided: return specs git = get_git() if not git: raise InvalidSpecConstraint( "The `--changed-*` options are only available if Git is used for the repository." ) changed_request = ChangedRequest( sources=tuple(changed_options.changed_files(git)), dependees=changed_options.dependees, ) (changed_addresses, ) = session.product_request( ChangedAddresses, [Params(changed_request, options_bootstrapper)]) logger.debug("changed addresses: %s", changed_addresses) address_specs = [] for address in cast(ChangedAddresses, changed_addresses): address_input = AddressInput.parse(address.spec) address_specs.append( AddressLiteralSpec( path_component=address_input.path_component, # NB: AddressInput.target_component may be None, but AddressLiteralSpec expects a # string. target_component=address_input.target_component or address.target_name, )) return Specs(AddressSpecs(address_specs, filter_by_global_options=True), FilesystemSpecs([]))
def product_request( self, product: type[T], subjects: Iterable[Any] = (), poll: bool = False, timeout: float | None = None, ) -> T: result = self.scheduler_session.product_request( product, [Params(*subjects)], poll=poll, timeout=timeout, ) assert len(result) == 1 return cast(T, result[0])
def run_goal_rules( self, *, options_bootstrapper: OptionsBootstrapper, union_membership: UnionMembership, goals: Iterable[str], specs: Specs, poll: bool = False, poll_delay: Optional[float] = None, ) -> int: """Runs @goal_rules sequentially and interactively by requesting their implicit Goal products. For retryable failures, raises scheduler.ExecutionError. :returns: An exit code. """ workspace = Workspace(self.scheduler_session) interactive_runner = InteractiveRunner(self.scheduler_session) for goal in goals: goal_product = self.goal_map[goal] # NB: We no-op for goals that have no implementation because no relevant backends are # registered. We might want to reconsider the behavior to instead warn or error when # trying to run something like `./pants run` without any backends registered. is_implemented = union_membership.has_members_for_all( goal_product.subsystem_cls.required_union_implementations) if not is_implemented: continue # NB: Keep this in sync with the method `goal_consumed_types`. params = Params(specs, options_bootstrapper, self.console, workspace, interactive_runner) logger.debug( f"requesting {goal_product} to satisfy execution of `{goal}` goal" ) try: exit_code = self.scheduler_session.run_goal_rule( goal_product, params, poll=poll, poll_delay=poll_delay) finally: self.console.flush() if exit_code != PANTS_SUCCEEDED_EXIT_CODE: return exit_code return PANTS_SUCCEEDED_EXIT_CODE
def run_goal_rule( self, goal: Type[Goal], *, global_args: Optional[Iterable[str]] = None, args: Optional[Iterable[str]] = None, env: Optional[Mapping[str, str]] = None, ) -> GoalRuleResult: options_bootstrapper = create_options_bootstrapper( args=(*(global_args or []), goal.name, *(args or [])), env=env, ) raw_specs = options_bootstrapper.get_full_options([ *GlobalOptions.known_scope_infos(), *goal.subsystem_cls.known_scope_infos() ]).specs specs = SpecsParser(self.build_root).parse_specs(raw_specs) stdout, stderr = StringIO(), StringIO() console = Console(stdout=stdout, stderr=stderr) session = self.scheduler.scheduler.new_session( build_id="buildid_for_test", should_report_workunits=True, session_values=SessionValues({ OptionsBootstrapper: options_bootstrapper, PantsEnvironment: PantsEnvironment(env) }), ) exit_code = session.run_goal_rule( goal, Params( specs, console, Workspace(self.scheduler), InteractiveRunner(self.scheduler), ), ) console.flush() return GoalRuleResult(exit_code, stdout.getvalue(), stderr.getvalue())
def request(self, output_type: Type["TestBase._O"], inputs: Iterable[Any]) -> "TestBase._O": # TODO: Update all callsites to pass this explicitly via session values. session = self.scheduler for value in inputs: if type(value) == OptionsBootstrapper: session = self.scheduler.scheduler.new_session( build_id="buildid_for_test", should_report_workunits=True, session_values=SessionValues({ OptionsBootstrapper: value, PantsEnvironment: PantsEnvironment() }), ) result = assert_single_element( session.product_request(output_type, [Params(*inputs)])) return cast(TestBase._O, result)
def test_source_roots_request(self) -> None: req = SourceRootsRequest( files=(PurePath("src/python/foo/bar.py"), PurePath("tests/python/foo/bar_test.py")), dirs=(PurePath("src/python/foo"), PurePath("src/python/baz/qux")), ) res = self.request_single_product( SourceRootsResult, Params( req, create_options_bootstrapper(args=[ "--source-root-patterns=['src/python','tests/python']" ]), ), ) assert { PurePath("src/python/foo/bar.py"): SourceRoot("src/python"), PurePath("tests/python/foo/bar_test.py"): SourceRoot("tests/python"), PurePath("src/python/foo"): SourceRoot("src/python"), PurePath("src/python/baz/qux"): SourceRoot("src/python"), } == dict(res.path_to_root)
def request(self, output_type: Type[_O], inputs: Iterable[Any]) -> _O: result = assert_single_element( self.scheduler.product_request(output_type, [Params(*inputs)])) return cast(_O, result)
def request_product(self, product_type: Type["TestBase._P"], subjects: Iterable[Any]) -> "TestBase._P": result = assert_single_element( self.scheduler.product_request(product_type, [Params(*subjects)])) return cast(TestBase._P, result)
def calculate_specs( options_bootstrapper: OptionsBootstrapper, options: Options, session: SchedulerSession, ) -> Specs: """Determine the specs for a given Pants run.""" global_options = options.for_global_scope() unmatched_cli_globs = global_options.unmatched_cli_globs.to_glob_match_error_behavior( ) convert_dir_literal_to_address_literal = ( global_options.use_deprecated_directory_cli_args_semantics) if global_options.is_default( "use_deprecated_directory_cli_args_semantics"): warn_or_error( "2.14.0.dev0", "`use_deprecated_directory_cli_args_semantics` defaulting to True", softwrap(f""" Currently, a directory argument like `{bin_name()} test dir` is shorthand for the target `dir:dir`, i.e. the target that leaves off `name=`. In Pants 2.14, by default, a directory argument will instead match all targets/files in the directory. To opt into the new and more intuitive semantics early, set `use_deprecated_directory_cli_args_semantics = false` in the `[GLOBAL]` section in `pants.toml`. Otherwise, set to `true` to silence this warning. """), ) specs = SpecsParser().parse_specs( options.specs, description_of_origin="CLI arguments", unmatched_glob_behavior=unmatched_cli_globs, convert_dir_literal_to_address_literal= convert_dir_literal_to_address_literal, ) changed_options = ChangedOptions.from_options(options.for_scope("changed")) logger.debug("specs are: %s", specs) logger.debug("changed_options are: %s", changed_options) if specs and changed_options.provided: changed_name = "--changed-since" if changed_options.since else "--changed-diffspec" specs_description = specs.arguments_provided_description() assert specs_description is not None raise InvalidSpecConstraint( f"You used `{changed_name}` at the same time as using {specs_description}. You can " f"only use `{changed_name}` or use normal arguments.") if not changed_options.provided: return specs (git_binary, ) = session.product_request(GitBinary, [Params(GitBinaryRequest())]) (maybe_git_worktree, ) = session.product_request( MaybeGitWorktree, [Params(GitWorktreeRequest(), git_binary)]) if not maybe_git_worktree.git_worktree: raise InvalidSpecConstraint( "The `--changed-*` options are only available if Git is used for the repository." ) changed_files = tuple( changed_options.changed_files(maybe_git_worktree.git_worktree)) file_literal_specs = tuple(FileLiteralSpec(f) for f in changed_files) changed_request = ChangedRequest(changed_files, changed_options.dependees) (changed_addresses, ) = session.product_request( ChangedAddresses, [Params(changed_request, options_bootstrapper)]) logger.debug("changed addresses: %s", changed_addresses) address_literal_specs = [] for address in cast(ChangedAddresses, changed_addresses): address_input = AddressInput.parse( address.spec, description_of_origin="`--changed-since`") address_literal_specs.append( AddressLiteralSpec( path_component=address_input.path_component, target_component=address_input.target_component, generated_component=address_input.generated_component, parameters=address_input.parameters, )) return Specs( includes=RawSpecs( # We need both address_literals and file_literals to cover all our edge cases, including # target-aware vs. target-less goals, e.g. `list` vs `count-loc`. address_literals=tuple(address_literal_specs), file_literals=file_literal_specs, unmatched_glob_behavior=unmatched_cli_globs, filter_by_global_options=True, from_change_detection=True, description_of_origin="`--changed-since`", ), ignores=RawSpecs(description_of_origin="`--changed-since`"), )