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.
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/workflows/ci.yaml +1 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/PKG-INFO +73 -5
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/README.md +72 -4
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/pyproject.toml +2 -1
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/ruff_sync.py +224 -59
- ruff_sync-0.0.2.dev0/scripts/dogfood_check.sh +48 -0
- ruff_sync-0.0.2.dev0/tasks.py +199 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_final.toml +4 -2
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_final.toml +4 -2
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_basic.py +15 -10
- ruff_sync-0.0.2.dev0/tests/test_check.py +223 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_e2e.py +64 -4
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_whitespace.py +27 -5
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/uv.lock +3 -1
- ruff_sync-0.0.1.dev4/tasks.py +0 -90
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.agents/TESTING.md +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.agents/workflows/add-test-case.md +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/dependabot.yml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.github/workflows/complexity.yaml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.gitignore +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/.pre-commit-config.yaml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/AGENTS.md +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/LICENSE.md +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/codecov.yml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/ruff_sync_banner.png +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/scripts/dogfood.sh +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/__init__.py +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_final.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_initial.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_changes_upstream.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_final.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_initial.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_dotted_keys_upstream.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_initial.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/no_ruff_cfg_upstream.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_initial.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/lifecycle_tomls/standard_upstream.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/ruff.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_corner_cases.py +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_project.py +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/test_toml_operations.py +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/w_ruff_sync_cfg/pyproject.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/wo_ruff_cfg/pyproject.toml +0 -0
- {ruff_sync-0.0.1.dev4 → ruff_sync-0.0.2.dev0}/tests/wo_ruff_sync_cfg/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ruff-sync
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
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
|
|
352
|
+
async def check(
|
|
279
353
|
args: Arguments,
|
|
280
|
-
) ->
|
|
281
|
-
"""
|
|
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() ->
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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())
|