diff --git a/Vagrantfile b/Vagrantfile new file mode 100755 index 0000000..34d5855 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,79 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +# All Vagrant configuration is done below. The "2" in Vagrant.configure +# configures the configuration version (we support older styles for +# backwards compatibility). Please don't change it unless you know what +# you're doing. +Vagrant.configure(2) do |config| + # The most common configuration options are documented and commented below. + # For a complete reference, please see the online documentation at + # https://docs.vagrantup.com. + + # Every Vagrant development environment requires a box. You can search for + # boxes at https://atlas.hashicorp.com/search. + config.vm.box = "dharmab/centos6" + + # Disable automatic box update checking. If you disable this, then + # boxes will only be checked for updates when the user runs + # `vagrant box outdated`. This is not recommended. + # config.vm.box_check_update = false + + # Create a forwarded port mapping which allows access to a specific port + # within the machine from a port on the host machine. In the example below, + # accessing "localhost:8080" will access port 80 on the guest machine. + # config.vm.network "forwarded_port", guest: 80, host: 8080 + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + # config.vm.network "private_network", ip: "192.168.33.10" + + # Create a public network, which generally matched to bridged network. + # Bridged networks make the machine appear as another physical device on + # your network. + # config.vm.network "public_network" + + # Share an additional folder to the guest VM. The first argument is + # the path on the host to the actual folder. The second argument is + # the path on the guest to mount the folder. And the optional third + # argument is a set of non-required options. + # config.vm.synced_folder "../data", "/vagrant_data" + + # Provider-specific configuration so you can fine-tune various + # backing providers for Vagrant. These expose provider-specific options. + # Example for VirtualBox: + # + # config.vm.provider "virtualbox" do |vb| + # # Display the VirtualBox GUI when booting the machine + # vb.gui = true + # + # # Customize the amount of memory on the VM: + # vb.memory = "1024" + # end + # + # View the documentation for the provider you are using for more + # information on available options. + + # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies + # such as FTP and Heroku are also available. See the documentation at + # https://docs.vagrantup.com/v2/push/atlas.html for more information. + # config.push.define "atlas" do |push| + # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME" + # end + + # Enable provisioning with a shell script. Additional provisioners such as + # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the + # documentation for more information about their specific syntax and use. + config.vm.provision "shell", inline: <<-SHELL + # create venv + sudo yum -y install python-virtualenv python-pip + sudo yum -y install python-virtualenvwrapper + mkvirtualenv --no-site-packages vcs # doesn't work + + # install perforce + sudo rpm --import https://package.perforce.com/perforce.pubkey + sudo yum install yum-utils + sudo yum-config-manager --add-repo=http://package.perforce.com/yum/rhel/6/x86_64 + sudo yum install helix-cli + SHELL +end diff --git a/docs/api/backends/p4.rst b/docs/api/backends/p4.rst new file mode 100755 index 0000000..eb630c7 --- /dev/null +++ b/docs/api/backends/p4.rst @@ -0,0 +1,21 @@ +.. _api-backends-p4: + +vcs.backends.p4 +=============== + +.. automodule:: vcs.backends.p4 + +P4Repository +------------------- + +.. autoclass:: vcs.backends.p4.P4Repository + :members: + +P4Changeset +------------------ + +.. autoclass:: vcs.backends.p4.P4Changeset + :members: + :inherited-members: + :undoc-members: + :show-inheritance: diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 44e9f95..e660fe3 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ "long_description (%s)\n" % readme_file) sys.exit(1) -install_requires = ['Pygments'] +install_requires = ['Pygments', 'pytz'] if sys.version_info < (2, 7): install_requires.append('unittest2') diff --git a/vcs/backends/p4/__init__.py b/vcs/backends/p4/__init__.py new file mode 100755 index 0000000..70df4d1 --- /dev/null +++ b/vcs/backends/p4/__init__.py @@ -0,0 +1,16 @@ +""" +Implements perforce backend. Uses command p4 -G to get all the info. + +Perforce streams not supported yet. Helix dvcs not supported yet. + +Used with server and client 2015.1. Older or newer versions may not work. +""" + +from .repository import P4Repository +from .changeset import P4Changeset + + + +__all__ = [ + 'P4Repository', 'P4Changeset' +] diff --git a/vcs/backends/p4/changeset.py b/vcs/backends/p4/changeset.py new file mode 100755 index 0000000..e700850 --- /dev/null +++ b/vcs/backends/p4/changeset.py @@ -0,0 +1,276 @@ +import datetime +import logging + +import pytz + +from vcs.backends.base import BaseChangeset +from vcs.utils.lazy import LazyProperty + +class P4Changeset(BaseChangeset): + """ + P4 changelist. Submitted CLs will be implemented first. + + **Attributes** + + ``repository`` + repository object within which changeset exists + + ``id`` + Changelist number (int) + + ``raw_id`` + same as id + + ``short_id`` + same as id + + ``revision`` + same as id + + ``files`` + list of ``FileNode`` (``Node`` with NodeKind.FILE) objects, TBD + + ``dirs`` + list of ``DirNode`` (``Node`` with NodeKind.DIR) objects, TBD + + ``nodes`` + combined list of ``Node`` objects, TBD + + ``author`` + author of the changeset, as unicode, TBD + + ``message`` + message of the changeset, as unicode + + ``parents`` + list of parent changesets, TBD + + ``last`` + ``True`` if this is last changeset in repository, ``False`` + otherwise; trying to access this attribute while there is no + changesets would raise ``EmptyRepositoryError``, TBD + + ``date`` + datetime object representing date and time of the submit. TZ aware. + + Added properties: + + ``raw_data`` + the raw dict returned by p4 lib or cmd + """ + def __init__(self, repository, changeset_dict): + """ + :param repository: repository object + :param changeset_dict: the raw dict returned by p4 cmd or lib + :return: + """ + self.repository = repository + self.revision = int(changeset_dict['change']) + self.short_id = self.revision + self.id = self.revision + + self.author = changeset_dict['user'] + self.message = changeset_dict['desc'] + + self.raw_data = changeset_dict + self.date = datetime.datetime.utcfromtimestamp(int(changeset_dict['time'])) # it is really in utc + self.date.replace(tzinfo=pytz.UTC) + self._describe_result = None + + @LazyProperty + def parents(self): + """ + Returns list of parents changesets. + """ + raise NotImplementedError + + @LazyProperty + def children(self): + """ + Returns list of children changesets. + """ + raise NotImplementedError + + @LazyProperty + def id(self): + """ + Returns string identifying this changeset. + """ + raise NotImplementedError + + @LazyProperty + def raw_id(self): + """ + Returns raw string identifying this changeset. + """ + raise NotImplementedError + + @LazyProperty + def short_id(self): + """ + Returns shortened version of ``raw_id`` attribute, as string, + identifying this changeset, useful for web representation. + """ + raise NotImplementedError + + @LazyProperty + def revision(self): + """ + Returns integer identifying this changeset. + + """ + raise NotImplementedError + + @LazyProperty + def committer(self): + """ + Returns Committer for given commit + """ + + raise NotImplementedError + + @LazyProperty + def committer_name(self): + """ + Returns Author name for given commit + """ + + return author_name(self.committer) + + @LazyProperty + def committer_email(self): + """ + Returns Author email address for given commit + """ + + return author_email(self.committer) + + @LazyProperty + def author(self): + """ + Returns Author for given commit + """ + + raise NotImplementedError + + + def get_file_mode(self, path): + """ + Returns stat mode of the file at the given ``path``. + """ + raise NotImplementedError + + def get_file_content(self, path): + """ + Returns content of the file at the given ``path``. + """ + raise NotImplementedError + + def get_file_size(self, path): + """ + Returns size of the file at the given ``path``. + """ + raise NotImplementedError + + def get_file_changeset(self, path): + """ + Returns last commit of the file at the given ``path``. + """ + raise NotImplementedError + + def get_file_history(self, path): + """ + Returns history of file as reversed list of ``Changeset`` objects for + which file at given ``path`` has been modified. + """ + raise NotImplementedError + + def get_nodes(self, path): + """ + Returns combined ``DirNode`` and ``FileNode`` objects list representing + state of changeset at the given ``path``. + + :raises ``ChangesetError``: if node at the given ``path`` is not + instance of ``DirNode`` + """ + raise NotImplementedError + + def get_node(self, path): + """ + Returns ``Node`` object from the given ``path``. + + :raises ``NodeDoesNotExistError``: if there is no node at the given + ``path`` + """ + raise NotImplementedError + + def fill_archive(self, stream=None, kind='tgz', prefix=None): + """ + Fills up given stream. + + :param stream: file like object. + :param kind: one of following: ``zip``, ``tar``, ``tgz`` + or ``tbz2``. Default: ``tgz``. + :param prefix: name of root directory in archive. + Default is repository name and changeset's raw_id joined with dash. + + repo-tip.<kind> + """ + + raise NotImplementedError + + def next(self, branch=None): + """ + Returns next changeset from current, if branch is gives it will return + next changeset belonging to this branch + + :param branch: show changesets within the given named branch + """ + raise NotImplementedError + + def prev(self, branch=None): + """ + Returns previous changeset from current, if branch is gives it will + return previous changeset belonging to this branch + + :param branch: show changesets within the given named branch + """ + raise NotImplementedError + + @LazyProperty + def added(self): + """ + Returns list of added ``FileNode`` objects. + """ + raise NotImplementedError + + @LazyProperty + def changed(self): + """ + Returns list of modified ``FileNode`` objects. + """ + raise NotImplementedError + + @LazyProperty + def removed(self): + """ + Returns list of removed ``FileNode`` objects. + """ + raise NotImplementedError + + def _describe(self): + if not self._describe_result: + self._describe_result = self.repository.repo.run(['describe', self.id]) + + if not len(self._describe_result) == 1: + logging.warning('describe returned something unexpected: %s', self._describe_result) + + return self._describe_result + + def affected_files(self): + try: + return self._describe()[-1]['depotFile'] + except: + logging.warning('No files in that changest %d', self.id) + return [] + diff --git a/vcs/backends/p4/common.py b/vcs/backends/p4/common.py new file mode 100755 index 0000000..3e49a32 --- /dev/null +++ b/vcs/backends/p4/common.py @@ -0,0 +1,152 @@ +import abc +import logging +import subprocess +import marshal + +import re + +import vcs.exceptions + +logger = logging.getLogger(__name__) + + +class P4(object): + __metaclass__ = abc.ABCMeta + + def __init__(self, user, passwd, port, client, env=None): + self.user = user + self.client = passwd + self.port = port + self.client = client + self.env = env + + @abc.abstractmethod + def run(self, args, input=None): + """ + + :param args: list of arguments, e.g. ['foo', 'bar'] + :param input: + :return: + """ + pass + + +class SubprocessP4(P4): + """A command-line based P4 class. + + Based on using 'p4 -G ...', which returns marshaled + python dictionaries as its output values, together with a 'code' key which may be 'stat', 'error', or 'info'. + The normal return case is 'stat'. Some fixup is required for things like array value results. + """ + ARRAY_KEY = re.compile(r'(\w+?)(\d+)$') + INT = re.compile(r'\d+$') + + def run(self, args, input=None, env=None): + logger.debug('Going to run p4 command %s', str(args)) + stdin_mode = subprocess.PIPE if input is not None else None + + p = subprocess.Popen(['p4', '-G'] + map(str, args), stdin=stdin_mode, stdout=subprocess.PIPE, + env=env or self.env) + + if input is not None: + input = SubprocessP4.encode_arrays(input) + marshal.dump(input, p.stdin, 0) # must specify version 0; http://kb.perforce.com/article/585/using-p4-g + p.stdin.close() + + result = [] + + while True: + try: + h = marshal.load(p.stdout) + if not isinstance(h, dict): + raise vcs.exceptions.CommandError('Command %r produced unmarshalled object of the wrong type: %r', + (['p4', '-G'] + map(str, args), h)) + result.append(h) + except EOFError: + break + + return SubprocessP4.post_process(result, args) + + @staticmethod + def post_process(result, args): + def append(l, v): + l.append(v) + + def process_dict(h): + code = h.get('code', None) + if code == 'error': + name = 'Error' if h['severity'] == 3 else 'Warning' + raise vcs.exceptions.CommandError('%ss during command execution( "p4 -G %s" )\n\n\t[%s]: %s\n' % + (name, ' '.join(map(str, args)), name, h['data'])) + elif code == 'info': + return h['data'], append + elif code == 'text': + # Mimic behavior of 'diff', + # e.g.: Result is a dict of clientFIle, depotFile, rev, text, then lines. + return h['data'], lambda l, v: l.extend(v.splitlines()) + elif code and code != 'stat': + raise vcs.exceptions.CommandError('ERROR: %r => %r' % (args, h)) # never happens + if code: + del h['code'] + + SubprocessP4.decode_arrays(h) + return h, append + + a = [] + for value, fn in map(process_dict, result): + fn(a, value) + return a + + @staticmethod + def encode_arrays(h): + if not isinstance(h, dict): + return h + keys_with_lists = [(key, value) for key, value in h.items() + if isinstance(value, (list, tuple))] + if not keys_with_lists: + return h + for key, valueList in keys_with_lists: + for i, value in enumerate(valueList): + indexed_key = '%s%d' % (key, i) + assert indexed_key not in h, 'Key %r already present in pre-marshaled dict: %r' % (indexed_key, h) + h[indexed_key] = value + del h[key] + return h + + @staticmethod + def decode_arrays(h): + """If there is an array of values, e.g. the result of [ p4 -G describe CHANGELIST ], then they are returned + as key0: value0, key1: value1, etc. Convert these to key: [value0, value1, ...].""" + keys_and_indices = [(m.group(1), int(m.group(2))) for key, m in + [(key, SubprocessP4.ARRAY_KEY.match(key)) for key in h.keys()] + if m] + if not keys_and_indices: + return h + key_range = {} + for key, index in keys_and_indices: + index_range = key_range.setdefault(key, [None, None]) + index_range[0] = index if index_range[0] is None else min(index_range[0], index) + index_range[1] = index if index_range[1] is None else max(index_range[1], index) + for key, index_range in key_range.items(): + # Handle otherOpen, otherChange, otherLock + if re.match(r'other[A-Z][a-z]*[a-rt-z]$', key): + plural_key = key + 's' + # fix special case (fstat) + # n.b.: sometimes the key value is '' instead of the array length (bug in p4???) + if key in h and plural_key not in h and (SubprocessP4.INT.match(h[key]) or h[key] == ''): + h[plural_key] = h[key] + del h[key] + assert key not in h, 'Key %r already present in unmarshaled dict: %r' % (key, h) + a = [] + for i in range(index_range[1]+1): + indexed_key = '%s%d' % (key, i) + if indexed_key in h: + a.append(h[indexed_key]) + del h[indexed_key] + else: + a.append(None) # n.b.: if zeroth entry is missing, p4 puts a None in + h[key] = a + return h + +def get_p4_class(): + return SubprocessP4 \ No newline at end of file diff --git a/vcs/backends/p4/repository.py b/vcs/backends/p4/repository.py new file mode 100755 index 0000000..f3b74f7 --- /dev/null +++ b/vcs/backends/p4/repository.py @@ -0,0 +1,275 @@ +import os + +import datetime + +from vcs.backends.base import BaseRepository +from .common import get_p4_class +from .changeset import P4Changeset +import vcs.exceptions +from vcs.utils.lazy import LazyProperty + +class P4Repository(BaseRepository): + """ + Base Repository for final backends + + ``repo`` + object from external api + + ``revisions`` + list of all available revisions' ids, in ascending order + + ``changesets`` + storage dict caching returned changesets + + ``path`` + absolute path to the repository + + ``branches`` + branches as list of changesets + + ``tags`` + tags as list of changesets + """ + scm = 'p4' + DEFAULT_BRANCH_NAME = None + + def __init__(self, repo_path, create=False, user=None, passwd=None, port=None, p4client=None): + """ + Initializes repository. Raises RepositoryError if repository could + not be find at the given ``repo_path`` or directory at ``repo_path`` + exists and ``create`` is set to True. + + :param repo_path: e.g. //depot/path/to/dir + :param create=False: if set to True, would try to sync to your workspace + :param p4user=None: Username for authorization on p4 server. If None, taken from env var P4USER + :param p4passwd=None: Password for authorization on p4 server. If None, taken from env var P4PASSWD + :param p4port=None Protocol, host and port of the p4 server, + e.g. ssl:perforce.mycompany.com:1667 If None, taken from env var P4PORT + :param p4client=None name of workspace to use for sync/write operations. + If not specified, taken from env var P4CLIENT. Not used yet. + """ + self.path = repo_path + + try: + user = user or os.environ['P4USER'] + passwd = passwd or os.environ['P4PASSWD'] + port = port or os.environ['P4PORT'] + except KeyError: + raise vcs.exceptions.RepositoryError('You have to specify user, password and port') + + client = p4client or os.environ.get('P4CLIENT') # this one isn't mandatory for read operations + + self.repo_path = repo_path + p4class = get_p4_class() + self.repo = p4class(user, passwd, port, client) + + def is_valid(self): + """ + Validates repository. + """ + raise NotImplementedError + + #========================================================================== + # CHANGESETS + #========================================================================== + + def get_changeset(self, revision=None): + """ + Returns instance of ``Changeset`` class. If ``revision`` is None, most + recent changeset is returned. + + :raises ``EmptyRepositoryError``: if there are no revisions + """ + if revision: + changesets = self.get_changesets(start=revision, end=revision) + + if not changesets: + raise vcs.exceptions.ChangesetDoesNotExistError('Changeset %d does not exit' % revision) + + return changesets[0] + else: + raise NotImplementedError('please specify the changeset id') + + def get_changesets(self, start=None, end=None, start_date=None, + end_date=None, branch_name=None, reverse=False): + """ + Returns iterator of ``P4Changeset`` objects from start to end + not inclusive This should behave just like a list, ie. end is not + inclusive + + :param start: None or int, TBD + :param end: None or int, should be bigger than start, TBD + :param start_date: instance of datetime or None + :param end_date: instance of datetime or None + :param branch_name: TBD + :param reversed: TBD + """ + # TODO + # do the previous command in cycle with modified start and end till you satisfy the start and end. + # it is better to have multiple requests with 1000 results than one with millions of results + + # http://stackoverflow.com/questions/17702785/python-generator-for-paged-api-resource + + STR_FORMAT = '%Y/%m/%d %H:%M:%S' + + if start and start_date: + raise vcs.exceptions.RepositoryError('both start and start_date specified') + + if start: + assert isinstance(start, int) + start = str(start) + elif start_date: + start = start_date.strftime(STR_FORMAT) + else: + start = 0 + + if end and end_date: + raise vcs.exceptions.RepositoryError('both end and end_date specified') + + if end: + assert isinstance(end, int) + end = str(end) + elif end_date: + end = end_date.strftime(STR_FORMAT) + else: + end = 'now' + + path_with_revspec = '{path}@{start},{end}'.format(path=self.repo_path, start=start, end=end) + + result = self.repo.run(['changes', '-l', '-s', 'submitted', path_with_revspec]) + result = [P4Changeset(self, cs) for cs in result] + + return result + + def tag(self, name, user, revision=None, message=None, date=None, **opts): + """ + Creates and returns a tag for the given ``revision``. + + :param name: name for new tag + :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>" + :param revision: changeset id for which new tag would be created + :param message: message of the tag's commit + :param date: date of tag's commit + + :raises TagAlreadyExistError: if tag with same name already exists + """ + raise NotImplementedError + + def remove_tag(self, name, user, message=None, date=None): + """ + Removes tag with the given ``name``. + + :param name: name of the tag to be removed + :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>" + :param message: message of the tag's removal commit + :param date: date of tag's removal commit + + :raises TagDoesNotExistError: if tag with given name does not exists + """ + raise NotImplementedError + + def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False, + context=3): + """ + Returns (git like) *diff*, as plain text. Shows changes introduced by + ``rev2`` since ``rev1``. + + :param rev1: Entry point from which diff is shown. Can be + ``self.EMPTY_CHANGESET`` - in this case, patch showing all + the changes since empty state of the repository until ``rev2`` + :param rev2: Until which revision changes should be shown. + :param ignore_whitespace: If set to ``True``, would not show whitespace + changes. Defaults to ``False``. + :param context: How many lines before/after changed lines should be + shown. Defaults to ``3``. + """ + raise NotImplementedError + + # ========== # + # COMMIT API # + # ========== # + + @LazyProperty + def in_memory_changeset(self): + """ + Returns ``InMemoryChangeset`` object for this repository. + """ + raise NotImplementedError + + def add(self, filenode, **kwargs): + """ + Commit api function that will add given ``FileNode`` into this + repository. + + :raises ``NodeAlreadyExistsError``: if there is a file with same path + already in repository + :raises ``NodeAlreadyAddedError``: if given node is already marked as + *added* + """ + raise NotImplementedError + + def remove(self, filenode, **kwargs): + """ + Commit api function that will remove given ``FileNode`` into this + repository. + + :raises ``EmptyRepositoryError``: if there are no changesets yet + :raises ``NodeDoesNotExistError``: if there is no file with given path + """ + raise NotImplementedError + + def commit(self, message, **kwargs): + """ + Persists current changes made on this repository and returns newly + created changeset. + + :raises ``NothingChangedError``: if no changes has been made + """ + raise NotImplementedError + + def get_state(self): + """ + Returns dictionary with ``added``, ``changed`` and ``removed`` lists + containing ``FileNode`` objects. + """ + raise NotImplementedError + + def get_config_value(self, section, name, config_file=None): + """ + Returns configuration value for a given [``section``] and ``name``. + + :param section: Section we want to retrieve value from + :param name: Name of configuration we want to retrieve + :param config_file: A path to file which should be used to retrieve + configuration from (might also be a list of file paths) + """ + raise NotImplementedError + + def get_user_name(self, config_file=None): + """ + Returns user's name from global configuration file. + + :param config_file: A path to file which should be used to retrieve + configuration from (might also be a list of file paths) + """ + raise NotImplementedError + + def get_user_email(self, config_file=None): + """ + Returns user's email from global configuration file. + + :param config_file: A path to file which should be used to retrieve + configuration from (might also be a list of file paths) + """ + raise NotImplementedError + + # =========== # + # WORKDIR API # + # =========== # + + @LazyProperty + def workdir(self): + """ + Returns ``Workdir`` instance for this repository. + """ + raise NotImplementedError \ No newline at end of file diff --git a/vcs/conf/settings.py b/vcs/conf/settings.py index d3271f4..eaca971 100644 --- a/vcs/conf/settings.py +++ b/vcs/conf/settings.py @@ -27,6 +27,7 @@ BACKENDS = { 'hg': 'vcs.backends.hg.MercurialRepository', 'git': 'vcs.backends.git.GitRepository', + 'p4': 'vcs.backends.p4.P4Repository' } ARCHIVE_SPECS = { diff --git a/vcs/tests/__init__.py b/vcs/tests/__init__.py index 5158bcf..10bd0cb 100644 --- a/vcs/tests/__init__.py +++ b/vcs/tests/__init__.py @@ -33,6 +33,7 @@ from test_getitem import * from test_getslice import * from test_git import * +from test_p4 import * from test_hg import * from test_inmemchangesets import * from test_nodes import * diff --git a/vcs/tests/test_p4.py b/vcs/tests/test_p4.py new file mode 100755 index 0000000..1e22360 --- /dev/null +++ b/vcs/tests/test_p4.py @@ -0,0 +1,57 @@ +import unittest +import logging + +import datetime + +from vcs.backends.p4.repository import P4Repository + +if True: # for msamia's purposes + TEST_P4_REPO = '//depot/Tools/p4sandbox/...' +else: + P4USER = 'vcs' + P4PORT = 'workshop.perforce.com:1666' + # see https://swarm.workshop.perforce.com/ + + + +class P4RepositoryTest(unittest.TestCase): + """ + These tests work only in Michel's repository, they should be changed so they first create some commits on + a sandbox server and then query these commits. + """ + + def setUp(self): + self.repo = P4Repository(TEST_P4_REPO) + logging.basicConfig(level=logging.DEBUG) + + def test_get_changelists_end_date_only(self): + CLs = self.repo.get_changesets(end_date=datetime.datetime(2014, 1, 1)) + + for cl in CLs: + logging.debug('%s, %s', cl, cl.date) + + self.assertEqual(len(CLs), 5) + + def test_get_changelists_range(self): + CLs = self.repo.get_changesets(start_date=datetime.datetime(2013, 5, 3, 3), + end_date=datetime.datetime(2014, 1, 1)) + + for cl in CLs: + logging.debug('%s, %s', cl, cl.date) + + self.assertEqual(len(CLs), 2) + + def test_get_affected_files(self): + cs = self.repo.get_changeset(562108) + files = cs.affected_files() + self.assertEqual(len(files), 1) + self.assertEqual(files[0], '//depot/Tools/p4sandbox/file_to_be_added.txt') + + # changeset after obliterated files + repo = P4Repository('//depot/...') + cs = repo.get_changeset(24754) + files = cs.affected_files() + + +if __name__ == '__main__': + unittest.main()