Repository: ValvePython/vpk

Issue:
cli: add flag to negate the regex or filter matching

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=VULNERABILITY) (and [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=SECURITY_HOTSPOT) to review)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=ValvePython_vpk&pullRequest=19&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ValvePython_vpk&pullRequest=19) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo.png' alt='No Duplication information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ValvePython_vpk&pullRequest=19&metric=duplicated_lines_density&view=list) No Duplication information



Patch:
diff --git a/README.rst b/README.rst
index 16e2392..6b9b958 100644
--- a/README.rst
+++ b/README.rst
@@ -80,7 +80,7 @@ A command line utility is also included
 .. code:: text
 
     usage: vpk [-h] [--version] [-l] [-la] [-x OUT_LOCATION] [-nd] [-t] [-c DIR]
-               [-p] [-f WILDCARD | -re REGEX | -name WILDCARD]
+               [-p] [-f WILDCARD | -re REGEX | -name WILDCARD] [-v]
                file
 
     Manage Valve Pak files
@@ -94,7 +94,7 @@ A command line utility is also included
       -l, --list            List file paths
       -la                   List file paths, crc, size
       -x OUT_LOCATION, --extract OUT_LOCATION
-                            Exctract files to directory
+                            Extract files to directory
       -nd, --no-directories
                             Don't create directries during extraction
       -t, --test            Verify contents
@@ -107,6 +107,7 @@ A command line utility is also included
       -re REGEX, --regex REGEX
                             Regular expression filter for file paths
       -name WILDCARD        Filename wildcard filter
+      -v, --invert-match    Use filters for exclusion
 
 
 Looking to decompile Valve custom asset formats?
diff --git a/vpk/cli.py b/vpk/cli.py
index 0355440..aafe8a4 100644
--- a/vpk/cli.py
+++ b/vpk/cli.py
@@ -27,7 +27,7 @@ def make_argparser():
     excl.add_argument('-t', '--test', action='store_true', help='Verify contents')
     excl.add_argument('-c', '--create', metavar='DIR', type=str, help='Create VPK file from directory')
     excl.add_argument('-p', '--pipe', dest='pipe_output', action='store_true', help='Write file contents to stdout')
-    excl.add_argument('-x', '--extract', dest='out_location', type=str, help='Exctract files to directory')
+    excl.add_argument('-x', '--extract', dest='out_location', type=str, help='Extract files to directory')
 
     info.add_argument('-nd', '--no-directories', dest='makedir', action='store_false', help="Don't create directries during extraction")
     info.add_argument('-pe', '--path-encoding', dest='path_enc', default='utf-8', metavar='ENC', type=str, help='File paths encoding')
@@ -37,6 +37,7 @@ def make_argparser():
     fexcl.add_argument('-f', '--filter', metavar="WILDCARD", type=str, help='Wildcard filter for file paths')
     fexcl.add_argument('-re', '--regex', type=str, help='Regular expression filter for file paths')
     fexcl.add_argument('-name', dest='filter_name', metavar="WILDCARD", type=str, help='Filename wildcard filter')
+    filtr.add_argument('-v', '--invert-match', action='store_true', help='Use filters for exclusion')
 
     return parser
 
@@ -68,18 +69,18 @@ def print_header(pak):
     print("% 20s"%"Number of files:", "{:,}".format(num_files))
 
 
-def make_filter_func(wildcard=None, name_wildcard=None, regex=None):
+def make_filter_func(wildcard=None, name_wildcard=None, regex=None, invert=False):
     path_filter = None
 
     if wildcard:
         def path_filter(path):
-            return fnmatch(path, wildcard)
+            return bool(fnmatch(path, wildcard)) is not invert
     elif name_wildcard:
         def path_filter(path):
-            return fnmatch(os.path.split(path)[1], name_wildcard)
+            return bool(fnmatch(os.path.split(path)[1], name_wildcard)) is not invert
     elif regex:
         def path_filter(path):
-            return not not re.search(regex, path)
+            return bool(re.search(regex, path)) is not invert
 
     return path_filter
 
@@ -162,6 +163,27 @@ def create_vpk(args):
     vpk.new(args.create, path_enc=args.path_enc).save(args.file)
 
 
