1#!/usr/bin/env python3 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 52try: 53 utc = datetime.timezone.utc 54except AttributeError: 55 # Python 2? In datetime.date.today().year? Yes. 56 class UTC(datetime.tzinfo): 57 ZERO = datetime.timedelta(0) 58 59 def utcoffset(self, dt): 60 return self.ZERO 61 62 def tzname(self, dt): 63 return "UTC" 64 65 def dst(self, dt): 66 return self.ZERO 67 utc = UTC() 68 69 70def mkdir_p(directory): 71 """Make the directory, and all its ancestors as required. Any of the 72 directories are allowed to already exist.""" 73 74 if directory == "": 75 # We're being asked to make the current directory. 76 return 77 78 try: 79 os.makedirs(directory) 80 except OSError as e: 81 if e.errno == errno.EEXIST and os.path.isdir(directory): 82 pass 83 else: 84 raise 85 86 87def command_output(cmd, directory): 88 """Runs a command in a directory and returns its standard output stream. 89 90 Captures the standard error stream. 91 92 Raises a RuntimeError if the command fails to launch or otherwise fails. 93 """ 94 p = subprocess.Popen(cmd, 95 cwd=directory, 96 stdout=subprocess.PIPE, 97 stderr=subprocess.PIPE) 98 (stdout, _) = p.communicate() 99 if p.returncode != 0: 100 raise RuntimeError('Failed to run %s in %s' % (cmd, directory)) 101 return stdout 102 103 104def deduce_software_version(directory): 105 """Returns a software version number parsed from the CHANGES.md file 106 in the given directory. 107 108 The CHANGES.md file describes most recent versions first. 109 """ 110 111 # Match the first well-formed version-and-date line. 112 # Allow trailing whitespace in the checked-out source code has 113 # unexpected carriage returns on a linefeed-only system such as 114 # Linux. 115 pattern = re.compile(r'^#* +(\d+)\.(\d+)\.(\d+)(-\w+)? (\d\d\d\d-\d\d-\d\d)? *$') 116 changes_file = os.path.join(directory, 'CHANGES.md') 117 with open(changes_file, mode='r') as f: 118 for line in f.readlines(): 119 match = pattern.match(line) 120 if match: 121 flavor = match.group(4) 122 if flavor == None: 123 flavor = "" 124 return { 125 "major": match.group(1), 126 "minor": match.group(2), 127 "patch": match.group(3), 128 "flavor": flavor.lstrip("-"), 129 "-flavor": flavor, 130 "date": match.group(5), 131 } 132 raise Exception('No version number found in {}'.format(changes_file)) 133 134 135def describe(directory): 136 """Returns a string describing the current Git HEAD version as descriptively 137 as possible. 138 139 Runs 'git describe', or alternately 'git rev-parse HEAD', in directory. If 140 successful, returns the output; otherwise returns 'unknown hash, <date>'.""" 141 try: 142 # decode() is needed here for Python3 compatibility. In Python2, 143 # str and bytes are the same type, but not in Python3. 144 # Popen.communicate() returns a bytes instance, which needs to be 145 # decoded into text data first in Python3. And this decode() won't 146 # hurt Python2. 147 return command_output(['git', 'describe'], directory).rstrip().decode() 148 except: 149 try: 150 return command_output( 151 ['git', 'rev-parse', 'HEAD'], directory).rstrip().decode() 152 except: 153 # This is the fallback case where git gives us no information, 154 # e.g. because the source tree might not be in a git tree. 155 # In this case, usually use a timestamp. However, to ensure 156 # reproducible builds, allow the builder to override the wall 157 # clock time with environment variable SOURCE_DATE_EPOCH 158 # containing a (presumably) fixed timestamp. 159 timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', time.time())) 160 formatted = datetime.datetime.fromtimestamp(timestamp, utc).isoformat() 161 return 'unknown hash, {}'.format(formatted) 162 163def parse_args(): 164 directory = None 165 input_string = None 166 input_file = None 167 output_file = None 168 169 if len(sys.argv) < 2: 170 raise Exception("Invalid number of arguments") 171 172 directory = sys.argv[1] 173 i = 2 174 175 if not sys.argv[i].startswith("-"): 176 input_string = sys.argv[i] 177 i = i + 1 178 179 while i < len(sys.argv): 180 opt = sys.argv[i] 181 i = i + 1 182 183 if opt == "-i" or opt == "-o": 184 if i == len(sys.argv): 185 raise Exception("Expected path after {}".format(opt)) 186 val = sys.argv[i] 187 i = i + 1 188 if (opt == "-i"): 189 input_file = val 190 elif (opt == "-o"): 191 output_file = val 192 else: 193 raise Exception("Unknown flag {}".format(opt)) 194 195 return { 196 "directory": directory, 197 "input_string": input_string, 198 "input_file": input_file, 199 "output_file": output_file, 200 } 201 202def main(): 203 args = None 204 try: 205 args = parse_args() 206 except Exception as e: 207 print(e) 208 print("\nUsage:\n") 209 print(usage.format(sys.argv[0])) 210 sys.exit(1) 211 212 directory = args["directory"] 213 template = args["input_string"] 214 if template == None: 215 with open(args["input_file"], 'r') as f: 216 template = f.read() 217 output_file = args["output_file"] 218 219 software_version = deduce_software_version(directory) 220 commit = describe(directory) 221 output = template \ 222 .replace("@major@", software_version["major"]) \ 223 .replace("@minor@", software_version["minor"]) \ 224 .replace("@patch@", software_version["patch"]) \ 225 .replace("@flavor@", software_version["flavor"]) \ 226 .replace("@-flavor@", software_version["-flavor"]) \ 227 .replace("@date@", software_version["date"]) \ 228 .replace("@commit@", commit) 229 230 if output_file is None: 231 print(output) 232 else: 233 mkdir_p(os.path.dirname(output_file)) 234 235 if os.path.isfile(output_file): 236 with open(output_file, 'r') as f: 237 if output == f.read(): 238 return 239 240 with open(output_file, 'w') as f: 241 f.write(output) 242 243if __name__ == '__main__': 244 main() 245