Skip to content

Commit 1d5e213

Browse files
committed
Support hooks that work on non-python files.
1 parent cf58a59 commit 1d5e213

File tree

10 files changed

+196
-13
lines changed

10 files changed

+196
-13
lines changed

formate/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444

4545
# this package
4646
from formate.classes import FormateConfigDict, Hook
47-
from formate.config import parse_hooks, wants_filename, wants_global_config
47+
from formate.config import get_hooks_for_filetype, parse_hooks, wants_filename, wants_global_config
4848
from formate.utils import _find_from_parents, syntaxerror_for_file
4949

5050
__author__: str = "Dominic Davis-Foster"
@@ -279,6 +279,7 @@ def run(self) -> bool:
279279
"""
280280

281281
hooks = parse_hooks(self.config)
282+
hooks = get_hooks_for_filetype(self.file_to_format.suffix, hooks)
282283
reformatted_source = StringList(call_hooks(hooks, self._unformatted_source, self.filename))
283284
reformatted_source.blankline(ensure_single=True)
284285

formate/__main__.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
from consolekit.tracebacks import handle_tracebacks, traceback_option
3939
from domdf_python_tools.typing import PathLike
4040

41+
# this package
42+
from formate.config import NoSupportedHooksError
43+
4144
__all__ = ("main", "version_callback")
4245

4346

@@ -129,17 +132,29 @@ def main(
129132

130133
path = PathPlus(path)
131134

132-
if path.suffix not in {".py", ".pyi", ''} or path.is_dir(): # pylint: disable=loop-invariant-statement
135+
if path.is_dir(): # pylint: disable=loop-invariant-statement
136+
if verbose >= 2:
137+
click.echo(f"Skipping directory {path}")
138+
139+
continue
140+
141+
if not path.exists(): # pylint: disable=loop-invariant-statement
133142
if verbose >= 2:
134-
click.echo(f"Skipping {path} as it doesn't appear to be a Python file")
143+
click.echo(f"Skipping {path} as it doesn't exist")
135144

136145
continue
137146

138147
r = Reformatter(path, config=config)
139148

140149
with handle_tracebacks(show_traceback, cls=SyntaxTracebackHandler):
141150
with syntaxerror_for_file(path):
142-
ret_for_file = r.run()
151+
try:
152+
ret_for_file = r.run()
153+
except NoSupportedHooksError:
154+
if verbose >= 2:
155+
click.echo(f"Skipping {path} as no hooks support this filetype.")
156+
157+
continue
143158

144159
if ret_for_file:
145160
if verbose:

formate/classes.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
#
2828

2929
# stdlib
30-
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Union
30+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Set, Union
3131

3232
# 3rd party
3333
import attrs
@@ -110,6 +110,14 @@ class Hook:
110110
#: A read-only view on the global configuration mapping, for hooks to do with as they wish.
111111
global_config: Mapping[str, Any] = attrs.field(factory=dict)
112112

113+
@property
114+
def supported_filetypes(self) -> Set[str]:
115+
"""
116+
The extensions of filetypes supported by this hook.
117+
"""
118+
119+
return getattr(self.entry_point.obj, "supported_filetypes", {".py", ".pyi"}) # type: ignore[union-attr]
120+
113121
@classmethod
114122
def parse(cls, data: HooksMapping) -> Iterator["Hook"]:
115123
r"""

formate/config.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,50 @@
3939
from formate.classes import FormateConfigDict, Hook
4040
from formate.utils import import_entry_points
4141

42-
__all__ = ("parse_hooks", "parse_global_config", "load_toml", "wants_global_config", "wants_filename", "_C_str")
42+
__all__ = (
43+
"parse_hooks",
44+
"parse_global_config",
45+
"load_toml",
46+
"wants_global_config",
47+
"wants_filename",
48+
"_C_str",
49+
"HookConfigError",
50+
"NoHooksError",
51+
"NoSupportedHooksError",
52+
"formats_filetypes",
53+
"get_hooks_for_filetype",
54+
)
4355

4456
_C_str = TypeVar("_C_str", bound=Callable[..., str])
4557

4658

