diff options
| author | Ben Murdoch <benm@google.com> | 2011-05-05 14:36:32 +0100 | 
|---|---|---|
| committer | Ben Murdoch <benm@google.com> | 2011-05-10 15:38:30 +0100 | 
| commit | f05b935882198ccf7d81675736e3aeb089c5113a (patch) | |
| tree | 4ea0ca838d9ef1b15cf17ddb3928efb427c7e5a1 /Tools/Scripts/webkitpy/common | |
| parent | 60fbdcc62bced8db2cb1fd233cc4d1e4ea17db1b (diff) | |
| download | external_webkit-f05b935882198ccf7d81675736e3aeb089c5113a.zip external_webkit-f05b935882198ccf7d81675736e3aeb089c5113a.tar.gz external_webkit-f05b935882198ccf7d81675736e3aeb089c5113a.tar.bz2  | |
Merge WebKit at r74534: Initial merge by git.
Change-Id: I6ccd1154fa1b19c2ec2a66878eb675738735f1eb
Diffstat (limited to 'Tools/Scripts/webkitpy/common')
85 files changed, 12275 insertions, 0 deletions
diff --git a/Tools/Scripts/webkitpy/common/__init__.py b/Tools/Scripts/webkitpy/common/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/array_stream.py b/Tools/Scripts/webkitpy/common/array_stream.py new file mode 100644 index 0000000..e425d02 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/array_stream.py @@ -0,0 +1,66 @@ +#!/usr/bin/python +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Package that private an array-based implementation of a stream.""" + + +class ArrayStream(object): +    """Simple class that implmements a stream interface on top of an array. + +    This is used primarily by unit test classes to mock output streams. It +    performs a similar function to StringIO, but (a) it is write-only, and +    (b) it can be used to retrieve each individual write(); StringIO  +    concatenates all of the writes together. +    """ + +    def __init__(self): +        self._contents = [] + +    def write(self, msg): +        """Implement stream.write() by appending to the stream's contents.""" +        self._contents.append(msg) + +    def get(self): +        """Return the contents of a stream (as an array).""" +        return self._contents + +    def reset(self): +        """Empty the stream.""" +        self._contents = [] + +    def empty(self): +        """Return whether the stream is empty.""" +        return (len(self._contents) == 0) + +    def flush(self): +        """Flush the stream (a no-op implemented for compatibility).""" +        pass + +    def __repr__(self): +        return '<ArrayStream: ' + str(self._contents) + '>' diff --git a/Tools/Scripts/webkitpy/common/array_stream_unittest.py b/Tools/Scripts/webkitpy/common/array_stream_unittest.py new file mode 100644 index 0000000..1a9b34a --- /dev/null +++ b/Tools/Scripts/webkitpy/common/array_stream_unittest.py @@ -0,0 +1,78 @@ +#!/usr/bin/python +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for array_stream.py.""" + +import pdb +import unittest + +from webkitpy.common.array_stream import ArrayStream + + +class ArrayStreamTest(unittest.TestCase): +    def assertEmpty(self, a_stream): +        self.assertTrue(a_stream.empty()) + +    def assertNotEmpty(self, a_stream): +        self.assertFalse(a_stream.empty()) + +    def assertContentsMatch(self, a_stream, contents): +        self.assertEquals(a_stream.get(), contents) + +    def test_basics(self): +        a = ArrayStream() +        self.assertEmpty(a) +        self.assertContentsMatch(a, []) + +        a.flush() +        self.assertEmpty(a) +        self.assertContentsMatch(a, []) + +        a.write("foo") +        a.write("bar") +        self.assertNotEmpty(a) +        self.assertContentsMatch(a, ["foo", "bar"]) + +        a.flush() +        self.assertNotEmpty(a) +        self.assertContentsMatch(a, ["foo", "bar"]) + +        a.reset() +        self.assertEmpty(a) +        self.assertContentsMatch(a, []) + +        self.assertEquals(str(a), "<ArrayStream: []>") + +        a.write("foo") +        self.assertNotEmpty(a) +        self.assertContentsMatch(a, ["foo"]) +        self.assertEquals(str(a), "<ArrayStream: ['foo']>") + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/checkout/__init__.py b/Tools/Scripts/webkitpy/common/checkout/__init__.py new file mode 100644 index 0000000..597dcbd --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/__init__.py @@ -0,0 +1,3 @@ +# Required for Python to search this directory for module files + +from api import Checkout diff --git a/Tools/Scripts/webkitpy/common/checkout/api.py b/Tools/Scripts/webkitpy/common/checkout/api.py new file mode 100644 index 0000000..6357982 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/api.py @@ -0,0 +1,164 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +import StringIO + +from webkitpy.common.config import urls +from webkitpy.common.checkout.changelog import ChangeLog +from webkitpy.common.checkout.commitinfo import CommitInfo +from webkitpy.common.checkout.scm import CommitMessage +from webkitpy.common.memoized import memoized +from webkitpy.common.net.bugzilla import parse_bug_id +from webkitpy.common.system.executive import Executive, run_command, ScriptError +from webkitpy.common.system.deprecated_logging import log + + +# This class represents the WebKit-specific parts of the checkout (like +# ChangeLogs). +# FIXME: Move a bunch of ChangeLog-specific processing from SCM to this object. +class Checkout(object): +    def __init__(self, scm): +        self._scm = scm + +    def _is_path_to_changelog(self, path): +        return os.path.basename(path) == "ChangeLog" + +    def _latest_entry_for_changelog_at_revision(self, changelog_path, revision): +        changelog_contents = self._scm.contents_at_revision(changelog_path, revision) +        # contents_at_revision returns a byte array (str()), but we know +        # that ChangeLog files are utf-8.  parse_latest_entry_from_file +        # expects a file-like object which vends unicode(), so we decode here. +        changelog_file = StringIO.StringIO(changelog_contents.decode("utf-8")) +        return ChangeLog.parse_latest_entry_from_file(changelog_file) + +    def changelog_entries_for_revision(self, revision): +        changed_files = self._scm.changed_files_for_revision(revision) +        return [self._latest_entry_for_changelog_at_revision(path, revision) for path in changed_files if self._is_path_to_changelog(path)] + +    @memoized +    def commit_info_for_revision(self, revision): +        committer_email = self._scm.committer_email_for_revision(revision) +        changelog_entries = self.changelog_entries_for_revision(revision) +        # Assume for now that the first entry has everything we need: +        # FIXME: This will throw an exception if there were no ChangeLogs. +        if not len(changelog_entries): +            return None +        changelog_entry = changelog_entries[0] +        changelog_data = { +            "bug_id": parse_bug_id(changelog_entry.contents()), +            "author_name": changelog_entry.author_name(), +            "author_email": changelog_entry.author_email(), +            "author": changelog_entry.author(), +            "reviewer_text": changelog_entry.reviewer_text(), +            "reviewer": changelog_entry.reviewer(), +        } +        # We could pass the changelog_entry instead of a dictionary here, but that makes +        # mocking slightly more involved, and would make aggregating data from multiple +        # entries more difficult to wire in if we need to do that in the future. +        return CommitInfo(revision, committer_email, changelog_data) + +    def bug_id_for_revision(self, revision): +        return self.commit_info_for_revision(revision).bug_id() + +    def _modified_files_matching_predicate(self, git_commit, predicate, changed_files=None): +        # SCM returns paths relative to scm.checkout_root +        # Callers (especially those using the ChangeLog class) may +        # expect absolute paths, so this method returns absolute paths. +        if not changed_files: +            changed_files = self._scm.changed_files(git_commit) +        absolute_paths = [os.path.join(self._scm.checkout_root, path) for path in changed_files] +        return [path for path in absolute_paths if predicate(path)] + +    def modified_changelogs(self, git_commit, changed_files=None): +        return self._modified_files_matching_predicate(git_commit, self._is_path_to_changelog, changed_files=changed_files) + +    def modified_non_changelogs(self, git_commit, changed_files=None): +        return self._modified_files_matching_predicate(git_commit, lambda path: not self._is_path_to_changelog(path), changed_files=changed_files) + +    def commit_message_for_this_commit(self, git_commit, changed_files=None): +        changelog_paths = self.modified_changelogs(git_commit, changed_files) +        if not len(changelog_paths): +            raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n" +                              "All changes require a ChangeLog.  See:\n %s" % urls.contribution_guidelines) + +        changelog_messages = [] +        for changelog_path in changelog_paths: +            log("Parsing ChangeLog: %s" % changelog_path) +            changelog_entry = ChangeLog(changelog_path).latest_entry() +            if not changelog_entry: +                raise ScriptError(message="Failed to parse ChangeLog: %s" % os.path.abspath(changelog_path)) +            changelog_messages.append(changelog_entry.contents()) + +        # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does. +        return CommitMessage("".join(changelog_messages).splitlines()) + +    def recent_commit_infos_for_files(self, paths): +        revisions = set(sum(map(self._scm.revisions_changing_file, paths), [])) +        return set(map(self.commit_info_for_revision, revisions)) + +    def suggested_reviewers(self, git_commit, changed_files=None): +        changed_files = self.modified_non_changelogs(git_commit, changed_files) +        commit_infos = self.recent_commit_infos_for_files(changed_files) +        reviewers = [commit_info.reviewer() for commit_info in commit_infos if commit_info.reviewer()] +        reviewers.extend([commit_info.author() for commit_info in commit_infos if commit_info.author() and commit_info.author().can_review]) +        return sorted(set(reviewers)) + +    def bug_id_for_this_commit(self, git_commit, changed_files=None): +        try: +            return parse_bug_id(self.commit_message_for_this_commit(git_commit, changed_files).message()) +        except ScriptError, e: +            pass # We might not have ChangeLogs. + +    def apply_patch(self, patch, force=False): +        # It's possible that the patch was not made from the root directory. +        # We should detect and handle that case. +        # FIXME: Move _scm.script_path here once we get rid of all the dependencies. +        args = [self._scm.script_path('svn-apply')] +        if patch.reviewer(): +            args += ['--reviewer', patch.reviewer().full_name] +        if force: +            args.append('--force') +        run_command(args, input=patch.contents()) + +    def apply_reverse_diff(self, revision): +        self._scm.apply_reverse_diff(revision) + +        # We revert the ChangeLogs because removing lines from a ChangeLog +        # doesn't make sense.  ChangeLogs are append only. +        changelog_paths = self.modified_changelogs(git_commit=None) +        if len(changelog_paths): +            self._scm.revert_files(changelog_paths) + +        conflicts = self._scm.conflicted_files() +        if len(conflicts): +            raise ScriptError(message="Failed to apply reverse diff for revision %s because of the following conflicts:\n%s" % (revision, "\n".join(conflicts))) + +    def apply_reverse_diffs(self, revision_list): +        for revision in sorted(revision_list, reverse=True): +            self.apply_reverse_diff(revision) diff --git a/Tools/Scripts/webkitpy/common/checkout/api_unittest.py b/Tools/Scripts/webkitpy/common/checkout/api_unittest.py new file mode 100644 index 0000000..1f97abd --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/api_unittest.py @@ -0,0 +1,196 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import codecs +import os +import shutil +import tempfile +import unittest + +from webkitpy.common.checkout.api import Checkout +from webkitpy.common.checkout.changelog import ChangeLogEntry +from webkitpy.common.checkout.scm import detect_scm_system, CommitMessage +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.thirdparty.mock import Mock + + +# FIXME: Copied from scm_unittest.py +def write_into_file_at_path(file_path, contents, encoding="utf-8"): +    with codecs.open(file_path, "w", encoding) as file: +        file.write(contents) + + +_changelog1entry1 = u"""2010-03-25  Tor Arne Vestb\u00f8  <vestbo@webkit.org> + +        Unreviewed build fix to un-break webkit-patch land. + +        Move commit_message_for_this_commit from scm to checkout +        https://bugs.webkit.org/show_bug.cgi?id=36629 + +        * Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage +""" +_changelog1entry2 = u"""2010-03-25  Adam Barth  <abarth@webkit.org> + +        Reviewed by Eric Seidel. + +        Move commit_message_for_this_commit from scm to checkout +        https://bugs.webkit.org/show_bug.cgi?id=36629 + +        * Scripts/webkitpy/common/checkout/api.py: +""" +_changelog1 = u"\n".join([_changelog1entry1, _changelog1entry2]) +_changelog2 = u"""2010-03-25  Tor Arne Vestb\u00f8  <vestbo@webkit.org> + +        Unreviewed build fix to un-break webkit-patch land. + +        Second part of this complicated change. + +        * Path/To/Complicated/File: Added. + +2010-03-25  Adam Barth  <abarth@webkit.org> + +        Reviewed by Eric Seidel. + +        Filler change. +""" + +class CommitMessageForThisCommitTest(unittest.TestCase): +    expected_commit_message = u"""2010-03-25  Tor Arne Vestb\u00f8  <vestbo@webkit.org> + +        Unreviewed build fix to un-break webkit-patch land. + +        Move commit_message_for_this_commit from scm to checkout +        https://bugs.webkit.org/show_bug.cgi?id=36629 + +        * Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage +2010-03-25  Tor Arne Vestb\u00f8  <vestbo@webkit.org> + +        Unreviewed build fix to un-break webkit-patch land. + +        Second part of this complicated change. + +        * Path/To/Complicated/File: Added. +""" + +    def setUp(self): +        self.temp_dir = tempfile.mkdtemp(suffix="changelogs") +        self.old_cwd = os.getcwd() +        os.chdir(self.temp_dir) +        write_into_file_at_path("ChangeLog1", _changelog1) +        write_into_file_at_path("ChangeLog2", _changelog2) + +    def tearDown(self): +        shutil.rmtree(self.temp_dir, ignore_errors=True) +        os.chdir(self.old_cwd) + +    # FIXME: This should not need to touch the file system, however +    # ChangeLog is difficult to mock at current. +    def test_commit_message_for_this_commit(self): +        checkout = Checkout(None) +        checkout.modified_changelogs = lambda git_commit, changed_files=None: ["ChangeLog1", "ChangeLog2"] +        output = OutputCapture() +        expected_stderr = "Parsing ChangeLog: ChangeLog1\nParsing ChangeLog: ChangeLog2\n" +        commit_message = output.assert_outputs(self, checkout.commit_message_for_this_commit, +            kwargs={"git_commit": None}, expected_stderr=expected_stderr) +        self.assertEqual(commit_message.message(), self.expected_commit_message) + + +class CheckoutTest(unittest.TestCase): +    def test_latest_entry_for_changelog_at_revision(self): +        scm = Mock() +        def mock_contents_at_revision(changelog_path, revision): +            self.assertEqual(changelog_path, "foo") +            self.assertEqual(revision, "bar") +            # contents_at_revision is expected to return a byte array (str) +            # so we encode our unicode ChangeLog down to a utf-8 stream. +            return _changelog1.encode("utf-8") +        scm.contents_at_revision = mock_contents_at_revision +        checkout = Checkout(scm) +        entry = checkout._latest_entry_for_changelog_at_revision("foo", "bar") +        self.assertEqual(entry.contents(), _changelog1entry1) + +    def test_commit_info_for_revision(self): +        scm = Mock() +        scm.committer_email_for_revision = lambda revision: "committer@example.com" +        checkout = Checkout(scm) +        checkout.changelog_entries_for_revision = lambda revision: [ChangeLogEntry(_changelog1entry1)] +        commitinfo = checkout.commit_info_for_revision(4) +        self.assertEqual(commitinfo.bug_id(), 36629) +        self.assertEqual(commitinfo.author_name(), u"Tor Arne Vestb\u00f8") +        self.assertEqual(commitinfo.author_email(), "vestbo@webkit.org") +        self.assertEqual(commitinfo.reviewer_text(), None) +        self.assertEqual(commitinfo.reviewer(), None) +        self.assertEqual(commitinfo.committer_email(), "committer@example.com") +        self.assertEqual(commitinfo.committer(), None) + +        checkout.changelog_entries_for_revision = lambda revision: [] +        self.assertEqual(checkout.commit_info_for_revision(1), None) + +    def test_bug_id_for_revision(self): +        scm = Mock() +        scm.committer_email_for_revision = lambda revision: "committer@example.com" +        checkout = Checkout(scm) +        checkout.changelog_entries_for_revision = lambda revision: [ChangeLogEntry(_changelog1entry1)] +        self.assertEqual(checkout.bug_id_for_revision(4), 36629) + +    def test_bug_id_for_this_commit(self): +        scm = Mock() +        checkout = Checkout(scm) +        checkout.commit_message_for_this_commit = lambda git_commit, changed_files=None: CommitMessage(ChangeLogEntry(_changelog1entry1).contents().splitlines()) +        self.assertEqual(checkout.bug_id_for_this_commit(git_commit=None), 36629) + +    def test_modified_changelogs(self): +        scm = Mock() +        scm.checkout_root = "/foo/bar" +        scm.changed_files = lambda git_commit: ["file1", "ChangeLog", "relative/path/ChangeLog"] +        checkout = Checkout(scm) +        expected_changlogs = ["/foo/bar/ChangeLog", "/foo/bar/relative/path/ChangeLog"] +        self.assertEqual(checkout.modified_changelogs(git_commit=None), expected_changlogs) + +    def test_suggested_reviewers(self): +        def mock_changelog_entries_for_revision(revision): +            if revision % 2 == 0: +                return [ChangeLogEntry(_changelog1entry1)] +            return [ChangeLogEntry(_changelog1entry2)] + +        def mock_revisions_changing_file(path, limit=5): +            if path.endswith("ChangeLog"): +                return [3] +            return [4, 8] + +        scm = Mock() +        scm.checkout_root = "/foo/bar" +        scm.changed_files = lambda git_commit: ["file1", "file2", "relative/path/ChangeLog"] +        scm.revisions_changing_file = mock_revisions_changing_file +        checkout = Checkout(scm) +        checkout.changelog_entries_for_revision = mock_changelog_entries_for_revision +        reviewers = checkout.suggested_reviewers(git_commit=None) +        reviewer_names = [reviewer.full_name for reviewer in reviewers] +        self.assertEqual(reviewer_names, [u'Tor Arne Vestb\xf8']) diff --git a/Tools/Scripts/webkitpy/common/checkout/changelog.py b/Tools/Scripts/webkitpy/common/checkout/changelog.py new file mode 100644 index 0000000..07f905d --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/changelog.py @@ -0,0 +1,191 @@ +# Copyright (C) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for parsing and modifying ChangeLog files + +import codecs +import fileinput # inplace file editing for set_reviewer_in_changelog +import os.path +import re +import textwrap + +from webkitpy.common.system.deprecated_logging import log +from webkitpy.common.config.committers import CommitterList +from webkitpy.common.config import urls +from webkitpy.common.net.bugzilla import parse_bug_id +from webkitpy.tool.grammar import join_with_separators + + +class ChangeLogEntry(object): +    # e.g. 2009-06-03  Eric Seidel  <eric@webkit.org> +    date_line_regexp = r'^(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<name>.+?)\s+<(?P<email>[^<>]+)>$' + +    def __init__(self, contents, committer_list=CommitterList()): +        self._contents = contents +        self._committer_list = committer_list +        self._parse_entry() + +    def _parse_entry(self): +        match = re.match(self.date_line_regexp, self._contents, re.MULTILINE) +        if not match: +            log("WARNING: Creating invalid ChangeLogEntry:\n%s" % self._contents) + +        # FIXME: group("name") does not seem to be Unicode?  Probably due to self._contents not being unicode. +        self._author_name = match.group("name") if match else None +        self._author_email = match.group("email") if match else None + +        match = re.search("^\s+Reviewed by (?P<reviewer>.*?)[\.,]?\s*$", self._contents, re.MULTILINE) # Discard everything after the first period +        self._reviewer_text = match.group("reviewer") if match else None + +        self._reviewer = self._committer_list.committer_by_name(self._reviewer_text) +        self._author = self._committer_list.committer_by_email(self._author_email) or self._committer_list.committer_by_name(self._author_name) + +    def author_name(self): +        return self._author_name + +    def author_email(self): +        return self._author_email + +    def author(self): +        return self._author # Might be None + +    # FIXME: Eventually we would like to map reviwer names to reviewer objects. +    # See https://bugs.webkit.org/show_bug.cgi?id=26533 +    def reviewer_text(self): +        return self._reviewer_text + +    def reviewer(self): +        return self._reviewer # Might be None + +    def contents(self): +        return self._contents + +    def bug_id(self): +        return parse_bug_id(self._contents) + + +# FIXME: Various methods on ChangeLog should move into ChangeLogEntry instead. +class ChangeLog(object): + +    def __init__(self, path): +        self.path = path + +    _changelog_indent = " " * 8 + +    @staticmethod +    def parse_latest_entry_from_file(changelog_file): +        """changelog_file must be a file-like object which returns +        unicode strings.  Use codecs.open or StringIO(unicode()) +        to pass file objects to this class.""" +        date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp) +        entry_lines = [] +        # The first line should be a date line. +        first_line = changelog_file.readline() +        assert(isinstance(first_line, unicode)) +        if not date_line_regexp.match(first_line): +            return None +        entry_lines.append(first_line) + +        for line in changelog_file: +            # If we've hit the next entry, return. +            if date_line_regexp.match(line): +                # Remove the extra newline at the end +                return ChangeLogEntry(''.join(entry_lines[:-1])) +            entry_lines.append(line) +        return None # We never found a date line! + +    def latest_entry(self): +        # ChangeLog files are always UTF-8, we read them in as such to support Reviewers with unicode in their names. +        changelog_file = codecs.open(self.path, "r", "utf-8") +        try: +            return self.parse_latest_entry_from_file(changelog_file) +        finally: +            changelog_file.close() + +    # _wrap_line and _wrap_lines exist to work around +    # http://bugs.python.org/issue1859 + +    def _wrap_line(self, line): +        return textwrap.fill(line, +                             width=70, +                             initial_indent=self._changelog_indent, +                             # Don't break urls which may be longer than width. +                             break_long_words=False, +                             subsequent_indent=self._changelog_indent) + +    # Workaround as suggested by guido in +    # http://bugs.python.org/issue1859#msg60040 + +    def _wrap_lines(self, message): +        lines = [self._wrap_line(line) for line in message.splitlines()] +        return "\n".join(lines) + +    # This probably does not belong in changelogs.py +    def _message_for_revert(self, revision_list, reason, bug_url): +        message = "Unreviewed, rolling out %s.\n" % join_with_separators(['r' + str(revision) for revision in revision_list]) +        for revision in revision_list: +            message += "%s\n" % urls.view_revision_url(revision) +        if bug_url: +            message += "%s\n" % bug_url +        # Add an extra new line after the rollout links, before any reason. +        message += "\n" +        if reason: +            message += "%s\n\n" % reason +        return self._wrap_lines(message) + +    def update_for_revert(self, revision_list, reason, bug_url=None): +        reviewed_by_regexp = re.compile( +                "%sReviewed by NOBODY \(OOPS!\)\." % self._changelog_indent) +        removing_boilerplate = False +        # inplace=1 creates a backup file and re-directs stdout to the file +        for line in fileinput.FileInput(self.path, inplace=1): +            if reviewed_by_regexp.search(line): +                message_lines = self._message_for_revert(revision_list, +                                                         reason, +                                                         bug_url) +                print reviewed_by_regexp.sub(message_lines, line), +                # Remove all the ChangeLog boilerplate between the Reviewed by +                # line and the first changed file. +                removing_boilerplate = True +            elif removing_boilerplate: +                if line.find('*') >= 0: # each changed file is preceded by a * +                    removing_boilerplate = False + +            if not removing_boilerplate: +                print line, + +    def set_reviewer(self, reviewer): +        # inplace=1 creates a backup file and re-directs stdout to the file +        for line in fileinput.FileInput(self.path, inplace=1): +            # Trailing comma suppresses printing newline +            print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")), + +    def set_short_description_and_bug_url(self, short_description, bug_url): +        message = "%s\n        %s" % (short_description, bug_url) +        for line in fileinput.FileInput(self.path, inplace=1): +            print line.replace("Need a short description and bug URL (OOPS!)", message.encode("utf-8")), diff --git a/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py b/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py new file mode 100644 index 0000000..20c6cfa --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/changelog_unittest.py @@ -0,0 +1,230 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import codecs +import os +import tempfile +import unittest + +from StringIO import StringIO + +from webkitpy.common.checkout.changelog import * + + +class ChangeLogTest(unittest.TestCase): + +    _example_entry = u'''2009-08-17  Peter Kasting  <pkasting@google.com> + +        Reviewed by Tor Arne Vestb\xf8. + +        https://bugs.webkit.org/show_bug.cgi?id=27323 +        Only add Cygwin to the path when it isn't already there.  This avoids +        causing problems for people who purposefully have non-Cygwin versions of +        executables like svn in front of the Cygwin ones in their paths. + +        * DumpRenderTree/win/DumpRenderTree.vcproj: +        * DumpRenderTree/win/ImageDiff.vcproj: +        * DumpRenderTree/win/TestNetscapePlugin/TestNetscapePlugin.vcproj: +''' + +    # More example text than we need.  Eventually we need to support parsing this all and write tests for the parsing. +    _example_changelog = u"""2009-08-17  Tor Arne Vestb\xf8  <vestbo@webkit.org> + +        <http://webkit.org/b/28393> check-webkit-style: add check for use of std::max()/std::min() instead of MAX()/MIN() + +        Reviewed by David Levin. + +        * Scripts/modules/cpp_style.py: +        (_ERROR_CATEGORIES): Added 'runtime/max_min_macros'. +        (check_max_min_macros): Added.  Returns level 4 error when MAX() +        and MIN() macros are used in header files and C++ source files. +        (check_style): Added call to check_max_min_macros(). +        * Scripts/modules/cpp_style_unittest.py: Added unit tests. +        (test_max_macro): Added. +        (test_min_macro): Added. + +2009-08-16  David Kilzer  <ddkilzer@apple.com> + +        Backed out r47343 which was mistakenly committed + +        * Scripts/bugzilla-tool: +        * Scripts/modules/scm.py: + +2009-06-18  Darin Adler  <darin@apple.com> + +        Rubber stamped by Mark Rowe. + +        * DumpRenderTree/mac/DumpRenderTreeWindow.mm: +        (-[DumpRenderTreeWindow close]): Resolved crashes seen during regression +        tests. The close method can be called on a window that's already closed +        so we can't assert here. + +== Rolled over to ChangeLog-2009-06-16 == +""" + +    def test_latest_entry_parse(self): +        changelog_contents = u"%s\n%s" % (self._example_entry, self._example_changelog) +        changelog_file = StringIO(changelog_contents) +        latest_entry = ChangeLog.parse_latest_entry_from_file(changelog_file) +        self.assertEquals(latest_entry.contents(), self._example_entry) +        self.assertEquals(latest_entry.author_name(), "Peter Kasting") +        self.assertEquals(latest_entry.author_email(), "pkasting@google.com") +        self.assertEquals(latest_entry.reviewer_text(), u"Tor Arne Vestb\xf8") +        self.assertTrue(latest_entry.reviewer())  # Make sure that our UTF8-based lookup of Tor works. + +    @staticmethod +    def _write_tmp_file_with_contents(byte_array): +        assert(isinstance(byte_array, str)) +        (file_descriptor, file_path) = tempfile.mkstemp() # NamedTemporaryFile always deletes the file on close in python < 2.6 +        with os.fdopen(file_descriptor, "w") as file: +            file.write(byte_array) +        return file_path + +    @staticmethod +    def _read_file_contents(file_path, encoding): +        with codecs.open(file_path, "r", encoding) as file: +            return file.read() + +    _new_entry_boilerplate = '''2009-08-19  Eric Seidel  <eric@webkit.org> + +        Reviewed by NOBODY (OOPS!). + +        Need a short description and bug URL (OOPS!) + +        * Scripts/bugzilla-tool: +''' + +    def test_set_reviewer(self): +        changelog_contents = u"%s\n%s" % (self._new_entry_boilerplate, self._example_changelog) +        changelog_path = self._write_tmp_file_with_contents(changelog_contents.encode("utf-8")) +        reviewer_name = 'Test Reviewer' +        ChangeLog(changelog_path).set_reviewer(reviewer_name) +        actual_contents = self._read_file_contents(changelog_path, "utf-8") +        expected_contents = changelog_contents.replace('NOBODY (OOPS!)', reviewer_name) +        os.remove(changelog_path) +        self.assertEquals(actual_contents, expected_contents) + +    def test_set_short_description_and_bug_url(self): +        changelog_contents = u"%s\n%s" % (self._new_entry_boilerplate, self._example_changelog) +        changelog_path = self._write_tmp_file_with_contents(changelog_contents.encode("utf-8")) +        short_description = "A short description" +        bug_url = "http://example.com/b/2344" +        ChangeLog(changelog_path).set_short_description_and_bug_url(short_description, bug_url) +        actual_contents = self._read_file_contents(changelog_path, "utf-8") +        expected_message = "%s\n        %s" % (short_description, bug_url) +        expected_contents = changelog_contents.replace("Need a short description and bug URL (OOPS!)", expected_message) +        os.remove(changelog_path) +        self.assertEquals(actual_contents, expected_contents) + +    _revert_message = """        Unreviewed, rolling out r12345. +        http://trac.webkit.org/changeset/12345 +        http://example.com/123 + +        This is a very long reason which should be long enough so that +        _message_for_revert will need to wrap it.  We'll also include +        a +        https://veryveryveryveryverylongbugurl.com/reallylongbugthingy.cgi?bug_id=12354 +        link so that we can make sure we wrap that right too. +""" + +    def test_message_for_revert(self): +        changelog = ChangeLog("/fake/path") +        long_reason = "This is a very long reason which should be long enough so that _message_for_revert will need to wrap it.  We'll also include a https://veryveryveryveryverylongbugurl.com/reallylongbugthingy.cgi?bug_id=12354 link so that we can make sure we wrap that right too." +        message = changelog._message_for_revert([12345], long_reason, "http://example.com/123") +        self.assertEquals(message, self._revert_message) + +    _revert_entry_with_bug_url = '''2009-08-19  Eric Seidel  <eric@webkit.org> + +        Unreviewed, rolling out r12345. +        http://trac.webkit.org/changeset/12345 +        http://example.com/123 + +        Reason + +        * Scripts/bugzilla-tool: +''' + +    _revert_entry_without_bug_url = '''2009-08-19  Eric Seidel  <eric@webkit.org> + +        Unreviewed, rolling out r12345. +        http://trac.webkit.org/changeset/12345 + +        Reason + +        * Scripts/bugzilla-tool: +''' + +    _multiple_revert_entry_with_bug_url = '''2009-08-19  Eric Seidel  <eric@webkit.org> + +        Unreviewed, rolling out r12345, r12346, and r12347. +        http://trac.webkit.org/changeset/12345 +        http://trac.webkit.org/changeset/12346 +        http://trac.webkit.org/changeset/12347 +        http://example.com/123 + +        Reason + +        * Scripts/bugzilla-tool: +''' + +    _multiple_revert_entry_without_bug_url = '''2009-08-19  Eric Seidel  <eric@webkit.org> + +        Unreviewed, rolling out r12345, r12346, and r12347. +        http://trac.webkit.org/changeset/12345 +        http://trac.webkit.org/changeset/12346 +        http://trac.webkit.org/changeset/12347 + +        Reason + +        * Scripts/bugzilla-tool: +''' + +    def _assert_update_for_revert_output(self, args, expected_entry): +        changelog_contents = u"%s\n%s" % (self._new_entry_boilerplate, self._example_changelog) +        changelog_path = self._write_tmp_file_with_contents(changelog_contents.encode("utf-8")) +        changelog = ChangeLog(changelog_path) +        changelog.update_for_revert(*args) +        actual_entry = changelog.latest_entry() +        os.remove(changelog_path) +        self.assertEquals(actual_entry.contents(), expected_entry) +        self.assertEquals(actual_entry.reviewer_text(), None) +        # These checks could be removed to allow this to work on other entries: +        self.assertEquals(actual_entry.author_name(), "Eric Seidel") +        self.assertEquals(actual_entry.author_email(), "eric@webkit.org") + +    def test_update_for_revert(self): +        self._assert_update_for_revert_output([[12345], "Reason"], self._revert_entry_without_bug_url) +        self._assert_update_for_revert_output([[12345], "Reason", "http://example.com/123"], self._revert_entry_with_bug_url) +        self._assert_update_for_revert_output([[12345, 12346, 12347], "Reason"], self._multiple_revert_entry_without_bug_url) +        self._assert_update_for_revert_output([[12345, 12346, 12347], "Reason", "http://example.com/123"], self._multiple_revert_entry_with_bug_url) + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/checkout/commitinfo.py b/Tools/Scripts/webkitpy/common/checkout/commitinfo.py new file mode 100644 index 0000000..f121f36 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/commitinfo.py @@ -0,0 +1,93 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's python module for holding information on a commit + +from webkitpy.common.config import urls +from webkitpy.common.config.committers import CommitterList + + +class CommitInfo(object): +    def __init__(self, revision, committer_email, changelog_data, committer_list=CommitterList()): +        self._revision = revision +        self._committer_email = committer_email +        self._bug_id = changelog_data["bug_id"] +        self._author_name = changelog_data["author_name"] +        self._author_email = changelog_data["author_email"] +        self._author = changelog_data["author"] +        self._reviewer_text = changelog_data["reviewer_text"] +        self._reviewer = changelog_data["reviewer"] + +        # Derived values: +        self._committer = committer_list.committer_by_email(committer_email) + +    def revision(self): +        return self._revision + +    def committer(self): +        return self._committer  # None if committer isn't in committers.py + +    def committer_email(self): +        return self._committer_email + +    def bug_id(self): +        return self._bug_id  # May be None + +    def author(self): +        return self._author  # May be None + +    def author_name(self): +        return self._author_name + +    def author_email(self): +        return self._author_email + +    def reviewer(self): +        return self._reviewer # May be None + +    def reviewer_text(self): +        return self._reviewer_text # May be None + +    def responsible_parties(self): +        responsible_parties = [ +            self.committer(), +            self.author(), +            self.reviewer(), +        ] +        return set([party for party in responsible_parties if party]) # Filter out None + +    # FIXME: It is slightly lame that this "view" method is on this "model" class (in MVC terms) +    def blame_string(self, bugs): +        string = "r%s:\n" % self.revision() +        string += "  %s\n" % urls.view_revision_url(self.revision()) +        string += "  Bug: %s (%s)\n" % (self.bug_id(), bugs.bug_url_for_bug_id(self.bug_id())) +        author_line = "\"%s\" <%s>" % (self.author_name(), self.author_email()) +        string += "  Author: %s\n" % (self.author() or author_line) +        string += "  Reviewer: %s\n" % (self.reviewer() or self.reviewer_text()) +        string += "  Committer: %s" % self.committer() +        return string diff --git a/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py b/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py new file mode 100644 index 0000000..f58e6f1 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py @@ -0,0 +1,61 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.checkout.commitinfo import CommitInfo +from webkitpy.common.config.committers import CommitterList, Committer, Reviewer + +class CommitInfoTest(unittest.TestCase): + +    def test_commit_info_creation(self): +        author = Committer("Author", "author@example.com") +        committer = Committer("Committer", "committer@example.com") +        reviewer = Reviewer("Reviewer", "reviewer@example.com") +        committer_list = CommitterList(committers=[author, committer], reviewers=[reviewer]) + +        changelog_data = { +            "bug_id": 1234, +            "author_name": "Committer", +            "author_email": "author@example.com", +            "author": author, +            "reviewer_text": "Reviewer", +            "reviewer": reviewer, +        } +        commit = CommitInfo(123, "committer@example.com", changelog_data, committer_list) + +        self.assertEqual(commit.revision(), 123) +        self.assertEqual(commit.bug_id(), 1234) +        self.assertEqual(commit.author_name(), "Committer") +        self.assertEqual(commit.author_email(), "author@example.com") +        self.assertEqual(commit.author(), author) +        self.assertEqual(commit.reviewer_text(), "Reviewer") +        self.assertEqual(commit.reviewer(), reviewer) +        self.assertEqual(commit.committer(), committer) +        self.assertEqual(commit.committer_email(), "committer@example.com") +        self.assertEqual(commit.responsible_parties(), set([author, committer, reviewer])) diff --git a/Tools/Scripts/webkitpy/common/checkout/diff_parser.py b/Tools/Scripts/webkitpy/common/checkout/diff_parser.py new file mode 100644 index 0000000..a6ea756 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/diff_parser.py @@ -0,0 +1,181 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""WebKit's Python module for interacting with patches.""" + +import logging +import re + +_log = logging.getLogger("webkitpy.common.checkout.diff_parser") + + +# FIXME: This is broken. We should compile our regexps up-front +# instead of using a custom cache. +_regexp_compile_cache = {} + + +# FIXME: This function should be removed. +def match(pattern, string): +    """Matches the string with the pattern, caching the compiled regexp.""" +    if not pattern in _regexp_compile_cache: +        _regexp_compile_cache[pattern] = re.compile(pattern) +    return _regexp_compile_cache[pattern].match(string) + + +# FIXME: This belongs on DiffParser (e.g. as to_svn_diff()). +def git_diff_to_svn_diff(line): +    """Converts a git formatted diff line to a svn formatted line. + +    Args: +      line: A string representing a line of the diff. +    """ +    # FIXME: This list should be a class member on DiffParser. +    # These regexp patterns should be compiled once instead of every time. +    conversion_patterns = (("^diff --git \w/(.+) \w/(?P<FilePath>.+)", lambda matched: "Index: " + matched.group('FilePath') + "\n"), +                           ("^new file.*", lambda matched: "\n"), +                           ("^index [0-9a-f]{7}\.\.[0-9a-f]{7} [0-9]{6}", lambda matched: "===================================================================\n"), +                           ("^--- \w/(?P<FilePath>.+)", lambda matched: "--- " + matched.group('FilePath') + "\n"), +                           ("^\+\+\+ \w/(?P<FilePath>.+)", lambda matched: "+++ " + matched.group('FilePath') + "\n")) + +    for pattern, conversion in conversion_patterns: +        matched = match(pattern, line) +        if matched: +            return conversion(matched) +    return line + + +# FIXME: This method belongs on DiffParser +def get_diff_converter(first_diff_line): +    """Gets a converter function of diff lines. + +    Args: +      first_diff_line: The first filename line of a diff file. +                       If this line is git formatted, we'll return a +                       converter from git to SVN. +    """ +    if match(r"^diff --git \w/", first_diff_line): +        return git_diff_to_svn_diff +    return lambda input: input + + +_INITIAL_STATE = 1 +_DECLARED_FILE_PATH = 2 +_PROCESSING_CHUNK = 3 + + +class DiffFile(object): +    """Contains the information for one file in a patch. + +    The field "lines" is a list which contains tuples in this format: +       (deleted_line_number, new_line_number, line_string) +    If deleted_line_number is zero, it means this line is newly added. +    If new_line_number is zero, it means this line is deleted. +    """ +    # FIXME: Tuples generally grow into classes.  We should consider +    # adding a DiffLine object. + +    def added_or_modified_line_numbers(self): +        # This logic was moved from patchreader.py, but may not be +        # the right API for this object long-term. +        return [line[1] for line in self.lines if not line[0]] + +    def __init__(self, filename): +        self.filename = filename +        self.lines = [] + +    def add_new_line(self, line_number, line): +        self.lines.append((0, line_number, line)) + +    def add_deleted_line(self, line_number, line): +        self.lines.append((line_number, 0, line)) + +    def add_unchanged_line(self, deleted_line_number, new_line_number, line): +        self.lines.append((deleted_line_number, new_line_number, line)) + + +class DiffParser(object): +    """A parser for a patch file. + +    The field "files" is a dict whose key is the filename and value is +    a DiffFile object. +    """ + +    # FIXME: This function is way too long and needs to be broken up. +    def __init__(self, diff_input): +        """Parses a diff. + +        Args: +          diff_input: An iterable object. +        """ +        state = _INITIAL_STATE + +        self.files = {} +        current_file = None +        old_diff_line = None +        new_diff_line = None +        for line in diff_input: +            line = line.rstrip("\n") +            if state == _INITIAL_STATE: +                transform_line = get_diff_converter(line) +            line = transform_line(line) + +            file_declaration = match(r"^Index: (?P<FilePath>.+)", line) +            if file_declaration: +                filename = file_declaration.group('FilePath') +                current_file = DiffFile(filename) +                self.files[filename] = current_file +                state = _DECLARED_FILE_PATH +                continue + +            lines_changed = match(r"^@@ -(?P<OldStartLine>\d+)(,\d+)? \+(?P<NewStartLine>\d+)(,\d+)? @@", line) +            if lines_changed: +                if state != _DECLARED_FILE_PATH and state != _PROCESSING_CHUNK: +                    _log.error('Unexpected line change without file path ' +                               'declaration: %r' % line) +                old_diff_line = int(lines_changed.group('OldStartLine')) +                new_diff_line = int(lines_changed.group('NewStartLine')) +                state = _PROCESSING_CHUNK +                continue + +            if state == _PROCESSING_CHUNK: +                if line.startswith('+'): +                    current_file.add_new_line(new_diff_line, line[1:]) +                    new_diff_line += 1 +                elif line.startswith('-'): +                    current_file.add_deleted_line(old_diff_line, line[1:]) +                    old_diff_line += 1 +                elif line.startswith(' '): +                    current_file.add_unchanged_line(old_diff_line, new_diff_line, line[1:]) +                    old_diff_line += 1 +                    new_diff_line += 1 +                elif line == '\\ No newline at end of file': +                    # Nothing to do.  We may still have some added lines. +                    pass +                else: +                    _log.error('Unexpected diff format when parsing a ' +                               'chunk: %r' % line) diff --git a/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py b/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py new file mode 100644 index 0000000..7eb0eab --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py @@ -0,0 +1,146 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest +import diff_parser +import re + + +class DiffParserTest(unittest.TestCase): + +    _PATCH = '''diff --git a/WebCore/rendering/style/StyleFlexibleBoxData.h b/WebCore/rendering/style/StyleFlexibleBoxData.h +index f5d5e74..3b6aa92 100644 +--- a/WebCore/rendering/style/StyleFlexibleBoxData.h ++++ b/WebCore/rendering/style/StyleFlexibleBoxData.h +@@ -47,7 +47,6 @@ public: +  +     unsigned align : 3; // EBoxAlignment +     unsigned pack: 3; // EBoxAlignment +-    unsigned orient: 1; // EBoxOrient +     unsigned lines : 1; // EBoxLines +  + private: +diff --git a/WebCore/rendering/style/StyleRareInheritedData.cpp b/WebCore/rendering/style/StyleRareInheritedData.cpp +index ce21720..324929e 100644 +--- a/WebCore/rendering/style/StyleRareInheritedData.cpp ++++ b/WebCore/rendering/style/StyleRareInheritedData.cpp +@@ -39,6 +39,7 @@ StyleRareInheritedData::StyleRareInheritedData() +     , textSizeAdjust(RenderStyle::initialTextSizeAdjust()) +     , resize(RenderStyle::initialResize()) +     , userSelect(RenderStyle::initialUserSelect()) ++    , boxOrient(RenderStyle::initialBoxOrient()) + { + } +  +@@ -58,6 +59,7 @@ StyleRareInheritedData::StyleRareInheritedData(const StyleRareInheritedData& o) +     , textSizeAdjust(o.textSizeAdjust) +     , resize(o.resize) +     , userSelect(o.userSelect) ++    , boxOrient(o.boxOrient) + { + } +  +@@ -81,7 +83,8 @@ bool StyleRareInheritedData::operator==(const StyleRareInheritedData& o) const +         && khtmlLineBreak == o.khtmlLineBreak +         && textSizeAdjust == o.textSizeAdjust +         && resize == o.resize +-        && userSelect == o.userSelect; ++        && userSelect == o.userSelect ++        && boxOrient == o.boxOrient; + } +  + bool StyleRareInheritedData::shadowDataEquivalent(const StyleRareInheritedData& o) const +diff --git a/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum +new file mode 100644 +index 0000000..6db26bd +--- /dev/null ++++ b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum +@@ -0,0 +1 @@ ++61a373ee739673a9dcd7bac62b9f182e +\ No newline at end of file +''' + +    def test_diff_parser(self, parser = None): +        if not parser: +            parser = diff_parser.DiffParser(self._PATCH.splitlines()) +        self.assertEquals(3, len(parser.files)) + +        self.assertTrue('WebCore/rendering/style/StyleFlexibleBoxData.h' in parser.files) +        diff = parser.files['WebCore/rendering/style/StyleFlexibleBoxData.h'] +        self.assertEquals(7, len(diff.lines)) +        # The first two unchaged lines. +        self.assertEquals((47, 47), diff.lines[0][0:2]) +        self.assertEquals('', diff.lines[0][2]) +        self.assertEquals((48, 48), diff.lines[1][0:2]) +        self.assertEquals('    unsigned align : 3; // EBoxAlignment', diff.lines[1][2]) +        # The deleted line +        self.assertEquals((50, 0), diff.lines[3][0:2]) +        self.assertEquals('    unsigned orient: 1; // EBoxOrient', diff.lines[3][2]) + +        # The first file looks OK. Let's check the next, more complicated file. +        self.assertTrue('WebCore/rendering/style/StyleRareInheritedData.cpp' in parser.files) +        diff = parser.files['WebCore/rendering/style/StyleRareInheritedData.cpp'] +        # There are 3 chunks. +        self.assertEquals(7 + 7 + 9, len(diff.lines)) +        # Around an added line. +        self.assertEquals((60, 61), diff.lines[9][0:2]) +        self.assertEquals((0, 62), diff.lines[10][0:2]) +        self.assertEquals((61, 63), diff.lines[11][0:2]) +        # Look through the last chunk, which contains both add's and delete's. +        self.assertEquals((81, 83), diff.lines[14][0:2]) +        self.assertEquals((82, 84), diff.lines[15][0:2]) +        self.assertEquals((83, 85), diff.lines[16][0:2]) +        self.assertEquals((84, 0), diff.lines[17][0:2]) +        self.assertEquals((0, 86), diff.lines[18][0:2]) +        self.assertEquals((0, 87), diff.lines[19][0:2]) +        self.assertEquals((85, 88), diff.lines[20][0:2]) +        self.assertEquals((86, 89), diff.lines[21][0:2]) +        self.assertEquals((87, 90), diff.lines[22][0:2]) + +        # Check if a newly added file is correctly handled. +        diff = parser.files['LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum'] +        self.assertEquals(1, len(diff.lines)) +        self.assertEquals((0, 1), diff.lines[0][0:2]) + +    def test_git_mnemonicprefix(self): +        p = re.compile(r' ([a|b])/') + +        prefixes = [ +            { 'a' : 'i', 'b' : 'w' }, # git-diff (compares the (i)ndex and the (w)ork tree) +            { 'a' : 'c', 'b' : 'w' }, # git-diff HEAD (compares a (c)ommit and the (w)ork tree) +            { 'a' : 'c', 'b' : 'i' }, # git diff --cached (compares a (c)ommit and the (i)ndex) +            { 'a' : 'o', 'b' : 'w' }, # git-diff HEAD:file1 file2 (compares an (o)bject and a (w)ork tree entity) +            { 'a' : '1', 'b' : '2' }, # git diff --no-index a b (compares two non-git things (1) and (2)) +        ] + +        for prefix in prefixes: +            patch = p.sub(lambda x: " %s/" % prefix[x.group(1)], self._PATCH) +            self.test_diff_parser(diff_parser.DiffParser(patch.splitlines())) + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/checkout/scm.py b/Tools/Scripts/webkitpy/common/checkout/scm.py new file mode 100644 index 0000000..c54fb42 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm.py @@ -0,0 +1,941 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Python module for interacting with an SCM system (like SVN or Git) + +import os +import re +import sys +import shutil + +from webkitpy.common.system.executive import Executive, run_command, ScriptError +from webkitpy.common.system.deprecated_logging import error, log +from webkitpy.common.memoized import memoized + + +def find_checkout_root(): +    """Returns the current checkout root (as determined by default_scm(). + +    Returns the absolute path to the top of the WebKit checkout, or None +    if it cannot be determined. + +    """ +    scm_system = default_scm() +    if scm_system: +        return scm_system.checkout_root +    return None + + +def default_scm(): +    """Return the default SCM object as determined by the CWD and running code. + +    Returns the default SCM object for the current working directory; if the +    CWD is not in a checkout, then we attempt to figure out if the SCM module +    itself is part of a checkout, and return that one. If neither is part of +    a checkout, None is returned. + +    """ +    cwd = os.getcwd() +    scm_system = detect_scm_system(cwd) +    if not scm_system: +        script_directory = os.path.dirname(os.path.abspath(__file__)) +        scm_system = detect_scm_system(script_directory) +        if scm_system: +            log("The current directory (%s) is not a WebKit checkout, using %s" % (cwd, scm_system.checkout_root)) +        else: +            error("FATAL: Failed to determine the SCM system for either %s or %s" % (cwd, script_directory)) +    return scm_system + + +def detect_scm_system(path): +    absolute_path = os.path.abspath(path) + +    if SVN.in_working_directory(absolute_path): +        return SVN(cwd=absolute_path) +     +    if Git.in_working_directory(absolute_path): +        return Git(cwd=absolute_path) +     +    return None + + +def first_non_empty_line_after_index(lines, index=0): +    first_non_empty_line = index +    for line in lines[index:]: +        if re.match("^\s*$", line): +            first_non_empty_line += 1 +        else: +            break +    return first_non_empty_line + + +class CommitMessage: +    def __init__(self, message): +        self.message_lines = message[first_non_empty_line_after_index(message, 0):] + +    def body(self, lstrip=False): +        lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):] +        if lstrip: +            lines = [line.lstrip() for line in lines] +        return "\n".join(lines) + "\n" + +    def description(self, lstrip=False, strip_url=False): +        line = self.message_lines[0] +        if lstrip: +            line = line.lstrip() +        if strip_url: +            line = re.sub("^(\s*)<.+> ", "\1", line) +        return line + +    def message(self): +        return "\n".join(self.message_lines) + "\n" + + +class CheckoutNeedsUpdate(ScriptError): +    def __init__(self, script_args, exit_code, output, cwd): +        ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd) + + +def commit_error_handler(error): +    if re.search("resource out of date", error.output): +        raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd) +    Executive.default_error_handler(error) + + +class AuthenticationError(Exception): +    def __init__(self, server_host): +        self.server_host = server_host + + +class AmbiguousCommitError(Exception): +    def __init__(self, num_local_commits, working_directory_is_clean): +        self.num_local_commits = num_local_commits +        self.working_directory_is_clean = working_directory_is_clean + + +# SCM methods are expected to return paths relative to self.checkout_root. +class SCM: +    def __init__(self, cwd): +        self.cwd = cwd +        self.checkout_root = self.find_checkout_root(self.cwd) +        self.dryrun = False + +    # A wrapper used by subclasses to create processes. +    def run(self, args, cwd=None, input=None, error_handler=None, return_exit_code=False, return_stderr=True, decode_output=True): +        # FIXME: We should set cwd appropriately. +        # FIXME: We should use Executive. +        return run_command(args, +                           cwd=cwd, +                           input=input, +                           error_handler=error_handler, +                           return_exit_code=return_exit_code, +                           return_stderr=return_stderr, +                           decode_output=decode_output) + +    # SCM always returns repository relative path, but sometimes we need +    # absolute paths to pass to rm, etc. +    def absolute_path(self, repository_relative_path): +        return os.path.join(self.checkout_root, repository_relative_path) + +    # FIXME: This belongs in Checkout, not SCM. +    def scripts_directory(self): +        return os.path.join(self.checkout_root, "Tools", "Scripts") + +    # FIXME: This belongs in Checkout, not SCM. +    def script_path(self, script_name): +        return os.path.join(self.scripts_directory(), script_name) + +    def ensure_clean_working_directory(self, force_clean): +        if not force_clean and not self.working_directory_is_clean(): +            # FIXME: Shouldn't this use cwd=self.checkout_root? +            print self.run(self.status_command(), error_handler=Executive.ignore_error) +            raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.") +         +        log("Cleaning working directory") +        self.clean_working_directory() +     +    def ensure_no_local_commits(self, force): +        if not self.supports_local_commits(): +            return +        commits = self.local_commits() +        if not len(commits): +            return +        if not force: +            error("Working directory has local commits, pass --force-clean to continue.") +        self.discard_local_commits() + +    def run_status_and_extract_filenames(self, status_command, status_regexp): +        filenames = [] +        # We run with cwd=self.checkout_root so that returned-paths are root-relative. +        for line in self.run(status_command, cwd=self.checkout_root).splitlines(): +            match = re.search(status_regexp, line) +            if not match: +                continue +            # status = match.group('status') +            filename = match.group('filename') +            filenames.append(filename) +        return filenames + +    def strip_r_from_svn_revision(self, svn_revision): +        match = re.match("^r(?P<svn_revision>\d+)", unicode(svn_revision)) +        if (match): +            return match.group('svn_revision') +        return svn_revision + +    def svn_revision_from_commit_text(self, commit_text): +        match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE) +        return match.group('svn_revision') + +    @staticmethod +    def _subclass_must_implement(): +        raise NotImplementedError("subclasses must implement") + +    @staticmethod +    def in_working_directory(path): +        SCM._subclass_must_implement() + +    @staticmethod +    def find_checkout_root(path): +        SCM._subclass_must_implement() + +    @staticmethod +    def commit_success_regexp(): +        SCM._subclass_must_implement() + +    def working_directory_is_clean(self): +        self._subclass_must_implement() + +    def clean_working_directory(self): +        self._subclass_must_implement() + +    def status_command(self): +        self._subclass_must_implement() + +    def add(self, path, return_exit_code=False): +        self._subclass_must_implement() + +    def delete(self, path): +        self._subclass_must_implement() + +    def changed_files(self, git_commit=None): +        self._subclass_must_implement() + +    def changed_files_for_revision(self, revision): +        self._subclass_must_implement() + +    def revisions_changing_file(self, path, limit=5): +        self._subclass_must_implement() + +    def added_files(self): +        self._subclass_must_implement() + +    def conflicted_files(self): +        self._subclass_must_implement() + +    def display_name(self): +        self._subclass_must_implement() + +    def create_patch(self, git_commit=None, changed_files=[]): +        self._subclass_must_implement() + +    def committer_email_for_revision(self, revision): +        self._subclass_must_implement() + +    def contents_at_revision(self, path, revision): +        self._subclass_must_implement() + +    def diff_for_revision(self, revision): +        self._subclass_must_implement() + +    def diff_for_file(self, path, log=None): +        self._subclass_must_implement() + +    def show_head(self, path): +        self._subclass_must_implement() + +    def apply_reverse_diff(self, revision): +        self._subclass_must_implement() + +    def revert_files(self, file_paths): +        self._subclass_must_implement() + +    def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): +        self._subclass_must_implement() + +    def svn_commit_log(self, svn_revision): +        self._subclass_must_implement() + +    def last_svn_commit_log(self): +        self._subclass_must_implement() + +    # Subclasses must indicate if they support local commits, +    # but the SCM baseclass will only call local_commits methods when this is true. +    @staticmethod +    def supports_local_commits(): +        SCM._subclass_must_implement() + +    def remote_merge_base(): +        SCM._subclass_must_implement() + +    def commit_locally_with_message(self, message): +        error("Your source control manager does not support local commits.") + +    def discard_local_commits(self): +        pass + +    def local_commits(self): +        return [] + + +class SVN(SCM): +    # FIXME: We should move these values to a WebKit-specific config. file. +    svn_server_host = "svn.webkit.org" +    svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge" + +    def __init__(self, cwd): +        SCM.__init__(self, cwd) +        self._bogus_dir = None + +    @staticmethod +    def in_working_directory(path): +        return os.path.isdir(os.path.join(path, '.svn')) +     +    @classmethod +    def find_uuid(cls, path): +        if not cls.in_working_directory(path): +            return None +        return cls.value_from_svn_info(path, 'Repository UUID') + +    @classmethod +    def value_from_svn_info(cls, path, field_name): +        svn_info_args = ['svn', 'info', path] +        info_output = run_command(svn_info_args).rstrip() +        match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE) +        if not match: +            raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name) +        return match.group('value') + +    @staticmethod +    def find_checkout_root(path): +        uuid = SVN.find_uuid(path) +        # If |path| is not in a working directory, we're supposed to return |path|. +        if not uuid: +            return path +        # Search up the directory hierarchy until we find a different UUID. +        last_path = None +        while True: +            if uuid != SVN.find_uuid(path): +                return last_path +            last_path = path +            (path, last_component) = os.path.split(path) +            if last_path == path: +                return None + +    @staticmethod +    def commit_success_regexp(): +        return "^Committed revision (?P<svn_revision>\d+)\.$" + +    def has_authorization_for_realm(self, realm=svn_server_realm, home_directory=os.getenv("HOME")): +        # Assumes find and grep are installed. +        if not os.path.isdir(os.path.join(home_directory, ".subversion")): +            return False +        find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"]; +        find_output = self.run(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip() +        return find_output and os.path.isfile(os.path.join(home_directory, find_output)) + +    @memoized +    def svn_version(self): +        return self.run(['svn', '--version', '--quiet']) + +    def working_directory_is_clean(self): +        return self.run(["svn", "diff"], cwd=self.checkout_root, decode_output=False) == "" + +    def clean_working_directory(self): +        # Make sure there are no locks lying around from a previously aborted svn invocation. +        # This is slightly dangerous, as it's possible the user is running another svn process +        # on this checkout at the same time.  However, it's much more likely that we're running +        # under windows and svn just sucks (or the user interrupted svn and it failed to clean up). +        self.run(["svn", "cleanup"], cwd=self.checkout_root) + +        # svn revert -R is not as awesome as git reset --hard. +        # It will leave added files around, causing later svn update +        # calls to fail on the bots.  We make this mirror git reset --hard +        # by deleting any added files as well. +        added_files = reversed(sorted(self.added_files())) +        # added_files() returns directories for SVN, we walk the files in reverse path +        # length order so that we remove files before we try to remove the directories. +        self.run(["svn", "revert", "-R", "."], cwd=self.checkout_root) +        for path in added_files: +            # This is robust against cwd != self.checkout_root +            absolute_path = self.absolute_path(path) +            # Completely lame that there is no easy way to remove both types with one call. +            if os.path.isdir(path): +                os.rmdir(absolute_path) +            else: +                os.remove(absolute_path) + +    def status_command(self): +        return ['svn', 'status'] + +    def _status_regexp(self, expected_types): +        field_count = 6 if self.svn_version() > "1.6" else 5 +        return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count) + +    def _add_parent_directories(self, path): +        """Does 'svn add' to the path and its parents.""" +        if self.in_working_directory(path): +            return +        dirname = os.path.dirname(path) +        # We have dirname directry - ensure it added. +        if dirname != path: +            self._add_parent_directories(dirname) +        self.add(path) + +    def add(self, path, return_exit_code=False): +        self._add_parent_directories(os.path.dirname(os.path.abspath(path))) +        return self.run(["svn", "add", path], return_exit_code=return_exit_code) + +    def delete(self, path): +        parent, base = os.path.split(os.path.abspath(path)) +        return self.run(["svn", "delete", "--force", base], cwd=parent) + +    def changed_files(self, git_commit=None): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("ACDMR")) + +    def changed_files_for_revision(self, revision): +        # As far as I can tell svn diff --summarize output looks just like svn status output. +        # No file contents printed, thus utf-8 auto-decoding in self.run is fine. +        status_command = ["svn", "diff", "--summarize", "-c", revision] +        return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR")) + +    def revisions_changing_file(self, path, limit=5): +        revisions = [] +        # svn log will exit(1) (and thus self.run will raise) if the path does not exist. +        log_command = ['svn', 'log', '--quiet', '--limit=%s' % limit, path] +        for line in self.run(log_command, cwd=self.checkout_root).splitlines(): +            match = re.search('^r(?P<revision>\d+) ', line) +            if not match: +                continue +            revisions.append(int(match.group('revision'))) +        return revisions + +    def conflicted_files(self): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C")) + +    def added_files(self): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) + +    def deleted_files(self): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) + +    @staticmethod +    def supports_local_commits(): +        return False + +    def display_name(self): +        return "svn" + +    # FIXME: This method should be on Checkout. +    def create_patch(self, git_commit=None, changed_files=[]): +        """Returns a byte array (str()) representing the patch file. +        Patch files are effectively binary since they may contain +        files of multiple different encodings.""" +        return self.run([self.script_path("svn-create-patch")] + changed_files, +            cwd=self.checkout_root, return_stderr=False, +            decode_output=False) + +    def committer_email_for_revision(self, revision): +        return self.run(["svn", "propget", "svn:author", "--revprop", "-r", revision]).rstrip() + +    def contents_at_revision(self, path, revision): +        """Returns a byte array (str()) containing the contents +        of path @ revision in the repository.""" +        remote_path = "%s/%s" % (self._repository_url(), path) +        return self.run(["svn", "cat", "-r", revision, remote_path], decode_output=False) + +    def diff_for_revision(self, revision): +        # FIXME: This should probably use cwd=self.checkout_root +        return self.run(['svn', 'diff', '-c', revision]) + +    def _bogus_dir_name(self): +        if sys.platform.startswith("win"): +            parent_dir = tempfile.gettempdir() +        else: +            parent_dir = sys.path[0]  # tempdir is not secure. +        return os.path.join(parent_dir, "temp_svn_config") + +    def _setup_bogus_dir(self, log): +        self._bogus_dir = self._bogus_dir_name() +        if not os.path.exists(self._bogus_dir): +            os.mkdir(self._bogus_dir) +            self._delete_bogus_dir = True +        else: +            self._delete_bogus_dir = False +        if log: +            log.debug('  Html: temp config dir: "%s".', self._bogus_dir) + +    def _teardown_bogus_dir(self, log): +        if self._delete_bogus_dir: +            shutil.rmtree(self._bogus_dir, True) +            if log: +                log.debug('  Html: removed temp config dir: "%s".', self._bogus_dir) +        self._bogus_dir = None + +    def diff_for_file(self, path, log=None): +        self._setup_bogus_dir(log) +        try: +            args = ['svn', 'diff'] +            if self._bogus_dir: +                args += ['--config-dir', self._bogus_dir] +            args.append(path) +            return self.run(args) +        finally: +            self._teardown_bogus_dir(log) + +    def show_head(self, path): +        return self.run(['svn', 'cat', '-r', 'BASE', path], decode_output=False) + +    def _repository_url(self): +        return self.value_from_svn_info(self.checkout_root, 'URL') + +    def apply_reverse_diff(self, revision): +        # '-c -revision' applies the inverse diff of 'revision' +        svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()] +        log("WARNING: svn merge has been known to take more than 10 minutes to complete.  It is recommended you use git for rollouts.") +        log("Running '%s'" % " ".join(svn_merge_args)) +        # FIXME: Should this use cwd=self.checkout_root? +        self.run(svn_merge_args) + +    def revert_files(self, file_paths): +        # FIXME: This should probably use cwd=self.checkout_root. +        self.run(['svn', 'revert'] + file_paths) + +    def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): +        # git-commit and force are not used by SVN. +        if self.dryrun: +            # Return a string which looks like a commit so that things which parse this output will succeed. +            return "Dry run, no commit.\nCommitted revision 0." + +        svn_commit_args = ["svn", "commit"] + +        if not username and not self.has_authorization_for_realm(): +            raise AuthenticationError(self.svn_server_host) +        if username: +            svn_commit_args.extend(["--username", username]) + +        svn_commit_args.extend(["-m", message]) +        # FIXME: Should this use cwd=self.checkout_root? +        return self.run(svn_commit_args, error_handler=commit_error_handler) + +    def svn_commit_log(self, svn_revision): +        svn_revision = self.strip_r_from_svn_revision(svn_revision) +        return self.run(['svn', 'log', '--non-interactive', '--revision', svn_revision]) + +    def last_svn_commit_log(self): +        # BASE is the checkout revision, HEAD is the remote repository revision +        # http://svnbook.red-bean.com/en/1.0/ch03s03.html +        return self.svn_commit_log('BASE') + +    def propset(self, pname, pvalue, path): +        dir, base = os.path.split(path) +        return self.run(['svn', 'pset', pname, pvalue, base], cwd=dir) + +    def propget(self, pname, path): +        dir, base = os.path.split(path) +        return self.run(['svn', 'pget', pname, base], cwd=dir).encode('utf-8').rstrip("\n") + + +# All git-specific logic should go here. +class Git(SCM): +    def __init__(self, cwd): +        SCM.__init__(self, cwd) +        self._check_git_architecture() + +    def _machine_is_64bit(self): +        import platform +        # This only is tested on Mac. +        if not platform.mac_ver()[0]: +            return False + +        # platform.architecture()[0] can be '64bit' even if the machine is 32bit: +        # http://mail.python.org/pipermail/pythonmac-sig/2009-September/021648.html +        # Use the sysctl command to find out what the processor actually supports. +        return self.run(['sysctl', '-n', 'hw.cpu64bit_capable']).rstrip() == '1' + +    def _executable_is_64bit(self, path): +        # Again, platform.architecture() fails us.  On my machine +        # git_bits = platform.architecture(executable=git_path, bits='default')[0] +        # git_bits is just 'default', meaning the call failed. +        file_output = self.run(['file', path]) +        return re.search('x86_64', file_output) + +    def _check_git_architecture(self): +        if not self._machine_is_64bit(): +            return + +        # We could path-search entirely in python or with +        # which.py (http://code.google.com/p/which), but this is easier: +        git_path = self.run(['which', 'git']).rstrip() +        if self._executable_is_64bit(git_path): +            return + +        webkit_dev_thead_url = "https://lists.webkit.org/pipermail/webkit-dev/2010-December/015249.html" +        log("Warning: This machine is 64-bit, but the git binary (%s) does not support 64-bit.\nInstall a 64-bit git for better performance, see:\n%s\n" % (git_path, webkit_dev_thead_url)) + +    @classmethod +    def in_working_directory(cls, path): +        return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true" + +    @classmethod +    def find_checkout_root(cls, path): +        # "git rev-parse --show-cdup" would be another way to get to the root +        (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=(path or "./"))) +        # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path) +        if not os.path.isabs(checkout_root): # Sometimes git returns relative paths +            checkout_root = os.path.join(path, checkout_root) +        return checkout_root + +    @classmethod +    def to_object_name(cls, filepath): +        root_end_with_slash = os.path.join(cls.find_checkout_root(os.path.dirname(filepath)), '') +        return filepath.replace(root_end_with_slash, '') + +    @classmethod +    def read_git_config(cls, key): +        # FIXME: This should probably use cwd=self.checkout_root. +        # Pass --get-all for cases where the config has multiple values +        return run_command(["git", "config", "--get-all", key], +            error_handler=Executive.ignore_error).rstrip('\n') + +    @staticmethod +    def commit_success_regexp(): +        return "^Committed r(?P<svn_revision>\d+)$" + +    def discard_local_commits(self): +        # FIXME: This should probably use cwd=self.checkout_root +        self.run(['git', 'reset', '--hard', self.remote_branch_ref()]) +     +    def local_commits(self): +        # FIXME: This should probably use cwd=self.checkout_root +        return self.run(['git', 'log', '--pretty=oneline', 'HEAD...' + self.remote_branch_ref()]).splitlines() + +    def rebase_in_progress(self): +        return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply')) + +    def working_directory_is_clean(self): +        # FIXME: This should probably use cwd=self.checkout_root +        return self.run(['git', 'diff', 'HEAD', '--name-only']) == "" + +    def clean_working_directory(self): +        # FIXME: These should probably use cwd=self.checkout_root. +        # Could run git clean here too, but that wouldn't match working_directory_is_clean +        self.run(['git', 'reset', '--hard', 'HEAD']) +        # Aborting rebase even though this does not match working_directory_is_clean +        if self.rebase_in_progress(): +            self.run(['git', 'rebase', '--abort']) + +    def status_command(self): +        # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead. +        # No file contents printed, thus utf-8 autodecoding in self.run is fine. +        return ["git", "diff", "--name-status", "HEAD"] + +    def _status_regexp(self, expected_types): +        return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types + +    def add(self, path, return_exit_code=False): +        return self.run(["git", "add", path], return_exit_code=return_exit_code) + +    def delete(self, path): +        return self.run(["git", "rm", "-f", path]) + +    def merge_base(self, git_commit): +        if git_commit: +            # Special-case HEAD.. to mean working-copy changes only. +            if git_commit.upper() == 'HEAD..': +                return 'HEAD' + +            if '..' not in git_commit: +                git_commit = git_commit + "^.." + git_commit +            return git_commit + +        return self.remote_merge_base() + +    def changed_files(self, git_commit=None): +        status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', "--no-ext-diff", "--full-index", self.merge_base(git_commit)] +        return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM")) + +    def _changes_files_for_commit(self, git_commit): +        # --pretty="format:" makes git show not print the commit log header, +        changed_files = self.run(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines() +        # instead it just prints a blank line at the top, so we skip the blank line: +        return changed_files[1:] + +    def changed_files_for_revision(self, revision): +        commit_id = self.git_commit_from_svn_revision(revision) +        return self._changes_files_for_commit(commit_id) + +    def revisions_changing_file(self, path, limit=5): +        # git rev-list head --remove-empty --limit=5 -- path would be equivalent. +        commit_ids = self.run(["git", "log", "--remove-empty", "--pretty=format:%H", "-%s" % limit, "--", path]).splitlines() +        return filter(lambda revision: revision, map(self.svn_revision_from_git_commit, commit_ids)) + +    def conflicted_files(self): +        # We do not need to pass decode_output for this diff command +        # as we're passing --name-status which does not output any data. +        status_command = ['git', 'diff', '--name-status', '-C', '-M', '--diff-filter=U'] +        return self.run_status_and_extract_filenames(status_command, self._status_regexp("U")) + +    def added_files(self): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A")) + +    def deleted_files(self): +        return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("D")) + +    @staticmethod +    def supports_local_commits(): +        return True + +    def display_name(self): +        return "git" + +    def create_patch(self, git_commit=None, changed_files=[]): +        """Returns a byte array (str()) representing the patch file. +        Patch files are effectively binary since they may contain +        files of multiple different encodings.""" +        return self.run(['git', 'diff', '--binary', "--no-ext-diff", "--full-index", "-M", self.merge_base(git_commit), "--"] + changed_files, decode_output=False, cwd=self.checkout_root) + +    def _run_git_svn_find_rev(self, arg): +        # git svn find-rev always exits 0, even when the revision or commit is not found. +        return self.run(['git', 'svn', 'find-rev', arg], cwd=self.checkout_root).rstrip() + +    def _string_to_int_or_none(self, string): +        try: +            return int(string) +        except ValueError, e: +            return None + +    @memoized +    def git_commit_from_svn_revision(self, svn_revision): +        git_commit = self._run_git_svn_find_rev('r%s' % svn_revision) +        if not git_commit: +            # FIXME: Alternatively we could offer to update the checkout? Or return None? +            raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % svn_revision) +        return git_commit + +    @memoized +    def svn_revision_from_git_commit(self, git_commit): +        svn_revision = self._run_git_svn_find_rev(git_commit) +        return self._string_to_int_or_none(svn_revision) + +    def contents_at_revision(self, path, revision): +        """Returns a byte array (str()) containing the contents +        of path @ revision in the repository.""" +        return self.run(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)], decode_output=False) + +    def diff_for_revision(self, revision): +        git_commit = self.git_commit_from_svn_revision(revision) +        return self.create_patch(git_commit) + +    def diff_for_file(self, path, log=None): +        return self.run(['git', 'diff', 'HEAD', '--', path]) + +    def show_head(self, path): +        return self.run(['git', 'show', 'HEAD:' + self.to_object_name(path)], decode_output=False) + +    def committer_email_for_revision(self, revision): +        git_commit = self.git_commit_from_svn_revision(revision) +        committer_email = self.run(["git", "log", "-1", "--pretty=format:%ce", git_commit]) +        # Git adds an extra @repository_hash to the end of every committer email, remove it: +        return committer_email.rsplit("@", 1)[0] + +    def apply_reverse_diff(self, revision): +        # Assume the revision is an svn revision. +        git_commit = self.git_commit_from_svn_revision(revision) +        # I think this will always fail due to ChangeLogs. +        self.run(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error) + +    def revert_files(self, file_paths): +        self.run(['git', 'checkout', 'HEAD'] + file_paths) + +    def _assert_can_squash(self, working_directory_is_clean): +        squash = Git.read_git_config('webkit-patch.commit-should-always-squash') +        should_squash = squash and squash.lower() == "true" + +        if not should_squash: +            # Only warn if there are actually multiple commits to squash. +            num_local_commits = len(self.local_commits()) +            if num_local_commits > 1 or (num_local_commits > 0 and not working_directory_is_clean): +                raise AmbiguousCommitError(num_local_commits, working_directory_is_clean) + +    def commit_with_message(self, message, username=None, git_commit=None, force_squash=False): +        # Username is ignored during Git commits. +        working_directory_is_clean = self.working_directory_is_clean() + +        if git_commit: +            # Special-case HEAD.. to mean working-copy changes only. +            if git_commit.upper() == 'HEAD..': +                if working_directory_is_clean: +                    raise ScriptError(message="The working copy is not modified. --git-commit=HEAD.. only commits working copy changes.") +                self.commit_locally_with_message(message) +                return self._commit_on_branch(message, 'HEAD') + +            # Need working directory changes to be committed so we can checkout the merge branch. +            if not working_directory_is_clean: +                # FIXME: webkit-patch land will modify the ChangeLogs to correct the reviewer. +                # That will modify the working-copy and cause us to hit this error. +                # The ChangeLog modification could be made to modify the existing local commit. +                raise ScriptError(message="Working copy is modified. Cannot commit individual git_commits.") +            return self._commit_on_branch(message, git_commit) + +        if not force_squash: +            self._assert_can_squash(working_directory_is_clean) +        self.run(['git', 'reset', '--soft', self.remote_merge_base()]) +        self.commit_locally_with_message(message) +        return self.push_local_commits_to_server() + +    def _commit_on_branch(self, message, git_commit): +        branch_ref = self.run(['git', 'symbolic-ref', 'HEAD']).strip() +        branch_name = branch_ref.replace('refs/heads/', '') +        commit_ids = self.commit_ids_from_commitish_arguments([git_commit]) + +        # We want to squash all this branch's commits into one commit with the proper description. +        # We do this by doing a "merge --squash" into a new commit branch, then dcommitting that. +        MERGE_BRANCH_NAME = 'webkit-patch-land' +        self.delete_branch(MERGE_BRANCH_NAME) + +        # We might be in a directory that's present in this branch but not in the +        # trunk.  Move up to the top of the tree so that git commands that expect a +        # valid CWD won't fail after we check out the merge branch. +        os.chdir(self.checkout_root) + +        # Stuff our change into the merge branch. +        # We wrap in a try...finally block so if anything goes wrong, we clean up the branches. +        commit_succeeded = True +        try: +            self.run(['git', 'checkout', '-q', '-b', MERGE_BRANCH_NAME, self.remote_branch_ref()]) + +            for commit in commit_ids: +                # We're on a different branch now, so convert "head" to the branch name. +                commit = re.sub(r'(?i)head', branch_name, commit) +                # FIXME: Once changed_files and create_patch are modified to separately handle each +                # commit in a commit range, commit each cherry pick so they'll get dcommitted separately. +                self.run(['git', 'cherry-pick', '--no-commit', commit]) + +            self.run(['git', 'commit', '-m', message]) +            output = self.push_local_commits_to_server() +        except Exception, e: +            log("COMMIT FAILED: " + str(e)) +            output = "Commit failed." +            commit_succeeded = False +        finally: +            # And then swap back to the original branch and clean up. +            self.clean_working_directory() +            self.run(['git', 'checkout', '-q', branch_name]) +            self.delete_branch(MERGE_BRANCH_NAME) + +        return output + +    def svn_commit_log(self, svn_revision): +        svn_revision = self.strip_r_from_svn_revision(svn_revision) +        return self.run(['git', 'svn', 'log', '-r', svn_revision]) + +    def last_svn_commit_log(self): +        return self.run(['git', 'svn', 'log', '--limit=1']) + +    # Git-specific methods: +    def _branch_ref_exists(self, branch_ref): +        return self.run(['git', 'show-ref', '--quiet', '--verify', branch_ref], return_exit_code=True) == 0 + +    def delete_branch(self, branch_name): +        if self._branch_ref_exists('refs/heads/' + branch_name): +            self.run(['git', 'branch', '-D', branch_name]) + +    def remote_merge_base(self): +        return self.run(['git', 'merge-base', self.remote_branch_ref(), 'HEAD']).strip() + +    def remote_branch_ref(self): +        # Use references so that we can avoid collisions, e.g. we don't want to operate on refs/heads/trunk if it exists. +        remote_branch_refs = Git.read_git_config('svn-remote.svn.fetch') +        if not remote_branch_refs: +            remote_master_ref = 'refs/remotes/origin/master' +            if not self._branch_ref_exists(remote_master_ref): +                raise ScriptError(message="Can't find a branch to diff against. svn-remote.svn.fetch is not in the git config and %s does not exist" % remote_master_ref) +            return remote_master_ref + +        # FIXME: What's the right behavior when there are multiple svn-remotes listed? +        # For now, just use the first one. +        first_remote_branch_ref = remote_branch_refs.split('\n')[0] +        return first_remote_branch_ref.split(':')[1] + +    def commit_locally_with_message(self, message): +        self.run(['git', 'commit', '--all', '-F', '-'], input=message) + +    def push_local_commits_to_server(self): +        dcommit_command = ['git', 'svn', 'dcommit'] +        if self.dryrun: +            dcommit_command.append('--dry-run') +        output = self.run(dcommit_command, error_handler=commit_error_handler) +        # Return a string which looks like a commit so that things which parse this output will succeed. +        if self.dryrun: +            output += "\nCommitted r0" +        return output + +    # This function supports the following argument formats: +    # no args : rev-list trunk..HEAD +    # A..B    : rev-list A..B +    # A...B   : error! +    # A B     : [A, B]  (different from git diff, which would use "rev-list A..B") +    def commit_ids_from_commitish_arguments(self, args): +        if not len(args): +            args.append('%s..HEAD' % self.remote_branch_ref()) + +        commit_ids = [] +        for commitish in args: +            if '...' in commitish: +                raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish) +            elif '..' in commitish: +                commit_ids += reversed(self.run(['git', 'rev-list', commitish]).splitlines()) +            else: +                # Turn single commits or branch or tag names into commit ids. +                commit_ids += self.run(['git', 'rev-parse', '--revs-only', commitish]).splitlines() +        return commit_ids + +    def commit_message_for_local_commit(self, commit_id): +        commit_lines = self.run(['git', 'cat-file', 'commit', commit_id]).splitlines() + +        # Skip the git headers. +        first_line_after_headers = 0 +        for line in commit_lines: +            first_line_after_headers += 1 +            if line == "": +                break +        return CommitMessage(commit_lines[first_line_after_headers:]) + +    def files_changed_summary_for_commit(self, commit_id): +        return self.run(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id]) diff --git a/Tools/Scripts/webkitpy/common/checkout/scm_unittest.py b/Tools/Scripts/webkitpy/common/checkout/scm_unittest.py new file mode 100644 index 0000000..8f24beb --- /dev/null +++ b/Tools/Scripts/webkitpy/common/checkout/scm_unittest.py @@ -0,0 +1,1320 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# Copyright (C) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import base64 +import codecs +import getpass +import os +import os.path +import re +import stat +import sys +import subprocess +import tempfile +import unittest +import urllib +import shutil + +from datetime import date +from webkitpy.common.checkout.api import Checkout +from webkitpy.common.checkout.scm import detect_scm_system, SCM, SVN, CheckoutNeedsUpdate, commit_error_handler, AuthenticationError, AmbiguousCommitError, find_checkout_root, default_scm +from webkitpy.common.config.committers import Committer  # FIXME: This should not be needed +from webkitpy.common.net.bugzilla import Attachment # FIXME: This should not be needed +from webkitpy.common.system.executive import Executive, run_command, ScriptError +from webkitpy.common.system.outputcapture import OutputCapture + +# Eventually we will want to write tests which work for both scms. (like update_webkit, changed_files, etc.) +# Perhaps through some SCMTest base-class which both SVNTest and GitTest inherit from. + +# FIXME: This should be unified into one of the executive.py commands! +# Callers could use run_and_throw_if_fail(args, cwd=cwd, quiet=True) +def run_silent(args, cwd=None): +    # Note: Not thread safe: http://bugs.python.org/issue2320 +    process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd) +    process.communicate() # ignore output +    exit_code = process.wait() +    if exit_code: +        raise ScriptError('Failed to run "%s"  exit_code: %d  cwd: %s' % (args, exit_code, cwd)) + + +def write_into_file_at_path(file_path, contents, encoding="utf-8"): +    if encoding: +        with codecs.open(file_path, "w", encoding) as file: +            file.write(contents) +    else: +        with open(file_path, "w") as file: +            file.write(contents) + + +def read_from_path(file_path, encoding="utf-8"): +    with codecs.open(file_path, "r", encoding) as file: +        return file.read() + + +def _make_diff(command, *args): +    # We use this wrapper to disable output decoding. diffs should be treated as +    # binary files since they may include text files of multiple differnet encodings. +    return run_command([command, "diff"] + list(args), decode_output=False) + + +def _svn_diff(*args): +    return _make_diff("svn", *args) + + +def _git_diff(*args): +    return _make_diff("git", *args) + + +# Exists to share svn repository creation code between the git and svn tests +class SVNTestRepository: +    @classmethod +    def _svn_add(cls, path): +        run_command(["svn", "add", path]) + +    @classmethod +    def _svn_commit(cls, message): +        run_command(["svn", "commit", "--quiet", "--message", message]) + +    @classmethod +    def _setup_test_commits(cls, test_object): +        # Add some test commits +        os.chdir(test_object.svn_checkout_path) + +        write_into_file_at_path("test_file", "test1") +        cls._svn_add("test_file") +        cls._svn_commit("initial commit") + +        write_into_file_at_path("test_file", "test1test2") +        # This used to be the last commit, but doing so broke +        # GitTest.test_apply_git_patch which use the inverse diff of the last commit. +        # svn-apply fails to remove directories in Git, see: +        # https://bugs.webkit.org/show_bug.cgi?id=34871 +        os.mkdir("test_dir") +        # Slash should always be the right path separator since we use cygwin on Windows. +        test_file3_path = "test_dir/test_file3" +        write_into_file_at_path(test_file3_path, "third file") +        cls._svn_add("test_dir") +        cls._svn_commit("second commit") + +        write_into_file_at_path("test_file", "test1test2test3\n") +        write_into_file_at_path("test_file2", "second file") +        cls._svn_add("test_file2") +        cls._svn_commit("third commit") + +        # This 4th commit is used to make sure that our patch file handling +        # code correctly treats patches as binary and does not attempt to +        # decode them assuming they're utf-8. +        write_into_file_at_path("test_file", u"latin1 test: \u00A0\n", "latin1") +        write_into_file_at_path("test_file2", u"utf-8 test: \u00A0\n", "utf-8") +        cls._svn_commit("fourth commit") + +        # svn does not seem to update after commit as I would expect. +        run_command(['svn', 'update']) + +    @classmethod +    def setup(cls, test_object): +        # Create an test SVN repository +        test_object.svn_repo_path = tempfile.mkdtemp(suffix="svn_test_repo") +        test_object.svn_repo_url = "file://%s" % test_object.svn_repo_path # Not sure this will work on windows +        # git svn complains if we don't pass --pre-1.5-compatible, not sure why: +        # Expected FS format '2'; found format '3' at /usr/local/libexec/git-core//git-svn line 1477 +        run_command(['svnadmin', 'create', '--pre-1.5-compatible', test_object.svn_repo_path]) + +        # Create a test svn checkout +        test_object.svn_checkout_path = tempfile.mkdtemp(suffix="svn_test_checkout") +        run_command(['svn', 'checkout', '--quiet', test_object.svn_repo_url, test_object.svn_checkout_path]) + +        # Create and checkout a trunk dir to match the standard svn configuration to match git-svn's expectations +        os.chdir(test_object.svn_checkout_path) +        os.mkdir('trunk') +        cls._svn_add('trunk') +        # We can add tags and branches as well if we ever need to test those. +        cls._svn_commit('add trunk') + +        # Change directory out of the svn checkout so we can delete the checkout directory. +        # _setup_test_commits will CD back to the svn checkout directory. +        os.chdir('/') +        run_command(['rm', '-rf', test_object.svn_checkout_path]) +        run_command(['svn', 'checkout', '--quiet', test_object.svn_repo_url + '/trunk', test_object.svn_checkout_path]) + +        cls._setup_test_commits(test_object) + +    @classmethod +    def tear_down(cls, test_object): +        run_command(['rm', '-rf', test_object.svn_repo_path]) +        run_command(['rm', '-rf', test_object.svn_checkout_path]) + +        # Now that we've deleted the checkout paths, cwddir may be invalid +        # Change back to a valid directory so that later calls to os.getcwd() do not fail. +        os.chdir(detect_scm_system(os.path.dirname(__file__)).checkout_root) + + +class StandaloneFunctionsTest(unittest.TestCase): +    """This class tests any standalone/top-level functions in the package.""" +    def setUp(self): +        self.orig_cwd = os.path.abspath(os.getcwd()) +        self.orig_abspath = os.path.abspath + +        # We capture but ignore the output from stderr to reduce unwanted +        # logging. +        self.output = OutputCapture() +        self.output.capture_output() + +    def tearDown(self): +        os.chdir(self.orig_cwd) +        os.path.abspath = self.orig_abspath +        self.output.restore_output() + +    def test_find_checkout_root(self): +        # Test from inside the tree. +        os.chdir(sys.path[0]) +        dir = find_checkout_root() +        self.assertNotEqual(dir, None) +        self.assertTrue(os.path.exists(dir)) + +        # Test from outside the tree. +        os.chdir(os.path.expanduser("~")) +        dir = find_checkout_root() +        self.assertNotEqual(dir, None) +        self.assertTrue(os.path.exists(dir)) + +        # Mock out abspath() to test being not in a checkout at all. +        os.path.abspath = lambda x: "/" +        self.assertRaises(SystemExit, find_checkout_root) +        os.path.abspath = self.orig_abspath + +    def test_default_scm(self): +        # Test from inside the tree. +        os.chdir(sys.path[0]) +        scm = default_scm() +        self.assertNotEqual(scm, None) + +        # Test from outside the tree. +        os.chdir(os.path.expanduser("~")) +        dir = find_checkout_root() +        self.assertNotEqual(dir, None) + +        # Mock out abspath() to test being not in a checkout at all. +        os.path.abspath = lambda x: "/" +        self.assertRaises(SystemExit, default_scm) +        os.path.abspath = self.orig_abspath + +# For testing the SCM baseclass directly. +class SCMClassTests(unittest.TestCase): +    def setUp(self): +        self.dev_null = open(os.devnull, "w") # Used to make our Popen calls quiet. + +    def tearDown(self): +        self.dev_null.close() + +    def test_run_command_with_pipe(self): +        input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null) +        self.assertEqual(run_command(['grep', 'bar'], input=input_process.stdout), "bar\n") + +        # Test the non-pipe case too: +        self.assertEqual(run_command(['grep', 'bar'], input="foo\nbar"), "bar\n") + +        command_returns_non_zero = ['/bin/sh', '--invalid-option'] +        # Test when the input pipe process fails. +        input_process = subprocess.Popen(command_returns_non_zero, stdout=subprocess.PIPE, stderr=self.dev_null) +        self.assertTrue(input_process.poll() != 0) +        self.assertRaises(ScriptError, run_command, ['grep', 'bar'], input=input_process.stdout) + +        # Test when the run_command process fails. +        input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null) # grep shows usage and calls exit(2) when called w/o arguments. +        self.assertRaises(ScriptError, run_command, command_returns_non_zero, input=input_process.stdout) + +    def test_error_handlers(self): +        git_failure_message="Merge conflict during commit: Your file or directory 'WebCore/ChangeLog' is probably out-of-date: resource out of date; try updating at /usr/local/libexec/git-core//git-svn line 469" +        svn_failure_message="""svn: Commit failed (details follow): +svn: File or directory 'ChangeLog' is out of date; try updating +svn: resource out of date; try updating +""" +        command_does_not_exist = ['does_not_exist', 'invalid_option'] +        self.assertRaises(OSError, run_command, command_does_not_exist) +        self.assertRaises(OSError, run_command, command_does_not_exist, error_handler=Executive.ignore_error) + +        command_returns_non_zero = ['/bin/sh', '--invalid-option'] +        self.assertRaises(ScriptError, run_command, command_returns_non_zero) +        # Check if returns error text: +        self.assertTrue(run_command(command_returns_non_zero, error_handler=Executive.ignore_error)) + +        self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=git_failure_message)) +        self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=svn_failure_message)) +        self.assertRaises(ScriptError, commit_error_handler, ScriptError(output='blah blah blah')) + + +# GitTest and SVNTest inherit from this so any test_ methods here will be run once for this class and then once for each subclass. +class SCMTest(unittest.TestCase): +    def _create_patch(self, patch_contents): +        # FIXME: This code is brittle if the Attachment API changes. +        attachment = Attachment({"bug_id": 12345}, None) +        attachment.contents = lambda: patch_contents + +        joe_cool = Committer(name="Joe Cool", email_or_emails=None) +        attachment.reviewer = lambda: joe_cool + +        return attachment + +    def _setup_webkittools_scripts_symlink(self, local_scm): +        webkit_scm = detect_scm_system(os.path.dirname(os.path.abspath(__file__))) +        webkit_scripts_directory = webkit_scm.scripts_directory() +        local_scripts_directory = local_scm.scripts_directory() +        os.mkdir(os.path.dirname(local_scripts_directory)) +        os.symlink(webkit_scripts_directory, local_scripts_directory) + +    # Tests which both GitTest and SVNTest should run. +    # FIXME: There must be a simpler way to add these w/o adding a wrapper method to both subclasses + +    def _shared_test_changed_files(self): +        write_into_file_at_path("test_file", "changed content") +        self.assertEqual(self.scm.changed_files(), ["test_file"]) +        write_into_file_at_path("test_dir/test_file3", "new stuff") +        self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"]) +        old_cwd = os.getcwd() +        os.chdir("test_dir") +        # Validate that changed_files does not change with our cwd, see bug 37015. +        self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"]) +        os.chdir(old_cwd) + +    def _shared_test_added_files(self): +        write_into_file_at_path("test_file", "changed content") +        self.assertEqual(self.scm.added_files(), []) + +        write_into_file_at_path("added_file", "new stuff") +        self.scm.add("added_file") + +        os.mkdir("added_dir") +        write_into_file_at_path("added_dir/added_file2", "new stuff") +        self.scm.add("added_dir") + +        # SVN reports directory changes, Git does not. +        added_files = self.scm.added_files() +        if "added_dir" in added_files: +            added_files.remove("added_dir") +        self.assertEqual(added_files, ["added_dir/added_file2", "added_file"]) + +        # Test also to make sure clean_working_directory removes added files +        self.scm.clean_working_directory() +        self.assertEqual(self.scm.added_files(), []) +        self.assertFalse(os.path.exists("added_file")) +        self.assertFalse(os.path.exists("added_dir")) + +    def _shared_test_changed_files_for_revision(self): +        # SVN reports directory changes, Git does not. +        changed_files = self.scm.changed_files_for_revision(3) +        if "test_dir" in changed_files: +            changed_files.remove("test_dir") +        self.assertEqual(changed_files, ["test_dir/test_file3", "test_file"]) +        self.assertEqual(sorted(self.scm.changed_files_for_revision(4)), sorted(["test_file", "test_file2"]))  # Git and SVN return different orders. +        self.assertEqual(self.scm.changed_files_for_revision(2), ["test_file"]) + +    def _shared_test_contents_at_revision(self): +        self.assertEqual(self.scm.contents_at_revision("test_file", 3), "test1test2") +        self.assertEqual(self.scm.contents_at_revision("test_file", 4), "test1test2test3\n") + +        # Verify that contents_at_revision returns a byte array, aka str(): +        self.assertEqual(self.scm.contents_at_revision("test_file", 5), u"latin1 test: \u00A0\n".encode("latin1")) +        self.assertEqual(self.scm.contents_at_revision("test_file2", 5), u"utf-8 test: \u00A0\n".encode("utf-8")) + +        self.assertEqual(self.scm.contents_at_revision("test_file2", 4), "second file") +        # Files which don't exist: +        # Currently we raise instead of returning None because detecting the difference between +        # "file not found" and any other error seems impossible with svn (git seems to expose such through the return code). +        self.assertRaises(ScriptError, self.scm.contents_at_revision, "test_file2", 2) +        self.assertRaises(ScriptError, self.scm.contents_at_revision, "does_not_exist", 2) + +    def _shared_test_revisions_changing_file(self): +        self.assertEqual(self.scm.revisions_changing_file("test_file"), [5, 4, 3, 2]) +        self.assertRaises(ScriptError, self.scm.revisions_changing_file, "non_existent_file") + +    def _shared_test_committer_email_for_revision(self): +        self.assertEqual(self.scm.committer_email_for_revision(3), getpass.getuser())  # Committer "email" will be the current user + +    def _shared_test_reverse_diff(self): +        self._setup_webkittools_scripts_symlink(self.scm) # Git's apply_reverse_diff uses resolve-ChangeLogs +        # Only test the simple case, as any other will end up with conflict markers. +        self.scm.apply_reverse_diff('5') +        self.assertEqual(read_from_path('test_file'), "test1test2test3\n") + +    def _shared_test_diff_for_revision(self): +        # Patch formats are slightly different between svn and git, so just regexp for things we know should be there. +        r3_patch = self.scm.diff_for_revision(4) +        self.assertTrue(re.search('test3', r3_patch)) +        self.assertFalse(re.search('test4', r3_patch)) +        self.assertTrue(re.search('test2', r3_patch)) +        self.assertTrue(re.search('test2', self.scm.diff_for_revision(3))) + +    def _shared_test_svn_apply_git_patch(self): +        self._setup_webkittools_scripts_symlink(self.scm) +        git_binary_addition = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +new file mode 100644 +index 0000000000000000000000000000000000000000..64a9532e7794fcd791f6f12157406d90 +60151690 +GIT binary patch +literal 512 +zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c? +zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap +zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ +zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A +zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&) +zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b +zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB +z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X +z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4 +ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H + +literal 0 +HcmV?d00001 + +""" +        self.checkout.apply_patch(self._create_patch(git_binary_addition)) +        added = read_from_path('fizzbuzz7.gif', encoding=None) +        self.assertEqual(512, len(added)) +        self.assertTrue(added.startswith('GIF89a')) +        self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files()) + +        # The file already exists. +        self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_addition)) + +        git_binary_modification = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +index 64a9532e7794fcd791f6f12157406d9060151690..323fae03f4606ea9991df8befbb2fca7 +GIT binary patch +literal 7 +OcmYex&reD$;sO8*F9L)B + +literal 512 +zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c? +zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap +zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ +zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A +zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&) +zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b +zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB +z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X +z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4 +ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H + +""" +        self.checkout.apply_patch(self._create_patch(git_binary_modification)) +        modified = read_from_path('fizzbuzz7.gif', encoding=None) +        self.assertEqual('foobar\n', modified) +        self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files()) + +        # Applying the same modification should fail. +        self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_modification)) + +        git_binary_deletion = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif +deleted file mode 100644 +index 323fae0..0000000 +GIT binary patch +literal 0 +HcmV?d00001 + +literal 7 +OcmYex&reD$;sO8*F9L)B + +""" +        self.checkout.apply_patch(self._create_patch(git_binary_deletion)) +        self.assertFalse(os.path.exists('fizzbuzz7.gif')) +        self.assertFalse('fizzbuzz7.gif' in self.scm.changed_files()) + +        # Cannot delete again. +        self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_deletion)) + +    def _shared_test_add_recursively(self): +        os.mkdir("added_dir") +        write_into_file_at_path("added_dir/added_file", "new stuff") +        self.scm.add("added_dir/added_file") +        self.assertTrue("added_dir/added_file" in self.scm.added_files()) + +class SVNTest(SCMTest): + +    @staticmethod +    def _set_date_and_reviewer(changelog_entry): +        # Joe Cool matches the reviewer set in SCMTest._create_patch +        changelog_entry = changelog_entry.replace('REVIEWER_HERE', 'Joe Cool') +        # svn-apply will update ChangeLog entries with today's date. +        return changelog_entry.replace('DATE_HERE', date.today().isoformat()) + +    def test_svn_apply(self): +        first_entry = """2009-10-26  Eric Seidel  <eric@webkit.org> + +        Reviewed by Foo Bar. + +        Most awesome change ever. + +        * scm_unittest.py: +""" +        intermediate_entry = """2009-10-27  Eric Seidel  <eric@webkit.org> + +        Reviewed by Baz Bar. + +        A more awesomer change yet! + +        * scm_unittest.py: +""" +        one_line_overlap_patch = """Index: ChangeLog +=================================================================== +--- ChangeLog	(revision 5) ++++ ChangeLog	(working copy) +@@ -1,5 +1,13 @@ + 2009-10-26  Eric Seidel  <eric@webkit.org> +  ++        Reviewed by NOBODY (OOPS!). ++ ++        Second most awesome change ever. ++ ++        * scm_unittest.py: ++ ++2009-10-26  Eric Seidel  <eric@webkit.org> ++ +         Reviewed by Foo Bar. +  +         Most awesome change ever. +""" +        one_line_overlap_entry = """DATE_HERE  Eric Seidel  <eric@webkit.org> + +        Reviewed by REVIEWER_HERE. + +        Second most awesome change ever. + +        * scm_unittest.py: +""" +        two_line_overlap_patch = """Index: ChangeLog +=================================================================== +--- ChangeLog	(revision 5) ++++ ChangeLog	(working copy) +@@ -2,6 +2,14 @@ +  +         Reviewed by Foo Bar. +  ++        Second most awesome change ever. ++ ++        * scm_unittest.py: ++ ++2009-10-26  Eric Seidel  <eric@webkit.org> ++ ++        Reviewed by Foo Bar. ++ +         Most awesome change ever. +  +         * scm_unittest.py: +""" +        two_line_overlap_entry = """DATE_HERE  Eric Seidel  <eric@webkit.org> + +        Reviewed by Foo Bar. + +        Second most awesome change ever. + +        * scm_unittest.py: +""" +        write_into_file_at_path('ChangeLog', first_entry) +        run_command(['svn', 'add', 'ChangeLog']) +        run_command(['svn', 'commit', '--quiet', '--message', 'ChangeLog commit']) + +        # Patch files were created against just 'first_entry'. +        # Add a second commit to make svn-apply have to apply the patches with fuzz. +        changelog_contents = "%s\n%s" % (intermediate_entry, first_entry) +        write_into_file_at_path('ChangeLog', changelog_contents) +        run_command(['svn', 'commit', '--quiet', '--message', 'Intermediate commit']) + +        self._setup_webkittools_scripts_symlink(self.scm) +        self.checkout.apply_patch(self._create_patch(one_line_overlap_patch)) +        expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(one_line_overlap_entry), changelog_contents) +        self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents) + +        self.scm.revert_files(['ChangeLog']) +        self.checkout.apply_patch(self._create_patch(two_line_overlap_patch)) +        expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(two_line_overlap_entry), changelog_contents) +        self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents) + +    def setUp(self): +        SVNTestRepository.setup(self) +        os.chdir(self.svn_checkout_path) +        self.scm = detect_scm_system(self.svn_checkout_path) +        # For historical reasons, we test some checkout code here too. +        self.checkout = Checkout(self.scm) + +    def tearDown(self): +        SVNTestRepository.tear_down(self) + +    def test_detect_scm_system_relative_url(self): +        scm = detect_scm_system(".") +        # I wanted to assert that we got the right path, but there was some +        # crazy magic with temp folder names that I couldn't figure out. +        self.assertTrue(scm.checkout_root) + +    def test_create_patch_is_full_patch(self): +        test_dir_path = os.path.join(self.svn_checkout_path, "test_dir2") +        os.mkdir(test_dir_path) +        test_file_path = os.path.join(test_dir_path, 'test_file2') +        write_into_file_at_path(test_file_path, 'test content') +        run_command(['svn', 'add', 'test_dir2']) + +        # create_patch depends on 'svn-create-patch', so make a dummy version. +        scripts_path = os.path.join(self.svn_checkout_path, 'Tools', 'Scripts') +        os.makedirs(scripts_path) +        create_patch_path = os.path.join(scripts_path, 'svn-create-patch') +        write_into_file_at_path(create_patch_path, '#!/bin/sh\necho $PWD') # We could pass -n to prevent the \n, but not all echo accept -n. +        os.chmod(create_patch_path, stat.S_IXUSR | stat.S_IRUSR) + +        # Change into our test directory and run the create_patch command. +        os.chdir(test_dir_path) +        scm = detect_scm_system(test_dir_path) +        self.assertEqual(scm.checkout_root, self.svn_checkout_path) # Sanity check that detection worked right. +        patch_contents = scm.create_patch() +        # Our fake 'svn-create-patch' returns $PWD instead of a patch, check that it was executed from the root of the repo. +        self.assertEqual("%s\n" % os.path.realpath(scm.checkout_root), patch_contents) # Add a \n because echo adds a \n. + +    def test_detection(self): +        scm = detect_scm_system(self.svn_checkout_path) +        self.assertEqual(scm.display_name(), "svn") +        self.assertEqual(scm.supports_local_commits(), False) + +    def test_apply_small_binary_patch(self): +        patch_contents = """Index: test_file.swf +=================================================================== +Cannot display: file marked as a binary type. +svn:mime-type = application/octet-stream + +Property changes on: test_file.swf +___________________________________________________________________ +Name: svn:mime-type +   + application/octet-stream + + +Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA== +""" +        expected_contents = base64.b64decode("Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA==") +        self._setup_webkittools_scripts_symlink(self.scm) +        patch_file = self._create_patch(patch_contents) +        self.checkout.apply_patch(patch_file) +        actual_contents = read_from_path("test_file.swf", encoding=None) +        self.assertEqual(actual_contents, expected_contents) + +    def test_apply_svn_patch(self): +        scm = detect_scm_system(self.svn_checkout_path) +        patch = self._create_patch(_svn_diff("-r5:4")) +        self._setup_webkittools_scripts_symlink(scm) +        Checkout(scm).apply_patch(patch) + +    def test_apply_svn_patch_force(self): +        scm = detect_scm_system(self.svn_checkout_path) +        patch = self._create_patch(_svn_diff("-r3:5")) +        self._setup_webkittools_scripts_symlink(scm) +        self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True) + +    def test_commit_logs(self): +        # Commits have dates and usernames in them, so we can't just direct compare. +        self.assertTrue(re.search('fourth commit', self.scm.last_svn_commit_log())) +        self.assertTrue(re.search('second commit', self.scm.svn_commit_log(3))) + +    def _shared_test_commit_with_message(self, username=None): +        write_into_file_at_path('test_file', 'more test content') +        commit_text = self.scm.commit_with_message("another test commit", username) +        self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '6') + +        self.scm.dryrun = True +        write_into_file_at_path('test_file', 'still more test content') +        commit_text = self.scm.commit_with_message("yet another test commit", username) +        self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '0') + +    def test_commit_text_parsing(self): +        self._shared_test_commit_with_message() + +    def test_commit_with_username(self): +        self._shared_test_commit_with_message("dbates@webkit.org") + +    def test_commit_without_authorization(self): +        self.scm.has_authorization_for_realm = lambda: False +        self.assertRaises(AuthenticationError, self._shared_test_commit_with_message) + +    def test_has_authorization_for_realm(self): +        scm = detect_scm_system(self.svn_checkout_path) +        fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir") +        svn_config_dir_path = os.path.join(fake_home_dir, ".subversion") +        os.mkdir(svn_config_dir_path) +        fake_webkit_auth_file = os.path.join(svn_config_dir_path, "fake_webkit_auth_file") +        write_into_file_at_path(fake_webkit_auth_file, SVN.svn_server_realm) +        self.assertTrue(scm.has_authorization_for_realm(home_directory=fake_home_dir)) +        os.remove(fake_webkit_auth_file) +        os.rmdir(svn_config_dir_path) +        os.rmdir(fake_home_dir) + +    def test_not_have_authorization_for_realm(self): +        scm = detect_scm_system(self.svn_checkout_path) +        fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir") +        svn_config_dir_path = os.path.join(fake_home_dir, ".subversion") +        os.mkdir(svn_config_dir_path) +        self.assertFalse(scm.has_authorization_for_realm(home_directory=fake_home_dir)) +        os.rmdir(svn_config_dir_path) +        os.rmdir(fake_home_dir) + +    def test_reverse_diff(self): +        self._shared_test_reverse_diff() + +    def test_diff_for_revision(self): +        self._shared_test_diff_for_revision() + +    def test_svn_apply_git_patch(self): +        self._shared_test_svn_apply_git_patch() + +    def test_changed_files(self): +        self._shared_test_changed_files() + +    def test_changed_files_for_revision(self): +        self._shared_test_changed_files_for_revision() + +    def test_added_files(self): +        self._shared_test_added_files() + +    def test_contents_at_revision(self): +        self._shared_test_contents_at_revision() + +    def test_revisions_changing_file(self): +        self._shared_test_revisions_changing_file() + +    def test_committer_email_for_revision(self): +        self._shared_test_committer_email_for_revision() + +    def test_add_recursively(self): +        self._shared_test_add_recursively() + +    def test_delete(self): +        os.chdir(self.svn_checkout_path) +        self.scm.delete("test_file") +        self.assertTrue("test_file" in self.scm.deleted_files()) + +    def test_propset_propget(self): +        filepath = os.path.join(self.svn_checkout_path, "test_file") +        expected_mime_type = "x-application/foo-bar" +        self.scm.propset("svn:mime-type", expected_mime_type, filepath) +        self.assertEqual(expected_mime_type, self.scm.propget("svn:mime-type", filepath)) + +    def test_show_head(self): +        write_into_file_at_path("test_file", u"Hello!", "utf-8") +        SVNTestRepository._svn_commit("fourth commit") +        self.assertEqual("Hello!", self.scm.show_head('test_file')) + +    def test_show_head_binary(self): +        data = "\244" +        write_into_file_at_path("binary_file", data, encoding=None) +        self.scm.add("binary_file") +        self.scm.commit_with_message("a test commit") +        self.assertEqual(data, self.scm.show_head('binary_file')) + +    def do_test_diff_for_file(self): +        write_into_file_at_path('test_file', 'some content') +        self.scm.commit_with_message("a test commit") +        diff = self.scm.diff_for_file('test_file') +        self.assertEqual(diff, "") + +        write_into_file_at_path("test_file", "changed content") +        diff = self.scm.diff_for_file('test_file') +        self.assertTrue("-some content" in diff) +        self.assertTrue("+changed content" in diff) + +    def clean_bogus_dir(self): +        self.bogus_dir = self.scm._bogus_dir_name() +        if os.path.exists(self.bogus_dir): +            shutil.rmtree(self.bogus_dir) + +    def test_diff_for_file_with_existing_bogus_dir(self): +        self.clean_bogus_dir() +        os.mkdir(self.bogus_dir) +        self.do_test_diff_for_file() +        self.assertTrue(os.path.exists(self.bogus_dir)) +        shutil.rmtree(self.bogus_dir) + +    def test_diff_for_file_with_missing_bogus_dir(self): +        self.clean_bogus_dir() +        self.do_test_diff_for_file() +        self.assertFalse(os.path.exists(self.bogus_dir)) + +    def test_svn_lock(self): +        svn_root_lock_path = ".svn/lock" +        write_into_file_at_path(svn_root_lock_path, "", "utf-8") +        # webkit-patch uses a Checkout object and runs update-webkit, just use svn update here. +        self.assertRaises(ScriptError, run_command, ['svn', 'update']) +        self.scm.clean_working_directory() +        self.assertFalse(os.path.exists(svn_root_lock_path)) +        run_command(['svn', 'update'])  # Should succeed and not raise. + + +class GitTest(SCMTest): + +    def setUp(self): +        """Sets up fresh git repository with one commit. Then setups a second git +        repo that tracks the first one.""" +        self.original_dir = os.getcwd() + +        self.untracking_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout2") +        run_command(['git', 'init', self.untracking_checkout_path]) + +        os.chdir(self.untracking_checkout_path) +        write_into_file_at_path('foo_file', 'foo') +        run_command(['git', 'add', 'foo_file']) +        run_command(['git', 'commit', '-am', 'dummy commit']) +        self.untracking_scm = detect_scm_system(self.untracking_checkout_path) + +        self.tracking_git_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout") +        run_command(['git', 'clone', '--quiet', self.untracking_checkout_path, self.tracking_git_checkout_path]) +        os.chdir(self.tracking_git_checkout_path) +        self.tracking_scm = detect_scm_system(self.tracking_git_checkout_path) + +    def tearDown(self): +        # Change back to a valid directory so that later calls to os.getcwd() do not fail. +        os.chdir(self.original_dir) +        run_command(['rm', '-rf', self.tracking_git_checkout_path]) +        run_command(['rm', '-rf', self.untracking_checkout_path]) + +    def test_remote_branch_ref(self): +        self.assertEqual(self.tracking_scm.remote_branch_ref(), 'refs/remotes/origin/master') + +        os.chdir(self.untracking_checkout_path) +        self.assertRaises(ScriptError, self.untracking_scm.remote_branch_ref) + +    def test_multiple_remotes(self): +        run_command(['git', 'config', '--add', 'svn-remote.svn.fetch', 'trunk:remote1']) +        run_command(['git', 'config', '--add', 'svn-remote.svn.fetch', 'trunk:remote2']) +        self.assertEqual(self.tracking_scm.remote_branch_ref(), 'remote1') + +class GitSVNTest(SCMTest): + +    def _setup_git_checkout(self): +        self.git_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout") +        # --quiet doesn't make git svn silent, so we use run_silent to redirect output +        run_silent(['git', 'svn', 'clone', '-T', 'trunk', self.svn_repo_url, self.git_checkout_path]) +        os.chdir(self.git_checkout_path) + +    def _tear_down_git_checkout(self): +        # Change back to a valid directory so that later calls to os.getcwd() do not fail. +        os.chdir(self.original_dir) +        run_command(['rm', '-rf', self.git_checkout_path]) + +    def setUp(self): +        self.original_dir = os.getcwd() + +        SVNTestRepository.setup(self) +        self._setup_git_checkout() +        self.scm = detect_scm_system(self.git_checkout_path) +        # For historical reasons, we test some checkout code here too. +        self.checkout = Checkout(self.scm) + +    def tearDown(self): +        SVNTestRepository.tear_down(self) +        self._tear_down_git_checkout() + +    def test_detection(self): +        scm = detect_scm_system(self.git_checkout_path) +        self.assertEqual(scm.display_name(), "git") +        self.assertEqual(scm.supports_local_commits(), True) + +    def test_read_git_config(self): +        key = 'test.git-config' +        value = 'git-config value' +        run_command(['git', 'config', key, value]) +        self.assertEqual(self.scm.read_git_config(key), value) + +    def test_local_commits(self): +        test_file = os.path.join(self.git_checkout_path, 'test_file') +        write_into_file_at_path(test_file, 'foo') +        run_command(['git', 'commit', '-a', '-m', 'local commit']) + +        self.assertEqual(len(self.scm.local_commits()), 1) + +    def test_discard_local_commits(self): +        test_file = os.path.join(self.git_checkout_path, 'test_file') +        write_into_file_at_path(test_file, 'foo') +        run_command(['git', 'commit', '-a', '-m', 'local commit']) + +        self.assertEqual(len(self.scm.local_commits()), 1) +        self.scm.discard_local_commits() +        self.assertEqual(len(self.scm.local_commits()), 0) + +    def test_delete_branch(self): +        new_branch = 'foo' + +        run_command(['git', 'checkout', '-b', new_branch]) +        self.assertEqual(run_command(['git', 'symbolic-ref', 'HEAD']).strip(), 'refs/heads/' + new_branch) + +        run_command(['git', 'checkout', '-b', 'bar']) +        self.scm.delete_branch(new_branch) + +        self.assertFalse(re.search(r'foo', run_command(['git', 'branch']))) + +    def test_remote_merge_base(self): +        # Diff to merge-base should include working-copy changes, +        # which the diff to svn_branch.. doesn't. +        test_file = os.path.join(self.git_checkout_path, 'test_file') +        write_into_file_at_path(test_file, 'foo') + +        diff_to_common_base = _git_diff(self.scm.remote_branch_ref() + '..') +        diff_to_merge_base = _git_diff(self.scm.remote_merge_base()) + +        self.assertFalse(re.search(r'foo', diff_to_common_base)) +        self.assertTrue(re.search(r'foo', diff_to_merge_base)) + +    def test_rebase_in_progress(self): +        svn_test_file = os.path.join(self.svn_checkout_path, 'test_file') +        write_into_file_at_path(svn_test_file, "svn_checkout") +        run_command(['svn', 'commit', '--message', 'commit to conflict with git commit'], cwd=self.svn_checkout_path) + +        git_test_file = os.path.join(self.git_checkout_path, 'test_file') +        write_into_file_at_path(git_test_file, "git_checkout") +        run_command(['git', 'commit', '-a', '-m', 'commit to be thrown away by rebase abort']) + +        # --quiet doesn't make git svn silent, so use run_silent to redirect output +        self.assertRaises(ScriptError, run_silent, ['git', 'svn', '--quiet', 'rebase']) # Will fail due to a conflict leaving us mid-rebase. + +        scm = detect_scm_system(self.git_checkout_path) +        self.assertTrue(scm.rebase_in_progress()) + +        # Make sure our cleanup works. +        scm.clean_working_directory() +        self.assertFalse(scm.rebase_in_progress()) + +        # Make sure cleanup doesn't throw when no rebase is in progress. +        scm.clean_working_directory() + +    def test_commitish_parsing(self): +        scm = detect_scm_system(self.git_checkout_path) +     +        # Multiple revisions are cherry-picked. +        self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD~2'])), 1) +        self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD', 'HEAD~2'])), 2) +     +        # ... is an invalid range specifier +        self.assertRaises(ScriptError, scm.commit_ids_from_commitish_arguments, ['trunk...HEAD']) + +    def test_commitish_order(self): +        scm = detect_scm_system(self.git_checkout_path) + +        commit_range = 'HEAD~3..HEAD' + +        actual_commits = scm.commit_ids_from_commitish_arguments([commit_range]) +        expected_commits = [] +        expected_commits += reversed(run_command(['git', 'rev-list', commit_range]).splitlines()) + +        self.assertEqual(actual_commits, expected_commits) + +    def test_apply_git_patch(self): +        scm = detect_scm_system(self.git_checkout_path) +        # We carefullly pick a diff which does not have a directory addition +        # as currently svn-apply will error out when trying to remove directories +        # in Git: https://bugs.webkit.org/show_bug.cgi?id=34871 +        patch = self._create_patch(_git_diff('HEAD..HEAD^')) +        self._setup_webkittools_scripts_symlink(scm) +        Checkout(scm).apply_patch(patch) + +    def test_apply_git_patch_force(self): +        scm = detect_scm_system(self.git_checkout_path) +        patch = self._create_patch(_git_diff('HEAD~2..HEAD')) +        self._setup_webkittools_scripts_symlink(scm) +        self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True) + +    def test_commit_text_parsing(self): +        write_into_file_at_path('test_file', 'more test content') +        commit_text = self.scm.commit_with_message("another test commit") +        self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '6') + +        self.scm.dryrun = True +        write_into_file_at_path('test_file', 'still more test content') +        commit_text = self.scm.commit_with_message("yet another test commit") +        self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '0') + +    def test_commit_with_message_working_copy_only(self): +        write_into_file_at_path('test_file_commit1', 'more test content') +        run_command(['git', 'add', 'test_file_commit1']) +        scm = detect_scm_system(self.git_checkout_path) +        commit_text = scm.commit_with_message("yet another test commit") + +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def _local_commit(self, filename, contents, message): +        write_into_file_at_path(filename, contents) +        run_command(['git', 'add', filename]) +        self.scm.commit_locally_with_message(message) + +    def _one_local_commit(self): +        self._local_commit('test_file_commit1', 'more test content', 'another test commit') + +    def _one_local_commit_plus_working_copy_changes(self): +        self._one_local_commit() +        write_into_file_at_path('test_file_commit2', 'still more test content') +        run_command(['git', 'add', 'test_file_commit2']) + +    def _two_local_commits(self): +        self._one_local_commit() +        self._local_commit('test_file_commit2', 'still more test content', 'yet another test commit') + +    def _three_local_commits(self): +        self._local_commit('test_file_commit0', 'more test content', 'another test commit') +        self._two_local_commits() + +    def test_revisions_changing_files_with_local_commit(self): +        self._one_local_commit() +        self.assertEquals(self.scm.revisions_changing_file('test_file_commit1'), []) + +    def test_commit_with_message(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "yet another test commit") +        commit_text = scm.commit_with_message("yet another test commit", force_squash=True) + +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_git_commit(self): +        self._two_local_commits() + +        scm = detect_scm_system(self.git_checkout_path) +        commit_text = scm.commit_with_message("another test commit", git_commit="HEAD^") +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) +        self.assertFalse(re.search(r'test_file_commit2', svn_log)) + +    def test_commit_with_message_git_commit_range(self): +        self._three_local_commits() + +        scm = detect_scm_system(self.git_checkout_path) +        commit_text = scm.commit_with_message("another test commit", git_commit="HEAD~2..HEAD") +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertFalse(re.search(r'test_file_commit0', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) + +    def test_changed_files_working_copy_only(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        commit_text = scm.commit_with_message("another test commit", git_commit="HEAD..") +        self.assertFalse(re.search(r'test_file_commit1', svn_log)) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) + +    def test_commit_with_message_only_local_commit(self): +        self._one_local_commit() +        scm = detect_scm_system(self.git_checkout_path) +        commit_text = scm.commit_with_message("another test commit") +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_multiple_local_commits_and_working_copy(self): +        self._two_local_commits() +        write_into_file_at_path('test_file_commit1', 'working copy change') +        scm = detect_scm_system(self.git_checkout_path) + +        self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "another test commit") +        commit_text = scm.commit_with_message("another test commit", force_squash=True) + +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_git_commit_and_working_copy(self): +        self._two_local_commits() +        write_into_file_at_path('test_file_commit1', 'working copy change') +        scm = detect_scm_system(self.git_checkout_path) +        self.assertRaises(ScriptError, scm.commit_with_message, "another test commit", git_commit="HEAD^") + +    def test_commit_with_message_multiple_local_commits_always_squash(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        scm._assert_can_squash = lambda working_directory_is_clean: True +        commit_text = scm.commit_with_message("yet another test commit") +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_multiple_local_commits(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "yet another test commit") +        commit_text = scm.commit_with_message("yet another test commit", force_squash=True) + +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_not_synced(self): +        run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        self.assertRaises(AmbiguousCommitError, scm.commit_with_message, "another test commit") +        commit_text = scm.commit_with_message("another test commit", force_squash=True) + +        self.assertEqual(scm.svn_revision_from_commit_text(commit_text), '6') + +        svn_log = run_command(['git', 'svn', 'log', '--limit=1', '--verbose']) +        self.assertFalse(re.search(r'test_file2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit2', svn_log)) +        self.assertTrue(re.search(r'test_file_commit1', svn_log)) + +    def test_commit_with_message_not_synced_with_conflict(self): +        run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) +        self._local_commit('test_file2', 'asdf', 'asdf commit') + +        scm = detect_scm_system(self.git_checkout_path) +        # There's a conflict between trunk and the test_file2 modification. +        self.assertRaises(ScriptError, scm.commit_with_message, "another test commit", force_squash=True) + +    def test_remote_branch_ref(self): +        self.assertEqual(self.scm.remote_branch_ref(), 'refs/remotes/trunk') + +    def test_reverse_diff(self): +        self._shared_test_reverse_diff() + +    def test_diff_for_revision(self): +        self._shared_test_diff_for_revision() + +    def test_svn_apply_git_patch(self): +        self._shared_test_svn_apply_git_patch() + +    def test_create_patch_local_plus_working_copy(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch() +        self.assertTrue(re.search(r'test_file_commit1', patch)) +        self.assertTrue(re.search(r'test_file_commit2', patch)) + +    def test_create_patch(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch() +        self.assertTrue(re.search(r'test_file_commit2', patch)) +        self.assertTrue(re.search(r'test_file_commit1', patch)) + +    def test_create_patch_with_changed_files(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch(changed_files=['test_file_commit2']) +        self.assertTrue(re.search(r'test_file_commit2', patch)) + +    def test_create_patch_with_rm_and_changed_files(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        os.remove('test_file_commit1') +        patch = scm.create_patch() +        patch_with_changed_files = scm.create_patch(changed_files=['test_file_commit1', 'test_file_commit2']) +        self.assertEquals(patch, patch_with_changed_files) + +    def test_create_patch_git_commit(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch(git_commit="HEAD^") +        self.assertTrue(re.search(r'test_file_commit1', patch)) +        self.assertFalse(re.search(r'test_file_commit2', patch)) + +    def test_create_patch_git_commit_range(self): +        self._three_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch(git_commit="HEAD~2..HEAD") +        self.assertFalse(re.search(r'test_file_commit0', patch)) +        self.assertTrue(re.search(r'test_file_commit2', patch)) +        self.assertTrue(re.search(r'test_file_commit1', patch)) + +    def test_create_patch_working_copy_only(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch(git_commit="HEAD..") +        self.assertFalse(re.search(r'test_file_commit1', patch)) +        self.assertTrue(re.search(r'test_file_commit2', patch)) + +    def test_create_patch_multiple_local_commits(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch() +        self.assertTrue(re.search(r'test_file_commit2', patch)) +        self.assertTrue(re.search(r'test_file_commit1', patch)) + +    def test_create_patch_not_synced(self): +        run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        patch = scm.create_patch() +        self.assertFalse(re.search(r'test_file2', patch)) +        self.assertTrue(re.search(r'test_file_commit2', patch)) +        self.assertTrue(re.search(r'test_file_commit1', patch)) + +    def test_create_binary_patch(self): +        # Create a git binary patch and check the contents. +        scm = detect_scm_system(self.git_checkout_path) +        test_file_name = 'binary_file' +        test_file_path = os.path.join(self.git_checkout_path, test_file_name) +        file_contents = ''.join(map(chr, range(256))) +        write_into_file_at_path(test_file_path, file_contents, encoding=None) +        run_command(['git', 'add', test_file_name]) +        patch = scm.create_patch() +        self.assertTrue(re.search(r'\nliteral 0\n', patch)) +        self.assertTrue(re.search(r'\nliteral 256\n', patch)) + +        # Check if we can apply the created patch. +        run_command(['git', 'rm', '-f', test_file_name]) +        self._setup_webkittools_scripts_symlink(scm) +        self.checkout.apply_patch(self._create_patch(patch)) +        self.assertEqual(file_contents, read_from_path(test_file_path, encoding=None)) + +        # Check if we can create a patch from a local commit. +        write_into_file_at_path(test_file_path, file_contents, encoding=None) +        run_command(['git', 'add', test_file_name]) +        run_command(['git', 'commit', '-m', 'binary diff']) +        patch_from_local_commit = scm.create_patch('HEAD') +        self.assertTrue(re.search(r'\nliteral 0\n', patch_from_local_commit)) +        self.assertTrue(re.search(r'\nliteral 256\n', patch_from_local_commit)) + +    def test_changed_files_local_plus_working_copy(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files() +        self.assertTrue('test_file_commit1' in files) +        self.assertTrue('test_file_commit2' in files) + +    def test_changed_files_git_commit(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files(git_commit="HEAD^") +        self.assertTrue('test_file_commit1' in files) +        self.assertFalse('test_file_commit2' in files) + +    def test_changed_files_git_commit_range(self): +        self._three_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files(git_commit="HEAD~2..HEAD") +        self.assertTrue('test_file_commit0' not in files) +        self.assertTrue('test_file_commit1' in files) +        self.assertTrue('test_file_commit2' in files) + +    def test_changed_files_working_copy_only(self): +        self._one_local_commit_plus_working_copy_changes() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files(git_commit="HEAD..") +        self.assertFalse('test_file_commit1' in files) +        self.assertTrue('test_file_commit2' in files) + +    def test_changed_files_multiple_local_commits(self): +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files() +        self.assertTrue('test_file_commit2' in files) +        self.assertTrue('test_file_commit1' in files) + +    def test_changed_files_not_synced(self): +        run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files() +        self.assertFalse('test_file2' in files) +        self.assertTrue('test_file_commit2' in files) +        self.assertTrue('test_file_commit1' in files) + +    def test_changed_files_not_synced(self): +        run_command(['git', 'checkout', '-b', 'my-branch', 'trunk~3']) +        self._two_local_commits() +        scm = detect_scm_system(self.git_checkout_path) +        files = scm.changed_files() +        self.assertFalse('test_file2' in files) +        self.assertTrue('test_file_commit2' in files) +        self.assertTrue('test_file_commit1' in files) + +    def test_changed_files(self): +        self._shared_test_changed_files() + +    def test_changed_files_for_revision(self): +        self._shared_test_changed_files_for_revision() + +    def test_contents_at_revision(self): +        self._shared_test_contents_at_revision() + +    def test_revisions_changing_file(self): +        self._shared_test_revisions_changing_file() + +    def test_added_files(self): +        self._shared_test_added_files() + +    def test_committer_email_for_revision(self): +        self._shared_test_committer_email_for_revision() + +    def test_add_recursively(self): +        self._shared_test_add_recursively() + +    def test_delete(self): +        self._two_local_commits() +        self.scm.delete('test_file_commit1') +        self.assertTrue("test_file_commit1" in self.scm.deleted_files()) + +    def test_to_object_name(self): +        relpath = 'test_file_commit1' +        fullpath = os.path.join(self.git_checkout_path, relpath) +        self._two_local_commits() +        self.assertEqual(relpath, self.scm.to_object_name(fullpath)) + +    def test_show_head(self): +        self._two_local_commits() +        self.assertEqual("more test content", self.scm.show_head('test_file_commit1')) + +    def test_show_head_binary(self): +        self._two_local_commits() +        data = "\244" +        write_into_file_at_path("binary_file", data, encoding=None) +        self.scm.add("binary_file") +        self.scm.commit_locally_with_message("a test commit") +        self.assertEqual(data, self.scm.show_head('binary_file')) + +    def test_diff_for_file(self): +        self._two_local_commits() +        write_into_file_at_path('test_file_commit1', "Updated", encoding=None) + +        diff = self.scm.diff_for_file('test_file_commit1') +        cached_diff = self.scm.diff_for_file('test_file_commit1') +        self.assertTrue("+Updated" in diff) +        self.assertTrue("-more test content" in diff) + +        self.scm.add('test_file_commit1') + +        cached_diff = self.scm.diff_for_file('test_file_commit1') +        self.assertTrue("+Updated" in cached_diff) +        self.assertTrue("-more test content" in cached_diff) + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/config/__init__.py b/Tools/Scripts/webkitpy/common/config/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/config/build.py b/Tools/Scripts/webkitpy/common/config/build.py new file mode 100644 index 0000000..2a432ce --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/build.py @@ -0,0 +1,136 @@ +# Copyright (C) 2010 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Functions relating to building WebKit""" + +import re + + +def _should_file_trigger_build(target_platform, file): +    # The directories and patterns lists below map directory names or +    # regexp patterns to the bot platforms for which they should trigger a +    # build. Mapping to the empty list means that no builds should be +    # triggered on any platforms. Earlier directories/patterns take +    # precendence over later ones. + +    # FIXME: The patterns below have only been verified to be correct on +    # Windows. We should implement this for other platforms and start using +    # it for their bots. Someone familiar with each platform will have to +    # figure out what the right set of directories/patterns is for that +    # platform. +    assert(target_platform == "win") + +    directories = [ +        # Directories that shouldn't trigger builds on any bots. +        ("PageLoadTests", []), +        ("WebCore/manual-tests", []), +        ("Examples", []), +        ("Websites", []), +        ("android", []), +        ("brew", []), +        ("efl", []), +        ("haiku", []), +        ("iphone", []), +        ("opengl", []), +        ("opentype", []), +        ("openvg", []), +        ("wx", []), +        ("wince", []), + +        # Directories that should trigger builds on only some bots. +        ("JavaScriptGlue", ["mac"]), +        ("LayoutTests/platform/mac", ["mac", "win"]), +        ("LayoutTests/platform/mac-snowleopard", ["mac-snowleopard", "win"]), +        ("WebCore/image-decoders", ["chromium"]), +        ("cairo", ["gtk", "wincairo"]), +        ("cf", ["chromium-mac", "mac", "qt", "win"]), +        ("chromium", ["chromium"]), +        ("cocoa", ["chromium-mac", "mac"]), +        ("curl", ["gtk", "wincairo"]), +        ("gobject", ["gtk"]), +        ("gpu", ["chromium", "mac"]), +        ("gstreamer", ["gtk"]), +        ("gtk", ["gtk"]), +        ("mac", ["chromium-mac", "mac"]), +        ("mac-leopard", ["mac-leopard"]), +        ("mac-snowleopard", ["mac-snowleopard"]), +        ("mac-wk2", ["mac-snowleopard", "win"]), +        ("objc", ["mac"]), +        ("qt", ["qt"]), +        ("skia", ["chromium"]), +        ("soup", ["gtk"]), +        ("v8", ["chromium"]), +        ("win", ["chromium-win", "win"]), +    ] +    patterns = [ +        # Patterns that shouldn't trigger builds on any bots. +        (r"(?:^|/)Makefile$", []), +        (r"/ARM", []), +        (r"/CMake.*", []), +        (r"/ChangeLog.*$", []), +        (r"/LICENSE[^/]+$", []), +        (r"ARM(?:v7)?\.(?:cpp|h)$", []), +        (r"MIPS\.(?:cpp|h)$", []), +        (r"WinCE\.(?:cpp|h|mm)$", []), +        (r"\.(?:bkl|mk)$", []), + +        # Patterns that should trigger builds on only some bots. +        (r"/GNUmakefile\.am$", ["gtk"]), +        (r"/\w+Chromium\w*\.(?:cpp|h|mm)$", ["chromium"]), +        (r"Mac\.(?:cpp|h|mm)$", ["mac"]), +        (r"\.exp$", ["mac"]), +        (r"\.gypi?", ["chromium"]), +        (r"\.order$", ["mac"]), +        (r"\.pr[io]$", ["qt"]), +        (r"\.xcconfig$", ["mac"]), +        (r"\.xcodeproj/", ["mac"]), +    ] + +    base_platform = target_platform.split("-")[0] + +    # See if the file is in one of the known directories. +    for directory, platforms in directories: +        if re.search(r"(?:^|/)%s/" % directory, file): +            return target_platform in platforms or base_platform in platforms + +    # See if the file matches a known pattern. +    for pattern, platforms in patterns: +        if re.search(pattern, file): +            return target_platform in platforms or base_platform in platforms + +    # See if the file is a platform-specific test result. +    match = re.match("LayoutTests/platform/(?P<platform>[^/]+)/", file) +    if match: +        # See if the file is a test result for this platform, our base +        # platform, or one of our sub-platforms. +        return match.group("platform") in (target_platform, base_platform) or match.group("platform").startswith("%s-" % target_platform) + +    # The file isn't one we know about specifically, so we should assume we +    # have to build. +    return True + + +def should_build(target_platform, changed_files): +    """Returns true if the changed files affect the given platform, and +    thus a build should be performed. target_platform should be one of the +    platforms used in the build.webkit.org master's config.json file.""" +    return any(_should_file_trigger_build(target_platform, file) for file in changed_files) diff --git a/Tools/Scripts/webkitpy/common/config/build_unittest.py b/Tools/Scripts/webkitpy/common/config/build_unittest.py new file mode 100644 index 0000000..d833464 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/build_unittest.py @@ -0,0 +1,64 @@ +# Copyright (C) 2010 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.config import build + + +class ShouldBuildTest(unittest.TestCase): +    _should_build_tests = [ +        (["Websites/bugs.webkit.org/foo", "WebCore/bar"], ["*"]), +        (["Websites/bugs.webkit.org/foo"], []), +        (["JavaScriptCore/JavaScriptCore.xcodeproj/foo"], ["mac-leopard", "mac-snowleopard"]), +        (["JavaScriptGlue/foo", "WebCore/bar"], ["*"]), +        (["JavaScriptGlue/foo"], ["mac-leopard", "mac-snowleopard"]), +        (["LayoutTests/foo"], ["*"]), +        (["LayoutTests/platform/chromium-linux/foo"], ["chromium-linux"]), +        (["LayoutTests/platform/chromium-win/fast/compact/001-expected.txt"], ["chromium-win"]), +        (["LayoutTests/platform/mac-leopard/foo"], ["mac-leopard"]), +        (["LayoutTests/platform/mac-snowleopard/foo"], ["mac-snowleopard", "win"]), +        (["LayoutTests/platform/mac-wk2/Skipped"], ["mac-snowleopard", "win"]), +        (["LayoutTests/platform/mac/foo"], ["mac-leopard", "mac-snowleopard", "win"]), +        (["LayoutTests/platform/win-xp/foo"], ["win"]), +        (["LayoutTests/platform/win-wk2/foo"], ["win"]), +        (["LayoutTests/platform/win/foo"], ["win"]), +        (["WebCore/mac/foo"], ["chromium-mac", "mac-leopard", "mac-snowleopard"]), +        (["WebCore/win/foo"], ["chromium-win", "win"]), +        (["WebCore/platform/graphics/gpu/foo"], ["mac-leopard", "mac-snowleopard"]), +        (["WebCore/platform/wx/wxcode/win/foo"], []), +        (["WebCore/rendering/RenderThemeMac.mm", "WebCore/rendering/RenderThemeMac.h"], ["mac-leopard", "mac-snowleopard"]), +        (["WebCore/rendering/RenderThemeChromiumLinux.h"], ["chromium-linux"]), +        (["WebCore/rendering/RenderThemeWinCE.h"], []), +    ] + +    def test_should_build(self): +        for files, platforms in self._should_build_tests: +            # FIXME: We should test more platforms here once +            # build._should_file_trigger_build is implemented for them. +            for platform in ["win"]: +                should_build = platform in platforms or "*" in platforms +                self.assertEqual(build.should_build(platform, files), should_build, "%s should%s have built but did%s (files: %s)" % (platform, "" if should_build else "n't", "n't" if should_build else "", str(files))) + + +if __name__ == "__main__": +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/config/committers.py b/Tools/Scripts/webkitpy/common/config/committers.py new file mode 100644 index 0000000..7c5bf8b --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/committers.py @@ -0,0 +1,335 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for committer and reviewer validation + + +class Committer: + +    def __init__(self, name, email_or_emails, irc_nickname=None): +        self.full_name = name +        if isinstance(email_or_emails, str): +            self.emails = [email_or_emails] +        else: +            self.emails = email_or_emails +        self.irc_nickname = irc_nickname +        self.can_review = False + +    def bugzilla_email(self): +        # FIXME: We're assuming the first email is a valid bugzilla email, +        # which might not be right. +        return self.emails[0] + +    def __str__(self): +        return '"%s" <%s>' % (self.full_name, self.emails[0]) + + +class Reviewer(Committer): + +    def __init__(self, name, email_or_emails, irc_nickname=None): +        Committer.__init__(self, name, email_or_emails, irc_nickname) +        self.can_review = True + + +# This is intended as a canonical, machine-readable list of all non-reviewer +# committers for WebKit.  If your name is missing here and you are a committer, +# please add it.  No review needed.  All reviewers are committers, so this list +# is only of committers who are not reviewers. + + +committers_unable_to_review = [ +    Committer("Aaron Boodman", "aa@chromium.org", "aboodman"), +    Committer("Abhishek Arya", "inferno@chromium.org", "inferno-sec"), +    Committer("Adam Langley", "agl@chromium.org", "agl"), +    Committer("Adrienne Walker", ["enne@google.com", "enne@chromium.org"], "enne"), +    Committer("Albert J. Wong", "ajwong@chromium.org"), +    Committer("Alejandro G. Castro", ["alex@igalia.com", "alex@webkit.org"]), +    Committer("Alexander Kellett", ["lypanov@mac.com", "a-lists001@lypanov.net", "lypanov@kde.org"], "lypanov"), +    Committer("Alexander Pavlov", "apavlov@chromium.org", "apavlov"), +    Committer("Andre Boule", "aboule@apple.com"), +    Committer("Andrei Popescu", "andreip@google.com", "andreip"), +    Committer("Andrew Wellington", ["andrew@webkit.org", "proton@wiretapped.net"], "proton"), +    Committer("Andrew Scherkus", "scherkus@chromium.org", "scherkus"), +    Committer("Andrey Kosyakov", "caseq@chromium.org", "caseq"), +    Committer("Andras Becsi", ["abecsi@webkit.org", "abecsi@inf.u-szeged.hu"], "bbandix"), +    Committer("Andy Estes", "aestes@apple.com", "estes"), +    Committer("Anthony Ricaud", "rik@webkit.org", "rik"), +    Committer("Anton Muhin", "antonm@chromium.org", "antonm"), +    Committer("Balazs Kelemen", "kbalazs@webkit.org", "kbalazs"), +    Committer("Ben Murdoch", "benm@google.com", "benm"), +    Committer("Benjamin C Meyer", ["ben@meyerhome.net", "ben@webkit.org"], "icefox"), +    Committer("Benjamin Otte", ["otte@gnome.org", "otte@webkit.org"], "otte"), +    Committer("Benjamin Poulain", ["benjamin.poulain@nokia.com", "ikipou@gmail.com"]), +    Committer("Brent Fulgham", "bfulgham@webkit.org", "bfulgham"), +    Committer("Brett Wilson", "brettw@chromium.org", "brettx"), +    Committer("Brian Weinstein", "bweinstein@apple.com", "bweinstein"), +    Committer("Cameron McCormack", "cam@webkit.org", "heycam"), +    Committer("Carol Szabo", "carol.szabo@nokia.com"), +    Committer("Chang Shu", "Chang.Shu@nokia.com"), +    Committer("Chris Evans", "cevans@google.com"), +    Committer("Chris Petersen", "cpetersen@apple.com", "cpetersen"), +    Committer("Chris Rogers", "crogers@google.com", "crogers"), +    Committer("Christian Dywan", ["christian@twotoasts.de", "christian@webkit.org"]), +    Committer("Collin Jackson", "collinj@webkit.org"), +    Committer("David Smith", ["catfish.man@gmail.com", "dsmith@webkit.org"], "catfishman"), +    Committer("Dean Jackson", "dino@apple.com", "dino"), +    Committer("Diego Gonzalez", ["diegohcg@webkit.org", "diego.gonzalez@openbossa.org"], "diegohcg"), +    Committer("Dirk Pranke", "dpranke@chromium.org"), +    Committer("Drew Wilson", "atwilson@chromium.org", "atwilson"), +    Committer("Eli Fidler", "eli@staikos.net", "QBin"), +    Committer("Enrica Casucci", "enrica@apple.com"), +    Committer("Erik Arvidsson", "arv@chromium.org", "arv"), +    Committer("Eric Roman", "eroman@chromium.org", "eroman"), +    Committer("Evan Martin", "evan@chromium.org", "evmar"), +    Committer("Evan Stade", "estade@chromium.org", "estade"), +    Committer("Fady Samuel", "fsamuel@chromium.org", "fsamuel"), +    Committer("Feng Qian", "feng@chromium.org"), +    Committer("Fumitoshi Ukai", "ukai@chromium.org", "ukai"), +    Committer("Gabor Loki", "loki@webkit.org", "loki04"), +    Committer("Girish Ramakrishnan", ["girish@forwardbias.in", "ramakrishnan.girish@gmail.com"]), +    Committer("Graham Dennis", ["Graham.Dennis@gmail.com", "gdennis@webkit.org"]), +    Committer("Greg Bolsinga", "bolsinga@apple.com"), +    Committer("Gyuyoung Kim", ["gyuyoung.kim@samsung.com", "gyuyoung@gmail.com", "gyuyoung@webkit.org"], "gyuyoung"), +    Committer("Hans Wennborg", "hans@chromium.org", "hwennborg"), +    Committer("Hayato Ito", "hayato@chromium.org", "hayato"), +    Committer("Hin-Chung Lam", ["hclam@google.com", "hclam@chromium.org"]), +    Committer("Ilya Tikhonovsky", "loislo@chromium.org", "loislo"), +    Committer("Jakob Petsovits", ["jpetsovits@rim.com", "jpetso@gmx.at"], "jpetso"), +    Committer("Jakub Wieczorek", "jwieczorek@webkit.org", "fawek"), +    Committer("James Hawkins", ["jhawkins@chromium.org", "jhawkins@google.com"], "jhawkins"), +    Committer("Jay Civelli", "jcivelli@chromium.org", "jcivelli"), +    Committer("Jens Alfke", ["snej@chromium.org", "jens@apple.com"]), +    Committer("Jer Noble", "jer.noble@apple.com", "jernoble"), +    Committer("Jeremy Moskovich", ["playmobil@google.com", "jeremy@chromium.org"], "jeremymos"), +    Committer("Jessie Berlin", ["jberlin@webkit.org", "jberlin@apple.com"]), +    Committer("Jesus Sanchez-Palencia", ["jesus@webkit.org", "jesus.palencia@openbossa.org"], "jeez_"), +    Committer("Jocelyn Turcotte", "jocelyn.turcotte@nokia.com", "jturcotte"), +    Committer("Jochen Eisinger", "jochen@chromium.org", "jochen__"), +    Committer("John Abd-El-Malek", "jam@chromium.org", "jam"), +    Committer("John Gregg", ["johnnyg@google.com", "johnnyg@chromium.org"], "johnnyg"), +    Committer("Johnny Ding", ["jnd@chromium.org", "johnnyding.webkit@gmail.com"], "johnnyding"), +    Committer("Joost de Valk", ["joost@webkit.org", "webkit-dev@joostdevalk.nl"], "Altha"), +    Committer("Julie Parent", ["jparent@google.com", "jparent@chromium.org"], "jparent"), +    Committer("Julien Chaffraix", ["jchaffraix@webkit.org", "julien.chaffraix@gmail.com"]), +    Committer("Jungshik Shin", "jshin@chromium.org"), +    Committer("Justin Schuh", "jschuh@chromium.org", "jschuh"), +    Committer("Keishi Hattori", "keishi@webkit.org", "keishi"), +    Committer("Kelly Norton", "knorton@google.com"), +    Committer("Kent Hansen", "kent.hansen@nokia.com", "khansen"), +    Committer("Kimmo Kinnunen", ["kimmo.t.kinnunen@nokia.com", "kimmok@iki.fi", "ktkinnun@webkit.org"], "kimmok"), +    Committer("Kinuko Yasuda", "kinuko@chromium.org", "kinuko"), +    Committer("Krzysztof Kowalczyk", "kkowalczyk@gmail.com"), +    Committer("Kwang Yul Seo", ["kwangyul.seo@gmail.com", "skyul@company100.net", "kseo@webkit.org"], "kwangseo"), +    Committer("Leandro Pereira", ["leandro@profusion.mobi", "leandro@webkit.org"], "acidx"), +    Committer("Levi Weintraub", "lweintraub@apple.com"), +    Committer("Lucas De Marchi", ["lucas.demarchi@profusion.mobi", "demarchi@webkit.org"], "demarchi"), +    Committer("Luiz Agostini", ["luiz@webkit.org", "luiz.agostini@openbossa.org"], "lca"), +    Committer("Mads Ager", "ager@chromium.org"), +    Committer("Marcus Voltis Bulach", "bulach@chromium.org"), +    Committer("Mario Sanchez Prada", ["msanchez@igalia.com", "mario@webkit.org"], "msanchez"), +    Committer("Matt Delaney", "mdelaney@apple.com"), +    Committer("Matt Lilek", ["webkit@mattlilek.com", "pewtermoose@webkit.org"]), +    Committer("Matt Perry", "mpcomplete@chromium.org"), +    Committer("Maxime Britto", ["maxime.britto@gmail.com", "britto@apple.com"]), +    Committer("Maxime Simon", ["simon.maxime@gmail.com", "maxime.simon@webkit.org"], "maxime.simon"), +    Committer("Michael Nordman", "michaeln@google.com", "michaeln"), +    Committer("Michael Saboff", "msaboff@apple.com"), +    Committer("Michelangelo De Simone", "michelangelo@webkit.org", "michelangelo"), +    Committer("Mihai Parparita", "mihaip@chromium.org", "mihaip"), +    Committer("Mike Belshe", ["mbelshe@chromium.org", "mike@belshe.com"]), +    Committer("Mike Fenton", ["mifenton@rim.com", "mike.fenton@torchmobile.com"], "mfenton"), +    Committer("Mike Thole", ["mthole@mikethole.com", "mthole@apple.com"]), +    Committer("Mikhail Naganov", "mnaganov@chromium.org"), +    Committer("MORITA Hajime", "morrita@google.com", "morrita"), +    Committer("Nico Weber", ["thakis@chromium.org", "thakis@google.com"], "thakis"), +    Committer("Noam Rosenthal", "noam.rosenthal@nokia.com", "noamr"), +    Committer("Pam Greene", "pam@chromium.org", "pamg"), +    Committer("Patrick Gansterer", ["paroga@paroga.com", "paroga@webkit.org"], "paroga"), +    Committer("Pavel Podivilov", "podivilov@chromium.org", "podivilov"), +    Committer("Peter Kasting", ["pkasting@google.com", "pkasting@chromium.org"], "pkasting"), +    Committer("Philippe Normand", ["pnormand@igalia.com", "philn@webkit.org"], "philn-tp"), +    Committer("Pierre d'Herbemont", ["pdherbemont@free.fr", "pdherbemont@apple.com"], "pdherbemont"), +    Committer("Pierre-Olivier Latour", "pol@apple.com", "pol"), +    Committer("Renata Hodovan", "reni@webkit.org", "reni"), +    Committer("Robert Hogan", ["robert@webkit.org", "robert@roberthogan.net", "lists@roberthogan.net"], "mwenge"), +    Committer("Roland Steiner", "rolandsteiner@chromium.org"), +    Committer("Satish Sampath", "satish@chromium.org"), +    Committer("Scott Violet", "sky@chromium.org", "sky"), +    Committer("Sergio Villar Senin", ["svillar@igalia.com", "sergio@webkit.org"], "svillar"), +    Committer("Stephen White", "senorblanco@chromium.org", "senorblanco"), +    Committer("Tony Gentilcore", "tonyg@chromium.org", "tonyg-cr"), +    Committer("Trey Matteson", "trey@usa.net", "trey"), +    Committer("Tristan O'Tierney", ["tristan@otierney.net", "tristan@apple.com"]), +    Committer("Vangelis Kokkevis", "vangelis@chromium.org", "vangelis"), +    Committer("Victor Wang", "victorw@chromium.org", "victorw"), +    Committer("Vitaly Repeshko", "vitalyr@chromium.org"), +    Committer("William Siegrist", "wsiegrist@apple.com", "wms"), +    Committer("Xiaomei Ji", "xji@chromium.org", "xji"), +    Committer("Yael Aharon", "yael.aharon@nokia.com"), +    Committer("Yaar Schnitman", ["yaar@chromium.org", "yaar@google.com"]), +    Committer("Yong Li", ["yong.li.webkit@gmail.com", "yong.li@torchmobile.com"], "yong"), +    Committer("Yongjun Zhang", "yongjun.zhang@nokia.com"), +    Committer("Yuta Kitamura", "yutak@chromium.org", "yutak"), +    Committer("Yuzo Fujishima", "yuzo@google.com", "yuzo"), +    Committer("Zhenyao Mo", "zmo@google.com", "zhenyao"), +    Committer("Zoltan Herczeg", "zherczeg@webkit.org", "zherczeg"), +    Committer("Zoltan Horvath", ["zoltan@webkit.org", "hzoltan@inf.u-szeged.hu", "horvath.zoltan.6@stud.u-szeged.hu"], "zoltan"), +] + + +# This is intended as a canonical, machine-readable list of all reviewers for +# WebKit.  If your name is missing here and you are a reviewer, please add it. +# No review needed. + + +reviewers_list = [ +    Reviewer("Ada Chan", "adachan@apple.com", "chanada"), +    Reviewer("Adam Barth", "abarth@webkit.org", "abarth"), +    Reviewer("Adam Roben", "aroben@apple.com", "aroben"), +    Reviewer("Adam Treat", ["treat@kde.org", "treat@webkit.org", "atreat@rim.com"], "manyoso"), +    Reviewer("Adele Peterson", "adele@apple.com", "adele"), +    Reviewer("Alexey Proskuryakov", ["ap@webkit.org", "ap@apple.com"], "ap"), +    Reviewer("Alice Liu", "alice.liu@apple.com", "aliu"), +    Reviewer("Alp Toker", ["alp@nuanti.com", "alp@atoker.com", "alp@webkit.org"], "alp"), +    Reviewer("Anders Carlsson", ["andersca@apple.com", "acarlsson@apple.com"], "andersca"), +    Reviewer("Andreas Kling", ["kling@webkit.org", "andreas.kling@nokia.com"], "kling"), +    Reviewer("Antonio Gomes", ["tonikitoo@webkit.org", "agomes@rim.com"], "tonikitoo"), +    Reviewer("Antti Koivisto", ["koivisto@iki.fi", "antti@apple.com", "antti.j.koivisto@nokia.com"], "anttik"), +    Reviewer("Ariya Hidayat", ["ariya.hidayat@gmail.com", "ariya@sencha.com", "ariya@webkit.org"], "ariya"), +    Reviewer("Beth Dakin", "bdakin@apple.com", "dethbakin"), +    Reviewer("Brady Eidson", "beidson@apple.com", "bradee-oh"), +    Reviewer("Cameron Zwarich", ["zwarich@apple.com", "cwzwarich@apple.com", "cwzwarich@webkit.org"]), +    Reviewer("Chris Blumenberg", "cblu@apple.com", "cblu"), +    Reviewer("Chris Marrin", "cmarrin@apple.com", "cmarrin"), +    Reviewer("Chris Fleizach", "cfleizach@apple.com", "cfleizach"), +    Reviewer("Chris Jerdonek", "cjerdonek@webkit.org", "cjerdonek"), +    Reviewer(u"Csaba Osztrogon\u00e1c", "ossy@webkit.org", "ossy"), +    Reviewer("Dan Bernstein", ["mitz@webkit.org", "mitz@apple.com"], "mitzpettel"), +    Reviewer("Daniel Bates", "dbates@webkit.org", "dydz"), +    Reviewer("Darin Adler", "darin@apple.com", "darin"), +    Reviewer("Darin Fisher", ["fishd@chromium.org", "darin@chromium.org"], "fishd"), +    Reviewer("David Harrison", "harrison@apple.com", "harrison"), +    Reviewer("David Hyatt", "hyatt@apple.com", "hyatt"), +    Reviewer("David Kilzer", ["ddkilzer@webkit.org", "ddkilzer@apple.com"], "ddkilzer"), +    Reviewer("David Levin", "levin@chromium.org", "dave_levin"), +    Reviewer("Dimitri Glazkov", "dglazkov@chromium.org", "dglazkov"), +    Reviewer("Dirk Schulze", "krit@webkit.org", "krit"), +    Reviewer("Dmitry Titov", "dimich@chromium.org", "dimich"), +    Reviewer("Don Melton", "gramps@apple.com", "gramps"), +    Reviewer("Dumitru Daniliuc", "dumi@chromium.org", "dumi"), +    Reviewer("Eric Carlson", "eric.carlson@apple.com"), +    Reviewer("Eric Seidel", "eric@webkit.org", "eseidel"), +    Reviewer("Gavin Barraclough", "barraclough@apple.com", "gbarra"), +    Reviewer("Geoffrey Garen", "ggaren@apple.com", "ggaren"), +    Reviewer("George Staikos", ["staikos@kde.org", "staikos@webkit.org"]), +    Reviewer("Gustavo Noronha Silva", ["gns@gnome.org", "kov@webkit.org", "gustavo.noronha@collabora.co.uk"], "kov"), +    Reviewer("Holger Freyther", ["zecke@selfish.org", "zecke@webkit.org"], "zecke"), +    Reviewer("James Robinson", ["jamesr@chromium.org", "jamesr@google.com"], "jamesr"), +    Reviewer("Jan Alonzo", ["jmalonzo@gmail.com", "jmalonzo@webkit.org"], "janm"), +    Reviewer("Jeremy Orlow", "jorlow@chromium.org", "jorlow"), +    Reviewer("Jian Li", "jianli@chromium.org", "jianli"), +    Reviewer("John Sullivan", "sullivan@apple.com", "sullivan"), +    Reviewer("Jon Honeycutt", "jhoneycutt@apple.com", "jhoneycutt"), +    Reviewer("Joseph Pecoraro", ["joepeck@webkit.org", "pecoraro@apple.com"], "JoePeck"), +    Reviewer("Justin Garcia", "justin.garcia@apple.com", "justing"), +    Reviewer("Ken Kocienda", "kocienda@apple.com"), +    Reviewer("Kenneth Rohde Christiansen", ["kenneth@webkit.org", "kenneth.christiansen@openbossa.org", "kenneth.christiansen@gmail.com"], "kenne"), +    Reviewer("Kenneth Russell", "kbr@google.com", "kbr_google"), +    Reviewer("Kent Tamura", "tkent@chromium.org", "tkent"), +    Reviewer("Kevin Decker", "kdecker@apple.com", "superkevin"), +    Reviewer("Kevin McCullough", "kmccullough@apple.com", "maculloch"), +    Reviewer("Kevin Ollivier", ["kevino@theolliviers.com", "kevino@webkit.org"], "kollivier"), +    Reviewer("Lars Knoll", ["lars@trolltech.com", "lars@kde.org", "lars.knoll@nokia.com"], "lars"), +    Reviewer("Laszlo Gombos", "laszlo.1.gombos@nokia.com", "lgombos"), +    Reviewer("Maciej Stachowiak", "mjs@apple.com", "othermaciej"), +    Reviewer("Mark Rowe", "mrowe@apple.com", "bdash"), +    Reviewer("Martin Robinson", ["mrobinson@webkit.org", "mrobinson@igalia.com", "martin.james.robinson@gmail.com"], "mrobinson"), +    Reviewer("Nate Chapin", "japhet@chromium.org", "japhet"), +    Reviewer("Nikolas Zimmermann", ["zimmermann@kde.org", "zimmermann@physik.rwth-aachen.de", "zimmermann@webkit.org"], "wildfox"), +    Reviewer("Ojan Vafai", "ojan@chromium.org", "ojan"), +    Reviewer("Oliver Hunt", "oliver@apple.com", "olliej"), +    Reviewer("Pavel Feldman", "pfeldman@chromium.org", "pfeldman"), +    Reviewer("Richard Williamson", "rjw@apple.com", "rjw"), +    Reviewer("Rob Buis", ["rwlbuis@gmail.com", "rwlbuis@webkit.org"], "rwlbuis"), +    Reviewer("Ryosuke Niwa", "rniwa@webkit.org", "rniwa"), +    Reviewer("Sam Weinig", ["sam@webkit.org", "weinig@apple.com"], "weinig"), +    Reviewer("Shinichiro Hamaji", "hamaji@chromium.org", "hamaji"), +    Reviewer("Simon Fraser", "simon.fraser@apple.com", "smfr"), +    Reviewer("Simon Hausmann", ["hausmann@webkit.org", "hausmann@kde.org", "simon.hausmann@nokia.com"], "tronical"), +    Reviewer("Stephanie Lewis", "slewis@apple.com", "sundiamonde"), +    Reviewer("Steve Block", "steveblock@google.com", "steveblock"), +    Reviewer("Steve Falkenburg", "sfalken@apple.com", "sfalken"), +    Reviewer("Tim Omernick", "timo@apple.com"), +    Reviewer("Timothy Hatcher", ["timothy@apple.com", "timothy@hatcher.name"], "xenon"), +    Reviewer("Tony Chang", "tony@chromium.org", "tony^work"), +    Reviewer(u"Tor Arne Vestb\u00f8", ["vestbo@webkit.org", "tor.arne.vestbo@nokia.com"], "torarne"), +    Reviewer("Vicki Murley", "vicki@apple.com"), +    Reviewer("Xan Lopez", ["xan.lopez@gmail.com", "xan@gnome.org", "xan@webkit.org"], "xan"), +    Reviewer("Yury Semikhatsky", "yurys@chromium.org", "yurys"), +    Reviewer("Zack Rusin", "zack@kde.org", "zackr"), +] + + +class CommitterList: + +    # Committers and reviewers are passed in to allow easy testing + +    def __init__(self, +                 committers=committers_unable_to_review, +                 reviewers=reviewers_list): +        self._committers = committers + reviewers +        self._reviewers = reviewers +        self._committers_by_email = {} + +    def committers(self): +        return self._committers + +    def reviewers(self): +        return self._reviewers + +    def _email_to_committer_map(self): +        if not len(self._committers_by_email): +            for committer in self._committers: +                for email in committer.emails: +                    self._committers_by_email[email] = committer +        return self._committers_by_email + +    def committer_by_name(self, name): +        # This could be made into a hash lookup if callers need it to be fast. +        for committer in self.committers(): +            if committer.full_name == name: +                return committer + +    def committer_by_email(self, email): +        return self._email_to_committer_map().get(email) + +    def reviewer_by_email(self, email): +        committer = self.committer_by_email(email) +        if committer and not committer.can_review: +            return None +        return committer diff --git a/Tools/Scripts/webkitpy/common/config/committers_unittest.py b/Tools/Scripts/webkitpy/common/config/committers_unittest.py new file mode 100644 index 0000000..068c0ee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/committers_unittest.py @@ -0,0 +1,72 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest +from webkitpy.common.config.committers import CommitterList, Committer, Reviewer + +class CommittersTest(unittest.TestCase): + +    def test_committer_lookup(self): +        committer = Committer('Test One', 'one@test.com', 'one') +        reviewer = Reviewer('Test Two', ['two@test.com', 'two@rad.com', 'so_two@gmail.com']) +        committer_list = CommitterList(committers=[committer], reviewers=[reviewer]) + +        # Test valid committer and reviewer lookup +        self.assertEqual(committer_list.committer_by_email('one@test.com'), committer) +        self.assertEqual(committer_list.reviewer_by_email('two@test.com'), reviewer) +        self.assertEqual(committer_list.committer_by_email('two@test.com'), reviewer) +        self.assertEqual(committer_list.committer_by_email('two@rad.com'), reviewer) +        self.assertEqual(committer_list.reviewer_by_email('so_two@gmail.com'), reviewer) + +        # Test valid committer and reviewer lookup +        self.assertEqual(committer_list.committer_by_name("Test One"), committer) +        self.assertEqual(committer_list.committer_by_name("Test Two"), reviewer) +        self.assertEqual(committer_list.committer_by_name("Test Three"), None) + +        # Test that the first email is assumed to be the Bugzilla email address (for now) +        self.assertEqual(committer_list.committer_by_email('two@rad.com').bugzilla_email(), 'two@test.com') + +        # Test that a known committer is not returned during reviewer lookup +        self.assertEqual(committer_list.reviewer_by_email('one@test.com'), None) + +        # Test that unknown email address fail both committer and reviewer lookup +        self.assertEqual(committer_list.committer_by_email('bar@bar.com'), None) +        self.assertEqual(committer_list.reviewer_by_email('bar@bar.com'), None) + +        # Test that emails returns a list. +        self.assertEqual(committer.emails, ['one@test.com']) + +        self.assertEqual(committer.irc_nickname, 'one') + +        # Test that committers returns committers and reviewers and reviewers() just reviewers. +        self.assertEqual(committer_list.committers(), [committer, reviewer]) +        self.assertEqual(committer_list.reviewers(), [reviewer]) + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/config/committervalidator.py b/Tools/Scripts/webkitpy/common/config/committervalidator.py new file mode 100644 index 0000000..9b1bbea --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/committervalidator.py @@ -0,0 +1,114 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# Copyright (c) 2010 Research In Motion Limited. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os + +from webkitpy.common.system.ospath import relpath +from webkitpy.common.config import committers, urls + + +class CommitterValidator(object): + +    def __init__(self, bugzilla): +        self._bugzilla = bugzilla + +    def _checkout_root(self): +        # FIXME: This is a hack, we would have this from scm.checkout_root +        # if we had any way to get to an scm object here. +        components = __file__.split(os.sep) +        tools_index = components.index("Tools") +        return os.sep.join(components[:tools_index]) + +    def _committers_py_path(self): +        # extension can sometimes be .pyc, we always want .py +        (path, extension) = os.path.splitext(committers.__file__) +        # FIXME: When we're allowed to use python 2.6 we can use the real +        # os.path.relpath +        path = relpath(path, self._checkout_root()) +        return ".".join([path, "py"]) + +    def _flag_permission_rejection_message(self, setter_email, flag_name): +        # This could be queried from the status_server. +        queue_administrator = "eseidel@chromium.org" +        # This could be queried from the tool. +        queue_name = "commit-queue" +        committers_list = self._committers_py_path() +        message = "%s does not have %s permissions according to %s." % ( +                        setter_email, +                        flag_name, +                        urls.view_source_url(committers_list)) +        message += "\n\n- If you do not have %s rights please read %s for instructions on how to use bugzilla flags." % ( +                        flag_name, urls.contribution_guidelines) +        message += "\n\n- If you have %s rights please correct the error in %s by adding yourself to the file (no review needed).  " % ( +                        flag_name, committers_list) +        message += "The %s restarts itself every 2 hours.  After restart the %s will correctly respect your %s rights." % ( +                        queue_name, queue_name, flag_name) +        return message + +    def _validate_setter_email(self, patch, result_key, rejection_function): +        committer = getattr(patch, result_key)() +        # If the flag is set, and we don't recognize the setter, reject the +        # flag! +        setter_email = patch._attachment_dictionary.get("%s_email" % result_key) +        if setter_email and not committer: +            rejection_function(patch.id(), +                self._flag_permission_rejection_message(setter_email, +                                                        result_key)) +            return False +        return True + +    def _reject_patch_if_flags_are_invalid(self, patch): +        return (self._validate_setter_email( +                patch, "reviewer", self.reject_patch_from_review_queue) +            and self._validate_setter_email( +                patch, "committer", self.reject_patch_from_commit_queue)) + +    def patches_after_rejecting_invalid_commiters_and_reviewers(self, patches): +        return [patch for patch in patches if self._reject_patch_if_flags_are_invalid(patch)] + +    def reject_patch_from_commit_queue(self, +                                       attachment_id, +                                       additional_comment_text=None): +        comment_text = "Rejecting attachment %s from commit-queue." % attachment_id +        self._bugzilla.set_flag_on_attachment(attachment_id, +                                              "commit-queue", +                                              "-", +                                              comment_text, +                                              additional_comment_text) + +    def reject_patch_from_review_queue(self, +                                       attachment_id, +                                       additional_comment_text=None): +        comment_text = "Rejecting attachment %s from review queue." % attachment_id +        self._bugzilla.set_flag_on_attachment(attachment_id, +                                              'review', +                                              '-', +                                              comment_text, +                                              additional_comment_text) diff --git a/Tools/Scripts/webkitpy/common/config/committervalidator_unittest.py b/Tools/Scripts/webkitpy/common/config/committervalidator_unittest.py new file mode 100644 index 0000000..58fd3a5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/committervalidator_unittest.py @@ -0,0 +1,43 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from .committervalidator import CommitterValidator + + +class CommitterValidatorTest(unittest.TestCase): +    def test_flag_permission_rejection_message(self): +        validator = CommitterValidator(bugzilla=None) +        self.assertEqual(validator._committers_py_path(), "Tools/Scripts/webkitpy/common/config/committers.py") +        expected_messsage = """foo@foo.com does not have review permissions according to http://trac.webkit.org/browser/trunk/Tools/Scripts/webkitpy/common/config/committers.py. + +- If you do not have review rights please read http://webkit.org/coding/contributing.html for instructions on how to use bugzilla flags. + +- If you have review rights please correct the error in Tools/Scripts/webkitpy/common/config/committers.py by adding yourself to the file (no review needed).  The commit-queue restarts itself every 2 hours.  After restart the commit-queue will correctly respect your review rights.""" +        self.assertEqual(validator._flag_permission_rejection_message("foo@foo.com", "review"), expected_messsage) diff --git a/Tools/Scripts/webkitpy/common/config/irc.py b/Tools/Scripts/webkitpy/common/config/irc.py new file mode 100644 index 0000000..950c573 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/irc.py @@ -0,0 +1,31 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +server="irc.freenode.net" +port=6667 +channel="#webkit" diff --git a/Tools/Scripts/webkitpy/common/config/ports.py b/Tools/Scripts/webkitpy/common/config/ports.py new file mode 100644 index 0000000..163d5ef --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/ports.py @@ -0,0 +1,249 @@ +# Copyright (C) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for understanding the various ports + +import os +import platform + +from webkitpy.common.system.executive import Executive + + +class WebKitPort(object): + +    # We might need to pass scm into this function for scm.checkout_root +    @classmethod +    def script_path(cls, script_name): +        return os.path.join("Tools", "Scripts", script_name) + +    @staticmethod +    def port(port_name): +        ports = { +            "chromium": ChromiumPort, +            "chromium-xvfb": ChromiumXVFBPort, +            "gtk": GtkPort, +            "mac": MacPort, +            "win": WinPort, +            "qt": QtPort, +            "efl": EflPort, +        } +        default_port = { +            "Windows": WinPort, +            "Darwin": MacPort, +        } +        # Do we really need MacPort as the ultimate default? +        return ports.get(port_name, default_port.get(platform.system(), MacPort)) + +    @staticmethod +    def makeArgs(): +        args = '--makeargs="-j%s"' % Executive().cpu_count() +        if os.environ.has_key('MAKEFLAGS'): +            args = '--makeargs="%s"' % os.environ['MAKEFLAGS'] +        return args + +    @classmethod +    def name(cls): +        raise NotImplementedError("subclasses must implement") + +    @classmethod +    def flag(cls): +        raise NotImplementedError("subclasses must implement") + +    @classmethod +    def update_webkit_command(cls): +        return [cls.script_path("update-webkit")] + +    @classmethod +    def build_webkit_command(cls, build_style=None): +        command = [cls.script_path("build-webkit")] +        if build_style == "debug": +            command.append("--debug") +        if build_style == "release": +            command.append("--release") +        return command + +    @classmethod +    def run_javascriptcore_tests_command(cls): +        return [cls.script_path("run-javascriptcore-tests")] + +    @classmethod +    def run_webkit_tests_command(cls): +        return [cls.script_path("run-webkit-tests")] + +    @classmethod +    def run_python_unittests_command(cls): +        return [cls.script_path("test-webkitpy")] + +    @classmethod +    def run_perl_unittests_command(cls): +        return [cls.script_path("test-webkitperl")] + +    @classmethod +    def layout_tests_results_path(cls): +        return "/tmp/layout-test-results/results.html" + + +class MacPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Mac" + +    @classmethod +    def flag(cls): +        return "--port=mac" + +    @classmethod +    def _system_version(cls): +        version_string = platform.mac_ver()[0]  # e.g. "10.5.6" +        version_tuple = version_string.split('.') +        return map(int, version_tuple) + +    @classmethod +    def is_leopard(cls): +        return tuple(cls._system_version()[:2]) == (10, 5) + + +class WinPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Win" + +    @classmethod +    def flag(cls): +        # FIXME: This is lame.  We should autogenerate this from a codename or something. +        return "--port=win" + + +class GtkPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Gtk" + +    @classmethod +    def flag(cls): +        return "--port=gtk" + +    @classmethod +    def build_webkit_command(cls, build_style=None): +        command = WebKitPort.build_webkit_command(build_style=build_style) +        command.append("--gtk") +        command.append(WebKitPort.makeArgs()) +        return command + +    @classmethod +    def run_webkit_tests_command(cls): +        command = WebKitPort.run_webkit_tests_command() +        command.append("--gtk") +        return command + + +class QtPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Qt" + +    @classmethod +    def flag(cls): +        return "--port=qt" + +    @classmethod +    def build_webkit_command(cls, build_style=None): +        command = WebKitPort.build_webkit_command(build_style=build_style) +        command.append("--qt") +        command.append(WebKitPort.makeArgs()) +        return command + + +class EflPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Efl" + +    @classmethod +    def flag(cls): +        return "--port=efl" + +    @classmethod +    def build_webkit_command(cls, build_style=None): +        command = WebKitPort.build_webkit_command(build_style=build_style) +        command.append("--efl") +        command.append(WebKitPort.makeArgs()) +        return command + + +class ChromiumPort(WebKitPort): + +    @classmethod +    def name(cls): +        return "Chromium" + +    @classmethod +    def flag(cls): +        return "--port=chromium" + +    @classmethod +    def update_webkit_command(cls): +        command = WebKitPort.update_webkit_command() +        command.append("--chromium") +        return command + +    @classmethod +    def build_webkit_command(cls, build_style=None): +        command = WebKitPort.build_webkit_command(build_style=build_style) +        command.append("--chromium") +        command.append("--update-chromium") +        return command + +    @classmethod +    def run_webkit_tests_command(cls): +        return [ +            cls.script_path("new-run-webkit-tests"), +            "--chromium", +            "--no-pixel-tests", +        ] + +    @classmethod +    def run_javascriptcore_tests_command(cls): +        return None + + +class ChromiumXVFBPort(ChromiumPort): + +    @classmethod +    def flag(cls): +        return "--port=chromium-xvfb" + +    @classmethod +    def run_webkit_tests_command(cls): +        # FIXME: We should find a better way to do this. +        return ["xvfb-run"] + ChromiumPort.run_webkit_tests_command() diff --git a/Tools/Scripts/webkitpy/common/config/ports_unittest.py b/Tools/Scripts/webkitpy/common/config/ports_unittest.py new file mode 100644 index 0000000..ba255c0 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/ports_unittest.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# Copyright (c) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.config.ports import * + + +class WebKitPortTest(unittest.TestCase): +    def test_mac_port(self): +        self.assertEquals(MacPort.name(), "Mac") +        self.assertEquals(MacPort.flag(), "--port=mac") +        self.assertEquals(MacPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests")]) +        self.assertEquals(MacPort.build_webkit_command(), [WebKitPort.script_path("build-webkit")]) +        self.assertEquals(MacPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug"]) +        self.assertEquals(MacPort.build_webkit_command(build_style="release"), [WebKitPort.script_path("build-webkit"), "--release"]) + +        class TestIsLeopard(MacPort): +            @classmethod +            def _system_version(cls): +                return [10, 5] +        self.assertTrue(TestIsLeopard.is_leopard()) + +    def test_gtk_port(self): +        self.assertEquals(GtkPort.name(), "Gtk") +        self.assertEquals(GtkPort.flag(), "--port=gtk") +        self.assertEquals(GtkPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests"), "--gtk"]) +        self.assertEquals(GtkPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--gtk", WebKitPort.makeArgs()]) +        self.assertEquals(GtkPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--gtk", WebKitPort.makeArgs()]) + +    def test_qt_port(self): +        self.assertEquals(QtPort.name(), "Qt") +        self.assertEquals(QtPort.flag(), "--port=qt") +        self.assertEquals(QtPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests")]) +        self.assertEquals(QtPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--qt", WebKitPort.makeArgs()]) +        self.assertEquals(QtPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--qt", WebKitPort.makeArgs()]) + +    def test_chromium_port(self): +        self.assertEquals(ChromiumPort.name(), "Chromium") +        self.assertEquals(ChromiumPort.flag(), "--port=chromium") +        self.assertEquals(ChromiumPort.run_webkit_tests_command(), [WebKitPort.script_path("new-run-webkit-tests"), "--chromium", "--no-pixel-tests"]) +        self.assertEquals(ChromiumPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--chromium", "--update-chromium"]) +        self.assertEquals(ChromiumPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--chromium", "--update-chromium"]) +        self.assertEquals(ChromiumPort.update_webkit_command(), [WebKitPort.script_path("update-webkit"), "--chromium"]) + +    def test_chromium_xvfb_port(self): +        self.assertEquals(ChromiumXVFBPort.run_webkit_tests_command(), ["xvfb-run", "Tools/Scripts/new-run-webkit-tests", "--chromium", "--no-pixel-tests"]) + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/config/urls.py b/Tools/Scripts/webkitpy/common/config/urls.py new file mode 100644 index 0000000..dfa6d69 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/config/urls.py @@ -0,0 +1,38 @@ +# Copyright (c) 2010, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +def view_source_url(local_path): +    return "http://trac.webkit.org/browser/trunk/%s" % local_path + + +def view_revision_url(revision_number): +    return "http://trac.webkit.org/changeset/%s" % revision_number + + +contribution_guidelines = "http://webkit.org/coding/contributing.html" diff --git a/Tools/Scripts/webkitpy/common/memoized.py b/Tools/Scripts/webkitpy/common/memoized.py new file mode 100644 index 0000000..dc844a5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/memoized.py @@ -0,0 +1,55 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# Python does not (yet) seem to provide automatic memoization.  So we've +# written a small decorator to do so. + +import functools + + +class memoized(object): +    def __init__(self, function): +        self._function = function +        self._results_cache = {} + +    def __call__(self, *args): +        try: +            return self._results_cache[args] +        except KeyError: +            # If we didn't find the args in our cache, call and save the results. +            result = self._function(*args) +            self._results_cache[args] = result +            return result +        # FIXME: We may need to handle TypeError here in the case +        # that "args" is not a valid dictionary key. + +    # Use python "descriptor" protocol __get__ to appear +    # invisible during property access. +    def __get__(self, instance, owner): +        # Return a function partial with obj already bound as self. +        return functools.partial(self.__call__, instance) diff --git a/Tools/Scripts/webkitpy/common/memoized_unittest.py b/Tools/Scripts/webkitpy/common/memoized_unittest.py new file mode 100644 index 0000000..dd7c793 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/memoized_unittest.py @@ -0,0 +1,65 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.memoized import memoized + + +class _TestObject(object): +    def __init__(self): +        self.callCount = 0 + +    @memoized +    def memoized_add(self, argument): +        """testing docstring""" +        self.callCount += 1 +        if argument is None: +            return None  # Avoid the TypeError from None + 1 +        return argument + 1 + + +class MemoizedTest(unittest.TestCase): +    def test_caching(self): +        test = _TestObject() +        test.callCount = 0 +        self.assertEqual(test.memoized_add(1), 2) +        self.assertEqual(test.callCount, 1) +        self.assertEqual(test.memoized_add(1), 2) +        self.assertEqual(test.callCount, 1) + +        # Validate that callCount is working as expected. +        self.assertEqual(test.memoized_add(2), 3) +        self.assertEqual(test.callCount, 2) + +    def test_tearoff(self): +        test = _TestObject() +        # Make sure that get()/tear-offs work: +        tearoff = test.memoized_add +        self.assertEqual(tearoff(4), 5) +        self.assertEqual(test.callCount, 1) diff --git a/Tools/Scripts/webkitpy/common/net/__init__.py b/Tools/Scripts/webkitpy/common/net/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py b/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py new file mode 100644 index 0000000..cfaf3b1 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/__init__.py @@ -0,0 +1,8 @@ +# Required for Python to search this directory for module files + +# We only export public API here. +# FIXME: parse_bug_id should not be a free function. +from .bugzilla import Bugzilla, parse_bug_id +# Unclear if Bug and Attachment need to be public classes. +from .bug import Bug +from .attachment import Attachment diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py b/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py new file mode 100644 index 0000000..85761fe --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/attachment.py @@ -0,0 +1,114 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# Copyright (c) 2010 Research In Motion Limited. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from webkitpy.common.system.deprecated_logging import log + + +class Attachment(object): + +    rollout_preamble = "ROLLOUT of r" + +    def __init__(self, attachment_dictionary, bug): +        self._attachment_dictionary = attachment_dictionary +        self._bug = bug +        self._reviewer = None +        self._committer = None + +    def _bugzilla(self): +        return self._bug._bugzilla + +    def id(self): +        return int(self._attachment_dictionary.get("id")) + +    def attacher_is_committer(self): +        return self._bugzilla.committers.committer_by_email( +            patch.attacher_email()) + +    def attacher_email(self): +        return self._attachment_dictionary.get("attacher_email") + +    def bug(self): +        return self._bug + +    def bug_id(self): +        return int(self._attachment_dictionary.get("bug_id")) + +    def is_patch(self): +        return not not self._attachment_dictionary.get("is_patch") + +    def is_obsolete(self): +        return not not self._attachment_dictionary.get("is_obsolete") + +    def is_rollout(self): +        return self.name().startswith(self.rollout_preamble) + +    def name(self): +        return self._attachment_dictionary.get("name") + +    def attach_date(self): +        return self._attachment_dictionary.get("attach_date") + +    def review(self): +        return self._attachment_dictionary.get("review") + +    def commit_queue(self): +        return self._attachment_dictionary.get("commit-queue") + +    def url(self): +        # FIXME: This should just return +        # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py +        # depends on the current behavior. +        return self._attachment_dictionary.get("url") + +    def contents(self): +        # FIXME: We shouldn't be grabbing at _bugzilla. +        return self._bug._bugzilla.fetch_attachment_contents(self.id()) + +    def _validate_flag_value(self, flag): +        email = self._attachment_dictionary.get("%s_email" % flag) +        if not email: +            return None +        committer = getattr(self._bugzilla().committers, +                            "%s_by_email" % flag)(email) +        if committer: +            return committer +        log("Warning, attachment %s on bug %s has invalid %s (%s)" % ( +                 self._attachment_dictionary['id'], +                 self._attachment_dictionary['bug_id'], flag, email)) + +    def reviewer(self): +        if not self._reviewer: +            self._reviewer = self._validate_flag_value("reviewer") +        return self._reviewer + +    def committer(self): +        if not self._committer: +            self._committer = self._validate_flag_value("committer") +        return self._committer diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py new file mode 100644 index 0000000..af258eb --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bug.py @@ -0,0 +1,111 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# Copyright (c) 2010 Research In Motion Limited. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from .attachment import Attachment + + +class Bug(object): +    # FIXME: This class is kinda a hack for now.  It exists so we have one +    # place to hold bug logic, even if much of the code deals with +    # dictionaries still. + +    def __init__(self, bug_dictionary, bugzilla): +        self.bug_dictionary = bug_dictionary +        self._bugzilla = bugzilla + +    def id(self): +        return self.bug_dictionary["id"] + +    def title(self): +        return self.bug_dictionary["title"] + +    def reporter_email(self): +        return self.bug_dictionary["reporter_email"] + +    def assigned_to_email(self): +        return self.bug_dictionary["assigned_to_email"] + +    # FIXME: This information should be stored in some sort of webkit_config.py instead of here. +    unassigned_emails = frozenset([ +        "webkit-unassigned@lists.webkit.org", +        "webkit-qt-unassigned@trolltech.com", +    ]) + +    def is_unassigned(self): +        return self.assigned_to_email() in self.unassigned_emails + +    def status(self): +        return self.bug_dictionary["bug_status"] + +    # Bugzilla has many status states we don't really use in WebKit: +    # https://bugs.webkit.org/page.cgi?id=fields.html#status +    _open_states = ["UNCONFIRMED", "NEW", "ASSIGNED", "REOPENED"] +    _closed_states = ["RESOLVED", "VERIFIED", "CLOSED"] + +    def is_open(self): +        return self.status() in self._open_states + +    def is_closed(self): +        return not self.is_open() + +    def duplicate_of(self): +        return self.bug_dictionary.get('dup_id', None) + +    # Rarely do we actually want obsolete attachments +    def attachments(self, include_obsolete=False): +        attachments = self.bug_dictionary["attachments"] +        if not include_obsolete: +            attachments = filter(lambda attachment: +                                 not attachment["is_obsolete"], attachments) +        return [Attachment(attachment, self) for attachment in attachments] + +    def patches(self, include_obsolete=False): +        return [patch for patch in self.attachments(include_obsolete) +                                   if patch.is_patch()] + +    def unreviewed_patches(self): +        return [patch for patch in self.patches() if patch.review() == "?"] + +    def reviewed_patches(self, include_invalid=False): +        patches = [patch for patch in self.patches() if patch.review() == "+"] +        if include_invalid: +            return patches +        # Checking reviewer() ensures that it was both reviewed and has a valid +        # reviewer. +        return filter(lambda patch: patch.reviewer(), patches) + +    def commit_queued_patches(self, include_invalid=False): +        patches = [patch for patch in self.patches() +                                      if patch.commit_queue() == "+"] +        if include_invalid: +            return patches +        # Checking committer() ensures that it was both commit-queue+'d and has +        # a valid committer. +        return filter(lambda patch: patch.committer(), patches) diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py new file mode 100644 index 0000000..d43d64f --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bug_unittest.py @@ -0,0 +1,40 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from .bug import Bug + + +class BugTest(unittest.TestCase): +    def test_is_unassigned(self): +        for email in Bug.unassigned_emails: +            bug = Bug({"assigned_to_email": email}, bugzilla=None) +            self.assertTrue(bug.is_unassigned()) +        bug = Bug({"assigned_to_email": "test@test.com"}, bugzilla=None) +        self.assertFalse(bug.is_unassigned()) diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py new file mode 100644 index 0000000..d6210d5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla.py @@ -0,0 +1,761 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# Copyright (c) 2010 Research In Motion Limited. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for interacting with Bugzilla + +import os.path +import re +import StringIO +import urllib + +from datetime import datetime # used in timestamp() + +from .attachment import Attachment +from .bug import Bug + +from webkitpy.common.system.deprecated_logging import log +from webkitpy.common.config import committers +from webkitpy.common.net.credentials import Credentials +from webkitpy.common.system.user import User +from webkitpy.thirdparty.autoinstalled.mechanize import Browser +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, SoupStrainer + + +# FIXME: parse_bug_id should not be a free function. +def parse_bug_id(message): +    if not message: +        return None +    match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message) +    if match: +        return int(match.group('bug_id')) +    match = re.search( +        Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", +        message) +    if match: +        return int(match.group('bug_id')) +    return None + + +def timestamp(): +    return datetime.now().strftime("%Y%m%d%H%M%S") + + +# A container for all of the logic for making and parsing buzilla queries. +class BugzillaQueries(object): + +    def __init__(self, bugzilla): +        self._bugzilla = bugzilla + +    def _is_xml_bugs_form(self, form): +        # ClientForm.HTMLForm.find_control throws if the control is not found, +        # so we do a manual search instead: +        return "xml" in [control.id for control in form.controls] + +    # This is kinda a hack.  There is probably a better way to get this information from bugzilla. +    def _parse_result_count(self, results_page): +        result_count_text = BeautifulSoup(results_page).find(attrs={'class': 'bz_result_count'}).string +        result_count_parts = result_count_text.strip().split(" ") +        if result_count_parts[0] == "Zarro": +            return 0 +        if result_count_parts[0] == "One": +            return 1 +        return int(result_count_parts[0]) + +    # Note: _load_query, _fetch_bug and _fetch_bugs_from_advanced_query +    # are the only methods which access self._bugzilla. + +    def _load_query(self, query): +        self._bugzilla.authenticate() +        full_url = "%s%s" % (self._bugzilla.bug_server_url, query) +        return self._bugzilla.browser.open(full_url) + +    def _fetch_bugs_from_advanced_query(self, query): +        results_page = self._load_query(query) +        if not self._parse_result_count(results_page): +            return [] +        # Bugzilla results pages have an "XML" submit button at the bottom +        # which can be used to get an XML page containing all of the <bug> elements. +        # This is slighty lame that this assumes that _load_query used +        # self._bugzilla.browser and that it's in an acceptable state. +        self._bugzilla.browser.select_form(predicate=self._is_xml_bugs_form) +        bugs_xml = self._bugzilla.browser.submit() +        return self._bugzilla._parse_bugs_from_xml(bugs_xml) + +    def _fetch_bug(self, bug_id): +        return self._bugzilla.fetch_bug(bug_id) + +    def _fetch_bug_ids_advanced_query(self, query): +        soup = BeautifulSoup(self._load_query(query)) +        # The contents of the <a> inside the cells in the first column happen +        # to be the bug id. +        return [int(bug_link_cell.find("a").string) +                for bug_link_cell in soup('td', "first-child")] + +    def _parse_attachment_ids_request_query(self, page): +        digits = re.compile("\d+") +        attachment_href = re.compile("attachment.cgi\?id=\d+&action=review") +        attachment_links = SoupStrainer("a", href=attachment_href) +        return [int(digits.search(tag["href"]).group(0)) +                for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)] + +    def _fetch_attachment_ids_request_query(self, query): +        return self._parse_attachment_ids_request_query(self._load_query(query)) + +    def _parse_quips(self, page): +        soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES) +        quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li") +        return [unicode(quip_entry.string) for quip_entry in quips] + +    def fetch_quips(self): +        return self._parse_quips(self._load_query("/quips.cgi?action=show")) + +    # List of all r+'d bugs. +    def fetch_bug_ids_from_pending_commit_list(self): +        needs_commit_query_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=review%2B" +        return self._fetch_bug_ids_advanced_query(needs_commit_query_url) + +    def fetch_bugs_matching_quicksearch(self, search_string): +        # We may want to use a more explicit query than "quicksearch". +        # If quicksearch changes we should probably change to use +        # a normal buglist.cgi?query_format=advanced query. +        quicksearch_url = "buglist.cgi?quicksearch=%s" % urllib.quote(search_string) +        return self._fetch_bugs_from_advanced_query(quicksearch_url) + +    # Currently this returns all bugs across all components. +    # In the future we may wish to extend this API to construct more restricted searches. +    def fetch_bugs_matching_search(self, search_string, author_email=None): +        query = "buglist.cgi?query_format=advanced" +        if search_string: +            query += "&short_desc_type=allwordssubstr&short_desc=%s" % urllib.quote(search_string) +        if author_email: +            query += "&emailreporter1=1&emailtype1=substring&email1=%s" % urllib.quote(search_string) +        return self._fetch_bugs_from_advanced_query(query) + +    def fetch_patches_from_pending_commit_list(self): +        return sum([self._fetch_bug(bug_id).reviewed_patches() +            for bug_id in self.fetch_bug_ids_from_pending_commit_list()], []) + +    def fetch_bug_ids_from_commit_queue(self): +        commit_queue_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=commit-queue%2B&order=Last+Changed" +        return self._fetch_bug_ids_advanced_query(commit_queue_url) + +    def fetch_patches_from_commit_queue(self): +        # This function will only return patches which have valid committers +        # set.  It won't reject patches with invalid committers/reviewers. +        return sum([self._fetch_bug(bug_id).commit_queued_patches() +                    for bug_id in self.fetch_bug_ids_from_commit_queue()], []) + +    def fetch_bug_ids_from_review_queue(self): +        review_queue_url = "buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=review?" +        return self._fetch_bug_ids_advanced_query(review_queue_url) + +    # This method will make several requests to bugzilla. +    def fetch_patches_from_review_queue(self, limit=None): +        # [:None] returns the whole array. +        return sum([self._fetch_bug(bug_id).unreviewed_patches() +            for bug_id in self.fetch_bug_ids_from_review_queue()[:limit]], []) + +    # NOTE: This is the only client of _fetch_attachment_ids_request_query +    # This method only makes one request to bugzilla. +    def fetch_attachment_ids_from_review_queue(self): +        review_queue_url = "request.cgi?action=queue&type=review&group=type" +        return self._fetch_attachment_ids_request_query(review_queue_url) + + +class Bugzilla(object): + +    def __init__(self, dryrun=False, committers=committers.CommitterList()): +        self.dryrun = dryrun +        self.authenticated = False +        self.queries = BugzillaQueries(self) +        self.committers = committers +        self.cached_quips = [] + +        # FIXME: We should use some sort of Browser mock object when in dryrun +        # mode (to prevent any mistakes). +        self.browser = Browser() +        # Ignore bugs.webkit.org/robots.txt until we fix it to allow this +        # script. +        self.browser.set_handle_robots(False) + +    # FIXME: Much of this should go into some sort of config module: +    bug_server_host = "bugs.webkit.org" +    bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host) +    bug_server_url = "https://%s/" % bug_server_host + +    def quips(self): +        # We only fetch and parse the list of quips once per instantiation +        # so that we do not burden bugs.webkit.org. +        if not self.cached_quips and not self.dryrun: +            self.cached_quips = self.queries.fetch_quips() +        return self.cached_quips + +    def bug_url_for_bug_id(self, bug_id, xml=False): +        if not bug_id: +            return None +        content_type = "&ctype=xml" if xml else "" +        return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, bug_id, content_type) + +    def short_bug_url_for_bug_id(self, bug_id): +        if not bug_id: +            return None +        return "http://webkit.org/b/%s" % bug_id + +    def add_attachment_url(self, bug_id): +        return "%sattachment.cgi?action=enter&bugid=%s" % (self.bug_server_url, bug_id) + +    def attachment_url_for_id(self, attachment_id, action="view"): +        if not attachment_id: +            return None +        action_param = "" +        if action and action != "view": +            action_param = "&action=%s" % action +        return "%sattachment.cgi?id=%s%s" % (self.bug_server_url, +                                             attachment_id, +                                             action_param) + +    def _parse_attachment_flag(self, +                               element, +                               flag_name, +                               attachment, +                               result_key): +        flag = element.find('flag', attrs={'name': flag_name}) +        if flag: +            attachment[flag_name] = flag['status'] +            if flag['status'] == '+': +                attachment[result_key] = flag['setter'] +        # Sadly show_bug.cgi?ctype=xml does not expose the flag modification date. + +    def _string_contents(self, soup): +        # WebKit's bugzilla instance uses UTF-8. +        # BeautifulSoup always returns Unicode strings, however +        # the .string method returns a (unicode) NavigableString. +        # NavigableString can confuse other parts of the code, so we +        # convert from NavigableString to a real unicode() object using unicode(). +        return unicode(soup.string) + +    # Example: 2010-01-20 14:31 PST +    # FIXME: Some bugzilla dates seem to have seconds in them? +    # Python does not support timezones out of the box. +    # Assume that bugzilla always uses PST (which is true for bugs.webkit.org) +    _bugzilla_date_format = "%Y-%m-%d %H:%M" + +    @classmethod +    def _parse_date(cls, date_string): +        (date, time, time_zone) = date_string.split(" ") +        # Ignore the timezone because python doesn't understand timezones out of the box. +        date_string = "%s %s" % (date, time) +        return datetime.strptime(date_string, cls._bugzilla_date_format) + +    def _date_contents(self, soup): +        return self._parse_date(self._string_contents(soup)) + +    def _parse_attachment_element(self, element, bug_id): +        attachment = {} +        attachment['bug_id'] = bug_id +        attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1") +        attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1") +        attachment['id'] = int(element.find('attachid').string) +        # FIXME: No need to parse out the url here. +        attachment['url'] = self.attachment_url_for_id(attachment['id']) +        attachment["attach_date"] = self._date_contents(element.find("date")) +        attachment['name'] = self._string_contents(element.find('desc')) +        attachment['attacher_email'] = self._string_contents(element.find('attacher')) +        attachment['type'] = self._string_contents(element.find('type')) +        self._parse_attachment_flag( +                element, 'review', attachment, 'reviewer_email') +        self._parse_attachment_flag( +                element, 'commit-queue', attachment, 'committer_email') +        return attachment + +    def _parse_bugs_from_xml(self, page): +        soup = BeautifulSoup(page) +        # Without the unicode() call, BeautifulSoup occasionally complains of being +        # passed None for no apparent reason. +        return [Bug(self._parse_bug_dictionary_from_xml(unicode(bug_xml)), self) for bug_xml in soup('bug')] + +    def _parse_bug_dictionary_from_xml(self, page): +        soup = BeautifulSoup(page) +        bug = {} +        bug["id"] = int(soup.find("bug_id").string) +        bug["title"] = self._string_contents(soup.find("short_desc")) +        bug["bug_status"] = self._string_contents(soup.find("bug_status")) +        dup_id = soup.find("dup_id") +        if dup_id: +            bug["dup_id"] = self._string_contents(dup_id) +        bug["reporter_email"] = self._string_contents(soup.find("reporter")) +        bug["assigned_to_email"] = self._string_contents(soup.find("assigned_to")) +        bug["cc_emails"] = [self._string_contents(element) for element in soup.findAll('cc')] +        bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')] +        return bug + +    # Makes testing fetch_*_from_bug() possible until we have a better +    # BugzillaNetwork abstration. + +    def _fetch_bug_page(self, bug_id): +        bug_url = self.bug_url_for_bug_id(bug_id, xml=True) +        log("Fetching: %s" % bug_url) +        return self.browser.open(bug_url) + +    def fetch_bug_dictionary(self, bug_id): +        try: +            return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id)) +        except KeyboardInterrupt: +            raise +        except: +            self.authenticate() +            return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id)) + +    # FIXME: A BugzillaCache object should provide all these fetch_ methods. + +    def fetch_bug(self, bug_id): +        return Bug(self.fetch_bug_dictionary(bug_id), self) + +    def fetch_attachment_contents(self, attachment_id): +        attachment_url = self.attachment_url_for_id(attachment_id) +        # We need to authenticate to download patches from security bugs. +        self.authenticate() +        return self.browser.open(attachment_url).read() + +    def _parse_bug_id_from_attachment_page(self, page): +        # The "Up" relation happens to point to the bug. +        up_link = BeautifulSoup(page).find('link', rel='Up') +        if not up_link: +            # This attachment does not exist (or you don't have permissions to +            # view it). +            return None +        match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href']) +        return int(match.group('bug_id')) + +    def bug_id_for_attachment_id(self, attachment_id): +        self.authenticate() + +        attachment_url = self.attachment_url_for_id(attachment_id, 'edit') +        log("Fetching: %s" % attachment_url) +        page = self.browser.open(attachment_url) +        return self._parse_bug_id_from_attachment_page(page) + +    # FIXME: This should just return Attachment(id), which should be able to +    # lazily fetch needed data. + +    def fetch_attachment(self, attachment_id): +        # We could grab all the attachment details off of the attachment edit +        # page but we already have working code to do so off of the bugs page, +        # so re-use that. +        bug_id = self.bug_id_for_attachment_id(attachment_id) +        if not bug_id: +            return None +        attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True) +        for attachment in attachments: +            if attachment.id() == int(attachment_id): +                return attachment +        return None # This should never be hit. + +    def authenticate(self): +        if self.authenticated: +            return + +        if self.dryrun: +            log("Skipping log in for dry run...") +            self.authenticated = True +            return + +        credentials = Credentials(self.bug_server_host, git_prefix="bugzilla") + +        attempts = 0 +        while not self.authenticated: +            attempts += 1 +            username, password = credentials.read_credentials() + +            log("Logging in as %s..." % username) +            self.browser.open(self.bug_server_url + +                              "index.cgi?GoAheadAndLogIn=1") +            self.browser.select_form(name="login") +            self.browser['Bugzilla_login'] = username +            self.browser['Bugzilla_password'] = password +            response = self.browser.submit() + +            match = re.search("<title>(.+?)</title>", response.read()) +            # If the resulting page has a title, and it contains the word +            # "invalid" assume it's the login failure page. +            if match and re.search("Invalid", match.group(1), re.IGNORECASE): +                errorMessage = "Bugzilla login failed: %s" % match.group(1) +                # raise an exception only if this was the last attempt +                if attempts < 5: +                    log(errorMessage) +                else: +                    raise Exception(errorMessage) +            else: +                self.authenticated = True +                self.username = username + +    def _commit_queue_flag(self, mark_for_landing, mark_for_commit_queue): +        if mark_for_landing: +            return '+' +        elif mark_for_commit_queue: +            return '?' +        return 'X' + +    # FIXME: mark_for_commit_queue and mark_for_landing should be joined into a single commit_flag argument. +    def _fill_attachment_form(self, +                              description, +                              file_object, +                              mark_for_review=False, +                              mark_for_commit_queue=False, +                              mark_for_landing=False, +                              is_patch=False, +                              filename=None, +                              mimetype=None): +        self.browser['description'] = description +        if is_patch: +            self.browser['ispatch'] = ("1",) +        # FIXME: Should this use self._find_select_element_for_flag? +        self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',) +        self.browser['flag_type-3'] = (self._commit_queue_flag(mark_for_landing, mark_for_commit_queue),) + +        filename = filename or "%s.patch" % timestamp() +        mimetype = mimetype or "text/plain" +        self.browser.add_file(file_object, mimetype, filename, 'data') + +    def _file_object_for_upload(self, file_or_string): +        if hasattr(file_or_string, 'read'): +            return file_or_string +        # Only if file_or_string is not already encoded do we want to encode it. +        if isinstance(file_or_string, unicode): +            file_or_string = file_or_string.encode('utf-8') +        return StringIO.StringIO(file_or_string) + +    # timestamp argument is just for unittests. +    def _filename_for_upload(self, file_object, bug_id, extension="txt", timestamp=timestamp): +        if hasattr(file_object, "name"): +            return file_object.name +        return "bug-%s-%s.%s" % (bug_id, timestamp(), extension) + +    def add_attachment_to_bug(self, +                              bug_id, +                              file_or_string, +                              description, +                              filename=None, +                              comment_text=None): +        self.authenticate() +        log('Adding attachment "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id))) +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.add_attachment_url(bug_id)) +        self.browser.select_form(name="entryform") +        file_object = self._file_object_for_upload(file_or_string) +        filename = filename or self._filename_for_upload(file_object, bug_id) +        self._fill_attachment_form(description, file_object, filename=filename) +        if comment_text: +            log(comment_text) +            self.browser['comment'] = comment_text +        self.browser.submit() + +    # FIXME: The arguments to this function should be simplified and then +    # this should be merged into add_attachment_to_bug +    def add_patch_to_bug(self, +                         bug_id, +                         file_or_string, +                         description, +                         comment_text=None, +                         mark_for_review=False, +                         mark_for_commit_queue=False, +                         mark_for_landing=False): +        self.authenticate() +        log('Adding patch "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id))) + +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.add_attachment_url(bug_id)) +        self.browser.select_form(name="entryform") +        file_object = self._file_object_for_upload(file_or_string) +        filename = self._filename_for_upload(file_object, bug_id, extension="patch") +        self._fill_attachment_form(description, +                                   file_object, +                                   mark_for_review=mark_for_review, +                                   mark_for_commit_queue=mark_for_commit_queue, +                                   mark_for_landing=mark_for_landing, +                                   is_patch=True, +                                   filename=filename) +        if comment_text: +            log(comment_text) +            self.browser['comment'] = comment_text +        self.browser.submit() + +    # FIXME: There has to be a more concise way to write this method. +    def _check_create_bug_response(self, response_html): +        match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>", +                          response_html) +        if match: +            return match.group('bug_id') + +        match = re.search( +            '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">', +            response_html, +            re.DOTALL) +        error_message = "FAIL" +        if match: +            text_lines = BeautifulSoup( +                    match.group('error_message')).findAll(text=True) +            error_message = "\n" + '\n'.join( +                    ["  " + line.strip() +                     for line in text_lines if line.strip()]) +        raise Exception("Bug not created: %s" % error_message) + +    def create_bug(self, +                   bug_title, +                   bug_description, +                   component=None, +                   diff=None, +                   patch_description=None, +                   cc=None, +                   blocked=None, +                   assignee=None, +                   mark_for_review=False, +                   mark_for_commit_queue=False): +        self.authenticate() + +        log('Creating bug with title "%s"' % bug_title) +        if self.dryrun: +            log(bug_description) +            # FIXME: This will make some paths fail, as they assume this returns an id. +            return + +        self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit") +        self.browser.select_form(name="Create") +        component_items = self.browser.find_control('component').items +        component_names = map(lambda item: item.name, component_items) +        if not component: +            component = "New Bugs" +        if component not in component_names: +            component = User.prompt_with_list("Please pick a component:", component_names) +        self.browser["component"] = [component] +        if cc: +            self.browser["cc"] = cc +        if blocked: +            self.browser["blocked"] = unicode(blocked) +        if not assignee: +            assignee = self.username +        if assignee and not self.browser.find_control("assigned_to").disabled: +            self.browser["assigned_to"] = assignee +        self.browser["short_desc"] = bug_title +        self.browser["comment"] = bug_description + +        if diff: +            # _fill_attachment_form expects a file-like object +            # Patch files are already binary, so no encoding needed. +            assert(isinstance(diff, str)) +            patch_file_object = StringIO.StringIO(diff) +            self._fill_attachment_form( +                    patch_description, +                    patch_file_object, +                    mark_for_review=mark_for_review, +                    mark_for_commit_queue=mark_for_commit_queue, +                    is_patch=True) + +        response = self.browser.submit() + +        bug_id = self._check_create_bug_response(response.read()) +        log("Bug %s created." % bug_id) +        log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id)) +        return bug_id + +    def _find_select_element_for_flag(self, flag_name): +        # FIXME: This will break if we ever re-order attachment flags +        if flag_name == "review": +            return self.browser.find_control(type='select', nr=0) +        elif flag_name == "commit-queue": +            return self.browser.find_control(type='select', nr=1) +        raise Exception("Don't know how to find flag named \"%s\"" % flag_name) + +    def clear_attachment_flags(self, +                               attachment_id, +                               additional_comment_text=None): +        self.authenticate() + +        comment_text = "Clearing flags on attachment: %s" % attachment_id +        if additional_comment_text: +            comment_text += "\n\n%s" % additional_comment_text +        log(comment_text) + +        if self.dryrun: +            return + +        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) +        self.browser.select_form(nr=1) +        self.browser.set_value(comment_text, name='comment', nr=0) +        self._find_select_element_for_flag('review').value = ("X",) +        self._find_select_element_for_flag('commit-queue').value = ("X",) +        self.browser.submit() + +    def set_flag_on_attachment(self, +                               attachment_id, +                               flag_name, +                               flag_value, +                               comment_text=None, +                               additional_comment_text=None): +        # FIXME: We need a way to test this function on a live bugzilla +        # instance. + +        self.authenticate() + +        if additional_comment_text: +            comment_text += "\n\n%s" % additional_comment_text +        log(comment_text) + +        if self.dryrun: +            return + +        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) +        self.browser.select_form(nr=1) + +        if comment_text: +            self.browser.set_value(comment_text, name='comment', nr=0) + +        self._find_select_element_for_flag(flag_name).value = (flag_value,) +        self.browser.submit() + +    # FIXME: All of these bug editing methods have a ridiculous amount of +    # copy/paste code. + +    def obsolete_attachment(self, attachment_id, comment_text=None): +        self.authenticate() + +        log("Obsoleting attachment: %s" % attachment_id) +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit')) +        self.browser.select_form(nr=1) +        self.browser.find_control('isobsolete').items[0].selected = True +        # Also clear any review flag (to remove it from review/commit queues) +        self._find_select_element_for_flag('review').value = ("X",) +        self._find_select_element_for_flag('commit-queue').value = ("X",) +        if comment_text: +            log(comment_text) +            # Bugzilla has two textareas named 'comment', one is somehow +            # hidden.  We want the first. +            self.browser.set_value(comment_text, name='comment', nr=0) +        self.browser.submit() + +    def add_cc_to_bug(self, bug_id, email_address_list): +        self.authenticate() + +        log("Adding %s to the CC list for bug %s" % (email_address_list, +                                                     bug_id)) +        if self.dryrun: +            return + +        self.browser.open(self.bug_url_for_bug_id(bug_id)) +        self.browser.select_form(name="changeform") +        self.browser["newcc"] = ", ".join(email_address_list) +        self.browser.submit() + +    def post_comment_to_bug(self, bug_id, comment_text, cc=None): +        self.authenticate() + +        log("Adding comment to bug %s" % bug_id) +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.bug_url_for_bug_id(bug_id)) +        self.browser.select_form(name="changeform") +        self.browser["comment"] = comment_text +        if cc: +            self.browser["newcc"] = ", ".join(cc) +        self.browser.submit() + +    def close_bug_as_fixed(self, bug_id, comment_text=None): +        self.authenticate() + +        log("Closing bug %s as fixed" % bug_id) +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.bug_url_for_bug_id(bug_id)) +        self.browser.select_form(name="changeform") +        if comment_text: +            self.browser['comment'] = comment_text +        self.browser['bug_status'] = ['RESOLVED'] +        self.browser['resolution'] = ['FIXED'] +        self.browser.submit() + +    def reassign_bug(self, bug_id, assignee, comment_text=None): +        self.authenticate() + +        log("Assigning bug %s to %s" % (bug_id, assignee)) +        if self.dryrun: +            log(comment_text) +            return + +        self.browser.open(self.bug_url_for_bug_id(bug_id)) +        self.browser.select_form(name="changeform") +        if comment_text: +            log(comment_text) +            self.browser["comment"] = comment_text +        self.browser["assigned_to"] = assignee +        self.browser.submit() + +    def reopen_bug(self, bug_id, comment_text): +        self.authenticate() + +        log("Re-opening bug %s" % bug_id) +        # Bugzilla requires a comment when re-opening a bug, so we know it will +        # never be None. +        log(comment_text) +        if self.dryrun: +            return + +        self.browser.open(self.bug_url_for_bug_id(bug_id)) +        self.browser.select_form(name="changeform") +        bug_status = self.browser.find_control("bug_status", type="select") +        # This is a hack around the fact that ClientForm.ListControl seems to +        # have no simpler way to ask if a control has an item named "REOPENED" +        # without using exceptions for control flow. +        possible_bug_statuses = map(lambda item: item.name, bug_status.items) +        if "REOPENED" in possible_bug_statuses: +            bug_status.value = ["REOPENED"] +        # If the bug was never confirmed it will not have a "REOPENED" +        # state, but only an "UNCONFIRMED" state. +        elif "UNCONFIRMED" in possible_bug_statuses: +            bug_status.value = ["UNCONFIRMED"] +        else: +            # FIXME: This logic is slightly backwards.  We won't print this +            # message if the bug is already open with state "UNCONFIRMED". +            log("Did not reopen bug %s, it appears to already be open with status %s." % (bug_id, bug_status.value)) +        self.browser['comment'] = comment_text +        self.browser.submit() diff --git a/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py new file mode 100644 index 0000000..1d08ca5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/bugzilla/bugzilla_unittest.py @@ -0,0 +1,392 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest +import datetime +import StringIO + +from .bugzilla import Bugzilla, BugzillaQueries, parse_bug_id + +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.tool.mocktool import MockBrowser +from webkitpy.thirdparty.mock import Mock +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup + + +class BugzillaTest(unittest.TestCase): +    _example_attachment = ''' +        <attachment +          isobsolete="1" +          ispatch="1" +          isprivate="0" +        > +        <attachid>33721</attachid> +        <date>2009-07-29 10:23 PDT</date> +        <desc>Fixed whitespace issue</desc> +        <filename>patch</filename> +        <type>text/plain</type> +        <size>9719</size> +        <attacher>christian.plesner.hansen@gmail.com</attacher> +          <flag name="review" +                id="17931" +                status="+" +                setter="one@test.com" +           /> +          <flag name="commit-queue" +                id="17932" +                status="+" +                setter="two@test.com" +           /> +        </attachment> +''' +    _expected_example_attachment_parsing = { +        'attach_date': datetime.datetime(2009, 07, 29, 10, 23), +        'bug_id' : 100, +        'is_obsolete' : True, +        'is_patch' : True, +        'id' : 33721, +        'url' : "https://bugs.webkit.org/attachment.cgi?id=33721", +        'name' : "Fixed whitespace issue", +        'type' : "text/plain", +        'review' : '+', +        'reviewer_email' : 'one@test.com', +        'commit-queue' : '+', +        'committer_email' : 'two@test.com', +        'attacher_email' : 'christian.plesner.hansen@gmail.com', +    } + +    def test_url_creation(self): +        # FIXME: These would be all better as doctests +        bugs = Bugzilla() +        self.assertEquals(None, bugs.bug_url_for_bug_id(None)) +        self.assertEquals(None, bugs.short_bug_url_for_bug_id(None)) +        self.assertEquals(None, bugs.attachment_url_for_id(None)) + +    def test_parse_bug_id(self): +        # FIXME: These would be all better as doctests +        bugs = Bugzilla() +        self.assertEquals(12345, parse_bug_id("http://webkit.org/b/12345")) +        self.assertEquals(12345, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?id=12345")) +        self.assertEquals(12345, parse_bug_id(bugs.short_bug_url_for_bug_id(12345))) +        self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345))) +        self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345, xml=True))) + +        # Our bug parser is super-fragile, but at least we're testing it. +        self.assertEquals(None, parse_bug_id("http://www.webkit.org/b/12345")) +        self.assertEquals(None, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?ctype=xml&id=12345")) + +    _bug_xml = """ +    <bug> +          <bug_id>32585</bug_id> +          <creation_ts>2009-12-15 15:17 PST</creation_ts> +          <short_desc>bug to test webkit-patch and commit-queue failures</short_desc> +          <delta_ts>2009-12-27 21:04:50 PST</delta_ts> +          <reporter_accessible>1</reporter_accessible> +          <cclist_accessible>1</cclist_accessible> +          <classification_id>1</classification_id> +          <classification>Unclassified</classification> +          <product>WebKit</product> +          <component>Tools / Tests</component> +          <version>528+ (Nightly build)</version> +          <rep_platform>PC</rep_platform> +          <op_sys>Mac OS X 10.5</op_sys> +          <bug_status>NEW</bug_status> +          <priority>P2</priority> +          <bug_severity>Normal</bug_severity> +          <target_milestone>---</target_milestone> +          <everconfirmed>1</everconfirmed> +          <reporter name="Eric Seidel">eric@webkit.org</reporter> +          <assigned_to name="Nobody">webkit-unassigned@lists.webkit.org</assigned_to> +          <cc>foo@bar.com</cc> +    <cc>example@example.com</cc> +          <long_desc isprivate="0"> +            <who name="Eric Seidel">eric@webkit.org</who> +            <bug_when>2009-12-15 15:17:28 PST</bug_when> +            <thetext>bug to test webkit-patch and commit-queue failures + +Ignore this bug.  Just for testing failure modes of webkit-patch and the commit-queue.</thetext> +          </long_desc> +          <attachment  +              isobsolete="0" +              ispatch="1" +              isprivate="0" +          >  +            <attachid>45548</attachid>  +            <date>2009-12-27 23:51 PST</date>  +            <desc>Patch</desc>  +            <filename>bug-32585-20091228005112.patch</filename>  +            <type>text/plain</type>  +            <size>10882</size>  +            <attacher>mjs@apple.com</attacher>  + +              <token>1261988248-dc51409e9c421a4358f365fa8bec8357</token>  +              <data encoding="base64">SW5kZXg6IFdlYktpdC9tYWMvQ2hhbmdlTG9nCj09PT09PT09PT09PT09PT09PT09PT09PT09PT09 +removed-because-it-was-really-long +ZEZpbmlzaExvYWRXaXRoUmVhc29uOnJlYXNvbl07Cit9CisKIEBlbmQKIAogI2VuZGlmCg== +</data>         + +            <flag name="review" +                id="27602" +                status="?" +                setter="mjs@apple.com" +            /> +        </attachment> +    </bug> +""" + +    _single_bug_xml = """ +<?xml version="1.0" encoding="UTF-8" standalone="yes" ?> +<!DOCTYPE bugzilla SYSTEM "https://bugs.webkit.org/bugzilla.dtd"> +<bugzilla version="3.2.3" +          urlbase="https://bugs.webkit.org/" +          maintainer="admin@webkit.org" +          exporter="eric@webkit.org" +> +%s +</bugzilla> +""" % _bug_xml + +    _expected_example_bug_parsing = { +        "id" : 32585, +        "title" : u"bug to test webkit-patch and commit-queue failures", +        "cc_emails" : ["foo@bar.com", "example@example.com"], +        "reporter_email" : "eric@webkit.org", +        "assigned_to_email" : "webkit-unassigned@lists.webkit.org", +        "bug_status": "NEW", +        "attachments" : [{ +            "attach_date": datetime.datetime(2009, 12, 27, 23, 51), +            'name': u'Patch', +            'url' : "https://bugs.webkit.org/attachment.cgi?id=45548", +            'is_obsolete': False, +            'review': '?', +            'is_patch': True, +            'attacher_email': 'mjs@apple.com', +            'bug_id': 32585, +            'type': 'text/plain', +            'id': 45548 +        }], +    } + +    # FIXME: This should move to a central location and be shared by more unit tests. +    def _assert_dictionaries_equal(self, actual, expected): +        # Make sure we aren't parsing more or less than we expect +        self.assertEquals(sorted(actual.keys()), sorted(expected.keys())) + +        for key, expected_value in expected.items(): +            self.assertEquals(actual[key], expected_value, ("Failure for key: %s: Actual='%s' Expected='%s'" % (key, actual[key], expected_value))) + +    def test_parse_bug_dictionary_from_xml(self): +        bug = Bugzilla()._parse_bug_dictionary_from_xml(self._single_bug_xml) +        self._assert_dictionaries_equal(bug, self._expected_example_bug_parsing) + +    _sample_multi_bug_xml = """ +<bugzilla version="3.2.3" urlbase="https://bugs.webkit.org/" maintainer="admin@webkit.org" exporter="eric@webkit.org"> +    %s +    %s +</bugzilla> +""" % (_bug_xml, _bug_xml) + +    def test_parse_bugs_from_xml(self): +        bugzilla = Bugzilla() +        bugs = bugzilla._parse_bugs_from_xml(self._sample_multi_bug_xml) +        self.assertEquals(len(bugs), 2) +        self.assertEquals(bugs[0].id(), self._expected_example_bug_parsing['id']) +        bugs = bugzilla._parse_bugs_from_xml("") +        self.assertEquals(len(bugs), 0) + +    # This could be combined into test_bug_parsing later if desired. +    def test_attachment_parsing(self): +        bugzilla = Bugzilla() +        soup = BeautifulSoup(self._example_attachment) +        attachment_element = soup.find("attachment") +        attachment = bugzilla._parse_attachment_element(attachment_element, self._expected_example_attachment_parsing['bug_id']) +        self.assertTrue(attachment) +        self._assert_dictionaries_equal(attachment, self._expected_example_attachment_parsing) + +    _sample_attachment_detail_page = """ +<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" +                      "http://www.w3.org/TR/html4/loose.dtd"> +<html> +  <head> +    <title> +  Attachment 41073 Details for Bug 27314</title> +<link rel="Top" href="https://bugs.webkit.org/"> +    <link rel="Up" href="show_bug.cgi?id=27314"> +""" + +    def test_attachment_detail_bug_parsing(self): +        bugzilla = Bugzilla() +        self.assertEquals(27314, bugzilla._parse_bug_id_from_attachment_page(self._sample_attachment_detail_page)) + +    def test_add_cc_to_bug(self): +        bugzilla = Bugzilla() +        bugzilla.browser = MockBrowser() +        bugzilla.authenticate = lambda: None +        expected_stderr = "Adding ['adam@example.com'] to the CC list for bug 42\n" +        OutputCapture().assert_outputs(self, bugzilla.add_cc_to_bug, [42, ["adam@example.com"]], expected_stderr=expected_stderr) + +    def _mock_control_item(self, name): +        mock_item = Mock() +        mock_item.name = name +        return mock_item + +    def _mock_find_control(self, item_names=[], selected_index=0): +        mock_control = Mock() +        mock_control.items = [self._mock_control_item(name) for name in item_names] +        mock_control.value = [item_names[selected_index]] if item_names else None +        return lambda name, type: mock_control + +    def _assert_reopen(self, item_names=None, selected_index=None, extra_stderr=None): +        bugzilla = Bugzilla() +        bugzilla.browser = MockBrowser() +        bugzilla.authenticate = lambda: None + +        mock_find_control = self._mock_find_control(item_names, selected_index) +        bugzilla.browser.find_control = mock_find_control +        expected_stderr = "Re-opening bug 42\n['comment']\n" +        if extra_stderr: +            expected_stderr += extra_stderr +        OutputCapture().assert_outputs(self, bugzilla.reopen_bug, [42, ["comment"]], expected_stderr=expected_stderr) + +    def test_reopen_bug(self): +        self._assert_reopen(item_names=["REOPENED", "RESOLVED", "CLOSED"], selected_index=1) +        self._assert_reopen(item_names=["UNCONFIRMED", "RESOLVED", "CLOSED"], selected_index=1) +        extra_stderr = "Did not reopen bug 42, it appears to already be open with status ['NEW'].\n" +        self._assert_reopen(item_names=["NEW", "RESOLVED"], selected_index=0, extra_stderr=extra_stderr) + +    def test_file_object_for_upload(self): +        bugzilla = Bugzilla() +        file_object = StringIO.StringIO() +        unicode_tor = u"WebKit \u2661 Tor Arne Vestb\u00F8!" +        utf8_tor = unicode_tor.encode("utf-8") +        self.assertEqual(bugzilla._file_object_for_upload(file_object), file_object) +        self.assertEqual(bugzilla._file_object_for_upload(utf8_tor).read(), utf8_tor) +        self.assertEqual(bugzilla._file_object_for_upload(unicode_tor).read(), utf8_tor) + +    def test_filename_for_upload(self): +        bugzilla = Bugzilla() +        mock_file = Mock() +        mock_file.name = "foo" +        self.assertEqual(bugzilla._filename_for_upload(mock_file, 1234), 'foo') +        mock_timestamp = lambda: "now" +        filename = bugzilla._filename_for_upload(StringIO.StringIO(), 1234, extension="patch", timestamp=mock_timestamp) +        self.assertEqual(filename, "bug-1234-now.patch") + + +class BugzillaQueriesTest(unittest.TestCase): +    _sample_request_page = """ +<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" +                      "http://www.w3.org/TR/html4/loose.dtd"> +<html> +  <head> +    <title>Request Queue</title> +  </head> +<body> + +<h3>Flag: review</h3> +  <table class="requests" cellspacing="0" cellpadding="4" border="1"> +    <tr> +        <th>Requester</th> +        <th>Requestee</th> +        <th>Bug</th> +        <th>Attachment</th> +        <th>Created</th> +    </tr> +    <tr> +        <td>Shinichiro Hamaji <hamaji@chromium.org></td> +        <td></td> +        <td><a href="show_bug.cgi?id=30015">30015: text-transform:capitalize is failing in CSS2.1 test suite</a></td> +        <td><a href="attachment.cgi?id=40511&action=review"> +40511: Patch v0</a></td> +        <td>2009-10-02 04:58 PST</td> +    </tr> +    <tr> +        <td>Zan Dobersek <zandobersek@gmail.com></td> +        <td></td> +        <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td> +        <td><a href="attachment.cgi?id=40722&action=review"> +40722: Media controls, the simple approach</a></td> +        <td>2009-10-06 09:13 PST</td> +    </tr> +    <tr> +        <td>Zan Dobersek <zandobersek@gmail.com></td> +        <td></td> +        <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td> +        <td><a href="attachment.cgi?id=40723&action=review"> +40723: Adjust the media slider thumb size</a></td> +        <td>2009-10-06 09:15 PST</td> +    </tr> +  </table> +</body> +</html> +""" +    _sample_quip_page = u""" +<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" +                      "http://www.w3.org/TR/html4/loose.dtd"> +<html> +  <head> +    <title>Bugzilla Quip System</title> +  </head> +  <body> +    <h2> + +      Existing quips: +    </h2> +    <ul> +        <li>Everything should be made as simple as possible, but not simpler. - Albert Einstein</li> +        <li>Good artists copy. Great artists steal. - Pablo Picasso</li> +        <li>\u00e7gua mole em pedra dura, tanto bate at\u008e que fura.</li> + +    </ul> +  </body> +</html> +""" + +    def _assert_result_count(self, queries, html, count): +        self.assertEquals(queries._parse_result_count(html), count) + +    def test_parse_result_count(self): +        queries = BugzillaQueries(None) +        # Pages with results, always list the count at least twice. +        self._assert_result_count(queries, '<span class="bz_result_count">314 bugs found.</span><span class="bz_result_count">314 bugs found.</span>', 314) +        self._assert_result_count(queries, '<span class="bz_result_count">Zarro Boogs found.</span>', 0) +        self._assert_result_count(queries, '<span class="bz_result_count">\n \nOne bug found.</span>', 1) +        self.assertRaises(Exception, queries._parse_result_count, ['Invalid']) + +    def test_request_page_parsing(self): +        queries = BugzillaQueries(None) +        self.assertEquals([40511, 40722, 40723], queries._parse_attachment_ids_request_query(self._sample_request_page)) + +    def test_quip_page_parsing(self): +        queries = BugzillaQueries(None) +        expected_quips = ["Everything should be made as simple as possible, but not simpler. - Albert Einstein", "Good artists copy. Great artists steal. - Pablo Picasso", u"\u00e7gua mole em pedra dura, tanto bate at\u008e que fura."] +        self.assertEquals(expected_quips, queries._parse_quips(self._sample_quip_page)) + +    def test_load_query(self): +        queries = BugzillaQueries(Mock()) +        queries._load_query("request.cgi?action=queue&type=review&group=type") diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py b/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py new file mode 100644 index 0000000..631ef6b --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/buildbot/__init__.py @@ -0,0 +1,5 @@ +# Required for Python to search this directory for module files + +# We only export public API here. +# It's unclear if Builder and Build need to be public. +from .buildbot import BuildBot, Builder, Build diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py new file mode 100644 index 0000000..3cb6da5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot.py @@ -0,0 +1,463 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for interacting with WebKit's buildbot + +try: +    import json +except ImportError: +    # python 2.5 compatibility +    import webkitpy.thirdparty.simplejson as json + +import operator +import re +import urllib +import urllib2 + +from webkitpy.common.net.failuremap import FailureMap +from webkitpy.common.net.layouttestresults import LayoutTestResults +from webkitpy.common.net.regressionwindow import RegressionWindow +from webkitpy.common.system.logutils import get_logger +from webkitpy.thirdparty.autoinstalled.mechanize import Browser +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup + +_log = get_logger(__file__) + + +class Builder(object): +    def __init__(self, name, buildbot): +        self._name = name +        self._buildbot = buildbot +        self._builds_cache = {} +        self._revision_to_build_number = None +        self._browser = Browser() +        self._browser.set_handle_robots(False) # The builder pages are excluded by robots.txt + +    def name(self): +        return self._name + +    def results_url(self): +        return "http://%s/results/%s" % (self._buildbot.buildbot_host, self.url_encoded_name()) + +    def url_encoded_name(self): +        return urllib.quote(self._name) + +    def url(self): +        return "http://%s/builders/%s" % (self._buildbot.buildbot_host, self.url_encoded_name()) + +    # This provides a single place to mock +    def _fetch_build(self, build_number): +        build_dictionary = self._buildbot._fetch_build_dictionary(self, build_number) +        if not build_dictionary: +            return None +        return Build(self, +            build_number=int(build_dictionary['number']), +            revision=int(build_dictionary['sourceStamp']['revision']), +            is_green=(build_dictionary['results'] == 0) # Undocumented, 0 seems to mean "pass" +        ) + +    def build(self, build_number): +        if not build_number: +            return None +        cached_build = self._builds_cache.get(build_number) +        if cached_build: +            return cached_build + +        build = self._fetch_build(build_number) +        self._builds_cache[build_number] = build +        return build + +    def force_build(self, username="webkit-patch", comments=None): +        def predicate(form): +            try: +                return form.find_control("username") +            except Exception, e: +                return False +        self._browser.open(self.url()) +        self._browser.select_form(predicate=predicate) +        self._browser["username"] = username +        if comments: +            self._browser["comments"] = comments +        return self._browser.submit() + +    file_name_regexp = re.compile(r"r(?P<revision>\d+) \((?P<build_number>\d+)\)") +    def _revision_and_build_for_filename(self, filename): +        # Example: "r47483 (1)/" or "r47483 (1).zip" +        match = self.file_name_regexp.match(filename) +        return (int(match.group("revision")), int(match.group("build_number"))) + +    def _fetch_revision_to_build_map(self): +        # All _fetch requests go through _buildbot for easier mocking +        # FIXME: This should use NetworkTransaction's 404 handling instead. +        try: +            # FIXME: This method is horribly slow due to the huge network load. +            # FIXME: This is a poor way to do revision -> build mapping. +            # Better would be to ask buildbot through some sort of API. +            print "Loading revision/build list from %s." % self.results_url() +            print "This may take a while..." +            result_files = self._buildbot._fetch_twisted_directory_listing(self.results_url()) +        except urllib2.HTTPError, error: +            if error.code != 404: +                raise +            result_files = [] + +        # This assumes there was only one build per revision, which is false but we don't care for now. +        return dict([self._revision_and_build_for_filename(file_info["filename"]) for file_info in result_files]) + +    def _revision_to_build_map(self): +        if not self._revision_to_build_number: +            self._revision_to_build_number = self._fetch_revision_to_build_map() +        return self._revision_to_build_number + +    def revision_build_pairs_with_results(self): +        return self._revision_to_build_map().items() + +    # This assumes there can be only one build per revision, which is false, but we don't care for now. +    def build_for_revision(self, revision, allow_failed_lookups=False): +        # NOTE: This lookup will fail if that exact revision was never built. +        build_number = self._revision_to_build_map().get(int(revision)) +        if not build_number: +            return None +        build = self.build(build_number) +        if not build and allow_failed_lookups: +            # Builds for old revisions with fail to lookup via buildbot's json api. +            build = Build(self, +                build_number=build_number, +                revision=revision, +                is_green=False, +            ) +        return build + +    def find_regression_window(self, red_build, look_back_limit=30): +        if not red_build or red_build.is_green(): +            return RegressionWindow(None, None) +        common_failures = None +        current_build = red_build +        build_after_current_build = None +        look_back_count = 0 +        while current_build: +            if current_build.is_green(): +                # current_build can't possibly have any failures in common +                # with red_build because it's green. +                break +            results = current_build.layout_test_results() +            # We treat a lack of results as if all the test failed. +            # This occurs, for example, when we can't compile at all. +            if results: +                failures = set(results.failing_tests()) +                if common_failures == None: +                    common_failures = failures +                else: +                    common_failures = common_failures.intersection(failures) +                    if not common_failures: +                        # current_build doesn't have any failures in common with +                        # the red build we're worried about.  We assume that any +                        # failures in current_build were due to flakiness. +                        break +            look_back_count += 1 +            if look_back_count > look_back_limit: +                return RegressionWindow(None, current_build, failing_tests=common_failures) +            build_after_current_build = current_build +            current_build = current_build.previous_build() +        # We must iterate at least once because red_build is red. +        assert(build_after_current_build) +        # Current build must either be green or have no failures in common +        # with red build, so we've found our failure transition. +        return RegressionWindow(current_build, build_after_current_build, failing_tests=common_failures) + +    def find_blameworthy_regression_window(self, red_build_number, look_back_limit=30, avoid_flakey_tests=True): +        red_build = self.build(red_build_number) +        regression_window = self.find_regression_window(red_build, look_back_limit) +        if not regression_window.build_before_failure(): +            return None  # We ran off the limit of our search +        # If avoid_flakey_tests, require at least 2 bad builds before we +        # suspect a real failure transition. +        if avoid_flakey_tests and regression_window.failing_build() == red_build: +            return None +        return regression_window + + +class Build(object): +    def __init__(self, builder, build_number, revision, is_green): +        self._builder = builder +        self._number = build_number +        self._revision = revision +        self._is_green = is_green +        self._layout_test_results = None + +    @staticmethod +    def build_url(builder, build_number): +        return "%s/builds/%s" % (builder.url(), build_number) + +    def url(self): +        return self.build_url(self.builder(), self._number) + +    def results_url(self): +        results_directory = "r%s (%s)" % (self.revision(), self._number) +        return "%s/%s" % (self._builder.results_url(), urllib.quote(results_directory)) + +    def _fetch_results_html(self): +        results_html = "%s/results.html" % (self.results_url()) +        # FIXME: This should use NetworkTransaction's 404 handling instead. +        try: +            # It seems this can return None if the url redirects and then returns 404. +            return urllib2.urlopen(results_html) +        except urllib2.HTTPError, error: +            if error.code != 404: +                raise + +    def layout_test_results(self): +        if not self._layout_test_results: +            # FIXME: This should cache that the result was a 404 and stop hitting the network. +            self._layout_test_results = LayoutTestResults.results_from_string(self._fetch_results_html()) +        return self._layout_test_results + +    def builder(self): +        return self._builder + +    def revision(self): +        return self._revision + +    def is_green(self): +        return self._is_green + +    def previous_build(self): +        # previous_build() allows callers to avoid assuming build numbers are sequential. +        # They may not be sequential across all master changes, or when non-trunk builds are made. +        return self._builder.build(self._number - 1) + + +class BuildBot(object): +    # FIXME: This should move into some sort of webkit_config.py +    default_host = "build.webkit.org" + +    def __init__(self, host=default_host): +        self.buildbot_host = host +        self._builder_by_name = {} + +        # If any core builder is red we should not be landing patches.  Other +        # builders should be added to this list once they are known to be +        # reliable. +        # See https://bugs.webkit.org/show_bug.cgi?id=33296 and related bugs. +        self.core_builder_names_regexps = [ +            "SnowLeopard.*Build", +            "SnowLeopard.*\(Test",  # Exclude WebKit2 for now. +            "Leopard", +            "Tiger", +            "Windows.*Build", +            "GTK.*32", +            "GTK.*64.*Debug",  # Disallow the 64-bit Release bot which is broken. +            "Qt", +            "Chromium.*Release$", +        ] + +    def _parse_last_build_cell(self, builder, cell): +        status_link = cell.find('a') +        if status_link: +            # Will be either a revision number or a build number +            revision_string = status_link.string +            # If revision_string has non-digits assume it's not a revision number. +            builder['built_revision'] = int(revision_string) \ +                                        if not re.match('\D', revision_string) \ +                                        else None + +            # FIXME: We treat slave lost as green even though it is not to +            # work around the Qts bot being on a broken internet connection. +            # The real fix is https://bugs.webkit.org/show_bug.cgi?id=37099 +            builder['is_green'] = not re.search('fail', cell.renderContents()) or \ +                                  not not re.search('lost', cell.renderContents()) + +            status_link_regexp = r"builders/(?P<builder_name>.*)/builds/(?P<build_number>\d+)" +            link_match = re.match(status_link_regexp, status_link['href']) +            builder['build_number'] = int(link_match.group("build_number")) +        else: +            # We failed to find a link in the first cell, just give up.  This +            # can happen if a builder is just-added, the first cell will just +            # be "no build" +            # Other parts of the code depend on is_green being present. +            builder['is_green'] = False +            builder['built_revision'] = None +            builder['build_number'] = None + +    def _parse_current_build_cell(self, builder, cell): +        activity_lines = cell.renderContents().split("<br />") +        builder["activity"] = activity_lines[0] # normally "building" or "idle" +        # The middle lines document how long left for any current builds. +        match = re.match("(?P<pending_builds>\d) pending", activity_lines[-1]) +        builder["pending_builds"] = int(match.group("pending_builds")) if match else 0 + +    def _parse_builder_status_from_row(self, status_row): +        status_cells = status_row.findAll('td') +        builder = {} + +        # First cell is the name +        name_link = status_cells[0].find('a') +        builder["name"] = unicode(name_link.string) + +        self._parse_last_build_cell(builder, status_cells[1]) +        self._parse_current_build_cell(builder, status_cells[2]) +        return builder + +    def _matches_regexps(self, builder_name, name_regexps): +        for name_regexp in name_regexps: +            if re.match(name_regexp, builder_name): +                return True +        return False + +    # FIXME: Should move onto Builder +    def _is_core_builder(self, builder_name): +        return self._matches_regexps(builder_name, self.core_builder_names_regexps) + +    # FIXME: This method needs to die, but is used by a unit test at the moment. +    def _builder_statuses_with_names_matching_regexps(self, builder_statuses, name_regexps): +        return [builder for builder in builder_statuses if self._matches_regexps(builder["name"], name_regexps)] + +    def red_core_builders(self): +        return [builder for builder in self.core_builder_statuses() if not builder["is_green"]] + +    def red_core_builders_names(self): +        return [builder["name"] for builder in self.red_core_builders()] + +    def idle_red_core_builders(self): +        return [builder for builder in self.red_core_builders() if builder["activity"] == "idle"] + +    def core_builders_are_green(self): +        return not self.red_core_builders() + +    # FIXME: These _fetch methods should move to a networking class. +    def _fetch_build_dictionary(self, builder, build_number): +        try: +            base = "http://%s" % self.buildbot_host +            path = urllib.quote("json/builders/%s/builds/%s" % (builder.name(), +                                                                build_number)) +            url = "%s/%s" % (base, path) +            jsondata = urllib2.urlopen(url) +            return json.load(jsondata) +        except urllib2.URLError, err: +            build_url = Build.build_url(builder, build_number) +            _log.error("Error fetching data for %s build %s (%s): %s" % (builder.name(), build_number, build_url, err)) +            return None +        except ValueError, err: +            build_url = Build.build_url(builder, build_number) +            _log.error("Error decoding json data from %s: %s" % (build_url, err)) +            return None + +    def _fetch_one_box_per_builder(self): +        build_status_url = "http://%s/one_box_per_builder" % self.buildbot_host +        return urllib2.urlopen(build_status_url) + +    def _file_cell_text(self, file_cell): +        """Traverses down through firstChild elements until one containing a string is found, then returns that string""" +        element = file_cell +        while element.string is None and element.contents: +            element = element.contents[0] +        return element.string + +    def _parse_twisted_file_row(self, file_row): +        string_or_empty = lambda string: unicode(string) if string else u"" +        file_cells = file_row.findAll('td') +        return { +            "filename": string_or_empty(self._file_cell_text(file_cells[0])), +            "size": string_or_empty(self._file_cell_text(file_cells[1])), +            "type": string_or_empty(self._file_cell_text(file_cells[2])), +            "encoding": string_or_empty(self._file_cell_text(file_cells[3])), +        } + +    def _parse_twisted_directory_listing(self, page): +        soup = BeautifulSoup(page) +        # HACK: Match only table rows with a class to ignore twisted header/footer rows. +        file_rows = soup.find('table').findAll('tr', {'class': re.compile(r'\b(?:directory|file)\b')}) +        return [self._parse_twisted_file_row(file_row) for file_row in file_rows] + +    # FIXME: There should be a better way to get this information directly from twisted. +    def _fetch_twisted_directory_listing(self, url): +        return self._parse_twisted_directory_listing(urllib2.urlopen(url)) + +    def builders(self): +        return [self.builder_with_name(status["name"]) for status in self.builder_statuses()] + +    # This method pulls from /one_box_per_builder as an efficient way to get information about +    def builder_statuses(self): +        soup = BeautifulSoup(self._fetch_one_box_per_builder()) +        return [self._parse_builder_status_from_row(status_row) for status_row in soup.find('table').findAll('tr')] + +    def core_builder_statuses(self): +        return [builder for builder in self.builder_statuses() if self._is_core_builder(builder["name"])] + +    def builder_with_name(self, name): +        builder = self._builder_by_name.get(name) +        if not builder: +            builder = Builder(name, self) +            self._builder_by_name[name] = builder +        return builder + +    def failure_map(self, only_core_builders=True): +        builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses() +        failure_map = FailureMap() +        revision_to_failing_bots = {} +        for builder_status in builder_statuses: +            if builder_status["is_green"]: +                continue +            builder = self.builder_with_name(builder_status["name"]) +            regression_window = builder.find_blameworthy_regression_window(builder_status["build_number"]) +            if regression_window: +                failure_map.add_regression_window(builder, regression_window) +        return failure_map + +    # This makes fewer requests than calling Builder.latest_build would.  It grabs all builder +    # statuses in one request using self.builder_statuses (fetching /one_box_per_builder instead of builder pages). +    def _latest_builds_from_builders(self, only_core_builders=True): +        builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses() +        return [self.builder_with_name(status["name"]).build(status["build_number"]) for status in builder_statuses] + +    def _build_at_or_before_revision(self, build, revision): +        while build: +            if build.revision() <= revision: +                return build +            build = build.previous_build() + +    def last_green_revision(self, only_core_builders=True): +        builds = self._latest_builds_from_builders(only_core_builders) +        target_revision = builds[0].revision() +        # An alternate way to do this would be to start at one revision and walk backwards +        # checking builder.build_for_revision, however build_for_revision is very slow on first load. +        while True: +            # Make builds agree on revision +            builds = [self._build_at_or_before_revision(build, target_revision) for build in builds] +            if None in builds: # One of the builds failed to load from the server. +                return None +            min_revision = min(map(lambda build: build.revision(), builds)) +            if min_revision != target_revision: +                target_revision = min_revision +                continue # Builds don't all agree on revision, keep searching +            # Check to make sure they're all green +            all_are_green = reduce(operator.and_, map(lambda build: build.is_green(), builds)) +            if not all_are_green: +                target_revision -= 1 +                continue +            return min_revision diff --git a/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py new file mode 100644 index 0000000..a10e432 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/buildbot/buildbot_unittest.py @@ -0,0 +1,413 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.layouttestresults import LayoutTestResults +from webkitpy.common.net.buildbot import BuildBot, Builder, Build +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup + + +class BuilderTest(unittest.TestCase): +    def _install_fetch_build(self, failure): +        def _mock_fetch_build(build_number): +            build = Build( +                builder=self.builder, +                build_number=build_number, +                revision=build_number + 1000, +                is_green=build_number < 4 +            ) +            parsed_results = {LayoutTestResults.fail_key: failure(build_number)} +            build._layout_test_results = LayoutTestResults(parsed_results) +            return build +        self.builder._fetch_build = _mock_fetch_build + +    def setUp(self): +        self.buildbot = BuildBot() +        self.builder = Builder(u"Test Builder \u2661", self.buildbot) +        self._install_fetch_build(lambda build_number: ["test1", "test2"]) + +    def test_find_regression_window(self): +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure().revision(), 1003) +        self.assertEqual(regression_window.failing_build().revision(), 1004) + +        regression_window = self.builder.find_regression_window(self.builder.build(10), look_back_limit=2) +        self.assertEqual(regression_window.build_before_failure(), None) +        self.assertEqual(regression_window.failing_build().revision(), 1008) + +    def test_none_build(self): +        self.builder._fetch_build = lambda build_number: None +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure(), None) +        self.assertEqual(regression_window.failing_build(), None) + +    def test_flaky_tests(self): +        self._install_fetch_build(lambda build_number: ["test1"] if build_number % 2 else ["test2"]) +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure().revision(), 1009) +        self.assertEqual(regression_window.failing_build().revision(), 1010) + +    def test_failure_and_flaky(self): +        self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"]) +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure().revision(), 1003) +        self.assertEqual(regression_window.failing_build().revision(), 1004) + +    def test_no_results(self): +        self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"]) +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure().revision(), 1003) +        self.assertEqual(regression_window.failing_build().revision(), 1004) + +    def test_failure_after_flaky(self): +        self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number > 6 else ["test3"]) +        regression_window = self.builder.find_regression_window(self.builder.build(10)) +        self.assertEqual(regression_window.build_before_failure().revision(), 1006) +        self.assertEqual(regression_window.failing_build().revision(), 1007) + +    def test_find_blameworthy_regression_window(self): +        self.assertEqual(self.builder.find_blameworthy_regression_window(10).revisions(), [1004]) +        self.assertEqual(self.builder.find_blameworthy_regression_window(10, look_back_limit=2), None) +        # Flakey test avoidance requires at least 2 red builds: +        self.assertEqual(self.builder.find_blameworthy_regression_window(4), None) +        self.assertEqual(self.builder.find_blameworthy_regression_window(4, avoid_flakey_tests=False).revisions(), [1004]) +        # Green builder: +        self.assertEqual(self.builder.find_blameworthy_regression_window(3), None) + +    def test_build_caching(self): +        self.assertEqual(self.builder.build(10), self.builder.build(10)) + +    def test_build_and_revision_for_filename(self): +        expectations = { +            "r47483 (1)/" : (47483, 1), +            "r47483 (1).zip" : (47483, 1), +        } +        for filename, revision_and_build in expectations.items(): +            self.assertEqual(self.builder._revision_and_build_for_filename(filename), revision_and_build) + + +class BuildTest(unittest.TestCase): +    def test_layout_test_results(self): +        build = Build(None, None, None, None) +        build._fetch_results_html = lambda: None +        # Test that layout_test_results() returns None if the fetch fails. +        self.assertEqual(build.layout_test_results(), None) + + +class BuildBotTest(unittest.TestCase): + +    _example_one_box_status = ''' +    <table> +    <tr> +    <td class="box"><a href="builders/Windows%20Debug%20%28Tests%29">Windows Debug (Tests)</a></td> +      <td align="center" class="LastBuild box success"><a href="builders/Windows%20Debug%20%28Tests%29/builds/3693">47380</a><br />build<br />successful</td> +      <td align="center" class="Activity building">building<br />ETA in<br />~ 14 mins<br />at 13:40</td> +    <tr> +    <td class="box"><a href="builders/SnowLeopard%20Intel%20Release">SnowLeopard Intel Release</a></td> +      <td class="LastBuild box" >no build</td> +      <td align="center" class="Activity building">building<br />< 1 min</td> +    <tr> +    <td class="box"><a href="builders/Qt%20Linux%20Release">Qt Linux Release</a></td> +      <td align="center" class="LastBuild box failure"><a href="builders/Qt%20Linux%20Release/builds/654">47383</a><br />failed<br />compile-webkit</td> +      <td align="center" class="Activity idle">idle<br />3 pending</td> +    <tr> +    <td class="box"><a href="builders/Qt%20Windows%2032-bit%20Debug">Qt Windows 32-bit Debug</a></td> +      <td align="center" class="LastBuild box failure"><a href="builders/Qt%20Windows%2032-bit%20Debug/builds/2090">60563</a><br />failed<br />failed<br />slave<br />lost</td> +      <td align="center" class="Activity building">building<br />ETA in<br />~ 5 mins<br />at 08:25</td> +    </table> +''' +    _expected_example_one_box_parsings = [ +        { +            'is_green': True, +            'build_number' : 3693, +            'name': u'Windows Debug (Tests)', +            'built_revision': 47380, +            'activity': 'building', +            'pending_builds': 0, +        }, +        { +            'is_green': False, +            'build_number' : None, +            'name': u'SnowLeopard Intel Release', +            'built_revision': None, +            'activity': 'building', +            'pending_builds': 0, +        }, +        { +            'is_green': False, +            'build_number' : 654, +            'name': u'Qt Linux Release', +            'built_revision': 47383, +            'activity': 'idle', +            'pending_builds': 3, +        }, +        { +            'is_green': True, +            'build_number' : 2090, +            'name': u'Qt Windows 32-bit Debug', +            'built_revision': 60563, +            'activity': 'building', +            'pending_builds': 0, +        }, +    ] + +    def test_status_parsing(self): +        buildbot = BuildBot() + +        soup = BeautifulSoup(self._example_one_box_status) +        status_table = soup.find("table") +        input_rows = status_table.findAll('tr') + +        for x in range(len(input_rows)): +            status_row = input_rows[x] +            expected_parsing = self._expected_example_one_box_parsings[x] + +            builder = buildbot._parse_builder_status_from_row(status_row) + +            # Make sure we aren't parsing more or less than we expect +            self.assertEquals(builder.keys(), expected_parsing.keys()) + +            for key, expected_value in expected_parsing.items(): +                self.assertEquals(builder[key], expected_value, ("Builder %d parse failure for key: %s: Actual='%s' Expected='%s'" % (x, key, builder[key], expected_value))) + +    def test_core_builder_methods(self): +        buildbot = BuildBot() + +        # Override builder_statuses function to not touch the network. +        def example_builder_statuses(): # We could use instancemethod() to bind 'self' but we don't need to. +            return BuildBotTest._expected_example_one_box_parsings +        buildbot.builder_statuses = example_builder_statuses + +        buildbot.core_builder_names_regexps = [ 'Leopard', "Windows.*Build" ] +        self.assertEquals(buildbot.red_core_builders_names(), []) +        self.assertTrue(buildbot.core_builders_are_green()) + +        buildbot.core_builder_names_regexps = [ 'SnowLeopard', 'Qt' ] +        self.assertEquals(buildbot.red_core_builders_names(), [ u'SnowLeopard Intel Release', u'Qt Linux Release' ]) +        self.assertFalse(buildbot.core_builders_are_green()) + +    def test_builder_name_regexps(self): +        buildbot = BuildBot() + +        # For complete testing, this list should match the list of builders at build.webkit.org: +        example_builders = [ +            {'name': u'Tiger Intel Release', }, +            {'name': u'Leopard Intel Release (Build)', }, +            {'name': u'Leopard Intel Release (Tests)', }, +            {'name': u'Leopard Intel Debug (Build)', }, +            {'name': u'Leopard Intel Debug (Tests)', }, +            {'name': u'SnowLeopard Intel Release (Build)', }, +            {'name': u'SnowLeopard Intel Release (Tests)', }, +            {'name': u'SnowLeopard Intel Release (WebKit2 Tests)', }, +            {'name': u'SnowLeopard Intel Leaks', }, +            {'name': u'Windows Release (Build)', }, +            {'name': u'Windows Release (Tests)', }, +            {'name': u'Windows Debug (Build)', }, +            {'name': u'Windows Debug (Tests)', }, +            {'name': u'GTK Linux 32-bit Release', }, +            {'name': u'GTK Linux 32-bit Debug', }, +            {'name': u'GTK Linux 64-bit Debug', }, +            {'name': u'GTK Linux 64-bit Release', }, +            {'name': u'Qt Linux Release', }, +            {'name': u'Qt Linux Release minimal', }, +            {'name': u'Qt Linux ARMv5 Release', }, +            {'name': u'Qt Linux ARMv7 Release', }, +            {'name': u'Qt Windows 32-bit Release', }, +            {'name': u'Qt Windows 32-bit Debug', }, +            {'name': u'Chromium Linux Release', }, +            {'name': u'Chromium Mac Release', }, +            {'name': u'Chromium Win Release', }, +            {'name': u'Chromium Linux Release (Tests)', }, +            {'name': u'Chromium Mac Release (Tests)', }, +            {'name': u'Chromium Win Release (Tests)', }, +            {'name': u'New run-webkit-tests', }, +        ] +        name_regexps = [ +            "SnowLeopard.*Build", +            "SnowLeopard.*\(Test", +            "Leopard", +            "Tiger", +            "Windows.*Build", +            "GTK.*32", +            "GTK.*64.*Debug",  # Disallow the 64-bit Release bot which is broken. +            "Qt", +            "Chromium.*Release$", +        ] +        expected_builders = [ +            {'name': u'Tiger Intel Release', }, +            {'name': u'Leopard Intel Release (Build)', }, +            {'name': u'Leopard Intel Release (Tests)', }, +            {'name': u'Leopard Intel Debug (Build)', }, +            {'name': u'Leopard Intel Debug (Tests)', }, +            {'name': u'SnowLeopard Intel Release (Build)', }, +            {'name': u'SnowLeopard Intel Release (Tests)', }, +            {'name': u'Windows Release (Build)', }, +            {'name': u'Windows Debug (Build)', }, +            {'name': u'GTK Linux 32-bit Release', }, +            {'name': u'GTK Linux 32-bit Debug', }, +            {'name': u'GTK Linux 64-bit Debug', }, +            {'name': u'Qt Linux Release', }, +            {'name': u'Qt Linux Release minimal', }, +            {'name': u'Qt Linux ARMv5 Release', }, +            {'name': u'Qt Linux ARMv7 Release', }, +            {'name': u'Qt Windows 32-bit Release', }, +            {'name': u'Qt Windows 32-bit Debug', }, +            {'name': u'Chromium Linux Release', }, +            {'name': u'Chromium Mac Release', }, +            {'name': u'Chromium Win Release', }, +        ] + +        # This test should probably be updated if the default regexp list changes +        self.assertEquals(buildbot.core_builder_names_regexps, name_regexps) + +        builders = buildbot._builder_statuses_with_names_matching_regexps(example_builders, name_regexps) +        self.assertEquals(builders, expected_builders) + +    def test_builder_with_name(self): +        buildbot = BuildBot() + +        builder = buildbot.builder_with_name("Test Builder") +        self.assertEqual(builder.name(), "Test Builder") +        self.assertEqual(builder.url(), "http://build.webkit.org/builders/Test%20Builder") +        self.assertEqual(builder.url_encoded_name(), "Test%20Builder") +        self.assertEqual(builder.results_url(), "http://build.webkit.org/results/Test%20Builder") + +        # Override _fetch_build_dictionary function to not touch the network. +        def mock_fetch_build_dictionary(self, build_number): +            build_dictionary = { +                "sourceStamp": { +                    "revision" : 2 * build_number, +                    }, +                "number" : int(build_number), +                "results" : build_number % 2, # 0 means pass +            } +            return build_dictionary +        buildbot._fetch_build_dictionary = mock_fetch_build_dictionary + +        build = builder.build(10) +        self.assertEqual(build.builder(), builder) +        self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/10") +        self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r20%20%2810%29") +        self.assertEqual(build.revision(), 20) +        self.assertEqual(build.is_green(), True) + +        build = build.previous_build() +        self.assertEqual(build.builder(), builder) +        self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/9") +        self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r18%20%289%29") +        self.assertEqual(build.revision(), 18) +        self.assertEqual(build.is_green(), False) + +        self.assertEqual(builder.build(None), None) + +    _example_directory_listing = ''' +<h1>Directory listing for /results/SnowLeopard Intel Leaks/</h1> + +<table> +        <tr class="alt"> +            <th>Filename</th> +            <th>Size</th> +            <th>Content type</th> +            <th>Content encoding</th> +        </tr> +<tr class="directory "> +    <td><a href="r47483%20%281%29/"><b>r47483 (1)/</b></a></td> +    <td><b></b></td> +    <td><b>[Directory]</b></td> +    <td><b></b></td> +</tr> +<tr class="file alt"> +    <td><a href="r47484%20%282%29.zip">r47484 (2).zip</a></td> +    <td>89K</td> +    <td>[application/zip]</td> +    <td></td> +</tr> +''' +    _expected_files = [ +        { +            "filename" : "r47483 (1)/", +            "size" : "", +            "type" : "[Directory]", +            "encoding" : "", +        }, +        { +            "filename" : "r47484 (2).zip", +            "size" : "89K", +            "type" : "[application/zip]", +            "encoding" : "", +        }, +    ] + +    def test_parse_build_to_revision_map(self): +        buildbot = BuildBot() +        files = buildbot._parse_twisted_directory_listing(self._example_directory_listing) +        self.assertEqual(self._expected_files, files) + +    # Revision, is_green +    # Ordered from newest (highest number) to oldest. +    fake_builder1 = [ +        [2, False], +        [1, True], +    ] +    fake_builder2 = [ +        [2, False], +        [1, True], +    ] +    fake_builders = [ +        fake_builder1, +        fake_builder2, +    ] +    def _build_from_fake(self, fake_builder, index): +        if index >= len(fake_builder): +            return None +        fake_build = fake_builder[index] +        build = Build( +            builder=fake_builder, +            build_number=index, +            revision=fake_build[0], +            is_green=fake_build[1], +        ) +        def mock_previous_build(): +            return self._build_from_fake(fake_builder, index + 1) +        build.previous_build = mock_previous_build +        return build + +    def _fake_builds_at_index(self, index): +        return [self._build_from_fake(builder, index) for builder in self.fake_builders] + +    def test_last_green_revision(self): +        buildbot = BuildBot() +        def mock_builds_from_builders(only_core_builders): +            return self._fake_builds_at_index(0) +        buildbot._latest_builds_from_builders = mock_builds_from_builders +        self.assertEqual(buildbot.last_green_revision(), 1) + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/net/credentials.py b/Tools/Scripts/webkitpy/common/net/credentials.py new file mode 100644 index 0000000..30480b3 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/credentials.py @@ -0,0 +1,155 @@ +# Copyright (c) 2009 Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Python module for reading stored web credentials from the OS. + +import getpass +import os +import platform +import re + +from webkitpy.common.checkout.scm import Git +from webkitpy.common.system.executive import Executive, ScriptError +from webkitpy.common.system.user import User +from webkitpy.common.system.deprecated_logging import log + +try: +    # Use keyring, a cross platform keyring interface, as a fallback: +    # http://pypi.python.org/pypi/keyring +    import keyring +except ImportError: +    keyring = None + + +class Credentials(object): +    _environ_prefix = "webkit_bugzilla_" + +    def __init__(self, host, git_prefix=None, executive=None, cwd=os.getcwd(), +                 keyring=keyring): +        self.host = host +        self.git_prefix = "%s." % git_prefix if git_prefix else "" +        self.executive = executive or Executive() +        self.cwd = cwd +        self._keyring = keyring + +    def _credentials_from_git(self): +        try: +            if not Git.in_working_directory(self.cwd): +                return (None, None) +            return (Git.read_git_config(self.git_prefix + "username"), +                    Git.read_git_config(self.git_prefix + "password")) +        except OSError, e: +            # Catch and ignore OSError exceptions such as "no such file +            # or directory" (OSError errno 2), which imply that the Git +            # command cannot be found/is not installed. +            pass +        return (None, None) + +    def _keychain_value_with_label(self, label, source_text): +        match = re.search("%s\"(?P<value>.+)\"" % label, +                                                  source_text, +                                                  re.MULTILINE) +        if match: +            return match.group('value') + +    def _is_mac_os_x(self): +        return platform.mac_ver()[0] + +    def _parse_security_tool_output(self, security_output): +        username = self._keychain_value_with_label("^\s*\"acct\"<blob>=", +                                                   security_output) +        password = self._keychain_value_with_label("^password: ", +                                                   security_output) +        return [username, password] + +    def _run_security_tool(self, username=None): +        security_command = [ +            "/usr/bin/security", +            "find-internet-password", +            "-g", +            "-s", +            self.host, +        ] +        if username: +            security_command += ["-a", username] + +        log("Reading Keychain for %s account and password.  " +            "Click \"Allow\" to continue..." % self.host) +        try: +            return self.executive.run_command(security_command) +        except ScriptError: +            # Failed to either find a keychain entry or somekind of OS-related +            # error occured (for instance, couldn't find the /usr/sbin/security +            # command). +            log("Could not find a keychain entry for %s." % self.host) +            return None + +    def _credentials_from_keychain(self, username=None): +        if not self._is_mac_os_x(): +            return [username, None] + +        security_output = self._run_security_tool(username) +        if security_output: +            return self._parse_security_tool_output(security_output) +        else: +            return [None, None] + +    def _read_environ(self, key): +        environ_key = self._environ_prefix + key +        return os.environ.get(environ_key.upper()) + +    def _credentials_from_environment(self): +        return (self._read_environ("username"), self._read_environ("password")) + +    def _offer_to_store_credentials_in_keyring(self, username, password): +        if not self._keyring: +            return +        if not User().confirm("Store password in system keyring?", User.DEFAULT_NO): +            return +        self._keyring.set_password(self.host, username, password) + +    def read_credentials(self): +        username, password = self._credentials_from_environment() +        # FIXME: We don't currently support pulling the username from one +        # source and the password from a separate source. +        if not username or not password: +            username, password = self._credentials_from_git() +        if not username or not password: +            username, password = self._credentials_from_keychain(username) + +        if username and not password and self._keyring: +            password = self._keyring.get_password(self.host, username) + +        if not username: +            username = User.prompt("%s login: " % self.host) +        if not password: +            password = getpass.getpass("%s password for %s: " % (self.host, username)) +            self._offer_to_store_credentials_in_keyring(username, password) + +        return (username, password) diff --git a/Tools/Scripts/webkitpy/common/net/credentials_unittest.py b/Tools/Scripts/webkitpy/common/net/credentials_unittest.py new file mode 100644 index 0000000..6f2d909 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/credentials_unittest.py @@ -0,0 +1,176 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import os +import tempfile +import unittest +from webkitpy.common.net.credentials import Credentials +from webkitpy.common.system.executive import Executive +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.thirdparty.mock import Mock + + +# FIXME: Other unit tests probably want this class. +class _TemporaryDirectory(object): +    def __init__(self, **kwargs): +        self._kwargs = kwargs +        self._directory_path = None + +    def __enter__(self): +        self._directory_path = tempfile.mkdtemp(**self._kwargs) +        return self._directory_path + +    def __exit__(self, type, value, traceback): +        os.rmdir(self._directory_path) + + +class CredentialsTest(unittest.TestCase): +    example_security_output = """keychain: "/Users/test/Library/Keychains/login.keychain" +class: "inet" +attributes: +    0x00000007 <blob>="bugs.webkit.org (test@webkit.org)" +    0x00000008 <blob>=<NULL> +    "acct"<blob>="test@webkit.org" +    "atyp"<blob>="form" +    "cdat"<timedate>=0x32303039303832353233353231365A00  "20090825235216Z\000" +    "crtr"<uint32>=<NULL> +    "cusi"<sint32>=<NULL> +    "desc"<blob>="Web form password" +    "icmt"<blob>="default" +    "invi"<sint32>=<NULL> +    "mdat"<timedate>=0x32303039303930393137323635315A00  "20090909172651Z\000" +    "nega"<sint32>=<NULL> +    "path"<blob>=<NULL> +    "port"<uint32>=0x00000000  +    "prot"<blob>=<NULL> +    "ptcl"<uint32>="htps" +    "scrp"<sint32>=<NULL> +    "sdmn"<blob>=<NULL> +    "srvr"<blob>="bugs.webkit.org" +    "type"<uint32>=<NULL> +password: "SECRETSAUCE" +""" + +    def test_keychain_lookup_on_non_mac(self): +        class FakeCredentials(Credentials): +            def _is_mac_os_x(self): +                return False +        credentials = FakeCredentials("bugs.webkit.org") +        self.assertEqual(credentials._is_mac_os_x(), False) +        self.assertEqual(credentials._credentials_from_keychain("foo"), ["foo", None]) + +    def test_security_output_parse(self): +        credentials = Credentials("bugs.webkit.org") +        self.assertEqual(credentials._parse_security_tool_output(self.example_security_output), ["test@webkit.org", "SECRETSAUCE"]) + +    def test_security_output_parse_entry_not_found(self): +        credentials = Credentials("foo.example.com") +        if not credentials._is_mac_os_x(): +            return # This test does not run on a non-Mac. + +        # Note, we ignore the captured output because it is already covered +        # by the test case CredentialsTest._assert_security_call (below). +        outputCapture = OutputCapture() +        outputCapture.capture_output() +        self.assertEqual(credentials._run_security_tool(), None) +        outputCapture.restore_output() + +    def _assert_security_call(self, username=None): +        executive_mock = Mock() +        credentials = Credentials("example.com", executive=executive_mock) + +        expected_stderr = "Reading Keychain for example.com account and password.  Click \"Allow\" to continue...\n" +        OutputCapture().assert_outputs(self, credentials._run_security_tool, [username], expected_stderr=expected_stderr) + +        security_args = ["/usr/bin/security", "find-internet-password", "-g", "-s", "example.com"] +        if username: +            security_args += ["-a", username] +        executive_mock.run_command.assert_called_with(security_args) + +    def test_security_calls(self): +        self._assert_security_call() +        self._assert_security_call(username="foo") + +    def test_credentials_from_environment(self): +        executive_mock = Mock() +        credentials = Credentials("example.com", executive=executive_mock) + +        saved_environ = os.environ.copy() +        os.environ['WEBKIT_BUGZILLA_USERNAME'] = "foo" +        os.environ['WEBKIT_BUGZILLA_PASSWORD'] = "bar" +        username, password = credentials._credentials_from_environment() +        self.assertEquals(username, "foo") +        self.assertEquals(password, "bar") +        os.environ = saved_environ + +    def test_read_credentials_without_git_repo(self): +        # FIXME: This should share more code with test_keyring_without_git_repo +        class FakeCredentials(Credentials): +            def _is_mac_os_x(self): +                return True + +            def _credentials_from_keychain(self, username): +                return ("test@webkit.org", "SECRETSAUCE") + +            def _credentials_from_environment(self): +                return (None, None) + +        with _TemporaryDirectory(suffix="not_a_git_repo") as temp_dir_path: +            credentials = FakeCredentials("bugs.webkit.org", cwd=temp_dir_path) +            # FIXME: Using read_credentials here seems too broad as higher-priority +            # credential source could be affected by the user's environment. +            self.assertEqual(credentials.read_credentials(), ("test@webkit.org", "SECRETSAUCE")) + + +    def test_keyring_without_git_repo(self): +        # FIXME: This should share more code with test_read_credentials_without_git_repo +        class MockKeyring(object): +            def get_password(self, host, username): +                return "NOMNOMNOM" + +        class FakeCredentials(Credentials): +            def _is_mac_os_x(self): +                return True + +            def _credentials_from_keychain(self, username): +                return ("test@webkit.org", None) + +            def _credentials_from_environment(self): +                return (None, None) + +        with _TemporaryDirectory(suffix="not_a_git_repo") as temp_dir_path: +            credentials = FakeCredentials("fake.hostname", cwd=temp_dir_path, keyring=MockKeyring()) +            # FIXME: Using read_credentials here seems too broad as higher-priority +            # credential source could be affected by the user's environment. +            self.assertEqual(credentials.read_credentials(), ("test@webkit.org", "NOMNOMNOM")) + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/net/failuremap.py b/Tools/Scripts/webkitpy/common/net/failuremap.py new file mode 100644 index 0000000..48cd3e6 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/failuremap.py @@ -0,0 +1,85 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# FIXME: This probably belongs in the buildbot module. +class FailureMap(object): +    def __init__(self): +        self._failures = [] + +    def add_regression_window(self, builder, regression_window): +        self._failures.append({ +            'builder': builder, +            'regression_window': regression_window, +        }) + +    def is_empty(self): +        return not self._failures + +    def failing_revisions(self): +        failing_revisions = [failure_info['regression_window'].revisions() +                             for failure_info in self._failures] +        return sorted(set(sum(failing_revisions, []))) + +    def builders_failing_for(self, revision): +        return self._builders_failing_because_of([revision]) + +    def tests_failing_for(self, revision): +        tests = [failure_info['regression_window'].failing_tests() +                 for failure_info in self._failures +                 if revision in failure_info['regression_window'].revisions() +                    and failure_info['regression_window'].failing_tests()] +        result = set() +        for test in tests: +            result = result.union(test) +        return sorted(result) + +    def _old_failures(self, is_old_failure): +        return filter(lambda revision: is_old_failure(revision), +                      self.failing_revisions()) + +    def _builders_failing_because_of(self, revisions): +        revision_set = set(revisions) +        return [failure_info['builder'] for failure_info in self._failures +                if revision_set.intersection( +                    failure_info['regression_window'].revisions())] + +    # FIXME: We should re-process old failures after some time delay. +    # https://bugs.webkit.org/show_bug.cgi?id=36581 +    def filter_out_old_failures(self, is_old_failure): +        old_failures = self._old_failures(is_old_failure) +        old_failing_builder_names = set([builder.name() +            for builder in self._builders_failing_because_of(old_failures)]) + +        # We filter out all the failing builders that could have been caused +        # by old_failures.  We could miss some new failures this way, but +        # emperically, this reduces the amount of spam we generate. +        failures = self._failures +        self._failures = [failure_info for failure_info in failures +            if failure_info['builder'].name() not in old_failing_builder_names] +        self._cache = {} diff --git a/Tools/Scripts/webkitpy/common/net/failuremap_unittest.py b/Tools/Scripts/webkitpy/common/net/failuremap_unittest.py new file mode 100644 index 0000000..2f0b49d --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/failuremap_unittest.py @@ -0,0 +1,76 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.buildbot import Build +from webkitpy.common.net.failuremap import * +from webkitpy.common.net.regressionwindow import RegressionWindow +from webkitpy.tool.mocktool import MockBuilder + + +class FailureMapTest(unittest.TestCase): +    builder1 = MockBuilder("Builder1") +    builder2 = MockBuilder("Builder2") + +    build1a = Build(builder1, build_number=22, revision=1233, is_green=True) +    build1b = Build(builder1, build_number=23, revision=1234, is_green=False) +    build2a = Build(builder2, build_number=89, revision=1233, is_green=True) +    build2b = Build(builder2, build_number=90, revision=1235, is_green=False) + +    regression_window1 = RegressionWindow(build1a, build1b, failing_tests=[u'test1', u'test1']) +    regression_window2 = RegressionWindow(build2a, build2b, failing_tests=[u'test1']) + +    def _make_failure_map(self): +        failure_map = FailureMap() +        failure_map.add_regression_window(self.builder1, self.regression_window1) +        failure_map.add_regression_window(self.builder2, self.regression_window2) +        return failure_map + +    def test_failing_revisions(self): +        failure_map = self._make_failure_map() +        self.assertEquals(failure_map.failing_revisions(), [1234, 1235]) + +    def test_new_failures(self): +        failure_map = self._make_failure_map() +        failure_map.filter_out_old_failures(lambda revision: False) +        self.assertEquals(failure_map.failing_revisions(), [1234, 1235]) + +    def test_new_failures_with_old_revisions(self): +        failure_map = self._make_failure_map() +        failure_map.filter_out_old_failures(lambda revision: revision == 1234) +        self.assertEquals(failure_map.failing_revisions(), []) + +    def test_new_failures_with_more_old_revisions(self): +        failure_map = self._make_failure_map() +        failure_map.filter_out_old_failures(lambda revision: revision == 1235) +        self.assertEquals(failure_map.failing_revisions(), [1234]) + +    def test_tests_failing_for(self): +        failure_map = self._make_failure_map() +        self.assertEquals(failure_map.tests_failing_for(1234), [u'test1']) diff --git a/Tools/Scripts/webkitpy/common/net/irc/__init__.py b/Tools/Scripts/webkitpy/common/net/irc/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/irc/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/net/irc/ircbot.py b/Tools/Scripts/webkitpy/common/net/irc/ircbot.py new file mode 100644 index 0000000..f742867 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/irc/ircbot.py @@ -0,0 +1,91 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import webkitpy.common.config.irc as config_irc + +from webkitpy.common.thread.messagepump import MessagePump, MessagePumpDelegate +from webkitpy.thirdparty.autoinstalled.irc import ircbot +from webkitpy.thirdparty.autoinstalled.irc import irclib + + +class IRCBotDelegate(object): +    def irc_message_received(self, nick, message): +        raise NotImplementedError, "subclasses must implement" + +    def irc_nickname(self): +        raise NotImplementedError, "subclasses must implement" + +    def irc_password(self): +        raise NotImplementedError, "subclasses must implement" + + +class IRCBot(ircbot.SingleServerIRCBot, MessagePumpDelegate): +    # FIXME: We should get this information from a config file. +    def __init__(self, +                 message_queue, +                 delegate): +        self._message_queue = message_queue +        self._delegate = delegate +        ircbot.SingleServerIRCBot.__init__( +            self, +            [( +                config_irc.server, +                config_irc.port, +                self._delegate.irc_password() +            )], +            self._delegate.irc_nickname(), +            self._delegate.irc_nickname()) +        self._channel = config_irc.channel + +    # ircbot.SingleServerIRCBot methods + +    def on_nicknameinuse(self, connection, event): +        connection.nick(connection.get_nickname() + "_") + +    def on_welcome(self, connection, event): +        connection.join(self._channel) +        self._message_pump = MessagePump(self, self._message_queue) + +    def on_pubmsg(self, connection, event): +        nick = irclib.nm_to_n(event.source()) +        request = event.arguments()[0].split(":", 1) +        if len(request) > 1 and irclib.irc_lower(request[0]) == irclib.irc_lower(self.connection.get_nickname()): +            response = self._delegate.irc_message_received(nick, request[1]) +            if response: +                connection.privmsg(self._channel, response) + +    # MessagePumpDelegate methods + +    def schedule(self, interval, callback): +        self.connection.execute_delayed(interval, callback) + +    def message_available(self, message): +        self.connection.privmsg(self._channel, message) + +    def final_message_delivered(self): +        self.die() diff --git a/Tools/Scripts/webkitpy/common/net/irc/ircproxy.py b/Tools/Scripts/webkitpy/common/net/irc/ircproxy.py new file mode 100644 index 0000000..13348b4 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/irc/ircproxy.py @@ -0,0 +1,62 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import threading + +from webkitpy.common.net.irc.ircbot import IRCBot +from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue +from webkitpy.common.system.deprecated_logging import log + + +class _IRCThread(threading.Thread): +    def __init__(self, message_queue, irc_delegate, irc_bot): +        threading.Thread.__init__(self) +        self.setDaemon(True) +        self._message_queue = message_queue +        self._irc_delegate = irc_delegate +        self._irc_bot = irc_bot + +    def run(self): +        bot = self._irc_bot(self._message_queue, self._irc_delegate) +        bot.start() + + +class IRCProxy(object): +    def __init__(self, irc_delegate, irc_bot=IRCBot): +        log("Connecting to IRC") +        self._message_queue = ThreadedMessageQueue() +        self._child_thread = _IRCThread(self._message_queue, irc_delegate, irc_bot) +        self._child_thread.start() + +    def post(self, message): +        self._message_queue.post(message) + +    def disconnect(self): +        log("Disconnecting from IRC...") +        self._message_queue.stop() +        self._child_thread.join() diff --git a/Tools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py b/Tools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py new file mode 100644 index 0000000..b44ce40 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py @@ -0,0 +1,43 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.irc.ircproxy import IRCProxy +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.thirdparty.mock import Mock + +class IRCProxyTest(unittest.TestCase): +    def test_trivial(self): +        def fun(): +            proxy = IRCProxy(Mock(), Mock()) +            proxy.post("hello") +            proxy.disconnect() + +        expected_stderr = "Connecting to IRC\nDisconnecting from IRC...\n" +        OutputCapture().assert_outputs(self, fun, expected_stderr=expected_stderr) diff --git a/Tools/Scripts/webkitpy/common/net/layouttestresults.py b/Tools/Scripts/webkitpy/common/net/layouttestresults.py new file mode 100644 index 0000000..15e95ce --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/layouttestresults.py @@ -0,0 +1,92 @@ +# Copyright (c) 2010, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# A module for parsing results.html files generated by old-run-webkit-tests + +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, SoupStrainer + + +# FIXME: This should be unified with all the layout test results code in the layout_tests package +# This doesn't belong in common.net, but we don't have a better place for it yet. +def path_for_layout_test(test_name): +    return "LayoutTests/%s" % test_name + + +# FIXME: This should be unified with all the layout test results code in the layout_tests package +# This doesn't belong in common.net, but we don't have a better place for it yet. +class LayoutTestResults(object): +    """This class knows how to parse old-run-webkit-tests results.html files.""" + +    stderr_key = u'Tests that had stderr output:' +    fail_key = u'Tests where results did not match expected results:' +    timeout_key = u'Tests that timed out:' +    crash_key = u'Tests that caused the DumpRenderTree tool to crash:' +    missing_key = u'Tests that had no expected results (probably new):' + +    expected_keys = [ +        stderr_key, +        fail_key, +        crash_key, +        timeout_key, +        missing_key, +    ] + +    @classmethod +    def _parse_results_html(cls, page): +        if not page: +            return None +        parsed_results = {} +        tables = BeautifulSoup(page).findAll("table") +        for table in tables: +            table_title = unicode(table.findPreviousSibling("p").string) +            if table_title not in cls.expected_keys: +                # This Exception should only ever be hit if run-webkit-tests changes its results.html format. +                raise Exception("Unhandled title: %s" % table_title) +            # We might want to translate table titles into identifiers before storing. +            parsed_results[table_title] = [unicode(row.find("a").string) for row in table.findAll("tr")] + +        return parsed_results + +    @classmethod +    def results_from_string(cls, string): +        parsed_results = cls._parse_results_html(string) +        if not parsed_results: +            return None +        return cls(parsed_results) + +    def __init__(self, parsed_results): +        self._parsed_results = parsed_results + +    def parsed_results(self): +        return self._parsed_results + +    def results_matching_keys(self, result_keys): +        return sorted(sum([tests for key, tests in self._parsed_results.items() if key in result_keys], [])) + +    def failing_tests(self): +        return self.results_matching_keys([self.fail_key, self.crash_key, self.timeout_key]) diff --git a/Tools/Scripts/webkitpy/common/net/layouttestresults_unittest.py b/Tools/Scripts/webkitpy/common/net/layouttestresults_unittest.py new file mode 100644 index 0000000..8490eae --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/layouttestresults_unittest.py @@ -0,0 +1,77 @@ +# Copyright (c) 2010, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.layouttestresults import LayoutTestResults + + +class LayoutTestResultsTest(unittest.TestCase): +    _example_results_html = """ +<html> +<head> +<title>Layout Test Results</title> +</head> +<body> +<p>Tests that had stderr output:</p> +<table> +<tr> +<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/accessibility/aria-activedescendant-crash.html">accessibility/aria-activedescendant-crash.html</a></td> +<td><a href="accessibility/aria-activedescendant-crash-stderr.txt">stderr</a></td> +</tr> +<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/http/tests/security/canvas-remote-read-svg-image.html">http/tests/security/canvas-remote-read-svg-image.html</a></td> +<td><a href="http/tests/security/canvas-remote-read-svg-image-stderr.txt">stderr</a></td> +</tr> +</table><p>Tests that had no expected results (probably new):</p> +<table> +<tr> +<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/fast/repaint/no-caret-repaint-in-non-content-editable-element.html">fast/repaint/no-caret-repaint-in-non-content-editable-element.html</a></td> +<td><a href="fast/repaint/no-caret-repaint-in-non-content-editable-element-actual.txt">result</a></td> +</tr> +</table></body> +</html> +""" + +    _expected_layout_test_results = { +        'Tests that had stderr output:': [ +            'accessibility/aria-activedescendant-crash.html', +        ], +        'Tests that had no expected results (probably new):': [ +            'fast/repaint/no-caret-repaint-in-non-content-editable-element.html', +        ], +    } + +    def test_parse_layout_test_results(self): +        results = LayoutTestResults._parse_results_html(self._example_results_html) +        self.assertEqual(self._expected_layout_test_results, results) + +    def test_results_from_string(self): +        self.assertEqual(LayoutTestResults.results_from_string(None), None) +        self.assertEqual(LayoutTestResults.results_from_string(""), None) +        results = LayoutTestResults.results_from_string(self._example_results_html) +        self.assertEqual(len(results.failing_tests()), 0) diff --git a/Tools/Scripts/webkitpy/common/net/networktransaction.py b/Tools/Scripts/webkitpy/common/net/networktransaction.py new file mode 100644 index 0000000..de19e94 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/networktransaction.py @@ -0,0 +1,72 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import logging +import time + +from webkitpy.thirdparty.autoinstalled import mechanize +from webkitpy.common.system.deprecated_logging import log + + +_log = logging.getLogger(__name__) + + +class NetworkTimeout(Exception): +    pass + + +class NetworkTransaction(object): +    def __init__(self, initial_backoff_seconds=10, grown_factor=1.5, timeout_seconds=(10 * 60), convert_404_to_None=False): +        self._initial_backoff_seconds = initial_backoff_seconds +        self._grown_factor = grown_factor +        self._timeout_seconds = timeout_seconds +        self._convert_404_to_None = convert_404_to_None + +    def run(self, request): +        self._total_sleep = 0 +        self._backoff_seconds = self._initial_backoff_seconds +        while True: +            try: +                return request() +            # FIXME: We should catch urllib2.HTTPError here too. +            except mechanize.HTTPError, e: +                if self._convert_404_to_None and e.code == 404: +                    return None +                self._check_for_timeout() +                _log.warn("Received HTTP status %s from server.  Retrying in " +                          "%s seconds..." % (e.code, self._backoff_seconds)) +                self._sleep() + +    def _check_for_timeout(self): +        if self._total_sleep + self._backoff_seconds > self._timeout_seconds: +            raise NetworkTimeout() + +    def _sleep(self): +        time.sleep(self._backoff_seconds) +        self._total_sleep += self._backoff_seconds +        self._backoff_seconds *= self._grown_factor diff --git a/Tools/Scripts/webkitpy/common/net/networktransaction_unittest.py b/Tools/Scripts/webkitpy/common/net/networktransaction_unittest.py new file mode 100644 index 0000000..49aaeed --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/networktransaction_unittest.py @@ -0,0 +1,93 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.networktransaction import NetworkTransaction, NetworkTimeout +from webkitpy.common.system.logtesting import LoggingTestCase +from webkitpy.thirdparty.autoinstalled.mechanize import HTTPError + + +class NetworkTransactionTest(LoggingTestCase): +    exception = Exception("Test exception") + +    def test_success(self): +        transaction = NetworkTransaction() +        self.assertEqual(transaction.run(lambda: 42), 42) + +    def _raise_exception(self): +        raise self.exception + +    def test_exception(self): +        transaction = NetworkTransaction() +        did_process_exception = False +        did_throw_exception = True +        try: +            transaction.run(lambda: self._raise_exception()) +            did_throw_exception = False +        except Exception, e: +            did_process_exception = True +            self.assertEqual(e, self.exception) +        self.assertTrue(did_throw_exception) +        self.assertTrue(did_process_exception) + +    def _raise_500_error(self): +        self._run_count += 1 +        if self._run_count < 3: +            raise HTTPError("http://example.com/", 500, "internal server error", None, None) +        return 42 + +    def _raise_404_error(self): +        raise HTTPError("http://foo.com/", 404, "not found", None, None) + +    def test_retry(self): +        self._run_count = 0 +        transaction = NetworkTransaction(initial_backoff_seconds=0) +        self.assertEqual(transaction.run(lambda: self._raise_500_error()), 42) +        self.assertEqual(self._run_count, 3) +        self.assertLog(['WARNING: Received HTTP status 500 from server.  ' +                        'Retrying in 0 seconds...\n', +                        'WARNING: Received HTTP status 500 from server.  ' +                        'Retrying in 0.0 seconds...\n']) + +    def test_convert_404_to_None(self): +        transaction = NetworkTransaction(convert_404_to_None=True) +        self.assertEqual(transaction.run(lambda: self._raise_404_error()), None) + +    def test_timeout(self): +        self._run_count = 0 +        transaction = NetworkTransaction(initial_backoff_seconds=60*60, timeout_seconds=60) +        did_process_exception = False +        did_throw_exception = True +        try: +            transaction.run(lambda: self._raise_500_error()) +            did_throw_exception = False +        except NetworkTimeout, e: +            did_process_exception = True +        self.assertTrue(did_throw_exception) +        self.assertTrue(did_process_exception) diff --git a/Tools/Scripts/webkitpy/common/net/regressionwindow.py b/Tools/Scripts/webkitpy/common/net/regressionwindow.py new file mode 100644 index 0000000..3960ba2 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/regressionwindow.py @@ -0,0 +1,52 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# FIXME: This probably belongs in the buildbot module. +class RegressionWindow(object): +    def __init__(self, build_before_failure, failing_build, failing_tests=None): +        self._build_before_failure = build_before_failure +        self._failing_build = failing_build +        self._failing_tests = failing_tests +        self._revisions = None + +    def build_before_failure(self): +        return self._build_before_failure + +    def failing_build(self): +        return self._failing_build + +    def failing_tests(self): +        return self._failing_tests + +    def revisions(self): +        # Cache revisions to avoid excessive allocations. +        if not self._revisions: +            self._revisions = range(self._failing_build.revision(), self._build_before_failure.revision(), -1) +            self._revisions.reverse() +        return self._revisions diff --git a/Tools/Scripts/webkitpy/common/net/statusserver.py b/Tools/Scripts/webkitpy/common/net/statusserver.py new file mode 100644 index 0000000..64dd77b --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/statusserver.py @@ -0,0 +1,160 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from webkitpy.common.net.networktransaction import NetworkTransaction +from webkitpy.common.system.deprecated_logging import log +from webkitpy.thirdparty.autoinstalled.mechanize import Browser +from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup + +import logging +import urllib2 + + +_log = logging.getLogger("webkitpy.common.net.statusserver") + + +class StatusServer: +    default_host = "queues.webkit.org" + +    def __init__(self, host=default_host, browser=None, bot_id=None): +        self.set_host(host) +        self._browser = browser or Browser() +        self.set_bot_id(bot_id) + +    def set_host(self, host): +        self.host = host +        self.url = "http://%s" % self.host + +    def set_bot_id(self, bot_id): +        self.bot_id = bot_id + +    def results_url_for_status(self, status_id): +        return "%s/results/%s" % (self.url, status_id) + +    def _add_patch(self, patch): +        if not patch: +            return +        if patch.bug_id(): +            self._browser["bug_id"] = unicode(patch.bug_id()) +        if patch.id(): +            self._browser["patch_id"] = unicode(patch.id()) + +    def _add_results_file(self, results_file): +        if not results_file: +            return +        self._browser.add_file(results_file, "text/plain", "results.txt", 'results_file') + +    def _post_status_to_server(self, queue_name, status, patch, results_file): +        if results_file: +            # We might need to re-wind the file if we've already tried to post it. +            results_file.seek(0) + +        update_status_url = "%s/update-status" % self.url +        self._browser.open(update_status_url) +        self._browser.select_form(name="update_status") +        self._browser["queue_name"] = queue_name +        if self.bot_id: +            self._browser["bot_id"] = self.bot_id +        self._add_patch(patch) +        self._browser["status"] = status +        self._add_results_file(results_file) +        return self._browser.submit().read()  # This is the id of the newly created status object. + +    def _post_svn_revision_to_server(self, svn_revision_number, broken_bot): +        update_svn_revision_url = "%s/update-svn-revision" % self.url +        self._browser.open(update_svn_revision_url) +        self._browser.select_form(name="update_svn_revision") +        self._browser["number"] = unicode(svn_revision_number) +        self._browser["broken_bot"] = broken_bot +        return self._browser.submit().read() + +    def _post_work_items_to_server(self, queue_name, work_items): +        update_work_items_url = "%s/update-work-items" % self.url +        self._browser.open(update_work_items_url) +        self._browser.select_form(name="update_work_items") +        self._browser["queue_name"] = queue_name +        work_items = map(unicode, work_items)  # .join expects strings +        self._browser["work_items"] = " ".join(work_items) +        return self._browser.submit().read() + +    def _post_work_item_to_ews(self, attachment_id): +        submit_to_ews_url = "%s/submit-to-ews" % self.url +        self._browser.open(submit_to_ews_url) +        self._browser.select_form(name="submit_to_ews") +        self._browser["attachment_id"] = unicode(attachment_id) +        self._browser.submit() + +    def submit_to_ews(self, attachment_id): +        _log.info("Submitting attachment %s to EWS queues" % attachment_id) +        return NetworkTransaction().run(lambda: self._post_work_item_to_ews(attachment_id)) + +    def next_work_item(self, queue_name): +        _log.debug("Fetching next work item for %s" % queue_name) +        patch_status_url = "%s/next-patch/%s" % (self.url, queue_name) +        return self._fetch_url(patch_status_url) + +    def _post_release_work_item(self, queue_name, patch): +        release_patch_url = "%s/release-patch" % (self.url) +        self._browser.open(release_patch_url) +        self._browser.select_form(name="release_patch") +        self._browser["queue_name"] = queue_name +        self._browser["attachment_id"] = unicode(patch.id()) +        self._browser.submit() + +    def release_work_item(self, queue_name, patch): +        _log.info("Releasing work item %s from %s" % (patch.id(), queue_name)) +        return NetworkTransaction(convert_404_to_None=True).run(lambda: self._post_release_work_item(queue_name, patch)) + +    def update_work_items(self, queue_name, work_items): +        _log.debug("Recording work items: %s for %s" % (work_items, queue_name)) +        return NetworkTransaction().run(lambda: self._post_work_items_to_server(queue_name, work_items)) + +    def update_status(self, queue_name, status, patch=None, results_file=None): +        log(status) +        return NetworkTransaction().run(lambda: self._post_status_to_server(queue_name, status, patch, results_file)) + +    def update_svn_revision(self, svn_revision_number, broken_bot): +        log("SVN revision: %s broke %s" % (svn_revision_number, broken_bot)) +        return NetworkTransaction().run(lambda: self._post_svn_revision_to_server(svn_revision_number, broken_bot)) + +    def _fetch_url(self, url): +        # FIXME: This should use NetworkTransaction's 404 handling instead. +        try: +            return urllib2.urlopen(url).read() +        except urllib2.HTTPError, e: +            if e.code == 404: +                return None +            raise e + +    def patch_status(self, queue_name, patch_id): +        patch_status_url = "%s/patch-status/%s/%s" % (self.url, queue_name, patch_id) +        return self._fetch_url(patch_status_url) + +    def svn_revision(self, svn_revision_number): +        svn_revision_url = "%s/svn-revision/%s" % (self.url, svn_revision_number) +        return self._fetch_url(svn_revision_url) diff --git a/Tools/Scripts/webkitpy/common/net/statusserver_unittest.py b/Tools/Scripts/webkitpy/common/net/statusserver_unittest.py new file mode 100644 index 0000000..1169ba0 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/net/statusserver_unittest.py @@ -0,0 +1,43 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.net.statusserver import StatusServer +from webkitpy.common.system.outputcapture import OutputCaptureTestCaseBase +from webkitpy.tool.mocktool import MockBrowser + + +class StatusServerTest(OutputCaptureTestCaseBase): +    def test_url_for_issue(self): +        mock_browser = MockBrowser() +        status_server = StatusServer(browser=mock_browser, bot_id='123') +        status_server.update_status('queue name', 'the status') +        self.assertEqual('queue name', mock_browser.params['queue_name']) +        self.assertEqual('the status', mock_browser.params['status']) +        self.assertEqual('123', mock_browser.params['bot_id']) diff --git a/Tools/Scripts/webkitpy/common/newstringio.py b/Tools/Scripts/webkitpy/common/newstringio.py new file mode 100644 index 0000000..f6d08ec --- /dev/null +++ b/Tools/Scripts/webkitpy/common/newstringio.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""'with'-compliant StringIO implementation.""" + +import StringIO + + +class StringIO(StringIO.StringIO): +    def __enter__(self): +        return self + +    def __exit__(self, type, value, traceback): +        pass diff --git a/Tools/Scripts/webkitpy/common/newstringio_unittest.py b/Tools/Scripts/webkitpy/common/newstringio_unittest.py new file mode 100644 index 0000000..5755c98 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/newstringio_unittest.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for newstringio module.""" + +from __future__ import with_statement + +import unittest + +import newstringio + + +class NewStringIOTest(unittest.TestCase): +    def test_with(self): +        with newstringio.StringIO("foo") as f: +            contents = f.read() +        self.assertEqual(contents, "foo") + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/prettypatch.py b/Tools/Scripts/webkitpy/common/prettypatch.py new file mode 100644 index 0000000..e8a913a --- /dev/null +++ b/Tools/Scripts/webkitpy/common/prettypatch.py @@ -0,0 +1,67 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +import tempfile + + +class PrettyPatch(object): +    # FIXME: PrettyPatch should not require checkout_root. +    def __init__(self, executive, checkout_root): +        self._executive = executive +        self._checkout_root = checkout_root + +    def pretty_diff_file(self, diff): +        # Diffs can contain multiple text files of different encodings +        # so we always deal with them as byte arrays, not unicode strings. +        assert(isinstance(diff, str)) +        pretty_diff = self.pretty_diff(diff) +        diff_file = tempfile.NamedTemporaryFile(suffix=".html") +        diff_file.write(pretty_diff) +        diff_file.flush() +        return diff_file + +    def pretty_diff(self, diff): +        # pretify.rb will hang forever if given no input. +        # Avoid the hang by returning an empty string. +        if not diff: +            return "" + +        pretty_patch_path = os.path.join(self._checkout_root, +                                         "Websites", "bugs.webkit.org", +                                         "PrettyPatch") +        prettify_path = os.path.join(pretty_patch_path, "prettify.rb") +        args = [ +            "ruby", +            "-I", +            pretty_patch_path, +            prettify_path, +        ] +        # PrettyPatch does not modify the encoding of the diff output +        # so we can't expect it to be utf-8. +        return self._executive.run_command(args, input=diff, decode_output=False) diff --git a/Tools/Scripts/webkitpy/common/prettypatch_unittest.py b/Tools/Scripts/webkitpy/common/prettypatch_unittest.py new file mode 100644 index 0000000..1307856 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/prettypatch_unittest.py @@ -0,0 +1,70 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os.path +import unittest + +from webkitpy.common.system.executive import Executive +from webkitpy.common.prettypatch import PrettyPatch + + +class PrettyPatchTest(unittest.TestCase): + +    _diff_with_multiple_encodings = """ +Index: utf8_test +=================================================================== +--- utf8_test\t(revision 0) ++++ utf8_test\t(revision 0) +@@ -0,0 +1 @@ ++utf-8 test: \xc2\xa0 +Index: latin1_test +=================================================================== +--- latin1_test\t(revision 0) ++++ latin1_test\t(revision 0) +@@ -0,0 +1 @@ ++latin1 test: \xa0 +""" + +    def _webkit_root(self): +        webkitpy_common = os.path.dirname(__file__) +        webkitpy = os.path.dirname(webkitpy_common) +        scripts = os.path.dirname(webkitpy) +        webkit_tools = os.path.dirname(scripts) +        webkit_root = os.path.dirname(webkit_tools) +        return webkit_root + +    def test_pretty_diff_encodings(self): +        pretty_patch = PrettyPatch(Executive(), self._webkit_root()) +        pretty = pretty_patch.pretty_diff(self._diff_with_multiple_encodings) +        self.assertTrue(pretty)  # We got some output +        self.assertTrue(isinstance(pretty, str))  # It's a byte array, not unicode + +    def test_pretty_print_empty_string(self): +        # Make sure that an empty diff does not hang the process. +        pretty_patch = PrettyPatch(Executive(), self._webkit_root()) +        self.assertEqual(pretty_patch.pretty_diff(""), "") diff --git a/Tools/Scripts/webkitpy/common/system/__init__.py b/Tools/Scripts/webkitpy/common/system/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/system/autoinstall.py b/Tools/Scripts/webkitpy/common/system/autoinstall.py new file mode 100755 index 0000000..9adab29 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/autoinstall.py @@ -0,0 +1,517 @@ +# Copyright (c) 2009, Daniel Krech All rights reserved. +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#  * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +#  * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +#  * Neither the name of the Daniel Krech nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Support for automatically downloading Python packages from an URL.""" + + +from __future__ import with_statement + +import codecs +import logging +import new +import os +import shutil +import sys +import tarfile +import tempfile +import urllib +import urlparse +import zipfile +import zipimport + +_log = logging.getLogger(__name__) + + +class AutoInstaller(object): + +    """Supports automatically installing Python packages from an URL. + +    Supports uncompressed files, .tar.gz, and .zip formats. + +    Basic usage: + +    installer = AutoInstaller() + +    installer.install(url="http://pypi.python.org/packages/source/p/pep8/pep8-0.5.0.tar.gz#md5=512a818af9979290cd619cce8e9c2e2b", +                      url_subpath="pep8-0.5.0/pep8.py") +    installer.install(url="http://pypi.python.org/packages/source/m/mechanize/mechanize-0.1.11.zip", +                      url_subpath="mechanize") + +    """ + +    def __init__(self, append_to_search_path=False, make_package=True, +                 target_dir=None, temp_dir=None): +        """Create an AutoInstaller instance, and set up the target directory. + +        Args: +          append_to_search_path: A boolean value of whether to append the +                                 target directory to the sys.path search path. +          make_package: A boolean value of whether to make the target +                        directory a package.  This adds an __init__.py file +                        to the target directory -- allowing packages and +                        modules within the target directory to be imported +                        explicitly using dotted module names. +          target_dir: The directory path to which packages should be installed. +                      Defaults to a subdirectory of the folder containing +                      this module called "autoinstalled". +          temp_dir: The directory path to use for any temporary files +                    generated while downloading, unzipping, and extracting +                    packages to install.  Defaults to a standard temporary +                    location generated by the tempfile module.  This +                    parameter should normally be used only for development +                    testing. + +        """ +        if target_dir is None: +            this_dir = os.path.dirname(__file__) +            target_dir = os.path.join(this_dir, "autoinstalled") + +        # Ensure that the target directory exists. +        self._set_up_target_dir(target_dir, append_to_search_path, make_package) + +        self._target_dir = target_dir +        self._temp_dir = temp_dir + +    def _log_transfer(self, message, source, target, log_method=None): +        """Log a debug message that involves a source and target.""" +        if log_method is None: +            log_method = _log.debug + +        log_method("%s" % message) +        log_method('    From: "%s"' % source) +        log_method('      To: "%s"' % target) + +    def _create_directory(self, path, name=None): +        """Create a directory.""" +        log = _log.debug + +        name = name + " " if name is not None else "" +        log('Creating %sdirectory...' % name) +        log('    "%s"' % path) + +        os.makedirs(path) + +    def _write_file(self, path, text, encoding): +        """Create a file at the given path with given text. + +        This method overwrites any existing file. + +        """ +        _log.debug("Creating file...") +        _log.debug('    "%s"' % path) +        with codecs.open(path, "w", encoding) as file: +            file.write(text) + +    def _set_up_target_dir(self, target_dir, append_to_search_path, +                           make_package): +        """Set up a target directory. + +        Args: +          target_dir: The path to the target directory to set up. +          append_to_search_path: A boolean value of whether to append the +                                 target directory to the sys.path search path. +          make_package: A boolean value of whether to make the target +                        directory a package.  This adds an __init__.py file +                        to the target directory -- allowing packages and +                        modules within the target directory to be imported +                        explicitly using dotted module names. + +        """ +        if not os.path.exists(target_dir): +            self._create_directory(target_dir, "autoinstall target") + +        if append_to_search_path: +            sys.path.append(target_dir) + +        if make_package: +            init_path = os.path.join(target_dir, "__init__.py") +            if not os.path.exists(init_path): +                text = ("# This file is required for Python to search this " +                        "directory for modules.\n") +                self._write_file(init_path, text, "ascii") + +    def _create_scratch_directory_inner(self, prefix): +        """Create a scratch directory without exception handling. + +        Creates a scratch directory inside the AutoInstaller temp +        directory self._temp_dir, or inside a platform-dependent temp +        directory if self._temp_dir is None.  Returns the path to the +        created scratch directory. + +        Raises: +          OSError: [Errno 2] if the containing temp directory self._temp_dir +                             is not None and does not exist. + +        """ +        # The tempfile.mkdtemp() method function requires that the +        # directory corresponding to the "dir" parameter already exist +        # if it is not None. +        scratch_dir = tempfile.mkdtemp(prefix=prefix, dir=self._temp_dir) +        return scratch_dir + +    def _create_scratch_directory(self, target_name): +        """Create a temporary scratch directory, and return its path. + +        The scratch directory is generated inside the temp directory +        of this AutoInstaller instance.  This method also creates the +        temp directory if it does not already exist. + +        """ +        prefix = target_name + "_" +        try: +            scratch_dir = self._create_scratch_directory_inner(prefix) +        except OSError: +            # Handle case of containing temp directory not existing-- +            # OSError: [Errno 2] No such file or directory:... +            temp_dir = self._temp_dir +            if temp_dir is None or os.path.exists(temp_dir): +                raise +            # Else try again after creating the temp directory. +            self._create_directory(temp_dir, "autoinstall temp") +            scratch_dir = self._create_scratch_directory_inner(prefix) + +        return scratch_dir + +    def _url_downloaded_path(self, target_name): +        """Return the path to the file containing the URL downloaded.""" +        filename = ".%s.url" % target_name +        path = os.path.join(self._target_dir, filename) +        return path + +    def _is_downloaded(self, target_name, url): +        """Return whether a package version has been downloaded.""" +        version_path = self._url_downloaded_path(target_name) + +        _log.debug('Checking %s URL downloaded...' % target_name) +        _log.debug('    "%s"' % version_path) + +        if not os.path.exists(version_path): +            # Then no package version has been downloaded. +            _log.debug("No URL file found.") +            return False + +        with codecs.open(version_path, "r", "utf-8") as file: +            version = file.read() + +        return version.strip() == url.strip() + +    def _record_url_downloaded(self, target_name, url): +        """Record the URL downloaded to a file.""" +        version_path = self._url_downloaded_path(target_name) +        _log.debug("Recording URL downloaded...") +        _log.debug('    URL: "%s"' % url) +        _log.debug('     To: "%s"' % version_path) + +        self._write_file(version_path, url, "utf-8") + +    def _extract_targz(self, path, scratch_dir): +        # tarfile.extractall() extracts to a path without the +        # trailing ".tar.gz". +        target_basename = os.path.basename(path[:-len(".tar.gz")]) +        target_path = os.path.join(scratch_dir, target_basename) + +        self._log_transfer("Starting gunzip/extract...", path, target_path) + +        try: +            tar_file = tarfile.open(path) +        except tarfile.ReadError, err: +            # Append existing Error message to new Error. +            message = ("Could not open tar file: %s\n" +                       " The file probably does not have the correct format.\n" +                       " --> Inner message: %s" +                       % (path, err)) +            raise Exception(message) + +        try: +            # This is helpful for debugging purposes. +            _log.debug("Listing tar file contents...") +            for name in tar_file.getnames(): +                _log.debug('    * "%s"' % name) +            _log.debug("Extracting gzipped tar file...") +            tar_file.extractall(target_path) +        finally: +            tar_file.close() + +        return target_path + +    # This is a replacement for ZipFile.extractall(), which is +    # available in Python 2.6 but not in earlier versions. +    def _extract_all(self, zip_file, target_dir): +        self._log_transfer("Extracting zip file...", zip_file, target_dir) + +        # This is helpful for debugging purposes. +        _log.debug("Listing zip file contents...") +        for name in zip_file.namelist(): +            _log.debug('    * "%s"' % name) + +        for name in zip_file.namelist(): +            path = os.path.join(target_dir, name) +            self._log_transfer("Extracting...", name, path) + +            if not os.path.basename(path): +                # Then the path ends in a slash, so it is a directory. +                self._create_directory(path) +                continue +            # Otherwise, it is a file. + +            try: +                # We open this file w/o encoding, as we're reading/writing +                # the raw byte-stream from the zip file. +                outfile = open(path, 'wb') +            except IOError, err: +                # Not all zip files seem to list the directories explicitly, +                # so try again after creating the containing directory. +                _log.debug("Got IOError: retrying after creating directory...") +                dir = os.path.dirname(path) +                self._create_directory(dir) +                outfile = open(path, 'wb') + +            try: +                outfile.write(zip_file.read(name)) +            finally: +                outfile.close() + +    def _unzip(self, path, scratch_dir): +        # zipfile.extractall() extracts to a path without the +        # trailing ".zip". +        target_basename = os.path.basename(path[:-len(".zip")]) +        target_path = os.path.join(scratch_dir, target_basename) + +        self._log_transfer("Starting unzip...", path, target_path) + +        try: +            zip_file = zipfile.ZipFile(path, "r") +        except zipfile.BadZipfile, err: +            message = ("Could not open zip file: %s\n" +                       " --> Inner message: %s" +                       % (path, err)) +            raise Exception(message) + +        try: +            self._extract_all(zip_file, scratch_dir) +        finally: +            zip_file.close() + +        return target_path + +    def _prepare_package(self, path, scratch_dir): +        """Prepare a package for use, if necessary, and return the new path. + +        For example, this method unzips zipped files and extracts +        tar files. + +        Args: +          path: The path to the downloaded URL contents. +          scratch_dir: The scratch directory.  Note that the scratch +                       directory contains the file designated by the +                       path parameter. + +        """ +        # FIXME: Add other natural extensions. +        if path.endswith(".zip"): +            new_path = self._unzip(path, scratch_dir) +        elif path.endswith(".tar.gz"): +            new_path = self._extract_targz(path, scratch_dir) +        else: +            # No preparation is needed. +            new_path = path + +        return new_path + +    def _download_to_stream(self, url, stream): +        """Download an URL to a stream, and return the number of bytes.""" +        try: +            netstream = urllib.urlopen(url) +        except IOError, err: +            # Append existing Error message to new Error. +            message = ('Could not download Python modules from URL "%s".\n' +                       " Make sure you are connected to the internet.\n" +                       " You must be connected to the internet when " +                       "downloading needed modules for the first time.\n" +                       " --> Inner message: %s" +                       % (url, err)) +            raise IOError(message) +        code = 200 +        if hasattr(netstream, "getcode"): +            code = netstream.getcode() +        if not 200 <= code < 300: +            raise ValueError("HTTP Error code %s" % code) + +        BUFSIZE = 2**13  # 8KB +        bytes = 0 +        while True: +            data = netstream.read(BUFSIZE) +            if not data: +                break +            stream.write(data) +            bytes += len(data) +        netstream.close() +        return bytes + +    def _download(self, url, scratch_dir): +        """Download URL contents, and return the download path.""" +        url_path = urlparse.urlsplit(url)[2] +        url_path = os.path.normpath(url_path)  # Removes trailing slash. +        target_filename = os.path.basename(url_path) +        target_path = os.path.join(scratch_dir, target_filename) + +        self._log_transfer("Starting download...", url, target_path) + +        with open(target_path, "wb") as stream: +            bytes = self._download_to_stream(url, stream) + +        _log.debug("Downloaded %s bytes." % bytes) + +        return target_path + +    def _install(self, scratch_dir, package_name, target_path, url, +                 url_subpath): +        """Install a python package from an URL. + +        This internal method overwrites the target path if the target +        path already exists. + +        """ +        path = self._download(url=url, scratch_dir=scratch_dir) +        path = self._prepare_package(path, scratch_dir) + +        if url_subpath is None: +            source_path = path +        else: +            source_path = os.path.join(path, url_subpath) + +        if os.path.exists(target_path): +            _log.debug('Refreshing install: deleting "%s".' % target_path) +            if os.path.isdir(target_path): +                shutil.rmtree(target_path) +            else: +                os.remove(target_path) + +        self._log_transfer("Moving files into place...", source_path, target_path) + +        # The shutil.move() command creates intermediate directories if they +        # do not exist, but we do not rely on this behavior since we +        # need to create the __init__.py file anyway. +        shutil.move(source_path, target_path) + +        self._record_url_downloaded(package_name, url) + +    def install(self, url, should_refresh=False, target_name=None, +                url_subpath=None): +        """Install a python package from an URL. + +        Args: +          url: The URL from which to download the package. + +        Optional Args: +          should_refresh: A boolean value of whether the package should be +                          downloaded again if the package is already present. +          target_name: The name of the folder or file in the autoinstaller +                       target directory at which the package should be +                       installed.  Defaults to the base name of the +                       URL sub-path.  This parameter must be provided if +                       the URL sub-path is not specified. +          url_subpath: The relative path of the URL directory that should +                       be installed.  Defaults to the full directory, or +                       the entire URL contents. + +        """ +        if target_name is None: +            if not url_subpath: +                raise ValueError('The "target_name" parameter must be ' +                                 'provided if the "url_subpath" parameter ' +                                 "is not provided.") +            # Remove any trailing slashes. +            url_subpath = os.path.normpath(url_subpath) +            target_name = os.path.basename(url_subpath) + +        target_path = os.path.join(self._target_dir, target_name) +        if not should_refresh and self._is_downloaded(target_name, url): +            _log.debug('URL for %s already downloaded.  Skipping...' +                       % target_name) +            _log.debug('    "%s"' % url) +            return + +        self._log_transfer("Auto-installing package: %s" % target_name, +                            url, target_path, log_method=_log.info) + +        # The scratch directory is where we will download and prepare +        # files specific to this install until they are ready to move +        # into place. +        scratch_dir = self._create_scratch_directory(target_name) + +        try: +            self._install(package_name=target_name, +                          target_path=target_path, +                          scratch_dir=scratch_dir, +                          url=url, +                          url_subpath=url_subpath) +        except Exception, err: +            # Append existing Error message to new Error. +            message = ("Error auto-installing the %s package to:\n" +                       ' "%s"\n' +                       " --> Inner message: %s" +                       % (target_name, target_path, err)) +            raise Exception(message) +        finally: +            _log.debug('Cleaning up: deleting "%s".' % scratch_dir) +            shutil.rmtree(scratch_dir) +        _log.debug('Auto-installed %s to:' % target_name) +        _log.debug('    "%s"' % target_path) + + +if __name__=="__main__": + +    # Configure the autoinstall logger to log DEBUG messages for +    # development testing purposes. +    console = logging.StreamHandler() + +    formatter = logging.Formatter('%(name)s: %(levelname)-8s %(message)s') +    console.setFormatter(formatter) +    _log.addHandler(console) +    _log.setLevel(logging.DEBUG) + +    # Use a more visible temp directory for debug purposes. +    this_dir = os.path.dirname(__file__) +    target_dir = os.path.join(this_dir, "autoinstalled") +    temp_dir = os.path.join(target_dir, "Temp") + +    installer = AutoInstaller(target_dir=target_dir, +                              temp_dir=temp_dir) + +    installer.install(should_refresh=False, +                      target_name="pep8.py", +                      url="http://pypi.python.org/packages/source/p/pep8/pep8-0.5.0.tar.gz#md5=512a818af9979290cd619cce8e9c2e2b", +                      url_subpath="pep8-0.5.0/pep8.py") +    installer.install(should_refresh=False, +                      target_name="mechanize", +                      url="http://pypi.python.org/packages/source/m/mechanize/mechanize-0.1.11.zip", +                      url_subpath="mechanize") + diff --git a/Tools/Scripts/webkitpy/common/system/deprecated_logging.py b/Tools/Scripts/webkitpy/common/system/deprecated_logging.py new file mode 100644 index 0000000..9e6b529 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/deprecated_logging.py @@ -0,0 +1,91 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# WebKit's Python module for logging +# This module is now deprecated in favor of python's built-in logging.py. + +import codecs +import os +import sys + + +def log(string): +    print >> sys.stderr, string + + +def error(string): +    log("ERROR: %s" % string) +    exit(1) + + +# Simple class to split output between multiple destinations +class tee: +    def __init__(self, *files): +        self.files = files + +    # Callers should pass an already encoded string for writing. +    def write(self, bytes): +        for file in self.files: +            file.write(bytes) + + +class OutputTee: +    def __init__(self): +        self._original_stdout = None +        self._original_stderr = None +        self._files_for_output = [] + +    def add_log(self, path): +        log_file = self._open_log_file(path) +        self._files_for_output.append(log_file) +        self._tee_outputs_to_files(self._files_for_output) +        return log_file + +    def remove_log(self, log_file): +        self._files_for_output.remove(log_file) +        self._tee_outputs_to_files(self._files_for_output) +        log_file.close() + +    @staticmethod +    def _open_log_file(log_path): +        (log_directory, log_name) = os.path.split(log_path) +        if log_directory and not os.path.exists(log_directory): +            os.makedirs(log_directory) +        return codecs.open(log_path, "a+", "utf-8") + +    def _tee_outputs_to_files(self, files): +        if not self._original_stdout: +            self._original_stdout = sys.stdout +            self._original_stderr = sys.stderr +        if files and len(files): +            sys.stdout = tee(self._original_stdout, *files) +            sys.stderr = tee(self._original_stderr, *files) +        else: +            sys.stdout = self._original_stdout +            sys.stderr = self._original_stderr diff --git a/Tools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py b/Tools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py new file mode 100644 index 0000000..3778162 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py @@ -0,0 +1,60 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +import StringIO +import tempfile +import unittest + +from webkitpy.common.system.executive import ScriptError +from webkitpy.common.system.deprecated_logging import * + +class LoggingTest(unittest.TestCase): + +    def assert_log_equals(self, log_input, expected_output): +        original_stderr = sys.stderr +        test_stderr = StringIO.StringIO() +        sys.stderr = test_stderr + +        try: +            log(log_input) +            actual_output = test_stderr.getvalue() +        finally: +            sys.stderr = original_stderr + +        self.assertEquals(actual_output, expected_output, "log(\"%s\") expected: %s actual: %s" % (log_input, expected_output, actual_output)) + +    def test_log(self): +        self.assert_log_equals("test", "test\n") + +        # Test that log() does not throw an exception when passed an object instead of a string. +        self.assert_log_equals(ScriptError(message="ScriptError"), "ScriptError\n") + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/system/executive.py b/Tools/Scripts/webkitpy/common/system/executive.py new file mode 100644 index 0000000..85a683a --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/executive.py @@ -0,0 +1,399 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# Copyright (c) 2009 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +try: +    # This API exists only in Python 2.6 and higher.  :( +    import multiprocessing +except ImportError: +    multiprocessing = None + +import ctypes +import errno +import logging +import os +import platform +import StringIO +import signal +import subprocess +import sys +import time + +from webkitpy.common.system.deprecated_logging import tee +from webkitpy.python24 import versioning + + +_log = logging.getLogger("webkitpy.common.system") + + +class ScriptError(Exception): + +    def __init__(self, +                 message=None, +                 script_args=None, +                 exit_code=None, +                 output=None, +                 cwd=None): +        if not message: +            message = 'Failed to run "%s"' % script_args +            if exit_code: +                message += " exit_code: %d" % exit_code +            if cwd: +                message += " cwd: %s" % cwd + +        Exception.__init__(self, message) +        self.script_args = script_args # 'args' is already used by Exception +        self.exit_code = exit_code +        self.output = output +        self.cwd = cwd + +    def message_with_output(self, output_limit=500): +        if self.output: +            if output_limit and len(self.output) > output_limit: +                return u"%s\nLast %s characters of output:\n%s" % \ +                    (self, output_limit, self.output[-output_limit:]) +            return u"%s\n%s" % (self, self.output) +        return unicode(self) + +    def command_name(self): +        command_path = self.script_args +        if type(command_path) is list: +            command_path = command_path[0] +        return os.path.basename(command_path) + + +def run_command(*args, **kwargs): +    # FIXME: This should not be a global static. +    # New code should use Executive.run_command directly instead +    return Executive().run_command(*args, **kwargs) + + +class Executive(object): + +    def _should_close_fds(self): +        # We need to pass close_fds=True to work around Python bug #2320 +        # (otherwise we can hang when we kill DumpRenderTree when we are running +        # multiple threads). See http://bugs.python.org/issue2320 . +        # Note that close_fds isn't supported on Windows, but this bug only +        # shows up on Mac and Linux. +        return sys.platform not in ('win32', 'cygwin') + +    def _run_command_with_teed_output(self, args, teed_output): +        args = map(unicode, args)  # Popen will throw an exception if args are non-strings (like int()) +        args = map(self._encode_argument_if_needed, args) + +        child_process = subprocess.Popen(args, +                                         stdout=subprocess.PIPE, +                                         stderr=subprocess.STDOUT, +                                         close_fds=self._should_close_fds()) + +        # Use our own custom wait loop because Popen ignores a tee'd +        # stderr/stdout. +        # FIXME: This could be improved not to flatten output to stdout. +        while True: +            output_line = child_process.stdout.readline() +            if output_line == "" and child_process.poll() != None: +                # poll() is not threadsafe and can throw OSError due to: +                # http://bugs.python.org/issue1731717 +                return child_process.poll() +            # We assume that the child process wrote to us in utf-8, +            # so no re-encoding is necessary before writing here. +            teed_output.write(output_line) + +    # FIXME: Remove this deprecated method and move callers to run_command. +    # FIXME: This method is a hack to allow running command which both +    # capture their output and print out to stdin.  Useful for things +    # like "build-webkit" where we want to display to the user that we're building +    # but still have the output to stuff into a log file. +    def run_and_throw_if_fail(self, args, quiet=False, decode_output=True): +        # Cache the child's output locally so it can be used for error reports. +        child_out_file = StringIO.StringIO() +        tee_stdout = sys.stdout +        if quiet: +            dev_null = open(os.devnull, "w")  # FIXME: Does this need an encoding? +            tee_stdout = dev_null +        child_stdout = tee(child_out_file, tee_stdout) +        exit_code = self._run_command_with_teed_output(args, child_stdout) +        if quiet: +            dev_null.close() + +        child_output = child_out_file.getvalue() +        child_out_file.close() + +        if decode_output: +            child_output = child_output.decode(self._child_process_encoding()) + +        if exit_code: +            raise ScriptError(script_args=args, +                              exit_code=exit_code, +                              output=child_output) +        return child_output + +    def cpu_count(self): +        if multiprocessing: +            return multiprocessing.cpu_count() +        # Darn.  We don't have the multiprocessing package. +        system_name = platform.system() +        if system_name == "Darwin": +            return int(self.run_command(["sysctl", "-n", "hw.ncpu"])) +        elif system_name == "Windows": +            return int(os.environ.get('NUMBER_OF_PROCESSORS', 1)) +        elif system_name == "Linux": +            num_cores = os.sysconf("SC_NPROCESSORS_ONLN") +            if isinstance(num_cores, int) and num_cores > 0: +                return num_cores +        # This quantity is a lie but probably a reasonable guess for modern +        # machines. +        return 2 + +    def kill_process(self, pid): +        """Attempts to kill the given pid. +        Will fail silently if pid does not exist or insufficient permisssions.""" +        if sys.platform == "win32": +            # We only use taskkill.exe on windows (not cygwin) because subprocess.pid +            # is a CYGWIN pid and taskkill.exe expects a windows pid. +            # Thankfully os.kill on CYGWIN handles either pid type. +            command = ["taskkill.exe", "/f", "/pid", pid] +            # taskkill will exit 128 if the process is not found.  We should log. +            self.run_command(command, error_handler=self.ignore_error) +            return + +        # According to http://docs.python.org/library/os.html +        # os.kill isn't available on Windows. python 2.5.5 os.kill appears +        # to work in cygwin, however it occasionally raises EAGAIN. +        retries_left = 10 if sys.platform == "cygwin" else 1 +        while retries_left > 0: +            try: +                retries_left -= 1 +                os.kill(pid, signal.SIGKILL) +            except OSError, e: +                if e.errno == errno.EAGAIN: +                    if retries_left <= 0: +                        _log.warn("Failed to kill pid %s.  Too many EAGAIN errors." % pid) +                    continue +                if e.errno == errno.ESRCH:  # The process does not exist. +                    _log.warn("Called kill_process with a non-existant pid %s" % pid) +                    return +                raise + +    def _win32_check_running_pid(self, pid): + +        class PROCESSENTRY32(ctypes.Structure): +            _fields_ = [("dwSize", ctypes.c_ulong), +                        ("cntUsage", ctypes.c_ulong), +                        ("th32ProcessID", ctypes.c_ulong), +                        ("th32DefaultHeapID", ctypes.c_ulong), +                        ("th32ModuleID", ctypes.c_ulong), +                        ("cntThreads", ctypes.c_ulong), +                        ("th32ParentProcessID", ctypes.c_ulong), +                        ("pcPriClassBase", ctypes.c_ulong), +                        ("dwFlags", ctypes.c_ulong), +                        ("szExeFile", ctypes.c_char * 260)] + +        CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot +        Process32First = ctypes.windll.kernel32.Process32First +        Process32Next = ctypes.windll.kernel32.Process32Next +        CloseHandle = ctypes.windll.kernel32.CloseHandle +        TH32CS_SNAPPROCESS = 0x00000002  # win32 magic number +        hProcessSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) +        pe32 = PROCESSENTRY32() +        pe32.dwSize = ctypes.sizeof(PROCESSENTRY32) +        result = False +        if not Process32First(hProcessSnap, ctypes.byref(pe32)): +            _log.debug("Failed getting first process.") +            CloseHandle(hProcessSnap) +            return result +        while True: +            if pe32.th32ProcessID == pid: +                result = True +                break +            if not Process32Next(hProcessSnap, ctypes.byref(pe32)): +                break +        CloseHandle(hProcessSnap) +        return result + +    def check_running_pid(self, pid): +        """Return True if pid is alive, otherwise return False.""" +        if sys.platform in ('darwin', 'linux2', 'cygwin'): +            try: +                os.kill(pid, 0) +                return True +            except OSError: +                return False +        elif sys.platform == 'win32': +            return self._win32_check_running_pid(pid) + +        assert(False) + +    def _windows_image_name(self, process_name): +        name, extension = os.path.splitext(process_name) +        if not extension: +            # taskkill expects processes to end in .exe +            # If necessary we could add a flag to disable appending .exe. +            process_name = "%s.exe" % name +        return process_name + +    def kill_all(self, process_name): +        """Attempts to kill processes matching process_name. +        Will fail silently if no process are found.""" +        if sys.platform in ("win32", "cygwin"): +            image_name = self._windows_image_name(process_name) +            command = ["taskkill.exe", "/f", "/im", image_name] +            # taskkill will exit 128 if the process is not found.  We should log. +            self.run_command(command, error_handler=self.ignore_error) +            return + +        # FIXME: This is inconsistent that kill_all uses TERM and kill_process +        # uses KILL.  Windows is always using /f (which seems like -KILL). +        # We should pick one mode, or add support for switching between them. +        # Note: Mac OS X 10.6 requires -SIGNALNAME before -u USER +        command = ["killall", "-TERM", "-u", os.getenv("USER"), process_name] +        # killall returns 1 if no process can be found and 2 on command error. +        # FIXME: We should pass a custom error_handler to allow only exit_code 1. +        # We should log in exit_code == 1 +        self.run_command(command, error_handler=self.ignore_error) + +    # Error handlers do not need to be static methods once all callers are +    # updated to use an Executive object. + +    @staticmethod +    def default_error_handler(error): +        raise error + +    @staticmethod +    def ignore_error(error): +        pass + +    def _compute_stdin(self, input): +        """Returns (stdin, string_to_communicate)""" +        # FIXME: We should be returning /dev/null for stdin +        # or closing stdin after process creation to prevent +        # child processes from getting input from the user. +        if not input: +            return (None, None) +        if hasattr(input, "read"):  # Check if the input is a file. +            return (input, None)  # Assume the file is in the right encoding. + +        # Popen in Python 2.5 and before does not automatically encode unicode objects. +        # http://bugs.python.org/issue5290 +        # See https://bugs.webkit.org/show_bug.cgi?id=37528 +        # for an example of a regresion caused by passing a unicode string directly. +        # FIXME: We may need to encode differently on different platforms. +        if isinstance(input, unicode): +            input = input.encode(self._child_process_encoding()) +        return (subprocess.PIPE, input) + +    def _command_for_printing(self, args): +        """Returns a print-ready string representing command args. +        The string should be copy/paste ready for execution in a shell.""" +        escaped_args = [] +        for arg in args: +            if isinstance(arg, unicode): +                # Escape any non-ascii characters for easy copy/paste +                arg = arg.encode("unicode_escape") +            # FIXME: Do we need to fix quotes here? +            escaped_args.append(arg) +        return " ".join(escaped_args) + +    # FIXME: run_and_throw_if_fail should be merged into this method. +    def run_command(self, +                    args, +                    cwd=None, +                    input=None, +                    error_handler=None, +                    return_exit_code=False, +                    return_stderr=True, +                    decode_output=True): +        """Popen wrapper for convenience and to work around python bugs.""" +        assert(isinstance(args, list) or isinstance(args, tuple)) +        start_time = time.time() +        args = map(unicode, args)  # Popen will throw an exception if args are non-strings (like int()) +        args = map(self._encode_argument_if_needed, args) + +        stdin, string_to_communicate = self._compute_stdin(input) +        stderr = subprocess.STDOUT if return_stderr else None + +        process = subprocess.Popen(args, +                                   stdin=stdin, +                                   stdout=subprocess.PIPE, +                                   stderr=stderr, +                                   cwd=cwd, +                                   close_fds=self._should_close_fds()) +        output = process.communicate(string_to_communicate)[0] + +        # run_command automatically decodes to unicode() unless explicitly told not to. +        if decode_output: +            output = output.decode(self._child_process_encoding()) + +        # wait() is not threadsafe and can throw OSError due to: +        # http://bugs.python.org/issue1731717 +        exit_code = process.wait() + +        _log.debug('"%s" took %.2fs' % (self._command_for_printing(args), time.time() - start_time)) + +        if return_exit_code: +            return exit_code + +        if exit_code: +            script_error = ScriptError(script_args=args, +                                       exit_code=exit_code, +                                       output=output, +                                       cwd=cwd) +            (error_handler or self.default_error_handler)(script_error) +        return output + +    def _child_process_encoding(self): +        # Win32 Python 2.x uses CreateProcessA rather than CreateProcessW +        # to launch subprocesses, so we have to encode arguments using the +        # current code page. +        if sys.platform == 'win32' and versioning.compare_version(sys, '3.0')[0] < 0: +            return 'mbcs' +        # All other platforms use UTF-8. +        # FIXME: Using UTF-8 on Cygwin will confuse Windows-native commands +        # which will expect arguments to be encoded using the current code +        # page. +        return 'utf-8' + +    def _should_encode_child_process_arguments(self): +        # Cygwin's Python's os.execv doesn't support unicode command +        # arguments, and neither does Cygwin's execv itself. +        if sys.platform == 'cygwin': +            return True + +        # Win32 Python 2.x uses CreateProcessA rather than CreateProcessW +        # to launch subprocesses, so we have to encode arguments using the +        # current code page. +        if sys.platform == 'win32' and versioning.compare_version(sys, '3.0')[0] < 0: +            return True + +        return False + +    def _encode_argument_if_needed(self, argument): +        if not self._should_encode_child_process_arguments(): +            return argument +        return argument.encode(self._child_process_encoding()) diff --git a/Tools/Scripts/webkitpy/common/system/executive_mock.py b/Tools/Scripts/webkitpy/common/system/executive_mock.py new file mode 100644 index 0000000..c1cf999 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/executive_mock.py @@ -0,0 +1,59 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# FIXME: Implement the rest of the interface as needed for testing :). + +# FIXME: Unify with tool/mocktool.MockExecutive. + + +class MockExecutive2(object): +    def __init__(self, output='', exit_code=0, exception=None, +                 run_command_fn=None): +        self._output = output +        self._exit_code = exit_code +        self._exception = exception +        self._run_command_fn = run_command_fn + +    def cpu_count(self): +        return 2 + +    def kill_all(self, process_name): +        pass + +    def kill_process(self, pid): +        pass + +    def run_command(self, arg_list, return_exit_code=False, +                    decode_output=False): +        if self._exception: +            raise self._exception +        if return_exit_code: +            return self._exit_code +        if self._run_command_fn: +            return self._run_command_fn(arg_list) +        return self._output diff --git a/Tools/Scripts/webkitpy/common/system/executive_unittest.py b/Tools/Scripts/webkitpy/common/system/executive_unittest.py new file mode 100644 index 0000000..b8fd82e --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/executive_unittest.py @@ -0,0 +1,151 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# Copyright (C) 2009 Daniel Bates (dbates@intudata.com). All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +import signal +import subprocess +import sys +import unittest + +from webkitpy.common.system.executive import Executive, run_command, ScriptError +from webkitpy.test import cat, echo + + +def never_ending_command(): +    """Arguments for a command that will never end (useful for testing process +    killing). It should be a process that is unlikely to already be running +    because all instances will be killed.""" +    if sys.platform == 'win32': +        return ['wmic'] +    return ['yes'] + + +class ExecutiveTest(unittest.TestCase): + +    def test_run_command_with_bad_command(self): +        def run_bad_command(): +            run_command(["foo_bar_command_blah"], error_handler=Executive.ignore_error, return_exit_code=True) +        self.failUnlessRaises(OSError, run_bad_command) + +    def test_run_command_args_type(self): +        executive = Executive() +        self.assertRaises(AssertionError, executive.run_command, "echo") +        self.assertRaises(AssertionError, executive.run_command, u"echo") +        executive.run_command(echo.command_arguments('foo')) +        executive.run_command(tuple(echo.command_arguments('foo'))) + +    def test_run_command_with_unicode(self): +        """Validate that it is safe to pass unicode() objects +        to Executive.run* methods, and they will return unicode() +        objects by default unless decode_output=False""" +        unicode_tor_input = u"WebKit \u2661 Tor Arne Vestb\u00F8!" +        if sys.platform == 'win32': +            encoding = 'mbcs' +        else: +            encoding = 'utf-8' +        encoded_tor = unicode_tor_input.encode(encoding) +        # On Windows, we expect the unicode->mbcs->unicode roundtrip to be +        # lossy. On other platforms, we expect a lossless roundtrip. +        if sys.platform == 'win32': +            unicode_tor_output = encoded_tor.decode(encoding) +        else: +            unicode_tor_output = unicode_tor_input + +        executive = Executive() + +        output = executive.run_command(cat.command_arguments(), input=unicode_tor_input) +        self.assertEquals(output, unicode_tor_output) + +        output = executive.run_command(echo.command_arguments("-n", unicode_tor_input)) +        self.assertEquals(output, unicode_tor_output) + +        output = executive.run_command(echo.command_arguments("-n", unicode_tor_input), decode_output=False) +        self.assertEquals(output, encoded_tor) + +        # Make sure that str() input also works. +        output = executive.run_command(cat.command_arguments(), input=encoded_tor, decode_output=False) +        self.assertEquals(output, encoded_tor) + +        # FIXME: We should only have one run* method to test +        output = executive.run_and_throw_if_fail(echo.command_arguments("-n", unicode_tor_input), quiet=True) +        self.assertEquals(output, unicode_tor_output) + +        output = executive.run_and_throw_if_fail(echo.command_arguments("-n", unicode_tor_input), quiet=True, decode_output=False) +        self.assertEquals(output, encoded_tor) + +    def test_kill_process(self): +        executive = Executive() +        process = subprocess.Popen(never_ending_command(), stdout=subprocess.PIPE) +        self.assertEqual(process.poll(), None)  # Process is running +        executive.kill_process(process.pid) +        # Note: Can't use a ternary since signal.SIGKILL is undefined for sys.platform == "win32" +        if sys.platform == "win32": +            expected_exit_code = 1 +        else: +            expected_exit_code = -signal.SIGKILL +        self.assertEqual(process.wait(), expected_exit_code) +        # Killing again should fail silently. +        executive.kill_process(process.pid) + +    def _assert_windows_image_name(self, name, expected_windows_name): +        executive = Executive() +        windows_name = executive._windows_image_name(name) +        self.assertEqual(windows_name, expected_windows_name) + +    def test_windows_image_name(self): +        self._assert_windows_image_name("foo", "foo.exe") +        self._assert_windows_image_name("foo.exe", "foo.exe") +        self._assert_windows_image_name("foo.com", "foo.com") +        # If the name looks like an extension, even if it isn't +        # supposed to, we have no choice but to return the original name. +        self._assert_windows_image_name("foo.baz", "foo.baz") +        self._assert_windows_image_name("foo.baz.exe", "foo.baz.exe") + +    def test_kill_all(self): +        executive = Executive() +        # We use "yes" because it loops forever. +        process = subprocess.Popen(never_ending_command(), stdout=subprocess.PIPE) +        self.assertEqual(process.poll(), None)  # Process is running +        executive.kill_all(never_ending_command()[0]) +        # Note: Can't use a ternary since signal.SIGTERM is undefined for sys.platform == "win32" +        if sys.platform == "cygwin": +            expected_exit_code = 0  # os.kill results in exit(0) for this process. +        elif sys.platform == "win32": +            expected_exit_code = 1 +        else: +            expected_exit_code = -signal.SIGTERM +        self.assertEqual(process.wait(), expected_exit_code) +        # Killing again should fail silently. +        executive.kill_all(never_ending_command()[0]) + +    def test_check_running_pid(self): +        executive = Executive() +        self.assertTrue(executive.check_running_pid(os.getpid())) +        # Maximum pid number on Linux is 32768 by default +        self.assertFalse(executive.check_running_pid(100000)) diff --git a/Tools/Scripts/webkitpy/common/system/file_lock.py b/Tools/Scripts/webkitpy/common/system/file_lock.py new file mode 100644 index 0000000..7296958 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/file_lock.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# Copyright (C) 2010 Gabor Rapcsanyi (rgabor@inf.u-szeged.hu), University of Szeged +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +#    notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +#    notice, this list of conditions and the following disclaimer in the +#    documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY UNIVERSITY OF SZEGED ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL UNIVERSITY OF SZEGED OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""This class helps to lock files exclusively across processes.""" + +import logging +import os +import sys +import time + + +_log = logging.getLogger("webkitpy.common.system.file_lock") + + +class FileLock(object): + +    def __init__(self, lock_file_path, max_wait_time_sec=20): +        self._lock_file_path = lock_file_path +        self._lock_file_descriptor = None +        self._max_wait_time_sec = max_wait_time_sec + +    def _create_lock(self): +        if sys.platform in ('darwin', 'linux2', 'cygwin'): +            import fcntl +            fcntl.flock(self._lock_file_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) +        elif sys.platform == 'win32': +            import msvcrt +            msvcrt.locking(self._lock_file_descriptor, msvcrt.LK_NBLCK, 32) + +    def _remove_lock(self): +        if sys.platform in ('darwin', 'linux2', 'cygwin'): +            import fcntl +            fcntl.flock(self._lock_file_descriptor, fcntl.LOCK_UN) +        elif sys.platform == 'win32': +            import msvcrt +            msvcrt.locking(self._lock_file_descriptor, msvcrt.LK_UNLCK, 32) + +    def acquire_lock(self): +        self._lock_file_descriptor = os.open(self._lock_file_path, os.O_TRUNC | os.O_CREAT) +        start_time = time.time() +        while True: +            try: +                self._create_lock() +                return True +            except IOError: +                if time.time() - start_time > self._max_wait_time_sec: +                    _log.debug("File locking failed: %s" % str(sys.exc_info())) +                    os.close(self._lock_file_descriptor) +                    self._lock_file_descriptor = None +                    return False + +    def release_lock(self): +        try: +            if self._lock_file_descriptor: +                self._remove_lock() +                os.close(self._lock_file_descriptor) +                self._lock_file_descriptor = None +            os.unlink(self._lock_file_path) +        except (IOError, OSError): +            _log.debug("Warning in release lock: %s" % str(sys.exc_info())) diff --git a/Tools/Scripts/webkitpy/common/system/file_lock_unittest.py b/Tools/Scripts/webkitpy/common/system/file_lock_unittest.py new file mode 100644 index 0000000..c5c1db3 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/file_lock_unittest.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# Copyright (C) 2010 Gabor Rapcsanyi (rgabor@inf.u-szeged.hu), University of Szeged +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +#    notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +#    notice, this list of conditions and the following disclaimer in the +#    documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY UNIVERSITY OF SZEGED ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL UNIVERSITY OF SZEGED OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +import tempfile +import unittest + +from webkitpy.common.system.file_lock import FileLock + + +class FileLockTest(unittest.TestCase): + +    def setUp(self): +        self._lock_name = "TestWebKit" + str(os.getpid()) + ".lock" +        self._lock_path = os.path.join(tempfile.gettempdir(), self._lock_name) +        self._file_lock1 = FileLock(self._lock_path, 1) +        self._file_lock2 = FileLock(self._lock_path, 1) + +    def tearDown(self): +        self._file_lock1.release_lock() +        self._file_lock2.release_lock() + +    def test_lock_lifecycle(self): +        # Create the lock. +        self._file_lock1.acquire_lock() +        self.assertTrue(os.path.exists(self._lock_path)) + +        # Try to lock again. +        self.assertFalse(self._file_lock2.acquire_lock()) + +        # Release the lock. +        self._file_lock1.release_lock() +        self.assertFalse(os.path.exists(self._lock_path)) + +    def test_stuck_lock(self): +        open(self._lock_path, 'w').close() +        self._file_lock1.acquire_lock() +        self._file_lock1.release_lock() diff --git a/Tools/Scripts/webkitpy/common/system/filesystem.py b/Tools/Scripts/webkitpy/common/system/filesystem.py new file mode 100644 index 0000000..f0b5e44 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/filesystem.py @@ -0,0 +1,154 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Wrapper object for the file system / source tree.""" + +from __future__ import with_statement + +import codecs +import errno +import exceptions +import os +import shutil +import tempfile +import time + +class FileSystem(object): +    """FileSystem interface for webkitpy. + +    Unless otherwise noted, all paths are allowed to be either absolute +    or relative.""" + +    def exists(self, path): +        """Return whether the path exists in the filesystem.""" +        return os.path.exists(path) + +    def isfile(self, path): +        """Return whether the path refers to a file.""" +        return os.path.isfile(path) + +    def isdir(self, path): +        """Return whether the path refers to a directory.""" +        return os.path.isdir(path) + +    def join(self, *comps): +        """Return the path formed by joining the components.""" +        return os.path.join(*comps) + +    def listdir(self, path): +        """Return the contents of the directory pointed to by path.""" +        return os.listdir(path) + +    def mkdtemp(self, **kwargs): +        """Create and return a uniquely named directory. + +        This is like tempfile.mkdtemp, but if used in a with statement +        the directory will self-delete at the end of the block (if the +        directory is empty; non-empty directories raise errors). The +        directory can be safely deleted inside the block as well, if so +        desired.""" +        class TemporaryDirectory(object): +            def __init__(self, **kwargs): +                self._kwargs = kwargs +                self._directory_path = None + +            def __enter__(self): +                self._directory_path = tempfile.mkdtemp(**self._kwargs) +                return self._directory_path + +            def __exit__(self, type, value, traceback): +                # Only self-delete if necessary. + +                # FIXME: Should we delete non-empty directories? +                if os.path.exists(self._directory_path): +                    os.rmdir(self._directory_path) + +        return TemporaryDirectory(**kwargs) + +    def maybe_make_directory(self, *path): +        """Create the specified directory if it doesn't already exist.""" +        try: +            os.makedirs(os.path.join(*path)) +        except OSError, e: +            if e.errno != errno.EEXIST: +                raise + +    class _WindowsError(exceptions.OSError): +        """Fake exception for Linux and Mac.""" +        pass + +    def remove(self, path, osremove=os.remove): +        """On Windows, if a process was recently killed and it held on to a +        file, the OS will hold on to the file for a short while.  This makes +        attempts to delete the file fail.  To work around that, this method +        will retry for a few seconds until Windows is done with the file.""" +        try: +            exceptions.WindowsError +        except AttributeError: +            exceptions.WindowsError = FileSystem._WindowsError + +        retry_timeout_sec = 3.0 +        sleep_interval = 0.1 +        while True: +            try: +                osremove(path) +                return True +            except exceptions.WindowsError, e: +                time.sleep(sleep_interval) +                retry_timeout_sec -= sleep_interval +                if retry_timeout_sec < 0: +                    raise e + +    def read_binary_file(self, path): +        """Return the contents of the file at the given path as a byte string.""" +        with file(path, 'rb') as f: +            return f.read() + +    def read_text_file(self, path): +        """Return the contents of the file at the given path as a Unicode string. + +        The file is read assuming it is a UTF-8 encoded file with no BOM.""" +        with codecs.open(path, 'r', 'utf8') as f: +            return f.read() + +    def write_binary_file(self, path, contents): +        """Write the contents to the file at the given location.""" +        with file(path, 'wb') as f: +            f.write(contents) + +    def write_text_file(self, path, contents): +        """Write the contents to the file at the given location. + +        The file is written encoded as UTF-8 with no BOM.""" +        with codecs.open(path, 'w', 'utf8') as f: +            f.write(contents) + +    def copyfile(self, source, destination): +        """Copies the contents of the file at the given path to the destination +        path.""" +        shutil.copyfile(source, destination) diff --git a/Tools/Scripts/webkitpy/common/system/filesystem_mock.py b/Tools/Scripts/webkitpy/common/system/filesystem_mock.py new file mode 100644 index 0000000..ea0f3f9 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/filesystem_mock.py @@ -0,0 +1,109 @@ +# Copyright (C) 2009 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import errno +import os +import path + + +class MockFileSystem(object): +    def __init__(self, files=None): +        """Initializes a "mock" filesystem that can be used to completely +        stub out a filesystem. + +        Args: +            files: a dict of filenames -> file contents. A file contents +                value of None is used to indicate that the file should +                not exist. +        """ +        self.files = files or {} + +    def exists(self, path): +        return self.isfile(path) or self.isdir(path) + +    def isfile(self, path): +        return path in self.files and self.files[path] is not None + +    def isdir(self, path): +        if path in self.files: +            return False +        if not path.endswith('/'): +            path += '/' +        return any(f.startswith(path) for f in self.files) + +    def join(self, *comps): +        return '/'.join(comps) + +    def listdir(self, path): +        if not self.isdir(path): +            raise OSError("%s is not a directory" % path) + +        if not path.endswith('/'): +            path += '/' + +        dirs = [] +        files = [] +        for f in self.files: +            if self.exists(f) and f.startswith(path): +                remaining = f[len(path):] +                if '/' in remaining: +                    dir = remaining[:remaining.index('/')] +                    if not dir in dirs: +                        dirs.append(dir) +                else: +                    files.append(remaining) +        return dirs + files + +    def maybe_make_directory(self, *path): +        # FIXME: Implement such that subsequent calls to isdir() work? +        pass + +    def read_text_file(self, path): +        return self.read_binary_file(path) + +    def read_binary_file(self, path): +        if path in self.files: +            if self.files[path] is None: +                raise IOError(errno.ENOENT, path, os.strerror(errno.ENOENT)) +            return self.files[path] + +    def write_text_file(self, path, contents): +        return self.write_binary_file(path, contents) + +    def write_binary_file(self, path, contents): +        self.files[path] = contents + +    def copyfile(self, source, destination): +        if not self.exists(source): +            raise IOError(errno.ENOENT, source, os.strerror(errno.ENOENT)) +        if self.isdir(source): +            raise IOError(errno.EISDIR, source, os.strerror(errno.ISDIR)) +        if self.isdir(destination): +            raise IOError(errno.EISDIR, destination, os.strerror(errno.ISDIR)) + +        self.files[destination] = self.files[source] diff --git a/Tools/Scripts/webkitpy/common/system/filesystem_unittest.py b/Tools/Scripts/webkitpy/common/system/filesystem_unittest.py new file mode 100644 index 0000000..267ca13 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/filesystem_unittest.py @@ -0,0 +1,172 @@ +# vim: set fileencoding=utf-8 : +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# NOTE: The fileencoding comment on the first line of the file is +# important; without it, Python will choke while trying to parse the file, +# since it includes non-ASCII characters. + +from __future__ import with_statement + +import os +import stat +import sys +import tempfile +import unittest + +from filesystem import FileSystem + + +class FileSystemTest(unittest.TestCase): +    def setUp(self): +        self._this_dir = os.path.dirname(os.path.abspath(__file__)) +        self._missing_file = os.path.join(self._this_dir, 'missing_file.py') +        self._this_file = os.path.join(self._this_dir, 'filesystem_unittest.py') + +    def test_exists__true(self): +        fs = FileSystem() +        self.assertTrue(fs.exists(self._this_file)) + +    def test_exists__false(self): +        fs = FileSystem() +        self.assertFalse(fs.exists(self._missing_file)) + +    def test_isdir__true(self): +        fs = FileSystem() +        self.assertTrue(fs.isdir(self._this_dir)) + +    def test_isdir__false(self): +        fs = FileSystem() +        self.assertFalse(fs.isdir(self._this_file)) + +    def test_join(self): +        fs = FileSystem() +        self.assertEqual(fs.join('foo', 'bar'), +                         os.path.join('foo', 'bar')) + +    def test_listdir(self): +        fs = FileSystem() +        with fs.mkdtemp(prefix='filesystem_unittest_') as d: +            self.assertEqual(fs.listdir(d), []) +            new_file = os.path.join(d, 'foo') +            fs.write_text_file(new_file, u'foo') +            self.assertEqual(fs.listdir(d), ['foo']) +            os.remove(new_file) + +    def test_maybe_make_directory__success(self): +        fs = FileSystem() + +        with fs.mkdtemp(prefix='filesystem_unittest_') as base_path: +            sub_path = os.path.join(base_path, "newdir") +            self.assertFalse(os.path.exists(sub_path)) +            self.assertFalse(fs.isdir(sub_path)) + +            fs.maybe_make_directory(sub_path) +            self.assertTrue(os.path.exists(sub_path)) +            self.assertTrue(fs.isdir(sub_path)) + +            # Make sure we can re-create it. +            fs.maybe_make_directory(sub_path) +            self.assertTrue(os.path.exists(sub_path)) +            self.assertTrue(fs.isdir(sub_path)) + +            # Clean up. +            os.rmdir(sub_path) + +        self.assertFalse(os.path.exists(base_path)) +        self.assertFalse(fs.isdir(base_path)) + +    def test_maybe_make_directory__failure(self): +        # FIXME: os.chmod() doesn't work on Windows to set directories +        # as readonly, so we skip this test for now. +        if sys.platform in ('win32', 'cygwin'): +            return + +        fs = FileSystem() +        with fs.mkdtemp(prefix='filesystem_unittest_') as d: +            # Remove write permissions on the parent directory. +            os.chmod(d, stat.S_IRUSR) + +            # Now try to create a sub directory - should fail. +            sub_dir = fs.join(d, 'subdir') +            self.assertRaises(OSError, fs.maybe_make_directory, sub_dir) + +            # Clean up in case the test failed and we did create the +            # directory. +            if os.path.exists(sub_dir): +                os.rmdir(sub_dir) + +    def test_read_and_write_file(self): +        fs = FileSystem() +        text_path = None +        binary_path = None + +        unicode_text_string = u'Ūnĭcōde̽' +        hex_equivalent = '\xC5\xAA\x6E\xC4\xAD\x63\xC5\x8D\x64\x65\xCC\xBD' +        try: +            text_path = tempfile.mktemp(prefix='tree_unittest_') +            binary_path = tempfile.mktemp(prefix='tree_unittest_') +            fs.write_text_file(text_path, unicode_text_string) +            contents = fs.read_binary_file(text_path) +            self.assertEqual(contents, hex_equivalent) + +            fs.write_text_file(binary_path, hex_equivalent) +            text_contents = fs.read_text_file(binary_path) +            self.assertEqual(text_contents, unicode_text_string) +        except: +            if text_path: +                os.remove(text_path) +            if binary_path: +                os.remove(binary_path) + +    def test_read_binary_file__missing(self): +        fs = FileSystem() +        self.assertRaises(IOError, fs.read_binary_file, self._missing_file) + +    def test_read_text_file__missing(self): +        fs = FileSystem() +        self.assertRaises(IOError, fs.read_text_file, self._missing_file) + +    def test_remove_file_with_retry(self): +        FileSystemTest._remove_failures = 2 + +        def remove_with_exception(filename): +            FileSystemTest._remove_failures -= 1 +            if FileSystemTest._remove_failures >= 0: +                try: +                    raise WindowsError +                except NameError: +                    raise FileSystem._WindowsError + +        fs = FileSystem() +        self.assertTrue(fs.remove('filename', remove_with_exception)) +        self.assertEquals(-1, FileSystemTest._remove_failures) + + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/system/fileutils.py b/Tools/Scripts/webkitpy/common/system/fileutils.py new file mode 100644 index 0000000..55821f8 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/fileutils.py @@ -0,0 +1,33 @@ +# Copyright (C) 2010 Apple Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import sys + + +def make_stdout_binary(): +    """Puts sys.stdout into binary mode (on platforms that have a distinction +    between text and binary mode).""" +    if sys.platform != 'win32' or not hasattr(sys.stdout, 'fileno'): +        return +    import msvcrt +    import os +    msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) diff --git a/Tools/Scripts/webkitpy/common/system/logtesting.py b/Tools/Scripts/webkitpy/common/system/logtesting.py new file mode 100644 index 0000000..e361cb5 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/logtesting.py @@ -0,0 +1,258 @@ +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Supports the unit-testing of logging code. + +Provides support for unit-testing messages logged using the built-in +logging module. + +Inherit from the LoggingTestCase class for basic testing needs.  For +more advanced needs (e.g. unit-testing methods that configure logging), +see the TestLogStream class, and perhaps also the LogTesting class. + +""" + +import logging +import unittest + + +class TestLogStream(object): + +    """Represents a file-like object for unit-testing logging. + +    This is meant for passing to the logging.StreamHandler constructor. +    Log messages captured by instances of this object can be tested +    using self.assertMessages() below. + +    """ + +    def __init__(self, test_case): +        """Create an instance. + +        Args: +          test_case: A unittest.TestCase instance. + +        """ +        self._test_case = test_case +        self.messages = [] +        """A list of log messages written to the stream.""" + +    # Python documentation says that any object passed to the StreamHandler +    # constructor should support write() and flush(): +    # +    # http://docs.python.org/library/logging.html#module-logging.handlers +    def write(self, message): +        self.messages.append(message) + +    def flush(self): +        pass + +    def assertMessages(self, messages): +        """Assert that the given messages match the logged messages. + +        messages: A list of log message strings. + +        """ +        self._test_case.assertEquals(messages, self.messages) + + +class LogTesting(object): + +    """Supports end-to-end unit-testing of log messages. + +        Sample usage: + +          class SampleTest(unittest.TestCase): + +              def setUp(self): +                  self._log = LogTesting.setUp(self)  # Turn logging on. + +              def tearDown(self): +                  self._log.tearDown()  # Turn off and reset logging. + +              def test_logging_in_some_method(self): +                  call_some_method()  # Contains calls to _log.info(), etc. + +                  # Check the resulting log messages. +                  self._log.assertMessages(["INFO: expected message #1", +                                          "WARNING: expected message #2"]) + +    """ + +    def __init__(self, test_stream, handler): +        """Create an instance. + +        This method should never be called directly.  Instances should +        instead be created using the static setUp() method. + +        Args: +          test_stream: A TestLogStream instance. +          handler: The handler added to the logger. + +        """ +        self._test_stream = test_stream +        self._handler = handler + +    @staticmethod +    def _getLogger(): +        """Return the logger being tested.""" +        # It is possible we might want to return something other than +        # the root logger in some special situation.  For now, the +        # root logger seems to suffice. +        return logging.getLogger() + +    @staticmethod +    def setUp(test_case, logging_level=logging.INFO): +        """Configure logging for unit testing. + +        Configures the root logger to log to a testing log stream. +        Only messages logged at or above the given level are logged +        to the stream.  Messages logged to the stream are formatted +        in the following way, for example-- + +        "INFO: This is a test log message." + +        This method should normally be called in the setUp() method +        of a unittest.TestCase.  See the docstring of this class +        for more details. + +        Returns: +          A LogTesting instance. + +        Args: +          test_case: A unittest.TestCase instance. +          logging_level: An integer logging level that is the minimum level +                         of log messages you would like to test. + +        """ +        stream = TestLogStream(test_case) +        handler = logging.StreamHandler(stream) +        handler.setLevel(logging_level) +        formatter = logging.Formatter("%(levelname)s: %(message)s") +        handler.setFormatter(formatter) + +        # Notice that we only change the root logger by adding a handler +        # to it.  In particular, we do not reset its level using +        # logger.setLevel().  This ensures that we have not interfered +        # with how the code being tested may have configured the root +        # logger. +        logger = LogTesting._getLogger() +        logger.addHandler(handler) + +        return LogTesting(stream, handler) + +    def tearDown(self): +        """Assert there are no remaining log messages, and reset logging. + +        This method asserts that there are no more messages in the array of +        log messages, and then restores logging to its original state. +        This method should normally be called in the tearDown() method of a +        unittest.TestCase.  See the docstring of this class for more details. + +        """ +        self.assertMessages([]) +        logger = LogTesting._getLogger() +        logger.removeHandler(self._handler) + +    def messages(self): +        """Return the current list of log messages.""" +        return self._test_stream.messages + +    # FIXME: Add a clearMessages() method for cases where the caller +    #        deliberately doesn't want to assert every message. + +    # We clear the log messages after asserting since they are no longer +    # needed after asserting.  This serves two purposes: (1) it simplifies +    # the calling code when we want to check multiple logging calls in a +    # single test method, and (2) it lets us check in the tearDown() method +    # that there are no remaining log messages to be asserted. +    # +    # The latter ensures that no extra log messages are getting logged that +    # the caller might not be aware of or may have forgotten to check for. +    # This gets us a bit more mileage out of our tests without writing any +    # additional code. +    def assertMessages(self, messages): +        """Assert the current array of log messages, and clear its contents. + +        Args: +          messages: A list of log message strings. + +        """ +        try: +            self._test_stream.assertMessages(messages) +        finally: +            # We want to clear the array of messages even in the case of +            # an Exception (e.g. an AssertionError).  Otherwise, another +            # AssertionError can occur in the tearDown() because the +            # array might not have gotten emptied. +            self._test_stream.messages = [] + + +# This class needs to inherit from unittest.TestCase.  Otherwise, the +# setUp() and tearDown() methods will not get fired for test case classes +# that inherit from this class -- even if the class inherits from *both* +# unittest.TestCase and LoggingTestCase. +# +# FIXME: Rename this class to LoggingTestCaseBase to be sure that +#        the unittest module does not interpret this class as a unittest +#        test case itself. +class LoggingTestCase(unittest.TestCase): + +    """Supports end-to-end unit-testing of log messages. + +        Sample usage: + +          class SampleTest(LoggingTestCase): + +              def test_logging_in_some_method(self): +                  call_some_method()  # Contains calls to _log.info(), etc. + +                  # Check the resulting log messages. +                  self.assertLog(["INFO: expected message #1", +                                  "WARNING: expected message #2"]) + +    """ + +    def setUp(self): +        self._log = LogTesting.setUp(self) + +    def tearDown(self): +        self._log.tearDown() + +    def logMessages(self): +        """Return the current list of log messages.""" +        return self._log.messages() + +    # FIXME: Add a clearMessages() method for cases where the caller +    #        deliberately doesn't want to assert every message. + +    # See the code comments preceding LogTesting.assertMessages() for +    # an explanation of why we clear the array of messages after +    # asserting its contents. +    def assertLog(self, messages): +        """Assert the current array of log messages, and clear its contents. + +        Args: +          messages: A list of log message strings. + +        """ +        self._log.assertMessages(messages) diff --git a/Tools/Scripts/webkitpy/common/system/logutils.py b/Tools/Scripts/webkitpy/common/system/logutils.py new file mode 100644 index 0000000..cd4e60f --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/logutils.py @@ -0,0 +1,207 @@ +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Supports webkitpy logging.""" + +# FIXME: Move this file to webkitpy/python24 since logging needs to +#        be configured prior to running version-checking code. + +import logging +import os +import sys + +import webkitpy + + +_log = logging.getLogger(__name__) + +# We set these directory paths lazily in get_logger() below. +_scripts_dir = "" +"""The normalized, absolute path to the ...Scripts directory.""" + +_webkitpy_dir = "" +"""The normalized, absolute path to the ...Scripts/webkitpy directory.""" + + +def _normalize_path(path): +    """Return the given path normalized. + +    Converts a path to an absolute path, removes any trailing slashes, +    removes any extension, and lower-cases it. + +    """ +    path = os.path.abspath(path) +    path = os.path.normpath(path) +    path = os.path.splitext(path)[0]  # Remove the extension, if any. +    path = path.lower() + +    return path + + +# Observe that the implementation of this function does not require +# the use of any hard-coded strings like "webkitpy", etc. +# +# The main benefit this function has over using-- +# +# _log = logging.getLogger(__name__) +# +# is that get_logger() returns the same value even if __name__ is +# "__main__" -- i.e. even if the module is the script being executed +# from the command-line. +def get_logger(path): +    """Return a logging.logger for the given path. + +    Returns: +      A logger whose name is the name of the module corresponding to +      the given path.  If the module is in webkitpy, the name is +      the fully-qualified dotted module name beginning with webkitpy.... +      Otherwise, the name is the base name of the module (i.e. without +      any dotted module name prefix). + +    Args: +      path: The path of the module.  Normally, this parameter should be +            the __file__ variable of the module. + +    Sample usage: + +      import webkitpy.common.system.logutils as logutils + +      _log = logutils.get_logger(__file__) + +    """ +    # Since we assign to _scripts_dir and _webkitpy_dir in this function, +    # we need to declare them global. +    global _scripts_dir +    global _webkitpy_dir + +    path = _normalize_path(path) + +    # Lazily evaluate _webkitpy_dir and _scripts_dir. +    if not _scripts_dir: +        # The normalized, absolute path to ...Scripts/webkitpy/__init__. +        webkitpy_path = _normalize_path(webkitpy.__file__) + +        _webkitpy_dir = os.path.split(webkitpy_path)[0] +        _scripts_dir = os.path.split(_webkitpy_dir)[0] + +    if path.startswith(_webkitpy_dir): +        # Remove the initial Scripts directory portion, so the path +        # starts with /webkitpy, for example "/webkitpy/init/logutils". +        path = path[len(_scripts_dir):] + +        parts = [] +        while True: +            (path, tail) = os.path.split(path) +            if not tail: +                break +            parts.insert(0, tail) + +        logger_name = ".".join(parts)  # For example, webkitpy.common.system.logutils. +    else: +        # The path is outside of webkitpy.  Default to the basename +        # without the extension. +        basename = os.path.basename(path) +        logger_name = os.path.splitext(basename)[0] + +    return logging.getLogger(logger_name) + + +def _default_handlers(stream): +    """Return a list of the default logging handlers to use. + +    Args: +      stream: See the configure_logging() docstring. + +    """ +    # Create the filter. +    def should_log(record): +        """Return whether a logging.LogRecord should be logged.""" +        # FIXME: Enable the logging of autoinstall messages once +        #        autoinstall is adjusted.  Currently, autoinstall logs +        #        INFO messages when importing already-downloaded packages, +        #        which is too verbose. +        if record.name.startswith("webkitpy.thirdparty.autoinstall"): +            return False +        return True + +    logging_filter = logging.Filter() +    logging_filter.filter = should_log + +    # Create the handler. +    handler = logging.StreamHandler(stream) +    formatter = logging.Formatter("%(name)s: [%(levelname)s] %(message)s") +    handler.setFormatter(formatter) +    handler.addFilter(logging_filter) + +    return [handler] + + +def configure_logging(logging_level=None, logger=None, stream=None, +                      handlers=None): +    """Configure logging for standard purposes. + +    Returns: +      A list of references to the logging handlers added to the root +      logger.  This allows the caller to later remove the handlers +      using logger.removeHandler.  This is useful primarily during unit +      testing where the caller may want to configure logging temporarily +      and then undo the configuring. + +    Args: +      logging_level: The minimum logging level to log.  Defaults to +                     logging.INFO. +      logger: A logging.logger instance to configure.  This parameter +              should be used only in unit tests.  Defaults to the +              root logger. +      stream: A file-like object to which to log used in creating the default +              handlers.  The stream must define an "encoding" data attribute, +              or else logging raises an error.  Defaults to sys.stderr. +      handlers: A list of logging.Handler instances to add to the logger +                being configured.  If this parameter is provided, then the +                stream parameter is not used. + +    """ +    # If the stream does not define an "encoding" data attribute, the +    # logging module can throw an error like the following: +    # +    # Traceback (most recent call last): +    #   File "/System/Library/Frameworks/Python.framework/Versions/2.6/... +    #         lib/python2.6/logging/__init__.py", line 761, in emit +    #     self.stream.write(fs % msg.encode(self.stream.encoding)) +    # LookupError: unknown encoding: unknown +    if logging_level is None: +        logging_level = logging.INFO +    if logger is None: +        logger = logging.getLogger() +    if stream is None: +        stream = sys.stderr +    if handlers is None: +        handlers = _default_handlers(stream) + +    logger.setLevel(logging_level) + +    for handler in handlers: +        logger.addHandler(handler) + +    _log.debug("Debug logging enabled.") + +    return handlers diff --git a/Tools/Scripts/webkitpy/common/system/logutils_unittest.py b/Tools/Scripts/webkitpy/common/system/logutils_unittest.py new file mode 100644 index 0000000..b77c284 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/logutils_unittest.py @@ -0,0 +1,142 @@ +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for logutils.py.""" + +import logging +import os +import unittest + +from webkitpy.common.system.logtesting import LogTesting +from webkitpy.common.system.logtesting import TestLogStream +import webkitpy.common.system.logutils as logutils + + +class GetLoggerTest(unittest.TestCase): + +    """Tests get_logger().""" + +    def test_get_logger_in_webkitpy(self): +        logger = logutils.get_logger(__file__) +        self.assertEquals(logger.name, "webkitpy.common.system.logutils_unittest") + +    def test_get_logger_not_in_webkitpy(self): +        # Temporarily change the working directory so that we +        # can test get_logger() for a path outside of webkitpy. +        working_directory = os.getcwd() +        root_dir = "/" +        os.chdir(root_dir) + +        logger = logutils.get_logger("/Tools/Scripts/test-webkitpy") +        self.assertEquals(logger.name, "test-webkitpy") + +        logger = logutils.get_logger("/Tools/Scripts/test-webkitpy.py") +        self.assertEquals(logger.name, "test-webkitpy") + +        os.chdir(working_directory) + + +class ConfigureLoggingTestBase(unittest.TestCase): + +    """Base class for configure_logging() unit tests.""" + +    def _logging_level(self): +        raise Exception("Not implemented.") + +    def setUp(self): +        log_stream = TestLogStream(self) + +        # Use a logger other than the root logger or one prefixed with +        # "webkitpy." so as not to conflict with test-webkitpy logging. +        logger = logging.getLogger("unittest") + +        # Configure the test logger not to pass messages along to the +        # root logger.  This prevents test messages from being +        # propagated to loggers used by test-webkitpy logging (e.g. +        # the root logger). +        logger.propagate = False + +        logging_level = self._logging_level() +        self._handlers = logutils.configure_logging(logging_level=logging_level, +                                                    logger=logger, +                                                    stream=log_stream) +        self._log = logger +        self._log_stream = log_stream + +    def tearDown(self): +        """Reset logging to its original state. + +        This method ensures that the logging configuration set up +        for a unit test does not affect logging in other unit tests. + +        """ +        logger = self._log +        for handler in self._handlers: +            logger.removeHandler(handler) + +    def _assert_log_messages(self, messages): +        """Assert that the logged messages equal the given messages.""" +        self._log_stream.assertMessages(messages) + + +class ConfigureLoggingTest(ConfigureLoggingTestBase): + +    """Tests configure_logging() with the default logging level.""" + +    def _logging_level(self): +        return None + +    def test_info_message(self): +        self._log.info("test message") +        self._assert_log_messages(["unittest: [INFO] test message\n"]) + +    def test_below_threshold_message(self): +        # We test the boundary case of a logging level equal to 19. +        # In practice, we will probably only be calling log.debug(), +        # which corresponds to a logging level of 10. +        level = logging.INFO - 1  # Equals 19. +        self._log.log(level, "test message") +        self._assert_log_messages([]) + +    def test_two_messages(self): +        self._log.info("message1") +        self._log.info("message2") +        self._assert_log_messages(["unittest: [INFO] message1\n", +                                   "unittest: [INFO] message2\n"]) + + +class ConfigureLoggingCustomLevelTest(ConfigureLoggingTestBase): + +    """Tests configure_logging() with a custom logging level.""" + +    _level = 36 + +    def _logging_level(self): +        return self._level + +    def test_logged_message(self): +        self._log.log(self._level, "test message") +        self._assert_log_messages(["unittest: [Level 36] test message\n"]) + +    def test_below_threshold_message(self): +        self._log.log(self._level - 1, "test message") +        self._assert_log_messages([]) diff --git a/Tools/Scripts/webkitpy/common/system/ospath.py b/Tools/Scripts/webkitpy/common/system/ospath.py new file mode 100644 index 0000000..aed7a3d --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/ospath.py @@ -0,0 +1,83 @@ +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Contains a substitute for Python 2.6's os.path.relpath().""" + +import os + + +# This function is a replacement for os.path.relpath(), which is only +# available in Python 2.6: +# +# http://docs.python.org/library/os.path.html#os.path.relpath +# +# It should behave essentially the same as os.path.relpath(), except for +# returning None on paths not contained in abs_start_path. +def relpath(path, start_path, os_path_abspath=None): +    """Return a path relative to the given start path, or None. + +    Returns None if the path is not contained in the directory start_path. + +    Args: +      path: An absolute or relative path to convert to a relative path. +      start_path: The path relative to which the given path should be +                  converted. +      os_path_abspath: A replacement function for unit testing.  This +                       function should strip trailing slashes just like +                       os.path.abspath().  Defaults to os.path.abspath. + +    """ +    if os_path_abspath is None: +        os_path_abspath = os.path.abspath + +    # Since os_path_abspath() calls os.path.normpath()-- +    # +    # (see http://docs.python.org/library/os.path.html#os.path.abspath ) +    # +    # it also removes trailing slashes and converts forward and backward +    # slashes to the preferred slash os.sep. +    start_path = os_path_abspath(start_path) +    path = os_path_abspath(path) + +    if not path.lower().startswith(start_path.lower()): +        # Then path is outside the directory given by start_path. +        return None + +    rel_path = path[len(start_path):] + +    if not rel_path: +        # Then the paths are the same. +        pass +    elif rel_path[0] == os.sep: +        # It is probably sufficient to remove just the first character +        # since os.path.normpath() collapses separators, but we use +        # lstrip() just to be sure. +        rel_path = rel_path.lstrip(os.sep) +    else: +        # We are in the case typified by the following example: +        # +        # start_path = "/tmp/foo" +        # path = "/tmp/foobar" +        # rel_path = "bar" +        return None + +    return rel_path diff --git a/Tools/Scripts/webkitpy/common/system/ospath_unittest.py b/Tools/Scripts/webkitpy/common/system/ospath_unittest.py new file mode 100644 index 0000000..d84c2c6 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/ospath_unittest.py @@ -0,0 +1,62 @@ +# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1.  Redistributions of source code must retain the above copyright +#     notice, this list of conditions and the following disclaimer. +# 2.  Redistributions in binary form must reproduce the above copyright +#     notice, this list of conditions and the following disclaimer in the +#     documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for ospath.py.""" + +import os +import unittest + +from webkitpy.common.system.ospath import relpath + + +# Make sure the tests in this class are platform independent. +class RelPathTest(unittest.TestCase): + +    """Tests relpath().""" + +    os_path_abspath = lambda self, path: path + +    def _rel_path(self, path, abs_start_path): +        return relpath(path, abs_start_path, self.os_path_abspath) + +    def test_same_path(self): +        rel_path = self._rel_path("WebKit", "WebKit") +        self.assertEquals(rel_path, "") + +    def test_long_rel_path(self): +        start_path = "WebKit" +        expected_rel_path = os.path.join("test", "Foo.txt") +        path = os.path.join(start_path, expected_rel_path) + +        rel_path = self._rel_path(path, start_path) +        self.assertEquals(expected_rel_path, rel_path) + +    def test_none_rel_path(self): +        """Test _rel_path() with None return value.""" +        start_path = "WebKit" +        path = os.path.join("other_dir", "foo.txt") + +        rel_path = self._rel_path(path, start_path) +        self.assertTrue(rel_path is None) + +        rel_path = self._rel_path("Tools", "WebKit") +        self.assertTrue(rel_path is None) diff --git a/Tools/Scripts/webkitpy/common/system/outputcapture.py b/Tools/Scripts/webkitpy/common/system/outputcapture.py new file mode 100644 index 0000000..45e0e3f --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/outputcapture.py @@ -0,0 +1,86 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Class for unittest support.  Used for capturing stderr/stdout. + +import sys +import unittest +from StringIO import StringIO + +class OutputCapture(object): +    def __init__(self): +        self.saved_outputs = dict() + +    def _capture_output_with_name(self, output_name): +        self.saved_outputs[output_name] = getattr(sys, output_name) +        captured_output = StringIO() +        setattr(sys, output_name, captured_output) +        return captured_output + +    def _restore_output_with_name(self, output_name): +        captured_output = getattr(sys, output_name).getvalue() +        setattr(sys, output_name, self.saved_outputs[output_name]) +        del self.saved_outputs[output_name] +        return captured_output + +    def capture_output(self): +        return (self._capture_output_with_name("stdout"), self._capture_output_with_name("stderr")) + +    def restore_output(self): +        return (self._restore_output_with_name("stdout"), self._restore_output_with_name("stderr")) + +    def assert_outputs(self, testcase, function, args=[], kwargs={}, expected_stdout="", expected_stderr="", expected_exception=None): +        self.capture_output() +        if expected_exception: +            return_value = testcase.assertRaises(expected_exception, function, *args, **kwargs) +        else: +            return_value = function(*args, **kwargs) +        (stdout_string, stderr_string) = self.restore_output() +        testcase.assertEqual(stdout_string, expected_stdout) +        testcase.assertEqual(stderr_string, expected_stderr) +        # This is a little strange, but I don't know where else to return this information. +        return return_value + + +class OutputCaptureTestCaseBase(unittest.TestCase): +    def setUp(self): +        unittest.TestCase.setUp(self) +        self.output_capture = OutputCapture() +        (self.__captured_stdout, self.__captured_stderr) = self.output_capture.capture_output() + +    def tearDown(self): +        del self.__captured_stdout +        del self.__captured_stderr +        self.output_capture.restore_output() +        unittest.TestCase.tearDown(self) + +    def assertStdout(self, expected_stdout): +        self.assertEquals(expected_stdout, self.__captured_stdout.getvalue()) + +    def assertStderr(self, expected_stderr): +        self.assertEquals(expected_stderr, self.__captured_stderr.getvalue()) diff --git a/Tools/Scripts/webkitpy/common/system/path.py b/Tools/Scripts/webkitpy/common/system/path.py new file mode 100644 index 0000000..09787d7 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/path.py @@ -0,0 +1,138 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""generic routines to convert platform-specific paths to URIs.""" +from __future__ import with_statement + +import atexit +import subprocess +import sys +import threading +import urllib + + +def abspath_to_uri(path, platform=None): +    """Converts a platform-specific absolute path to a file: URL.""" +    if platform is None: +        platform = sys.platform +    return "file:" + _escape(_convert_path(path, platform)) + + +def cygpath(path): +    """Converts an absolute cygwin path to an absolute Windows path.""" +    return _CygPath.convert_using_singleton(path) + + +# Note that this object is not threadsafe and must only be called +# from multiple threads under protection of a lock (as is done in cygpath()) +class _CygPath(object): +    """Manages a long-running 'cygpath' process for file conversion.""" +    _lock = None +    _singleton = None + +    @staticmethod +    def stop_cygpath_subprocess(): +        if not _CygPath._lock: +            return + +        with _CygPath._lock: +            if _CygPath._singleton: +                _CygPath._singleton.stop() + +    @staticmethod +    def convert_using_singleton(path): +        if not _CygPath._lock: +            _CygPath._lock = threading.Lock() + +        with _CygPath._lock: +            if not _CygPath._singleton: +                _CygPath._singleton = _CygPath() +                # Make sure the cygpath subprocess always gets shutdown cleanly. +                atexit.register(_CygPath.stop_cygpath_subprocess) + +            return _CygPath._singleton.convert(path) + +    def __init__(self): +        self._child_process = None + +    def start(self): +        assert(self._child_process is None) +        args = ['cygpath', '-f', '-', '-wa'] +        self._child_process = subprocess.Popen(args, +                                               stdin=subprocess.PIPE, +                                               stdout=subprocess.PIPE) + +    def is_running(self): +        if not self._child_process: +            return False +        return self._child_process.returncode is None + +    def stop(self): +        if self._child_process: +            self._child_process.stdin.close() +            self._child_process.wait() +        self._child_process = None + +    def convert(self, path): +        if not self.is_running(): +            self.start() +        self._child_process.stdin.write("%s\r\n" % path) +        self._child_process.stdin.flush() +        windows_path = self._child_process.stdout.readline().rstrip() +        # Some versions of cygpath use lowercase drive letters while others +        # use uppercase. We always convert to uppercase for consistency. +        windows_path = '%s%s' % (windows_path[0].upper(), windows_path[1:]) +        return windows_path + + +def _escape(path): +    """Handle any characters in the path that should be escaped.""" +    # FIXME: web browsers don't appear to blindly quote every character +    # when converting filenames to files. Instead of using urllib's default +    # rules, we allow a small list of other characters through un-escaped. +    # It's unclear if this is the best possible solution. +    return urllib.quote(path, safe='/+:') + + +def _convert_path(path, platform): +    """Handles any os-specific path separators, mappings, etc.""" +    if platform == 'win32': +        return _winpath_to_uri(path) +    if platform == 'cygwin': +        return _winpath_to_uri(cygpath(path)) +    return _unixypath_to_uri(path) + + +def _winpath_to_uri(path): +    """Converts a window absolute path to a file: URL.""" +    return "///" + path.replace("\\", "/") + + +def _unixypath_to_uri(path): +    """Converts a unix-style path to a file: URL.""" +    return "//" + path diff --git a/Tools/Scripts/webkitpy/common/system/path_unittest.py b/Tools/Scripts/webkitpy/common/system/path_unittest.py new file mode 100644 index 0000000..4dbd38a --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/path_unittest.py @@ -0,0 +1,105 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest +import sys + +import path + +class AbspathTest(unittest.TestCase): +    def assertMatch(self, test_path, expected_uri, +                    platform=None): +        if platform == 'cygwin' and sys.platform != 'cygwin': +            return +        self.assertEqual(path.abspath_to_uri(test_path, platform=platform), +                         expected_uri) + +    def test_abspath_to_uri_cygwin(self): +        if sys.platform != 'cygwin': +            return + +        self.assertMatch('/cygdrive/c/foo/bar.html', +                         'file:///C:/foo/bar.html', +                         platform='cygwin') +        self.assertEqual(path.abspath_to_uri('/cygdrive/c/foo/bar.html', +                                             platform='cygwin'), +                         'file:///C:/foo/bar.html') + +    def test_abspath_to_uri_darwin(self): +        self.assertMatch('/foo/bar.html', +                         'file:///foo/bar.html', +                         platform='darwin') +        self.assertEqual(path.abspath_to_uri("/foo/bar.html", +                                             platform='darwin'), +                         "file:///foo/bar.html") + +    def test_abspath_to_uri_linux2(self): +        self.assertMatch('/foo/bar.html', +                         'file:///foo/bar.html', +                         platform='darwin') +        self.assertEqual(path.abspath_to_uri("/foo/bar.html", +                                             platform='linux2'), +                         "file:///foo/bar.html") + +    def test_abspath_to_uri_win(self): +        self.assertMatch('c:\\foo\\bar.html', +                         'file:///c:/foo/bar.html', +                         platform='win32') +        self.assertEqual(path.abspath_to_uri("c:\\foo\\bar.html", +                                             platform='win32'), +                         "file:///c:/foo/bar.html") + +    def test_abspath_to_uri_escaping(self): +        self.assertMatch('/foo/bar + baz%?.html', +                         'file:///foo/bar%20+%20baz%25%3F.html', +                         platform='darwin') +        self.assertMatch('/foo/bar + baz%?.html', +                         'file:///foo/bar%20+%20baz%25%3F.html', +                         platform='linux2') + +        # Note that you can't have '?' in a filename on windows. +        self.assertMatch('/cygdrive/c/foo/bar + baz%.html', +                         'file:///C:/foo/bar%20+%20baz%25.html', +                         platform='cygwin') + +    def test_stop_cygpath_subprocess(self): +        if sys.platform != 'cygwin': +            return + +        # Call cygpath to ensure the subprocess is running. +        path.cygpath("/cygdrive/c/foo.txt") +        self.assertTrue(path._CygPath._singleton.is_running()) + +        # Stop it. +        path._CygPath.stop_cygpath_subprocess() + +        # Ensure that it is stopped. +        self.assertFalse(path._CygPath._singleton.is_running()) + +if __name__ == '__main__': +    unittest.main() diff --git a/Tools/Scripts/webkitpy/common/system/platforminfo.py b/Tools/Scripts/webkitpy/common/system/platforminfo.py new file mode 100644 index 0000000..cc370ba --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/platforminfo.py @@ -0,0 +1,43 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import platform + + +# We use this instead of calls to platform directly to allow mocking. +class PlatformInfo(object): + +    def display_name(self): +        # platform.platform() returns Darwin information for Mac, which is just confusing. +        if platform.system() == "Darwin": +            return "Mac OS X %s" % platform.mac_ver()[0] + +        # Returns strings like: +        # Linux-2.6.18-194.3.1.el5-i686-with-redhat-5.5-Final +        # Windows-2008ServerR2-6.1.7600 +        return platform.platform() diff --git a/Tools/Scripts/webkitpy/common/system/user.py b/Tools/Scripts/webkitpy/common/system/user.py new file mode 100644 index 0000000..b79536c --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/user.py @@ -0,0 +1,143 @@ +# Copyright (c) 2009, Google Inc. All rights reserved. +#  +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +#  +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +#  +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import logging +import os +import re +import shlex +import subprocess +import sys +import webbrowser + + +_log = logging.getLogger("webkitpy.common.system.user") + + +try: +    import readline +except ImportError: +    if sys.platform != "win32": +        # There is no readline module for win32, not much to do except cry. +        _log.warn("Unable to import readline.") +    # FIXME: We could give instructions for non-mac platforms. +    # Lack of readline results in a very bad user experiance. +    if sys.platform == "darwin": +        _log.warn("If you're using MacPorts, try running:") +        _log.warn("  sudo port install py25-readline") + + +class User(object): +    DEFAULT_NO = 'n' +    DEFAULT_YES = 'y' + +    # FIXME: These are @classmethods because bugzilla.py doesn't have a Tool object (thus no User instance). +    @classmethod +    def prompt(cls, message, repeat=1, raw_input=raw_input): +        response = None +        while (repeat and not response): +            repeat -= 1 +            response = raw_input(message) +        return response + +    @classmethod +    def prompt_with_list(cls, list_title, list_items, can_choose_multiple=False, raw_input=raw_input): +        print list_title +        i = 0 +        for item in list_items: +            i += 1 +            print "%2d. %s" % (i, item) + +        # Loop until we get valid input +        while True: +            if can_choose_multiple: +                response = cls.prompt("Enter one or more numbers (comma-separated), or \"all\": ", raw_input=raw_input) +                if not response.strip() or response == "all": +                    return list_items +                try: +                    indices = [int(r) - 1 for r in re.split("\s*,\s*", response)] +                except ValueError, err: +                    continue +                return [list_items[i] for i in indices] +            else: +                try: +                    result = int(cls.prompt("Enter a number: ", raw_input=raw_input)) - 1 +                except ValueError, err: +                    continue +                return list_items[result] + +    def edit(self, files): +        editor = os.environ.get("EDITOR") or "vi" +        args = shlex.split(editor) +        # Note: Not thread safe: http://bugs.python.org/issue2320 +        subprocess.call(args + files) + +    def _warn_if_application_is_xcode(self, edit_application): +        if "Xcode" in edit_application: +            print "Instead of using Xcode.app, consider using EDITOR=\"xed --wait\"." + +    def edit_changelog(self, files): +        edit_application = os.environ.get("CHANGE_LOG_EDIT_APPLICATION") +        if edit_application and sys.platform == "darwin": +            # On Mac we support editing ChangeLogs using an application. +            args = shlex.split(edit_application) +            print "Using editor in the CHANGE_LOG_EDIT_APPLICATION environment variable." +            print "Please quit the editor application when done editing." +            self._warn_if_application_is_xcode(edit_application) +            subprocess.call(["open", "-W", "-n", "-a"] + args + files) +            return +        self.edit(files) + +    def page(self, message): +        pager = os.environ.get("PAGER") or "less" +        try: +            # Note: Not thread safe: http://bugs.python.org/issue2320 +            child_process = subprocess.Popen([pager], stdin=subprocess.PIPE) +            child_process.communicate(input=message) +        except IOError, e: +            pass + +    def confirm(self, message=None, default=DEFAULT_YES, raw_input=raw_input): +        if not message: +            message = "Continue?" +        choice = {'y': 'Y/n', 'n': 'y/N'}[default] +        response = raw_input("%s [%s]: " % (message, choice)) +        if not response: +            response = default +        return response.lower() == 'y' + +    def can_open_url(self): +        try: +            webbrowser.get() +            return True +        except webbrowser.Error, e: +            return False + +    def open_url(self, url): +        if not self.can_open_url(): +            _log.warn("Failed to open %s" % url) +        webbrowser.open(url) diff --git a/Tools/Scripts/webkitpy/common/system/user_unittest.py b/Tools/Scripts/webkitpy/common/system/user_unittest.py new file mode 100644 index 0000000..7ec9b34 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/system/user_unittest.py @@ -0,0 +1,109 @@ +# Copyright (C) 2010 Research in Motion Ltd. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Research in Motion Ltd. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.system.outputcapture import OutputCapture +from webkitpy.common.system.user import User + +class UserTest(unittest.TestCase): + +    example_user_response = "example user response" + +    def test_prompt_repeat(self): +        self.repeatsRemaining = 2 +        def mock_raw_input(message): +            self.repeatsRemaining -= 1 +            if not self.repeatsRemaining: +                return UserTest.example_user_response +            return None +        self.assertEqual(User.prompt("input", repeat=self.repeatsRemaining, raw_input=mock_raw_input), UserTest.example_user_response) + +    def test_prompt_when_exceeded_repeats(self): +        self.repeatsRemaining = 2 +        def mock_raw_input(message): +            self.repeatsRemaining -= 1 +            return None +        self.assertEqual(User.prompt("input", repeat=self.repeatsRemaining, raw_input=mock_raw_input), None) + +    def test_prompt_with_list(self): +        def run_prompt_test(inputs, expected_result, can_choose_multiple=False): +            def mock_raw_input(message): +                return inputs.pop(0) +            output_capture = OutputCapture() +            actual_result = output_capture.assert_outputs( +                self, +                User.prompt_with_list, +                args=["title", ["foo", "bar"]], +                kwargs={"can_choose_multiple": can_choose_multiple, "raw_input": mock_raw_input}, +                expected_stdout="title\n 1. foo\n 2. bar\n") +            self.assertEqual(actual_result, expected_result) +            self.assertEqual(len(inputs), 0) + +        run_prompt_test(["1"], "foo") +        run_prompt_test(["badinput", "2"], "bar") + +        run_prompt_test(["1,2"], ["foo", "bar"], can_choose_multiple=True) +        run_prompt_test(["  1,  2   "], ["foo", "bar"], can_choose_multiple=True) +        run_prompt_test(["all"], ["foo", "bar"], can_choose_multiple=True) +        run_prompt_test([""], ["foo", "bar"], can_choose_multiple=True) +        run_prompt_test(["  "], ["foo", "bar"], can_choose_multiple=True) +        run_prompt_test(["badinput", "all"], ["foo", "bar"], can_choose_multiple=True) + +    def test_confirm(self): +        test_cases = ( +            (("Continue? [Y/n]: ", True), (User.DEFAULT_YES, 'y')), +            (("Continue? [Y/n]: ", False), (User.DEFAULT_YES, 'n')), +            (("Continue? [Y/n]: ", True), (User.DEFAULT_YES, '')), +            (("Continue? [Y/n]: ", False), (User.DEFAULT_YES, 'q')), +            (("Continue? [y/N]: ", True), (User.DEFAULT_NO, 'y')), +            (("Continue? [y/N]: ", False), (User.DEFAULT_NO, 'n')), +            (("Continue? [y/N]: ", False), (User.DEFAULT_NO, '')), +            (("Continue? [y/N]: ", False), (User.DEFAULT_NO, 'q')), +        ) +        for test_case in test_cases: +            expected, inputs = test_case + +            def mock_raw_input(message): +                self.assertEquals(expected[0], message) +                return inputs[1] + +            result = User().confirm(default=inputs[0], +                                    raw_input=mock_raw_input) +            self.assertEquals(expected[1], result) + +    def test_warn_if_application_is_xcode(self): +        output = OutputCapture() +        user = User() +        output.assert_outputs(self, user._warn_if_application_is_xcode, ["TextMate"]) +        output.assert_outputs(self, user._warn_if_application_is_xcode, ["/Applications/TextMate.app"]) +        output.assert_outputs(self, user._warn_if_application_is_xcode, ["XCode"])  # case sensitive matching + +        xcode_warning = "Instead of using Xcode.app, consider using EDITOR=\"xed --wait\".\n" +        output.assert_outputs(self, user._warn_if_application_is_xcode, ["Xcode"], expected_stdout=xcode_warning) +        output.assert_outputs(self, user._warn_if_application_is_xcode, ["/Developer/Applications/Xcode.app"], expected_stdout=xcode_warning) diff --git a/Tools/Scripts/webkitpy/common/thread/__init__.py b/Tools/Scripts/webkitpy/common/thread/__init__.py new file mode 100644 index 0000000..ef65bee --- /dev/null +++ b/Tools/Scripts/webkitpy/common/thread/__init__.py @@ -0,0 +1 @@ +# Required for Python to search this directory for module files diff --git a/Tools/Scripts/webkitpy/common/thread/messagepump.py b/Tools/Scripts/webkitpy/common/thread/messagepump.py new file mode 100644 index 0000000..0e39285 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/thread/messagepump.py @@ -0,0 +1,59 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +class MessagePumpDelegate(object): +    def schedule(self, interval, callback): +        raise NotImplementedError, "subclasses must implement" + +    def message_available(self, message): +        raise NotImplementedError, "subclasses must implement" + +    def final_message_delivered(self): +        raise NotImplementedError, "subclasses must implement" + + +class MessagePump(object): +    interval = 10 # seconds + +    def __init__(self, delegate, message_queue): +        self._delegate = delegate +        self._message_queue = message_queue +        self._schedule() + +    def _schedule(self): +        self._delegate.schedule(self.interval, self._callback) + +    def _callback(self): +        (messages, is_running) = self._message_queue.take_all() +        for message in messages: +            self._delegate.message_available(message) +        if not is_running: +            self._delegate.final_message_delivered() +            return +        self._schedule() diff --git a/Tools/Scripts/webkitpy/common/thread/messagepump_unittest.py b/Tools/Scripts/webkitpy/common/thread/messagepump_unittest.py new file mode 100644 index 0000000..f731db2 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/thread/messagepump_unittest.py @@ -0,0 +1,83 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.thread.messagepump import MessagePump, MessagePumpDelegate +from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue + + +class TestDelegate(MessagePumpDelegate): +    def __init__(self): +        self.log = [] + +    def schedule(self, interval, callback): +        self.callback = callback +        self.log.append("schedule") + +    def message_available(self, message): +        self.log.append("message_available: %s" % message) + +    def final_message_delivered(self): +        self.log.append("final_message_delivered") + + +class MessagePumpTest(unittest.TestCase): + +    def test_basic(self): +        queue = ThreadedMessageQueue() +        delegate = TestDelegate() +        pump = MessagePump(delegate, queue) +        self.assertEqual(delegate.log, [ +            'schedule' +        ]) +        delegate.callback() +        queue.post("Hello") +        queue.post("There") +        delegate.callback() +        self.assertEqual(delegate.log, [ +            'schedule', +            'schedule', +            'message_available: Hello', +            'message_available: There', +            'schedule' +        ]) +        queue.post("More") +        queue.post("Messages") +        queue.stop() +        delegate.callback() +        self.assertEqual(delegate.log, [ +            'schedule', +            'schedule', +            'message_available: Hello', +            'message_available: There', +            'schedule', +            'message_available: More', +            'message_available: Messages', +            'final_message_delivered' +        ]) diff --git a/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue.py b/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue.py new file mode 100644 index 0000000..17b6277 --- /dev/null +++ b/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue.py @@ -0,0 +1,54 @@ +# Copyright (c) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#     * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#     * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#     * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import with_statement + +import threading + + +class ThreadedMessageQueue(object): +    def __init__(self): +        self._messages = [] +        self._is_running = True +        self._lock = threading.Lock() + +    def post(self, message): +        with self._lock: +            self._messages.append(message) + +    def stop(self): +        with self._lock: +            self._is_running = False + +    def take_all(self): +        with self._lock: +            messages = self._messages +            is_running = self._is_running +            self._messages = [] +        return (messages, is_running) + diff --git a/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py b/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py new file mode 100644 index 0000000..cb67c1e --- /dev/null +++ b/Tools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py @@ -0,0 +1,53 @@ +# Copyright (C) 2010 Google Inc. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +#    * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +#    * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +#    * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import unittest + +from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue + +class ThreadedMessageQueueTest(unittest.TestCase): + +    def test_basic(self): +        queue = ThreadedMessageQueue() +        queue.post("Hello") +        queue.post("There") +        (messages, is_running) = queue.take_all() +        self.assertEqual(messages, ["Hello", "There"]) +        self.assertTrue(is_running) +        (messages, is_running) = queue.take_all() +        self.assertEqual(messages, []) +        self.assertTrue(is_running) +        queue.post("More") +        queue.stop() +        queue.post("Messages") +        (messages, is_running) = queue.take_all() +        self.assertEqual(messages, ["More", "Messages"]) +        self.assertFalse(is_running) +        (messages, is_running) = queue.take_all() +        self.assertEqual(messages, []) +        self.assertFalse(is_running)  | 
