Repository: nerdvegas/rez (python)

Issue:
add ability to delete package family
Currently we have the support to delete package versions. Once an entire family is empty, itshould be possible to delete the family.

**Motivation**
My studio is working on package archival and we have a number of packages that are no longer in use at all. In these cases it makes sense to archive entire package families.



Pull request description:
added package fam removal func, and tests
fixes #1248 

Patch:
diff --git a/src/rez/cli/rm.py b/src/rez/cli/rm.py
index c4997d49..31512e2d 100644
--- a/src/rez/cli/rm.py
+++ b/src/rez/cli/rm.py
@@ -15,6 +15,13 @@ def setup_parser(parser, completions=False):
         "-p", "--package",
         help="remove the specified package (eg 'foo-1.2.3'). This will work "
         "even if the package is currently ignored.")
+    group.add_argument(
+        "-f", "--family",
+        help="remove the specified package family (eg 'python'). This is only "
+        "supported if the family is empty")
+    group.add_argument(
+        "--force-family", action="store_true",
+        help="like -f, but delete package family even if not empty")
     group.add_argument(
         "-i", "--ignored-since", type=int, metavar="DAYS",
         help="remove all packages that have been ignored for >= DAYS")
@@ -24,7 +31,8 @@ def setup_parser(parser, completions=False):
         help="dry run mode")
     parser.add_argument(
         "PATH", nargs='?',
-        help="the repository containing the package(s) to remove.")
+        help="the repository containing the package(s) or package family "
+        " to remove.")
 
 
 def remove_package(opts, parser):
@@ -46,6 +54,35 @@ def remove_package(opts, parser):
         sys.exit(1)
 
 
+def remove_package_family(opts, parser, force=False):
+    from rez.vendor.version.requirement import VersionedObject
+    from rez.package_remove import remove_package_family
+    from rez.exceptions import PackageRepositoryError
+
+    if opts.dry_run:
+        parser.error("--dry-run is not supported with --family")
+
+    if not opts.PATH:
+        parser.error("Must specify PATH with --family")
+
+    obj = VersionedObject(opts.family)
+    if obj.version:
+        parser.error("Expected package name, not version")
+
+    success = False
+    try:
+        success = remove_package_family(obj.name, opts.PATH, force=force)
+    except PackageRepositoryError as e:
+        print("Error: %s" % e, file=sys.stderr)
+        sys.exit(1)
+
+    if success:
+        print("Package family removed.")
+    else:
+        print("Package family not found.", file=sys.stderr)
+        sys.exit(1)
+
+
 def remove_ignored_since(opts, parser):
     from rez.package_remove import remove_packages_ignored_since
 
@@ -73,6 +110,10 @@ def remove_ignored_since(opts, parser):
 def command(opts, parser, extra_arg_groups=None):
     if opts.package:
         remove_package(opts, parser)
+    elif opts.family:
+        remove_package_family(opts, parser)
+    elif opts.force_family:
+        remove_package_family(opts, parser, force=True)
     elif opts.ignored_since is not None:
         remove_ignored_since(opts, parser)
     else:
diff --git a/src/rez/package_remove.py b/src/rez/package_remove.py
index 1d22e0eb..cbd35676 100644
--- a/src/rez/package_remove.py
+++ b/src/rez/package_remove.py
@@ -12,6 +12,24 @@ from rez.config import config
 basestring = six.string_types[0]
 
 
+def remove_package_family(name, path, force=False):
+    """Remove a package family from its repository.
+
+    A family can only be deleted if it contains no packages, hidden or
+    otherwise, unless `force` is True.
+
+    Args:
+        name (str): Name of package family.
+        path (str): Package repository path containing the package family.
+        force (bool): If True, delete family even if not empty.
+
+    Returns:
+        bool: True if the package family was removed, False if not found.
+    """
+    repo = package_repository_manager.get_repository(path)
+    return repo.remove_package_family(name, force=force)
+
+
 def remove_package(name, version, path):
     """Remove a package from its repository.
 
diff --git a/src/rez/package_repository.py b/src/rez/package_repository.py
index 3ceeeb7f..67bba604 100644
--- a/src/rez/package_repository.py
+++ b/src/rez/package_repository.py
@@ -269,6 +269,18 @@ class PackageRepository(object):
         """
         raise NotImplementedError
 
+    def remove_package_family(self, pkg_name, force=False):
+        """Remove an empty package family.
+
+        Args:
+            pkg_name (str): Package name
+            force (bool): If Trur, delete even if not empty.
+
+        Returns:
+            bool: True if the family was removed, False if it wasn't found.
+        """
+        raise NotImplementedError
+
     def remove_ignored_since(self, days, dry_run=False, verbose=False):
         """Remove packages ignored for >= specified number of days.
 
diff --git a/src/rez/packages.py b/src/rez/packages.py
index 64394bb9..b4c0c914 100644
--- a/src/rez/packages.py
+++ b/src/rez/packages.py
@@ -598,6 +598,24 @@ def get_package(name, version, paths=None):
         return None
 
 
+def get_package_family_from_repository(name, path):
+    """Get a package family from a repository.
+
+    Args:
+        name (str): Name of the package, eg 'maya'.
+
+    Returns:
+        `PackageFamily` object, or None if the family was not found.
+    """
+    repo = package_repository_manager.get_repository(path)
+
+    family_resource = repo.get_package_family(name)
+    if family_resource is None:
+        return None
+
+    return PackageFamily(family_resource)
+
+
 def get_package_from_repository(name, version, path):
     """Get a package from a repository.
 
