ruff-sync 0.1.5.dev2__py3-none-any.whl → 0.1.5.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ruff_sync/__init__.py +2 -0
- ruff_sync/cli.py +25 -9
- ruff_sync/dependencies.py +37 -11
- ruff_sync/system.py +136 -5
- ruff_sync/tui/__init__.py +26 -0
- ruff_sync/tui/app.py +304 -0
- ruff_sync/tui/constants.py +9 -0
- ruff_sync/tui/screens.py +185 -0
- ruff_sync/tui/themes.py +21 -0
- ruff_sync/tui/widgets.py +293 -0
- {ruff_sync-0.1.5.dev2.dist-info → ruff_sync-0.1.5.dev3.dist-info}/METADATA +1 -1
- ruff_sync-0.1.5.dev3.dist-info/RECORD +21 -0
- {ruff_sync-0.1.5.dev2.dist-info → ruff_sync-0.1.5.dev3.dist-info}/entry_points.txt +1 -0
- ruff_sync-0.1.5.dev2.dist-info/RECORD +0 -15
- {ruff_sync-0.1.5.dev2.dist-info → ruff_sync-0.1.5.dev3.dist-info}/WHEEL +0 -0
- {ruff_sync-0.1.5.dev2.dist-info → ruff_sync-0.1.5.dev3.dist-info}/licenses/LICENSE.md +0 -0
ruff_sync/__init__.py
CHANGED
|
@@ -9,6 +9,7 @@ from .cli import (
|
|
|
9
9
|
Arguments,
|
|
10
10
|
__version__,
|
|
11
11
|
get_config,
|
|
12
|
+
inspect,
|
|
12
13
|
main,
|
|
13
14
|
)
|
|
14
15
|
from .config_io import (
|
|
@@ -46,6 +47,7 @@ __all__ = [
|
|
|
46
47
|
"get_formatter",
|
|
47
48
|
"get_ruff_config",
|
|
48
49
|
"get_ruff_tool_table",
|
|
50
|
+
"inspect",
|
|
49
51
|
"is_ruff_toml_file",
|
|
50
52
|
"load_local_ruff_config",
|
|
51
53
|
"main",
|
ruff_sync/cli.py
CHANGED
|
@@ -42,6 +42,7 @@ from ruff_sync.core import (
|
|
|
42
42
|
pull,
|
|
43
43
|
resolve_raw_url,
|
|
44
44
|
)
|
|
45
|
+
from ruff_sync.dependencies import DependencyError
|
|
45
46
|
|
|
46
47
|
if TYPE_CHECKING:
|
|
47
48
|
from collections.abc import Iterable, Mapping
|
|
@@ -378,6 +379,9 @@ def _resolve_upstream(args: CLIArguments, config: Config) -> tuple[URL, ...]:
|
|
|
378
379
|
f"got {type(config_upstream).__name__}"
|
|
379
380
|
)
|
|
380
381
|
|
|
382
|
+
if args.command == "inspect":
|
|
383
|
+
return ()
|
|
384
|
+
|
|
381
385
|
PARSER.error(
|
|
382
386
|
"❌ the following arguments are required: upstream "
|
|
383
387
|
f"(or define it in [tool.ruff-sync] in {RuffConfigFileName.PYPROJECT_TOML}) 💥"
|
|
@@ -599,25 +603,37 @@ def main() -> int:
|
|
|
599
603
|
|
|
600
604
|
try:
|
|
601
605
|
if exec_args.command == "inspect":
|
|
602
|
-
from ruff_sync.
|
|
603
|
-
|
|
604
|
-
try:
|
|
605
|
-
require_dependency("textual", extra_name="tui")
|
|
606
|
-
except ImportError as e:
|
|
607
|
-
LOGGER.error(f"❌ {e}") # noqa: TRY400
|
|
608
|
-
return 1
|
|
606
|
+
from ruff_sync.tui import get_tui_app # noqa: PLC0415
|
|
609
607
|
|
|
610
|
-
|
|
611
|
-
return
|
|
608
|
+
app_class = get_tui_app()
|
|
609
|
+
return app_class(exec_args).run() or 0
|
|
612
610
|
|
|
613
611
|
if exec_args.command == "check":
|
|
614
612
|
return asyncio.run(check(exec_args))
|
|
615
613
|
return asyncio.run(pull(exec_args))
|
|
614
|
+
except DependencyError as e:
|
|
615
|
+
LOGGER.error(f"❌ {e}") # noqa: TRY400
|
|
616
|
+
return 1
|
|
616
617
|
except UpstreamError as e:
|
|
617
618
|
for url, err in e.errors:
|
|
618
619
|
LOGGER.error(f"❌ Failed to fetch {url}: {err}") # noqa: TRY400
|
|
619
620
|
return 4
|
|
620
621
|
|
|
621
622
|
|
|
623
|
+
def inspect() -> int:
|
|
624
|
+
"""Entry point for the ruff-inspect console script."""
|
|
625
|
+
# Handle optional subcommands/args if user passed any to ruff-inspect
|
|
626
|
+
# but primarily ensure "inspect" is the command.
|
|
627
|
+
if len(sys.argv) > 1 and sys.argv[1] not in ("-h", "--help", "--version"):
|
|
628
|
+
# If they passed args but no command, insert 'inspect'
|
|
629
|
+
if sys.argv[1] not in ("pull", "check", "inspect"):
|
|
630
|
+
sys.argv.insert(1, "inspect")
|
|
631
|
+
else:
|
|
632
|
+
# Default to 'inspect' if no args or just flags
|
|
633
|
+
sys.argv.insert(1, "inspect")
|
|
634
|
+
|
|
635
|
+
return main()
|
|
636
|
+
|
|
637
|
+
|
|
622
638
|
if __name__ == "__main__":
|
|
623
639
|
sys.exit(main())
|
ruff_sync/dependencies.py
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import importlib
|
|
5
6
|
import importlib.util
|
|
6
|
-
from typing import Final
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__: Final[list[str]] = ["DependencyError", "is_installed", "require_dependency"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DependencyError(Exception):
|
|
17
|
+
"""Raised when a required optional dependency is missing or broken."""
|
|
9
18
|
|
|
10
19
|
|
|
11
20
|
def is_installed(package_name: str) -> bool:
|
|
@@ -20,19 +29,36 @@ def is_installed(package_name: str) -> bool:
|
|
|
20
29
|
return importlib.util.find_spec(package_name) is not None
|
|
21
30
|
|
|
22
31
|
|
|
23
|
-
def require_dependency(
|
|
24
|
-
|
|
32
|
+
def require_dependency(
|
|
33
|
+
package_name: str,
|
|
34
|
+
extra_name: str,
|
|
35
|
+
*,
|
|
36
|
+
_is_installed: Callable[[str], bool] = is_installed,
|
|
37
|
+
_import_module: Callable[[str], Any] = importlib.import_module,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Ensure a dependency is installed and importable, or raise DependencyError.
|
|
25
40
|
|
|
26
41
|
Args:
|
|
27
42
|
package_name: The name of the required package.
|
|
28
43
|
extra_name: The name of the ruff-sync extra that provides this package.
|
|
44
|
+
_is_installed: Internal use only for testing. Function to check if a package is installed.
|
|
45
|
+
_import_module: Internal use only for testing. Function to import a module.
|
|
29
46
|
|
|
30
47
|
Raises:
|
|
31
|
-
|
|
48
|
+
DependencyError: If the package is not installed or raises an error during import.
|
|
32
49
|
"""
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
msg = (
|
|
51
|
+
f"The '{package_name}' package is required for this feature. "
|
|
52
|
+
f"Install it with: pip install 'ruff-sync[{extra_name}]'"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 1. Fast check (dry) to see if it exists at all
|
|
56
|
+
if not _is_installed(package_name):
|
|
57
|
+
raise DependencyError(msg)
|
|
58
|
+
|
|
59
|
+
# 2. Wet check (real import) to ensure it's functional
|
|
60
|
+
try:
|
|
61
|
+
_import_module(package_name)
|
|
62
|
+
except (ImportError, ModuleNotFoundError) as e:
|
|
63
|
+
# If it failed here, it's installed but BROKEN (e.g. missing sub-dependencies)
|
|
64
|
+
raise DependencyError(msg) from e
|
ruff_sync/system.py
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
|
-
from typing import Final
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping
|
|
8
12
|
|
|
9
13
|
LOGGER = logging.getLogger(__name__)
|
|
10
14
|
|
|
@@ -20,6 +24,132 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None:
|
|
|
20
24
|
or the rule is not found.
|
|
21
25
|
"""
|
|
22
26
|
cmd: Final[list[str]] = ["ruff", "rule", rule_code]
|
|
27
|
+
return await _run_ruff_command(cmd, f"ruff rule {rule_code}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_ruff_config_markdown(setting_path: str) -> str | None:
|
|
31
|
+
"""Execute `ruff config <SETTING>` and return the Markdown documentation.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
setting_path: The Ruff configuration setting path (e.g., 'lint.select').
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The Markdown documentation for the setting, or None if the execution fails.
|
|
38
|
+
"""
|
|
39
|
+
# Strip 'tool.ruff.' prefix if present as 'ruff config' expects relative paths
|
|
40
|
+
clean_path = setting_path.removeprefix("tool.ruff.")
|
|
41
|
+
if not clean_path or clean_path == "tool.ruff":
|
|
42
|
+
return None
|
|
43
|
+
cmd: Final[list[str]] = ["ruff", "config", clean_path]
|
|
44
|
+
return await _run_ruff_command(cmd, f"ruff config {clean_path}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_all_ruff_rules() -> list[dict[str, Any]]:
|
|
48
|
+
"""Execute `ruff rule --all --output-format json` and return the parsed rules.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A list of dictionaries representing all supported Ruff rules.
|
|
52
|
+
"""
|
|
53
|
+
cmd: Final[list[str]] = ["ruff", "rule", "--all", "--output-format", "json"]
|
|
54
|
+
output = await _run_ruff_command(cmd, "ruff rule --all")
|
|
55
|
+
if not output:
|
|
56
|
+
return []
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(output)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
LOGGER.exception("Failed to parse Ruff rules JSON.")
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
if isinstance(data, list):
|
|
64
|
+
return data
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def get_ruff_linters() -> list[dict[str, Any]]:
|
|
69
|
+
"""Execute `ruff linter --output-format json` and return the parsed linters.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A list of dictionaries representing Ruff linter categories.
|
|
73
|
+
"""
|
|
74
|
+
cmd: Final[list[str]] = ["ruff", "linter", "--output-format", "json"]
|
|
75
|
+
output = await _run_ruff_command(cmd, "ruff linter")
|
|
76
|
+
if not output:
|
|
77
|
+
return []
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(output)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
LOGGER.exception("Failed to parse Ruff linters JSON.")
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
if isinstance(data, list):
|
|
85
|
+
return data
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compute_effective_rules(
|
|
90
|
+
all_rules: list[dict[str, Any]], toml_config: Mapping[str, Any]
|
|
91
|
+
) -> list[dict[str, Any]]:
|
|
92
|
+
"""Determine the status (Enabled, Ignored, Disabled) for each rule.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
all_rules: The list of all supported rules.
|
|
96
|
+
toml_config: The local configuration dictionary.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The list of rules enriched with a 'status' key.
|
|
100
|
+
"""
|
|
101
|
+
# The config may be "wrapped" (top-level 'tool' key) or
|
|
102
|
+
# "unwrapped" (direct Ruff config as returned by load_local_ruff_config).
|
|
103
|
+
lint = toml_config.get("lint")
|
|
104
|
+
if lint is None:
|
|
105
|
+
lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {})
|
|
106
|
+
|
|
107
|
+
select = set(lint.get("select", [])) | set(lint.get("extend-select", []))
|
|
108
|
+
ignore = set(lint.get("ignore", [])) | set(lint.get("extend-ignore", []))
|
|
109
|
+
|
|
110
|
+
# If no select/extend-select is provided, Ruff defaults to E and F
|
|
111
|
+
if not lint.get("select") and not lint.get("extend-select"):
|
|
112
|
+
select.update(["E", "F"])
|
|
113
|
+
|
|
114
|
+
enriched: list[dict[str, Any]] = []
|
|
115
|
+
for rule in all_rules:
|
|
116
|
+
code = rule["code"]
|
|
117
|
+
|
|
118
|
+
# Find longest matching select prefix
|
|
119
|
+
best_select_len = -1
|
|
120
|
+
for s in select:
|
|
121
|
+
if code.startswith(s):
|
|
122
|
+
best_select_len = max(best_select_len, len(s))
|
|
123
|
+
|
|
124
|
+
# Find longest matching ignore prefix
|
|
125
|
+
best_ignore_len = -1
|
|
126
|
+
for i in ignore:
|
|
127
|
+
if code.startswith(i):
|
|
128
|
+
best_ignore_len = max(best_ignore_len, len(i))
|
|
129
|
+
|
|
130
|
+
status = "Disabled"
|
|
131
|
+
if best_select_len > best_ignore_len:
|
|
132
|
+
status = "Enabled"
|
|
133
|
+
elif best_ignore_len >= best_select_len and best_ignore_len != -1:
|
|
134
|
+
status = "Ignored"
|
|
135
|
+
|
|
136
|
+
rule_with_status = dict(rule)
|
|
137
|
+
rule_with_status["status"] = status
|
|
138
|
+
enriched.append(rule_with_status)
|
|
139
|
+
|
|
140
|
+
return enriched
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _run_ruff_command(cmd: list[str], description: str) -> str | None:
|
|
144
|
+
"""Execute a ruff command and return the decoded output.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
cmd: The command to execute.
|
|
148
|
+
description: A human-readable description for logging.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The decoded stdout, or None if the command fails.
|
|
152
|
+
"""
|
|
23
153
|
LOGGER.debug(f"Executing system command: {' '.join(cmd)}")
|
|
24
154
|
|
|
25
155
|
try:
|
|
@@ -32,14 +162,15 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None:
|
|
|
32
162
|
|
|
33
163
|
if process.returncode != 0:
|
|
34
164
|
msg = stderr.decode().strip()
|
|
35
|
-
LOGGER.warning(f"
|
|
165
|
+
LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}")
|
|
36
166
|
return None
|
|
37
167
|
|
|
38
|
-
|
|
39
|
-
|
|
168
|
+
output = stdout.decode().strip()
|
|
40
169
|
except FileNotFoundError:
|
|
41
170
|
LOGGER.exception("Ruff executable not found in PATH.")
|
|
42
171
|
return None
|
|
43
172
|
except Exception:
|
|
44
|
-
LOGGER.exception(f"Unexpected error executing
|
|
173
|
+
LOGGER.exception(f"Unexpected error executing '{description}'")
|
|
45
174
|
return None
|
|
175
|
+
else:
|
|
176
|
+
return output or None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""TUI package for ruff-sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ruff_sync.tui.app import RuffSyncApp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_tui_app() -> type[RuffSyncApp]:
|
|
12
|
+
"""Lazy-load the TUI and return the RuffSyncApp class.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The RuffSyncApp class.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
DependencyError: If 'textual' is not installed or the TUI cannot be loaded.
|
|
19
|
+
"""
|
|
20
|
+
from ruff_sync.dependencies import require_dependency # noqa: PLC0415
|
|
21
|
+
|
|
22
|
+
require_dependency("textual", extra_name="tui")
|
|
23
|
+
|
|
24
|
+
from ruff_sync.tui.app import RuffSyncApp # noqa: PLC0415
|
|
25
|
+
|
|
26
|
+
return RuffSyncApp
|
ruff_sync/tui/app.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Main application logic for the Ruff-Sync Terminal User Interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final
|
|
7
|
+
|
|
8
|
+
from textual import on, work
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.widgets import DataTable, Footer, Header, Tree
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
from ruff_sync.config_io import load_local_ruff_config
|
|
15
|
+
from ruff_sync.system import compute_effective_rules, get_all_ruff_rules, get_ruff_linters
|
|
16
|
+
from ruff_sync.tui.constants import RULE_PATTERN
|
|
17
|
+
from ruff_sync.tui.screens import LegendScreen, OmniboxScreen
|
|
18
|
+
from ruff_sync.tui.themes import AMBER_EMBER
|
|
19
|
+
from ruff_sync.tui.widgets import CategoryTable, ConfigTree, RuleInspector
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ruff_sync.cli import Arguments
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
LOGGER = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
MIN_RULE_COLUMNS: Final = 4
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RuffSyncApp(App[None]):
|
|
31
|
+
"""Ruff-Sync Terminal User Interface."""
|
|
32
|
+
|
|
33
|
+
CSS = """
|
|
34
|
+
Screen {
|
|
35
|
+
background: $surface;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#config-tree {
|
|
39
|
+
width: 1fr;
|
|
40
|
+
max-width: 42;
|
|
41
|
+
height: 100%;
|
|
42
|
+
border-right: solid $primary-darken-2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#content-pane {
|
|
46
|
+
width: 1fr;
|
|
47
|
+
height: 100%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#category-table {
|
|
51
|
+
height: 40%;
|
|
52
|
+
border-bottom: solid $primary-darken-2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#inspector {
|
|
56
|
+
height: 60%;
|
|
57
|
+
padding: 1;
|
|
58
|
+
background: $surface-darken-1;
|
|
59
|
+
overflow-y: auto;
|
|
60
|
+
}
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
BINDINGS: ClassVar[list[Any]] = [
|
|
64
|
+
("q", "quit", "Quit"),
|
|
65
|
+
("/", "search", "Search Rules"),
|
|
66
|
+
("?", "show_legend", "Show Legend"),
|
|
67
|
+
("l", "show_legend", "Show Legend"),
|
|
68
|
+
("c", "copy_content", "Copy Content"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
def __init__(self, args: Arguments, **kwargs: Any) -> None:
|
|
72
|
+
"""Initialize the application.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
args: The CLI arguments.
|
|
76
|
+
**kwargs: Additional keyword arguments.
|
|
77
|
+
"""
|
|
78
|
+
super().__init__(**kwargs)
|
|
79
|
+
self.args = args
|
|
80
|
+
self.config: dict[str, Any] = {}
|
|
81
|
+
self.all_rules: list[dict[str, Any]] = []
|
|
82
|
+
self.effective_rules: list[dict[str, Any]] = []
|
|
83
|
+
self.linters: list[dict[str, Any]] = []
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def compose(self) -> ComposeResult:
|
|
87
|
+
"""Compose the user interface elements."""
|
|
88
|
+
yield Header()
|
|
89
|
+
with Horizontal():
|
|
90
|
+
yield ConfigTree("Local Configuration", id="config-tree")
|
|
91
|
+
with Vertical(id="content-pane"):
|
|
92
|
+
yield CategoryTable(id="category-table")
|
|
93
|
+
yield RuleInspector(id="inspector")
|
|
94
|
+
yield Footer()
|
|
95
|
+
|
|
96
|
+
async def on_mount(self) -> None:
|
|
97
|
+
"""Load the configuration and populate the tree."""
|
|
98
|
+
try:
|
|
99
|
+
self.config = load_local_ruff_config(self.args.to)
|
|
100
|
+
except Exception:
|
|
101
|
+
LOGGER.exception("Failed to load Ruff configuration.")
|
|
102
|
+
self.notify("Failed to load Ruff configuration.", severity="error")
|
|
103
|
+
self.config = {}
|
|
104
|
+
|
|
105
|
+
tree = self.query_one(ConfigTree)
|
|
106
|
+
tree.populate(self.config, has_rules=True)
|
|
107
|
+
tree.focus()
|
|
108
|
+
|
|
109
|
+
# Register and set the default theme
|
|
110
|
+
self.register_theme(AMBER_EMBER)
|
|
111
|
+
self.theme = "amber-ember"
|
|
112
|
+
|
|
113
|
+
# Prime the caches in the background
|
|
114
|
+
self._prime_caches()
|
|
115
|
+
|
|
116
|
+
@work
|
|
117
|
+
async def _prime_caches(self) -> None:
|
|
118
|
+
"""Fetch rules and compute effectiveness in the background."""
|
|
119
|
+
self.all_rules = await get_all_ruff_rules()
|
|
120
|
+
self.linters = await get_ruff_linters()
|
|
121
|
+
if self.config:
|
|
122
|
+
self.effective_rules = compute_effective_rules(self.all_rules, self.config)
|
|
123
|
+
|
|
124
|
+
# Refresh the tree once metadata is loaded to show linter groups
|
|
125
|
+
# We pass effective_rules so the tree can filter out entire groups that are disabled
|
|
126
|
+
tree = self.query_one(ConfigTree)
|
|
127
|
+
tree.populate(
|
|
128
|
+
self.config,
|
|
129
|
+
has_rules=True,
|
|
130
|
+
linters=self.linters,
|
|
131
|
+
effective_rules=self.effective_rules,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@work
|
|
135
|
+
async def _display_effective_rules(self) -> None:
|
|
136
|
+
"""Populate the table with the effective rules list."""
|
|
137
|
+
if not self.all_rules:
|
|
138
|
+
self.all_rules = await get_all_ruff_rules()
|
|
139
|
+
self.effective_rules = compute_effective_rules(self.all_rules, self.config)
|
|
140
|
+
|
|
141
|
+
# Filter for only Enabled or Ignored rules as per the "Effective Rules" proposal
|
|
142
|
+
effective_only = [r for r in self.effective_rules if r["status"] != "Disabled"]
|
|
143
|
+
|
|
144
|
+
table = self.query_one(CategoryTable)
|
|
145
|
+
table.update_rules(effective_only)
|
|
146
|
+
|
|
147
|
+
inspector = self.query_one(RuleInspector)
|
|
148
|
+
inspector.update(
|
|
149
|
+
"## Effective Rule Status\n\nThis table shows rules that are actively being used "
|
|
150
|
+
"or have been explicitly ignored in your configuration."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@on(Tree.NodeSelected)
|
|
154
|
+
def handle_node_selected(self, event: Tree.NodeSelected[Any]) -> None:
|
|
155
|
+
"""Handle tree node selection.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
event: The tree node selected event.
|
|
159
|
+
"""
|
|
160
|
+
data = event.node.data
|
|
161
|
+
label = event.node.label
|
|
162
|
+
label_text = str(label.plain) if hasattr(label, "plain") else str(label)
|
|
163
|
+
|
|
164
|
+
table = self.query_one(CategoryTable)
|
|
165
|
+
inspector = self.query_one(RuleInspector)
|
|
166
|
+
|
|
167
|
+
if data == "__rules__":
|
|
168
|
+
self._display_effective_rules()
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if isinstance(data, dict) and data.get("type") == "linter":
|
|
172
|
+
prefix = data.get("prefix", "")
|
|
173
|
+
name = data.get("name", "Unknown Linter")
|
|
174
|
+
|
|
175
|
+
# Filter effective rules by prefix
|
|
176
|
+
# We show all rules in the group (even Disabled) as per the Discovery proposal
|
|
177
|
+
filtered_rules = [r for r in self.effective_rules if r["code"].startswith(prefix)]
|
|
178
|
+
|
|
179
|
+
table.update_rules(filtered_rules)
|
|
180
|
+
msg = f"## {name} ({prefix or 'No Prefix'})\n\nShowing rules for the {name} category."
|
|
181
|
+
inspector.update(msg)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Build full path for context
|
|
185
|
+
full_path = self._get_node_path(event.node)
|
|
186
|
+
|
|
187
|
+
# Check if the node label or path matches a ruff rule
|
|
188
|
+
if isinstance(label_text, str) and RULE_PATTERN.match(label_text):
|
|
189
|
+
self._inspect_rule(label_text)
|
|
190
|
+
elif isinstance(data, (dict, list)):
|
|
191
|
+
table.update_content(data)
|
|
192
|
+
# Fetch config documentation for the section if possible (skip root)
|
|
193
|
+
if full_path != "tool.ruff":
|
|
194
|
+
inspector.fetch_and_display(full_path, is_rule=False)
|
|
195
|
+
else:
|
|
196
|
+
inspector.show_placeholder()
|
|
197
|
+
else:
|
|
198
|
+
table.update_content(data)
|
|
199
|
+
inspector.fetch_and_display(full_path, is_rule=False)
|
|
200
|
+
|
|
201
|
+
@on(DataTable.RowSelected)
|
|
202
|
+
def handle_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
203
|
+
"""Handle data table row selection.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
event: The data table row selected event.
|
|
207
|
+
"""
|
|
208
|
+
table = self.query_one(CategoryTable)
|
|
209
|
+
row = table.get_row_at(event.cursor_row)
|
|
210
|
+
|
|
211
|
+
# Handle multi-column rules view vs key-value view
|
|
212
|
+
if len(row) >= MIN_RULE_COLUMNS:
|
|
213
|
+
# Use row_key for stable rule code extraction (avoids markup)
|
|
214
|
+
rule_code = str(event.row_key.value)
|
|
215
|
+
self._inspect_rule(rule_code)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
key, value = row
|
|
219
|
+
# Check if the value or key looks like a rule code
|
|
220
|
+
rule_code_from_kv = None
|
|
221
|
+
if RULE_PATTERN.match(str(key)):
|
|
222
|
+
rule_code_from_kv = str(key)
|
|
223
|
+
elif RULE_PATTERN.match(str(value)):
|
|
224
|
+
rule_code_from_kv = str(value)
|
|
225
|
+
|
|
226
|
+
if rule_code_from_kv:
|
|
227
|
+
self._inspect_rule(rule_code_from_kv)
|
|
228
|
+
else:
|
|
229
|
+
# It's a configuration key, show its documentation
|
|
230
|
+
inspector = self.query_one(RuleInspector)
|
|
231
|
+
full_path = f"{self._get_node_path(self.query_one(ConfigTree).cursor_node)}.{key}"
|
|
232
|
+
inspector.fetch_and_display(full_path, is_rule=False)
|
|
233
|
+
|
|
234
|
+
def _inspect_rule(self, rule_code: str) -> None:
|
|
235
|
+
"""Centralized helper for rule inspection with metadata enrichment.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
rule_code: The Ruff rule code to inspect.
|
|
239
|
+
"""
|
|
240
|
+
# Fetch metadata for enrichment
|
|
241
|
+
rule_data = next((r for r in self.effective_rules if r["code"] == rule_code), None)
|
|
242
|
+
name = rule_data.get("name") if rule_data else None
|
|
243
|
+
status = rule_data.get("status") if rule_data else "Disabled"
|
|
244
|
+
explanation = rule_data.get("explanation") if rule_data else None
|
|
245
|
+
|
|
246
|
+
inspector = self.query_one(RuleInspector)
|
|
247
|
+
inspector.fetch_and_display(
|
|
248
|
+
rule_code,
|
|
249
|
+
is_rule=True,
|
|
250
|
+
cached_content=explanation,
|
|
251
|
+
rule_name=name,
|
|
252
|
+
rule_status=status,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _get_node_path(self, node: Any) -> str:
|
|
256
|
+
"""Construct the full configuration path to a tree node.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
node: The tree node.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The dot-separated configuration path.
|
|
263
|
+
"""
|
|
264
|
+
path: list[str] = []
|
|
265
|
+
current = node
|
|
266
|
+
tree = self.query_one(ConfigTree)
|
|
267
|
+
while current and current != tree.root:
|
|
268
|
+
label = current.label
|
|
269
|
+
label_text = str(label.plain) if hasattr(label, "plain") else str(label)
|
|
270
|
+
path.append(label_text)
|
|
271
|
+
current = current.parent
|
|
272
|
+
|
|
273
|
+
if not path:
|
|
274
|
+
return "tool.ruff"
|
|
275
|
+
return "tool.ruff." + ".".join(reversed(path))
|
|
276
|
+
|
|
277
|
+
def action_search(self) -> None:
|
|
278
|
+
"""Launch the global fuzzy search Omnibox."""
|
|
279
|
+
if not self.all_rules:
|
|
280
|
+
self.notify("Still fetching rule metadata...", severity="warning")
|
|
281
|
+
# Even if empty, we push; the screen handles empty list
|
|
282
|
+
self.push_screen(OmniboxScreen(self.all_rules), self.handle_omnibox_result)
|
|
283
|
+
|
|
284
|
+
def handle_omnibox_result(self, rule_code: str | None) -> None:
|
|
285
|
+
"""Handle the result from the Omnibox search.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
rule_code: The selected rule code, or None if cancelled.
|
|
289
|
+
"""
|
|
290
|
+
if rule_code:
|
|
291
|
+
self._inspect_rule(rule_code)
|
|
292
|
+
|
|
293
|
+
def action_show_legend(self) -> None:
|
|
294
|
+
"""Display the TUI legend modal."""
|
|
295
|
+
self.push_screen(LegendScreen())
|
|
296
|
+
|
|
297
|
+
def action_copy_content(self) -> None:
|
|
298
|
+
"""Copy the current inspector content to the clipboard."""
|
|
299
|
+
inspector = self.query_one(RuleInspector)
|
|
300
|
+
if inspector.source:
|
|
301
|
+
self.copy_to_clipboard(str(inspector.source))
|
|
302
|
+
self.notify("Copied content to clipboard", title="Clipboard")
|
|
303
|
+
else:
|
|
304
|
+
self.notify("No content to copy", severity="warning")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Constants for the Ruff-Sync Terminal User Interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
# Regex pattern for matching Ruff rule codes (e.g., E501, RUF012)
|
|
9
|
+
RULE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z]+[0-9]+$")
|
ruff_sync/tui/screens.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Screens for the Ruff-Sync Terminal User Interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Final
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import Input, OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
from typing_extensions import override
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from textual.app import ComposeResult
|
|
16
|
+
|
|
17
|
+
MAX_SEARCH_RESULTS: Final = 15
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OmniboxScreen(ModalScreen[str]):
|
|
21
|
+
"""A modal search screen for quickly finding Ruff rules."""
|
|
22
|
+
|
|
23
|
+
CSS = """
|
|
24
|
+
OmniboxScreen {
|
|
25
|
+
align: center middle;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#omnibox-container {
|
|
29
|
+
width: 60;
|
|
30
|
+
height: auto;
|
|
31
|
+
max-height: 20;
|
|
32
|
+
background: $boost;
|
|
33
|
+
border: thick $primary;
|
|
34
|
+
padding: 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#omnibox-input {
|
|
38
|
+
margin-bottom: 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#omnibox-results {
|
|
42
|
+
height: auto;
|
|
43
|
+
max-height: 12;
|
|
44
|
+
border: none;
|
|
45
|
+
background: $surface;
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, all_rules: list[dict[str, Any]], **kwargs: Any) -> None:
|
|
50
|
+
"""Initialize the search screen.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
all_rules: The list of all rules to search through.
|
|
54
|
+
**kwargs: Additional keyword arguments.
|
|
55
|
+
"""
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
self.all_rules = all_rules
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
"""Compose the search interface."""
|
|
62
|
+
with Vertical(id="omnibox-container"):
|
|
63
|
+
yield Static("[b]Search Ruff Rules[/b] (e.g. F401, unused)", id="omnibox-title")
|
|
64
|
+
yield Input(placeholder="Start typing...", id="omnibox-input")
|
|
65
|
+
yield OptionList(id="omnibox-results")
|
|
66
|
+
|
|
67
|
+
def on_mount(self) -> None:
|
|
68
|
+
"""Focus the input on mount."""
|
|
69
|
+
self.query_one(Input).focus()
|
|
70
|
+
|
|
71
|
+
@on(Input.Changed)
|
|
72
|
+
def handle_input_changed(self, event: Input.Changed) -> None:
|
|
73
|
+
"""Filter rules based on search input.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
event: The input changed event.
|
|
77
|
+
"""
|
|
78
|
+
search_query = event.value.strip().lower()
|
|
79
|
+
results_list = self.query_one(OptionList)
|
|
80
|
+
results_list.clear_options()
|
|
81
|
+
|
|
82
|
+
if not search_query:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
matches = []
|
|
86
|
+
for rule in self.all_rules:
|
|
87
|
+
code = rule["code"].lower()
|
|
88
|
+
name = rule["name"].lower()
|
|
89
|
+
if search_query in code or search_query in name:
|
|
90
|
+
matches.append(rule)
|
|
91
|
+
if len(matches) >= MAX_SEARCH_RESULTS: # Limit results
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
for match in matches:
|
|
95
|
+
results_list.add_option(
|
|
96
|
+
Option(f"[b]{match['code']}[/b] - {match['name']}", id=match["code"])
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@on(Input.Submitted)
|
|
100
|
+
def handle_input_submitted(self) -> None:
|
|
101
|
+
"""Handle enter key in the input."""
|
|
102
|
+
results_list = self.query_one(OptionList)
|
|
103
|
+
if results_list.option_count > 0:
|
|
104
|
+
# If there's a selected option, use it. Otherwise use the first matching one.
|
|
105
|
+
index = results_list.highlighted if results_list.highlighted is not None else 0
|
|
106
|
+
option = results_list.get_option_at_index(index)
|
|
107
|
+
if option.id:
|
|
108
|
+
self.dismiss(str(option.id))
|
|
109
|
+
|
|
110
|
+
@on(OptionList.OptionSelected)
|
|
111
|
+
def handle_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
112
|
+
"""Handle selection from the results list."""
|
|
113
|
+
if event.option.id:
|
|
114
|
+
self.dismiss(str(event.option.id))
|
|
115
|
+
|
|
116
|
+
def action_cancel(self) -> None:
|
|
117
|
+
"""Close the screen without selection."""
|
|
118
|
+
self.dismiss()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LegendScreen(ModalScreen[None]):
|
|
122
|
+
"""A toggleable legend explaining TUI status colors and icons."""
|
|
123
|
+
|
|
124
|
+
BINDINGS: ClassVar[list[Any]] = [
|
|
125
|
+
("escape,l,?", "dismiss", "Close Legend"),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
CSS = """
|
|
129
|
+
LegendScreen {
|
|
130
|
+
align: center middle;
|
|
131
|
+
background: black 50%;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#legend-container {
|
|
135
|
+
width: 50;
|
|
136
|
+
height: auto;
|
|
137
|
+
background: $boost;
|
|
138
|
+
border: heavy $accent;
|
|
139
|
+
padding: 1 2;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.legend-title {
|
|
143
|
+
text-align: center;
|
|
144
|
+
text-style: bold;
|
|
145
|
+
margin-bottom: 1;
|
|
146
|
+
color: $accent;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.legend-section {
|
|
150
|
+
text-style: underline;
|
|
151
|
+
margin-top: 1;
|
|
152
|
+
margin-bottom: 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.legend-row {
|
|
156
|
+
margin-left: 2;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#legend-footer {
|
|
160
|
+
text-align: center;
|
|
161
|
+
margin-top: 1;
|
|
162
|
+
color: $text-disabled;
|
|
163
|
+
}
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
@override
|
|
167
|
+
def compose(self) -> ComposeResult:
|
|
168
|
+
"""Compose the legend content."""
|
|
169
|
+
with Vertical(id="legend-container"):
|
|
170
|
+
yield Static("Ruff-Sync TUI Legend", id="legend-title", classes="legend-title")
|
|
171
|
+
|
|
172
|
+
yield Static("Rule Status", classes="legend-section")
|
|
173
|
+
yield Static(
|
|
174
|
+
"🟢 [green]Enabled[/green] - Active in configuration", classes="legend-row"
|
|
175
|
+
)
|
|
176
|
+
yield Static("🟡 [yellow]Ignored[/yellow] - Explicitly ignored", classes="legend-row")
|
|
177
|
+
yield Static("⚪ [dim]Disabled[/dim] - Category not selected", classes="legend-row")
|
|
178
|
+
|
|
179
|
+
yield Static("Fix Availability", classes="legend-section")
|
|
180
|
+
yield Static("[cyan]Always[/cyan] - Automatic fix available", classes="legend-row")
|
|
181
|
+
yield Static(
|
|
182
|
+
"[yellow]Sometimes[/yellow] - Conditional/enforced fix", classes="legend-row"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
yield Static("Press [b]? [/b] or [b]Esc[/b] to close", id="legend-footer")
|
ruff_sync/tui/themes.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Custom themes for the ruff-sync TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.theme import Theme
|
|
6
|
+
|
|
7
|
+
# AMBER_EMBER
|
|
8
|
+
# High-contrast, warm dark theme with vibrant gold primary colors.
|
|
9
|
+
AMBER_EMBER = Theme(
|
|
10
|
+
name="amber-ember",
|
|
11
|
+
primary="#FFB300", # Darker, punchy Amber
|
|
12
|
+
secondary="#FFD54F", # Lighter Amber
|
|
13
|
+
accent="#D81B60", # Material Magenta
|
|
14
|
+
warning="#FFB300", # Amber 600
|
|
15
|
+
error="#E91E63", # Pink 500
|
|
16
|
+
success="#81C784", # Light Green 300
|
|
17
|
+
background="#121212", # Material Dark
|
|
18
|
+
surface="#1E1E1E",
|
|
19
|
+
panel="#2C2C2C",
|
|
20
|
+
boost="#2C2C2C",
|
|
21
|
+
)
|
ruff_sync/tui/widgets.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Widgets for the Ruff-Sync Terminal User Interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from textual import work
|
|
8
|
+
from textual.widgets import DataTable, Markdown, Tree
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from ruff_sync.system import get_ruff_config_markdown, get_ruff_rule_markdown
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from textual.widgets.tree import TreeNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigTree(Tree[Any]):
|
|
18
|
+
"""A tree widget for navigating Ruff configuration."""
|
|
19
|
+
|
|
20
|
+
def populate(
|
|
21
|
+
self,
|
|
22
|
+
config: dict[str, Any],
|
|
23
|
+
has_rules: bool = False,
|
|
24
|
+
linters: list[dict[str, Any]] | None = None,
|
|
25
|
+
effective_rules: list[dict[str, Any]] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Populate the tree with configuration sections.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: The unwrapped dictionary of Ruff configuration.
|
|
31
|
+
has_rules: Whether to inject the 'Effective Rule Status' node.
|
|
32
|
+
linters: Optional linter metadata for expanding the rules node.
|
|
33
|
+
effective_rules: List of rules with status for filtering.
|
|
34
|
+
"""
|
|
35
|
+
self.clear()
|
|
36
|
+
self.root.expand()
|
|
37
|
+
if has_rules:
|
|
38
|
+
rules_node = self.root.add("Effective Rule Status", data="__rules__")
|
|
39
|
+
if linters and effective_rules:
|
|
40
|
+
self._populate_linter_nodes(rules_node, linters, effective_rules)
|
|
41
|
+
self._populate_node(self.root, config)
|
|
42
|
+
|
|
43
|
+
# Auto-expand up to 2 levels if it fits in the current view
|
|
44
|
+
self._expand_if_fits()
|
|
45
|
+
|
|
46
|
+
def _is_linter_active(
|
|
47
|
+
self, linter: dict[str, Any], effective_rules: list[dict[str, Any]]
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""Check if a linter (or any of its categories) has active/ignored rules."""
|
|
50
|
+
prefix = linter.get("prefix")
|
|
51
|
+
if prefix and any(
|
|
52
|
+
r["code"].startswith(prefix) and r["status"] != "Disabled" for r in effective_rules
|
|
53
|
+
):
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
if "categories" in linter:
|
|
57
|
+
return any(self._is_linter_active(c, effective_rules) for c in linter["categories"])
|
|
58
|
+
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def _populate_linter_nodes(
|
|
62
|
+
self,
|
|
63
|
+
parent: TreeNode[Any],
|
|
64
|
+
linters: list[dict[str, Any]],
|
|
65
|
+
effective_rules: list[dict[str, Any]],
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Recursively add linter category nodes to the tree."""
|
|
68
|
+
# Sort linters by name for better navigation
|
|
69
|
+
for linter in sorted(linters, key=lambda x: x["name"]):
|
|
70
|
+
# Filter: only show if this linter or any category has active rules
|
|
71
|
+
if not self._is_linter_active(linter, effective_rules):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
name = linter["name"]
|
|
75
|
+
prefix = linter.get("prefix")
|
|
76
|
+
label = f"{name} ({prefix})" if prefix else name
|
|
77
|
+
|
|
78
|
+
# Use 'linter' structure in data for easy detection
|
|
79
|
+
node_data = {"type": "linter", "prefix": prefix, "name": name}
|
|
80
|
+
node = parent.add(label, data=node_data)
|
|
81
|
+
|
|
82
|
+
# Recursive call for sub-categories (e.g. Pylint, pycodestyle)
|
|
83
|
+
if "categories" in linter:
|
|
84
|
+
self._populate_linter_nodes(node, linter["categories"], effective_rules)
|
|
85
|
+
|
|
86
|
+
def _populate_node(self, parent: TreeNode[Any], data: Any) -> None:
|
|
87
|
+
"""Recursively add nodes to the tree.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
parent: The parent tree node.
|
|
91
|
+
data: The data to add to the tree.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(data, dict):
|
|
94
|
+
for key, value in sorted(data.items()):
|
|
95
|
+
node = parent.add(key, data=value)
|
|
96
|
+
if isinstance(value, (dict, list)):
|
|
97
|
+
self._populate_node(node, value)
|
|
98
|
+
elif isinstance(data, list):
|
|
99
|
+
for i, item in enumerate(data):
|
|
100
|
+
label = str(item) if not isinstance(item, (dict, list)) else f"[{i}]"
|
|
101
|
+
node = parent.add(label, data=item)
|
|
102
|
+
if isinstance(item, (dict, list)):
|
|
103
|
+
self._populate_node(node, item)
|
|
104
|
+
|
|
105
|
+
def _expand_if_fits(self) -> None:
|
|
106
|
+
"""Expand the first few levels of the tree if they fit in the vertical space."""
|
|
107
|
+
# We target depth 2 expansion (Root -> Categories -> Items)
|
|
108
|
+
target_depth = 2
|
|
109
|
+
|
|
110
|
+
# Use widget height if available, otherwise fallback to a common terminal height
|
|
111
|
+
# Subtract some margin for header/footer
|
|
112
|
+
limit = (self.size.height or 24) - 2
|
|
113
|
+
|
|
114
|
+
# Count visible nodes if we were to expand
|
|
115
|
+
to_expand: list[TreeNode[Any]] = []
|
|
116
|
+
visible_count = 1 # Start with the root
|
|
117
|
+
|
|
118
|
+
def collect_nodes(node: TreeNode[Any], depth: int) -> int:
|
|
119
|
+
nonlocal visible_count
|
|
120
|
+
if depth >= target_depth:
|
|
121
|
+
return visible_count
|
|
122
|
+
|
|
123
|
+
children = list(node.children)
|
|
124
|
+
if not children:
|
|
125
|
+
return visible_count
|
|
126
|
+
|
|
127
|
+
# If adding these children exceeds the limit, stop
|
|
128
|
+
if visible_count + len(children) > limit:
|
|
129
|
+
return visible_count
|
|
130
|
+
|
|
131
|
+
# Mark for expansion and continue
|
|
132
|
+
to_expand.append(node)
|
|
133
|
+
visible_count += len(children)
|
|
134
|
+
|
|
135
|
+
for child in children:
|
|
136
|
+
collect_nodes(child, depth + 1)
|
|
137
|
+
return visible_count
|
|
138
|
+
|
|
139
|
+
collect_nodes(self.root, 0)
|
|
140
|
+
|
|
141
|
+
# Apply the expansions
|
|
142
|
+
for node in to_expand:
|
|
143
|
+
node.expand()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class CategoryTable(DataTable[Any]):
|
|
147
|
+
"""A table widget for displaying configuration keys and values."""
|
|
148
|
+
|
|
149
|
+
@override
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
"""Initialize the table columns."""
|
|
152
|
+
self.cursor_type = "row"
|
|
153
|
+
self.add_columns("Key", "Value")
|
|
154
|
+
|
|
155
|
+
def update_content(self, data: Any) -> None:
|
|
156
|
+
"""Update the table rows based on the selected data.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
data: The data to display in the table.
|
|
160
|
+
"""
|
|
161
|
+
self.clear(columns=True)
|
|
162
|
+
self.add_columns("Key", "Value")
|
|
163
|
+
if isinstance(data, dict):
|
|
164
|
+
for key, value in sorted(data.items()):
|
|
165
|
+
self.add_row(key, str(value))
|
|
166
|
+
elif isinstance(data, list):
|
|
167
|
+
for i, item in enumerate(data):
|
|
168
|
+
self.add_row(str(i), str(item))
|
|
169
|
+
else:
|
|
170
|
+
self.add_row("Value", str(data))
|
|
171
|
+
|
|
172
|
+
def update_rules(self, rules: list[dict[str, Any]]) -> None:
|
|
173
|
+
"""Update the table with a list of rules using row-level highlighting.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
rules: The enriched rules list to display.
|
|
177
|
+
"""
|
|
178
|
+
self.clear(columns=True)
|
|
179
|
+
# Status is now indicated by row highlighting (colors), so column is removed
|
|
180
|
+
self.add_columns("Code", "Name", "Linter", "Fix")
|
|
181
|
+
for rule in rules:
|
|
182
|
+
status = rule.get("status", "Unknown")
|
|
183
|
+
|
|
184
|
+
# Determine row color based on status
|
|
185
|
+
color = ""
|
|
186
|
+
if status == "Enabled":
|
|
187
|
+
color = "success"
|
|
188
|
+
elif status == "Ignored":
|
|
189
|
+
color = "warning"
|
|
190
|
+
elif status == "Disabled":
|
|
191
|
+
color = (
|
|
192
|
+
"dim" # Keep dim as it's a standard Rich style that works across backgrounds
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Rich uses [/] to close the nearest open tag
|
|
196
|
+
code_markup = f"[{color}]{rule['code']}[/]" if color else rule["code"]
|
|
197
|
+
name_markup = f"[{color}]{rule['name']}[/]" if color else rule["name"]
|
|
198
|
+
|
|
199
|
+
fix = rule.get("fix_availability", "None")
|
|
200
|
+
fix_markup = fix
|
|
201
|
+
if fix == "Always":
|
|
202
|
+
fix_markup = f"[accent]{fix}[/]"
|
|
203
|
+
elif fix in ("Sometimes", "Enforced"):
|
|
204
|
+
fix_markup = f"[warning]{fix}[/]"
|
|
205
|
+
|
|
206
|
+
# Note: We still keep linter as-is for clarity
|
|
207
|
+
self.add_row(code_markup, name_markup, rule["linter"], fix_markup, key=rule["code"])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class RuleInspector(Markdown):
|
|
211
|
+
"""A markdown widget for inspecting Ruff rules and settings."""
|
|
212
|
+
|
|
213
|
+
_current_meta: ClassVar[dict[str, str]] = {}
|
|
214
|
+
|
|
215
|
+
def on_mount(self) -> None:
|
|
216
|
+
"""Set initial placeholder content."""
|
|
217
|
+
self.show_placeholder()
|
|
218
|
+
|
|
219
|
+
@work(exclusive=True, group="inspector_update")
|
|
220
|
+
async def show_placeholder(self) -> None:
|
|
221
|
+
"""Display a placeholder message."""
|
|
222
|
+
self.update(
|
|
223
|
+
"## Selection Details\n\nSelect a configuration key in the tree or a rule "
|
|
224
|
+
"code in the table to view documentation or additional context."
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def show_context(self, path: str, value: Any) -> None:
|
|
228
|
+
"""Display general context for a configuration setting.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
path: The full configuration path (e.g., 'tool.ruff.lint.select').
|
|
232
|
+
value: The value of the setting.
|
|
233
|
+
"""
|
|
234
|
+
# Show a summary for complex types, or the raw value for simple ones
|
|
235
|
+
if isinstance(value, dict):
|
|
236
|
+
summary = f"Table with {len(value)} keys"
|
|
237
|
+
elif isinstance(value, list):
|
|
238
|
+
summary = f"List with {len(value)} items"
|
|
239
|
+
else:
|
|
240
|
+
summary = f"`{value}`"
|
|
241
|
+
|
|
242
|
+
self.update(f"### Configuration Context\n\n**Path**: `{path}`\n\n**Value**: {summary}")
|
|
243
|
+
|
|
244
|
+
@work(exclusive=True, group="inspector_update")
|
|
245
|
+
async def fetch_and_display(
|
|
246
|
+
self,
|
|
247
|
+
target: str,
|
|
248
|
+
is_rule: bool = True,
|
|
249
|
+
cached_content: str | None = None,
|
|
250
|
+
rule_name: str | None = None,
|
|
251
|
+
rule_status: str | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Fetch and display the documentation for a rule or setting.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
target: The Ruff rule code or configuration path.
|
|
257
|
+
is_rule: True if fetching a rule, False if fetching a config setting.
|
|
258
|
+
cached_content: Optional pre-fetched documentation.
|
|
259
|
+
rule_name: Optional rule name for display.
|
|
260
|
+
rule_status: Optional rule status (Enabled, Ignored, Disabled).
|
|
261
|
+
"""
|
|
262
|
+
if target == "tool.ruff":
|
|
263
|
+
self.show_placeholder()
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
content: str | None = None
|
|
267
|
+
if cached_content:
|
|
268
|
+
content = cached_content
|
|
269
|
+
else:
|
|
270
|
+
# Set a loading message
|
|
271
|
+
desc = "rule" if is_rule else "config"
|
|
272
|
+
self.update(
|
|
273
|
+
f"## Inspecting {target}...\n\nFetching documentation from `ruff {desc}`..."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if is_rule:
|
|
277
|
+
content = await get_ruff_rule_markdown(target)
|
|
278
|
+
else:
|
|
279
|
+
content = await get_ruff_config_markdown(target)
|
|
280
|
+
|
|
281
|
+
if content:
|
|
282
|
+
# Prepend header if it's a rule
|
|
283
|
+
header = ""
|
|
284
|
+
if is_rule:
|
|
285
|
+
status_icons = {"Enabled": "🟢", "Ignored": "🟡", "Disabled": "⚪"}
|
|
286
|
+
icon = status_icons.get(rule_status or "Disabled", "⚪")
|
|
287
|
+
name = rule_name or "Unknown Rule"
|
|
288
|
+
header = f"# {icon} {target}: {name}\n\n---\n\n"
|
|
289
|
+
|
|
290
|
+
self.update(header + content.strip())
|
|
291
|
+
else:
|
|
292
|
+
desc = "rule" if is_rule else "config"
|
|
293
|
+
self.update(f"## Error\n\nCould not fetch documentation for {desc} `{target}`.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ruff-sync
|
|
3
|
-
Version: 0.1.5.
|
|
3
|
+
Version: 0.1.5.dev3
|
|
4
4
|
Summary: Synchronize Ruff linter configuration across projects
|
|
5
5
|
Project-URL: Homepage, https://github.com/Kilo59/ruff-sync
|
|
6
6
|
Project-URL: Documentation, https://kilo59.github.io/ruff-sync/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
ruff_sync/__init__.py,sha256=cuUpweRN6fkH9fmxyyTSFkmS4lbBGIhGs6Y5k5Z-2RE,1129
|
|
2
|
+
ruff_sync/__main__.py,sha256=oMIbv9ywToleBKJrZ-GMo_KhM4lHg02nJe7t6neWzJE,179
|
|
3
|
+
ruff_sync/cli.py,sha256=rc-VFsecKnceuhXznRTHyi28ZE6LhrL6J7CSBVVY5LI,21275
|
|
4
|
+
ruff_sync/config_io.py,sha256=3GlFjFlii_hBLaNnUJRfFI0iozkngTBbfqu4rLrpZKQ,4348
|
|
5
|
+
ruff_sync/constants.py,sha256=yE6ORJaErY8Bz5KTDDk7kRKLOlJ-xgS7Gvz_mFpjDJY,4604
|
|
6
|
+
ruff_sync/core.py,sha256=RtuKFRHjZmvi5W7bD3quD4ueqD8wt_5R2xWbtx3_MbI,42599
|
|
7
|
+
ruff_sync/dependencies.py,sha256=RpJXAsrB-Y-ol_ZH4U92XIuyq8_bWaYwp9sn9KpxraA,2096
|
|
8
|
+
ruff_sync/formatters.py,sha256=sDZgx7SYNM1pjKshZLTgQHuAeT0P_tqAeV0VRZuz5FA,26311
|
|
9
|
+
ruff_sync/pre_commit.py,sha256=0ltXUkmocbrSwwf6cPbNc_K5K9n-gvou98xJy9YUhr0,5303
|
|
10
|
+
ruff_sync/system.py,sha256=BtDCXT_fPY-QsZHJ4CJnDBEPnHEJQ1nw-C4CBQ2xDmI,5662
|
|
11
|
+
ruff_sync/tui/__init__.py,sha256=DcwvE70ETjBcC6pQeHpjrYtkJp8z4StGM8L3EuaVcIo,635
|
|
12
|
+
ruff_sync/tui/app.py,sha256=GWvo1cQj5AHI3dOCyBQ5V74H5cYo6mxAzhLimJgDvYg,10656
|
|
13
|
+
ruff_sync/tui/constants.py,sha256=fEPyp2k817QLluyiVHqDObzDONlHz_L3Ec0SYAUzzMs,267
|
|
14
|
+
ruff_sync/tui/screens.py,sha256=uuP3yGsR6nbzRg7S5wHFBppFfsl6UVBsjOHY-ZcoWR8,5520
|
|
15
|
+
ruff_sync/tui/themes.py,sha256=eHZsXmlYs5WVN_5Z7bnlGnka1B5n6sRxl4p7qurie2k,591
|
|
16
|
+
ruff_sync/tui/widgets.py,sha256=tlaCvCzaN92aboMDJgYdDcU3paaZ0qcvhPBQHM15_l0,10894
|
|
17
|
+
ruff_sync-0.1.5.dev3.dist-info/METADATA,sha256=CowilhV_UA5lc4vqoXrri2e9C1jrleoKzLGXC8iFlNs,21464
|
|
18
|
+
ruff_sync-0.1.5.dev3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
19
|
+
ruff_sync-0.1.5.dev3.dist-info/entry_points.txt,sha256=QQ0pjxMIN0vp5EmNFkyb-1Yk8JOIkid8zAPN3IpAExA,78
|
|
20
|
+
ruff_sync-0.1.5.dev3.dist-info/licenses/LICENSE.md,sha256=RcCP8t8xyCx2cvMZSLjBrPqJfctciWC6q85ceQAcEfA,1080
|
|
21
|
+
ruff_sync-0.1.5.dev3.dist-info/RECORD,,
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
ruff_sync/__init__.py,sha256=BFndMiyH2201W9mvl585ox331iwY_L5T_uX9HUJS81c,1101
|
|
2
|
-
ruff_sync/__main__.py,sha256=oMIbv9ywToleBKJrZ-GMo_KhM4lHg02nJe7t6neWzJE,179
|
|
3
|
-
ruff_sync/cli.py,sha256=hdq5M7GdMQm5BrJrRVEwl9LRJvqtYvbgM-GRrkS6SK0,20726
|
|
4
|
-
ruff_sync/config_io.py,sha256=3GlFjFlii_hBLaNnUJRfFI0iozkngTBbfqu4rLrpZKQ,4348
|
|
5
|
-
ruff_sync/constants.py,sha256=yE6ORJaErY8Bz5KTDDk7kRKLOlJ-xgS7Gvz_mFpjDJY,4604
|
|
6
|
-
ruff_sync/core.py,sha256=RtuKFRHjZmvi5W7bD3quD4ueqD8wt_5R2xWbtx3_MbI,42599
|
|
7
|
-
ruff_sync/dependencies.py,sha256=ND4WMPKmXjRTuanMVsYxZJ60mR6umffCnFJVZEpV8p4,1145
|
|
8
|
-
ruff_sync/formatters.py,sha256=sDZgx7SYNM1pjKshZLTgQHuAeT0P_tqAeV0VRZuz5FA,26311
|
|
9
|
-
ruff_sync/pre_commit.py,sha256=0ltXUkmocbrSwwf6cPbNc_K5K9n-gvou98xJy9YUhr0,5303
|
|
10
|
-
ruff_sync/system.py,sha256=sAqp6U4OQ4tWmfX2QD4b2ztutfz0SrqnAfuwaoygrbM,1350
|
|
11
|
-
ruff_sync-0.1.5.dev2.dist-info/METADATA,sha256=3pAjsPujF8BUALhRBy3DcME-WVa4E2OXeiq81DUO8b8,21464
|
|
12
|
-
ruff_sync-0.1.5.dev2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
-
ruff_sync-0.1.5.dev2.dist-info/entry_points.txt,sha256=z8TlIElMWLlqY_YWglHeMBksDohf2voVih6YnCBCWzk,45
|
|
14
|
-
ruff_sync-0.1.5.dev2.dist-info/licenses/LICENSE.md,sha256=RcCP8t8xyCx2cvMZSLjBrPqJfctciWC6q85ceQAcEfA,1080
|
|
15
|
-
ruff_sync-0.1.5.dev2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|