Generate build information from CHANGES.md
[platform/upstream/glslang.git] / build_info.py
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
17 import datetime
18 import errno
19 import os
20 import os.path
21 import re
22 import subprocess
23 import sys
24 import time
25
26 usage = """{} emits a string to stdout or file with project version information.
27
28 args: <project-dir> [<input-string>] [-i <input-file>] [-o <output-file>]
29
30 Either <input-string> or -i <input-file> needs to be provided.
31
32 The tool will output the provided string or file content with the following
33 tokens 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
52 def 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
69 def 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
86 def 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                 return {
104                     "major": match.group(1),
105                     "minor": match.group(2),
106                     "patch": match.group(3),
107                     "flavor": match.group(4).lstrip("-"),
108                     "-flavor": match.group(4),
109                     "date": match.group(5),
110                 }
111     raise Exception('No version number found in {}'.format(changes_file))
112
113
114 def describe(directory):
115     """Returns a string describing the current Git HEAD version as descriptively
116     as possible.
117
118     Runs 'git describe', or alternately 'git rev-parse HEAD', in directory.  If
119     successful, returns the output; otherwise returns 'unknown hash, <date>'."""
120     try:
121         # decode() is needed here for Python3 compatibility. In Python2,
122         # str and bytes are the same type, but not in Python3.
123         # Popen.communicate() returns a bytes instance, which needs to be
124         # decoded into text data first in Python3. And this decode() won't
125         # hurt Python2.
126         return command_output(['git', 'describe'], directory).rstrip().decode()
127     except:
128         try:
129             return command_output(
130                 ['git', 'rev-parse', 'HEAD'], directory).rstrip().decode()
131         except:
132             # This is the fallback case where git gives us no information,
133             # e.g. because the source tree might not be in a git tree.
134             # In this case, usually use a timestamp.  However, to ensure
135             # reproducible builds, allow the builder to override the wall
136             # clock time with environment variable SOURCE_DATE_EPOCH
137             # containing a (presumably) fixed timestamp.
138             timestamp = int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))
139             formatted = datetime.datetime.utcfromtimestamp(timestamp).isoformat()
140             return 'unknown hash, {}'.format(formatted)
141
142 def parse_args():
143     directory = None
144     input_string = None
145     input_file = None
146     output_file = None
147
148     if len(sys.argv) < 2:
149         raise Exception("Invalid number of arguments")
150
151     directory = sys.argv[1]
152     i = 2
153
154     if not sys.argv[i].startswith("-"):
155         input_string = sys.argv[i]
156         i = i + 1
157
158     while i < len(sys.argv):
159         opt = sys.argv[i]
160         i = i + 1
161
162         if opt == "-i" or opt == "-o":
163             if i == len(sys.argv):
164                 raise Exception("Expected path after {}".format(opt))
165             val = sys.argv[i]
166             i = i + 1
167             if (opt == "-i"):
168                 input_file = val
169             elif (opt == "-o"):
170                 output_file = val
171             else:
172                 raise Exception("Unknown flag {}".format(opt))
173
174     return {
175         "directory": directory,
176         "input_string": input_string,
177         "input_file": input_file,
178         "output_file": output_file,
179     }
180
181 def main():
182     args = None
183     try:
184         args = parse_args()
185     except Exception as e:
186         print(e)
187         print("\nUsage:\n")
188         print(usage.format(sys.argv[0]))
189         sys.exit(1)
190
191     directory = args["directory"]
192     template = args["input_string"]
193     if template == None:
194         with open(args["input_file"], 'r') as f:
195             template = f.read()
196     output_file = args["output_file"]
197
198     software_version = deduce_software_version(directory)
199     commit = describe(directory)
200     output = template \
201         .replace("<major>", software_version["major"]) \
202         .replace("<minor>", software_version["minor"]) \
203         .replace("<patch>", software_version["patch"]) \
204         .replace("<flavor>", software_version["flavor"]) \
205         .replace("<-flavor>", software_version["-flavor"]) \
206         .replace("<date>", software_version["date"]) \
207         .replace("<commit>", commit)
208
209     if output_file is None:
210         print(output)
211     else:
212         mkdir_p(os.path.dirname(output_file))
213
214         if os.path.isfile(output_file):
215             with open(output_file, 'r') as f:
216                 if output == f.read():
217                     return
218
219         with open(output_file, 'w') as f:
220             f.write(output)
221
222 if __name__ == '__main__':
223     main()