diff --git a/src/rezplugins/package_repository/filesystem.py b/src/rezplugins/package_repository/filesystem.py
index 6ed3743e..77d9f54a 100644
--- a/src/rezplugins/package_repository/filesystem.py
+++ b/src/rezplugins/package_repository/filesystem.py
@@ -735,6 +735,36 @@ class FileSystemPackageRepository(PackageRepository):
 
         return True
 
+    def remove_package_family(self, pkg_name, force=False):
+        # get a non-cached copy and see if fam exists
+        repo_copy = self._copy(
+            disable_pkg_ignore=True,
+            disable_memcache=True
+        )
+
+        fam = repo_copy.get_package_family(pkg_name)
+        if fam is None:
+            return False
+
+        # check that the pkg fam is empty
+        if not force:
+            empty = True
+            for _ in repo_copy.iter_packages(fam):
+                empty = False
+                break
+
+            if not empty:
+                raise PackageRepositoryError(
+                    "Cannot remove non-empty package family %r" % pkg_name
+                )
+
+        # delete the fam dir
+        fam_dir = os.path.join(self.location, pkg_name)
+        shutil.rmtree(fam_dir)
+
+        self._on_changed(pkg_name)
+        return True
+
     def remove_ignored_since(self, days, dry_run=False, verbose=False):
         now = int(time.time())
         num_removed = 0
@@ -1464,7 +1494,9 @@ class FileSystemPackageRepository(PackageRepository):
         # stats are required to determine if a resolve cache entry is stale.
         #
         family_path = os.path.join(self.location, pkg_name)
-        os.utime(family_path, None)
+
+        if os.path.exists(family_path):
+            os.utime(family_path, None)
 
         # clear internal caches, otherwise change may not be visible
         self.clear_caches()


Test patch:
diff --git a/src/rez/data/tests/packages/py_packages/empty/README.md b/src/rez/data/tests/packages/py_packages/empty/README.md
new file mode 100644
index 00000000..11706b47
--- /dev/null
+++ b/src/rez/data/tests/packages/py_packages/empty/README.md
@@ -0,0 +1,1 @@
+A deliberately empty package family.
diff --git a/src/rez/tests/test_packages.py b/src/rez/tests/test_packages.py
index 82dde0dc..b94a0dde 100644
--- a/src/rez/tests/test_packages.py
+++ b/src/rez/tests/test_packages.py
@@ -7,11 +7,14 @@ test package iteration, serialization etc
 """
 from rez.packages import iter_package_families, iter_packages, get_package, \
     create_package, get_developer_package, get_variant_from_uri, \
-    get_package_from_uri, get_package_from_repository
+    get_package_from_uri, get_package_from_repository, \
+    get_package_family_from_repository
+from rez.exceptions import PackageRepositoryError
 from rez.package_py_utils import expand_requirement
 from rez.package_resources import package_release_keys
 from rez.package_move import move_package
-from rez.package_remove import remove_package, remove_packages_ignored_since
+from rez.package_remove import remove_package, remove_packages_ignored_since, \
+    remove_package_family
 from rez.package_repository import package_repository_manager
 from rez.tests.util import TestBase, TempdirMixin
 from rez.utils.formatting import PackageRequest
@@ -58,7 +61,10 @@ ALL_PACKAGES = set([
 ])
 
 
-ALL_FAMILIES = set(x.split('-')[0] for x in ALL_PACKAGES)
+ALL_FAMILIES = set(
+    [x.split('-')[0] for x in ALL_PACKAGES]
+    + ["empty"]
+)
 
 
 def _to_names(it):
@@ -544,6 +550,48 @@ class TestPackages(TestBase, TempdirMixin):
         i = repo.unignore_package(pkg_name, pkg_version)
         self.assertEqual(i, -1)
 
+    def test_package_family_remove(self):
+        """Test package family remove."""
+        pkg_name = "pydad"
+
+        # copy packages to a temp repo
+        repo_path = os.path.join(self.root, "tmp6_packages")
+        shutil.copytree(self.solver_packages_path, repo_path)
+
+        # verify that source fam exists
+        src_fam = get_package_family_from_repository(pkg_name, repo_path)
+        self.assertNotEqual(src_fam, None)
+
+        # remove it, will fail as not empty
+        with self.assertRaises(PackageRepositoryError):
+            remove_package_family(pkg_name, repo_path)
+
+        # remove all pydad packages
+        versions = [pkg.version for pkg in src_fam.iter_packages()]
+        for version in versions:
+            self.assertTrue(remove_package(pkg_name, version, repo_path))
+
+        # now it should remove successfully
+        self.assertTrue(remove_package_family(pkg_name, repo_path))
+
+        # verify that the fam no longer exists
+        self.assertFalse(remove_package_family(pkg_name, repo_path))
+
+        pkg_name2 = "pymum"
+
+        # get another fam
+        src_fam2 = get_package_family_from_repository(pkg_name2, repo_path)
+        self.assertNotEqual(src_fam2, None)
+
+        # force remove another fam
+        self.assertTrue(remove_package_family(pkg_name2, repo_path, force=True))
+
+        # verify that the fam no longer exists
+        self.assertEqual(
+            get_package_family_from_repository(pkg_name2, repo_path),
+            None
+        )
+
     def test_remove_packages_ignored_since(self):
         pkg_name = "pydad"
         pkg_version = Version("2")