diff --git a/.gitignore b/.gitignore index d712718..df6b755 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ venv* .tox /pip-selfcheck.json /man +.eggs diff --git a/svn/common.py b/svn/common.py index e3dcda6..402b7b9 100644 --- a/svn/common.py +++ b/svn/common.py @@ -20,6 +20,9 @@ 'type', 'revision', ]) +LOG_ENTRY = collections.namedtuple( + 'LogEntry', + ('date', 'msg', 'revision', 'author', 'changelist')) class CommonClient(svn.common_base.CommonBase): @@ -157,6 +160,11 @@ def properties(self, rel_path=None): # query the proper list of this path root = xml.etree.ElementTree.fromstring(result) target_elem = root.find('target') + + # check for empty property list + if target_elem is None: + return {} + property_names = [p.attrib["name"] for p in target_elem.findall('property')] @@ -185,7 +193,7 @@ def cat(self, rel_filepath, revision=None): def log_default(self, timestamp_from_dt=None, timestamp_to_dt=None, limit=None, rel_filepath=None, stop_on_copy=False, revision_from=None, revision_to=None, changelist=False, - use_merge_history=False): + use_merge_history=False, search=None): """Allow for the most-likely kind of log listing: the complete list, a FROM and TO timestamp, a FROM timestamp only, or a quantity limit. """ @@ -233,6 +241,9 @@ def log_default(self, timestamp_from_dt=None, timestamp_to_dt=None, if stop_on_copy is True: args += ['--stop-on-copy'] + if search is not None: + args += ['--search', search] + if use_merge_history is True: args += ['--use-merge-history'] @@ -245,10 +256,6 @@ def log_default(self, timestamp_from_dt=None, timestamp_to_dt=None, do_combine=True) root = xml.etree.ElementTree.fromstring(result) - named_fields = ['date', 'msg', 'revision', 'author', 'changelist'] - c = collections.namedtuple( - 'LogEntry', - named_fields) # Merge history can create nested log entries, so use iter instead of findall for e in root.iter('logentry'): @@ -275,7 +282,7 @@ def log_default(self, timestamp_from_dt=None, timestamp_to_dt=None, else: log_entry['changelist'] = None - yield c(**log_entry) + yield LOG_ENTRY(**log_entry) def export(self, to_path, revision=None, force=False): cmd = [] @@ -287,20 +294,64 @@ def export(self, to_path, revision=None, force=False): cmd.append('--force') if force else None self.run_command('export', cmd) + + def blame(self, rel_filepath, + revision_from=None, revision_to=None): + """ + svn usage: blame [-r M:N] TARGET[@REV] + """ + + full_url_or_path = self.__url_or_path + "/" + rel_filepath + + args = [] + if revision_from or revision_to: + if not revision_from: + revision_from = '1' + + if not revision_to: + revision_to = 'HEAD' - def status(self, rel_path=None): + args += ['-r', str(revision_from) + ':' + str(revision_to)] + + result = self.run_command( + "blame", args + ["--xml", full_url_or_path], do_combine=True) + + root = xml.etree.ElementTree.fromstring(result) + + for entry in root.findall("target/entry"): + commit = entry.find("commit") + author = entry.find("commit/author") + date_ = entry.find("commit/date") + if author is None or date_ is None: + continue + info = { + 'line_number': int(entry.attrib['line-number']), + "commit_author": self.__element_text(author), + "commit_date": dateutil.parser.parse(date_.text), + "commit_revision": int(commit.attrib["revision"]), + } + yield info + + def status(self, rel_path=None, no_ignore=False, include_changelists=False): full_url_or_path = self.__url_or_path if rel_path is not None: full_url_or_path += '/' + rel_path + cmd = ['--xml', full_url_or_path] + if no_ignore: + cmd.append('--no-ignore') + raw = self.run_command( 'status', - ['--xml', full_url_or_path], + cmd, do_combine=True) root = xml.etree.ElementTree.fromstring(raw) list_ = root.findall('target/entry') + if include_changelists is True: + list_ += root.findall('changelist/entry') + for entry in list_: entry_attr = entry.attrib name = entry_attr['path'] @@ -454,13 +505,36 @@ def diff(self, old, new, rel_path=None): '--new', '{0}@{1}'.format(full_url_or_path, new)], do_combine=True) file_to_diff = {} + + # A diff has this form, potentially repeating for multiple files. + # + # Index: relative/filename.txt + # =================================================================== + # The diff content + # + # Here we split diffs into files by the index section, pick up the + # file name, then split again to pick up the content. for non_empty_diff in filter(None, diff_result.decode('utf8').split('Index: ')): - split_diff = non_empty_diff.split('==') - file_to_diff[split_diff[0].strip().strip('/')] = split_diff[-1].strip('=').strip() + split_diff = non_empty_diff.split('='*67) + index_filename = split_diff[0].strip().strip('/') + file_to_diff[index_filename] = split_diff[-1].strip() diff_summaries = self.diff_summary(old, new, rel_path) + + # Allocate summary info to the text for each change. + # Not all diffs necessarily affect file text (directory changes, for example). for diff_summary in diff_summaries: - diff_summary['diff'] = \ - file_to_diff[diff_summary['path'].split(full_url_or_path)[-1].strip('/')] + diff_summary['diff'] = '' + if 'path' in diff_summary: + # Summary file paths are absolute, while diff file indexes are relative. + # We try to match them up, first checking the root of the diff. + summary_index = diff_summary['path'][len(full_url_or_path):].strip('/') + # If the diff was conducted directly on the file, not a directory, the + # above will fail to find the relative file name, so we look for that directly. + if summary_index == '': + summary_index = os.path.basename(full_url_or_path) + # If we can match a diff to a summary, we attach it. + if summary_index in file_to_diff: + diff_summary['diff'] = file_to_diff[summary_index] return diff_summaries @property diff --git a/svn/common_base.py b/svn/common_base.py index 5e3db98..3808394 100644 --- a/svn/common_base.py +++ b/svn/common_base.py @@ -1,4 +1,5 @@ import os +import sys import subprocess import logging @@ -12,18 +13,28 @@ class CommonBase(object): def external_command(self, cmd, success_code=0, do_combine=False, return_binary=False, environment={}, wd=None): _LOGGER.debug("RUN: %s" % (cmd,)) + # print("RUN:", cmd) env = os.environ.copy() env['LANG'] = svn.config.CONSOLE_ENCODING env.update(environment) - p = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - cwd=wd, - env=env) - + kwargs = { + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + 'cwd': wd, + 'env': env, + } + + try: + # Don't open Command Prompt on Windows + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + kwargs['startupinfo'] = startupinfo + except AttributeError: + pass + + p = subprocess.Popen(cmd, **kwargs) stdout = p.stdout.read() r = p.wait() p.stdout.close() diff --git a/svn/local.py b/svn/local.py index bb03fd5..8c7b820 100644 --- a/svn/local.py +++ b/svn/local.py @@ -46,3 +46,13 @@ def cleanup(self): 'cleanup', [], wd=self.path) + + def propset(self, property_name, property_value, rel_path=None): + '''Set a property on a relative path''' + if rel_path is None: + rel_path = '.' + + self.run_command( + 'propset', + [property_name, property_value, rel_path], + wd=self.path) diff --git a/svn/resources/README.rst b/svn/resources/README.rst index bb51e9c..d52166e 100644 --- a/svn/resources/README.rst +++ b/svn/resources/README.rst @@ -1,5 +1,3 @@ -|donate| - |Build\_Status| |Coverage\_Status| @@ -22,11 +20,13 @@ Functions currently implemented: - cat - diff - diff_summary +- propset - status - add - commit - update - cleanup +- blame In addition, there is also an "admin" class (`svn.admin.Admin`) that provides a `create` method with which to create repositories. @@ -135,8 +135,8 @@ Get file-data as string:: l = svn.local.LocalClient('/tmp/test_repo') content = l.cat('test_file') -log_default(timestamp_from_dt=None, timestamp_to_dt=None, limit=None, rel_filepath='', stop_on_copy=False, revision_from=None, revision_to=None, changelist=False) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +log_default(timestamp_from_dt=None, timestamp_to_dt=None, limit=None, rel_filepath='', stop_on_copy=False, revision_from=None, revision_to=None, changelist=False, search=None) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Perform a log-listing that can be bounded by time or revision number and/or take a maximum- count:: @@ -280,9 +280,24 @@ diff(start_revision, end_revision) Finds all the diff between start and end revision id. Here another key of 'diff' is added which shows the diff of files. -.. |donate| image:: https://pledgie.com/campaigns/31718.png?skin_name=chrome - :alt: Click here to lend your support to: PySvn and make a donation at pledgie.com ! - :target: https://pledgie.com/campaigns/31718 +blame(rel_filepath, revision=None) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Blame specified file :: + + import svn.local + + l = svn.local.LocalClient('/tmp/test_repo.co') + + for line_info in l.blame("version.py"): + print(line_info) + + # { + # 'line_number': 1, + # 'commit_revision': 1222, + # 'commit_author': 'codeskyblue', + # 'commit_date': datetime.datetime(2018, 12, 20, 3, 25, 13, 479212, tzinfo=tzutc())} + .. |Build_Status| image:: https://travis-ci.org/dsoprea/PySvn.svg?branch=master :target: https://travis-ci.org/dsoprea/PySvn .. |Coverage_Status| image:: https://coveralls.io/repos/github/dsoprea/PySvn/badge.svg?branch=master diff --git a/tests/test_admin.py b/tests/test_admin.py index 9e9a7b6..b1d5477 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -35,7 +35,12 @@ def test_create_repository(self): a.create(temp_path) # Do a read. - rc = svn.remote.RemoteClient('file://' + temp_path) + # Duck-type identification of path type (Windows, Linux, etc.) to ensure + # file uri canonicity is enforced. URI must be file:///. + if temp_path[0] == '/': + rc = svn.remote.RemoteClient('file://' + temp_path) + else: + rc = svn.remote.RemoteClient('file:///' + temp_path) info = rc.info() _LOGGER.debug("Info from new repository: [%s]", str(info)) finally: diff --git a/tests/test_common.py b/tests/test_common.py index 444a514..2f4f264 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -57,7 +57,12 @@ def __setup_test_environment(self): self.__temp_co_path = self.__get_temp_path_to_use() _LOGGER.debug("CO_PATH: {}".format(self.__temp_co_path)) - r = svn.remote.RemoteClient('file://' + self.__temp_repo_path) + # Duck-type identification of path type (Windows, Linux, etc.) to ensure + # file uri canonicity is enforced. URI must be file:///. + if self.__temp_repo_path[0] == '/': + r = svn.remote.RemoteClient('file://' + self.__temp_repo_path) + else: + r = svn.remote.RemoteClient('file:///' + self.__temp_repo_path) r.checkout(self.__temp_co_path) # Create a client for it. @@ -79,7 +84,7 @@ def tearDown(self): _LOGGER.exception("Could not cleanup temporary repository path: [%s]", self.__temp_repo_path) def __stage_co_directory_1(self): - """Establish a new file, an added file, a committed file, and a changed file.""" + """Establish a new file, an added file, a committed file, a changed file, and an ignored file.""" # Create a file that will not be committed. @@ -117,6 +122,15 @@ def __stage_co_directory_1(self): self.__temp_lc.add(rel_filepath_deleted) + # Create a file that will be ignored + + rel_filepath_ignored = 'ignored' + filepath = os.path.join(self.__temp_co_path, rel_filepath_ignored) + with open(filepath, 'w') as f: + pass + + self.__temp_lc.propset('svn:ignore', 'ignored') + # Commit the new files. self.__temp_lc.commit("Initial commit.") @@ -143,11 +157,18 @@ def __stage_co_directory_1(self): self.__temp_lc.add(rel_filepath) + # Create a file that will not be added + + rel_filepath = 'unversioned' + filepath = os.path.join(self.__temp_co_path, rel_filepath) + with open(filepath, 'w') as f: + pass + def test_status(self): self.__stage_co_directory_1() status = {} - for s in self.__temp_lc.status(): + for s in self.__temp_lc.status(no_ignore=True): _LOGGER.debug("STATUS: %s", s) filename = os.path.basename(s.name) @@ -162,6 +183,27 @@ def test_status(self): committed_deleted = status['committed_deleted'] self.assertTrue(committed_deleted is not None and committed_deleted.type == svn.constants.ST_MISSING) + ignored = status['ignored'] + self.assertTrue(ignored is not None and ignored.type == svn.constants.ST_IGNORED) + + ignored = status['unversioned'] + self.assertTrue(ignored is not None and ignored.type == svn.constants.ST_UNVERSIONED) + + # Test that ignored files are hidden when no_ignore=False + status = {} + for s in self.__temp_lc.status(): + _LOGGER.debug("STATUS: %s", s) + + filename = os.path.basename(s.name) + status[filename] = s + + self.assertTrue('ignored' not in status) + + # ..but spot check that others are not hidden + added = status['added'] + self.assertTrue(added is not None and added.type == svn.constants.ST_ADDED) + + def test_update(self): self.__stage_co_directory_1() self.__temp_lc.commit("Second commit.") @@ -225,6 +267,18 @@ def test_diff(self): 'http://svn.apache.org/repos/asf/sling/trunk/pom.xml' \ in individual_diff[diff_key]) + def test_diff_separator(self): + """ + Checking diff + :return: + """ + cc = self.__get_cc() + change = '502054' + file_cluster_event = 'activemq/activecluster/trunk/src/main/java/org/apache/activecluster/ClusterEvent.java' + actual_answer = cc.diff('0', change, file_cluster_event)[0] + self.assertTrue('static final int UPDATE_NODE = 2' in actual_answer['diff']) + self.assertTrue('else if (type == FAILED_NODE)' in actual_answer['diff']) + def test_list(self): """ Checking list @@ -257,7 +311,15 @@ def test_info(self): self.assertEqual( actual_answer['repository/uuid'], '13f79535-47bb-0310-9956-ffa450edef68') - + + def test_blame(self): + cc = self.__get_cc() + actual_answer = cc.blame('abdera/abdera2/README', revision_to=1173209) + ans = next(actual_answer) + self.assertEqual(ans['line_number'], 1) + self.assertEqual(ans['commit_revision'], 1173209) + self.assertEqual(ans['commit_author'], 'jmsnell') + def test_info_revision(self): cc = self.__get_cc() actual_answer = cc.info(revision=1000000) @@ -274,6 +336,17 @@ def test_log(self): cc.log_default(revision_from=1761404, revision_to=1761403) self.assertEqual(next(actual_answer).author, 'sseifert') + def test_search(self): + """ + Checking log search + :return: + """ + + cc = self.__get_cc() + actual_answer = \ + cc.log_default(search='Get ready to tag httpd 2.4.34', revision_from=1835550, revision_to=1836000) + self.assertEqual(next(actual_answer).author, 'jim') + def test_cat(self): """ Checking cat @@ -316,3 +389,46 @@ def test_force_export(self): def test_cleanup(self): self.__temp_lc.cleanup() + + def test_properties_cc(self): + """ + '''Tests CommonClient.properties()''' + """ + cc = self.__get_cc() + props = cc.properties('activemq/activecluster/trunk/src/main/java/org/apache/activecluster@1808406') + self.assertFalse(bool(props)) + props = cc.properties('activemq/activecluster/trunk/src/main/java/org/apache/activecluster/Cluster.java@1808406') + self.assertEqual(props['svn:eol-style'], 'native') + + actual_answer = cc.properties(rel_path='whirr/tags/release-0.7.0/services/zookeeper') + self.assertDictEqual(actual_answer, {'svn:ignore': '\n'.join(['.project', '.classpath', '.settings', 'target', ''])}) + + def test_properties(self): + '''Tests CommonClient.properties() and LocalClient.propset()''' + l = self.__temp_lc + + # Test propset with rel_path omitted + self.assertTrue('prop1' not in l.properties()) + l.propset('prop1', 'value1') + self.assertTrue('prop1' in l.properties() and + l.properties()['prop1'] == 'value1') + + # Add a file + filepath = os.path.join(self.__temp_co_path, 'propfile2') + with open(filepath, 'w') as f: + pass + + l.add('propfile2') + + # properties should be empty until we add some + self.assertTrue(l.properties(rel_path='propfile2') == {}) + + # Test with rel_path='propfile2' + self.assertTrue('prop2' not in l.properties(rel_path='propfile2')) + l.propset('prop2', 'value2', rel_path='propfile2') + self.assertTrue('prop2' in l.properties(rel_path='propfile2') and + l.properties(rel_path='propfile2')['prop2'] == 'value2') + + # Cross-check + self.assertTrue('prop1' not in l.properties(rel_path='propfile2')) + self.assertTrue('prop2' not in l.properties())