diff --git a/docs/descriptor.rst b/docs/descriptor.rst index 77de4feb70..d962a5c796 100644 --- a/docs/descriptor.rst +++ b/docs/descriptor.rst @@ -95,6 +95,8 @@ Several different descriptor types are supported by Toolkit: - A **git** descriptor represents a tag in a git repository - A **git_branch** descriptor represents a commit in a git branch - A **github_release** descriptor represents a Release on a Github repo +- A **perforce_change** descriptor represents a changelist in a Perforce depot +- A **perforce_label** descriptor represents a Label in a Perforce depot - A **path** descriptor represents a location on disk - A **dev** descriptor represents a developer sandbox - A **manual** descriptor gives raw access to the bundle caching structure @@ -343,6 +345,87 @@ A token must be set as environment variable that is specific to the organization .. note:: For private repos, it's recommended that you use a personal access token (classic) with read-only access to Content. Fine-grained tokens are not yet supported. For more information, see the `Github Documentation on Personal Access Tokens `_. +Tracking against changelist in Perforce +======================================= + +The ``perforce_change`` descriptor type is useful for studios and 3rd parties wishing to deploy apps directly from their +Perforce server. +This ``print's`` all the files from depot at the supplied path at the revision of the supplied changelist. +Connects to the perforce server via command line. Prior setup of perforce is and user should be login +as usual if authenticating is required on your server. This also supports remote over a VPN connection. + +Getting ``tk-multi-loader2`` from ``//DEPOT/AppStore/tk-multi-loader2`` in Perforce: + +.. code-block:: yaml + + { + type: perforce_change, + path: //DEPOT/AppStore/tk-multi-loader2 + changelist: 12345 + } + + +Use latest: + +.. code-block:: yaml + + { + type: perforce_change, + path: //DEPOT/AppStore/tk-multi-loader2 + } + +Environment variable support: + +.. code-block:: yaml + + { + type: perforce_change, + path: ${DEPOT_APPSTORE}/tk-multi-loader2 + } + +.. code-block:: yaml + + sgtk:descriptor:perforce_change?path=//DEPOT/AppStore/tk-multi-loader2&changelist=12345 + +- If ``changelist`` is not supplied, the latest will be fetched. +- ``path`` is the depot path to where the code is stored. +- ``changelist`` is the changelist number in the depot. + +.. _perforce_descriptors: + +Tracking against Labels in Perforce +=================================== + +The ``perforce_label`` descriptor ``print's`` all the files from depot at the +supplied path at the revision of the supplied changelist. + +Getting ``tk-multi-loader2`` from ``//DEPOT/AppStore/tk-multi-loader2`` in Perforce: + +.. code-block:: yaml + + { + type: perforce_label + path: //DEPOT/AppStore/tk-multi-loader2 + label: v3.0.0 + } +.. code-block:: yaml + + { + type: perforce_label, + path: //DEPOT/AppStore/tk-multi-loader2 + label: tk-multi-loader2-v3.0.0 + } + +.. code-block:: yaml + + sgtk:descriptor:perforce_label?path=//DEPOT/AppStore/tk-multi-loader2&label=v3.0.0 + +- ``path`` is the depot path to where the code is stored. +- ``label`` is the Label tag given to the depot path. + +.. note:: If you want constraint patterns (i.e. ``v1.x.x``) to work correctly with this descriptor, you must follow the `semantic versioning `_ specification when naming Labels in Perforce. + + Pointing to a path on disk ========================== diff --git a/python/tank/descriptor/io_descriptor/__init__.py b/python/tank/descriptor/io_descriptor/__init__.py index 5076369732..23f8170cfd 100644 --- a/python/tank/descriptor/io_descriptor/__init__.py +++ b/python/tank/descriptor/io_descriptor/__init__.py @@ -30,6 +30,8 @@ def _initialize_descriptor_factory(): from .git_tag import IODescriptorGitTag from .git_branch import IODescriptorGitBranch from .github_release import IODescriptorGithubRelease + from .perforce_change import IODescriptorPerforceChange + from .perforce_label import IODescriptorPerforceLabel from .manual import IODescriptorManual IODescriptorBase.register_descriptor_factory("app_store", IODescriptorAppStore) @@ -41,6 +43,12 @@ def _initialize_descriptor_factory(): IODescriptorBase.register_descriptor_factory( "github_release", IODescriptorGithubRelease ) + IODescriptorBase.register_descriptor_factory( + "perforce_change", IODescriptorPerforceChange + ) + IODescriptorBase.register_descriptor_factory( + "perforce_label", IODescriptorPerforceLabel + ) IODescriptorBase.register_descriptor_factory("manual", IODescriptorManual) diff --git a/python/tank/descriptor/io_descriptor/perforce.py b/python/tank/descriptor/io_descriptor/perforce.py new file mode 100644 index 0000000000..94f49b409b --- /dev/null +++ b/python/tank/descriptor/io_descriptor/perforce.py @@ -0,0 +1,237 @@ +# Copyright (c) 2016 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. +import os +import subprocess + +from .downloadable import IODescriptorDownloadable +from ... import LogManager +from ...util.process import subprocess_check_output, SubprocessCalledProcessError + +from ..errors import TankError +from ...util import filesystem +from ...util import is_windows + +log = LogManager.get_logger(__name__) + + +def _can_hide_terminal(): + """ + Ensures this version of Python can hide the terminal of a subprocess + launched with the subprocess module. + """ + try: + # These values are not defined between Python 2.6.6 and 2.7.1 inclusively. + subprocess.STARTF_USESHOWWINDOW + subprocess.SW_HIDE + return True + except Exception: + return False + + +def _check_output(*args, **kwargs): + """ + Wraps the call to subprocess_check_output so it can run headless on Windows. + """ + if is_windows() and _can_hide_terminal(): + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + kwargs["startupinfo"] = startupinfo + + return subprocess_check_output(*args, **kwargs) + + +class TankPerforceError(TankError): + """ + Errors related to p4 communication + """ + + pass + + +class IODescriptorPerforce(IODescriptorDownloadable): + """ + Base class for perforce descriptors. + + Abstracts operations around depots, since all p4 + descriptors have a repository associated (via the 'path' + parameter). + """ + + def __init__(self, descriptor_dict, sg_connection, bundle_type): + """ + Constructor + + :param descriptor_dict: descriptor dictionary describing the bundle + :param sg_connection: Shotgun connection to associated site. + :param bundle_type: Either AppDescriptor.APP, CORE, ENGINE or FRAMEWORK. + :return: Descriptor instance + """ + + self._cache_type = "perforce" + + super(IODescriptorPerforce, self).__init__( + descriptor_dict, sg_connection, bundle_type + ) + + self._path = descriptor_dict.get("path") + # Expand environment variables for depot roots + self._path = os.path.expandvars(self._path) + self._path = os.path.expanduser(self._path) + + # strip trailing slashes - this is so that when we build + # the name later (using os.basename) we construct it correctly. + if self._path.endswith("/") or self._path.endswith("\\"): + self._path = self._path[:-1] + + @LogManager.log_timing + def execute_p4_commands(self, target_path, commands): + """ + Downloads the depot path into the given location + + The initial sync operation happens via the subprocess module, ensuring + there is no terminal that will pop for credentials, leading to a more + seamless experience. If the operation failed, we try a second time with + os.system, ensuring that there is an initialized shell environment + + :param target_path: path to clone into + :param commands: list p4 commands to execute, e.g. ['p4 x'] + :returns: stdout and stderr of the last command executed as a string + :raises: TankPerforceError on p4 failure + """ + # ensure *parent* folder exists + parent_folder = os.path.dirname(target_path) + + filesystem.ensure_folder_exists(parent_folder) + + # first probe to check that p4 exists in our PATH + log.debug("Checking that p4 exists and can be executed...") + try: + output = _check_output(["p4", "info"]) + except Exception: + log.exception("Unexpected error:") + raise TankPerforceError( + "Cannot execute the 'p4' command. Please make sure that p4 is " + "installed on your system." + ) + + run_with_os_system = True + + output = None + if is_windows() and _can_hide_terminal(): + log.debug("Executing command '%s' using subprocess module." % commands) + try: + environ = {} + environ.update(os.environ) + output = _check_output(commands, env=environ) + + log.debug("p4 output %s" % output) + + # If that works, we're done and we don't need to use os.system. + run_with_os_system = False + status = 0 + except SubprocessCalledProcessError: + log.debug("Subprocess call failed.") + + if run_with_os_system: + # Make sure path and repo path are quoted. + log.debug("Executing command '%s' using os.system" % commands) + log.debug( + "Note: in a terminal environment, this may prompt for authentication" + ) + status = os.system(" ".join(commands)) + + log.debug("Command returned exit code %s" % status) + if status != 0: + raise TankPerforceError( + "Error executing p4 operation. The p4 command '%s' " + "returned error code %s." % (commands, status) + ) + log.debug("P4 print into '%s' successful." % target_path) + + # return the last returned stdout/stderr + return output + + def _download_local(self, destination_path): + """ + Retrieves this version to from the depot + Will exit early if app already exists local. + + This will connect to p4 depot. + The p4 depot path will be downloaded at the descriptor version (changelist or label) + + :param destination_path: The destination path on disk to which + the p4 depot path is to be downloaded to. + """ + try: + # Use perforce print to download the files without requiring + # workspace setup. Applies the path format to download from + # a depot path to a folder at the specified change or label. + destination_path = destination_path.replace("\\", "/") + commands = [ + "p4", + "print", + "-o", + "%s/..." % destination_path, + "%s/...@%s" % (self._path, self._version), + ] + self.execute_p4_commands(destination_path, commands) + + except Exception as e: + raise TankPerforceError( + "Could not download %s, " + "commit %s: %s" % (self._path, self._version, e) + ) + + def get_system_name(self): + """ + Returns a short name, suitable for use in configuration files + and for folders on disk, e.g. 'tk-maya' + """ + bn = os.path.basename(self._path) + (name, ext) = os.path.splitext(bn) + return name + + def has_remote_access(self): + """ + Probes if the current descriptor is able to handle + remote requests. If this method returns, true, operations + such as :meth:`download_local` and :meth:`get_latest_version` + can be expected to succeed. + + :return: True if a remote is accessible, false if not. + """ + # check if we can clone the repo + can_connect = True + try: + log.debug("%r: Probing if a connection to p4 can be established..." % self) + # clone repo into temp folder + subprocess.check_output(["p4", "info"]) + log.debug("...connection established") + except Exception as e: + log.debug("...could not establish connection: %s" % e) + can_connect = False + return can_connect + + def _get_bundle_cache_path(self, bundle_cache_root): + """ + Given a cache root, compute a cache path suitable + for this descriptor, using the 0.18+ path format. + + :param bundle_cache_root: Bundle cache root path + :return: Path to bundle cache location + """ + # If the descriptor is an integer change the version to a string type + if isinstance(self._version, int): + self._version = str(self._version) + + name = os.path.basename(self._path) + + return os.path.join(bundle_cache_root, self._cache_type, name, self._version) diff --git a/python/tank/descriptor/io_descriptor/perforce_change.py b/python/tank/descriptor/io_descriptor/perforce_change.py new file mode 100644 index 0000000000..288dc53d8c --- /dev/null +++ b/python/tank/descriptor/io_descriptor/perforce_change.py @@ -0,0 +1,180 @@ +# Copyright (c) 2016 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. +import copy + +from .perforce import IODescriptorPerforce, TankPerforceError, _check_output +from ... import LogManager + +try: + from tank_vendor import sgutils +except ImportError: + from tank_vendor import six as sgutils + +log = LogManager.get_logger(__name__) + + +def _find_latest_change(changelist): + """ + Given a list of changelist strings, cast to ints and get the max value + to determine the latest changelist. + + :return: latest changelist or None + """ + + changes = [] + for i in changelist: + try: + changes.append(int(i)) + except ValueError: + pass + + return max(changes) if changes else None + + +class IODescriptorPerforceChange(IODescriptorPerforce): + """ + Represents a changelist in perforce, belonging to a depot. + + Change format: + location: {"type": "perforce_change", + "path": "//path/to/stream", + "changelist": "3156014"} + + + The payload cached in the bundle cache represents a changelist at a path in the depot. + """ + + def __init__(self, descriptor_dict, sg_connection, bundle_type): + """ + Constructor + + :param descriptor_dict: descriptor dictionary describing the bundle + :param sg_connection: Shotgun connection to associated site. + :param bundle_type: Either AppDescriptor.APP, CORE, ENGINE or FRAMEWORK. + :return: Descriptor instance + """ + # make sure all required fields are there + self._validate_descriptor( + descriptor_dict, required=["type", "path"], optional=["changelist"] + ) + + self._cache_type = "perforce_change" + + # call base class + super(IODescriptorPerforceChange, self).__init__( + descriptor_dict, sg_connection, bundle_type + ) + + # path is handled by base class + self._sg_connection = sg_connection + self._bundle_type = bundle_type + if not descriptor_dict.get("changelist"): + self._version = self._get_latest_changelist() + else: + self._version = descriptor_dict["changelist"] + + def __str__(self): + """ + Human-readable representation + """ + # //DEPOT/Appstore/tk-multi-loader2, Perforce changelist 123456 + return "%s, Perforce changelist %s" % (self._path, self._version) + + def get_version(self): + """ + Returns the changelist number string for this item, .e.g '12345' + """ + return self._version + + def _get_latest_changelist(self): + """ + Retrieve the latest changelist for this path in the perforce depot. + + :return: The latest changelist. + """ + + changelist = None + + log.debug("Getting the latest changelist at %s" % self._path) + try: + commands = ["p4", "changes", "-m", "1", "%s/..." % self._path] + result = _check_output(commands) + + # Parse the changelist number from the output + # command result: Change 12345 on 2024/11/28 by User.Name@Client 'Commit Message' + if result.strip(): + parts = result.split(" ", 4) + changelist = parts[1] + except Exception as e: + raise TankPerforceError( + "Could not get latest changelist for %s: %s" % (self._path, e) + ) + + return changelist + + def get_latest_version(self, constraint_pattern=None): + """ + Returns a descriptor object that represents the latest version. + + This will connect to p4 depot. + This will check the latest changelist of a depot path + + :param constraint_pattern: If this is specified, the query will be constrained + by the given pattern. Version patterns are on the following forms: + + - v0.1.2, v0.12.3.2, v0.1.3beta - a specific version + - v0.12.x - get the highest v0.12 version + - v1.x.x - get the highest v1 version + + :returns: IODescriptorPerforceStream object + """ + + if constraint_pattern: + log.warning( + "%s does not handle constraint patterns. " + "Latest version will be used." % self + ) + + latest_changelist = self._get_latest_changelist() + + # make a new descriptor + new_loc_dict = copy.deepcopy(self._descriptor_dict) + new_loc_dict["changelist"] = sgutils.ensure_str(latest_changelist) + desc = IODescriptorPerforceChange( + new_loc_dict, self._sg_connection, self._bundle_type + ) + desc.set_cache_roots(self._bundle_cache_root, self._fallback_roots) + return desc + + def get_latest_cached_version(self, constraint_pattern=None): + """ + Returns a descriptor object that represents the latest changelist + that is locally available in the bundle cache search path. + + :param constraint_pattern: Not implemented with changelist. + + :returns: instance deriving from IODescriptorBase or None if not found + """ + all_versions = self._get_locally_cached_versions() + changes = list(all_versions.keys()) + + change_to_use = _find_latest_change(changes) + if change_to_use is None: + return None + + # make a descriptor dict + new_loc_dict = copy.deepcopy(self._descriptor_dict) + new_loc_dict["changelist"] = sgutils.ensure_str(change_to_use) + desc = IODescriptorPerforceChange( + new_loc_dict, self._sg_connection, self._bundle_type + ) + desc.set_cache_roots(self._bundle_cache_root, self._fallback_roots) + log.debug("Latest changelist resolved to %r" % desc) + return desc diff --git a/python/tank/descriptor/io_descriptor/perforce_label.py b/python/tank/descriptor/io_descriptor/perforce_label.py new file mode 100644 index 0000000000..8718ea2318 --- /dev/null +++ b/python/tank/descriptor/io_descriptor/perforce_label.py @@ -0,0 +1,188 @@ +# Copyright (c) 2016 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. +import copy +import re + +from .perforce import IODescriptorPerforce, TankPerforceError, _check_output +from ... import LogManager + +try: + from tank_vendor import sgutils +except ImportError: + from tank_vendor import six as sgutils + +log = LogManager.get_logger(__name__) + + +class IODescriptorPerforceLabel(IODescriptorPerforce): + """ + Represents a label in perforce, belonging to a particular depot. + + Label format: + location: {"type": "perforce_label", + "path": "//path/to/stream", + "label": "v1.0.0"} + + The payload cached in the bundle cache represents a Label in the depot, and the label + """ + + def __init__(self, descriptor_dict, sg_connection, bundle_type): + """ + Constructor + + :param descriptor_dict: descriptor dictionary describing the bundle + :param sg_connection: Shotgun connection to associated site. + :param bundle_type: Either AppDescriptor.APP, CORE, ENGINE or FRAMEWORK. + :return: Descriptor instance + """ + # make sure all required fields are there + self._validate_descriptor( + descriptor_dict, required=["type", "path", "label"], optional=[] + ) + + self._cache_type = "perforce_label" + + # call base class + super(IODescriptorPerforceLabel, self).__init__( + descriptor_dict, sg_connection, bundle_type + ) + + # path is handled by base class + self._sg_connection = sg_connection + self._bundle_type = bundle_type + self._version = descriptor_dict.get("label") + + def __str__(self): + """ + Human-readable representation + """ + # //DEPOT/Appstore/tk-multi-loader2, Perforce Label 123456 + return "%s, Perforce Label %s" % (self._path, self._version) + + def get_version(self): + """ + Returns the version number string for this item, .e.g 'v1.2.3' + """ + return self._version + + def get_latest_version(self, constraint_pattern=None): + """ + Returns a descriptor object that represents the latest version. + + :param constraint_pattern: If this is specified, the query will be constrained + by the given pattern. Version patterns are on the following forms: + + - v1.2.3 (means the descriptor returned will inevitably be same as self) + - v1.2.x + - v1.x.x + + :returns: IODescriptorManual object + """ + + p4_labels = self._fetch_labels() + labels_list = list(p4_labels.keys()) + latest_label = self._find_latest_tag_by_pattern(labels_list, pattern=None) + if latest_label is None: + raise TankPerforceError( + "Perforce depot %s doesn't have any tags!" % self._path + ) + + # make a new descriptor + new_loc_dict = copy.deepcopy(self._descriptor_dict) + new_loc_dict["label"] = sgutils.ensure_str(latest_label) + desc = IODescriptorPerforceLabel( + new_loc_dict, self._sg_connection, self._bundle_type + ) + desc.set_cache_roots(self._bundle_cache_root, self._fallback_roots) + log.debug("Latest version resolved to %r" % desc) + return desc + + def _fetch_labels(self): + """ + Get the labels semantic versions and full label name from perforce. + + { + "v1.0.1": "bundleA-v1.0.1", + "v2.0.0": "bundleA-v1.0.0", + "v3.2.1": "bundleA_beta-v3.2.1" + "v4.0.0": "v4.0.0" + } + + :returns: dict + """ + try: + # query labels for this depot path + commands = ["p4", "labels", "%s/..." % self._path] + output = _check_output(commands) + # Expected result: + # Label tk-multi-app-v1.0.1 2024/11/29 'Created by User.Name.' + # Label v1.0.0 2024/11/29 'Created by User.Name.' + + p4_labels = {} + # Map the found version to the source label name for sorting. + for line in output.splitlines(): + if line.startswith("Label "): + # Extract the label name from lines like: "Label label_name date description" + parts = line.split() + label_name = parts[1] + # Typically labels are global for the depot, so labels + # maybe prepended by more data, try to extract the version + regex = re.compile(r"v\d+\.\d+\.\d+") + m = regex.match(sgutils.ensure_str(label_name)) + if m: + p4_labels[m.group(1)] = label_name + + except Exception as e: + raise TankPerforceError( + "Could not get list of labels for %s: %s" % (self._path, e) + ) + + if len(p4_labels.keys()) == 0: + raise TankPerforceError( + "Depot path %s doesn't have any labels!" % self._path + ) + + return p4_labels + + def get_latest_cached_version(self, constraint_pattern=None): + """ + Returns a descriptor object that represents the latest version + that is locally available in the bundle cache search path. + + :param constraint_pattern: If this is specified, the query will be constrained + by the given pattern. Version patterns are on the following forms: + + - v0.1.2, v0.12.3.2, v0.1.3beta - a specific version + - v0.12.x - get the highest v0.12 version + - v1.x.x - get the highest v1 version + + :returns: instance deriving from IODescriptorBase or None if not found + """ + all_versions = self._get_locally_cached_versions() + version_numbers = list(all_versions.keys()) + + if not version_numbers: + return None + + version_to_use = self._find_latest_tag_by_pattern( + version_numbers, constraint_pattern + ) + if version_to_use is None: + return None + + # make a descriptor dict + new_loc_dict = copy.deepcopy(self._descriptor_dict) + new_loc_dict["label"] = sgutils.ensure_str(version_to_use) + desc = IODescriptorPerforceLabel( + new_loc_dict, self._sg_connection, self._bundle_type + ) + desc.set_cache_roots(self._bundle_cache_root, self._fallback_roots) + log.debug("Latest version resolved to %r" % desc) + return desc diff --git a/python/tank/util/filesystem.py b/python/tank/util/filesystem.py index c4d4320736..4135ef53e5 100644 --- a/python/tank/util/filesystem.py +++ b/python/tank/util/filesystem.py @@ -29,7 +29,7 @@ log = LogManager.get_logger(__name__) # files or directories to skip if no skip_list is specified -SKIP_LIST_DEFAULT = [".svn", ".git", ".gitignore", ".hg", ".hgignore"] +SKIP_LIST_DEFAULT = [".svn", ".git", ".gitignore", ".hg", ".hgignore", ".p4ignore"] def with_cleared_umask(func): diff --git a/tests/descriptor_tests/test_perforce.py b/tests/descriptor_tests/test_perforce.py new file mode 100644 index 0000000000..036e3c4735 --- /dev/null +++ b/tests/descriptor_tests/test_perforce.py @@ -0,0 +1,152 @@ +# Copyright (c) 2016 Shotgun Software Inc. +# +# CONFIDENTIAL AND PROPRIETARY +# +# This work is provided "AS IS" and subject to the Shotgun Pipeline Toolkit +# Source Code License included in this distribution package. See LICENSE. +# By accessing, using, copying or modifying this work you indicate your +# agreement to the Shotgun Pipeline Toolkit Source Code License. All rights +# not expressly granted therein are reserved by Shotgun Software Inc. + +import os + +import sgtk +from sgtk.descriptor import Descriptor +from tank_test.tank_test_base import setUpModule # noqa +from tank_test.tank_test_base import ShotgunTestBase, skip_if_p4_missing + + +class TestPerforceIODescriptor(ShotgunTestBase): + """ + Testing the Shotgun deploy main API methods + """ + + def setUp(self): + """ + Sets up the next test's environment. + """ + ShotgunTestBase.setUp(self) + + # Depot path, Requires p4 server with a bundle path + # and app with a changelist and a label + self.p4_depot_uri = "//TestRepo/AppStore/tk-shotgun-pythonconsole" + + self.bundle_cache = os.path.join(self.project_root, "bundle_cache") + + def _create_desc(self, location, resolve_latest=False, desc_type=Descriptor.APP): + """ + Helper method around create_descriptor + """ + return sgtk.descriptor.create_descriptor( + self.mockgun, + desc_type, + location, + bundle_cache_root_override=self.bundle_cache, + resolve_latest=resolve_latest, + ) + + @skip_if_p4_missing + def test_latest(self): + location_dict = { + "type": "perforce_change", + "path": self.p4_depot_uri, + "changelist": "100", + } + + desc = self._create_desc(location_dict) + self.assertEqual(desc.version, "100") + + location_dict = { + "type": "perforce_label", + "path": self.p4_depot_uri, + "label": "v1.0.0", + } + + desc = self._create_desc(location_dict) + self.assertEqual(desc.version, "v1.0.0") + + @skip_if_p4_missing + def test_change(self): + location_dict = { + "type": "perforce_change", + "path": self.p4_depot_uri, + "changelist": "100", + } + + desc = self._create_desc(location_dict) + + self.assertEqual(desc.get_path(), None) + + desc.ensure_local() + + self.assertEqual( + desc.get_path(), + os.path.join( + self.bundle_cache, "perforce_change", "tk-shotgun-pythonconsole", "100" + ), + ) + + latest_desc = desc.find_latest_version() + + self.assertEqual(latest_desc.version, "100") + self.assertEqual(latest_desc.get_path(), None) + + latest_desc.ensure_local() + + self.assertEqual( + latest_desc.get_path(), + os.path.join( + self.bundle_cache, "perforce_change", "tk-shotgun-pythonconsole", "100" + ), + ) + + # test that the copy method copies the .git folder + copy_target = os.path.join(self.project_root, "test_copy_target") + latest_desc.copy(copy_target) + self.assertTrue(os.path.exists(copy_target)) + + @skip_if_p4_missing + def test_label(self): + location_dict = { + "type": "perforce_label", + "path": self.p4_depot_uri, + "label": "v1.0.0", + } + + desc = self._create_desc(location_dict) + + self.assertEqual(desc.get_path(), None) + + desc.ensure_local() + + self.assertEqual( + desc.get_path(), + os.path.join( + self.bundle_cache, + "perforce_label", + "tk-shotgun-pythonconsole", + "v1.0.0", + ), + ) + + latest_desc = desc.find_latest_version() + + self.assertEqual(latest_desc.version, "v1.0.0") + self.assertEqual(latest_desc.get_path(), None) + + latest_desc.ensure_local() + + self.assertEqual( + latest_desc.get_path(), + os.path.join( + self.bundle_cache, + "perforce_label", + "tk-shotgun-pythonconsole", + "v1.0.0", + ), + ) + + # test that the copy method copies the .git folder + copy_target = os.path.join(self.project_root, "test_copy_target") + latest_desc.copy(copy_target) + self.assertTrue(os.path.exists(copy_target)) diff --git a/tests/python/tank_test/tank_test_base.py b/tests/python/tank_test/tank_test_base.py index d0c19fe54b..d3d9fc59e5 100644 --- a/tests/python/tank_test/tank_test_base.py +++ b/tests/python/tank_test/tank_test_base.py @@ -102,6 +102,20 @@ def _is_git_missing(): pass return git_missing +def _is_p4_missing(): + """ + Tests is p4 is available in PATH + :returns: True is p4 is available, False otherwise. + """ + p4_missing = True + try: + sgtk.util.process.subprocess_check_output(["p4", "info"]) + p4_missing = False + except Exception: + # no p4! + pass + return p4_missing + def skip_if_on_travis_ci(reason): """ @@ -123,6 +137,14 @@ def skip_if_git_missing(func): """ return unittest.skipIf(_is_git_missing(), "git is missing from PATH")(func) +def skip_if_p4_missing(func): + """ + Decorated that allows to skips a test if P4 is missing. + :param func: Function to be decorated. + :returns: The decorated function. + """ + return unittest.skipIf(_is_p4_missing(), "p4 is missing from PATH")(func) + def _is_pyside_missing(): """