ruff-sync 0.1.5.dev2__py3-none-any.whl → 0.1.5.dev4__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/core.py CHANGED
@@ -11,11 +11,10 @@ import re
11
11
  import subprocess
12
12
  import sys
13
13
  import tempfile
14
- from collections.abc import Iterable, Mapping
14
+ from collections.abc import Iterable, Mapping, MutableMapping
15
15
  from io import StringIO
16
16
  from typing import (
17
17
  TYPE_CHECKING,
18
- Any,
19
18
  Final,
20
19
  Literal,
21
20
  NamedTuple,
@@ -27,7 +26,7 @@ import httpx
27
26
  import tomlkit
28
27
  from httpx import URL
29
28
  from tomlkit import TOMLDocument, table
30
- from tomlkit.items import Table
29
+ from tomlkit.items import Item, Table
31
30
  from tomlkit.toml_file import TOMLFile
32
31
 
33
32
  from ruff_sync.config_io import RuffConfigFileName, is_ruff_toml_file, resolve_target_path
@@ -41,6 +40,8 @@ from ruff_sync.formatters import ResultFormatter, get_formatter
41
40
  from ruff_sync.pre_commit import sync_pre_commit
42
41
 
43
42
  if TYPE_CHECKING:
43
+ from tomlkit.container import Container
44
+
44
45
  from ruff_sync.cli import Arguments
45
46
 
46
47
  __all__: Final[list[str]] = [
@@ -103,8 +104,8 @@ class DiffContext(NamedTuple):
103
104
  source_toml_path: pathlib.Path
104
105
  source_doc: TOMLDocument
105
106
  merged_doc: TOMLDocument
106
- source_val: Any
107
- merged_val: Any
107
+ source_val: object
108
+ merged_val: object
108
109
 
109
110
 
110
111
  class Config(TypedDict, total=False):
@@ -552,16 +553,19 @@ def _apply_exclusions(tbl: Table | TOMLDocument, exclude: Iterable[str]) -> None
552
553
  """
553
554
  for key_path in exclude:
554
555
  parts = key_path.split(".")
555
- target: Any = tbl
556
+ target: Table | TOMLDocument | Item | Container | None = tbl
556
557
  for part in parts[:-1]:
557
- target = target.get(part) if hasattr(target, "get") else None
558
- if target is None:
558
+ if isinstance(target, Mapping):
559
+ target = target.get(part)
560
+ else:
561
+ target = None
559
562
  break
560
- if target is not None and hasattr(target, "pop"):
563
+ if isinstance(target, Mapping):
561
564
  leaf = parts[-1]
562
565
  if leaf in target:
563
566
  LOGGER.info(f"Excluding `{key_path}` from upstream ruff config.")
564
- target.pop(leaf)
567
+ if hasattr(target, "pop"):
568
+ target.pop(leaf)
565
569
 
566
570
 
567
571
  def toml_ruff_parse(toml_s: str, exclude: Iterable[str]) -> TOMLDocument:
@@ -573,9 +577,11 @@ def toml_ruff_parse(toml_s: str, exclude: Iterable[str]) -> TOMLDocument:
573
577
  return ruff_toml
574
578
 
575
579
 
576
- def _recursive_update(source_table: Any, upstream: Any) -> None:
580
+ def _recursive_update(
581
+ source_table: MutableMapping[str, object], upstream: Mapping[str, object]
582
+ ) -> None:
577
583
  """Recursively update a TOML table, preserving formatting of existing keys."""
578
- if hasattr(upstream, "items") or isinstance(upstream, Mapping):
584
+ if isinstance(upstream, Mapping) or hasattr(upstream, "items"):
579
585
  items = upstream.items()
580
586
  else:
581
587
  return
@@ -588,26 +594,32 @@ def _recursive_update(source_table: Any, upstream: Any) -> None:
588
594
  # Structural fix: if the target is a proxy (dotted key),
589
595
  # and we are adding NEW keys to it, we must convert it to a real
590
596
  # table to ensure children get correct headers.
591
- source_sub_keys = set(source_table[key].keys())
592
- upstream_sub_keys = set(value.keys())
597
+ source_key_val = source_table[key]
598
+ source_sub_keys = (
599
+ set(source_key_val.keys()) if isinstance(source_key_val, Mapping) else set()
600
+ )
601
+ upstream_sub_keys = set(value.keys()) if isinstance(value, Mapping) else set()
593
602
  if not upstream_sub_keys.issubset(source_sub_keys):
594
- current_val = source_table[key].unwrap()
603
+ current_val = getattr(
604
+ source_table[key], "unwrap", lambda k=key: source_table[k]
605
+ )()
595
606
  # DELETE PROXY FIRST to avoid structural doubling
596
607
  del source_table[key]
597
608
  # ADD AS REAL TABLE
598
- source_table.add(key, current_val)
599
-
600
- _recursive_update(source_table[key], value)
609
+ if hasattr(source_table, "add"):
610
+ source_table.add(key, current_val)
611
+ else:
612
+ source_table[key] = current_val
613
+
614
+ child_source = source_table[key]
615
+ if isinstance(child_source, MutableMapping) and isinstance(value, Mapping):
616
+ _recursive_update(child_source, value)
601
617
  else:
602
618
  # Overwrite existing leaf value only if it's semantically different.
603
619
  # Compare unwrapped values, but assign the raw tomlkit Item to
604
620
  # preserve inline comments attached to the upstream value.
605
- current_val = (
606
- source_table[key].unwrap()
607
- if hasattr(source_table[key], "unwrap")
608
- else source_table[key]
609
- )
610
- new_val_unwrapped = value.unwrap() if hasattr(value, "unwrap") else value
621
+ current_val = getattr(source_table[key], "unwrap", lambda k=key: source_table[k])()
622
+ new_val_unwrapped = getattr(value, "unwrap", lambda v=value: v)()
611
623
  if current_val != new_val_unwrapped:
612
624
  source_table[key] = value
613
625
  else:
@@ -813,8 +825,8 @@ def _check_pre_commit_sync(args: Arguments, fmt: ResultFormatter) -> int | None:
813
825
 
814
826
 
815
827
  def _find_changed_keys(
816
- source: Any,
817
- merged: Any,
828
+ source: object,
829
+ merged: object,
818
830
  prefix: str = "",
819
831
  ) -> list[str]:
820
832
  """Return a list of dotted TOML keys that differ between *source* and *merged*.
@@ -834,12 +846,9 @@ def _find_changed_keys(
834
846
  """
835
847
  changed: list[str] = []
836
848
 
837
- source_is_mapping = isinstance(source, Mapping)
838
- merged_is_mapping = isinstance(merged, Mapping)
839
-
840
- if source_is_mapping and merged_is_mapping:
849
+ if isinstance(source, Mapping) and isinstance(merged, Mapping):
841
850
  for key, merged_val in merged.items():
842
- full_key = f"{prefix}.{key}" if prefix else key
851
+ full_key = f"{prefix}.{key}" if prefix else str(key)
843
852
  source_val = source.get(key)
844
853
  if source_val is None and key not in source:
845
854
  # Key is new (only in merged)
@@ -859,7 +868,7 @@ def _find_changed_keys(
859
868
  changed.extend(nested)
860
869
  elif src_unwrapped != mrg_unwrapped:
861
870
  changed.append(full_key)
862
- elif source_is_mapping != merged_is_mapping:
871
+ elif isinstance(source, Mapping) != isinstance(merged, Mapping):
863
872
  # Structural type mismatch (table vs scalar or vice-versa): treat the
864
873
  # whole node as changed rather than attempting a meaningless value
865
874
  # comparison between incompatible TOML node types.
@@ -889,8 +898,8 @@ def _report_drift(
889
898
  generic error.
890
899
  """
891
900
  if is_ruff_toml:
892
- source_section: Any = source_doc
893
- merged_section: Any = merged_doc
901
+ source_section: object = source_doc
902
+ merged_section: object = merged_doc
894
903
  else:
895
904
  source_section = source_doc.get("tool", {}).get("ruff") or {}
896
905
  merged_section = merged_doc.get("tool", {}).get("ruff") or {}
@@ -966,8 +975,8 @@ async def check(
966
975
  )
967
976
 
968
977
  is_source_ruff_toml = is_ruff_toml_file(_source_toml_path.name)
969
- source_val: Any = None
970
- merged_val: Any = None
978
+ source_val: object = None
979
+ merged_val: object = None
971
980
  if args.semantic:
972
981
  if is_source_ruff_toml:
973
982
  source_ruff = source_doc
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,14 @@
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
12
+
13
+ from ruff_sync.types_ import RuffLinter, RuffRule
8
14
 
9
15
  LOGGER = logging.getLogger(__name__)
10
16
 
@@ -20,6 +26,132 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None:
20
26
  or the rule is not found.
21
27
  """
22
28
  cmd: Final[list[str]] = ["ruff", "rule", rule_code]
29
+ return await _run_ruff_command(cmd, f"ruff rule {rule_code}")
30
+
31
+
32
+ async def get_ruff_config_markdown(setting_path: str) -> str | None:
33
+ """Execute `ruff config <SETTING>` and return the Markdown documentation.
34
+
35
+ Args:
36
+ setting_path: The Ruff configuration setting path (e.g., 'lint.select').
37
+
38
+ Returns:
39
+ The Markdown documentation for the setting, or None if the execution fails.
40
+ """
41
+ # Strip 'tool.ruff.' prefix if present as 'ruff config' expects relative paths
42
+ clean_path = setting_path.removeprefix("tool.ruff.")
43
+ if not clean_path or clean_path == "tool.ruff":
44
+ return None
45
+ cmd: Final[list[str]] = ["ruff", "config", clean_path]
46
+ return await _run_ruff_command(cmd, f"ruff config {clean_path}")
47
+
48
+
49
+ async def get_all_ruff_rules() -> list[RuffRule]:
50
+ """Execute `ruff rule --all --output-format json` and return the parsed rules.
51
+
52
+ Returns:
53
+ A list of dictionaries representing all supported Ruff rules.
54
+ """
55
+ cmd: Final[list[str]] = ["ruff", "rule", "--all", "--output-format", "json"]
56
+ output = await _run_ruff_command(cmd, "ruff rule --all")
57
+ if not output:
58
+ return []
59
+ try:
60
+ data = json.loads(output)
61
+ except json.JSONDecodeError:
62
+ LOGGER.exception("Failed to parse Ruff rules JSON.")
63
+ return []
64
+
65
+ if isinstance(data, list):
66
+ return data
67
+ return []
68
+
69
+
70
+ async def get_ruff_linters() -> list[RuffLinter]:
71
+ """Execute `ruff linter --output-format json` and return the parsed linters.
72
+
73
+ Returns:
74
+ A list of dictionaries representing Ruff linter categories.
75
+ """
76
+ cmd: Final[list[str]] = ["ruff", "linter", "--output-format", "json"]
77
+ output = await _run_ruff_command(cmd, "ruff linter")
78
+ if not output:
79
+ return []
80
+ try:
81
+ data = json.loads(output)
82
+ except json.JSONDecodeError:
83
+ LOGGER.exception("Failed to parse Ruff linters JSON.")
84
+ return []
85
+
86
+ if isinstance(data, list):
87
+ return data
88
+ return []
89
+
90
+
91
+ def compute_effective_rules(
92
+ all_rules: list[RuffRule], toml_config: Mapping[str, Any]
93
+ ) -> list[RuffRule]:
94
+ """Determine the status (Enabled, Ignored, Disabled) for each rule.
95
+
96
+ Args:
97
+ all_rules: The list of all supported rules.
98
+ toml_config: The local configuration dictionary.
99
+
100
+ Returns:
101
+ The list of rules enriched with a 'status' key.
102
+ """
103
+ # The config may be "wrapped" (top-level 'tool' key) or
104
+ # "unwrapped" (direct Ruff config as returned by load_local_ruff_config).
105
+ lint = toml_config.get("lint")
106
+ if lint is None:
107
+ lint = toml_config.get("tool", {}).get("ruff", {}).get("lint", {})
108
+
109
+ select = set(lint.get("select", [])) | set(lint.get("extend-select", []))
110
+ ignore = set(lint.get("ignore", [])) | set(lint.get("extend-ignore", []))
111
+
112
+ # If no select/extend-select is provided, Ruff defaults to E and F
113
+ if not lint.get("select") and not lint.get("extend-select"):
114
+ select.update(["E", "F"])
115
+
116
+ enriched: list[RuffRule] = []
117
+ for rule in all_rules:
118
+ code = rule["code"]
119
+
120
+ # Find longest matching select prefix
121
+ best_select_len = -1
122
+ for s in select:
123
+ if code.startswith(s):
124
+ best_select_len = max(best_select_len, len(s))
125
+
126
+ # Find longest matching ignore prefix
127
+ best_ignore_len = -1
128
+ for i in ignore:
129
+ if code.startswith(i):
130
+ best_ignore_len = max(best_ignore_len, len(i))
131
+
132
+ status = "Disabled"
133
+ if best_select_len > best_ignore_len:
134
+ status = "Enabled"
135
+ elif best_ignore_len >= best_select_len and best_ignore_len != -1:
136
+ status = "Ignored"
137
+
138
+ rule_with_status = dict(rule)
139
+ rule_with_status["status"] = status
140
+ enriched.append(rule_with_status) # type: ignore[arg-type]
141
+
142
+ return enriched
143
+
144
+
145
+ async def _run_ruff_command(cmd: list[str], description: str) -> str | None:
146
+ """Execute a ruff command and return the decoded output.
147
+
148
+ Args:
149
+ cmd: The command to execute.
150
+ description: A human-readable description for logging.
151
+
152
+ Returns:
153
+ The decoded stdout, or None if the command fails.
154
+ """
23
155
  LOGGER.debug(f"Executing system command: {' '.join(cmd)}")
24
156
 
25
157
  try:
@@ -32,14 +164,15 @@ async def get_ruff_rule_markdown(rule_code: str) -> str | None:
32
164
 
33
165
  if process.returncode != 0:
34
166
  msg = stderr.decode().strip()
35
- LOGGER.warning(f"Ruff command failed with exit code {process.returncode}: {msg}")
167
+ LOGGER.warning(f"Command '{description}' failed with code {process.returncode}: {msg}")
36
168
  return None
37
169
 
38
- return stdout.decode().strip()
39
-
170
+ output = stdout.decode().strip()
40
171
  except FileNotFoundError:
41
172
  LOGGER.exception("Ruff executable not found in PATH.")
42
173
  return None
43
174
  except Exception:
44
- LOGGER.exception(f"Unexpected error executing ruff rule {rule_code}")
175
+ LOGGER.exception(f"Unexpected error executing '{description}'")
45
176
  return None
177
+ else:
178
+ 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