3 # Copyright (c) 2020 Google Inc.
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
9 # http://www.apache.org/licenses/LICENSE-2.0
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.
26 usage
= """{} emits a string to stdout or file with project version information.
28 args: <project-dir> [<input-string>] [-i <input-file>] [-o <output-file>]
30 Either <input-string> or -i <input-file> needs to be provided.
32 The tool will output the provided string or file content with the following
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
40 <-flavor> - The optional dash suffix parsed from the CHANGES.md file (including
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
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.
53 utc
= datetime
.timezone
.utc
54 except AttributeError:
55 # Python 2? In datetime.date.today().year? Yes.
56 class UTC(datetime
.tzinfo
):
57 ZERO
= datetime
.timedelta(0)
59 def utcoffset(self
, dt
):
70 def mkdir_p(directory
):
71 """Make the directory, and all its ancestors as required. Any of the
72 directories are allowed to already exist."""
75 # We're being asked to make the current directory.
79 os
.makedirs(directory
)
81 if e
.errno
== errno
.EEXIST
and os
.path
.isdir(directory
):
87 def command_output(cmd
, directory
):
88 """Runs a command in a directory and returns its standard output stream.
90 Captures the standard error stream.
92 Raises a RuntimeError if the command fails to launch or otherwise fails.
94 p
= subprocess
.Popen(cmd
,
96 stdout
=subprocess
.PIPE
,
97 stderr
=subprocess
.PIPE
)
98 (stdout
, _
) = p
.communicate()
100 raise RuntimeError('Failed to run %s in %s' % (cmd
, directory
))
104 def deduce_software_version(directory
):
105 """Returns a software version number parsed from the CHANGES.md file
106 in the given directory.
108 The CHANGES.md file describes most recent versions first.
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
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
)
121 flavor
= match
.group(4)
125 "major": match
.group(1),
126 "minor": match
.group(2),
127 "patch": match
.group(3),
128 "flavor": flavor
.lstrip("-"),
130 "date": match
.group(5),
132 raise Exception('No version number found in {}'.format(changes_file
))
135 def describe(directory
):
136 """Returns a string describing the current Git HEAD version as descriptively
139 Runs 'git describe', or alternately 'git rev-parse HEAD', in directory. If
140 successful, returns the output; otherwise returns 'unknown hash, <date>'."""
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
147 return command_output(['git', 'describe'], directory
).rstrip().decode()
150 return command_output(
151 ['git', 'rev-parse', 'HEAD'], directory
).rstrip().decode()
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
)
169 if len(sys
.argv
) < 2:
170 raise Exception("Invalid number of arguments")
172 directory
= sys
.argv
[1]
175 if not sys
.argv
[i
].startswith("-"):
176 input_string
= sys
.argv
[i
]
179 while i
< len(sys
.argv
):
183 if opt
== "-i" or opt
== "-o":
184 if i
== len(sys
.argv
):
185 raise Exception("Expected path after {}".format(opt
))
193 raise Exception("Unknown flag {}".format(opt
))
196 "directory": directory
,
197 "input_string": input_string
,
198 "input_file": input_file
,
199 "output_file": output_file
,
206 except Exception as e
:
209 print(usage
.format(sys
.argv
[0]))
212 directory
= args
["directory"]
213 template
= args
["input_string"]
215 with
open(args
["input_file"], 'r') as f
:
217 output_file
= args
["output_file"]
219 software_version
= deduce_software_version(directory
)
220 commit
= describe(directory
)
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
)
230 if output_file
is None:
233 mkdir_p(os
.path
.dirname(output_file
))
235 if os
.path
.isfile(output_file
):
236 with
open(output_file
, 'r') as f
:
237 if output
== f
.read():
240 with
open(output_file
, 'w') as f
:
243 if __name__
== '__main__':