1#!/usr/bin/env python
2
3# Copyright 2017 The Glslang Authors. All rights reserved.
4# Copyright (c) 2018 Valve Corporation
5# Copyright (c) 2018 LunarG, Inc.
6#
7# Licensed under the Apache License, Version 2.0 (the "License");
8# you may not use this file except in compliance with the License.
9# You may obtain a copy of the License at
10#
11#     http://www.apache.org/licenses/LICENSE-2.0
12#
13# Unless required by applicable law or agreed to in writing, software
14# distributed under the License is distributed on an "AS IS" BASIS,
15# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16# See the License for the specific language governing permissions and
17# limitations under the License.
18
19# This script was heavily leveraged from KhronosGroup/glslang
20# update_glslang_sources.py.
21"""update_deps.py
22
23Get and build dependent repositories using known-good commits.
24
25Purpose
26-------
27
28This program is intended to assist a developer of this repository
29(the "home" repository) by gathering and building the repositories that
30this home repository depend on.  It also checks out each dependent
31repository at a "known-good" commit in order to provide stability in
32the dependent repositories.
33
34Python Compatibility
35--------------------
36
37This program can be used with Python 2.7 and Python 3.
38
39Known-Good JSON Database
40------------------------
41
42This program expects to find a file named "known-good.json" in the
43same directory as the program file.  This JSON file is tailored for
44the needs of the home repository by including its dependent repositories.
45
46Program Options
47---------------
48
49See the help text (update_deps.py --help) for a complete list of options.
50
51Program Operation
52-----------------
53
54The program uses the user's current directory at the time of program
55invocation as the location for fetching and building the dependent
56repositories.  The user can override this by using the "--dir" option.
57
58For example, a directory named "build" in the repository's root directory
59is a good place to put the dependent repositories because that directory
60is not tracked by Git. (See the .gitignore file.)  The "external" directory
61may also be a suitable location.
62A user can issue:
63
64$ cd My-Repo
65$ mkdir build
66$ cd build
67$ ../scripts/update_deps.py
68
69or, to do the same thing, but using the --dir option:
70
71$ cd My-Repo
72$ mkdir build
73$ scripts/update_deps.py --dir=build
74
75With these commands, the "build" directory is considered the "top"
76directory where the program clones the dependent repositories.  The
77JSON file configures the build and install working directories to be
78within this "top" directory.
79
80Note that the "dir" option can also specify an absolute path:
81
82$ cd My-Repo
83$ scripts/update_deps.py --dir=/tmp/deps
84
85The "top" dir is then /tmp/deps (Linux filesystem example) and is
86where this program will clone and build the dependent repositories.
87
88Helper CMake Config File
89------------------------
90
91When the program finishes building the dependencies, it writes a file
92named "helper.cmake" to the "top" directory that contains CMake commands
93for setting CMake variables for locating the dependent repositories.
94This helper file can be used to set up the CMake build files for this
95"home" repository.
96
97A complete sequence might look like:
98
99$ git clone git@github.com:My-Group/My-Repo.git
100$ cd My-Repo
101$ mkdir build
102$ cd build
103$ ../scripts/update_deps.py
104$ cmake -C helper.cmake ..
105$ cmake --build .
106
107JSON File Schema
108----------------
109
110There's no formal schema for the "known-good" JSON file, but here is
111a description of its elements.  All elements are required except those
112marked as optional.  Please see the "known_good.json" file for
113examples of all of these elements.
114
115- name
116
117The name of the dependent repository.  This field can be referenced
118by the "deps.repo_name" structure to record a dependency.
119
120- url
121
122Specifies the URL of the repository.
123Example: https://github.com/KhronosGroup/Vulkan-Loader.git
124
125- sub_dir
126
127The directory where the program clones the repository, relative to
128the "top" directory.
129
130- build_dir
131
132The directory used to build the repository, relative to the "top"
133directory.
134
135- install_dir
136
137The directory used to store the installed build artifacts, relative
138to the "top" directory.
139
140- commit
141
142The commit used to checkout the repository.  This can be a SHA-1
143object name or a refname used with the remote name "origin".
144For example, this field can be set to "origin/sdk-1.1.77" to
145select the end of the sdk-1.1.77 branch.
146
147- deps (optional)
148
149An array of pairs consisting of a CMake variable name and a
150repository name to specify a dependent repo and a "link" to
151that repo's install artifacts.  For example:
152
153"deps" : [
154    {
155        "var_name" : "VULKAN_HEADERS_INSTALL_DIR",
156        "repo_name" : "Vulkan-Headers"
157    }
158]
159
160which represents that this repository depends on the Vulkan-Headers
161repository and uses the VULKAN_HEADERS_INSTALL_DIR CMake variable to
162specify the location where it expects to find the Vulkan-Headers install
163directory.
164Note that the "repo_name" element must match the "name" element of some
165other repository in the JSON file.
166
167- prebuild (optional)
168- prebuild_linux (optional)  (For Linux and MacOS)
169- prebuild_windows (optional)
170
171A list of commands to execute before building a dependent repository.
172This is useful for repositories that require the execution of some
173sort of "update" script or need to clone an auxillary repository like
174googletest.
175
176The commands listed in "prebuild" are executed first, and then the
177commands for the specific platform are executed.
178
179- custom_build (optional)
180
181A list of commands to execute as a custom build instead of using
182the built in CMake way of building. Requires "build_step" to be
183set to "custom"
184
185You can insert the following keywords into the commands listed in
186"custom_build" if they require runtime information (like whether the
187build config is "Debug" or "Release").
188
189Keywords:
190{0} reference to a dictionary of repos and their attributes
191{1} reference to the command line arguments set before start
192{2} reference to the CONFIG_MAP value of config.
193
194Example:
195{2} returns the CONFIG_MAP value of config e.g. debug -> Debug
196{1}.config returns the config variable set when you ran update_dep.py
197{0}[Vulkan-Headers][repo_root] returns the repo_root variable from
198                                   the Vulkan-Headers GoodRepo object.
199
200- cmake_options (optional)
201
202A list of options to pass to CMake during the generation phase.
203
204- ci_only (optional)
205
206A list of environment variables where one must be set to "true"
207(case-insensitive) in order for this repo to be fetched and built.
208This list can be used to specify repos that should be built only in CI.
209Typically, this list might contain "TRAVIS" and/or "APPVEYOR" because
210each of these CI systems sets an environment variable with its own
211name to "true".  Note that this could also be (ab)used to control
212the processing of the repo with any environment variable.  The default
213is an empty list, which means that the repo is always processed.
214
215- build_step (optional)
216
217Specifies if the dependent repository should be built or not. This can
218have a value of 'build', 'custom',  or 'skip'. The dependent repositories are
219built by default.
220
221- build_platforms (optional)
222
223A list of platforms the repository will be built on.
224Legal options include:
225"windows"
226"linux"
227"darwin"
228
229Builds on all platforms by default.
230
231Note
232----
233
234The "sub_dir", "build_dir", and "install_dir" elements are all relative
235to the effective "top" directory.  Specifying absolute paths is not
236supported.  However, the "top" directory specified with the "--dir"
237option can be a relative or absolute path.
238
239"""
240
241from __future__ import print_function
242
243import argparse
244import json
245import distutils.dir_util
246import os.path
247import subprocess
248import sys
249import platform
250import multiprocessing
251import shlex
252import shutil
253
254KNOWN_GOOD_FILE_NAME = 'known_good.json'
255
256CONFIG_MAP = {
257    'debug': 'Debug',
258    'release': 'Release',
259    'relwithdebinfo': 'RelWithDebInfo',
260    'minsizerel': 'MinSizeRel'
261}
262
263VERBOSE = False
264
265DEVNULL = open(os.devnull, 'wb')
266
267
268def command_output(cmd, directory, fail_ok=False):
269    """Runs a command in a directory and returns its standard output stream.
270
271    Captures the standard error stream and prints it if error.
272
273    Raises a RuntimeError if the command fails to launch or otherwise fails.
274    """
275    if VERBOSE:
276        print('In {d}: {cmd}'.format(d=directory, cmd=cmd))
277    p = subprocess.Popen(
278        cmd, cwd=directory, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
279    (stdout, stderr) = p.communicate()
280    if p.returncode != 0:
281        print('*** Error ***\nstderr contents:\n{}'.format(stderr))
282        if not fail_ok:
283            raise RuntimeError('Failed to run {} in {}'.format(cmd, directory))
284    if VERBOSE:
285        print(stdout)
286    return stdout
287
288class GoodRepo(object):
289    """Represents a repository at a known-good commit."""
290
291    def __init__(self, json, args):
292        """Initializes this good repo object.
293
294        Args:
295        'json':  A fully populated JSON object describing the repo.
296        'args':  Results from ArgumentParser
297        """
298        self._json = json
299        self._args = args
300        # Required JSON elements
301        self.name = json['name']
302        self.url = json['url']
303        self.sub_dir = json['sub_dir']
304        self.commit = json['commit']
305        # Optional JSON elements
306        self.build_dir = None
307        self.install_dir = None
308        if json.get('build_dir'):
309            self.build_dir = os.path.normpath(json['build_dir'])
310        if json.get('install_dir'):
311            self.install_dir = os.path.normpath(json['install_dir'])
312        self.deps = json['deps'] if ('deps' in json) else []
313        self.prebuild = json['prebuild'] if ('prebuild' in json) else []
314        self.prebuild_linux = json['prebuild_linux'] if (
315            'prebuild_linux' in json) else []
316        self.prebuild_windows = json['prebuild_windows'] if (
317            'prebuild_windows' in json) else []
318        self.custom_build = json['custom_build'] if ('custom_build' in json) else []
319        self.cmake_options = json['cmake_options'] if (
320            'cmake_options' in json) else []
321        self.ci_only = json['ci_only'] if ('ci_only' in json) else []
322        self.build_step = json['build_step'] if ('build_step' in json) else 'build'
323        self.build_platforms = json['build_platforms'] if ('build_platforms' in json) else []
324        # Absolute paths for a repo's directories
325        dir_top = os.path.abspath(args.dir)
326        self.repo_dir = os.path.join(dir_top, self.sub_dir)
327        if self.build_dir:
328            self.build_dir = os.path.join(dir_top, self.build_dir)
329        if self.install_dir:
330            self.install_dir = os.path.join(dir_top, self.install_dir)
331	    # Check if platform is one to build on
332        self.on_build_platform = False
333        if self.build_platforms == [] or platform.system().lower() in self.build_platforms:
334            self.on_build_platform = True
335
336    def Clone(self):
337        distutils.dir_util.mkpath(self.repo_dir)
338        command_output(['git', 'clone', self.url, '.'], self.repo_dir)
339
340    def Fetch(self):
341        command_output(['git', 'fetch', 'origin'], self.repo_dir)
342
343    def Checkout(self):
344        print('Checking out {n} in {d}'.format(n=self.name, d=self.repo_dir))
345        if self._args.do_clean_repo:
346            shutil.rmtree(self.repo_dir, ignore_errors=True)
347        if not os.path.exists(os.path.join(self.repo_dir, '.git')):
348            self.Clone()
349        self.Fetch()
350        if len(self._args.ref):
351            command_output(['git', 'checkout', self._args.ref], self.repo_dir)
352        else:
353            command_output(['git', 'checkout', self.commit], self.repo_dir)
354        print(command_output(['git', 'status'], self.repo_dir))
355
356    def CustomPreProcess(self, cmd_str, repo_dict):
357        return cmd_str.format(repo_dict, self._args, CONFIG_MAP[self._args.config])
358
359    def PreBuild(self):
360        """Execute any prebuild steps from the repo root"""
361        for p in self.prebuild:
362            command_output(shlex.split(p), self.repo_dir)
363        if platform.system() == 'Linux' or platform.system() == 'Darwin':
364            for p in self.prebuild_linux:
365                command_output(shlex.split(p), self.repo_dir)
366        if platform.system() == 'Windows':
367            for p in self.prebuild_windows:
368                command_output(shlex.split(p), self.repo_dir)
369
370    def CustomBuild(self, repo_dict):
371        """Execute any custom_build steps from the repo root"""
372        for p in self.custom_build:
373            cmd = self.CustomPreProcess(p, repo_dict)
374            command_output(shlex.split(cmd), self.repo_dir)
375
376    def CMakeConfig(self, repos):
377        """Build CMake command for the configuration phase and execute it"""
378        if self._args.do_clean_build:
379            shutil.rmtree(self.build_dir)
380        if self._args.do_clean_install:
381            shutil.rmtree(self.install_dir)
382
383        # Create and change to build directory
384        distutils.dir_util.mkpath(self.build_dir)
385        os.chdir(self.build_dir)
386
387        cmake_cmd = [
388            'cmake', self.repo_dir,
389            '-DCMAKE_INSTALL_PREFIX=' + self.install_dir
390        ]
391
392        # For each repo this repo depends on, generate a CMake variable
393        # definitions for "...INSTALL_DIR" that points to that dependent
394        # repo's install dir.
395        for d in self.deps:
396            dep_commit = [r for r in repos if r.name == d['repo_name']]
397            if len(dep_commit):
398                cmake_cmd.append('-D{var_name}={install_dir}'.format(
399                    var_name=d['var_name'],
400                    install_dir=dep_commit[0].install_dir))
401
402        # Add any CMake options
403        for option in self.cmake_options:
404            cmake_cmd.append(option)
405
406        # Set build config for single-configuration generators
407        if platform.system() == 'Linux' or platform.system() == 'Darwin':
408            cmake_cmd.append('-DCMAKE_BUILD_TYPE={config}'.format(
409                config=CONFIG_MAP[self._args.config]))
410
411        # Use the CMake -A option to select the platform architecture
412        # without needing a Visual Studio generator.
413        if platform.system() == 'Windows':
414            if self._args.arch == '64' or self._args.arch == 'x64' or self._args.arch == 'win64':
415                cmake_cmd.append('-A')
416                cmake_cmd.append('x64')
417
418        # Apply a generator, if one is specified.  This can be used to supply
419        # a specific generator for the dependent repositories to match
420        # that of the main repository.
421        if self._args.generator is not None:
422            cmake_cmd.extend(['-G', self._args.generator])
423
424        if VERBOSE:
425            print("CMake command: " + " ".join(cmake_cmd))
426
427        ret_code = subprocess.call(cmake_cmd)
428        if ret_code != 0:
429            sys.exit(ret_code)
430
431    def CMakeBuild(self):
432        """Build CMake command for the build phase and execute it"""
433        cmake_cmd = ['cmake', '--build', self.build_dir, '--target', 'install']
434        if self._args.do_clean:
435            cmake_cmd.append('--clean-first')
436
437        if platform.system() == 'Windows':
438            cmake_cmd.append('--config')
439            cmake_cmd.append(CONFIG_MAP[self._args.config])
440
441        # Speed up the build.
442        if platform.system() == 'Linux' or platform.system() == 'Darwin':
443            cmake_cmd.append('--')
444            num_make_jobs = multiprocessing.cpu_count()
445            env_make_jobs = os.environ.get('MAKE_JOBS', None)
446            if env_make_jobs is not None:
447                try:
448                    num_make_jobs = min(num_make_jobs, int(env_make_jobs))
449                except ValueError:
450                    print('warning: environment variable MAKE_JOBS has non-numeric value "{}".  '
451                          'Using {} (CPU count) instead.'.format(env_make_jobs, num_make_jobs))
452            cmake_cmd.append('-j{}'.format(num_make_jobs))
453        if platform.system() == 'Windows':
454            cmake_cmd.append('--')
455            cmake_cmd.append('/maxcpucount')
456
457        if VERBOSE:
458            print("CMake command: " + " ".join(cmake_cmd))
459
460        ret_code = subprocess.call(cmake_cmd)
461        if ret_code != 0:
462            sys.exit(ret_code)
463
464    def Build(self, repos, repo_dict):
465        """Build the dependent repo"""
466        print('Building {n} in {d}'.format(n=self.name, d=self.repo_dir))
467        print('Build dir = {b}'.format(b=self.build_dir))
468        print('Install dir = {i}\n'.format(i=self.install_dir))
469
470        # Run any prebuild commands
471        self.PreBuild()
472
473        if self.build_step == 'custom':
474            self.CustomBuild(repo_dict)
475            return
476
477        # Build and execute CMake command for creating build files
478        self.CMakeConfig(repos)
479
480        # Build and execute CMake command for the build
481        self.CMakeBuild()
482
483
484def GetGoodRepos(args):
485    """Returns the latest list of GoodRepo objects.
486
487    The known-good file is expected to be in the same
488    directory as this script unless overridden by the 'known_good_dir'
489    parameter.
490    """
491    if args.known_good_dir:
492        known_good_file = os.path.join( os.path.abspath(args.known_good_dir),
493            KNOWN_GOOD_FILE_NAME)
494    else:
495        known_good_file = os.path.join(
496            os.path.dirname(os.path.abspath(__file__)), KNOWN_GOOD_FILE_NAME)
497    with open(known_good_file) as known_good:
498        return [
499            GoodRepo(repo, args)
500            for repo in json.loads(known_good.read())['repos']
501        ]
502
503
504def GetInstallNames(args):
505    """Returns the install names list.
506
507    The known-good file is expected to be in the same
508    directory as this script unless overridden by the 'known_good_dir'
509    parameter.
510    """
511    if args.known_good_dir:
512        known_good_file = os.path.join(os.path.abspath(args.known_good_dir),
513            KNOWN_GOOD_FILE_NAME)
514    else:
515        known_good_file = os.path.join(
516            os.path.dirname(os.path.abspath(__file__)), KNOWN_GOOD_FILE_NAME)
517    with open(known_good_file) as known_good:
518        install_info = json.loads(known_good.read())
519        if install_info.get('install_names'):
520            return install_info['install_names']
521        else:
522            return None
523
524
525def CreateHelper(args, repos, filename):
526    """Create a CMake config helper file.
527
528    The helper file is intended to be used with 'cmake -C <file>'
529    to build this home repo using the dependencies built by this script.
530
531    The install_names dictionary represents the CMake variables used by the
532    home repo to locate the install dirs of the dependent repos.
533    This information is baked into the CMake files of the home repo and so
534    this dictionary is kept with the repo via the json file.
535    """
536    def escape(path):
537        return path.replace('\\', '\\\\')
538    install_names = GetInstallNames(args)
539    with open(filename, 'w') as helper_file:
540        for repo in repos:
541            if install_names and repo.name in install_names and repo.on_build_platform:
542                helper_file.write('set({var} "{dir}" CACHE STRING "" FORCE)\n'
543                                  .format(
544                                      var=install_names[repo.name],
545                                      dir=escape(repo.install_dir)))
546
547
548def main():
549    parser = argparse.ArgumentParser(
550        description='Get and build dependent repos at known-good commits')
551    parser.add_argument(
552        '--known_good_dir',
553        dest='known_good_dir',
554        help="Specify directory for known_good.json file.")
555    parser.add_argument(
556        '--dir',
557        dest='dir',
558        default='.',
559        help="Set target directory for repository roots. Default is \'.\'.")
560    parser.add_argument(
561        '--ref',
562        dest='ref',
563        default='',
564        help="Override 'commit' with git reference. E.g., 'origin/master'")
565    parser.add_argument(
566        '--no-build',
567        dest='do_build',
568        action='store_false',
569        help=
570        "Clone/update repositories and generate build files without performing compilation",
571        default=True)
572    parser.add_argument(
573        '--clean',
574        dest='do_clean',
575        action='store_true',
576        help="Clean files generated by compiler and linker before building",
577        default=False)
578    parser.add_argument(
579        '--clean-repo',
580        dest='do_clean_repo',
581        action='store_true',
582        help="Delete repository directory before building",
583        default=False)
584    parser.add_argument(
585        '--clean-build',
586        dest='do_clean_build',
587        action='store_true',
588        help="Delete build directory before building",
589        default=False)
590    parser.add_argument(
591        '--clean-install',
592        dest='do_clean_install',
593        action='store_true',
594        help="Delete install directory before building",
595        default=False)
596    parser.add_argument(
597        '--arch',
598        dest='arch',
599        choices=['32', '64', 'x86', 'x64', 'win32', 'win64'],
600        type=str.lower,
601        help="Set build files architecture (Windows)",
602        default='64')
603    parser.add_argument(
604        '--config',
605        dest='config',
606        choices=['debug', 'release', 'relwithdebinfo', 'minsizerel'],
607        type=str.lower,
608        help="Set build files configuration",
609        default='debug')
610    parser.add_argument(
611        '--generator',
612        dest='generator',
613        help="Set the CMake generator",
614        default=None)
615
616    args = parser.parse_args()
617    save_cwd = os.getcwd()
618
619    # Create working "top" directory if needed
620    distutils.dir_util.mkpath(args.dir)
621    abs_top_dir = os.path.abspath(args.dir)
622
623    repos = GetGoodRepos(args)
624    repo_dict = {}
625
626    print('Starting builds in {d}'.format(d=abs_top_dir))
627    for repo in repos:
628        # If the repo has a platform whitelist, skip the repo
629        # unless we are building on a whitelisted platform.
630        if not repo.on_build_platform:
631            continue
632
633        field_list = ('url',
634                      'sub_dir',
635                      'commit',
636                      'build_dir',
637                      'install_dir',
638                      'deps',
639                      'prebuild',
640                      'prebuild_linux',
641                      'prebuild_windows',
642                      'custom_build',
643                      'cmake_options',
644                      'ci_only',
645                      'build_step',
646                      'build_platforms',
647                      'repo_dir',
648                      'on_build_platform')
649        repo_dict[repo.name] = {field: getattr(repo, field) for field in field_list}
650
651        # If the repo has a CI whitelist, skip the repo unless
652        # one of the CI's environment variable is set to true.
653        if len(repo.ci_only):
654            do_build = False
655            for env in repo.ci_only:
656                if not env in os.environ:
657                    continue
658                if os.environ[env].lower() == 'true':
659                    do_build = True
660                    break
661            if not do_build:
662                continue
663
664        # Clone/update the repository
665        repo.Checkout()
666
667        # Build the repository
668        if args.do_build and repo.build_step != 'skip':
669            repo.Build(repos, repo_dict)
670
671    # Need to restore original cwd in order for CreateHelper to find json file
672    os.chdir(save_cwd)
673    CreateHelper(args, repos, os.path.join(abs_top_dir, 'helper.cmake'))
674
675    sys.exit(0)
676
677
678if __name__ == '__main__':
679    main()
680