1 # Copyright 2011 OpenStack Foundation
2 # Copyright 2012-2013 Hewlett-Packard Development Company, L.P.
5 # Licensed under the Apache License, Version 2.0 (the "License"); you may
6 # not use this file except in compliance with the License. You may obtain
7 # 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, WITHOUT
13 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14 # License for the specific language governing permissions and limitations
17 from __future__ import unicode_literals
19 import distutils.errors
20 from distutils import log
30 from pbr import options
31 from pbr import version
34 def _run_shell_command(cmd, throw_on_error=False, buffer=True, env=None):
36 out_location = subprocess.PIPE
37 err_location = subprocess.PIPE
42 newenv = os.environ.copy()
46 output = subprocess.Popen(cmd,
50 out = output.communicate()
51 if output.returncode and throw_on_error:
52 raise distutils.errors.DistutilsError(
53 "%s returned %d" % (cmd, output.returncode))
54 if len(out) == 0 or not out[0] or not out[0].strip():
56 # Since we don't control the history, and forcing users to rebase arbitrary
57 # history to fix utf8 issues is harsh, decode with replace.
58 return out[0].strip().decode('utf-8', 'replace')
61 def _run_git_command(cmd, git_dir, **kwargs):
62 if not isinstance(cmd, (list, tuple)):
64 return _run_shell_command(
65 ['git', '--git-dir=%s' % git_dir] + cmd, **kwargs)
68 def _get_git_directory():
70 return _run_shell_command(['git', 'rev-parse', '--git-dir'])
72 if e.errno == errno.ENOENT:
78 def _git_is_installed():
80 # We cannot use 'which git' as it may not be available
81 # in some distributions, So just try 'git --version'
82 # to see if we run into trouble
83 _run_shell_command(['git', '--version'])
89 def _get_highest_tag(tags):
90 """Find the highest tag from a list.
92 Pass in a list of tag strings and this will return the highest
93 (latest) as sorted by the pkg_resources version parser.
95 return max(tags, key=pkg_resources.parse_version)
98 def _find_git_files(dirname='', git_dir=None):
99 """Behave like a file finder entrypoint plugin.
101 We don't actually use the entrypoints system for this because it runs
102 at absurd times. We only want to do this when we are building an sdist.
106 git_dir = _run_git_functions()
108 log.info("[pbr] In git context, generating filelist from git")
109 file_list = _run_git_command(['ls-files', '-z'], git_dir)
110 # Users can fix utf8 issues locally with a single commit, so we are
112 file_list = file_list.split(b'\x00'.decode('utf-8'))
113 return [f for f in file_list if f]
116 def _get_raw_tag_info(git_dir):
117 describe = _run_git_command(['describe', '--always'], git_dir)
119 return describe.rsplit("-", 2)[-2]
125 def get_is_release(git_dir):
126 return _get_raw_tag_info(git_dir) == 0
129 def _run_git_functions():
131 if _git_is_installed():
132 git_dir = _get_git_directory()
133 return git_dir or None
136 def get_git_short_sha(git_dir=None):
137 """Return the short sha for this repo, if it exists."""
139 git_dir = _run_git_functions()
141 return _run_git_command(
142 ['log', '-n1', '--pretty=format:%h'], git_dir)
146 def _clean_changelog_message(msg):
147 """Cleans any instances of invalid sphinx wording.
149 This escapes/removes any instances of invalid characters
150 that can be interpreted by sphinx as a warning or error
151 when translating the Changelog into an HTML file for
152 documentation building within projects.
154 * Escapes '_' which is interpreted as a link
155 * Escapes '*' which is interpreted as a new line
156 * Escapes '`' which is interpreted as a literal
159 msg = msg.replace('*', '\*')
160 msg = msg.replace('_', '\_')
161 msg = msg.replace('`', '\`')
166 def _iter_changelog(changelog):
167 """Convert a oneline log iterator to formatted strings.
169 :param changelog: An iterator of one line log entries like
170 that given by _iter_log_oneline.
171 :return: An iterator over (release, formatted changelog) tuples.
174 current_release = None
175 yield current_release, "CHANGES\n=======\n\n"
176 for hash, tags, msg in changelog:
178 current_release = _get_highest_tag(tags)
179 underline = len(current_release) * '-'
181 yield current_release, '\n'
182 yield current_release, (
183 "%(tag)s\n%(underline)s\n\n" %
184 dict(tag=current_release, underline=underline))
186 if not msg.startswith("Merge "):
187 if msg.endswith("."):
189 msg = _clean_changelog_message(msg)
190 yield current_release, "* %(msg)s\n" % dict(msg=msg)
194 def _iter_log_oneline(git_dir=None):
195 """Iterate over --oneline log entries if possible.
197 This parses the output into a structured form but does not apply
198 presentation logic to the output - making it suitable for different
201 :return: An iterator of (hash, tags_set, 1st_line) tuples, or None if
202 changelog generation is disabled / not available.
205 git_dir = _get_git_directory()
208 return _iter_log_inner(git_dir)
211 def _is_valid_version(candidate):
213 version.SemanticVersion.from_pip_string(candidate)
219 def _iter_log_inner(git_dir):
220 """Iterate over --oneline log entries.
222 This parses the output intro a structured form but does not apply
223 presentation logic to the output - making it suitable for different
226 .. caution:: this function risk to return a tag that doesn't exist really
227 inside the git objects list due to replacement made
228 to tag name to also list pre-release suffix.
229 Compliant with the SemVer specification (e.g 1.2.3-rc1)
231 :return: An iterator of (hash, tags_set, 1st_line) tuples.
233 log.info('[pbr] Generating ChangeLog')
234 log_cmd = ['log', '--decorate=full', '--format=%h%x00%s%x00%d']
235 changelog = _run_git_command(log_cmd, git_dir)
236 for line in changelog.split('\n'):
237 line_parts = line.split('\x00')
238 if len(line_parts) != 3:
240 sha, msg, refname = line_parts
245 # HEAD, tag: refs/tags/1.4.0, refs/remotes/origin/master, \
248 if "refs/tags/" in refname:
249 refname = refname.strip()[1:-1] # remove wrapping ()'s
250 # If we start with "tag: refs/tags/1.2b1, tag: refs/tags/1.2"
251 # The first split gives us "['', '1.2b1, tag:', '1.2']"
252 # Which is why we do the second split below on the comma
253 for tag_string in refname.split("refs/tags/")[1:]:
254 # git tag does not allow : or " " in tag names, so we split
255 # on ", " which is the separator between elements
256 candidate = tag_string.split(", ")[0].replace("-", ".")
257 if _is_valid_version(candidate):
263 def write_git_changelog(git_dir=None, dest_dir=os.path.curdir,
264 option_dict=None, changelog=None):
265 """Write a changelog based on the git changelog."""
269 should_skip = options.get_boolean_option(option_dict, 'skip_changelog',
270 'SKIP_WRITE_GIT_CHANGELOG')
274 changelog = _iter_log_oneline(git_dir=git_dir)
276 changelog = _iter_changelog(changelog)
279 new_changelog = os.path.join(dest_dir, 'ChangeLog')
280 # If there's already a ChangeLog and it's not writable, just use it
281 if (os.path.exists(new_changelog)
282 and not os.access(new_changelog, os.W_OK)):
283 log.info('[pbr] ChangeLog not written (file already'
284 ' exists and it is not writeable)')
286 log.info('[pbr] Writing ChangeLog')
287 with io.open(new_changelog, "w", encoding="utf-8") as changelog_file:
288 for release, content in changelog:
289 changelog_file.write(content)
291 log.info('[pbr] ChangeLog complete (%0.1fs)' % (stop - start))
294 def generate_authors(git_dir=None, dest_dir='.', option_dict=dict()):
295 """Create AUTHORS file using git commits."""
296 should_skip = options.get_boolean_option(option_dict, 'skip_authors',
297 'SKIP_GENERATE_AUTHORS')
301 old_authors = os.path.join(dest_dir, 'AUTHORS.in')
302 new_authors = os.path.join(dest_dir, 'AUTHORS')
303 # If there's already an AUTHORS file and it's not writable, just use it
304 if (os.path.exists(new_authors)
305 and not os.access(new_authors, os.W_OK)):
307 log.info('[pbr] Generating AUTHORS')
308 ignore_emails = '((jenkins|zuul)@review|infra@lists|jenkins@openstack)'
310 git_dir = _get_git_directory()
314 # don't include jenkins email address in AUTHORS file
315 git_log_cmd = ['log', '--format=%aN <%aE>']
316 authors += _run_git_command(git_log_cmd, git_dir).split('\n')
317 authors = [a for a in authors if not re.search(ignore_emails, a)]
319 # get all co-authors from commit messages
320 co_authors_out = _run_git_command('log', git_dir)
321 co_authors = re.findall('Co-authored-by:.+', co_authors_out,
323 co_authors = [signed.split(":", 1)[1].strip()
324 for signed in co_authors if signed]
326 authors += co_authors
327 authors = sorted(set(authors))
329 with open(new_authors, 'wb') as new_authors_fh:
330 if os.path.exists(old_authors):
331 with open(old_authors, "rb") as old_authors_fh:
332 new_authors_fh.write(old_authors_fh.read())
333 new_authors_fh.write(('\n'.join(authors) + '\n')
336 log.info('[pbr] AUTHORS complete (%0.1fs)' % (stop - start))