Skip to content

Commit ddebe9f

Browse files
feat: override keys and dot-prop (amperser#1440)
- Fix typings in `config.py` - Implement configuration flattening for dot-propping - Implement overriding in `get_all_enabled` - Test overriding behaviour --------- Signed-off-by: drainpixie <121581793+drainpixie@users.noreply.github.com> Co-authored-by: Tyler J Russell <xtylerjrx@gmail.com>
1 parent 9fa6339 commit ddebe9f

File tree

5 files changed

+141
-20
lines changed

5 files changed

+141
-20
lines changed

proselint/config/__init__.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Configuration for proselint."""
22

33
import json
4+
from collections.abc import Hashable, Mapping
45
from importlib.resources import files
6+
from itertools import chain
57
from pathlib import Path
6-
from typing import TypedDict
8+
from typing import TypeAlias, TypedDict, TypeVar, cast
79
from warnings import showwarning as warn
810

911
from proselint import config
@@ -23,14 +25,22 @@ class Config(TypedDict):
2325
checks: dict[str, bool]
2426

2527

26-
DEFAULT: Config = json.loads((files(config) / "default.json").read_text())
28+
DEFAULT = cast(
29+
"Config", json.loads((files(config) / "default.json").read_text())
30+
)
2731

32+
Checks: TypeAlias = Mapping[str, "bool | Checks"]
33+
KT_co = TypeVar("KT_co", bound=Hashable, covariant=True)
34+
VT_co = TypeVar("VT_co", covariant=True)
2835

29-
def _deepmerge_dicts(base: dict, overrides: dict) -> dict:
36+
37+
def _deepmerge_dicts(
38+
base: dict[KT_co, VT_co], overrides: dict[KT_co, VT_co]
39+
) -> dict[KT_co, VT_co]:
3040
# fmt: off
3141
return base | overrides | {
3242
key: (
33-
_deepmerge_dicts(b_value, o_value)
43+
_deepmerge_dicts(b_value, o_value) # pyright: ignore[reportUnknownArgumentType]
3444
if isinstance(b_value := base[key], dict)
3545
else o_value
3646
)
@@ -39,6 +49,29 @@ def _deepmerge_dicts(base: dict, overrides: dict) -> dict:
3949
}
4050

4151

52+
def _flatten_checks(checks: Checks, prefix: str = "") -> dict[str, bool]:
53+
return dict(
54+
chain.from_iterable(
55+
[(full_key, value)]
56+
if isinstance(value, bool)
57+
else _flatten_checks(value, full_key).items()
58+
for key, value in checks.items()
59+
for full_key in [f"{prefix}.{key}" if prefix else key]
60+
)
61+
)
62+
63+
64+
def _sort_by_specificity(checks: dict[str, bool]) -> dict[str, bool]:
65+
"""Sort selected checks by depth (specificity) in descending order."""
66+
return dict(
67+
sorted(
68+
checks.items(),
69+
key=lambda x: x[0].count("."),
70+
reverse=True,
71+
)
72+
)
73+
74+
4275
def load_from(config_path: Path | None = None) -> Config:
4376
"""
4477
Read various config paths, allowing user overrides.
@@ -50,9 +83,9 @@ def load_from(config_path: Path | None = None) -> Config:
5083

5184
for path in config_paths:
5285
if path.is_file():
53-
result: Config = _deepmerge_dicts(
54-
result,
55-
json.loads(path.read_text()),
86+
result = _deepmerge_dicts(
87+
cast("dict[str, object]", result),
88+
json.loads(path.read_text()), # pyright: ignore[reportAny]
5689
)
5790
if path.suffix == ".json" and (old := path.with_suffix("")).is_file():
5891
warn(
@@ -62,4 +95,9 @@ def load_from(config_path: Path | None = None) -> Config:
6295
0,
6396
)
6497

65-
return result
98+
result = cast("Config", result)
99+
100+
return Config(
101+
max_errors=result.get("max_errors", 0),
102+
checks=_sort_by_specificity(_flatten_checks(result.get("checks", {}))),
103+
)

proselint/registry/__init__.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,24 @@ def checks(self) -> list[Check]:
5353
def get_all_enabled(
5454
self, enabled: dict[str, bool] = DEFAULT["checks"]
5555
) -> list[Check]:
56-
"""Filter registered checks by config values based on their keys."""
57-
self.enabled_checks = enabled
56+
"""
57+
Filter registered checks by config values based on their keys.
5858
59-
enabled_checks: list[str] = []
60-
skipped_checks: list[str] = []
61-
for key, key_enabled in self.enabled_checks.items():
62-
(skipped_checks, enabled_checks)[key_enabled].append(key)
59+
This assumes that keys are not nested, and sorted in descending order
60+
of depth (specificity). For example, all keys should look like
61+
`a.b.c`, and `a.b.c` should come before `a.b`.
62+
"""
63+
self.enabled_checks = enabled
6364

6465
return [
6566
check
6667
for check in self.checks
67-
if not any(check.matches_partial(key) for key in skipped_checks)
68-
and any(
69-
check.path == key or check.matches_partial(key)
70-
for key in enabled_checks
68+
if next(
69+
(
70+
value
71+
for prefix, value in self.enabled_checks.items()
72+
if check.matches_partial(prefix)
73+
),
74+
False,
7175
)
7276
]

tests/test-proselintrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"lexical_illusions": true,
1111
"malapropisms": true,
1212
"misc": true,
13-
"mixed_metaphors": true,
13+
"mixed_metaphors": true,
1414
"mondegreens": true,
1515
"needless_variants": true,
1616
"nonwords": true,

tests/test_config_flag.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from proselint.command_line import get_parser, proselint
99
from proselint.config import (
1010
DEFAULT,
11-
_deepmerge_dicts, # pyright: ignore[reportUnknownVariableType, reportPrivateUsage]
11+
_deepmerge_dicts, # pyright: ignore[reportPrivateUsage]
12+
_flatten_checks, # pyright: ignore[reportPrivateUsage]
13+
_sort_by_specificity, # pyright: ignore[reportPrivateUsage]
1214
load_from,
1315
)
1416

@@ -29,6 +31,44 @@ def test_deepmerge_dicts() -> None:
2931
}
3032

3133

34+
def test_sort_by_specificity() -> None:
35+
"""Test sort_by_specificity sorts by dot count descending."""
36+
unsorted = {
37+
"a": True,
38+
"a.b.c": False,
39+
"x.y": True,
40+
"a.b": True,
41+
}
42+
43+
sorted_checks = _sort_by_specificity(unsorted)
44+
keys = list(sorted_checks.keys())
45+
46+
dots = [key.count(".") for key in keys]
47+
48+
assert dots == sorted(dots, reverse=True)
49+
assert keys[0] == "a.b.c"
50+
assert keys[-1] == "a"
51+
52+
assert sorted_checks["a.b.c"] is False
53+
assert sorted_checks["a"] is True
54+
55+
56+
def test_flatten_checks() -> None:
57+
"""Test flatten_checks."""
58+
assert _flatten_checks({"a": True, "b": False}) == {
59+
"a": True,
60+
"b": False,
61+
}
62+
63+
assert _flatten_checks({"x": {"y": True, "z": False}, "w": True}) == {
64+
"x.y": True,
65+
"x.z": False,
66+
"w": True,
67+
}
68+
69+
assert _flatten_checks({"a": {"b": {"c": True}}}) == {"a.b.c": True}
70+
71+
3272
def test_load_from() -> None:
3373
"""Test load_options by specifying a user options path."""
3474
overrides = load_from(CONFIG_FILE)

tests/test_registry.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Test the registry module."""
2+
3+
from proselint.config import (
4+
_sort_by_specificity, # pyright: ignore[reportPrivateUsage]
5+
)
6+
from proselint.registry import CheckRegistry
7+
8+
9+
def test_specific_overrides_general() -> None:
10+
"""Test that specific config keys override general ones."""
11+
checks = _sort_by_specificity(
12+
{
13+
"typography": True,
14+
"typography.symbols": False,
15+
"typography.symbols.curly_quotes": True,
16+
"typography.punctuation.hyperbole": False,
17+
}
18+
)
19+
20+
registry = CheckRegistry()
21+
enabled = registry.get_all_enabled(checks)
22+
23+
paths = {check.path for check in enabled}
24+
25+
assert "typography.symbols.curly_quotes" in paths
26+
assert "typography.punctuation.hyperbole" not in paths
27+
28+
assert all(
29+
path == "typography.symbols.curly_quotes"
30+
or not path.startswith("typography.symbols.")
31+
for path in paths
32+
)
33+
34+
assert any(
35+
path.startswith("typography.")
36+
and not path.startswith("typography.symbols.")
37+
and path != "typography.punctuation.hyperbole"
38+
for path in paths
39+
)

0 commit comments

Comments
 (0)