1#!/usr/bin/env python 2 3# Copyright (c) 2020 Google Inc. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17import datetime 18import errno 19import os 20import os.path 21import re 22import subprocess 23import sys 24import time 25 26usage = """{} emits a string to stdout or file with project version information. 27 28args: <project-dir> [<input-string>] [-i <input-file>] [-o <output-file>] 29 30Either <input-string> or -i <input-file> needs to be provided. 31 32The tool will output the provided string or file content with the following 33tokens substituted: 34 35 <major> - The major version point parsed from the CHANGES.md file. 36 <minor> - The minor version point parsed from the CHANGES.md file. 37 <patch> - The point version point parsed from the CHANGES.md file. 38 <flavor> - The optional dash suffix parsed from the CHANGES.md file (excluding 39 dash prefix). 40 <-flavor> - The optional dash suffix parsed from the CHANGES.md file (including 41 dash prefix). 42 <date> - The optional date of the release in the form YYYY-MM-DD 43 <commit> - The git commit information for the directory taken from 44 "git describe" if that succeeds, or "git rev-parse HEAD" 45 if that succeeds, or otherwise a message containing the phrase 46 "unknown hash". 47 48-o is an optional flag for writing the output string to the given file. If 49 ommitted then the string is printed to stdout. 50""" 51 52def mkdir_p(directory): 53 """Make the directory, and all its ancestors as required. Any of the 54 directories are allowed to already exist.""" 55 56 if directory == "": 57 # We're being asked to make the current directory. 58 return 59 60 try: 61 os.makedirs(directory) 62 except OSError as e: 63 if e.errno == errno.EEXIST and os.path.isdir(directory): 64 pass 65 else: 66 raise 67 68 69def command_output(cmd, directory): 70 """Runs a command in a directory and returns its standard output stream. 71 72 Captures the standard error stream. 73 74 Raises a RuntimeError if the command fails to launch or otherwise fails. 75 """ 76 p = subprocess.Popen(cmd, 77 cwd=directory, 78 stdout=subprocess.PIPE, 79 stderr=subprocess.PIPE) 80 (stdout, _) = p.communicate() 81 if p.returncode != 0: 82 raise RuntimeError('Failed to run %s in %s' % (cmd, directory)) 83 return stdout 84 85 86def deduce_software_version(directory): 87 """Returns a software version number parsed from the CHANGES.md file 88 in the given directory. 89 90 The CHANGES.md file describes most recent versions first. 91 """ 92 93 # Match the first well-formed version-and-date line. 94 # Allow trailing whitespace in the checked-out source code has 95 # unexpected carriage returns on a linefeed-only system such as 96 # Linux. 97 pattern = re.compile(r'^#* +(\d+)\.(\d+)\.(\d+)(-\w+)? (\d\d\d\d-\d\d-\d\d)? *$') 98 changes_file = os.path.join(directory, 'CHANGES.md') 99 with open(changes_file, mode='r') as f: 100 for line in f.readlines(): 101 match = pattern.match(line) 102 if match: 103 flavor = match.group(4) 104 if flavor == None: 105 flavor = "" 106 return { 107 "major": match.group(1), 108 "minor": match.group(2), 109 "patch": match.group(3), 110 "flavor": flavor.lstrip("-"), 111 "-flavor": flavor, 112 "date": match.group(5), 113 } 114 raise Exception('No version number found in {}'.format(changes_file)) 115 116 117def describe(directory): 118 """Returns a string describing the current Git HEAD version as descriptively 119 as possible. 120 121 Runs 'git describe', or alternately 'git rev-parse HEAD', in directory. If 122 successful, returns the output; otherwise returns 'unknown hash, <date>'.""" 123 try: 124 # decode() is needed here for Python3 compatibility. In Python2, 125 # str and bytes are the same type, but not in Python3. 126 # Popen.communicate() returns a bytes instance, which needs to be 127 # decoded into text data first in Python3. And this decode() won't 128 # hurt Python2. 129 return command_output(['git', 'describe'], directory).rstrip().decode() 130 except: 131 try: 132 return command_output( 133 ['git', 'rev-parse', 'HEAD'], directory).rstrip().decode() 134 except: 135 # This is the fallback case where git gives us no information, 136 # e.g. because the source tree might not be in a git tree. 137 # In this case, usually use a timestamp. However, to ensure 138 # reproducible builds, allow the builder to override the wall 139 # clock time with environment variable SOURCE_DATE_EPOCH 140 # containing a (presumably) fixed timestamp. 141 timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', time.time())) 142 formatted = datetime.datetime.utcfromtimestamp(timestamp).isoformat() 143 return 'unknown hash, {}'.format(formatted) 144 145def parse_args(): 146 directory = None 147 input_string = None 148 input_file = None 149 output_file = None 150 151 if len(sys.argv) < 2: 152 raise Exception("Invalid number of arguments") 153 154 directory = sys.argv[1] 155 i = 2 156 157 if not sys.argv[i].startswith("-"): 158 input_string = sys.argv[i] 159 i = i + 1 160 161 while i < len(sys.argv): 162 opt = sys.argv[i] 163 i = i + 1 164 165 if opt == "-i" or opt == "-o": 166 if i == len(sys.argv): 167 raise Exception("Expected path after {}".format(opt)) 168 val = sys.argv[i] 169 i = i + 1 170 if (opt == "-i"): 171 input_file = val 172 elif (opt == "-o"): 173 output_file = val 174 else: 175 raise Exception("Unknown flag {}".format(opt)) 176 177 return { 178 "directory": directory, 179 "input_string": input_string, 180 "input_file": input_file, 181 "output_file": output_file, 182 } 183 184def main(): 185 args = None 186 try: 187 args = parse_args() 188 except Exception as e: 189 print(e) 190 print("\nUsage:\n") 191 print(usage.format(sys.argv[0])) 192 sys.exit(1) 193 194 directory = args["directory"] 195 template = args["input_string"] 196 if template == None: 197 with open(args["input_file"], 'r') as f: 198 template = f.read() 199 output_file = args["output_file"] 200 201 software_version = deduce_software_version(directory) 202 commit = describe(directory) 203 output = template \ 204 .replace("<major>", software_version["major"]) \ 205 .replace("<minor>", software_version["minor"]) \ 206 .replace("<patch>", software_version["patch"]) \ 207 .replace("<flavor>", software_version["flavor"]) \ 208 .replace("<-flavor>", software_version["-flavor"]) \ 209 .replace("<date>", software_version["date"]) \ 210 .replace("<commit>", commit) 211 212 if output_file is None: 213 print(output) 214 else: 215 mkdir_p(os.path.dirname(output_file)) 216 217 if os.path.isfile(output_file): 218 with open(output_file, 'r') as f: 219 if output == f.read(): 220 return 221 222 with open(output_file, 'w') as f: 223 f.write(output) 224 225if __name__ == '__main__': 226 main() 227