source/buck/interface.ml (431 lines of code) (raw):
(*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*)
open Base
exception JsonError of string
module BuckOptions = struct
type t = {
raw: Raw.t;
mode: string option;
isolation_prefix: string option;
use_buck2: bool;
}
end
module BuildResult = struct
type t = {
build_map: BuildMap.t;
targets: Target.t list;
}
end
module BuckChangedTargetsQueryOutput = struct
type t = {
source_base_path: string;
artifact_base_path: string;
artifacts_to_sources: (string * string) list;
}
[@@deriving sexp, compare]
let to_partial_build_map { source_base_path; artifact_base_path; artifacts_to_sources } =
let to_build_mapping (artifact, source) =
Filename.concat artifact_base_path artifact, Filename.concat source_base_path source
in
match BuildMap.Partial.of_alist (List.map artifacts_to_sources ~f:to_build_mapping) with
| `Duplicate_key artifact ->
let message = Format.sprintf "Overlapping artifact file detected: %s" artifact in
Result.Error message
| `Ok partial_build_map -> Result.Ok partial_build_map
let to_build_map_batch outputs =
let rec merge ~sofar = function
| [] -> Result.Ok (BuildMap.create sofar)
| output :: rest -> (
match to_partial_build_map output with
| Result.Error _ as error -> error
| Result.Ok next_build_map -> (
match BuildMap.Partial.merge sofar next_build_map with
| BuildMap.Partial.MergeResult.Incompatible
{ BuildMap.Partial.MergeResult.IncompatibleItem.key; _ } ->
let message = Format.sprintf "Overlapping artifact file detected: %s" key in
Result.Error message
| BuildMap.Partial.MergeResult.Ok sofar -> merge ~sofar rest))
in
merge ~sofar:BuildMap.Partial.empty outputs
end
type t = {
normalize_targets: string list -> Target.t list Lwt.t;
(* NOTE(grievejia): This function is intentionally not exposed to downstream clients of the [Buck]
module, as we are still unsure whether to rely on it or not in the long term. *)
query_owner_targets:
targets:Target.t list -> PyrePath.t list -> BuckChangedTargetsQueryOutput.t list Lwt.t;
construct_build_map: Target.t list -> BuildResult.t Lwt.t;
}
module V1 = struct
let source_database_suffix = "#source-db"
let query_buck_for_normalized_targets
{ BuckOptions.raw; mode; isolation_prefix; use_buck2 = _ }
target_specifications
=
match target_specifications with
| [] -> Lwt.return "{}"
| _ ->
List.concat
[
(* Force `buck` to hand back structured JSON output instead of plain text. *)
["--json"];
(* Mark the query as coming from `pyre` for `buck`, to make troubleshooting easier. *)
["--config"; "client.id=pyre"];
Option.value_map mode ~default:[] ~f:(fun mode -> [mode]);
[
"kind(\"python_binary|python_library|python_test\", %s)"
(* Don't bother with generated rules. *)
^ " - attrfilter(labels, generated, %s)"
(* `python_unittest()` sources are separated into a macro-generated library, so make
sure we include those. *)
^ " + attrfilter(labels, unittest-library, %s)"
^ (* Provide an opt-out label so that rules can avoid type-checking (e.g. some
libraries wrap generated sources which are expensive to build and therefore
typecheck). *)
" - attrfilter(labels, no_pyre, %s)";
];
target_specifications;
]
|> Raw.query ?isolation_prefix raw
let query_buck_for_changed_targets
~targets
{ BuckOptions.raw; mode; isolation_prefix; use_buck2 = _ }
source_paths
=
match targets with
| [] -> Lwt.return "{}"
| targets -> (
match source_paths with
| [] -> Lwt.return "{}"
| source_paths ->
let target_string =
(* Targets need to be quoted since `buck query` can fail with syntax errors if target
name contains special characters like `=`. *)
let quote_string value = Format.sprintf "\"%s\"" value in
let quote_target target = Target.show target |> quote_string in
List.map targets ~f:quote_target |> String.concat ~sep:" "
in
List.concat
[
["--json"];
["--config"; "client.id=pyre"];
Option.value_map mode ~default:[] ~f:(fun mode -> [mode]);
[
(* This will get only those owner targets that are beneath our targets or the
dependencies of our targets. *)
Format.sprintf "owner(%%s) ^ deps(set(%s))" target_string;
];
List.map source_paths ~f:PyrePath.show;
(* These attributes are all we need to locate the source and artifact relative
paths. *)
["--output-attributes"; "srcs"; "buck.base_path"; "buck.base_module"; "base_module"];
]
|> Raw.query ?isolation_prefix raw)
let run_buck_build_for_targets { BuckOptions.raw; mode; isolation_prefix; use_buck2 = _ } targets =
match targets with
| [] -> Lwt.return "{}"
| _ ->
List.concat
[
(* Force `buck` to hand back structured JSON output instead of plain text. *)
["--show-full-json-output"];
(* Mark the query as coming from `pyre` for `buck`, to make troubleshooting easier. *)
["--config"; "client.id=pyre"];
Option.value_map mode ~default:[] ~f:(fun mode -> [mode]);
List.map targets ~f:(fun target ->
Format.sprintf "%s%s" (Target.show target) source_database_suffix);
]
|> Raw.build ?isolation_prefix raw
end
module V2 = struct
let source_database_suffix = "[source-db]"
let query_buck_for_normalized_targets
{ BuckOptions.raw; mode; isolation_prefix; use_buck2 = _ }
target_specifications
=
match target_specifications with
| [] -> Lwt.return "{}"
| _ ->
List.concat
[
(* Force `buck` to hand back structured JSON output instead of plain text. *)
["--json"];
(* Force `buck` to opt-out fancy tui logging. *)
["--console=simple"];
(* Mark the query as coming from `pyre` for `buck`, to make troubleshooting easier. *)
["--config"; "client.id=pyre"];
Option.value_map mode ~default:[] ~f:(fun mode -> [mode]);
[
"kind(\"python_binary|python_library|python_test\", %s)"
(* Don't bother with generated rules. *)
^ " - attrfilter(labels, generated, %s)"
(* `python_unittest()` sources are separated into a macro-generated library, so make
sure we include those. *)
^ " + attrfilter(labels, unittest-library, %s)"
^ (* Provide an opt-out label so that rules can avoid type-checking (e.g. some
libraries wrap generated sources which are expensive to build and therefore
typecheck). *)
" - attrfilter(labels, no_pyre, %s)";
];
target_specifications;
]
|> Raw.query ?isolation_prefix raw
let query_buck_for_changed_targets ~targets:_ _ _ =
failwith "Changed targets query is currently not implemented for Buck2"
let run_buck_build_for_targets { BuckOptions.raw; mode; isolation_prefix; use_buck2 = _ } targets =
match targets with
| [] -> Lwt.return "{}"
| _ ->
List.concat
[
(* Force `buck` to opt-out fancy tui logging. *)
["--console=simple"];
(* Force `buck` to hand back structured JSON output that contains absolute paths. *)
["--show-full-json-output"];
(* Mark the query as coming from `pyre` for `buck`, to make troubleshooting easier. *)
["--config"; "client.id=pyre"];
Option.value_map mode ~default:[] ~f:(fun mode -> [mode]);
List.map targets ~f:(fun target ->
Format.sprintf "%s%s" (Target.show target) source_database_suffix);
]
|> Raw.build ?isolation_prefix raw
end
let get_source_database_suffix { BuckOptions.use_buck2; _ } =
if use_buck2 then
V2.source_database_suffix
else
V1.source_database_suffix
let query_buck_for_normalized_targets
({ BuckOptions.use_buck2; _ } as buck_options)
target_specifications
=
if use_buck2 then
V2.query_buck_for_normalized_targets buck_options target_specifications
else
V1.query_buck_for_normalized_targets buck_options target_specifications
let query_buck_for_changed_targets
~targets
({ BuckOptions.use_buck2; _ } as buck_options)
source_paths
=
if use_buck2 then
V2.query_buck_for_changed_targets ~targets buck_options source_paths
else
V1.query_buck_for_changed_targets ~targets buck_options source_paths
let run_buck_build_for_targets ({ BuckOptions.use_buck2; _ } as buck_options) targets =
if use_buck2 then
V2.run_buck_build_for_targets buck_options targets
else
V1.run_buck_build_for_targets buck_options targets
let parse_buck_normalized_targets_query_output query_output =
let is_ignored_target target =
(* We should probably tag these targets as `no_pyre` in the long run. *)
String.is_suffix target ~suffix:"-mypy_ini"
|| String.is_suffix target ~suffix:"-testmodules-lib"
in
let open Yojson.Safe in
try
from_string ~fname:"buck query output" query_output
|> Util.to_assoc
|> List.map ~f:(fun (_, targets_json) ->
Util.to_list targets_json
|> List.map ~f:Util.to_string
|> List.filter ~f:(Fn.non is_ignored_target))
|> List.concat_no_order
|> List.dedup_and_sort ~compare:String.compare
with
| Yojson.Json_error message
| Util.Type_error (message, _) ->
raise (JsonError message)
let parse_buck_changed_targets_query_output query_output =
let open Yojson.Safe in
try
let parse_target_json target_json =
let source_base_path = Util.member "buck.base_path" target_json |> Util.to_string in
let artifact_base_path =
match Util.member "buck.base_module" target_json with
| `String base_module -> String.tr ~target:'.' ~replacement:'/' base_module
| _ -> source_base_path
in
let artifact_base_path =
match Util.member "base_module" target_json with
| `String base_module -> String.tr ~target:'.' ~replacement:'/' base_module
| _ -> artifact_base_path
in
let artifacts_to_sources =
match Util.member "srcs" target_json with
| `Assoc targets_to_sources ->
List.map targets_to_sources ~f:(fun (target, source_json) ->
target, Util.to_string source_json)
|> List.filter ~f:(function
| _, source when String.is_prefix ~prefix:"//" source ->
(* This can happen for custom rules. *)
false
| _ -> true)
| _ -> []
in
{ BuckChangedTargetsQueryOutput.source_base_path; artifact_base_path; artifacts_to_sources }
in
from_string ~fname:"buck changed paths query output" query_output
|> Util.to_assoc
|> List.map ~f:(fun (_, target_json) -> parse_target_json target_json)
with
| Yojson.Json_error message
| Util.Type_error (message, _) ->
raise (JsonError message)
let parse_buck_build_output query_output =
let open Yojson.Safe in
try
from_string ~fname:"buck build output" query_output
|> Util.to_assoc
|> List.map ~f:(fun (target, path_json) -> target, Util.to_string path_json)
with
| Yojson.Json_error message
| Util.Type_error (message, _) ->
raise (JsonError message)
let load_partial_build_map_from_json json =
let filter_mapping ~key ~data:_ =
match key with
| "__manifest__.py"
| "__test_main__.py"
| "__test_modules__.py" ->
(* These files are not useful for type checking but create many conflicts when merging
different targets. *)
false
| _ -> true
in
BuildMap.Partial.of_json_exn_ignoring_duplicates json |> BuildMap.Partial.filter ~f:filter_mapping
let load_partial_build_map path =
let open Lwt.Infix in
let path = PyrePath.absolute path in
Lwt_io.(with_file ~mode:Input path read)
>>= fun content ->
try
Yojson.Safe.from_string ~fname:path content |> load_partial_build_map_from_json |> Lwt.return
with
| Yojson.Safe.Util.Type_error (message, _)
| Yojson.Safe.Util.Undefined (message, _) ->
raise (JsonError message)
| Yojson.Json_error message -> raise (JsonError message)
let normalize_targets_with_options buck_options target_specifications =
let open Lwt.Infix in
Log.info "Collecting buck targets to build...";
query_buck_for_normalized_targets buck_options target_specifications
>>= fun query_output ->
let targets =
parse_buck_normalized_targets_query_output query_output |> List.map ~f:Target.of_string
in
Log.info "Collected %d targets" (List.length targets);
Lwt.return targets
let query_owner_targets_with_options buck_options ~targets changed_paths =
let open Lwt.Infix in
Log.info "Running `buck query`...";
query_buck_for_changed_targets ~targets buck_options changed_paths
>>= fun query_output -> Lwt.return (parse_buck_changed_targets_query_output query_output)
(* Run `buck build` on the given target with the `#source-db` flavor. This will make `buck`
construct its link tree and for each target, dump a source-db JSON file containing how files in
the link tree corresponds to the final Python artifacts. Return a list containing the input
targets as well as the corresponding location of the source-db JSON file. Note that targets in
the returned list is not guaranteed to be in the same order as the input list.
May raise [Buck.Raw.BuckError] when `buck` invocation fails, or [Buck.Builder.JsonError] when
`buck` itself succeeds but its output cannot be parsed. *)
let build_source_databases buck_options targets =
let open Lwt.Infix in
Log.info "Building Buck source databases...";
run_buck_build_for_targets buck_options targets
>>= fun build_output ->
let source_database_suffix_length = get_source_database_suffix buck_options |> String.length in
parse_buck_build_output build_output
|> List.map ~f:(fun (target, path) ->
( String.drop_suffix target source_database_suffix_length |> Target.of_string,
PyrePath.create_absolute path ))
|> Lwt.return
let merge_target_and_build_map
(target_and_build_maps_sofar, build_map_sofar)
(next_target, next_build_map)
=
let open BuildMap.Partial in
match merge build_map_sofar next_build_map with
| MergeResult.Incompatible { MergeResult.IncompatibleItem.key; left_value; right_value } ->
Log.warning "Cannot include target for type checking: %s" (Target.show next_target);
(* For better error message, try to figure out which target casued the conflict. *)
let conflicting_target =
let match_target ~key (target, build_map) =
if contains ~key build_map then Some target else None
in
List.find_map target_and_build_maps_sofar ~f:(match_target ~key)
in
Log.info
"... file `%s` has already been mapped to `%s`%s but the target maps it to `%s` instead. "
key
left_value
(Option.value_map conflicting_target ~default:"" ~f:(Format.sprintf " by `%s`"))
right_value;
target_and_build_maps_sofar, build_map_sofar
| MergeResult.Ok merged_build_map ->
(next_target, next_build_map) :: target_and_build_maps_sofar, merged_build_map
let load_and_merge_build_maps target_and_source_database_paths =
let open Lwt.Infix in
let number_of_targets_to_load = List.length target_and_source_database_paths in
Log.info "Loading source databases for %d targets..." number_of_targets_to_load;
let rec fold ~sofar = function
| [] -> Lwt.return sofar
| (next_target, next_build_map_path) :: rest ->
load_partial_build_map next_build_map_path
>>= fun next_build_map ->
let sofar = merge_target_and_build_map sofar (next_target, next_build_map) in
fold ~sofar rest
in
fold target_and_source_database_paths ~sofar:([], BuildMap.Partial.empty)
>>= fun (reversed_target_and_build_maps, merged_build_map) ->
let targets = List.rev_map reversed_target_and_build_maps ~f:fst in
if List.length targets < number_of_targets_to_load then
Log.warning
"One or more targets get dropped by Pyre due to potential conflicts. For more details, see \
https://fburl.com/pyre-target-conflict";
Lwt.return { BuildResult.targets; build_map = BuildMap.create merged_build_map }
(* Unlike [load_and_merge_build_maps], this function assumes build maps are already loaded into
memory and just try to merge them synchronously. Its main purpose is to facilitate testing of the
[merge_target_and_build_map] function. *)
let merge_build_maps target_and_build_maps =
let reversed_target_and_build_maps, merged_build_map =
List.fold target_and_build_maps ~init:([], BuildMap.Partial.empty) ~f:merge_target_and_build_map
in
let targets = List.rev_map reversed_target_and_build_maps ~f:fst in
targets, BuildMap.create merged_build_map
let load_and_merge_source_databases target_and_source_database_paths =
(* Make sure the targets are in a determinstic order. This is important to make the merging
process deterministic later. Note that our dependency on the ordering of the target also
implies that the loading process is non-parallelizable. *)
List.sort target_and_source_database_paths ~compare:(fun (left_target, _) (right_target, _) ->
Target.compare left_target right_target)
|> load_and_merge_build_maps
let construct_build_map_with_options buck_options normalized_targets =
let open Lwt.Infix in
build_source_databases buck_options normalized_targets
>>= fun target_and_source_database_paths ->
load_and_merge_source_databases target_and_source_database_paths
let do_create ?mode ?isolation_prefix ~use_buck2 raw =
let buck_options = { BuckOptions.mode; isolation_prefix; raw; use_buck2 } in
{
normalize_targets = normalize_targets_with_options buck_options;
query_owner_targets = query_owner_targets_with_options buck_options;
construct_build_map = construct_build_map_with_options buck_options;
}
let create ?mode ?isolation_prefix raw = do_create ?mode ?isolation_prefix ~use_buck2:false raw
let create_v2 ?mode ?isolation_prefix raw = do_create ?mode ?isolation_prefix ~use_buck2:true raw
let create_for_testing ~normalize_targets ~construct_build_map () =
let query_owner_targets ~targets:_ _ =
failwith "`query_owner_targets` invoked but not implemented"
in
{ normalize_targets; construct_build_map; query_owner_targets }
let normalize_targets { normalize_targets; _ } target_specifications =
normalize_targets target_specifications
let query_owner_targets { query_owner_targets; _ } ~targets paths =
query_owner_targets ~targets paths
let construct_build_map { construct_build_map; _ } normalized_targets =
construct_build_map normalized_targets