Skip to content

Commit 2afa4b2

Browse files
committed
Implement --template-tags CLI flag
1 parent 28316ee commit 2afa4b2

File tree

7 files changed

+157
-29
lines changed

7 files changed

+157
-29
lines changed

curlylint/cli.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import re
22
from functools import partial
33
from pathlib import Path
4-
from typing import Any, Dict, Mapping, Optional, Pattern, Set, Tuple, Union
4+
from typing import (
5+
Any,
6+
Dict,
7+
List,
8+
Mapping,
9+
Optional,
10+
Pattern,
11+
Set,
12+
Tuple,
13+
Union,
14+
)
515

616
import click # lgtm [py/import-and-import-from]
717

818
from curlylint.rule_param import RULE
19+
from curlylint.template_tags_param import TEMPLATE_TAGS
920

1021
from . import __version__
1122
from .config import (
@@ -122,6 +133,15 @@ def path_empty(
122133
),
123134
multiple=True,
124135
)
136+
@click.option(
137+
"--template-tags",
138+
type=TEMPLATE_TAGS,
139+
default="[]",
140+
help=(
141+
'Specify additional sets of template tags, with the syntax --template-tags \'[["cache", "endcache"]]\'. '
142+
),
143+
show_default=True,
144+
)
125145
@click.argument(
126146
"src",
127147
nargs=-1,
@@ -161,6 +181,7 @@ def main(
161181
include: str,
162182
exclude: str,
163183
rule: Union[Mapping[str, Any], Tuple[Mapping[str, Any], ...]],
184+
template_tags: List[List[str]],
164185
src: Tuple[str, ...],
165186
) -> None:
166187
"""Prototype linter for Jinja and Django templates, forked from jinjalint"""
@@ -236,6 +257,7 @@ def main(
236257
configuration["rules"] = rules
237258
configuration["verbose"] = verbose
238259
configuration["parse_only"] = parse_only
260+
configuration["template_tags"] = template_tags
239261

240262
if stdin_filepath:
241263
configuration["stdin_filepath"] = Path(stdin_filepath)

curlylint/cli_test.py

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,53 @@
11
import unittest
22

33
from io import BytesIO
4+
from typing import List
45

56
from curlylint.tests.utils import BlackRunner
67

78
from curlylint.cli import main
89

910

10-
class TestParser(unittest.TestCase):
11-
def test_no_flag(self):
11+
class TestCLI(unittest.TestCase):
12+
"""
13+
Heavily inspired by Black’s CLI tests.
14+
See https://github.com/psf/black/blob/master/tests/test_black.py.
15+
"""
16+
17+
def invoke_curlylint(
18+
self, exit_code: int, args: List[str], input: str = None
19+
):
1220
runner = BlackRunner()
13-
result = runner.invoke(main, [])
21+
result = runner.invoke(
22+
main, args, input=BytesIO(input.encode("utf8")) if input else None
23+
)
24+
self.assertEqual(
25+
result.exit_code,
26+
exit_code,
27+
msg=(
28+
f"Failed with args: {args}\n"
29+
f"stdout: {runner.stdout_bytes.decode()!r}\n"
30+
f"stderr: {runner.stderr_bytes.decode()!r}\n"
31+
f"exception: {result.exception}"
32+
),
33+
)
34+
return runner
35+
36+
def test_no_flag(self):
37+
runner = self.invoke_curlylint(0, [])
1438
self.assertEqual(runner.stdout_bytes.decode(), "")
1539
self.assertEqual(
1640
runner.stderr_bytes.decode(), "No Path provided. Nothing to do 😴\n"
1741
)
18-
self.assertEqual(result.exit_code, 0)
1942

2043
def test_stdin(self):
21-
runner = BlackRunner()
22-
result = runner.invoke(
23-
main, ["-"], input=BytesIO("<p>Hello, World!</p>".encode("utf8")),
24-
)
44+
runner = self.invoke_curlylint(0, ["-"], input="<p>Hello, World!</p>")
2545
self.assertEqual(runner.stdout_bytes.decode(), "")
2646
self.assertEqual(runner.stderr_bytes.decode(), "All done! ✨ 🍰 ✨\n\n")
27-
self.assertEqual(result.exit_code, 0)
2847

2948
def test_stdin_verbose(self):
30-
runner = BlackRunner()
31-
result = runner.invoke(
32-
main,
33-
["--verbose", "-"],
34-
input=BytesIO("<p>Hello, World!</p>".encode("utf8")),
49+
runner = self.invoke_curlylint(
50+
0, ["--verbose", "-"], input="<p>Hello, World!</p>"
3551
)
3652
self.assertEqual(runner.stdout_bytes.decode(), "")
3753
self.assertIn(
@@ -45,14 +61,40 @@ def test_stdin_verbose(self):
4561
""",
4662
runner.stderr_bytes.decode(),
4763
)
48-
self.assertEqual(result.exit_code, 0)
4964

5065
def test_flag_help(self):
51-
runner = BlackRunner()
52-
result = runner.invoke(main, ["--help"])
66+
runner = self.invoke_curlylint(0, ["--help"])
5367
self.assertIn(
5468
"Prototype linter for Jinja and Django templates",
5569
runner.stdout_bytes.decode(),
5670
)
5771
self.assertEqual(runner.stderr_bytes.decode(), "")
58-
self.assertEqual(result.exit_code, 0)
72+
73+
def test_template_tags_validation_fail_no_nesting(self):
74+
runner = self.invoke_curlylint(
75+
2,
76+
["--template-tags", '["cache", "endcache"]', "-"],
77+
input="<p>Hello, World!</p>",
78+
)
79+
self.assertIn(
80+
"Error: Invalid value for '--template-tags': expected a list of lists of tags as JSON, got '[\"cache\", \"endcache\"]'",
81+
runner.stderr_bytes.decode(),
82+
)
83+
84+
def test_template_tags_cli_configured(self):
85+
self.invoke_curlylint(
86+
0,
87+
["--template-tags", '[["of", "elseof", "endof"]]', "-"],
88+
input="<p>{% of a %}c{% elseof %}test{% endof %}</p>",
89+
)
90+
91+
def test_template_tags_cli_unconfigured_fails(self):
92+
runner = self.invoke_curlylint(
93+
1,
94+
["--template-tags", "[]", "-"],
95+
input="<p>{% of a %}c{% elseof %}test{% endof %}</p>",
96+
)
97+
self.assertIn(
98+
"Parse error: expected one of 'autoescape', 'block', 'blocktrans', 'comment', 'filter', 'for', 'if', 'ifchanged', 'ifequal', 'ifnotequal', 'not an intermediate Jinja tag name', 'spaceless', 'verbatim', 'with' at 0:17\tparse_error",
99+
runner.stdout_bytes.decode(),
100+
)

curlylint/parse.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,8 @@ def make_jinja_parser(config, content):
592592
(names[0], names)
593593
for names in (
594594
DEFAULT_JINJA_STRUCTURED_ELEMENTS_NAMES
595+
+ config.get("template_tags", [])
596+
# Deprecated, will be removed in a future release.
595597
+ config.get("jinja_custom_elements_names", [])
596598
)
597599
).values()

curlylint/parse_test.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -372,25 +372,25 @@ def test_jinja_blocks(self):
372372
src = "{% if a %}b{% elif %}c{% elif %}d{% else %}e{% endif %}"
373373
self.assertEqual(src, str(jinja.parse(src)))
374374

375-
def test_jinja_custom_block_self_closing(self):
375+
def test_jinja_custom_tag_self_closing(self):
376376
self.assertEqual(
377-
jinja.parse("{% exampletest %}"),
377+
jinja.parse("{% potato %}"),
378378
JinjaElement(
379379
parts=[
380380
JinjaElementPart(
381-
tag=JinjaTag(name="exampletest", content=""),
382-
content=None,
381+
tag=JinjaTag(name="potato", content=""), content=None,
383382
)
384383
],
385384
closing_tag=None,
386385
),
387386
)
388387

389-
def test_jinja_custom_block_open_close_unconfigured(self):
388+
def test_jinja_custom_tag_open_close_unconfigured(self):
390389
with pytest.raises(P.ParseError):
391390
jinja.parse("{% of a %}c{% endof %}")
392391

393-
def test_jinja_custom_block_open_close_configured(self):
392+
def test_jinja_custom_tag_open_close_configured_deprecated(self):
393+
# Deprecated, will be removed in a future release.
394394
parser = make_parser({"jinja_custom_elements_names": [["of", "endof"]]})
395395
jinja = parser["jinja"]
396396
self.assertEqual(
@@ -406,11 +406,27 @@ def test_jinja_custom_block_open_close_configured(self):
406406
),
407407
)
408408

409-
def test_jinja_custom_block_open_middle_close_unconfigured(self):
409+
def test_jinja_custom_tag_open_close_configured(self):
410+
parser = make_parser({"template_tags": [["of", "endof"]]})
411+
jinja = parser["jinja"]
412+
self.assertEqual(
413+
jinja.parse("{% of a %}c{% endof %}"),
414+
JinjaElement(
415+
parts=[
416+
JinjaElementPart(
417+
tag=JinjaTag(name="of", content="a"),
418+
content=Interp(["c"]),
419+
),
420+
],
421+
closing_tag=JinjaTag(name="endof", content=""),
422+
),
423+
)
424+
425+
def test_jinja_custom_tag_open_middle_close_unconfigured(self):
410426
with pytest.raises(P.ParseError):
411427
jinja.parse("{% of a %}b{% elseof %}c{% endof %}")
412428

413-
def test_jinja_custom_block_open_middle_close(self):
429+
def test_jinja_custom_tag_open_middle_close(self):
414430
parser = make_parser(
415431
{"jinja_custom_elements_names": [["of", "elseof", "endof"]]}
416432
)

curlylint/template_tags_param.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import json
2+
3+
import click
4+
5+
6+
class TemplateTagsParamType(click.ParamType):
7+
"""
8+
Validates and converts CLI-provided template tags configuration.
9+
Expects: --template-tags '[["cache", "endcache"]]'
10+
"""
11+
12+
name = "template tags"
13+
14+
def convert(self, value, param, ctx):
15+
try:
16+
if isinstance(value, str):
17+
template_tags = json.loads(value)
18+
else:
19+
template_tags = value
20+
# Validate the expected list of lists.
21+
if not isinstance(template_tags, (list, tuple)):
22+
raise ValueError
23+
for tags in template_tags:
24+
if not isinstance(tags, (list, tuple)):
25+
raise ValueError
26+
return template_tags
27+
except ValueError:
28+
self.fail(
29+
f"expected a list of lists of tags as JSON, got {value!r}",
30+
param,
31+
ctx,
32+
)
33+
34+
35+
TEMPLATE_TAGS = TemplateTagsParamType()

example_pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
[tool.curlylint]
22
# Specify additional Jinja elements which can wrap HTML here. You
3-
# don't neet to specify simple elements which can't wrap anything like
3+
# dont neet to specify simple elements which can't wrap anything like
44
# {% extends %} or {% include %}.
5-
jinja-custom-elements-names = [
5+
template_tags = [
6+
["of", "elseof", "endof"],
67
["cache", "endcache"],
78
["captureas", "endcaptureas"]
89
]

website/docs/command-line-usage.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ curlylint --rule 'html_has_lang: "en"' template-directory/
8181
curlylint --rule 'html_has_lang: ["en", "en-US"]' template-directory/
8282
```
8383

84+
### `--template-tags`
85+
86+
Specify additional sets of template tags, with the syntax `--template-tags '[["start_tag", "end_tag"]]'`. This is only needed for tags that wrap other markup (like `{% block %}<p>Hello!</p>{% endblock %}`), not for single / “void” tags.
87+
88+
🚧 Note the list of lists is formatted as JSON, with each sub-list containing the tags expected to work together as opening/intermediary/closing tags.
89+
90+
```bash
91+
curlylint --template-tags '[["cache", "endcache"]]' template-directory/
92+
```
93+
8494
### `--config`
8595

8696
Read configuration from the provided file.

0 commit comments

Comments
 (0)