From 356453d5fb9f1bb727b9b6dac780bd871789b970 Mon Sep 17 00:00:00 2001 From: Zach Anderson Date: Thu, 31 Aug 2023 22:39:18 -0700 Subject: [PATCH] Adds a Dart library for loading and parsing build configs --- ci/builders/linux_unopt.json | 4 + ci/builders/mac_android_aot_engine.json | 2 +- ci/check_build_configs.sh | 41 + testing/run_tests.py | 20 + .../analysis_options.yaml | 5 + tools/pkg/engine_build_configs/bin/check.dart | 67 ++ .../lib/engine_build_configs.dart | 24 + .../lib/src/build_config.dart | 843 ++++++++++++++++++ .../lib/src/build_config_loader.dart | 70 ++ tools/pkg/engine_build_configs/pubspec.yaml | 61 ++ .../test/build_config_loader_test.dart | 128 +++ .../test/build_config_test.dart | 368 ++++++++ tools/pub_get_offline.py | 1 + 13 files changed, 1633 insertions(+), 1 deletion(-) create mode 100755 ci/check_build_configs.sh create mode 100644 tools/pkg/engine_build_configs/analysis_options.yaml create mode 100644 tools/pkg/engine_build_configs/bin/check.dart create mode 100644 tools/pkg/engine_build_configs/lib/engine_build_configs.dart create mode 100644 tools/pkg/engine_build_configs/lib/src/build_config.dart create mode 100644 tools/pkg/engine_build_configs/lib/src/build_config_loader.dart create mode 100644 tools/pkg/engine_build_configs/pubspec.yaml create mode 100644 tools/pkg/engine_build_configs/test/build_config_loader_test.dart create mode 100644 tools/pkg/engine_build_configs/test/build_config_test.dart diff --git a/ci/builders/linux_unopt.json b/ci/builders/linux_unopt.json index af6e33ad3ccca..2570f491dbf1e 100644 --- a/ci/builders/linux_unopt.json +++ b/ci/builders/linux_unopt.json @@ -69,6 +69,10 @@ "language": "dart", "name": "test: Lint android host", "script": "flutter/tools/android_lint/bin/main.dart" + }, + { + "name": "Check build configs", + "script": "flutter/ci/check_build_configs.sh" } ] }, diff --git a/ci/builders/mac_android_aot_engine.json b/ci/builders/mac_android_aot_engine.json index 20dd187c19775..7f2f8861ab2dd 100644 --- a/ci/builders/mac_android_aot_engine.json +++ b/ci/builders/mac_android_aot_engine.json @@ -239,6 +239,6 @@ } } ], - "generators": [], + "generators": {}, "archives": [] } diff --git a/ci/check_build_configs.sh b/ci/check_build_configs.sh new file mode 100755 index 0000000000000..b1de5c5edd8f0 --- /dev/null +++ b/ci/check_build_configs.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +set -e + +# Needed because if it is set, cd may print the path it changed to. +unset CDPATH + +# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one +# link at a time, and then cds into the link destination and find out where it +# ends up. +# +# The function is enclosed in a subshell to avoid changing the working directory +# of the caller. +function follow_links() ( + cd -P "$(dirname -- "$1")" + file="$PWD/$(basename -- "$1")" + while [[ -h "$file" ]]; do + cd -P "$(dirname -- "$file")" + file="$(readlink -- "$file")" + cd -P "$(dirname -- "$file")" + file="$PWD/$(basename -- "$file")" + done + echo "$file" +) + +SCRIPT_DIR=$(follow_links "$(dirname -- "${BASH_SOURCE[0]}")") +SRC_DIR="$(cd "$SCRIPT_DIR/../.."; pwd -P)" +FLUTTER_DIR="$(cd "$SCRIPT_DIR/.."; pwd -P)" +DART_BIN="${SRC_DIR}/third_party/dart/tools/sdks/dart-sdk/bin" +DART="${DART_BIN}/dart" + +cd "$SCRIPT_DIR" +"$DART" \ + --disable-dart-dev \ + "$SRC_DIR/flutter/tools/pkg/engine_build_configs/bin/check.dart" \ + "$SRC_DIR" + diff --git a/testing/run_tests.py b/testing/run_tests.py index 20c3ade050974..a22579b6e0c17 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -972,6 +972,25 @@ def gather_build_bucket_golden_scraper_tests(build_dir): ) +def gather_engine_build_configs_tests(build_dir): + test_dir = os.path.join( + BUILDROOT_DIR, 'flutter', 'tools', 'pkg', 'engine_build_configs' + ) + dart_tests = glob.glob('%s/*_test.dart' % test_dir) + for dart_test_file in dart_tests: + opts = [ + '--disable-dart-dev', + dart_test_file, + ] + yield EngineExecutableTask( + build_dir, + os.path.join('dart-sdk', 'bin', 'dart'), + None, + flags=opts, + cwd=test_dir + ) + + def gather_engine_repo_tools_tests(build_dir): test_dir = os.path.join( BUILDROOT_DIR, 'flutter', 'tools', 'pkg', 'engine_repo_tools' @@ -1269,6 +1288,7 @@ def main(): tasks += list(gather_githooks_tests(build_dir)) tasks += list(gather_clang_tidy_tests(build_dir)) tasks += list(gather_build_bucket_golden_scraper_tests(build_dir)) + tasks += list(gather_engine_build_configs_tests(build_dir)) tasks += list(gather_engine_repo_tools_tests(build_dir)) tasks += list(gather_api_consistency_tests(build_dir)) tasks += list(gather_path_ops_tests(build_dir)) diff --git a/tools/pkg/engine_build_configs/analysis_options.yaml b/tools/pkg/engine_build_configs/analysis_options.yaml new file mode 100644 index 0000000000000..79335970f853f --- /dev/null +++ b/tools/pkg/engine_build_configs/analysis_options.yaml @@ -0,0 +1,5 @@ +include: ../../../analysis_options.yaml + +linter: + rules: + public_member_api_docs: false diff --git a/tools/pkg/engine_build_configs/bin/check.dart b/tools/pkg/engine_build_configs/bin/check.dart new file mode 100644 index 0000000000000..0eb959fed6cd9 --- /dev/null +++ b/tools/pkg/engine_build_configs/bin/check.dart @@ -0,0 +1,67 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io' as io; + +import 'package:engine_build_configs/engine_build_configs.dart'; +import 'package:engine_repo_tools/engine_repo_tools.dart'; +import 'package:path/path.dart' as p; + +// Usage: +// $ dart bin/check.dart [/path/to/engine/src] + +void main(List args) { + final String? engineSrcPath; + if (args.isNotEmpty) { + engineSrcPath = args[0]; + } else { + engineSrcPath = null; + } + + // Find the engine repo. + final Engine engine; + try { + engine = Engine.findWithin(engineSrcPath); + } catch (e) { + io.stderr.writeln(e); + io.exitCode = 1; + return; + } + + // Find and parse the engine build configs. + final io.Directory buildConfigsDir = io.Directory(p.join( + engine.flutterDir.path, 'ci', 'builders', + )); + final BuildConfigLoader loader = BuildConfigLoader( + buildConfigsDir: buildConfigsDir, + ); + + // Treat it as an error if no build configs were found. The caller likely + // expected to find some. + final Map configs = loader.configs; + if (configs.isEmpty) { + io.stderr.writeln( + 'Error: No build configs found under ${buildConfigsDir.path}', + ); + io.exitCode = 1; + return; + } + if (loader.errors.isNotEmpty) { + loader.errors.forEach(io.stderr.writeln); + io.exitCode = 1; + } + + // Check the parsed build configs for validity. + for (final String name in configs.keys) { + final BuildConfig buildConfig = configs[name]!; + final List buildConfigErrors = buildConfig.check(name); + if (buildConfigErrors.isNotEmpty) { + io.stderr.writeln('Errors in ${buildConfig.path}:'); + io.exitCode = 1; + } + for (final String error in buildConfigErrors) { + io.stderr.writeln(' $error'); + } + } +} diff --git a/tools/pkg/engine_build_configs/lib/engine_build_configs.dart b/tools/pkg/engine_build_configs/lib/engine_build_configs.dart new file mode 100644 index 0000000000000..e0ebcb581e4b9 --- /dev/null +++ b/tools/pkg/engine_build_configs/lib/engine_build_configs.dart @@ -0,0 +1,24 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// This is a library for parsing the Engine CI configurations that live under +/// flutter/ci/builders. They describe how CI builds, tests, archives, and +/// uploads the engine to cloud storage. The documentation and spec for the +/// format is at: +/// +/// https://github.com/flutter/engine/blob/main/ci/builders/README.md +/// +/// The code in this library is *not* used by CI to run these configurations. +/// Rather, that code executes these configs on CI is part of the "engine_v2" +/// recipes at: +/// +/// https://cs.opensource.google/flutter/recipes/+/main:recipes/engine_v2 +/// +/// This library exposes two main classes, [BuildConfigLoader], which reads and +/// loads all build configurations under a directory, and [BuildConfig], which +/// is the Dart representation of a single build configuration. +library; + +export 'src/build_config.dart'; +export 'src/build_config_loader.dart'; diff --git a/tools/pkg/engine_build_configs/lib/src/build_config.dart b/tools/pkg/engine_build_configs/lib/src/build_config.dart new file mode 100644 index 0000000000000..935e3ac90bad7 --- /dev/null +++ b/tools/pkg/engine_build_configs/lib/src/build_config.dart @@ -0,0 +1,843 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:meta/meta.dart'; + +// This library parses Engine build config data out of the "Engine v2" build +// config JSON files with the format described at: +// https://github.com/flutter/engine/blob/main/ci/builders/README.md + +/// Base class for all nodes in the build config. +sealed class BuildConfigBase { + BuildConfigBase(this.errors); + + /// Accumulated errors. Non-null and non-empty when a node is invalid. + final List? errors; + + /// Whether there were errors when loading the data for this node. + late final bool valid = errors == null; + + /// Returns an empty list when the object is valid, and errors when it is not. + /// Subclasses with more data to check for validity should override this + /// method and add `super.check(path)` to the returned list. + @mustCallSuper + List check(String path) { + if (valid) { + return []; + } + return errors!.map((String s) => '$path: $s').toList(); + } +} + +/// The build configuration is a json file containing a list of builds, tests, +/// generators and archives. +/// +/// Each build config file contains a top-level json map with the following +/// fields: +/// { +/// "builds": [], +/// "tests": [], +/// "generators": { +/// "tasks": [] +/// }, +/// "archives": [] +/// } +final class BuildConfig extends BuildConfigBase { + /// Load build configuration data into an instance of this class. + /// + /// [path] should be the file system path to the file that the JSON data comes + /// from. [map] must be the JSON data returned by e.g. `JsonDecoder.convert`. + factory BuildConfig.fromJson({ + required String path, + required Map map, + }) { + final List errors = []; + + // Parse the "builds" field. + final List? builds = objListOfJson( + map, 'builds', errors, GlobalBuild.fromJson, + ); + + // Parse the "tests" field. + final List? tests = objListOfJson( + map, 'tests', errors, GlobalTest.fromJson, + ); + + // Parse the "generators" field. + final List? generators; + if (map['generators'] == null) { + generators = []; + } else if (map['generators'] is! Map) { + appendTypeError(map, 'generators', 'map', errors); + generators = null; + } else { + generators = objListOfJson( + map['generators']! as Map, + 'tasks', + errors, + TestTask.fromJson, + ); + } + + // Parse the "archives" field. + final List? archives = objListOfJson( + map, 'archives', errors, GlobalArchive.fromJson, + ); + + if (builds == null || + tests == null || + generators == null || + archives == null) { + return BuildConfig._invalid(path, errors); + } + return BuildConfig._(path, builds, tests, generators, archives); + } + + BuildConfig._( + this.path, + this.builds, + this.tests, + this.generators, + this.archives, + ) : super(null); + + BuildConfig._invalid(this.path, super.errors) : + builds = [], + tests = [], + generators = [], + archives = []; + + /// The path to the JSON file. + final String path; + + /// A list of independent builds that have no dependencies among them. They + /// can run in parallel if need be. + final List builds; + + /// A list of tests. The tests may have dependencies on one or more of the + /// builds. + final List tests; + + /// A list of generator tasks that produce additional artifacts, which may + /// depend on the output of one or more builds. + final List generators; + + /// A description of the upload instructions for the artifacts produced by + /// the global generators. + final List archives; + + @override + List check(String path) { + final List errors = []; + errors.addAll(super.check(path)); + for (int i = 0; i < builds.length; i++) { + final GlobalBuild build = builds[i]; + errors.addAll(build.check('$path/builds[$i]')); + } + for (int i = 0; i < tests.length; i++) { + final GlobalTest test = tests[i]; + errors.addAll(test.check('$path/tests[$i]')); + } + for (int i = 0; i < generators.length; i++) { + final TestTask task = generators[i]; + errors.addAll(task.check('$path/generators/tasks[$i]')); + } + for (int i = 0; i < archives.length; i++) { + final GlobalArchive archive = archives[i]; + errors.addAll(archive.check('$path/archives[$i]')); + } + return errors; + } +} + +/// A "build" is a dictionary with a gn command, a ninja command, zero or more +/// generator commands, zero or more local tests, zero or more local generators +/// and zero or more output artifacts. +/// +/// "builds" contains a list of maps with fields like: +/// { +/// "name": "", +/// "gn": [""], +/// "ninja": {}, +/// "tests": [], +/// "generators": { +/// "tasks": [] +/// }, (optional) +/// "archives": [], +/// "drone_dimensions": [""], +/// "gclient_variables": {} +/// } +final class GlobalBuild extends BuildConfigBase { + factory GlobalBuild.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final List? gn = stringListOfJson(map, 'gn', errors); + final List? tests = objListOfJson( + map, 'tests', errors, BuildTest.fromJson, + ); + final List? archives = objListOfJson( + map, 'archives', errors, BuildArchive.fromJson, + ); + final List? droneDimensions = stringListOfJson( + map, 'drone_dimensions', errors, + ); + + final BuildNinja? ninja; + if (map['ninja'] == null) { + ninja = BuildNinja.nop(); + } else if (map['ninja'] is! Map) { + ninja = null; + } else { + ninja = BuildNinja.fromJson(map['ninja']! as Map); + } + if (ninja == null) { + appendTypeError(map, 'ninja', 'map', errors); + } + + final List? generators; + if (map['generators'] == null) { + generators = []; + } else if (map['generators'] is! Map) { + appendTypeError(map, 'generators', 'map', errors); + generators = null; + } else { + generators = objListOfJson( + map['generators']! as Map, + 'tasks', + errors, + BuildTask.fromJson, + ); + } + + final Map? gclientVariables; + if (map['gclient_variables'] == null) { + gclientVariables = {}; + } else if (map['gclient_variables'] is! Map) { + gclientVariables = null; + } else { + gclientVariables = map['gclient_variables']! as Map; + } + if (gclientVariables == null) { + appendTypeError(map, 'gclient_variables', 'map', errors); + } + + if (name == null || + gn == null || + ninja == null || + archives == null || + tests == null || + generators == null || + droneDimensions == null || + gclientVariables == null) { + return GlobalBuild._invalid(errors); + } + return GlobalBuild._( + name, gn, ninja, tests, generators, archives, droneDimensions, + gclientVariables, + ); + } + + GlobalBuild._( + this.name, + this.gn, + this.ninja, + this.tests, + this.generators, + this.archives, + this.droneDimensions, + this.gclientVariables, + ) : super(null); + + GlobalBuild._invalid(super.errors) : + name = '', + gn = [], + ninja = BuildNinja.nop(), + tests = [], + generators = [], + archives = [], + droneDimensions = [], + gclientVariables = {}; + + /// The name of the build which may also be used to reference it as a + /// depdendency of a global test. + final String name; + + /// The parameters to pass to `flutter/tools/gn` to configure the build. + final List gn; + + /// The data to form the ninja command to perform the build. + final BuildNinja ninja; + + /// The list of tests that can be run after the ninja build is finished. + final List tests; + + /// A list of other tasks that may generate new artifacts after the ninja + /// build is finished. + final List generators; + + /// Upload instructions for the artifacts produced by the build. + final List archives; + + /// A list 'key=value' strings that are used to select the bot where this + /// build will be running. + final List droneDimensions; + + /// A dictionary with variables included in the `custom_vars` section of the + /// .gclient file before `gclient sync` is run. + final Map gclientVariables; + + @override + List check(String path) { + final List errors = []; + errors.addAll(super.check(path)); + errors.addAll(ninja.check('$path/ninja')); + for (int i = 0; i < tests.length; i++) { + final BuildTest test = tests[i]; + errors.addAll(test.check('$path/tests[$i]')); + } + for (int i = 0; i < generators.length; i++) { + final BuildTask task = generators[i]; + errors.addAll(task.check('$path/generators/tasks[$i]')); + } + for (int i = 0; i < archives.length; i++) { + final BuildArchive archive = archives[i]; + errors.addAll(archive.check('$path/archives[$i]')); + } + return errors; + } +} + +/// "builds" -> "ninja" contains a map with fields like: +/// { +/// "config": "", +/// "targets": [""] +/// }, +final class BuildNinja extends BuildConfigBase { + factory BuildNinja.fromJson(Map map) { + final List errors = []; + final String? config = stringOfJson(map, 'config', errors); + final List? targets = stringListOfJson(map, 'targets', errors); + if (config == null || targets == null) { + return BuildNinja._invalid(errors); + } + return BuildNinja._(config, targets); + } + + BuildNinja._(this.config, this.targets) : super(null); + + BuildNinja._invalid(super.errors) : + config = '', + targets = []; + + BuildNinja.nop() : + config = '', + targets = [], + super(null); + + /// The name of the configuration created by gn. + /// + /// This is also the subdirectory of the `out/` directory where the build + /// output will go. + final String config; + + /// The ninja targets to build. + final List targets; +} + +/// "builds" -> "tests" contains a list of maps with fields like: +/// { +/// "language": "", +/// "name": "", +/// "parameters": [""], +/// "script": "", +/// "contexts": [""] +/// } +final class BuildTest extends BuildConfigBase { + factory BuildTest.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final String? language = stringOfJson(map, 'language', errors); + final String? script = stringOfJson(map, 'script', errors); + final List? parameters = stringListOfJson( + map, 'parameters', errors, + ); + final List? contexts = stringListOfJson( + map, 'contexts', errors, + ); + if (name == null || + language == null || + script == null || + parameters == null || + contexts == null) { + return BuildTest._invalid(errors); + } + return BuildTest._(name, language, script, parameters, contexts); + } + + BuildTest._( + this.name, + this.language, + this.script, + this.parameters, + this.contexts, + ) : super(null); + + BuildTest._invalid(super.errors) : + name = '', + language = '', + script = '', + parameters = [], + contexts = []; + + /// The human readable description of the test. + final String name; + + /// The executable used to run the script. + final String language; + + /// The path to the script to execute relative to the checkout directory. + final String script; + + /// Flags or parameters passed to the script. + /// + /// Parameters accept magic environment variables (placeholders replaced + /// before executing the test). Magic environment variables have the following + /// limitations: only ${FLUTTER_LOGS_DIR} is currently supported and it needs + /// to be used alone within the parameter string(e.g. ["${FLUTTER_LOGS_DIR}"] + /// is OK but ["path=${FLUTTER_LOGS_DIR}"] is not). + final List parameters; + + /// A list of available contexts to add to the text execution step. + /// + /// Two contexts are supported: "android_virtual_device" and + /// "metric_center_token". + final List contexts; +} + +/// "builds" -> "generators" is a map containing a single property "tasks", +/// which is a list of maps with fields like: +/// { +/// "name": "", +/// "parameters": [""], +/// "scripts": [""], +/// "language": "" +/// } +/// +/// The semantics of this task are that each script in the list of scripts is +/// run in sequence by appending the same parameter list to each one. +final class BuildTask extends BuildConfigBase { + factory BuildTask.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final String? language = stringOfJson(map, 'language', errors); + final List? scripts = stringListOfJson(map, 'scripts', errors); + final List? parameters = stringListOfJson( + map, 'parameters', errors, + ); + if (name == null || + language == null || + scripts == null || + parameters == null) { + return BuildTask._invalid(errors); + } + return BuildTask._(name, language, scripts, parameters); + } + + BuildTask._invalid(super.errors) : + name = '', + language = '', + scripts = [], + parameters = []; + + BuildTask._(this.name, this.language, this.scripts, this.parameters) : + super(null); + + /// The human readable name of the step running the script. + final String name; + + /// The script language executable to run the script. If empty it is assumed + /// to be bash. + final String language; + + /// A list of paths of scripts relative to the checkout directory. Each + /// script is run in turn by appending the list of parameters to it. + final List scripts; + + /// The flags passed to the script. Paths referenced in the list are relative + /// to the checkout directory. + final List parameters; +} + +/// "builds" -> "archives" contains a list of maps with fields like: +/// { +/// "name": "", +/// "base_path": "", +/// "type": "", +/// "include_paths": [""], +/// "realm": "" +/// } +final class BuildArchive extends BuildConfigBase { + factory BuildArchive.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final String? type = stringOfJson(map, 'type', errors); + final String? basePath = stringOfJson(map, 'base_path', errors); + final String? realm = stringOfJson(map, 'realm', errors); + final List? includePaths = stringListOfJson( + map, 'include_paths', errors, + ); + if (name == null || + type == null || + basePath == null || + realm == null || + includePaths == null) { + return BuildArchive._invalid(errors); + } + return BuildArchive._(name, type, basePath, realm, includePaths); + } + + BuildArchive._invalid(super.error) : + name = '', + type = '', + basePath = '', + realm = '', + includePaths = []; + + BuildArchive._( + this.name, + this.type, + this.basePath, + this.realm, + this.includePaths, + ) : super(null); + + /// The name which may be referenced later as a dependency of global tests. + final String name; + + /// The type of storage to use. Currently only “gcs” and “cas” are supported. + final String type; + + /// The portion of the path to remove from the full path before uploading + final String basePath; + + /// Either "production" or "experimental". + final String realm; + + /// A list of strings with the paths to be uploaded to a given destination. + final List includePaths; +} + +/// Global "tests" is a list of maps containing fields like: +/// { +/// "name": "", +/// "recipe": "", +/// "drone_dimensions": [""], +/// "dependencies": [""], +/// "tasks": [] (same format as above) +/// } +final class GlobalTest extends BuildConfigBase { + factory GlobalTest.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final String? recipe = stringOfJson(map, 'recipe', errors); + final List? droneDimensions = stringListOfJson( + map, 'drone_dimensions', errors, + ); + final List? dependencies = stringListOfJson( + map, 'dependencies', errors, + ); + final List? testDependencies = objListOfJson( + map, 'test_dependencies', errors, TestDependency.fromJson, + ); + final List? tasks = objListOfJson( + map, 'tasks', errors, TestTask.fromJson, + ); + if (name == null || + recipe == null || + droneDimensions == null || + dependencies == null || + testDependencies == null || + tasks == null) { + return GlobalTest._invalid(errors); + } + return GlobalTest._( + name, recipe, droneDimensions, dependencies, testDependencies, tasks); + } + + GlobalTest._invalid(super.errors) : + name = '', + recipe = '', + droneDimensions = [], + dependencies = [], + testDependencies = [], + tasks = []; + + GlobalTest._( + this.name, + this.recipe, + this.droneDimensions, + this.dependencies, + this.testDependencies, + this.tasks, + ) : super(null); + + /// The name that will be assigned to the sub-build. + final String name; + + /// The recipe name to use if different than tester. + final String recipe; + + /// A list of strings with key values to select the bot where the test will + /// run. + final List droneDimensions; + + /// A list of build outputs required by the test. + final List dependencies; + + /// A list of dependencies required for the test to run. + final List testDependencies; + + /// A list of dictionaries representing scripts and parameters to run them + final List tasks; + + @override + List check(String path) { + final List errors = []; + errors.addAll(super.check(path)); + for (int i = 0; i < testDependencies.length; i++) { + final TestDependency testDependency = testDependencies[i]; + errors.addAll(testDependency.check('$path/test_dependencies[$i]')); + } + for (int i = 0; i < tasks.length; i++) { + final TestTask task = tasks[i]; + errors.addAll(task.check('$path/tasks[$i]')); + } + return errors; + } +} + +/// A test dependency for a global test has fields like: +/// { +/// "dependency": "", +/// "version": "" +/// } +final class TestDependency extends BuildConfigBase { + factory TestDependency.fromJson(Map map) { + final List errors = []; + final String? dependency = stringOfJson(map, 'dependency', errors); + final String? version = stringOfJson(map, 'version', errors); + if (dependency == null || version == null) { + return TestDependency._invalid(errors); + } + return TestDependency._(dependency, version); + } + + TestDependency._invalid(super.error) : dependency = '', version = ''; + + TestDependency._(this.dependency, this.version) : super(null); + + /// A dependency from the list at: + /// https://flutter.googlesource.com/recipes/+/refs/heads/main/recipe_modules/flutter_deps/api.py#75 + final String dependency; + + /// The CIPD version string of the dependency. + final String version; +} + +/// Task for a global generator and a global test. +/// { +/// "name": "", +/// "parameters": [""], +/// "script": "", +/// "language": "" +/// } +final class TestTask extends BuildConfigBase { + factory TestTask.fromJson(Map map) { + final List errors = []; + final String? name = stringOfJson(map, 'name', errors); + final String? language = stringOfJson(map, 'language', errors); + final String? script = stringOfJson(map, 'script', errors); + final int? maxAttempts = intOfJson(map, 'max_attempts', fallback: 1, errors); + final List? parameters = stringListOfJson( + map, 'parameters', errors, + ); + if (name == null || + language == null || + script == null || + maxAttempts == null || + parameters == null) { + return TestTask._invalid(errors); + } + return TestTask._(name, language, script, maxAttempts, parameters); + } + + TestTask._invalid(super.error) : + name = '', + language = '', + script = '', + maxAttempts = 0, + parameters = []; + + TestTask._( + this.name, + this.language, + this.script, + this.maxAttempts, + this.parameters, + ) : super(null); + + /// The human readable name of the step running the script. + final String name; + + /// The script language executable to run the script. If empty it is assumed + /// to be bash. + final String language; + + /// The script path relative to the checkout repository. + final String script; + + /// The maximum number of failures to tolerate. The default is 1. + final int maxAttempts; + + /// The flags passed to the script. Paths referenced in the list are relative + /// to the checkout directory. + final List parameters; +} + +/// The objects that populate the list of global archives have fields like: +/// { +/// "source": "out/debug/artifacts.zip", +/// "destination": "ios/artifacts.zip", +/// "realm": "production" +/// }, +final class GlobalArchive extends BuildConfigBase { + factory GlobalArchive.fromJson(Map map) { + final List errors = []; + final String? source = stringOfJson(map, 'source', errors); + final String? destination = stringOfJson(map, 'destination', errors); + final String? realm = stringOfJson(map, 'realm', errors); + if (source == null || + destination == null || + realm == null) { + return GlobalArchive._invalid(errors); + } + return GlobalArchive._(source, destination, realm); + } + + GlobalArchive._invalid(super.error) : + source = '', + destination = '', + realm = ''; + + GlobalArchive._(this.source, this.destination, this.realm) : + super(null); + + /// The path of the artifact relative to the engine checkout. + final String source; + + /// The destination folder in the storage bucket. + final String destination; + + /// Which storage bucket the destination path is relative to. + /// Either "production" or "experimental". + final String realm; +} + + +void appendTypeError( + Map map, + String field, + String expected, + List errors, { + Object? element, +}) { + if (element == null) { + final Type actual = map[field]!.runtimeType; + errors.add( + 'For field "$field", expected type: $expected, actual type: $actual.', + ); + } else { + final Type actual = element.runtimeType; + errors.add( + 'For element "$element" of "$field", ' + 'expected type: $expected, actual type: $actual', + ); + } +} + + +List? objListOfJson( + Map map, + String field, + List errors, + T Function(Map) fn, +) { + if (map[field] == null) { + return []; + } + if (map[field]! is! List) { + appendTypeError(map, field, 'list', errors); + return null; + } + for (final Object? obj in map[field]! as List) { + if (obj is! Map) { + appendTypeError(map, field, 'map', errors); + return null; + } + } + return (map[field]! as List) + .cast>().map(fn).toList(); +} + +List? stringListOfJson( + Map map, + String field, + List errors, +) { + if (map[field] == null) { + return []; + } + if (map[field]! is! List) { + appendTypeError(map, field, 'list', errors); + return null; + } + for (final Object? obj in map[field]! as List) { + if (obj is! String) { + appendTypeError(map, field, element: obj, 'string', errors); + return null; + } + } + return (map[field]! as List).cast(); +} + +String? stringOfJson( + Map map, + String field, + List errors, +) { + if (map[field] == null) { + return ''; + } + if (map[field]! is! String) { + appendTypeError(map, field, 'string', errors); + return null; + } + return map[field]! as String; +} + +int? intOfJson( + Map map, + String field, + List errors, { + int fallback = 0, +}) { + if (map[field] == null) { + return fallback; + } + if (map[field]! is! int) { + appendTypeError(map, field, 'int', errors); + return null; + } + return map[field]! as int; +} diff --git a/tools/pkg/engine_build_configs/lib/src/build_config_loader.dart b/tools/pkg/engine_build_configs/lib/src/build_config_loader.dart new file mode 100644 index 0000000000000..760db4eb3808a --- /dev/null +++ b/tools/pkg/engine_build_configs/lib/src/build_config_loader.dart @@ -0,0 +1,70 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert' as convert; +import 'dart:io' as io show Directory, File; + +import 'package:path/path.dart' as p; + +import 'build_config.dart'; + +/// This is a utility class for reading all of the build configurations from +/// a subdirectory of the engine repo. After building an instance of this class, +/// the build configurations can be accessed on the [configs] getter. +class BuildConfigLoader { + BuildConfigLoader({required this.buildConfigsDir}); + + /// Any errors encountered while parsing and loading the build config files + /// are accumulated in this list as strings. It should be checked for errors + /// after the first access to the [configs] getter. + final List errors = []; + + /// The directory where the engine's build config .json files exist. + final io.Directory buildConfigsDir; + + /// Walks [buildConfigsDir] looking for .json files, which it attempts to + /// parse as engine build configs. JSON parsing errors during this process + /// are added as strings to the [errors] list. That last should be checked + /// for errors after accessing this getter. + /// + /// The [BuildConfig]s given by this getter should be further checked for + /// validity by calling `BuildConfig.check()` on each one. See + /// `bin/check.dart` for an example. + late final Map configs = (){ + return _parseAllBuildConfigs(buildConfigsDir); + }(); + + Map _parseAllBuildConfigs(io.Directory dir) { + final Map result = {}; + if (!dir.existsSync()) { + errors.add('${buildConfigsDir.path} does not exist.'); + return result; + } + final List jsonFiles = dir + .listSync(recursive: true) + .whereType() + .where((io.File f) => f.path.endsWith('.json')) + .toList(); + for (final io.File jsonFile in jsonFiles) { + final String basename = p.basename(jsonFile.path); + final String name = basename.substring( + 0, basename.length - 5, + ); + final String jsonData = jsonFile.readAsStringSync(); + final dynamic maybeJson; + try { + maybeJson = convert.jsonDecode(jsonData); + } on FormatException catch (e) { + errors.add('While parsing ${jsonFile.path}:\n$e'); + continue; + } + if (maybeJson is! Map) { + errors.add('${jsonFile.path} did not contain a json map.'); + continue; + } + result[name] = BuildConfig.fromJson(path: jsonFile.path, map: maybeJson); + } + return result; + } +} diff --git a/tools/pkg/engine_build_configs/pubspec.yaml b/tools/pkg/engine_build_configs/pubspec.yaml new file mode 100644 index 0000000000000..b093547b0ed49 --- /dev/null +++ b/tools/pkg/engine_build_configs/pubspec.yaml @@ -0,0 +1,61 @@ +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +name: engine_build_configs +publish_to: none +environment: + sdk: '>=3.1.0-0 <4.0.0' + +# Do not add any dependencies that require more than what is provided in +# //third_party/pkg, //third_party/dart/pkg, or +# //third_party/dart/third_party/pkg. In particular, package:test is not usable +# here. + +# If you do add packages here, make sure you can run `pub get --offline`, and +# check the .packages and .package_config to make sure all the paths are +# relative to this directory into //third_party/dart + +dependencies: + args: any + engine_repo_tools: + path: ../engine_repo_tools + file: any + meta: any + path: any + platform: any + process_runner: any + +dev_dependencies: + async_helper: any + expect: any + litetest: any + smith: any + +dependency_overrides: + args: + path: ../../../../third_party/dart/third_party/pkg/args + async: + path: ../../../../third_party/dart/third_party/pkg/async + async_helper: + path: ../../../../third_party/dart/pkg/async_helper + collection: + path: ../../../../third_party/dart/third_party/pkg/collection + expect: + path: ../../../../third_party/dart/pkg/expect + file: + path: ../../../../third_party/pkg/file/packages/file + litetest: + path: ../../../testing/litetest + meta: + path: ../../../../third_party/dart/pkg/meta + path: + path: ../../../../third_party/dart/third_party/pkg/path + platform: + path: ../../../../third_party/pkg/platform + process: + path: ../../../../third_party/pkg/process + process_runner: + path: ../../../../third_party/pkg/process_runner + smith: + path: ../../../../third_party/dart/pkg/smith diff --git a/tools/pkg/engine_build_configs/test/build_config_loader_test.dart b/tools/pkg/engine_build_configs/test/build_config_loader_test.dart new file mode 100644 index 0000000000000..80e32bcbd00ee --- /dev/null +++ b/tools/pkg/engine_build_configs/test/build_config_loader_test.dart @@ -0,0 +1,128 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:engine_build_configs/src/build_config.dart'; +import 'package:engine_build_configs/src/build_config_loader.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:litetest/litetest.dart'; + +const String buildConfigJson = ''' +{ + "builds": [ + { + "archives": [ + { + "name": "build_name", + "base_path": "base/path", + "type": "gcs", + "include_paths": ["include/path"], + "realm": "archive_realm" + } + ], + "drone_dimensions": ["dimension"], + "gclient_variables": { + "variable": false + }, + "gn": ["--gn-arg"], + "name": "build_name", + "ninja": { + "config": "build_name", + "targets": ["ninja_target"] + }, + "tests": [ + { + "language": "python3", + "name": "build_name tests", + "parameters": ["--test-params"], + "script": "test/script.py", + "contexts": ["context"] + } + ], + "generators": { + "tasks": [ + { + "name": "generator_task", + "parameters": ["--gen-param"], + "scripts": ["gen/script.py"] + } + ] + } + } + ], + "generators": { + "tasks": [ + { + "name": "global generator task", + "parameters": ["--global-gen-param"], + "script": "global/gen_script.dart", + "language": "dart" + } + ] + }, + "tests": [ + { + "name": "global test", + "recipe": "engine_v2/tester_engine", + "drone_dimensions": ["dimension"], + "gclient_variables": { + "variable": false + }, + "dependencies": ["dependency"], + "test_dependencies": [ + { + "dependency": "test_dependency", + "version": "git_revision:3a77d0b12c697a840ca0c7705208e8622dc94603" + } + ], + "tasks": [ + { + "name": "global test task", + "parameters": ["--test-parameter"], + "script": "global/test/script.py" + } + ] + } + ] +} +'''; + +int main() { + test('BuildConfigLoader can load a build config', () { + final FileSystem fs = MemoryFileSystem(); + final String buildConfigPath = fs.path.join('flutter', 'ci', 'builders'); + final Directory buildConfigsDir = fs.directory(buildConfigPath); + final File buildConfigFile = buildConfigsDir.childFile( + 'linux_test_build.json', + ); + buildConfigFile.create(recursive: true); + buildConfigFile.writeAsStringSync(buildConfigJson); + + final BuildConfigLoader loader = BuildConfigLoader( + buildConfigsDir: buildConfigsDir, + ); + + expect(loader.configs, isNotNull); + expect(loader.errors, isEmpty); + expect(loader.configs['linux_test_build'], isNotNull); + }); + + test('BuildConfigLoader gives an empty config when no configs found', () { + final FileSystem fs = MemoryFileSystem(); + final String buildConfigPath = fs.path.join( + 'flutter', 'ci', 'builders', 'linux_test_build.json', + ); + final Directory buildConfigsDir = fs.directory(buildConfigPath); + final BuildConfigLoader loader = BuildConfigLoader( + buildConfigsDir: buildConfigsDir, + ); + + expect(loader.configs, isNotNull); + expect(loader.errors[0], equals( + 'flutter/ci/builders/linux_test_build.json does not exist.', + )); + expect(loader.configs, equals({})); + }); + return 0; +} diff --git a/tools/pkg/engine_build_configs/test/build_config_test.dart b/tools/pkg/engine_build_configs/test/build_config_test.dart new file mode 100644 index 0000000000000..20220aad98c9b --- /dev/null +++ b/tools/pkg/engine_build_configs/test/build_config_test.dart @@ -0,0 +1,368 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert' as convert; + +import 'package:engine_build_configs/src/build_config.dart'; +import 'package:litetest/litetest.dart'; + +const String buildConfigJson = ''' +{ + "builds": [ + { + "archives": [ + { + "name": "build_name", + "base_path": "base/path", + "type": "gcs", + "include_paths": ["include/path"], + "realm": "archive_realm" + } + ], + "drone_dimensions": ["dimension"], + "gclient_variables": { + "variable": false + }, + "gn": ["--gn-arg"], + "name": "build_name", + "ninja": { + "config": "build_name", + "targets": ["ninja_target"] + }, + "tests": [ + { + "language": "python3", + "name": "build_name tests", + "parameters": ["--test-params"], + "script": "test/script.py", + "contexts": ["context"] + } + ], + "generators": { + "tasks": [ + { + "name": "generator_task", + "parameters": ["--gen-param"], + "scripts": ["gen/script.py"] + } + ] + } + } + ], + "generators": { + "tasks": [ + { + "name": "global generator task", + "parameters": ["--global-gen-param"], + "script": "global/gen_script.dart", + "language": "dart" + } + ] + }, + "tests": [ + { + "name": "global test", + "recipe": "engine_v2/tester_engine", + "drone_dimensions": ["dimension"], + "gclient_variables": { + "variable": false + }, + "dependencies": ["dependency"], + "test_dependencies": [ + { + "dependency": "test_dependency", + "version": "git_revision:3a77d0b12c697a840ca0c7705208e8622dc94603" + } + ], + "tasks": [ + { + "name": "global test task", + "parameters": ["--test-parameter"], + "script": "global/test/script.py" + } + ] + } + ] +} +'''; + +int main() { + test('BuildConfig parser works', () { + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(buildConfigJson) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.errors, isNull); + expect(buildConfig.builds.length, equals(1)); + + final GlobalBuild globalBuild = buildConfig.builds[0]; + expect(globalBuild.name, equals('build_name')); + expect(globalBuild.gn.length, equals(1)); + expect(globalBuild.gn[0], equals('--gn-arg')); + expect(globalBuild.droneDimensions.length, equals(1)); + expect(globalBuild.droneDimensions[0], equals('dimension')); + + final BuildNinja ninja = globalBuild.ninja; + expect(ninja.config, equals('build_name')); + expect(ninja.targets.length, equals(1)); + expect(ninja.targets[0], equals('ninja_target')); + + expect(globalBuild.archives.length, equals(1)); + final BuildArchive buildArchive = globalBuild.archives[0]; + expect(buildArchive.name, equals('build_name')); + expect(buildArchive.basePath, equals('base/path')); + expect(buildArchive.type, equals('gcs')); + expect(buildArchive.includePaths.length, equals(1)); + expect(buildArchive.includePaths[0], equals('include/path')); + + expect(globalBuild.tests.length, equals(1)); + final BuildTest tst = globalBuild.tests[0]; + expect(tst.name, equals('build_name tests')); + expect(tst.language, equals('python3')); + expect(tst.script, equals('test/script.py')); + expect(tst.parameters.length, equals(1)); + expect(tst.parameters[0], equals('--test-params')); + expect(tst.contexts.length, equals(1)); + expect(tst.contexts[0], equals('context')); + + expect(globalBuild.generators.length, equals(1)); + final BuildTask buildTask = globalBuild.generators[0]; + expect(buildTask.name, equals('generator_task')); + expect(buildTask.scripts.length, equals(1)); + expect(buildTask.scripts[0], equals('gen/script.py')); + expect(buildTask.parameters.length, equals(1)); + expect(buildTask.parameters[0], equals('--gen-param')); + + expect(buildConfig.generators.length, equals(1)); + final TestTask testTask = buildConfig.generators[0]; + expect(testTask.name, equals('global generator task')); + expect(testTask.language, equals('dart')); + expect(testTask.script, equals('global/gen_script.dart')); + expect(testTask.parameters.length, equals(1)); + expect(testTask.parameters[0], equals('--global-gen-param')); + + expect(buildConfig.tests.length, equals(1)); + final GlobalTest globalTest = buildConfig.tests[0]; + expect(globalTest.name, equals('global test')); + expect(globalTest.recipe, equals('engine_v2/tester_engine')); + expect(globalTest.droneDimensions.length, equals(1)); + expect(globalTest.droneDimensions[0], equals('dimension')); + expect(globalTest.dependencies.length, equals(1)); + expect(globalTest.dependencies[0], equals('dependency')); + + expect(globalTest.tasks.length, equals(1)); + final TestTask globalTestTask = globalTest.tasks[0]; + expect(globalTestTask.name, equals('global test task')); + expect(globalTestTask.script, equals('global/test/script.py')); + expect(globalTestTask.language, equals('')); + }); + + test('BuildConfig flags invalid input', () { + const String invalidInput = ''' +{ + "builds": 5, + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isFalse); + expect(buildConfig.errors![0], equals( + 'For field "builds", expected type: list, actual type: int.', + )); + }); + + test('GlobalBuild flags invalid input', () { + const String invalidInput = ''' +{ + "builds": [ + { + "name": 5 + } + ], + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.builds.length, equals(1)); + expect(buildConfig.builds[0].valid, isFalse); + expect(buildConfig.builds[0].errors![0], equals( + 'For field "name", expected type: string, actual type: int.', + )); + }); + + test('BuildNinja flags invalid input', () { + const String invalidInput = ''' +{ + "builds": [ + { + "ninja": { + "config": 5 + } + } + ], + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.builds.length, equals(1)); + expect(buildConfig.builds[0].valid, isTrue); + expect(buildConfig.builds[0].ninja.valid, isFalse); + expect(buildConfig.builds[0].ninja.errors![0], equals( + 'For field "config", expected type: string, actual type: int.', + )); + }); + + test('BuildTest flags invalid input', () { + const String invalidInput = ''' +{ + "builds": [ + { + "tests": [ + { + "language": 5 + } + ] + } + ], + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.builds.length, equals(1)); + expect(buildConfig.builds[0].valid, isTrue); + expect(buildConfig.builds[0].tests[0].valid, isFalse); + expect(buildConfig.builds[0].tests[0].errors![0], equals( + 'For field "language", expected type: string, actual type: int.', + )); + }); + + test('BuildTask flags invalid input', () { + const String invalidInput = ''' +{ + "builds": [ + { + "generators": { + "tasks": [ + { + "name": 5 + } + ] + } + } + ], + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.builds.length, equals(1)); + expect(buildConfig.builds[0].valid, isTrue); + expect(buildConfig.builds[0].generators[0].valid, isFalse); + expect(buildConfig.builds[0].generators[0].errors![0], equals( + 'For field "name", expected type: string, actual type: int.', + )); + }); + + test('BuildArchive flags invalid input', () { + const String invalidInput = ''' +{ + "builds": [ + { + "archives": [ + { + "name": 5 + } + ] + } + ], + "generators": {}, + "tests": [] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.builds.length, equals(1)); + expect(buildConfig.builds[0].valid, isTrue); + expect(buildConfig.builds[0].archives[0].valid, isFalse); + expect(buildConfig.builds[0].archives[0].errors![0], equals( + 'For field "name", expected type: string, actual type: int.', + )); + }); + + test('GlobalTest flags invalid input', () { + const String invalidInput = ''' +{ + "tests": [ + { + "name": 5 + } + ] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.tests.length, equals(1)); + expect(buildConfig.tests[0].valid, isFalse); + expect(buildConfig.tests[0].errors![0], equals( + 'For field "name", expected type: string, actual type: int.', + )); + }); + + test('TestTask flags invalid input', () { + const String invalidInput = ''' +{ + "tests": [ + { + "tasks": [ + { + "name": 5 + } + ] + } + ] +} +'''; + final BuildConfig buildConfig = BuildConfig.fromJson( + path: 'linux_test_config', + map: convert.jsonDecode(invalidInput) as Map, + ); + expect(buildConfig.valid, isTrue); + expect(buildConfig.tests.length, equals(1)); + expect(buildConfig.tests[0].tasks[0].valid, isFalse); + expect(buildConfig.tests[0].tasks[0].errors![0], contains( + 'For field "name", expected type: string, actual type: int.', + )); + }); + return 0; +} diff --git a/tools/pub_get_offline.py b/tools/pub_get_offline.py index c0e7eedba7fe1..17f529c61c805 100644 --- a/tools/pub_get_offline.py +++ b/tools/pub_get_offline.py @@ -42,6 +42,7 @@ os.path.join(ENGINE_DIR, 'tools', 'githooks'), os.path.join(ENGINE_DIR, 'tools', 'licenses'), os.path.join(ENGINE_DIR, 'tools', 'path_ops', 'dart'), + os.path.join(ENGINE_DIR, 'tools', 'pkg', 'engine_build_configs'), os.path.join(ENGINE_DIR, 'tools', 'pkg', 'engine_repo_tools'), ]