ruff-sync 0.1.4.dev1__py3-none-any.whl → 0.1.5.dev2__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
@@ -11,38 +11,48 @@ from .cli import (
11
11
  get_config,
12
12
  main,
13
13
  )
14
+ from .config_io import (
15
+ RuffConfigFileName,
16
+ is_ruff_toml_file,
17
+ load_local_ruff_config,
18
+ resolve_target_path,
19
+ )
20
+ from .constants import OutputFormat
14
21
  from .core import (
15
22
  Config,
16
23
  FetchResult,
17
- RuffConfigFileName,
18
24
  check,
19
25
  fetch_upstream_config,
20
26
  get_ruff_config,
21
27
  get_ruff_tool_table,
22
- is_ruff_toml_file,
23
28
  merge_ruff_toml,
24
29
  pull,
25
30
  resolve_raw_url,
26
31
  to_git_url,
27
32
  toml_ruff_parse,
28
33
  )
34
+ from .formatters import get_formatter
29
35
 
30
36
  __all__ = [
31
37
  "Arguments",
32
38
  "Config",
33
39
  "FetchResult",
40
+ "OutputFormat",
34
41
  "RuffConfigFileName",
35
42
  "__version__",
36
43
  "check",
37
44
  "fetch_upstream_config",
38
45
  "get_config",
46
+ "get_formatter",
39
47
  "get_ruff_config",
40
48
  "get_ruff_tool_table",
41
49
  "is_ruff_toml_file",
50
+ "load_local_ruff_config",
42
51
  "main",
43
52
  "merge_ruff_toml",
44
53
  "pull",
45
54
  "resolve_raw_url",
55
+ "resolve_target_path",
46
56
  "to_git_url",
47
57
  "toml_ruff_parse",
48
58
  ]
ruff_sync/cli.py CHANGED
@@ -16,25 +16,23 @@ from functools import lru_cache
16
16
  from importlib import metadata
17
17
  from typing import (
18
18
  TYPE_CHECKING,
19
- Any,
20
19
  ClassVar,
21
20
  Final,
21
+ Literal,
22
22
  NamedTuple,
23
- cast,
23
+ Protocol,
24
24
  )
25
25
 
26
26
  import tomlkit
27
27
  from httpx import URL
28
- from typing_extensions import deprecated
28
+ from typing_extensions import deprecated, override
29
29
 
30
30
  from ruff_sync.constants import (
31
31
  DEFAULT_BRANCH,
32
32
  DEFAULT_EXCLUDE,
33
33
  DEFAULT_PATH,
34
- MISSING,
35
- MissingType,
34
+ ConfKey,
36
35
  OutputFormat,
37
- resolve_defaults,
38
36
  )
