Repository: netromdk/vermin

Issue:
Vermin reports that code is incompatible but it is
**Describe the bug**

Vermin reports that my codebase is not compatible with 3.8, but it is. Oddly, it points out:

    Minimum required versions: 2.3, 3.0

Which sounds like it should be fine to me.

**To Reproduce**

```python
if sys.version_info < (3, 8):  # noqa: UP036 # novermin
    errstr = "khal only supports python version 3.8+. Please Upgrade.\n"
    sys.stderr.write("#" * len(errstr) + '\n')
    sys.stderr.write(errstr)
    sys.stderr.write("#" * len(errstr) + '\n')
    sys.exit(1)
```

Vermin fails, indicating that this code doesn't run for the specified version:

```con
> vermin -t=3.8  --violations setup.py
Detecting python files..
Analyzing using 24 processes..
Minimum required versions: 2.3, 3.0
Target versions not met:   3.8
Note: Number of specified targets (1) doesn't match number of detected minimum versions (2).
```

**Expected behavior**


This should work just fine.

**Environment (please complete the following information):**

- Via pre-commit, v1.5.2.
- From Alpine packages, v1.5.2

**Additional context**

Full file is here: https://github.com/pimutils/khal/blob/f5ca8883f2e3d8a403d7a6d16fa4f552b6b69283/setup.py

Comments:

