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 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.dependencies import require_dependency # noqa: PLC0415
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
- LOGGER.error("❌ The Terminal UI (inspect) is not yet implemented.")
611
- return 1
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
- __all__: Final[list[str]] = ["is_installed", "require_dependency"]
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(package_name: str, extra_name: str) -> None:
24
- """Ensure a dependency is installed, or raise a helpful ImportError.
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
- ImportError: If the package is not installed.
48
+ DependencyError: If the package is not installed or raises an error during import.
32
49
  """
33
- if not is_installed(package_name):
34
- msg = (
35
- f"The '{package_name}' package is required for this feature. "
36
- f"Install it with: pip install 'ruff-sync[{extra_name}]'"
37
- )
38
- raise ImportError(msg)
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"Ruff command failed with exit code {process.returncode}: {msg}")
165
+ LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}")
36
166
  return None
37
167
 
38
- return stdout.decode().strip()
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 ruff rule {rule_code}")
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]+$")
@@ -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")
@@ -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
+ )
@@ -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.dev2
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,2 +1,3 @@
1
1
  [console_scripts]
2
+ ruff-inspect = ruff_sync:inspect
2
3
  ruff-sync = ruff_sync:main
@@ -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,,