39
37
  from ruff_sync.core import (
40
38
  Config,
@@ -51,6 +49,7 @@ if TYPE_CHECKING:
51
49
  __all__: Final[list[str]] = [
52
50
  "Arguments",
53
51
  "ColoredFormatter",
52
+ "OutputFormat",
54
53
  "get_config",
55
54
  "main",
56
55
  ]
@@ -61,6 +60,9 @@ except metadata.PackageNotFoundError:
61
60
  __version__ = "0.0.0-dev"
62
61
 
63
62
 
63
+ CIProvider = Literal[OutputFormat.GITHUB, OutputFormat.GITLAB]
64
+
65
+
64
66
  LOGGER = logging.getLogger(__name__)
65
67
 
66
68
 
@@ -80,7 +82,8 @@ class ColoredFormatter(logging.Formatter):
80
82
  """Initialize the formatter with a format string."""
81
83
  super().__init__(fmt)
82
84
 
83
- def format(self, record: logging.LogRecord) -> str: # type: ignore[explicit-override]
85
+ @override
86
+ def format(self, record: logging.LogRecord) -> str:
84
87
  """Format the log record with colors if the output is a TTY."""
85
88
  if sys.stderr.isatty():
86
89
  color = self.COLORS.get(record.levelno, self.RESET)
@@ -94,14 +97,14 @@ class Arguments(NamedTuple):
94
97
  command: str
95
98
  upstream: tuple[URL, ...]
96
99
  to: pathlib.Path
97
- exclude: Iterable[str] | MissingType
100
+ exclude: Iterable[str]
98
101
  verbose: int
99
- branch: str | MissingType = MISSING
100
- path: str | MissingType = MISSING
102
+ branch: str = DEFAULT_BRANCH
103
+ path: str | None = None
101
104
  semantic: bool = False
102
105
  diff: bool = True
103
106
  init: bool = False
104
- pre_commit: bool | MissingType = MISSING
107
+ pre_commit: bool = False
105
108
  save: bool | None = None
106
109
  output_format: OutputFormat = OutputFormat.TEXT
107
110
 
@@ -123,9 +126,30 @@ class ResolvedArgs(NamedTuple):
123
126
 
124
127
  upstream: tuple[URL, ...]
125
128
  to: pathlib.Path
126
- exclude: Iterable[str] | MissingType
127
- branch: str | MissingType
128
- path: str | MissingType
129
+ exclude: Iterable[str]
130
+ branch: str
131
+ path: str | None
132
+ output_format: OutputFormat
133
+
134
+
135
+ class CLIArguments(Protocol):
136
+ """Protocol for parsed CLI arguments from ArgumentParser."""
137
+
138
+ command: str | None
139
+ upstream: list[URL]
140
+ to: str | None
141
+ source: str | None
142
+ exclude: list[str] | None
143
+ verbose: int
144
+ branch: str | None
145
+ path: str | None
146
+ pre_commit: bool | None
147
+ output_format: OutputFormat | None
148
+ # Subcommand specific
149
+ init: bool
150
+ save: bool | None
151
+ semantic: bool
152
+ diff: bool
129
153
 
130
154
 
131
155
  @lru_cache(maxsize=1)
@@ -142,32 +166,39 @@ def get_config(
142
166
  """
143
167
  local_toml = source / RuffConfigFileName.PYPROJECT_TOML
144
168
  # TODO: use pydantic to validate the toml file
145
- cfg_result: dict[str, Any] = {}
169
+ cfg_result: Config = {}
146
170
  if local_toml.exists():
147
171
  toml = tomlkit.parse(local_toml.read_text())
148
172
  config = toml.get("tool", {}).get("ruff-sync")
149
173
  if config:
150
174
  allowed_keys = set(Config.__annotations__.keys())
151
- for arg, value in config.items():
152
- arg_norm = arg.replace("-", "_")
153
-
154
- # Handle aliases for pre-commit
155
- if arg_norm in ("pre_commit_sync", "pre_commit"):
156
- arg_norm = "pre_commit_version_sync"
157
-
158
- if arg_norm in allowed_keys:
159
- if arg_norm == "source":
160
- LOGGER.warning(
161
- "DeprecationWarning: [tool.ruff-sync] 'source' is deprecated. "
162
- "Use 'to' instead."
163
- )
164
- cfg_result[arg_norm] = value
175
+ for raw_key, value in config.items():
176
+ # Check for legacy 'source' key to emit deprecation warning
177
+ if raw_key.replace("-", "_") == ConfKey.to_attr(ConfKey.SOURCE):
178
+ LOGGER.warning(
179
+ f"DeprecationWarning: [tool.ruff-sync] '{raw_key}' "
180
+ f"is deprecated. Use '{ConfKey.TO}' instead."
181
+ )
182
+
183
+ # Map legacy names (source, pre_commit_sync) to canonical
184
+ # (to, pre-commit-version-sync)
185
+ canonical_key = ConfKey.get_canonical(raw_key)
186
+
187
+ # Normalize TOML key (dashes) to internal Python attribute name (underscores)
188
+ # e.g. "pre-commit-version-sync" -> "pre_commit_version_sync"
189
+ arg_attr = ConfKey.to_attr(canonical_key)
190
+
191
+ if arg_attr in allowed_keys:
192
+ cfg_result[arg_attr] = value # type: ignore[literal-required]
165
193
  else:
166
- LOGGER.warning(f"Unknown ruff-sync configuration: {arg}")
194
+ LOGGER.warning(f"Unknown ruff-sync configuration: {raw_key}")
195
+
167
196
  # Ensure 'to' is populated if 'source' was used
168
- if "source" in cfg_result and "to" not in cfg_result:
169
- cfg_result["to"] = cfg_result["source"]
170
- return cast("Config", cfg_result)
197
+ to_attr = ConfKey.to_attr(ConfKey.TO)
198
+ source_attr = ConfKey.to_attr(ConfKey.SOURCE)
199
+ if source_attr in cfg_result and to_attr not in cfg_result:
200
+ cfg_result[to_attr] = cfg_result[source_attr] # type: ignore[literal-required]
201
+ return cfg_result
171
202
 
172
203
 
173
204
  def _get_cli_parser() -> ArgumentParser:
@@ -255,8 +286,8 @@ def _get_cli_parser() -> ArgumentParser:
255
286
  "--output-format",
256
287
  type=OutputFormat,
257
288
  choices=list(OutputFormat),
258
- default=OutputFormat.TEXT,
259
- help="Format for output. Default: text.",
289
+ default=None,
290
+ help="Format for output. Default: text (auto-detected in CI).",
260
291
  )
261
292
 
262
293
  # Pull subcommand (the default action)
@@ -299,16 +330,23 @@ def _get_cli_parser() -> ArgumentParser:
299
330
  help="Do not show a diff.",
300
331
  )
301
332
 
333
+ # Inspect subcommand
334
+ subparsers.add_parser(
335
+ "inspect",
336
+ parents=[common_parser],
337
+ help="Open a Terminal UI to explore and interrogate your local ruff configuration.",
338
+ )
339
+
302
340
  return parser
303
341
 
304
342
 
305
343
  PARSER: Final[ArgumentParser] = _get_cli_parser()
306
344
 
307
345
 
308
- def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> tuple[URL, ...]:
346
+ def _resolve_upstream(args: CLIArguments, config: Config) -> tuple[URL, ...]:
309
347
  """Resolve upstream URL(s) from CLI or config."""
310
348
  if args.upstream:
311
- upstreams = tuple(cast("Iterable[URL]", args.upstream))
349
+ upstreams = tuple(args.upstream)
312
350
  # Log CLI upstreams for consistency with config sourcing
313
351
  summary = (
314
352
  f"{upstreams[0]}... ({len(upstreams)} total)"
@@ -346,54 +384,80 @@ def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> tuple[URL, ...]:
346
384
  )
347
385
 
348
386
 
349
- def _resolve_exclude(args: Any, config: Mapping[str, Any]) -> Iterable[str] | MissingType:
387
+ def _resolve_exclude(args: CLIArguments, config: Config) -> Iterable[str]:
350
388
  """Resolve exclude patterns from CLI or config.
351
389
 
352
390
  Returns the CLI value if provided, otherwise the value from
353
391
  ``[tool.ruff-sync].exclude`` in the config. If neither is set,
354
- returns ``MISSING`` so that :func:`~ruff_sync.constants.resolve_defaults`
355
- can apply the ``DEFAULT_EXCLUDE`` set downstream.
392
+ returns ``DEFAULT_EXCLUDE``.
356
393
  """
357
394
  if args.exclude is not None:
358
- return cast("Iterable[str]", args.exclude)
395
+ return args.exclude
359
396
  if "exclude" in config:
360
- exclude = config["exclude"]
397
+ exclude: Iterable[str] = config["exclude"]
361
398
  LOGGER.info(f"🚫 Using exclude from [tool.ruff-sync]: {list(exclude)}")
362
- return cast("Iterable[str]", exclude)
363
- return MISSING
399
+ return exclude
400
+ return DEFAULT_EXCLUDE
364
401
 
365
402
 
366
- def _resolve_branch(args: Any, config: Mapping[str, Any]) -> str | MissingType:
403
+ def _resolve_branch(args: CLIArguments, config: Config) -> str:
367
404
  """Resolve branch name from CLI, config, or MISSING.
368
405
 
369
406
  An empty string (``--branch ""``) is treated as falsy and falls back to
370
407
  the config or default, preserving the original behaviour.
371
408
  """
372
- if getattr(args, "branch", None):
373
- return cast("str", args.branch)
409
+ if args.branch:
410
+ return args.branch
374
411
  if "branch" in config:
375
- branch = cast("str", config["branch"])
412
+ branch: str = config["branch"]
376
413
  LOGGER.info(f"🌿 Using branch from [tool.ruff-sync]: {branch}")
377
414
  return branch
378
- return MISSING
415
+ return DEFAULT_BRANCH
379
416
 
380
417
 
381
- def _resolve_path(args: Any, config: Mapping[str, Any]) -> str | MissingType:
418
+ def _resolve_path(args: CLIArguments, config: Config) -> str:
382
419
  """Resolve path prefix from CLI, config, or MISSING.
383
420
 
384
421
  An empty string (``--path ""``) is treated as falsy and falls back to
385
422
  the config or default, preserving the original behaviour.
386
423
  """
387
- if getattr(args, "path", None):
388
- return cast("str", args.path)
424
+ if args.path:
425
+ return args.path
389
426
  if "path" in config:
390
- path = cast("str", config["path"])
427
+ path: str = config["path"]
391
428
  LOGGER.info(f"📄 Using path from [tool.ruff-sync]: {path}")
392
429
  return path
393
- return MISSING
430
+ return DEFAULT_PATH
431
+
432
+
433
+ def _resolve_output_format(args: CLIArguments, config: Config) -> OutputFormat:
434
+ """Resolve output format from CLI, config, or environment auto-detection."""
435
+ if args.output_format:
436
+ return args.output_format
437
+
438
+ if "output_format" in config:
439
+ fmt_str = config["output_format"]
440
+ try:
441
+ fmt = OutputFormat(fmt_str)
442
+ except ValueError:
443
+ valid_formats = ", ".join(f.value for f in OutputFormat)
444
+ LOGGER.warning(
445
+ f"Unknown output format in config: {fmt_str}. Valid values: {valid_formats}"
446
+ )
447
+ else:
448
+ LOGGER.info(f"📊 Using output format from [tool.ruff-sync]: {fmt}")
449
+ return fmt
450
+
451
+ # Auto-detection
452
+ provider = _detect_ci_provider()
453
+ if provider:
454
+ LOGGER.info(f"🤖 Auto-detected CI environment: {provider}")
455
+ return provider
456
+
457
+ return OutputFormat.TEXT
394
458
 
395
459
 
396
- def _resolve_to(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path) -> pathlib.Path:
460
+ def _resolve_to(args: CLIArguments, config: Config, initial_to: pathlib.Path) -> pathlib.Path:
397
461
  """Resolve target path from CLI, config, or default."""
398
462
  if args.source:
399
463
  LOGGER.warning("DeprecationWarning: --source is deprecated. Use --to instead.")
@@ -410,14 +474,62 @@ def _resolve_to(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path)
410
474
  return initial_to
411
475
 
412
476
 
413
- def _resolve_args(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path) -> ResolvedArgs:
477
+ def _resolve_args(args: CLIArguments, config: Config, initial_to: pathlib.Path) -> ResolvedArgs:
414
478
  """Resolve upstream, to, exclude, branch, and path from CLI and config."""
415
479
  upstream = _resolve_upstream(args, config)
416
480
  to = _resolve_to(args, config, initial_to)
417
481
  exclude = _resolve_exclude(args, config)
418
482
  branch = _resolve_branch(args, config)
419
483
  path = _resolve_path(args, config)
420
- return ResolvedArgs(upstream, to, cast("Any", exclude), cast("Any", branch), cast("Any", path))
484
+ output_format = _resolve_output_format(args, config)
485
+
486
+ # Normalize path "" -> None to match core expectation
487
+ final_path: str | None = path or None
488
+
489
+ return ResolvedArgs(
490
+ upstream,
491
+ to,
492
+ exclude,
493
+ branch,
494
+ final_path,
495
+ output_format,
496
+ )
497
+
498
+
499
+ def _resolve_pre_commit(args: CLIArguments, config: Config) -> bool:
500
+ """Resolve pre-commit sync setting from CLI or config."""
501
+ if args.pre_commit is not None:
502
+ return args.pre_commit
503
+
504
+ pre_commit_attr = ConfKey.to_attr(ConfKey.PRE_COMMIT_VERSION_SYNC)
505
+ if pre_commit_attr in config:
506
+ val = config.get(pre_commit_attr)
507
+ return bool(val)
508
+ return False
509
+
510
+
511
+ def _detect_ci_provider() -> CIProvider | None:
512
+ """Return the current CI provider, or None if not in a CI environment."""
513
+ if os.environ.get("GITHUB_ACTIONS") == "true":
514
+ return OutputFormat.GITHUB
515
+ if os.environ.get("GITLAB_CI") == "true":
516
+ return OutputFormat.GITLAB
517
+ return None
518
+
519
+
520
+ def _validate_ci_output_format(args: Arguments) -> None:
521
+ """Log a warning if the specified output format does not match the CI environment."""
522
+ provider = _detect_ci_provider()
523
+ if provider is OutputFormat.GITHUB and args.output_format == OutputFormat.GITLAB:
524
+ LOGGER.warning(
525
+ "⚠️ GitLab output format detected in GitHub Actions environment. "
526
+ "Did you mean '--output-format github'?"
527
+ )
528
+ elif provider is OutputFormat.GITLAB and args.output_format == OutputFormat.GITHUB:
529
+ LOGGER.warning(
530
+ "⚠️ GitHub output format detected in GitLab CI environment. "
531
+ "Did you mean '--output-format gitlab'?"
532
+ )
421
533
 
422
534
 
423
535
  def main() -> int:
@@ -426,6 +538,7 @@ def main() -> int:
426
538
  if len(sys.argv) > 1 and sys.argv[1] not in (
427
539
  "pull",
428
540
  "check",
541
+ "inspect",
429
542
  "-h",
430
543
  "--help",
431
544
  "--version",
@@ -460,24 +573,14 @@ def main() -> int:
460
573
  initial_to = pathlib.Path(arg_to) if arg_to else pathlib.Path()
461
574
  config: Config = get_config(initial_to)
462
575
 
463
- upstream, to_val, exclude, branch, path = _resolve_args(args, config, initial_to)
464
-
465
- pre_commit_arg = getattr(args, "pre_commit", None)
466
- if pre_commit_arg is not None:
467
- pre_commit_val: bool | MissingType = pre_commit_arg
468
- elif "pre-commit-version-sync" in config:
469
- pre_commit_val = cast("Any", config).get("pre-commit-version-sync")
470
- elif "pre_commit_version_sync" in config:
471
- pre_commit_val = config.get("pre_commit_version_sync", MISSING)
472
- else:
473
- pre_commit_val = MISSING
474
-
475
- # Build Arguments first so _resolve_defaults can centralize MISSING → default
476
- # resolution for branch and path (keeps cli.main and core._merge_multiple_upstreams
477
- # in sync with a single source of truth).
576
+ upstream, to_val, exclude, branch, path, output_format = _resolve_args(args, config, initial_to)
577
+ pre_commit_val = _resolve_pre_commit(args, config)
578
+
579
+ resolved_upstream = tuple(resolve_raw_url(u, branch=branch, path=path) for u in upstream)
580
+
478
581
  exec_args = Arguments(
479
582
  command=args.command,
480
- upstream=upstream,
583
+ upstream=resolved_upstream,
481
584
  to=to_val,
482
585
  exclude=exclude,
483
586
  verbose=args.verbose,
@@ -488,18 +591,25 @@ def main() -> int:
488
591
  init=getattr(args, "init", False),
489
592
  pre_commit=pre_commit_val,
490
593
  save=getattr(args, "save", None),
491
- output_format=getattr(args, "output_format", OutputFormat.TEXT),
594
+ output_format=output_format,
492
595
  )
493
596
 
494
- # Use the shared helper from constants so the MISSING→default logic for
495
- # branch/path cannot diverge between cli.main and core._merge_multiple_upstreams.
496
- res_branch, res_path, _exclude = resolve_defaults(branch, path, exclude)
497
- resolved_upstream = tuple(
498
- resolve_raw_url(u, branch=res_branch, path=res_path) for u in upstream
499
- )
500
- exec_args = exec_args._replace(upstream=resolved_upstream)
597
+ # Warn if the specified output format doesn't match the current CI environment
598
+ _validate_ci_output_format(exec_args)
501
599
 
502
600
  try:
601
+ 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
609
+
610
+ LOGGER.error("❌ The Terminal UI (inspect) is not yet implemented.")
611
+ return 1
612
+
503
613
  if exec_args.command == "check":
504
614
  return asyncio.run(check(exec_args))
505
615
  return asyncio.run(pull(exec_args))
ruff_sync/config_io.py ADDED
@@ -0,0 +1,136 @@
1
+ """Configuration file discovery and loading logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ import pathlib
7
+ from collections.abc import Iterable, Mapping
8
+ from typing import TYPE_CHECKING, Any
9
+ from urllib.parse import urlparse
10
+
11
+ import tomlkit
12
+ from tomlkit.items import Table
13
+ from typing_extensions import override
14
+
15
+ if TYPE_CHECKING:
16
+ from httpx import URL
17
+
18
+
19
+ @enum.unique
20
+ class RuffConfigFileName(str, enum.Enum):
21
+ """Enumeration of Ruff configuration filenames."""
22
+
23
+ PYPROJECT_TOML = "pyproject.toml"
24
+ RUFF_TOML = "ruff.toml"
25
+ DOT_RUFF_TOML = ".ruff.toml"
26
+
27
+ @classmethod
28
+ def tried_order(cls) -> list[RuffConfigFileName]:
29
+ """Return the order in which configuration files should be tried."""
30
+ return [cls.RUFF_TOML, cls.DOT_RUFF_TOML, cls.PYPROJECT_TOML]
31
+
32
+ @override
33
+ def __str__(self) -> str:
34
+ """Return the filename as a string."""
35
+ return self.value
36
+
37
+
38
+ def is_ruff_toml_file(path_or_url: str | URL) -> bool:
39
+ """Return True if the path or URL indicates a ruff.toml file.
40
+
41
+ This handles cases with query strings or fragments by examining only the path component.
42
+ """
43
+ parsed = urlparse(str(path_or_url))
44
+
45
+ # If it's a URL with a scheme/netloc, use the parsed path component.
46
+ # Otherwise, fall back to stripping any query/fragment from the raw string.
47
+ if parsed.scheme or parsed.netloc:
48
+ path = parsed.path
49
+ else:
50
+ path = str(path_or_url).split("?", 1)[0].split("#", 1)[0]
51
+
52
+ return pathlib.Path(path).name in (
53
+ RuffConfigFileName.RUFF_TOML,
54
+ RuffConfigFileName.DOT_RUFF_TOML,
55
+ )
56
+
57
+
58
+ def resolve_target_path(
59
+ to: pathlib.Path, upstreams: Iterable[str | URL] | None = None
60
+ ) -> pathlib.Path:
61
+ """Resolve the target path for configuration files.
62
+
63
+ If 'to' is a file, it's used directly.
64
+ Otherwise, it looks for existing ruff/pyproject.toml in the 'to' directory.
65
+ If none found, it defaults to pyproject.toml unless the first upstream is a ruff.toml.
66
+ """
67
+ if to.is_file():
68
+ return to
69
+
70
+ # If it's a directory, look for common config files
71
+ for filename in RuffConfigFileName.tried_order():
72
+ candidate = to / filename
73
+ if candidate.exists():
74
+ return candidate
75
+
76
+ # Use the first upstream URL as a hint for the default file name
77
+ first_upstream = next(iter(upstreams), None) if upstreams else None
78
+
79
+ # If upstream is specified and is a ruff.toml, default to ruff.toml
80
+ if first_upstream and is_ruff_toml_file(first_upstream):
81
+ return to / RuffConfigFileName.RUFF_TOML
82
+
83
+ return to / RuffConfigFileName.PYPROJECT_TOML
84
+
85
+
86
+ def load_local_ruff_config(target: pathlib.Path) -> dict[str, Any]:
87
+ """Load the local Ruff configuration as a plain dictionary.
88
+
89
+ Args:
90
+ target: The directory or file path to load configuration from.
91
+
92
+ Returns:
93
+ A plain dictionary containing the [tool.ruff] configuration.
94
+
95
+ Raises:
96
+ FileNotFoundError: If no configuration file is found at the target path.
97
+ TypeError: If the configuration structure is invalid.
98
+ """
99
+ config_path = resolve_target_path(target)
100
+ if not config_path.exists():
101
+ msg = f"No Ruff configuration file found at: {config_path}"
102
+ raise FileNotFoundError(msg)
103
+
104
+ content = config_path.read_text(encoding="utf-8")
105
+ doc = tomlkit.parse(content)
106
+
107
+ is_ruff_toml = config_path.name in (
108
+ RuffConfigFileName.RUFF_TOML,
109
+ RuffConfigFileName.DOT_RUFF_TOML,
110
+ )
111
+
112
+ if is_ruff_toml:
113
+ # For ruff.toml, the entire document is the configuration
114
+ unwrapped = doc.unwrap() if hasattr(doc, "unwrap") else doc
115
+ return unwrapped if isinstance(unwrapped, dict) else {}
116
+
117
+ # For pyproject.toml, extract [tool.ruff]
118
+ try:
119
+ # We use isinstance checks instead of cast to satisfy mypy
120
+ tool = doc.get("tool")
121
+ if not isinstance(tool, Mapping):
122
+ return {}
123
+ ruff = tool.get("ruff")
124
+ if ruff is None:
125
+ return {}
126
+ if not isinstance(ruff, (Mapping, Table)):
127
+ msg = f"Expected table for [tool.ruff], got {type(ruff)}"
128
+ raise TypeError(msg)
129
+
130
+ data = ruff.unwrap() if hasattr(ruff, "unwrap") else ruff
131
+ if not isinstance(data, dict):
132
+ return {}
133
+ except KeyError:
134
+ return {}
135
+ else:
136
+ return data
ruff_sync/constants.py CHANGED
@@ -15,6 +15,7 @@ __all__: Final[list[str]] = [
15
15
  "DEFAULT_EXCLUDE",
16
16
  "DEFAULT_PATH",
17
17
  "MISSING",
18
+ "ConfKey",
18
19
  "MissingType",
19
20
  "OutputFormat",
20
21
  "resolve_defaults",
@@ -61,6 +62,69 @@ class OutputFormat(str, enum.Enum):
61
62
  return self.value
62
63
 
63
64
 
65
+ @enum.unique
66
+ class ConfKey(str, enum.Enum):
67
+ """Centralized configuration keys for [tool.ruff-sync].
68
+
69
+ These are the canonical names used in the pyproject.toml configuration file.
70
+ """
71
+
72
+ UPSTREAM = "upstream"
73
+ TO = "to"
74
+ EXCLUDE = "exclude"
75
+ BRANCH = "branch"
76
+ PATH = "path"
77
+ PRE_COMMIT_VERSION_SYNC = "pre-commit-version-sync"
78
+ OUTPUT_FORMAT = "output-format"
79
+ SEMANTIC = "semantic"
80
+ DIFF = "diff"
81
+ INIT = "init"
82
+ SAVE = "save"
83
+ VERBOSE = "verbose"
84
+
85
+ # Legacy / Alias Keys
86
+ SOURCE = "source" # Legacy for 'to'
87
+ PRE_COMMIT = "pre-commit" # Legacy for 'pre-commit-version-sync'
88
+ PRE_COMMIT_SYNC_LEGACY = "pre-commit-sync" # Legacy for 'pre-commit-version-sync'
89
+
90
+ @override
91
+ def __str__(self) -> str:
92
+ """Return the string value for TOML keys."""
93
+ return self.value
94
+
95
+ @classmethod
96
+ def to_attr(cls, key: str | ConfKey) -> str:
97
+ """Normalize a configuration key to its Python attribute name (underscore)."""
98
+ return str(key).replace("-", "_")
99
+
100
+ @classmethod
101
+ def get_canonical(cls, key: str) -> str:
102
+ """Map legacy or aliased configuration keys to their canonical names.
103
+
104
+ Args:
105
+ key: The raw key from the configuration file.
106
+
107
+ Returns:
108
+ The canonical ConfKey name (still as a string for use in logic).
109
+ """
110
+ # Normalize the input key to underscores for robust alias matching
111
+ norm_key = cls.to_attr(key)
112
+
113
+ # Handle aliases for 'to'
114
+ if norm_key == cls.to_attr(cls.SOURCE):
115
+ return cls.TO.value
116
+
117
+ # Handle aliases for 'pre-commit-version-sync'
118
+ if norm_key in (
119
+ cls.to_attr(cls.PRE_COMMIT_SYNC_LEGACY),
120
+ cls.to_attr(cls.PRE_COMMIT),
121
+ ):
122
+ return cls.PRE_COMMIT_VERSION_SYNC.value
123
+
124
+ # Return the original key (even if unknown, let 'allowed_keys' handle it)
125
+ return key
126
+
127
+
64
128
  def resolve_defaults(
65
129
  branch: str | MissingType,
66
130
  path: str | MissingType,
@@ -69,8 +133,7 @@ def resolve_defaults(
69
133
  """Resolve MISSING sentinel values to their effective defaults.
70
134
 
71
135
  This is the single source of truth for MISSING → default resolution across
72
- both ``cli.main`` and ``core._merge_multiple_upstreams``, keeping the two
73
- layers in sync without introducing a circular dependency between them.
136
+ the CLI and internal logic, keeping the layers in sync.
74
137
 
75
138
  Args:
76
139
  branch: The resolved branch value or ``MISSING``.
@@ -80,8 +143,7 @@ def resolve_defaults(
80
143
  Returns:
81
144
  A ``(branch, path, exclude)`` tuple with defaults applied.
82
145
  ``path`` is normalised to ``None`` (not ``""``) so callers can forward
83
- it directly to :func:`~ruff_sync.core.resolve_raw_url` and
84
- :func:`~ruff_sync.core.fetch_upstreams_concurrently`.
146
+ it directly to :func:`~ruff_sync.core.resolve_raw_url`.
85
147
  """
86
148
  eff_branch = branch if branch is not MISSING else DEFAULT_BRANCH
87
149
  raw_path = path if path is not MISSING else DEFAULT_PATH
ruff_sync/core.py CHANGED
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import difflib
7
- import enum
8
7
  import json
9
8
  import logging
10
9
  import pathlib
@@ -23,7 +22,6 @@ from typing import (
23
22
  TypedDict,
24
23
  overload,
25
24
  )
26
- from urllib.parse import urlparse
27
25
 
28
26
  import httpx
29
27
  import tomlkit
@@ -31,12 +29,13 @@ from httpx import URL
31
29
  from tomlkit import TOMLDocument, table
32
30
  from tomlkit.items import Table
33
31
  from tomlkit.toml_file import TOMLFile
34
- from typing_extensions import override
35
32
 
33
+ from ruff_sync.config_io import RuffConfigFileName, is_ruff_toml_file, resolve_target_path
36
34
  from ruff_sync.constants import (
37
35
  DEFAULT_BRANCH,
38
- MISSING,
39
- resolve_defaults,
36
+ DEFAULT_EXCLUDE,
37
+ DEFAULT_PATH,
38
+ ConfKey,
40
39
  )
41
40
  from ruff_sync.formatters import ResultFormatter, get_formatter
42
41
  from ruff_sync.pre_commit import sync_pre_commit
@@ -76,25 +75,6 @@ _HTTP_OK: Final = 200
76
75
  _HTTP_NOT_FOUND: Final[int] = 404
77
76
 
78
77
 
79
- @enum.unique
80
- class RuffConfigFileName(str, enum.Enum):
81
- """Enumeration of Ruff configuration filenames."""
82
-
83
- PYPROJECT_TOML = "pyproject.toml"
84
- RUFF_TOML = "ruff.toml"
85
- DOT_RUFF_TOML = ".ruff.toml"
86
-
87
- @classmethod
88
- def tried_order(cls) -> list[RuffConfigFileName]:
89
- """Return the order in which configuration files should be tried."""
90
- return [cls.RUFF_TOML, cls.DOT_RUFF_TOML, cls.PYPROJECT_TOML]
91
-
92
- @override
93
- def __str__(self) -> str:
94
- """Return the filename as a string."""
95
- return self.value
96
-
97
-
98
78
  class FetchResult(NamedTuple):
99
79
  """Result of fetching an upstream configuration."""
100
80
 
@@ -141,34 +121,7 @@ class Config(TypedDict, total=False):
141
121
  diff: bool
142
122
  init: bool
143
123
  pre_commit_version_sync: bool
144
-
145
-
146
- def resolve_target_path(
147
- to: pathlib.Path, upstreams: Iterable[str | URL] | None = None
148
- ) -> pathlib.Path:
149
- """Resolve the target path for configuration files.
150
-
151
- If 'to' is a file, it's used directly.
152
- Otherwise, it looks for existing ruff/pyproject.toml in the 'to' directory.
153
- If none found, it defaults to pyproject.toml unless the first upstream is a ruff.toml.
154
- """
155
- if to.is_file():
156
- return to
157
-
158
- # If it's a directory, look for common config files
159
- for filename in RuffConfigFileName.tried_order():
160
- candidate = to / filename
161
- if candidate.exists():
162
- return candidate
163
-
164
- # Use the first upstream URL as a hint for the default file name
165
- first_upstream = next(iter(upstreams), None) if upstreams else None
166
-
167
- # If upstream is specified and is a ruff.toml, default to ruff.toml
168
- if first_upstream and is_ruff_toml_file(first_upstream):
169
- return to / RuffConfigFileName.RUFF_TOML
170
-
171
- return to / RuffConfigFileName.PYPROJECT_TOML
124
+ output_format: str
172
125
 
173
126
 
174
127
  def _resolve_upstream_target_path(path: str | None) -> str:
@@ -530,29 +483,6 @@ async def fetch_upstream_config(
530
483
  raise httpx.HTTPStatusError(msg, request=err.request, response=err.response) from None
531
484
 
532
485
 
533
- def is_ruff_toml_file(path_or_url: str | URL) -> bool:
534
- """Return True if the path or URL indicates a ruff.toml file.
535
-
536
- This handles:
537
- - Plain paths (e.g. "ruff.toml", ".ruff.toml", "configs/ruff.toml")
538
- - URLs with query strings or fragments (e.g. "ruff.toml?ref=main", "ruff.toml#L10")
539
- by examining only the path component (or the part before any query/fragment).
540
- """
541
- parsed = urlparse(str(path_or_url))
542
-
543
- # If it's a URL with a scheme/netloc, use the parsed path component.
544
- # Otherwise, fall back to stripping any query/fragment from the raw string.
545
- if parsed.scheme or parsed.netloc:
546
- path = parsed.path
547
- else:
548
- path = str(path_or_url).split("?", 1)[0].split("#", 1)[0]
549
-
550
- return pathlib.Path(path).name in (
551
- RuffConfigFileName.RUFF_TOML,
552
- RuffConfigFileName.DOT_RUFF_TOML,
553
- )
554
-
555
-
556
486
  @overload
557
487
  def get_ruff_config(
558
488
  toml: str | TOMLDocument,
@@ -798,18 +728,6 @@ async def fetch_upstreams_concurrently(
798
728
  return fetch_results
799
729
 
800
730
 
801
- def _resolve_defaults(
802
- args: Arguments,
803
- ) -> tuple[str, str | None, Iterable[str]]:
804
- """Resolve MISSING sentinel values in *args* to their effective defaults.
805
-
806
- Delegates to :func:`~ruff_sync.constants.resolve_defaults` so that the
807
- MISSING → default logic is centralised in one place and shared with
808
- ``cli.main`` without coupling the two layers together.
809
- """
810
- return resolve_defaults(args.branch, args.path, args.exclude)
811
-
812
-
813
731
  async def _merge_multiple_upstreams(
814
732
  target_doc: TOMLDocument,
815
733
  is_target_ruff_toml: bool,
@@ -821,7 +739,7 @@ async def _merge_multiple_upstreams(
821
739
  Downloads are performed concurrently via fetch_upstreams_concurrently,
822
740
  while merging remains sequential to preserve layering order.
823
741
  """
824
- branch, path, exclude = _resolve_defaults(args)
742
+ branch, path, exclude = args.branch, args.path, args.exclude
825
743
 
826
744
  fetch_results = await fetch_upstreams_concurrently(
827
745
  args.upstream, client, branch=branch, path=path
@@ -1017,7 +935,8 @@ async def check(
1017
935
  ... )
1018
936
  >>> # asyncio.run(check(args))
1019
937
  """
1020
- fmt = get_formatter(args.output_format)
938
+ output_format = args.output_format
939
+ fmt: ResultFormatter = get_formatter(output_format)
1021
940
  try:
1022
941
  fmt.note("🔍 Checking Ruff sync status...")
1023
942
 
@@ -1144,32 +1063,32 @@ def serialize_ruff_sync_config(doc: TOMLDocument, args: Arguments) -> None:
1144
1063
 
1145
1064
  # TODO: Consider only saving upstream if it differs from existing config
1146
1065
  if len(args.upstream) == 1:
1147
- ruff_sync_table["upstream"] = str(args.upstream[0])
1066
+ ruff_sync_table[ConfKey.UPSTREAM] = str(args.upstream[0])
1148
1067
  else:
1149
1068
  urls_array = tomlkit.array()
1150
1069
  urls_array.multiline(True)
1151
1070
  for url in args.upstream:
1152
1071
  urls_array.append(str(url))
1153
- ruff_sync_table["upstream"] = urls_array
1072
+ ruff_sync_table[ConfKey.UPSTREAM] = urls_array
1154
1073
 
1155
1074
  # Normalize excludes and de-duplicate while preserving order.
1156
1075
  # Only compute and persist excludes when explicitly provided so that
1157
1076
  # DEFAULT_EXCLUDE remains an implicit default and is not serialized.
1158
- if args.exclude is not MISSING:
1077
+ if args.exclude != DEFAULT_EXCLUDE:
1159
1078
  normalized_excludes = list(dict.fromkeys(args.exclude))
1160
1079
  exclude_array = tomlkit.array()
1161
1080
  for ex in normalized_excludes:
1162
1081
  exclude_array.append(ex)
1163
- ruff_sync_table["exclude"] = exclude_array
1082
+ ruff_sync_table[ConfKey.EXCLUDE] = exclude_array
1164
1083
 
1165
- if args.branch is not MISSING:
1166
- ruff_sync_table["branch"] = args.branch
1084
+ if args.branch != DEFAULT_BRANCH:
1085
+ ruff_sync_table[ConfKey.BRANCH] = args.branch
1167
1086
 
1168
- if args.path is not MISSING:
1169
- ruff_sync_table["path"] = args.path
1087
+ if args.path != DEFAULT_PATH and args.path is not None:
1088
+ ruff_sync_table[ConfKey.PATH] = args.path
1170
1089
 
1171
- if args.pre_commit is not MISSING:
1172
- ruff_sync_table["pre-commit-version-sync"] = args.pre_commit
1090
+ if args.pre_commit:
1091
+ ruff_sync_table[ConfKey.PRE_COMMIT_VERSION_SYNC] = args.pre_commit
1173
1092
 
1174
1093
 
1175
1094
  async def pull(
@@ -1194,7 +1113,8 @@ async def pull(
1194
1113
  ... )
1195
1114
  >>> # asyncio.run(pull(args))
1196
1115
  """
1197
- fmt = get_formatter(args.output_format)
1116
+ output_format = args.output_format
1117
+ fmt = get_formatter(output_format)
1198
1118
  try:
1199
1119
  fmt.note("🔄 Syncing Ruff...")
1200
1120
  _source_toml_path = resolve_target_path(args.to, args.upstream).resolve(strict=False)
@@ -1247,7 +1167,7 @@ async def pull(
1247
1167
  rel_path = _source_toml_path.resolve()
1248
1168
  fmt.success(f"✅ Updated {rel_path}")
1249
1169
 
1250
- if args.pre_commit is not MISSING and args.pre_commit:
1170
+ if args.pre_commit:
1251
1171
  sync_pre_commit(pathlib.Path.cwd(), dry_run=False)
1252
1172
 
1253
1173
  return 0
@@ -0,0 +1,38 @@
1
+ """Utilities for handling optional dependencies and lazy loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ from typing import Final
7
+
8
+ __all__: Final[list[str]] = ["is_installed", "require_dependency"]
9
+
10
+
11
+ def is_installed(package_name: str) -> bool:
12
+ """Check if a package is installed without importing it.
13
+
14
+ Args:
15
+ package_name: The name of the package to check.
16
+
17
+ Returns:
18
+ True if the package is available, False otherwise.
19
+ """
20
+ return importlib.util.find_spec(package_name) is not None
21
+
22
+
23
+ def require_dependency(package_name: str, extra_name: str) -> None:
24
+ """Ensure a dependency is installed, or raise a helpful ImportError.
25
+
26
+ Args:
27
+ package_name: The name of the required package.
28
+ extra_name: The name of the ruff-sync extra that provides this package.
29
+
30
+ Raises:
31
+ ImportError: If the package is not installed.
32
+ """
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)
ruff_sync/system.py ADDED
@@ -0,0 +1,45 @@
1
+ """System and subprocess utilities for ruff-sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Final
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ async def get_ruff_rule_markdown(rule_code: str) -> str | None:
13
+ """Execute `ruff rule <CODE>` and return the Markdown documentation.
14
+
15
+ Args:
16
+ rule_code: The Ruff rule code (e.g., 'RUF012').
17
+
18
+ Returns:
19
+ The Markdown documentation for the rule, or None if the execution fails
20
+ or the rule is not found.
21
+ """
22
+ cmd: Final[list[str]] = ["ruff", "rule", rule_code]
23
+ LOGGER.debug(f"Executing system command: {' '.join(cmd)}")
24
+
25
+ try:
26
+ process = await asyncio.create_subprocess_exec(
27
+ *cmd,
28
+ stdout=asyncio.subprocess.PIPE,
29
+ stderr=asyncio.subprocess.PIPE,
30
+ )
31
+ stdout, stderr = await process.communicate()
32
+
33
+ if process.returncode != 0:
34
+ msg = stderr.decode().strip()
35
+ LOGGER.warning(f"Ruff command failed with exit code {process.returncode}: {msg}")
36
+ return None
37
+
38
+ return stdout.decode().strip()
39
+
40
+ except FileNotFoundError:
41
+ LOGGER.exception("Ruff executable not found in PATH.")
42
+ return None
43
+ except Exception:
44
+ LOGGER.exception(f"Unexpected error executing ruff rule {rule_code}")
45
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruff-sync
3
- Version: 0.1.4.dev1
3
+ Version: 0.1.5.dev2
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/
@@ -27,6 +27,8 @@ Requires-Python: >=3.10
27
27
  Requires-Dist: httpx<1.0.0,>=0.27.0
28
28
  Requires-Dist: tomlkit<2.0.0,>=0.12.3
29
29
  Requires-Dist: typing-extensions>=4.5.0
30
+ Provides-Extra: tui
31
+ Requires-Dist: textual>=8.2.2; extra == 'tui'
30
32
  Description-Content-Type: text/markdown
31
33
 
32
34
  <p align="center">
@@ -0,0 +1,15 @@
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,,
@@ -1,12 +0,0 @@
1
- ruff_sync/__init__.py,sha256=k3I_7nbBGsORKMVpbE57pzkRiV18WvztBmw5UQRmASo,849
2
- ruff_sync/__main__.py,sha256=oMIbv9ywToleBKJrZ-GMo_KhM4lHg02nJe7t6neWzJE,179
3
- ruff_sync/cli.py,sha256=MKhLxUMlduR8aPhCzWLDewBm77HDqeZqCcJ7JyND1GI,17724
4
- ruff_sync/constants.py,sha256=10YBAxowzmd_0wwBHeAKgWRYdyI5WnK-OiPl16prNxc,2817
5
- ruff_sync/core.py,sha256=0PEvEdSsO8lKsScw9qwi08xK37HG8e3V-BKeR6JotzU,45302
6
- ruff_sync/formatters.py,sha256=sDZgx7SYNM1pjKshZLTgQHuAeT0P_tqAeV0VRZuz5FA,26311
7
- ruff_sync/pre_commit.py,sha256=0ltXUkmocbrSwwf6cPbNc_K5K9n-gvou98xJy9YUhr0,5303
8
- ruff_sync-0.1.4.dev1.dist-info/METADATA,sha256=pyknLtGnrKrR_2fGmPQOn3JYy6mjE9F8YVS1BDDtbjw,21398
9
- ruff_sync-0.1.4.dev1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
- ruff_sync-0.1.4.dev1.dist-info/entry_points.txt,sha256=z8TlIElMWLlqY_YWglHeMBksDohf2voVih6YnCBCWzk,45
11
- ruff_sync-0.1.4.dev1.dist-info/licenses/LICENSE.md,sha256=RcCP8t8xyCx2cvMZSLjBrPqJfctciWC6q85ceQAcEfA,1080
12
- ruff_sync-0.1.4.dev1.dist-info/RECORD,,