|
1 | 1 | # flake8: noqa |
2 | | -import unittest |
3 | | -import re |
| 2 | +from __future__ import annotations |
4 | 3 | import importlib |
| 4 | +import inspect |
| 5 | +import os |
| 6 | +import re |
| 7 | +import unittest |
5 | 8 |
|
6 | 9 | import sublime |
7 | 10 |
|
8 | | -LinterModule = importlib.import_module('SublimeLinter-mypy.linter') |
9 | | -Linter = LinterModule.Mypy |
| 11 | +from SublimeLinter.lint import Linter as BaseLinter |
| 12 | + |
| 13 | + |
| 14 | +class TestRegexMeta(type): |
| 15 | + """Metaclass that automatically generates test methods from examples.""" |
| 16 | + def __new__(mcs, name, bases, attrs): |
| 17 | + if 'Linter' in attrs and isinstance(attrs['Linter'], str): |
| 18 | + attrs['Linter'] = mcs.resolve_linter(attrs['Linter']) |
| 19 | + |
| 20 | + # Process examples if they exist |
| 21 | + if 'examples' in attrs: |
| 22 | + examples = attrs['examples'] |
| 23 | + |
| 24 | + # If examples is a single (string, dict) tuple, convert it to a list |
| 25 | + if isinstance(examples, tuple) and len(examples) == 2 and isinstance(examples[0], str) and isinstance(examples[1], dict): |
| 26 | + examples = [examples] |
| 27 | + |
| 28 | + # Create test for matches |
| 29 | + attrs['test_matches'] = mcs.create_matches_test(examples) |
| 30 | + |
| 31 | + # Process does_not_match if they exist |
| 32 | + if 'does_not_match' in attrs: |
| 33 | + does_not_match = attrs['does_not_match'] |
| 34 | + |
| 35 | + # If does_not_match is a single string, convert it to a list |
| 36 | + if isinstance(does_not_match, str): |
| 37 | + does_not_match = [does_not_match] |
| 38 | + |
| 39 | + # Create test for no matches |
| 40 | + attrs['test_no_matches'] = mcs.create_no_matches_test(does_not_match) |
| 41 | + |
| 42 | + return super().__new__(mcs, name, bases, attrs) |
| 43 | + |
| 44 | + @staticmethod |
| 45 | + def resolve_linter(linter_name): |
| 46 | + """ |
| 47 | + Resolve a linter name string to the actual linter class. |
10 | 48 |
|
| 49 | + Strategy 0: Resolve the fully qualified path |
| 50 | + Strategy 1: Try standard naming pattern - SublimeLinter-{name}.linter.{Name} |
| 51 | + Strategy 2: Try to infer from test file location |
| 52 | + """ |
| 53 | + def import_(module_name, linter_name): |
| 54 | + linter_module = importlib.import_module(module_name) |
| 55 | + return getattr(linter_module, linter_name) |
| 56 | + |
| 57 | + if "." in linter_name: |
| 58 | + try: |
| 59 | + return import_(*linter_name.rsplit(".", 1)) |
| 60 | + except (ImportError, AttributeError): |
| 61 | + pass |
| 62 | + |
| 63 | + # Try the standard pattern first (Strategy 1) |
| 64 | + for module_name in ( |
| 65 | + f"SublimeLinter-{linter_name.lower()}.linter", |
| 66 | + f"SublimeLinter-contrib-{linter_name.lower()}.linter", |
| 67 | + ): |
| 68 | + try: |
| 69 | + return import_(module_name, linter_name) |
| 70 | + except (ImportError, AttributeError): |
| 71 | + pass |
| 72 | + |
| 73 | + # If that fails, try to infer from the test file location (Strategy 2) |
| 74 | + # Get the caller's frame (stack level 2 to get past resolve_linter and __new__) |
| 75 | + frame = inspect.stack()[2] |
| 76 | + module = inspect.getmodule(frame[0]) |
| 77 | + if module and (module_path := module.__file__): |
| 78 | + parts = module_path.split(os.sep) |
| 79 | + for candidate in ("Packages", "InstalledPackages"): |
| 80 | + try: |
| 81 | + i = parts.index(candidate) |
| 82 | + except ValueError: |
| 83 | + pass |
| 84 | + else: |
| 85 | + package_name = parts[i + 1] |
| 86 | + module_name = f"{package_name}.linter" |
| 87 | + try: |
| 88 | + import_(module_name, linter_name) |
| 89 | + except (ImportError, AttributeError): |
| 90 | + break |
| 91 | + |
| 92 | + # If all strategies fail, raise a helpful error |
| 93 | + raise ImportError( |
| 94 | + f"Could not resolve Linter='{linter_name}'. " |
| 95 | + f"Please ensure your package follows the naming convention " |
| 96 | + f"SublimeLinter-<contrib->{linter_name.lower()}, provide a full " |
| 97 | + "importable path or the Linter class." |
| 98 | + ) |
| 99 | + |
| 100 | + @staticmethod |
| 101 | + def create_matches_test(examples): |
| 102 | + """Create a test method for matching examples.""" |
| 103 | + def test_matches(self): |
| 104 | + for string, expected in examples: |
| 105 | + with self.subTest(string=string): |
| 106 | + self.assertMatch(string, expected) |
| 107 | + return test_matches |
| 108 | + |
| 109 | + @staticmethod |
| 110 | + def create_no_matches_test(does_not_match): |
| 111 | + """Create a test method for non-matching examples.""" |
| 112 | + def test_no_matches(self): |
| 113 | + for string in does_not_match: |
| 114 | + with self.subTest(string=string): |
| 115 | + self.assertNoMatch(string) |
| 116 | + return test_no_matches |
| 117 | + |
| 118 | + |
| 119 | +class TestRegex(unittest.TestCase, metaclass=TestRegexMeta): |
| 120 | + """ |
| 121 | + Base class for testing regular expressions with examples. |
| 122 | +
|
| 123 | + End users just need to define: |
| 124 | + - Linter: the Linter class under test |
| 125 | + - examples: List/tuple of (string, expected_dict) for patterns that should match |
| 126 | + - does_not_match: List/tuple of strings for patterns that shouldn't match |
| 127 | +
|
| 128 | + The test runner automatically generates test methods from these examples. |
| 129 | + """ |
| 130 | + Linter: type[BaseLinter] | str |
11 | 131 |
|
12 | | -class TestRegex(unittest.TestCase): |
13 | 132 | def assertMatch(self, string, expected): |
14 | | - linter = Linter(sublime.View(0), {}) |
| 133 | + """Assert that a string matches an expected pattern.""" |
| 134 | + assert isinstance(self.Linter, BaseLinter) |
| 135 | + linter = self.Linter(sublime.View(0), {}) |
15 | 136 | actual = list(linter.find_errors(string))[0] |
16 | 137 | # `find_errors` fills out more information we don't want to write down |
17 | 138 | # in the examples |
18 | 139 | self.assertEqual({k: actual[k] for k in expected.keys()}, expected) |
19 | 140 |
|
20 | 141 | def assertNoMatch(self, string): |
21 | | - linter = Linter(sublime.View(0), {}) |
| 142 | + """Assert that a string doesn't match any pattern.""" |
| 143 | + assert isinstance(self.Linter, BaseLinter) |
| 144 | + linter = self.Linter(sublime.View(0), {}) |
22 | 145 | actual = list(linter.find_errors(string)) |
23 | 146 | self.assertFalse(actual) |
24 | 147 |
|
25 | | - def test_no_matches(self): |
26 | | - self.assertNoMatch('') |
27 | | - self.assertNoMatch('foo') |
28 | | - |
29 | | - def test_matches(self): |
30 | | - self.assertMatch( |
31 | | - '/path/to/package/module.py:18:4: error: No return value expected', { |
32 | | - 'error_type': 'error', |
33 | | - 'line': 17, |
34 | | - 'col': 3, |
35 | | - 'message': 'No return value expected'}) |
36 | | - |
37 | | - self.assertMatch( |
38 | | - '/path/to/package/module.py:40: error: "dict" is not subscriptable, use "typing.Dict" instead', { |
39 | | - 'error_type': 'error', |
40 | | - 'line': 39, |
41 | | - 'col': None, |
42 | | - 'message': '"dict" is not subscriptable, use "typing.Dict" instead'}) |
43 | | - |
44 | | - self.assertMatch( |
45 | | - 'codespell_lib\\tests\\test_basic.py:518:5:518:13: error: Module has no attribute "mkfifo" [attr-defined]', { |
46 | | - 'line': 517, |
47 | | - 'col': 4, |
48 | | - 'end_line': 517, |
49 | | - 'end_col': 12, |
50 | | - }) |
51 | | - |
52 | | - self.assertMatch( |
53 | | - 'codespell_lib\\tests\\test_basic.py:-1:-1:-1:-1: error: Module has no attribute "mkfifo" [attr-defined]', { |
54 | | - 'line': 0, |
55 | | - 'col': None, |
56 | | - 'end_line': None, |
57 | | - 'end_col': None, |
58 | | - }) |
59 | | - |
60 | | - def test_tmp_files_that_have_no_file_extension(self): |
61 | | - self.assertMatch( |
62 | | - '/tmp/yoeai32h2:6:1: error: Cannot find module named \'PackageName.lib\'', { |
63 | | - 'error_type': 'error', |
64 | | - 'line': 5, |
65 | | - 'col': 0, |
66 | | - 'message': 'Cannot find module named \'PackageName.lib\''}) |
| 148 | + |
| 149 | +class TestMyPyRegex(TestRegex): |
| 150 | + Linter = "Mypy" |
| 151 | + |
| 152 | + examples = [ |
| 153 | + ('/path/to/package/module.py:18:4: error: No return value expected', { |
| 154 | + 'error_type': 'error', |
| 155 | + 'line': 17, |
| 156 | + 'col': 3, |
| 157 | + 'message': 'No return value expected' |
| 158 | + }), |
| 159 | + ('/path/to/package/module.py:40: error: "dict" is not subscriptable, use "typing.Dict" instead', { |
| 160 | + 'error_type': 'error', |
| 161 | + 'line': 39, |
| 162 | + 'col': None, |
| 163 | + 'message': '"dict" is not subscriptable, use "typing.Dict" instead' |
| 164 | + }), |
| 165 | + ('codespell_lib\\tests\\test_basic.py:518:5:518:13: error: Module has no attribute "mkfifo" [attr-defined]', { |
| 166 | + 'line': 517, |
| 167 | + 'col': 4, |
| 168 | + 'end_line': 517, |
| 169 | + 'end_col': 12, |
| 170 | + }), |
| 171 | + ('codespell_lib\\tests\\test_basic.py:-1:-1:-1:-1: error: Module has no attribute "mkfifo" [attr-defined]', { |
| 172 | + 'line': 0, |
| 173 | + 'col': None, |
| 174 | + 'end_line': None, |
| 175 | + 'end_col': None, |
| 176 | + }), |
| 177 | + # check tmp_files that might not have extensions |
| 178 | + ('/tmp/yoeai32h2:6:1: error: Cannot find module named \'PackageName.lib\'', { |
| 179 | + 'error_type': 'error', |
| 180 | + 'line': 5, |
| 181 | + 'col': 0, |
| 182 | + 'message': 'Cannot find module named \'PackageName.lib\'' |
| 183 | + }) |
| 184 | + ] |
| 185 | + |
| 186 | + does_not_match = [ |
| 187 | + '', |
| 188 | + 'foo' |
| 189 | + ] |
0 commit comments