59+
class HookConfigError(ValueError):
60+
"""
61+
Base exception for errors with hook configuration.
62+
"""
63+
64+
65+
class NoHooksError(HookConfigError):
66+
"""
67+
Exception for when no hooks are configured.
68+
"""
69+
70+
def __init__(self):
71+
super().__init__("No hooks configured")
72+
73+
74+
class NoSupportedHooksError(HookConfigError):
75+
"""
76+
Exception for when no hooks support the given file type.
77+
78+
:param filetype:
79+
"""
80+
81+
def __init__(self, filetype: str):
82+
super().__init__(f"No supported hooks for this file type ({filetype})")
83+
self.filetype = filetype
84+
85+
4786
def parse_hooks(config: Mapping) -> List[Hook]:
4887
"""
4988
Given a mapping parsed from a TOML file (or similar), return a list of hooks selected by the user.
@@ -68,6 +107,28 @@ def parse_hooks(config: Mapping) -> List[Hook]:
68107
return hooks
69108

70109

110+
def get_hooks_for_filetype(filetype: str, hooks: List[Hook]) -> List[Hook]:
111+
"""
112+
Filters the hooks to those that support the given filetype.
113+
114+
:param filetype:
115+
:param hooks:
116+
"""
117+
118+
if not hooks:
119+
raise NoHooksError()
120+
121+
supported_hooks: List[Hook] = []
122+
for hook in hooks:
123+
if filetype in hook.supported_filetypes:
124+
supported_hooks.append(hook)
125+
126+
if not supported_hooks:
127+
raise NoSupportedHooksError(filetype=filetype)
128+
129+
return supported_hooks
130+
131+
71132
def parse_global_config(config: Mapping) -> MappingProxyType:
72133
"""
73134
Returns a read-only view on the global configuration mapping, for hooks to do with as they wish.
@@ -127,3 +188,19 @@ def wants_filename(func: _C_str) -> _C_str:
127188

128189
func.wants_filename = True # type: ignore[attr-defined]
129190
return func
191+
192+
193+
def formats_filetypes(*filetypes) -> Callable[[_C_str], _C_str]:
194+
r"""
195+
Decorator to indicate to ``formate`` that the hook formats the specified filetypes (as extensions, e.g. ``".js"``.
196+
197+
.. versionadded:: 1.2.0
198+
199+
:param \*filetypes:
200+
"""
201+
202+
def deco(func: _C_str) -> _C_str:
203+
func.supported_filetypes = set(filetypes) # type: ignore[attr-defined]
204+
return func
205+
206+
return deco

formate/mini_hooks.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from domdf_python_tools.typing import PathLike
4040

4141
# this package
42-
from formate.config import wants_filename
42+
from formate.config import formats_filetypes, wants_filename
4343

4444
__all__ = ("check_ast", "newline_after_equals", "noqa_reformat", "squish_stubs")
4545

@@ -71,6 +71,7 @@ def check_ast(source: str) -> str:
7171
return source
7272

7373

