2 # Copyright 2007-2011 Gentoo Foundation
3 # Distributed under the terms of the GNU General Public License v2
5 """This module contains utility functions to help repoman find ebuilds to
8 from __future__ import print_function
11 "detect_vcs_conflicts",
12 "editor_is_executable",
17 "get_commit_message_with_editor",
18 "get_commit_message_with_stdin",
29 from itertools import chain
38 from tempfile import mkstemp
40 from portage import os
41 from portage import subprocess_getstatusoutput
42 from portage import _encodings
43 from portage import _unicode_decode
44 from portage import _unicode_encode
45 from portage import output
46 from portage.localization import _
47 from portage.output import red, green
48 from portage.process import find_binary
49 from portage import exception
50 from portage import util
51 normalize_path = util.normalize_path
52 util.initialize_logger()
54 if sys.hexversion >= 0x3000000:
57 def detect_vcs_conflicts(options, vcs):
58 """Determine if the checkout has problems like cvs conflicts.
60 If you want more vcs support here just keep adding if blocks...
63 TODO(antarus): Also this should probably not call sys.exit() as
64 repoman is run on >1 packages and one failure should not cause
65 subsequent packages to fail.
68 vcs - A string identifying the version control system in use
70 None (calls sys.exit on fatal problems)
74 logging.info("Performing a " + output.green("cvs -n up") + \
75 " with a little magic grep to check for updates.")
76 retval = subprocess_getstatusoutput("cvs -n up 2>/dev/null | " + \
77 "egrep '^[^\?] .*' | " + \
78 "egrep -v '^. .*/digest-[^/]+|^cvs server: .* -- ignored$'")
80 logging.info("Performing a " + output.green("svn status -u") + \
81 " with a little magic grep to check for updates.")
82 retval = subprocess_getstatusoutput("svn status -u 2>&1 | " + \
83 "egrep -v '^. +.*/digest-[^/]+' | " + \
86 if vcs in ['cvs', 'svn']:
87 mylines = retval[1].splitlines()
92 if line[0] not in " UPMARD": # unmodified(svn),Updates,Patches,Modified,Added,Removed/Replaced(svn),Deleted(svn)
93 # Stray Manifest is fine, we will readd it anyway.
94 if line[0] == '?' and line[1:].lstrip() == 'Manifest':
96 logging.error(red("!!! Please fix the following issues reported " + \
97 "from cvs: ")+green("(U,P,M,A,R,D are ok)"))
98 logging.error(red("!!! Note: This is a pretend/no-modify pass..."))
99 logging.error(retval[1])
101 elif vcs == 'cvs' and line[0] in "UP":
102 myupdates.append(line[2:])
103 elif vcs == 'svn' and line[8] == '*':
104 myupdates.append(line[9:].lstrip(" 1234567890"))
107 logging.info(green("Fetching trivial updates..."))
109 logging.info("(" + vcs + " update " + " ".join(myupdates) + ")")
112 retval = os.system(vcs + " update " + " ".join(myupdates))
113 if retval != os.EX_OK:
114 logging.fatal("!!! " + vcs + " exited with an error. Terminating.")
118 def have_profile_dir(path, maxdepth=3, filename="profiles.desc"):
120 Try to figure out if 'path' has a profiles/
121 dir in it by checking for the given filename.
123 while path != "/" and maxdepth:
124 if os.path.exists(os.path.join(path, "profiles", filename)):
125 return normalize_path(path)
126 path = normalize_path(path + "/..")
129 def parse_metadata_use(xml_tree):
131 Records are wrapped in XML as per GLEP 56
132 returns a dict with keys constisting of USE flag names and values
133 containing their respective descriptions
137 usetags = xml_tree.findall("use")
141 # It's possible to have multiple 'use' elements.
142 for usetag in usetags:
143 flags = usetag.findall("flag")
145 # DTD allows use elements containing no flag elements.
149 pkg_flag = flag.get("name")
151 raise exception.ParseError("missing 'name' attribute for 'flag' tag")
152 flag_restrict = flag.get("restrict")
154 # emulate the Element.itertext() method from python-2.7
160 if isinstance(obj, basestring):
161 inner_text.append(obj)
163 if isinstance(obj.text, basestring):
164 inner_text.append(obj.text)
165 if isinstance(obj.tail, basestring):
166 stack.append(obj.tail)
167 stack.extend(reversed(obj))
169 if pkg_flag not in uselist:
170 uselist[pkg_flag] = {}
172 # (flag_restrict can be None)
173 uselist[pkg_flag][flag_restrict] = " ".join("".join(inner_text).split())
177 class UnknownHerdsError(ValueError):
178 def __init__(self, herd_names):
179 _plural = len(herd_names) != 1
180 super(UnknownHerdsError, self).__init__(
181 'Unknown %s %s' % (_plural and 'herds' or 'herd',
182 ','.join('"%s"' % e for e in herd_names)))
185 def check_metadata_herds(xml_tree, herd_base):
186 herd_nodes = xml_tree.findall('herd')
187 unknown_herds = [name for name in
188 (e.text.strip() for e in herd_nodes if e.text is not None)
189 if not herd_base.known_herd(name)]
192 raise UnknownHerdsError(unknown_herds)
194 def check_metadata(xml_tree, herd_base):
195 if herd_base is not None:
196 check_metadata_herds(xml_tree, herd_base)
198 def FindPackagesToScan(settings, startdir, reposplit):
199 """ Try to find packages that need to be scanned
202 settings - portage.config instance, preferably repoman_settings
203 startdir - directory that repoman was run in
204 reposplit - root of the repository
206 A list of directories to scan
210 def AddPackagesInDir(path):
211 """ Given a list of dirs, add any packages in it """
213 pkgdirs = os.listdir(path)
215 if d == 'CVS' or d.startswith('.'):
217 p = os.path.join(path, d)
220 cat_pkg_dir = os.path.join(*p.split(os.path.sep)[-2:])
221 logging.debug('adding %s to scanlist' % cat_pkg_dir)
222 ret.append(cat_pkg_dir)
226 repolevel = len(reposplit)
227 if repolevel == 1: # root of the tree, startdir = repodir
228 for cat in settings.categories:
229 path = os.path.join(startdir, cat)
230 if not os.path.isdir(path):
232 pkgdirs = os.listdir(path)
233 scanlist.extend(AddPackagesInDir(path))
234 elif repolevel == 2: # category level, startdir = catdir
235 # we only want 1 segment of the directory, is why we use catdir instead of startdir
236 catdir = reposplit[-2]
237 if catdir not in settings.categories:
238 logging.warn('%s is not a valid category according to profiles/categories, ' \
239 'skipping checks in %s' % (catdir, catdir))
241 scanlist = AddPackagesInDir(catdir)
242 elif repolevel == 3: # pkgdir level, startdir = pkgdir
243 catdir = reposplit[-2]
244 pkgdir = reposplit[-1]
245 if catdir not in settings.categories:
246 logging.warn('%s is not a valid category according to profiles/categories, ' \
247 'skipping checks in %s' % (catdir, catdir))
249 path = os.path.join(catdir, pkgdir)
250 logging.debug('adding %s to scanlist' % path)
251 scanlist.append(path)
255 def format_qa_output(formatter, stats, fails, dofull, dofail, options, qawarnings):
256 """Helper function that formats output properly
259 formatter - a subclass of Formatter
260 stats - a dict of qa status items
261 fails - a dict of qa status failures
262 dofull - boolean to print full results or a summary
263 dofail - boolean to decide if failure was hard or soft
266 None (modifies formatter)
268 full = options.mode == 'full'
269 # we only want key value pairs where value > 0
270 for category, number in \
271 filter(lambda myitem: myitem[1] > 0, iter(stats.items())):
272 formatter.add_literal_data(_unicode_decode(" " + category.ljust(30)))
273 if category in qawarnings:
274 formatter.push_style("WARN")
276 formatter.push_style("BAD")
277 formatter.add_literal_data(_unicode_decode(str(number)))
278 formatter.pop_style()
279 formatter.add_line_break()
281 if not full and dofail and category in qawarnings:
282 # warnings are considered noise when there are failures
284 fails_list = fails[category]
285 if not full and len(fails_list) > 12:
286 fails_list = fails_list[:12]
287 for failure in fails_list:
288 formatter.add_literal_data(_unicode_decode(" " + failure))
289 formatter.add_line_break()
292 def editor_is_executable(editor):
294 Given an EDITOR string, validate that it refers to
295 an executable. This uses shlex_split() to split the
296 first component and do a PATH lookup if necessary.
298 @param editor: An EDITOR value from the environment.
301 @returns: True if an executable is found, False otherwise.
303 editor_split = util.shlex_split(editor)
306 filename = editor_split[0]
307 if not os.path.isabs(filename):
308 return find_binary(filename) is not None
309 return os.access(filename, os.X_OK) and os.path.isfile(filename)
312 def get_commit_message_with_editor(editor, message=None):
314 Execute editor with a temporary file as it's argument
315 and return the file content afterwards.
317 @param editor: An EDITOR value from the environment
319 @param message: An iterable of lines to show in the editor.
321 @rtype: string or None
322 @returns: A string on success or None if an error occurs.
324 fd, filename = mkstemp()
326 os.write(fd, _unicode_encode(_(
327 "\n# Please enter the commit message " + \
328 "for your changes.\n# (Comment lines starting " + \
329 "with '#' will not be included)\n"),
330 encoding=_encodings['content'], errors='backslashreplace'))
334 os.write(fd, _unicode_encode("#" + line,
335 encoding=_encodings['content'], errors='backslashreplace'))
337 retval = os.system(editor + " '%s'" % filename)
338 if not (os.WIFEXITED(retval) and os.WEXITSTATUS(retval) == os.EX_OK):
341 mylines = io.open(_unicode_encode(filename,
342 encoding=_encodings['fs'], errors='strict'),
343 mode='r', encoding=_encodings['content'], errors='replace'
346 if e.errno != errno.ENOENT:
350 return "".join(line for line in mylines if not line.startswith("#"))
358 def get_commit_message_with_stdin():
360 Read a commit message from the user and return it.
362 @rtype: string or None
363 @returns: A string on success or None if an error occurs.
365 print("Please enter a commit message. Use Ctrl-d to finish or Ctrl-c to abort.")
368 commitmessage.append(sys.stdin.readline())
369 if not commitmessage[-1]:
371 commitmessage = "".join(commitmessage)
375 def FindPortdir(settings):
376 """ Try to figure out what repo we are in and whether we are in a regular
381 1. Determine what directory we are in (supports symlinks).
382 2. Build a list of directories from / to our current location
383 3. Iterate over PORTDIR_OVERLAY, if we find a match, search for a profiles directory
384 in the overlay. If it has one, make it portdir, otherwise make it portdir_overlay.
385 4. If we didn't find an overlay in PORTDIR_OVERLAY, see if we are in PORTDIR; if so, set
386 portdir_overlay to PORTDIR. If we aren't in PORTDIR, see if PWD has a profiles dir, if
387 so, set portdir_overlay and portdir to PWD, else make them False.
388 5. If we haven't found portdir_overlay yet, it means the user is doing something odd, report
390 6. If we haven't found a portdir yet, set portdir to PORTDIR.
393 settings - portage.config instance, preferably repoman_settings
395 list(portdir, portdir_overlay, location)
399 portdir_overlay = None
400 location = os.getcwd()
401 pwd = os.environ.get('PWD', '')
402 if pwd and pwd != location and os.path.realpath(pwd) == location:
403 # getcwd() returns the canonical path but that makes it hard for repoman to
404 # orient itself if the user has symlinks in their portage tree structure.
405 # We use os.environ["PWD"], if available, to get the non-canonical path of
406 # the current working directory (from the shell).
409 location = normalize_path(location)
416 path_ids[(s.st_dev, s.st_ino)] = p
419 p = os.path.dirname(p)
420 if location[-1] != "/":
423 for overlay in settings["PORTDIR_OVERLAY"].split():
424 overlay = os.path.realpath(overlay)
429 overlay = path_ids.get((s.st_dev, s.st_ino))
432 if overlay[-1] != "/":
435 portdir_overlay = overlay
436 subdir = location[len(overlay):]
437 if subdir and subdir[-1] != "/":
439 if have_profile_dir(location, subdir.count("/")):
440 portdir = portdir_overlay
443 # Couldn't match location with anything from PORTDIR_OVERLAY,
444 # so fall back to have_profile_dir() checks alone. Assume that
445 # an overlay will contain at least a "repo_name" file while a
446 # master repo (portdir) will contain at least a "profiles.desc"
448 if not portdir_overlay:
449 portdir_overlay = have_profile_dir(location, filename="repo_name")
451 subdir = location[len(portdir_overlay):]
452 if subdir and subdir[-1] != os.sep:
454 if have_profile_dir(location, subdir.count(os.sep)):
455 portdir = portdir_overlay
457 if not portdir_overlay:
458 if (settings["PORTDIR"] + os.path.sep).startswith(location):
459 portdir_overlay = settings["PORTDIR"]
461 portdir_overlay = have_profile_dir(location)
462 portdir = portdir_overlay
464 if not portdir_overlay:
465 msg = 'Repoman is unable to determine PORTDIR or PORTDIR_OVERLAY' + \
466 ' from the current working directory'
467 logging.critical(msg)
468 return (None, None, None)
471 portdir = settings["PORTDIR"]
473 if not portdir_overlay.endswith('/'):
474 portdir_overlay += '/'
476 if not portdir.endswith('/'):
479 return [normalize_path(x) for x in (portdir, portdir_overlay, location)]
482 """ Try to figure out in what VCS' working tree we are. """
486 def seek(depth = None):
487 """ Seek for VCSes that have a top-level data directory only. """
491 while depth is None or depth > 0:
492 if os.path.isdir(os.path.join(pathprep, '.git')):
494 if os.path.isdir(os.path.join(pathprep, '.bzr')):
496 if os.path.isdir(os.path.join(pathprep, '.hg')):
498 if os.path.isdir(os.path.join(pathprep, '.svn')): # >=1.7
503 pathprep = os.path.join(pathprep, '..')
504 if os.path.realpath(pathprep).strip('/') == '':
506 if depth is not None:
512 if os.path.isdir('CVS'):
514 if os.path.isdir('.svn'): # <1.7
517 # If we already found one of 'level zeros', just take a quick look
518 # at the current directory. Otherwise, seek parents till we get
519 # something or reach root.
521 outvcs.extend(seek(1))
527 _copyright_re1 = re.compile(br'^(# Copyright \d\d\d\d)-\d\d\d\d ')
528 _copyright_re2 = re.compile(br'^(# Copyright )(\d\d\d\d) ')
531 class _copyright_repl(object):
532 __slots__ = ('year',)
533 def __init__(self, year):
535 def __call__(self, matchobj):
536 if matchobj.group(2) == self.year:
537 return matchobj.group(0)
539 return matchobj.group(1) + matchobj.group(2) + \
540 b'-' + self.year + b' '
542 def _update_copyright_year(year, line):
544 These two regexes are taken from echangelog
545 update_copyright(), except that we don't hardcode
546 1999 here (in order to be more generic).
548 is_bytes = isinstance(line, bytes)
550 if not line.startswith(b'# Copyright '):
553 if not line.startswith('# Copyright '):
556 year = _unicode_encode(year)
557 line = _unicode_encode(line)
559 line = _copyright_re1.sub(br'\1-' + year + b' ', line)
560 line = _copyright_re2.sub(_copyright_repl(year), line)
562 line = _unicode_decode(line)
565 def update_copyright(fn_path, year, pretend):
567 Check file for a Copyright statement, and update its year. The
568 patterns used for replacing copyrights are taken from echangelog.
569 Only the first lines of each file that start with a hash ('#') are
570 considered, until a line is found that doesn't start with a hash.
571 Files are read and written in binary mode, so that this function
572 will work correctly with files encoded in any character set, as
573 long as the copyright statements consist of plain ASCII.
577 fn_hdl = io.open(_unicode_encode(fn_path,
578 encoding=_encodings['fs'], errors='strict'),
580 except EnvironmentError:
587 line_strip = line.strip()
588 orig_header.append(line)
589 if not line_strip or line_strip[:1] != b'#':
590 new_header.append(line)
593 line = _update_copyright_year(year, line)
594 new_header.append(line)
597 for line in difflib.unified_diff(
598 [_unicode_decode(line) for line in orig_header],
599 [_unicode_decode(line) for line in new_header],
600 fromfile=fn_path, tofile=fn_path, n=0):
601 util.writemsg_stdout(line, noiselevel=-1)
603 util.writemsg_stdout("\n", noiselevel=-1)
605 # unified diff has three lines to start with
606 if difflines > 3 and not pretend:
607 # write new file with changed header
608 f, fnnew_path = mkstemp()
609 f = io.open(f, mode='wb')
610 for line in new_header:
616 fn_stat = os.stat(fn_path)
620 shutil.move(fnnew_path, fn_path)
623 util.apply_permissions(fn_path, mode=0o644)
625 util.apply_stat_permissions(fn_path, fn_stat)
628 def get_committer_name(env=None):
629 """Generate a committer string like echangelog does."""
632 if 'GENTOO_COMMITTER_NAME' in env and \
633 'GENTOO_COMMITTER_EMAIL' in env:
634 user = '%s <%s>' % (env['GENTOO_COMMITTER_NAME'],
635 env['GENTOO_COMMITTER_EMAIL'])
636 elif 'GENTOO_AUTHOR_NAME' in env and \
637 'GENTOO_AUTHOR_EMAIL' in env:
638 user = '%s <%s>' % (env['GENTOO_AUTHOR_NAME'],
639 env['GENTOO_AUTHOR_EMAIL'])
640 elif 'ECHANGELOG_USER' in env:
641 user = env['ECHANGELOG_USER']
643 pwd_struct = pwd.getpwuid(os.getuid())
644 gecos = pwd_struct.pw_gecos.split(',')[0] # bug #80011
645 user = '%s <%s@gentoo.org>' % (gecos, pwd_struct.pw_name)
648 def UpdateChangeLog(pkgdir, user, msg, skel_path, category, package,
649 new=(), removed=(), changed=(), pretend=False):
651 Write an entry to an existing ChangeLog, or create a new one.
652 Updates copyright year on changed files, and updates the header of
653 ChangeLog with the contents of skel.ChangeLog.
657 err = 'Please set ECHANGELOG_USER or run as non-root'
658 logging.critical(err)
661 # ChangeLog times are in UTC
662 gmtime = time.gmtime()
663 year = time.strftime('%Y', gmtime)
664 date = time.strftime('%d %b %Y', gmtime)
666 # check modified files and the ChangeLog for copyright updates
667 # patches and diffs (identified by .patch and .diff) are excluded
668 for fn in chain(new, changed):
669 if fn.endswith('.diff') or fn.endswith('.patch'):
671 update_copyright(os.path.join(pkgdir, fn), year, pretend)
673 cl_path = os.path.join(pkgdir, 'ChangeLog')
676 old_header_lines = []
680 clold_file = io.open(_unicode_encode(cl_path,
681 encoding=_encodings['fs'], errors='strict'),
682 mode='r', encoding=_encodings['repo.content'], errors='replace')
683 except EnvironmentError:
687 if clold_file is None:
688 # we will only need the ChangeLog skeleton if there is no
691 clskel_file = io.open(_unicode_encode(skel_path,
692 encoding=_encodings['fs'], errors='strict'),
693 mode='r', encoding=_encodings['repo.content'],
695 except EnvironmentError:
698 f, clnew_path = mkstemp()
700 # construct correct header first
702 if clold_file is not None:
703 # retain header from old ChangeLog
704 for line in clold_file:
705 line_strip = line.strip()
706 if line_strip and line[:1] != "#":
707 clold_lines.append(line)
709 old_header_lines.append(line)
710 header_lines.append(_update_copyright_year(year, line))
714 elif clskel_file is not None:
715 # read skel.ChangeLog up to first empty line
716 for line in clskel_file:
717 line_strip = line.strip()
720 line = line.replace('<CATEGORY>', category)
721 line = line.replace('<PACKAGE_NAME>', package)
722 line = _update_copyright_year(year, line)
723 header_lines.append(line)
724 header_lines.append(_unicode_decode('\n'))
727 # write new ChangeLog entry
728 clnew_lines.extend(header_lines)
731 if not fn.endswith('.ebuild'):
733 ebuild = fn.split(os.sep)[-1][0:-7]
734 clnew_lines.append(_unicode_decode('*%s (%s)\n' % (ebuild, date)))
737 clnew_lines.append(_unicode_decode('\n'))
738 trivial_files = ('ChangeLog', 'Manifest')
739 display_new = ['+' + elem for elem in new
740 if elem not in trivial_files]
741 display_removed = ['-' + elem for elem in removed]
742 display_changed = [elem for elem in changed
743 if elem not in trivial_files]
744 if not (display_new or display_removed or display_changed):
745 # If there's nothing else to display, show one of the
747 for fn in trivial_files:
749 display_new = ['+' + fn]
752 display_changed = [fn]
755 mesg = '%s; %s %s:' % (date, user, ', '.join(chain(
756 display_new, display_removed, display_changed)))
757 for line in textwrap.wrap(mesg, 80, \
758 initial_indent=' ', subsequent_indent=' ', \
759 break_on_hyphens=False):
760 clnew_lines.append(_unicode_decode('%s\n' % line))
761 for line in textwrap.wrap(msg, 80, \
762 initial_indent=' ', subsequent_indent=' '):
763 clnew_lines.append(_unicode_decode('%s\n' % line))
764 clnew_lines.append(_unicode_decode('\n'))
766 f = io.open(f, mode='w', encoding=_encodings['repo.content'],
767 errors='backslashreplace')
769 for line in clnew_lines:
772 # append stuff from old ChangeLog
773 if clold_file is not None:
774 # clold_lines may contain a saved non-header line
775 # that we want to write first.
776 # Also, append this line to clnew_lines so that the
777 # unified_diff call doesn't show it as removed.
778 for line in clold_lines:
780 clnew_lines.append(line)
782 # Now prepend old_header_lines to clold_lines, for use
783 # in the unified_diff call below.
784 clold_lines = old_header_lines + clold_lines
786 # ensure that there is no more than one blank
787 # line after our new entry
788 for line in clold_file:
793 for line in clold_file:
798 # show diff (do we want to keep on doing this, or only when
800 for line in difflib.unified_diff(clold_lines, clnew_lines,
801 fromfile=cl_path, tofile=cl_path, n=0):
802 util.writemsg_stdout(line, noiselevel=-1)
803 util.writemsg_stdout("\n", noiselevel=-1)
806 # remove what we've done
807 os.remove(clnew_path)
809 # rename to ChangeLog, and set permissions
811 clold_stat = os.stat(cl_path)
815 shutil.move(clnew_path, cl_path)
817 if clold_stat is None:
818 util.apply_permissions(cl_path, mode=0o644)
820 util.apply_stat_permissions(cl_path, clold_stat)
822 if clold_file is None:
827 err = 'Repoman is unable to create/write to Changelog.new file: %s' % (e,)
828 logging.critical(err)
829 # try to remove if possible
831 os.remove(clnew_path)