Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions proselint/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Configuration for proselint."""

import json
from collections.abc import Hashable
from importlib.resources import files
from pathlib import Path
from typing import TypedDict
from typing import TypedDict, TypeVar, cast
from warnings import showwarning as warn

from proselint import config
Expand All @@ -23,14 +24,22 @@ class Config(TypedDict):
checks: dict[str, bool]


DEFAULT: Config = json.loads((files(config) / "default.json").read_text())
DEFAULT = cast(
"Config",
json.loads((files(config) / "default.json").read_text())
)

KT_co = TypeVar("KT_co", bound=Hashable, covariant=True)
VT_co = TypeVar("VT_co", covariant=True)

def _deepmerge_dicts(base: dict, overrides: dict) -> dict:

def _deepmerge_dicts(
base: dict[KT_co, VT_co], overrides: dict[KT_co, VT_co]
) -> dict[KT_co, VT_co]:
# fmt: off
return base | overrides | {
key: (
_deepmerge_dicts(b_value, o_value)
_deepmerge_dicts(b_value, o_value) # pyright: ignore[reportUnknownArgumentType]
if isinstance(b_value := base[key], dict)
else o_value
)
Expand All @@ -50,9 +59,9 @@ def load_from(config_path: Path | None = None) -> Config:

for path in config_paths:
if path.is_file():
result: Config = _deepmerge_dicts(
result,
json.loads(path.read_text()),
result = _deepmerge_dicts(
dict(result),
json.loads(path.read_text()), # pyright: ignore[reportAny]
)
if path.suffix == ".json" and (old := path.with_suffix("")).is_file():
warn(
Expand All @@ -62,4 +71,4 @@ def load_from(config_path: Path | None = None) -> Config:
0,
)

return result
return cast("Config", result)
43 changes: 32 additions & 11 deletions proselint/registry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

from __future__ import annotations

from collections.abc import Mapping
from importlib import import_module
from itertools import chain
from typing import TYPE_CHECKING, ClassVar
from typing import TYPE_CHECKING, ClassVar, TypeAlias

from proselint.config import DEFAULT

Expand All @@ -24,6 +25,22 @@ def build_modules_register(
)


Config: TypeAlias = Mapping[str, "bool | Config"]


def _flatten_config(config: Config, prefix: str = "") -> dict[str, bool]:
return dict(
chain.from_iterable(
_flatten_config(value, full_key).items()
if isinstance(value, Mapping)
else [(full_key, bool(value))]
for key, value in config.items()
for full_key in [f"{prefix}.{key}" if prefix else key]

)
)


class CheckRegistry:
"""A global registry for lint checks."""

Expand Down Expand Up @@ -54,19 +71,23 @@ def get_all_enabled(
self, enabled: dict[str, bool] = DEFAULT["checks"]
) -> list[Check]:
"""Filter registered checks by config values based on their keys."""
self.enabled_checks = enabled

enabled_checks: list[str] = []
skipped_checks: list[str] = []
for key, key_enabled in self.enabled_checks.items():
(skipped_checks, enabled_checks)[key_enabled].append(key)
self.enabled_checks = _flatten_config(enabled)
by_specificity = sorted(
self.enabled_checks.items(),
key=lambda x: x[0].count("."),
reverse=True,
)

return [
check
for check in self.checks
if not any(check.matches_partial(key) for key in skipped_checks)
and any(
check.path == key or check.matches_partial(key)
for key in enabled_checks
if next(
(
value
for prefix, value in by_specificity
if check.path == prefix
or check.path.startswith(prefix + ".")
),
False,
)
]
2 changes: 1 addition & 1 deletion tests/test-proselintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lexical_illusions": true,
"malapropisms": true,
"misc": true,
"mixed_metaphors": true,
"mixed_metaphors": true,
"mondegreens": true,
"needless_variants": true,
"nonwords": true,
Expand Down
41 changes: 40 additions & 1 deletion tests/test_config_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
from proselint.command_line import get_parser, proselint
from proselint.config import (
DEFAULT,
_deepmerge_dicts, # pyright: ignore[reportUnknownVariableType, reportPrivateUsage]
_deepmerge_dicts, # pyright: ignore[reportPrivateUsage]
load_from,
)
from proselint.registry import CheckRegistry

CONFIG_FILE = Path(__file__).parent / "test-proselintrc.json"
PARSER = get_parser()
Expand All @@ -29,6 +30,44 @@ def test_deepmerge_dicts() -> None:
}


def test_specific_overrides_general() -> None:
"""Test that specific config keys override general ones."""
checks = {
"typography": True,
"typography.symbols": False,
"typography.symbols.curly_quotes": True,
"typography.punctuation.hyperbole": False,
}

registry = CheckRegistry()
enabled = registry.get_all_enabled(checks)

paths = {check.path for check in enabled}

assert any(
"typography.symbols.curly_quotes" in path
for path in paths
)

assert len(
[path for path in paths
if path.startswith("typography.symbols.")
and "curly_quotes" not in path]
) == 0

assert not any(
"typography.punctuation.hyperbole" in path
for path in paths
)

assert any(
path.startswith("typography.")
and not path.startswith("typography.symbols.")
and "hyperbole" not in path
for path in paths
)


def test_load_from() -> None:
"""Test load_options by specifying a user options path."""
overrides = load_from(CONFIG_FILE)
Expand Down