Patch:
diff --git a/vermin/arguments.py b/vermin/arguments.py
index 71fc6e4..9fc7189 100644
--- a/vermin/arguments.py
+++ b/vermin/arguments.py
@@ -63,10 +63,11 @@ class Arguments:
       print("  --target=V | -t=V\n"
             "        Target version that files must abide by. Can be specified once or twice.\n"
             "        A '-' can be appended to match target version or smaller, like '-t=3.5-'.\n"
-            "        If not met Vermin will exit with code 1. Note that the amount of target\n"
-            "        versions must match the amount of minimum required versions detected.\n"
-            "        However, if used in conjunction with --violations, and no rules are\n"
-            "        triggered, it will exit with code 0.\n")
+            "        If not met Vermin will exit with code 1. Vermin will only compare target\n"
+            "        versions with the same major version, so if you do not care about Python\n"
+            "        2, you can just specify one target for Python 3. However, if used in\n"
+            "        conjunction with --violations, and no rules are triggered, it will exit\n"
+            "        with code 0.\n")
       print("  --no-target (default)\n"
             "        Don't expect certain target version(s).\n")
       print("  --processes=N | -p=N\n"
diff --git a/vermin/main.py b/vermin/main.py
index be7a883..e0425db 100644
--- a/vermin/main.py
+++ b/vermin/main.py
@@ -7,7 +7,7 @@ from .printing import nprint, vprint
 from .detection import detect_paths
 from .processor import Processor
 from .arguments import Arguments
-from .utility import version_strings, dotted_name
+from .utility import version_strings, dotted_name, compare_requirements
 from .backports import Backports
 
 def main():
@@ -144,16 +144,10 @@ def main():
     # don't fail wrt. targets.
     all_inconclusive = config.only_show_violations() and len(reqs) > 0 and \
       all(req == (0, 0) for req in reqs)
-    if not all_inconclusive and\
-       not (len(reqs) == len(targets) and
-            all(((exact and target == req) or (not exact and target >= req)) for
-                ((exact, target), req) in zip(targets, reqs))):
+    if not all_inconclusive and not compare_requirements(reqs, targets):
       if not parsable:
         vers = ["{}{}".format(dotted_name(t), "-" if not e else "") for (e, t) in targets]
         nprint("Target versions not met:   {}".format(version_strings(vers)), config)
-        if len(targets) < len(reqs):
-          nprint("Note: Number of specified targets ({}) doesn't match number of detected minimum "
-                 "versions ({}).".format(len(targets), len(reqs)), config)
       sys.exit(1)
 
   sys.exit(0)
diff --git a/vermin/utility.py b/vermin/utility.py
index 61bb00a..b38ae41 100644
--- a/vermin/utility.py
+++ b/vermin/utility.py
@@ -189,3 +189,20 @@ def parse_target(target):
     return None
 
   return (exact, elms)
+
+def compare_requirements(reqs, targets):
+  maj_to_req = {ver[0]: ver for ver in reqs}
+  maj_to_target = {ver[0]: (exact, ver) for (exact, ver) in targets}
+  common_major_versions = set(maj_to_req.keys()) & set(maj_to_target.keys())
+  if not common_major_versions:
+    return False
+  if set(maj_to_target.keys()) - common_major_versions:
+    return False  # target major version missing from the requirements
+  for maj in common_major_versions:
+    exact, target = maj_to_target[maj]
+    req = maj_to_req[maj]
+    if exact and target != req:
+      return False
+    if not exact and target < req:
+      return False
+  return True


Test patch:
diff --git a/tests/general.py b/tests/general.py
index f36ba6b..d540df5 100644
--- a/tests/general.py
+++ b/tests/general.py
@@ -12,6 +12,7 @@ from vermin import combine_versions, InvalidVersionException, detect_paths,\
   remove_whitespace, main, sort_line_column, sort_line_column_parsable, version_strings,\
   format_title_descs, DEFAULT_PROCESSES
 from vermin.formats import ParsableFormat
+from vermin.utility import compare_requirements
 
 from .testutils import VerminTest, current_version, ScopedTemporaryFile, detect, visit, touch, \
   working_dir
@@ -1054,3 +1055,79 @@ three - three.one
 
   def test_default_processes(self):
     self.assertEqual(cpu_count(), DEFAULT_PROCESSES)
+
+  def test_compare_requirements_py2_and_py3_compatible(self):
+    reqs = [(2, 7), (3, 6)]
+
+    # User provides only one target
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 6))]))
+    self.assertTrue(compare_requirements(reqs, [(True, (2, 7))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (2, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 3))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (3, 3))]))
+    self.assertTrue(compare_requirements(reqs, [(True, (3, 6))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (3, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 7))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (3, 7))]))
+
+    # Missing and invalid targets
+    self.assertFalse(compare_requirements(reqs, []))
+    self.assertFalse(compare_requirements(reqs, [(True, (4, 1))]))
+
+    # User provides multiple valid requirements, return true when both are
+    # satisfied.
+    self.assertTrue(compare_requirements(reqs, [(True, (2, 7)), (False, (3, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 7)), (True, (3, 7))]))
+
+    # User provides valid along with invalid version: fail because the target
+    # major version is missing
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 7)), (False, (4, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 7)), (True, (4, 7))]))
+
+  def test_compare_requirements_py2_only(self):
+    reqs = [(2, 7)]
+
+    # Correct major version, compare against minor version
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 6))]))
+    self.assertTrue(compare_requirements(reqs, [(True, (2, 7))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (2, 7))]))
+
+    # The user specifies the wrong major version: this will always fail
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 3))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (3, 3))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (3, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (3, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (4, 1))]))
+
+    # Missing target: fail
+    self.assertFalse(compare_requirements(reqs, []))
+
+    # Multiple targets: fail because one target major version is missing
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 7)), (False, (3, 6))]))
+
+  def test_compare_requirements_py3_only(self):
+    reqs = [(3, 6)]
+    # The user specifies the wrong major version: this will always fail
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (2, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 7))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (4, 1))]))
+
+    # Correct major version, compare against minor version
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 3))]))
+    self.assertFalse(compare_requirements(reqs, [(False, (3, 3))]))
+    self.assertTrue(compare_requirements(reqs, [(True, (3, 6))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (3, 6))]))
+    self.assertFalse(compare_requirements(reqs, [(True, (3, 7))]))
+    self.assertTrue(compare_requirements(reqs, [(False, (3, 7))]))
+
+    # Missing and invalid requirements
+    self.assertFalse(compare_requirements(reqs, []))
+
+    # Multiple targets: fail because one target amjor version is missing
+    self.assertFalse(compare_requirements(reqs, [(False, (2, 7)), (False, (3, 6))]))