74+
@formats_filetypes(".pyi")
7475
@wants_filename
7576
def squish_stubs(source: str, formate_filename: PathLike) -> str:
7677
"""
@@ -87,7 +88,7 @@ def squish_stubs(source: str, formate_filename: PathLike) -> str:
8788
filename = PathPlus(formate_filename)
8889

8990
if filename.suffix != ".pyi":
90-
return source
91+
raise ValueError(f"Unsupported filetype {filename.suffix!r}")
9192

9293
blocks = _breakup_source(source)
9394
return str(_reformat_blocks(blocks))

tests/test_integration.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# stdlib
22
import re
3-
from typing import Union, no_type_check
3+
from typing import List, Mapping, Union, no_type_check
44

55
# 3rd party
66
import pytest
@@ -10,11 +10,14 @@
1010
from consolekit.terminal_colours import strip_ansi
1111
from consolekit.testing import CliRunner, Result, click_version
1212
from domdf_python_tools.paths import PathPlus, in_directory
13+
from domdf_python_tools.typing import PathLike
1314

1415
# this package
16+
import formate
1517
from formate import Reformatter, reformat_file
1618
from formate.__main__ import main
17-
from formate.config import load_toml
19+
from formate.classes import EntryPoint, Hook
20+
from formate.config import formats_filetypes, load_toml, wants_filename
1821

1922
path_sub = re.compile(r" .*/pytest-of-.*/pytest-\d+")
2023

@@ -169,6 +172,32 @@ def test_reformatter_class(
169172
advanced_file_regression.check_file(tmp_pathplus / "code.py")
170173

171174

175+
@pytest.mark.usefixtures("demo_environment")
176+
def test_reformatter_class_non_python_hook(
177+
tmp_pathplus: PathPlus,
178+
monkeypatch,
179+
):
180+
181+
config = load_toml(tmp_pathplus / "formate.toml")
182+
config["hooks"]["format-foo"] = {"priority": 10} # type: ignore[index]
183+
184+
(tmp_pathplus / "code.foo").touch()
185+
186+
@formats_filetypes(".foo")
187+
@wants_filename
188+
def format_foo(source: str, formate_filename: PathLike) -> str:
189+
return "Result of format-foo"
190+
191+
def parse_hooks(config: Mapping) -> List[Hook]:
192+
return [Hook(name="format-foo", entry_point=EntryPoint("format-foo", format_foo))]
193+
194+
monkeypatch.setattr(formate, "parse_hooks", parse_hooks)
195+
r = Reformatter(tmp_pathplus / "code.foo", config)
196+
197+
assert r.run()
198+
assert r.to_string() == "Result of format-foo\n"
199+
200+
172201
@pytest.mark.usefixtures("demo_environment")
173202
def test_cli(
174203
tmp_pathplus: PathPlus,
@@ -243,6 +272,41 @@ def test_cli_verbose_verbose(
243272
check_out(result, advanced_data_regression)
244273

245274

275+
@pytest.mark.usefixtures("demo_environment")
276+
def test_cli_verbose_verbose_no_supported_hooks(
277+
tmp_pathplus: PathPlus,
278+
advanced_file_regression: AdvancedFileRegressionFixture,
279+
advanced_data_regression: AdvancedDataRegressionFixture,
280+
):
281+
282+
result: Result
283+
(tmp_pathplus / "code.c").touch()
284+
(tmp_pathplus / "a_dir").mkdir()
285+
286+
with in_directory(tmp_pathplus):
287+
runner = CliRunner(mix_stderr=False)
288+
result = runner.invoke(
289+
main,
290+
args=["code.py", "a_dir", "--no-colour", "--diff", "--verbose", "-v"],
291+
)
292+
293+
assert result.exit_code == 1
294+
295+
advanced_file_regression.check_file(tmp_pathplus / "code.py")
296+
297+
# Calling a second time shouldn't change anything
298+
with in_directory(tmp_pathplus):
299+
runner = CliRunner(mix_stderr=False)
300+
result = runner.invoke(
301+
main,
302+
args=["code.py", "code.c", "--no-colour", "--diff", "--verbose", "-v"],
303+
)
304+
305+
assert result.exit_code == 0
306+
307+
check_out(result, advanced_data_regression)
308+
309+
246310
@pytest.mark.usefixtures("demo_environment")
247311
@max_version("3.9.9", reason="Output differs on Python 3.10+")
248312
@not_pypy("Output differs on PyPy")

tests/test_integration_/test_cli_verbose_verbose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ err:
22
- ''
33
out:
44
- Checking code.py
5-
- Skipping code.c as it doesn't appear to be a Python file
5+
- Skipping code.c as it doesn't exist
66
- ''
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class F:
2+
# stdlib
3+
from collections import Counter
4+
from collections.abc import Iterable
5+
6+
def foo(self):
7+
pass
8+
9+
10+
print("hello world")
11+
assert t.uname == "\udce4\udcf6\udcfc"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
err:
2+
- ''
3+
out:
4+
- Checking code.py
5+
- Skipping code.c as no hooks support this filetype.
6+
- ''

tests/test_mini_hooks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,8 @@ def foo():
252252
new_code = squish_stubs(code, formate_filename="code.pyi")
253253
assert new_code != code
254254

255-
new_code = squish_stubs(code, formate_filename="code.py")
256-
assert new_code == code
255+
with pytest.raises(ValueError, match=r"Unsupported filetype '\.py'"):
256+
squish_stubs(code, formate_filename="code.py")
257257

258258

259259
newline_after_equals_src = """

0 commit comments

Comments
 (0)