+def run(args):
+    if args.create:
+        create_vpk(args)
+        return
+
+    pak = vpk.open(args.file, path_enc=args.path_enc)
+
+    path_filter = make_filter_func(args.filter, args.filter_name, args.regex, args.invert_match)
+
+    if args.list or args.listall:
+        print_file_list(pak, path_filter, args.listall)
+    elif args.pipe_output:
+        pipe_files(pak, path_filter)
+    elif args.test:
+        print_verifcation(pak)
+    elif args.out_location:
+        extract_files(pak, path_filter, args.out_location, args.makedir)
+    else:
+        print_header(pak)
+
+
 def main():
     parser = make_argparser()
     args = parser.parse_args()
@@ -174,26 +196,12 @@ def main():
         print("Reading from/writing to a pipe is not supported")
         return
 
-    try:
-        if args.create:
-            create_vpk(args)
-            return
-
-        pak = vpk.open(args.file, path_enc=args.path_enc)
-
-        path_filter = make_filter_func(args.filter, args.filter_name, args.regex)
-
-        if args.list or args.listall:
-            print_file_list(pak, path_filter, args.listall)
-        elif args.pipe_output:
-            pipe_files(pak, path_filter)
-        elif args.test:
-            print_verifcation(pak)
-        elif args.out_location:
-            extract_files(pak, path_filter, args.out_location, args.makedir)
-        else:
-            print_header(pak)
+    if args.invert_match and not args.filter and not args.filter_name and not args.regex:
+        print("--invert-match/-v requires one of --filter, --name or --regex")
+        return
 
+    try:
+        run(args)
     except ValueError as e:
         print("Error:", str(e))
     except IOError as e:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..058ba31
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,85 @@
+import sys
+import unittest
+from contextlib import contextmanager
+
+try:
+    from io import StringIO
+except ImportError:
+    from StringIO import StringIO
+
+from vpk import cli
+
+
+@contextmanager
+def capture_stdout():
+    new_out = StringIO()
+    old_out = sys.stdout
+    try:
+        sys.stdout = new_out
+        yield sys.stdout
+    finally:
+        sys.stdout = old_out
+
+
+class testcase_cli(unittest.TestCase):
+    def setUp(self):
+        self.vpk_path = './tests/test_dir.vpk'
+        self.parser = cli.make_argparser()
+        self.vpk_content = ['testfile1.txt', 'testdir/testfile2.txt', 'a/b/c/d/testfile3.bin']
+
+    def run_cli_with_args(self, args):
+        args = self.parser.parse_args(args)
+        with capture_stdout() as stdout:
+            cli.run(args)
+        stdout = stdout.getvalue().split()
+        return stdout
+
+    def test_cli_list(self):
+        stdout = self.run_cli_with_args([self.vpk_path, '--list'])
+        self.assertEqual(len(stdout), len(self.vpk_content))
+        for expected_content in self.vpk_content:
+            self.assertIn(expected_content, stdout)
+
+    def test_cli_list_filter_filter(self):
+        # filter on file name
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '--filter',  '*file2*'])
+        self.assertEqual(len(stdout), 1)
+        self.assertIn(self.vpk_content[1], stdout)
+
+        # filter on dir name
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '--filter', 'testdir*'])
+        self.assertEqual(len(stdout), 1)
+        self.assertIn(self.vpk_content[1], stdout)
+
+        # use filter as exclusion
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '--filter', '*file2*', '-v'])
+        self.assertEqual(len(stdout), 2)
+        self.assertIn(self.vpk_content[0], stdout)
+        self.assertIn(self.vpk_content[2], stdout)
+
+    def test_cli_list_filter_name(self):
+        # filter on file name
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '-name',  '*file2*'])
+        self.assertEqual(len(stdout), 1)
+        self.assertIn(self.vpk_content[1], stdout)
+
+        # filter on dir name (should not work)
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '-name', 'testdir*'])
+        self.assertEqual(len(stdout), 0)
+
+        # use filter as exclusion
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '-name',  '*file2*', '-v'])
+        self.assertEqual(len(stdout), 2)
+        self.assertIn(self.vpk_content[0], stdout)
+        self.assertIn(self.vpk_content[2], stdout)
+
+    def test_cli_list_filter_regex(self):
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '--regex',  r'file2\.t[tx]{2}$'])
+        self.assertEqual(len(stdout), 1)
+        self.assertIn(self.vpk_content[1], stdout)
+
+        # use filter as exclusion
+        stdout = self.run_cli_with_args([self.vpk_path, '--list', '--regex',  r'file2\.t[tx]{2}$', '-v'])
+        self.assertEqual(len(stdout), 2)
+        self.assertIn(self.vpk_content[0], stdout)
+        self.assertIn(self.vpk_content[2], stdout)