ruff-sync 0.0.1.dev4__tar.gz → 0.0.2.dev0__tar.gz

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.
Files changed (44) hide show
  1. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/workflows/ci.yaml +1 -0
  2. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/PKG-INFO +73 -5
  3. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/README.md +72 -4
  4. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/pyproject.toml +2 -1
  5. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/ruff_sync.py +224 -59
  6. ruff_sync-0.0.2.dev0/scripts/dogfood_check.sh +48 -0
  7. ruff_sync-0.0.2.dev0/tasks.py +199 -0
  8. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_final.toml +4 -2
  9. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_final.toml +4 -2
  10. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_basic.py +15 -10
  11. ruff_sync-0.0.2.dev0/tests/test_check.py +223 -0
  12. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_e2e.py +64 -4
  13. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_whitespace.py +27 -5
  14. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/uv.lock +3 -1
  15. ruff_sync-0.0.1.dev4/tasks.py +0 -90
  16. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.agents/TESTING.md +0 -0
  17. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.agents/workflows/add-test-case.md +0 -0
  18. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/dependabot.yml +0 -0
  19. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/workflows/complexity.yaml +0 -0
  20. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.gitignore +0 -0
  21. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.pre-commit-config.yaml +0 -0
  22. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/AGENTS.md +0 -0
  23. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/LICENSE.md +0 -0
  24. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/codecov.yml +0 -0
  25. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/ruff_sync_banner.png +0 -0
  26. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/scripts/dogfood.sh +0 -0
  27. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/__init__.py +0 -0
  28. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_final.toml +0 -0
  29. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_initial.toml +0 -0
  30. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_upstream.toml +0 -0
  31. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_final.toml +0 -0
  32. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_initial.toml +0 -0
  33. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_upstream.toml +0 -0
  34. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_initial.toml +0 -0
  35. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_upstream.toml +0 -0
  36. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_initial.toml +0 -0
  37. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_upstream.toml +0 -0
  38. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/ruff.toml +0 -0
  39. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_corner_cases.py +0 -0
  40. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_project.py +0 -0
  41. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_toml_operations.py +0 -0
  42. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/w_ruff_sync_cfg/pyproject.toml +0 -0
  43. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/wo_ruff_cfg/pyproject.toml +0 -0
  44. {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/wo_ruff_sync_cfg/pyproject.toml +0 -0
@@ -33,6 +33,7 @@ jobs:
33
33
 
34
34
  tests:
35
35
  strategy:
36
+ fail-fast: ${{ github.event.pull_request.draft == true }}
36
37
  matrix:
37
38
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
38
39
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruff-sync
3
- Version: 0.0.1.dev4
3
+ Version: 0.0.2.dev0
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://github.com/Kilo59/ruff-sync#readme
@@ -52,6 +52,7 @@ Description-Content-Type: text/markdown
52
52
  - [Install](#install)
53
53
  - [Usage](#usage)
54
54
  - [Key Features](#key-features)
55
+ - [CI Integration](#ci-integration)
55
56
  - [Configuration](#configuration)
56
57
  - [Contributing](#contributing)
57
58
  - [License](#license)
@@ -150,10 +151,18 @@ ruff-sync --source ./my-project
150
151
 
151
152
  # Exclude specific sections from being overwritten using dotted paths
152
153
  ruff-sync --exclude lint.per-file-ignores lint.ignore
154
+
155
+ # Check if your local config is in sync (useful in CI)
156
+ ruff-sync check https://github.com/my-org/standards/blob/main/pyproject.toml
157
+
158
+ # Semantic check — ignore cosmetic differences like comments and whitespace
159
+ ruff-sync check --semantic
153
160
  ```
154
161
 
155
162
  ### CLI Reference
156
163
 
164
+ #### `ruff-sync`
165
+
157
166
  ```
158
167
  usage: ruff-sync [-h] [--source SOURCE] [--exclude EXCLUDE [EXCLUDE ...]] [-v] [upstream]
159
168
 
@@ -163,18 +172,71 @@ positional arguments:
163
172
 
164
173
  optional arguments:
165
174
  -h, --help show this help message and exit
166
- --source SOURCE The directory to sync the pyproject.toml file to. Default: .
175
+ --source SOURCE The directory or file to sync. Default: .
176
+ --exclude EXCLUDE [EXCLUDE ...]
177
+ Exclude certain ruff config keys. Default: lint.per-file-ignores
178
+ -v, --verbose Increase verbosity. -v for INFO, -vv for DEBUG.
179
+ ```
180
+
181
+ #### `ruff-sync check`
182
+
183
+ ```
184
+ usage: ruff-sync check [-h] [--source SOURCE] [--exclude EXCLUDE [EXCLUDE ...]] [--semantic] [--no-diff] [-v] [upstream]
185
+
186
+ positional arguments:
187
+ upstream The URL to download the pyproject.toml file from.
188
+ Optional if defined in [tool.ruff-sync]
189
+
190
+ optional arguments:
191
+ -h, --help show this help message and exit
192
+ --source SOURCE The directory or file to check. Default: .
167
193
  --exclude EXCLUDE [EXCLUDE ...]
168
- Exclude certain ruff configs. Default: lint.per-file-ignores
194
+ Exclude certain ruff config keys.
195
+ --semantic Ignore cosmetic differences (whitespace, comments);
196
+ only fail on actual value changes.
197
+ --no-diff Suppress the diff output.
169
198
  -v, --verbose Increase verbosity. -v for INFO, -vv for DEBUG.
170
199
  ```
171
200
 
201
+ Exits **0** if in sync, **1** if out of sync.
202
+
172
203
  ## Key Features
173
204
 
174
205
  - **Format-preserving merges** — Uses [tomlkit](https://github.com/sdispater/tomlkit) under the hood, so your comments, whitespace, and TOML structure are preserved. No reformatting surprises.
175
206
  - **GitHub URL support** — Paste a GitHub blob URL and it will automatically convert it to the raw content URL.
176
207
  - **Selective exclusions** — Keep project-specific overrides (like `target-version`) from being clobbered by the upstream config.
177
208
  - **Works with any host** — GitHub, GitLab, Bitbucket, or any raw URL that serves a `pyproject.toml`.
209
+ - **CI-ready `check` command** — Verify that your local config is in sync without modifying anything. Exits 1 if out of sync, making it perfect for pre-merge gates.
210
+ - **Semantic mode** — Use `--semantic` to ignore cosmetic differences (comments, whitespace) and only fail on real value changes.
211
+
212
+ ## CI Integration
213
+
214
+ The `check` command is designed for use in CI pipelines. Add it as a step to catch config drift before it merges:
215
+
216
+ ```yaml
217
+ # .github/workflows/ci.yaml
218
+ - name: Check ruff config is in sync
219
+ run: |
220
+ ruff-sync check --semantic
221
+ ```
222
+
223
+ With `--semantic`, minor reformatting of your local file won't cause a false positive — only actual rule or value differences will fail the check.
224
+
225
+ To see exactly what's drifted, omit `--no-diff` (the default) and the output will include a unified diff:
226
+
227
+ ```console
228
+ $ ruff-sync check --semantic
229
+ 🔍 Checking Ruff sync status...
230
+ ❌ Ruff configuration at pyproject.toml is out of sync!
231
+ --- local (semantic)
232
+ +++ upstream (semantic)
233
+ @@ -5,6 +5,7 @@
234
+ "select": [
235
+ + "PERF",
236
+ "RUF",
237
+ ...
238
+ ]
239
+ ```
178
240
 
179
241
  ## Configuration
180
242
 
@@ -233,9 +295,15 @@ uv run pytest -vv # test
233
295
 
234
296
  ## Dogfooding
235
297
 
236
- To see `ruff-sync` in action on a complex, real-world configuration, you can "dogfood" it by syncing this project's own `pyproject.toml` with a large upstream config like Pydantic's.
298
+ To see `ruff-sync` in action, you can "dogfood" it on this project's own config.
299
+
300
+ **Check if this project is in sync with its upstream:**
301
+
302
+ ```console
303
+ ./scripts/dogfood_check.sh
304
+ ```
237
305
 
238
- We've provided a script to make this easy:
306
+ **Or sync from a large upstream like Pydantic's config:**
239
307
 
240
308
  ```console
241
309
  ./scripts/dogfood.sh
@@ -22,6 +22,7 @@
22
22
  - [Install](#install)
23
23
  - [Usage](#usage)
24
24
  - [Key Features](#key-features)
25
+ - [CI Integration](#ci-integration)
25
26
  - [Configuration](#configuration)
26
27
  - [Contributing](#contributing)
27
28
  - [License](#license)
@@ -120,10 +121,18 @@ ruff-sync --source ./my-project
120
121
 
121
122
  # Exclude specific sections from being overwritten using dotted paths
122
123
  ruff-sync --exclude lint.per-file-ignores lint.ignore
124
+
125
+ # Check if your local config is in sync (useful in CI)
126
+ ruff-sync check https://github.com/my-org/standards/blob/main/pyproject.toml
127
+
128
+ # Semantic check — ignore cosmetic differences like comments and whitespace
129
+ ruff-sync check --semantic
123
130
  ```
124
131
 
125
132
  ### CLI Reference
126
133
 
134
+ #### `ruff-sync`
135
+
127
136
  ```
128
137
  usage: ruff-sync [-h] [--source SOURCE] [--exclude EXCLUDE [EXCLUDE ...]] [-v] [upstream]
129
138
 
@@ -133,18 +142,71 @@ positional arguments:
133
142
 
134
143
  optional arguments:
135
144
  -h, --help show this help message and exit
136
- --source SOURCE The directory to sync the pyproject.toml file to. Default: .
145
+ --source SOURCE The directory or file to sync. Default: .
146
+ --exclude EXCLUDE [EXCLUDE ...]
147
+ Exclude certain ruff config keys. Default: lint.per-file-ignores
148
+ -v, --verbose Increase verbosity. -v for INFO, -vv for DEBUG.
149
+ ```
150
+
151
+ #### `ruff-sync check`
152
+
153
+ ```
154
+ usage: ruff-sync check [-h] [--source SOURCE] [--exclude EXCLUDE [EXCLUDE ...]] [--semantic] [--no-diff] [-v] [upstream]
155
+
156
+ positional arguments:
157
+ upstream The URL to download the pyproject.toml file from.
158
+ Optional if defined in [tool.ruff-sync]
159
+
160
+ optional arguments:
161
+ -h, --help show this help message and exit
162
+ --source SOURCE The directory or file to check. Default: .
137
163
  --exclude EXCLUDE [EXCLUDE ...]
138
- Exclude certain ruff configs. Default: lint.per-file-ignores
164
+ Exclude certain ruff config keys.
165
+ --semantic Ignore cosmetic differences (whitespace, comments);
166
+ only fail on actual value changes.
167
+ --no-diff Suppress the diff output.
139
168
  -v, --verbose Increase verbosity. -v for INFO, -vv for DEBUG.
140
169
  ```
141
170
 
171
+ Exits **0** if in sync, **1** if out of sync.
172
+
142
173
  ## Key Features
143
174
 
144
175
  - **Format-preserving merges** — Uses [tomlkit](https://github.com/sdispater/tomlkit) under the hood, so your comments, whitespace, and TOML structure are preserved. No reformatting surprises.
145
176
  - **GitHub URL support** — Paste a GitHub blob URL and it will automatically convert it to the raw content URL.
146
177
  - **Selective exclusions** — Keep project-specific overrides (like `target-version`) from being clobbered by the upstream config.
147
178
  - **Works with any host** — GitHub, GitLab, Bitbucket, or any raw URL that serves a `pyproject.toml`.
179
+ - **CI-ready `check` command** — Verify that your local config is in sync without modifying anything. Exits 1 if out of sync, making it perfect for pre-merge gates.
180
+ - **Semantic mode** — Use `--semantic` to ignore cosmetic differences (comments, whitespace) and only fail on real value changes.
181
+
182
+ ## CI Integration
183
+
184
+ The `check` command is designed for use in CI pipelines. Add it as a step to catch config drift before it merges:
185
+
186
+ ```yaml
187
+ # .github/workflows/ci.yaml
188
+ - name: Check ruff config is in sync
189
+ run: |
190
+ ruff-sync check --semantic
191
+ ```
192
+
193
+ With `--semantic`, minor reformatting of your local file won't cause a false positive — only actual rule or value differences will fail the check.
194
+
195
+ To see exactly what's drifted, omit `--no-diff` (the default) and the output will include a unified diff:
196
+
197
+ ```console
198
+ $ ruff-sync check --semantic
199
+ 🔍 Checking Ruff sync status...
200
+ ❌ Ruff configuration at pyproject.toml is out of sync!
201
+ --- local (semantic)
202
+ +++ upstream (semantic)
203
+ @@ -5,6 +5,7 @@
204
+ "select": [
205
+ + "PERF",
206
+ "RUF",
207
+ ...
208
+ ]
209
+ ```
148
210
 
149
211
  ## Configuration
150
212
 
@@ -203,9 +265,15 @@ uv run pytest -vv # test
203
265
 
204
266
  ## Dogfooding
205
267
 
206
- To see `ruff-sync` in action on a complex, real-world configuration, you can "dogfood" it by syncing this project's own `pyproject.toml` with a large upstream config like Pydantic's.
268
+ To see `ruff-sync` in action, you can "dogfood" it on this project's own config.
269
+
270
+ **Check if this project is in sync with its upstream:**
271
+
272
+ ```console
273
+ ./scripts/dogfood_check.sh
274
+ ```
207
275
 
208
- We've provided a script to make this easy:
276
+ **Or sync from a large upstream like Pydantic's config:**
209
277
 
210
278
  ```console
211
279
  ./scripts/dogfood.sh
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ruff-sync"
3
- version = "0.0.1.dev4"
3
+ version = "0.0.2.dev0"
4
4
  description = "Synchronize Ruff linter configuration across projects"
5
5
  keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit"]
6
6
  authors = [
@@ -43,6 +43,7 @@ dev = [
43
43
  "coverage>=7.4.4",
44
44
  "invoke>=2.2.0",
45
45
  "mypy>=1.10.0",
46
+ "packaging>=26.0",
46
47
  "pre-commit>=3.7.0",
47
48
  "pyfakefs>=5.4.1",
48
49
  "pytest>=8.0.0",
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import difflib
5
+ import json
4
6
  import logging
5
7
  import pathlib
8
+ import re
6
9
  import sys
7
- from argparse import ArgumentParser
10
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
8
11
  from collections.abc import Iterable, Mapping
9
12
  from functools import lru_cache
10
13
  from io import StringIO
@@ -17,7 +20,7 @@ from tomlkit import TOMLDocument, table
17
20
  from tomlkit.items import Table
18
21
  from tomlkit.toml_file import TOMLFile
19
22
 
20
- __version__ = "0.0.1.dev4"
23
+ __version__ = "0.0.2.dev0"
21
24
 
22
25
  _DEFAULT_EXCLUDE: Final[set[str]] = {"lint.per-file-ignores"}
23
26
 
@@ -47,10 +50,13 @@ class ColoredFormatter(logging.Formatter):
47
50
 
48
51
 
49
52
  class Arguments(NamedTuple):
53
+ command: str
50
54
  upstream: URL
51
55
  source: pathlib.Path
52
56
  exclude: Iterable[str]
53
57
  verbose: int
58
+ semantic: bool = False
59
+ diff: bool = True
54
60
 
55
61
  @classmethod
56
62
  @lru_cache(maxsize=1)
@@ -85,35 +91,89 @@ def _resolve_source(source: str | pathlib.Path) -> pathlib.Path:
85
91
 
86
92
 
87
93
  def _get_cli_parser() -> ArgumentParser:
88
- # https://docs.python.org/3/library/argparse.html#nargs
89
- parser = ArgumentParser()
90
- parser.add_argument(
94
+ parser = ArgumentParser(
95
+ prog="ruff-sync",
96
+ description=(
97
+ "Synchronize Ruff linter configuration across Python projects.\n\n"
98
+ "Downloads a pyproject.toml from an upstream URL, extracts the\n"
99
+ "[tool.ruff] section, and merges it into the local pyproject.toml\n"
100
+ "while preserving formatting, comments, and whitespace.\n\n"
101
+ "Defaults to the 'pull' subcommand when none is specified."
102
+ ),
103
+ epilog=(
104
+ "Examples:\n"
105
+ " ruff-sync pull https://github.com/org/repo/blob/main/pyproject.toml\n"
106
+ " ruff-sync check https://github.com/org/repo/blob/main/pyproject.toml\n"
107
+ " ruff-sync check --semantic # ignore formatting-only differences\n\n"
108
+ "The upstream URL can also be set in [tool.ruff-sync] in pyproject.toml\n"
109
+ "so you can simply run: ruff-sync pull"
110
+ ),
111
+ formatter_class=RawDescriptionHelpFormatter,
112
+ )
113
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
114
+ subparsers = parser.add_subparsers(
115
+ dest="command", help="Subcommand to run (default: pull)"
116
+ )
117
+
118
+ # Common arguments
119
+ common_parser = ArgumentParser(add_help=False)
120
+ common_parser.add_argument(
91
121
  "upstream",
92
122
  type=URL,
93
123
  nargs="?",
94
124
  help="The URL to download the pyproject.toml file from."
95
125
  " Optional if defined in [tool.ruff-sync].",
96
126
  )
97
- parser.add_argument(
127
+ common_parser.add_argument(
98
128
  "--source",
99
129
  type=pathlib.Path,
100
130
  default=".",
101
131
  help="The directory to sync the pyproject.toml file to. Default: .",
102
132
  required=False,
103
133
  )
104
- parser.add_argument(
134
+ common_parser.add_argument(
105
135
  "--exclude",
106
136
  nargs="+",
107
137
  help=f"Exclude certain ruff configs. Default: {' '.join(_DEFAULT_EXCLUDE)}",
108
138
  default=None,
109
139
  )
110
- parser.add_argument(
140
+ common_parser.add_argument(
111
141
  "-v",
112
142
  "--verbose",
113
143
  action="count",
114
144
  default=0,
115
145
  help="Increase verbosity. -v for INFO, -vv for DEBUG.",
116
146
  )
147
+
148
+ # Pull subcommand (the default action)
149
+ subparsers.add_parser(
150
+ "pull",
151
+ parents=[common_parser],
152
+ help="Pull and apply upstream ruff configuration",
153
+ )
154
+
155
+ # Check subcommand
156
+ check_parser = subparsers.add_parser(
157
+ "check", parents=[common_parser], help="Check if ruff configuration is in sync"
158
+ )
159
+ check_parser.add_argument(
160
+ "--semantic",
161
+ action="store_true",
162
+ help="Ignore non-functional differences like whitespace and comments.",
163
+ )
164
+ check_parser.add_argument(
165
+ "--diff",
166
+ action="store_true",
167
+ default=True,
168
+ help="Show a diff of what would change. Default: True.",
169
+ )
170
+ check_parser.add_argument(
171
+ "--no-diff",
172
+ action="store_false",
173
+ dest="diff",
174
+ help="Do not show a diff.",
175
+ )
176
+
117
177
  return parser
118
178
 
119
179
 
@@ -221,6 +281,48 @@ def toml_ruff_parse(toml_s: str, exclude: Iterable[str]) -> TOMLDocument:
221
281
  return ruff_toml
222
282
 
223
283
 
284
+ def _recursive_update(source_table: Any, upstream: Any) -> None:
285
+ """Recursively update a TOML table, preserving formatting of existing keys."""
286
+ if hasattr(upstream, "items") or isinstance(upstream, Mapping):
287
+ items = upstream.items()
288
+ else:
289
+ return
290
+
291
+ for key, value in items:
292
+ if key in source_table:
293
+ if hasattr(source_table[key], "items") and (
294
+ hasattr(value, "items") or isinstance(value, Mapping)
295
+ ):
296
+ # Structural fix: if the target is a proxy (dotted key),
297
+ # and we are adding NEW keys to it, we must convert it to a real
298
+ # table to ensure children get correct headers.
299
+ source_sub_keys = set(source_table[key].keys())
300
+ upstream_sub_keys = set(value.keys())
301
+ if not upstream_sub_keys.issubset(source_sub_keys):
302
+ current_val = source_table[key].unwrap()
303
+ # DELETE PROXY FIRST to avoid structural doubling
304
+ del source_table[key]
305
+ # ADD AS REAL TABLE
306
+ source_table.add(key, current_val)
307
+
308
+ _recursive_update(source_table[key], value)
309
+ else:
310
+ # Overwrite existing leaf value only if it's semantically different.
311
+ # Compare unwrapped values, but assign the raw tomlkit Item to
312
+ # preserve inline comments attached to the upstream value.
313
+ current_val = (
314
+ source_table[key].unwrap()
315
+ if hasattr(source_table[key], "unwrap")
316
+ else source_table[key]
317
+ )
318
+ new_val_unwrapped = value.unwrap() if hasattr(value, "unwrap") else value
319
+ if current_val != new_val_unwrapped:
320
+ source_table[key] = value
321
+ else:
322
+ # New key: assign the raw tomlkit Item to preserve comments
323
+ source_table[key] = value
324
+
325
+
224
326
  def merge_ruff_toml(
225
327
  source: TOMLDocument, upstream_ruff_doc: TOMLDocument | Table | None
226
328
  ) -> TOMLDocument:
@@ -231,54 +333,100 @@ def merge_ruff_toml(
231
333
 
232
334
  source_tool_ruff = get_ruff_tool_table(source)
233
335
 
234
- def _recursive_update(source_table: Any, upstream: Any) -> None:
235
- """Recursively update a TOML table to preserve formatting of existing keys."""
236
- if hasattr(upstream, "items") or isinstance(upstream, Mapping):
237
- items = upstream.items()
238
- else:
239
- return
240
-
241
- for key, value in items:
242
- if key in source_table:
243
- if hasattr(source_table[key], "items") and (
244
- hasattr(value, "items") or isinstance(value, Mapping)
245
- ):
246
- # Structural fix: if the target is a proxy (dotted key),
247
- # and we are adding NEW keys to it, we must convert it to a real
248
- # table to ensure children get correct headers.
249
- source_sub_keys = set(source_table[key].keys())
250
- upstream_sub_keys = set(value.keys())
251
- if not upstream_sub_keys.issubset(source_sub_keys):
252
- current_val = source_table[key].unwrap()
253
- # DELETE PROXY FIRST to avoid structural doubling
254
- del source_table[key]
255
- # ADD AS REAL TABLE
256
- source_table.add(key, current_val)
257
-
258
- _recursive_update(source_table[key], value)
259
- else:
260
- # Overwrite existing value
261
- source_table[key] = (
262
- value.unwrap() if hasattr(value, "unwrap") else value
263
- )
264
- else:
265
- # Add new key/value
266
- source_table[key] = value.unwrap() if hasattr(value, "unwrap") else value
267
-
268
336
  _recursive_update(source_tool_ruff, upstream_ruff_doc)
269
337
 
270
- # Ensure a newline at the end of the section for better readability.
271
- # We only add it if it's missing to avoid triple newlines between sections.
272
- if not source_tool_ruff.as_string().endswith("\n\n"):
338
+ # Add a blank separator line after the ruff section but only when another
339
+ # top-level section follows it. Adding \n\n at end-of-file is unnecessary.
340
+ doc_str = source.as_string()
341
+ ruff_start = doc_str.find("[tool.ruff]")
342
+ # Look for any non-ruff top-level section header after [tool.ruff]
343
+ ruff_is_last = ruff_start == -1 or not re.search(
344
+ r"^\[(?!tool\.ruff)", doc_str[ruff_start:], re.MULTILINE
345
+ )
346
+ if not ruff_is_last and not source_tool_ruff.as_string().endswith("\n\n"):
273
347
  source_tool_ruff.add(tomlkit.nl())
274
348
 
275
349
  return source
276
350
 
277
351
 
278
- async def sync(
352
+ async def check(
279
353
  args: Arguments,
280
- ) -> None:
281
- """Sync the upstream pyproject.toml file to the source directory."""
354
+ ) -> int:
355
+ """Check if the local pyproject.toml is in sync with the upstream."""
356
+ print("🔍 Checking Ruff sync status...")
357
+ if args.source.is_file():
358
+ _source_toml_path = args.source
359
+ else:
360
+ _source_toml_path = args.source / "pyproject.toml"
361
+
362
+ _source_toml_path = _source_toml_path.resolve(strict=True)
363
+ source_toml_file = TOMLFile(_source_toml_path)
364
+ source_doc = source_toml_file.read()
365
+
366
+ async with httpx.AsyncClient() as client:
367
+ file_buffer = await download(args.upstream, client)
368
+ LOGGER.info(f"Downloaded upstream file from {args.upstream}")
369
+
370
+ upstream_ruff_toml = get_ruff_tool_table(
371
+ file_buffer.read(), create_if_missing=False, exclude=args.exclude
372
+ )
373
+
374
+ # Create a copy for comparison
375
+ source_doc_copy = tomlkit.parse(source_doc.as_string())
376
+ merged_doc = merge_ruff_toml(source_doc_copy, upstream_ruff_toml)
377
+
378
+ if args.semantic:
379
+ source_ruff = source_doc.get("tool", {}).get("ruff")
380
+ merged_ruff = merged_doc.get("tool", {}).get("ruff")
381
+
382
+ # Compare unwrapped versions
383
+ source_val = source_ruff.unwrap() if source_ruff is not None else None
384
+ merged_val = merged_ruff.unwrap() if merged_ruff is not None else None
385
+
386
+ if source_val == merged_val:
387
+ print("✅ Ruff configuration is semantically in sync.")
388
+ return 0
389
+ elif source_doc.as_string() == merged_doc.as_string():
390
+ print("✅ Ruff configuration is in sync.")
391
+ return 0
392
+
393
+ try:
394
+ rel_path = _source_toml_path.relative_to(pathlib.Path.cwd())
395
+ except ValueError:
396
+ rel_path = _source_toml_path
397
+ print(f"❌ Ruff configuration at {rel_path} is out of sync!")
398
+ if args.diff:
399
+ if args.semantic:
400
+ # Semantic diff of the managed section
401
+ from_lines = json.dumps(source_val, indent=2, sort_keys=True).splitlines(
402
+ keepends=True
403
+ )
404
+ to_lines = json.dumps(merged_val, indent=2, sort_keys=True).splitlines(
405
+ keepends=True
406
+ )
407
+ from_file = "local (semantic)"
408
+ to_file = "upstream (semantic)"
409
+ else:
410
+ # Full text diff of the file
411
+ from_lines = source_doc.as_string().splitlines(keepends=True)
412
+ to_lines = merged_doc.as_string().splitlines(keepends=True)
413
+ from_file = f"local/{_source_toml_path.name}"
414
+ to_file = f"upstream/{_source_toml_path.name}"
415
+
416
+ diff = difflib.unified_diff(
417
+ from_lines,
418
+ to_lines,
419
+ fromfile=from_file,
420
+ tofile=to_file,
421
+ )
422
+ sys.stdout.writelines(diff)
423
+ return 1
424
+
425
+
426
+ async def pull(
427
+ args: Arguments,
428
+ ) -> int:
429
+ """Pull the upstream ruff config and apply it to the source pyproject.toml."""
282
430
  print("🔄 Syncing Ruff...")
283
431
  if args.source.is_file():
284
432
  _source_toml_path = args.source
@@ -300,12 +448,25 @@ async def sync(
300
448
  )
301
449
  source_toml_file.write(merged_toml)
302
450
  print(f"✅ Updated {_source_toml_path.resolve().relative_to(pathlib.Path.cwd())}")
451
+ return 0
303
452
 
304
453
 
305
454
  PARSER: Final[ArgumentParser] = _get_cli_parser()
306
455
 
307
456
 
308
- def main() -> None:
457
+ def main() -> int:
458
+ # Handle backward compatibility: default to 'pull' if no command provided
459
+ if len(sys.argv) > 1 and sys.argv[1] not in (
460
+ "pull",
461
+ "check",
462
+ "-h",
463
+ "--help",
464
+ "--version",
465
+ ):
466
+ sys.argv.insert(1, "pull")
467
+ elif len(sys.argv) == 1:
468
+ sys.argv.append("pull")
469
+
309
470
  args = PARSER.parse_args()
310
471
  config = get_config(args.source)
311
472
 
@@ -355,17 +516,21 @@ def main() -> None:
355
516
  # Convert non-raw github upstream url to the raw equivalent
356
517
  upstream = github_url_to_raw_url(upstream)
357
518
 
358
- asyncio.run(
359
- sync(
360
- Arguments(
361
- upstream=upstream,
362
- source=args.source,
363
- exclude=exclude,
364
- verbose=args.verbose,
365
- )
366
- )
519
+ # Create Arguments object
520
+ exec_args = Arguments(
521
+ command=args.command,
522
+ upstream=upstream,
523
+ source=args.source,
524
+ exclude=exclude,
525
+ verbose=args.verbose,
526
+ semantic=getattr(args, "semantic", False),
527
+ diff=getattr(args, "diff", True),
367
528
  )
368
529
 
530
+ if exec_args.command == "check":
531
+ return asyncio.run(check(exec_args))
532
+ return asyncio.run(pull(exec_args))
533
+
369
534
 
370
535
  if __name__ == "__main__":
371
- main()
536
+ sys.exit(main())