Skip to content

Commit ef30fe4

Browse files
authored
Merge pull request #61 from SublimeLinter/factor-generalized-testcase
Factor generalized TestCase, ready to export to SublimeLinter core
2 parents c110503 + 43d7faa commit ef30fe4

File tree

1 file changed

+172
-49
lines changed

1 file changed

+172
-49
lines changed

tests/test_regex.py

Lines changed: 172 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,189 @@
11
# flake8: noqa
2-
import unittest
3-
import re
2+
from __future__ import annotations
43
import importlib
4+
import inspect
5+
import os
6+
import re
7+
import unittest
58

69
import sublime
710

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.
1048
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
11131

12-
class TestRegex(unittest.TestCase):
13132
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), {})
15136
actual = list(linter.find_errors(string))[0]
16137
# `find_errors` fills out more information we don't want to write down
17138
# in the examples
18139
self.assertEqual({k: actual[k] for k in expected.keys()}, expected)
19140

20141
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), {})
22145
actual = list(linter.find_errors(string))
23146
self.assertFalse(actual)
24147

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

Comments
 (0)