ruff-sync 0.0.1.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.
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ruff-sync
|
|
3
|
+
Version: 0.0.1.dev2
|
|
4
|
+
Summary: Syncronize ruff linter config settings accross projects
|
|
5
|
+
Author-email: Gabriel Gore <gabriel59kg@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE.md
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
10
|
+
Requires-Dist: tomlkit<2.0.0,>=0.12.3
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
[](https://codecov.io/gh/Kilo59/ruff-sync)
|
|
14
|
+
[](https://results.pre-commit.ci/latest/github/Kilo59/ruff-sync/main)
|
|
15
|
+
[](https://wily.readthedocs.io/)
|
|
16
|
+
[](https://github.com/astral-sh/ruff)
|
|
17
|
+
|
|
18
|
+
# ruff-sync
|
|
19
|
+
|
|
20
|
+
**Keep your Ruff config consistent across every repo — automatically.**
|
|
21
|
+
|
|
22
|
+
`ruff-sync` is a CLI tool that pulls a canonical [Ruff](https://docs.astral.sh/ruff/) configuration from an upstream `pyproject.toml` (hosted anywhere — GitHub, GitLab, a raw URL) and merges it into your local project, preserving your comments, formatting, and project-specific overrides.
|
|
23
|
+
|
|
24
|
+
## The Problem
|
|
25
|
+
|
|
26
|
+
If you maintain more than one Python project, you've probably copy-pasted your `[tool.ruff]` config between repos more than once. When you decide to enable a new rule or bump your target Python version, you get to do it again — in _every_ repo. Configs drift, standards diverge, and your "shared" style guide becomes a polite suggestion.
|
|
27
|
+
|
|
28
|
+
### How Other Ecosystems Solve This
|
|
29
|
+
|
|
30
|
+
| Ecosystem | Mechanism | Limitation for Ruff users |
|
|
31
|
+
|-----------|-----------|---------------------------|
|
|
32
|
+
| **ESLint** | [Shareable configs](https://eslint.org/docs/latest/extend/shareable-configs) — publish an npm package, then `extends: ["my-org-config"]` | Requires a package registry (npm). Python doesn't have an equivalent convention. |
|
|
33
|
+
| **Prettier** | [Shared configs](https://prettier.io/docs/sharing-configurations) — same npm-package pattern, referenced via `"prettier": "@my-org/prettier-config"` in `package.json` | Same — tightly coupled to npm. |
|
|
34
|
+
| **Ruff** | [`extend`](https://docs.astral.sh/ruff/configuration/#config-file-discovery) — extend from a _local_ file path (great for monorepos) | Only supports local paths. No native remote URL support ([requested in astral-sh/ruff#12352](https://github.com/astral-sh/ruff/issues/12352)). |
|
|
35
|
+
|
|
36
|
+
Ruff's `extend` is perfect inside a monorepo, but if your projects live in **separate repositories**, there's no built-in way to inherit config from a central source.
|
|
37
|
+
|
|
38
|
+
**That's what `ruff-sync` does.**
|
|
39
|
+
|
|
40
|
+
## How It Works
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
┌─────────────────────────────┐
|
|
44
|
+
│ Upstream repo │
|
|
45
|
+
│ (your "source of truth") │
|
|
46
|
+
│ │
|
|
47
|
+
│ pyproject.toml │
|
|
48
|
+
│ [tool.ruff] │
|
|
49
|
+
│ target-version = "py310" │
|
|
50
|
+
│ lint.select = [...] │
|
|
51
|
+
└──────────┬──────────────────┘
|
|
52
|
+
│ ruff-sync downloads
|
|
53
|
+
│ & extracts [tool.ruff]
|
|
54
|
+
▼
|
|
55
|
+
┌─────────────────────────────┐
|
|
56
|
+
│ Your local project │
|
|
57
|
+
│ │
|
|
58
|
+
│ pyproject.toml │
|
|
59
|
+
│ [tool.ruff] ◄── merged │
|
|
60
|
+
│ # your comments kept ✓ │
|
|
61
|
+
│ # formatting kept ✓ │
|
|
62
|
+
│ # per-file-ignores kept ✓│
|
|
63
|
+
└─────────────────────────────┘
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
1. You point `ruff-sync` at the URL of your canonical `pyproject.toml`.
|
|
67
|
+
2. It downloads the file, extracts the `[tool.ruff]` section.
|
|
68
|
+
3. It **merges** the upstream config into your local `pyproject.toml` — updating values that changed, adding new rules, but preserving your local comments, whitespace, and any sections you've chosen to exclude (like `per-file-ignores`).
|
|
69
|
+
|
|
70
|
+
No package registry. No publishing step. Just a URL.
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
### Install
|
|
75
|
+
|
|
76
|
+
<!-- ### PyPi Install
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
pip install ruff-sync
|
|
80
|
+
``` -->
|
|
81
|
+
|
|
82
|
+
From Git (with [`uv`](https://docs.astral.sh/uv/guides/tools/)):
|
|
83
|
+
|
|
84
|
+
```console
|
|
85
|
+
uv tool install git+https://github.com/Kilo59/ruff-sync
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
From Git (with [`pipx`](https://pipx.pypa.io/stable/) — recommended):
|
|
89
|
+
|
|
90
|
+
```console
|
|
91
|
+
pipx install git+https://github.com/Kilo59/ruff-sync
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Or with pip:
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
pip install git+https://github.com/Kilo59/ruff-sync
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Usage
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
# Sync from a GitHub URL (blob URLs are auto-converted to raw)
|
|
104
|
+
ruff-sync https://github.com/my-org/standards/blob/main/pyproject.toml
|
|
105
|
+
|
|
106
|
+
# Sync into a specific project directory
|
|
107
|
+
ruff-sync https://github.com/my-org/standards/blob/main/pyproject.toml --source ./my-project
|
|
108
|
+
|
|
109
|
+
# Exclude specific sections from being overwritten using dotted paths
|
|
110
|
+
ruff-sync https://github.com/my-org/standards/blob/main/pyproject.toml --exclude lint.per-file-ignores lint.ignore
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### CLI Reference
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
usage: ruff-sync [-h] [--source SOURCE] [--exclude EXCLUDE [EXCLUDE ...]] upstream
|
|
117
|
+
|
|
118
|
+
positional arguments:
|
|
119
|
+
upstream The URL to download the pyproject.toml file from.
|
|
120
|
+
|
|
121
|
+
optional arguments:
|
|
122
|
+
-h, --help show this help message and exit
|
|
123
|
+
--source SOURCE The directory to sync the pyproject.toml file to. Default: .
|
|
124
|
+
--exclude EXCLUDE [EXCLUDE ...]
|
|
125
|
+
Exclude certain ruff configs. Default: lint.per-file-ignores
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Key Features
|
|
129
|
+
|
|
130
|
+
- **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.
|
|
131
|
+
- **GitHub URL support** — Paste a GitHub blob URL and it will automatically convert it to the raw content URL.
|
|
132
|
+
- **Selective exclusions** — Keep project-specific overrides (like `target-version`) from being clobbered by the upstream config.
|
|
133
|
+
- **Works with any host** — GitHub, GitLab, Bitbucket, or any raw URL that serves a `pyproject.toml`.
|
|
134
|
+
|
|
135
|
+
## Configuration
|
|
136
|
+
|
|
137
|
+
You can configure `ruff-sync` itself in your `pyproject.toml`:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
[tool.ruff-sync]
|
|
141
|
+
# Use simple names for top-level keys, and dotted paths for nested keys
|
|
142
|
+
exclude = [
|
|
143
|
+
"target-version", # A top-level key under [tool.ruff]
|
|
144
|
+
"lint.per-file-ignores", # A nested key under [tool.ruff.lint]
|
|
145
|
+
"lint.ignore"
|
|
146
|
+
]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
This sets the default exclusions so you don't need to pass `--exclude` on the command line every time.
|
|
150
|
+
*Note: Any explicitly provided CLI arguments will override the list in `pyproject.toml`.*
|
|
151
|
+
|
|
152
|
+
## Example Workflow
|
|
153
|
+
|
|
154
|
+
A typical setup for an organization:
|
|
155
|
+
|
|
156
|
+
1. **Create a "standards" repo** with your canonical `pyproject.toml` containing your shared `[tool.ruff]` config.
|
|
157
|
+
2. **In each project**, run `ruff-sync` pointing at that repo — either manually, in a Makefile, or as a CI step.
|
|
158
|
+
3. **When you update the standard**, re-run `ruff-sync` in each project to pull the changes. Your local comments and `per-file-ignores` stay intact.
|
|
159
|
+
|
|
160
|
+
```console
|
|
161
|
+
# In each project repo:
|
|
162
|
+
ruff-sync https://github.com/my-org/python-standards/blob/main/pyproject.toml
|
|
163
|
+
git diff pyproject.toml # review the changes
|
|
164
|
+
git commit -am "sync ruff config from upstream"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Contributing
|
|
168
|
+
|
|
169
|
+
This project uses:
|
|
170
|
+
|
|
171
|
+
- [uv](https://docs.astral.sh/uv/) for dependency management
|
|
172
|
+
- [Ruff](https://docs.astral.sh/ruff/) for linting and formatting
|
|
173
|
+
- [mypy](https://mypy-lang.org/) for type checking (strict mode)
|
|
174
|
+
- [pytest](https://docs.pytest.org/) for testing
|
|
175
|
+
|
|
176
|
+
```console
|
|
177
|
+
# Setup
|
|
178
|
+
uv sync --group dev
|
|
179
|
+
|
|
180
|
+
# Run checks
|
|
181
|
+
uv run ruff check . --fix # lint
|
|
182
|
+
uv run ruff format . # format
|
|
183
|
+
uv run mypy . # type check
|
|
184
|
+
uv run pytest -vv # test
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Dogfooding
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
We've provided a script to make this easy:
|
|
192
|
+
|
|
193
|
+
```console
|
|
194
|
+
./scripts/dogfood.sh
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This will download Pydantic's Ruff configuration and merge it into the local `pyproject.toml`. You can then use `git diff` to see how it merged the keys while preserving the existing structure and comments.
|
|
198
|
+
|
|
199
|
+
**To revert the changes after testing:**
|
|
200
|
+
|
|
201
|
+
```console
|
|
202
|
+
git checkout pyproject.toml
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
[MIT](LICENSE.md)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ruff_sync.py,sha256=kyz1Vmwgyv3DEHvLlBJyLQjgRLePlu33ZUhsvxXTPs8,9699
|
|
2
|
+
ruff_sync-0.0.1.dev2.dist-info/METADATA,sha256=ckyU-Z7SwdroP9meJf3kv9hOLhFOs2PYcZT-mk-sTII,8467
|
|
3
|
+
ruff_sync-0.0.1.dev2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
4
|
+
ruff_sync-0.0.1.dev2.dist-info/entry_points.txt,sha256=z8TlIElMWLlqY_YWglHeMBksDohf2voVih6YnCBCWzk,45
|
|
5
|
+
ruff_sync-0.0.1.dev2.dist-info/licenses/LICENSE.md,sha256=RcCP8t8xyCx2cvMZSLjBrPqJfctciWC6q85ceQAcEfA,1080
|
|
6
|
+
ruff_sync-0.0.1.dev2.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
The MIT License (MIT)
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2023 Gabriel Gore
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
ruff_sync.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import pathlib
|
|
6
|
+
import warnings
|
|
7
|
+
from argparse import ArgumentParser
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from io import StringIO
|
|
11
|
+
from typing import Any, Final, Literal, NamedTuple, overload
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import tomlkit
|
|
15
|
+
from httpx import URL
|
|
16
|
+
from tomlkit import TOMLDocument, table
|
|
17
|
+
from tomlkit.items import Table
|
|
18
|
+
from tomlkit.toml_file import TOMLFile
|
|
19
|
+
|
|
20
|
+
__version__ = "0.0.1.dev0"
|
|
21
|
+
|
|
22
|
+
_DEFAULT_EXCLUDE: Final[set[str]] = {"lint.per-file-ignores"}
|
|
23
|
+
|
|
24
|
+
LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Arguments(NamedTuple):
|
|
28
|
+
upstream: URL
|
|
29
|
+
source: pathlib.Path
|
|
30
|
+
exclude: Iterable[str]
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
@lru_cache(maxsize=1)
|
|
34
|
+
def fields(cls) -> set[str]:
|
|
35
|
+
return set(cls._fields)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=1)
|
|
39
|
+
def get_config(
|
|
40
|
+
source: pathlib.Path,
|
|
41
|
+
) -> Mapping[Literal["upstream", "source", "exclude"], str | list[str]]:
|
|
42
|
+
local_toml = source / "pyproject.toml"
|
|
43
|
+
# TODO: use pydantic to validate the toml file
|
|
44
|
+
cfg_result = {}
|
|
45
|
+
if local_toml.exists():
|
|
46
|
+
toml = tomlkit.parse(local_toml.read_text())
|
|
47
|
+
config = toml.get("tool", {}).get("ruff-sync")
|
|
48
|
+
if config:
|
|
49
|
+
for arg, value in config.items():
|
|
50
|
+
if arg in Arguments.fields():
|
|
51
|
+
cfg_result[arg] = value
|
|
52
|
+
else:
|
|
53
|
+
warnings.warn(f"Unknown ruff-sync configuration: {arg}", stacklevel=2)
|
|
54
|
+
return cfg_result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@lru_cache(maxsize=1)
|
|
58
|
+
def _resolve_source(source: str | pathlib.Path) -> pathlib.Path:
|
|
59
|
+
if isinstance(source, str):
|
|
60
|
+
source = pathlib.Path(source)
|
|
61
|
+
return source.resolve(strict=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_cli_parser() -> ArgumentParser:
|
|
65
|
+
# TODO: determine if args was provided by user or not
|
|
66
|
+
# https://docs.python.org/3/library/argparse.html#nargs
|
|
67
|
+
parser = ArgumentParser()
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"upstream",
|
|
70
|
+
type=URL,
|
|
71
|
+
help="The URL to download the pyproject.toml file from.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--source",
|
|
75
|
+
type=pathlib.Path,
|
|
76
|
+
default=".",
|
|
77
|
+
help="The directory to sync the pyproject.toml file to. Default: .",
|
|
78
|
+
required=False,
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--exclude",
|
|
82
|
+
nargs="+",
|
|
83
|
+
help=f"Exclude certain ruff configs. Default: {' '.join(_DEFAULT_EXCLUDE)}",
|
|
84
|
+
default=None,
|
|
85
|
+
)
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def github_url_to_raw_url(url: URL) -> URL:
|
|
90
|
+
"""Convert a GitHub URL to its corresponding raw content URL
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
url (URL): The GitHub URL to be converted.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
URL: The corresponding raw content URL.
|
|
97
|
+
"""
|
|
98
|
+
url_str = str(url)
|
|
99
|
+
if "github.com" in url_str and "/blob/" in url_str:
|
|
100
|
+
raw_url_str = url_str.replace("github.com", "raw.githubusercontent.com").replace(
|
|
101
|
+
"/blob/", "/"
|
|
102
|
+
)
|
|
103
|
+
return httpx.URL(raw_url_str)
|
|
104
|
+
else:
|
|
105
|
+
return url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def download(url: URL, client: httpx.AsyncClient) -> StringIO:
|
|
109
|
+
"""Download a file from a URL and return a StringIO object."""
|
|
110
|
+
response = await client.get(url)
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
return StringIO(response.text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@overload
|
|
116
|
+
def get_ruff_tool_table(
|
|
117
|
+
toml: str | TOMLDocument,
|
|
118
|
+
create_if_missing: Literal[True] = ...,
|
|
119
|
+
exclude: Iterable[str] = ...,
|
|
120
|
+
) -> Table: ...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@overload
|
|
124
|
+
def get_ruff_tool_table(
|
|
125
|
+
toml: str | TOMLDocument,
|
|
126
|
+
create_if_missing: Literal[False] = ...,
|
|
127
|
+
exclude: Iterable[str] = ...,
|
|
128
|
+
) -> Table | None: ...
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_ruff_tool_table(
|
|
132
|
+
toml: str | TOMLDocument,
|
|
133
|
+
create_if_missing: bool = True,
|
|
134
|
+
exclude: Iterable[str] = (),
|
|
135
|
+
) -> Table | None:
|
|
136
|
+
"""Get the tool.ruff section from a TOML string.
|
|
137
|
+
If it does not exist, create it.
|
|
138
|
+
"""
|
|
139
|
+
if isinstance(toml, str):
|
|
140
|
+
doc: TOMLDocument = tomlkit.parse(toml)
|
|
141
|
+
else:
|
|
142
|
+
doc = toml
|
|
143
|
+
try:
|
|
144
|
+
tool: Table = doc["tool"] # type: ignore[assignment]
|
|
145
|
+
ruff = tool["ruff"]
|
|
146
|
+
LOGGER.debug("Found `tool.ruff` section.")
|
|
147
|
+
except KeyError:
|
|
148
|
+
if not create_if_missing:
|
|
149
|
+
return None
|
|
150
|
+
LOGGER.info("No `tool.ruff` section found, creating it.")
|
|
151
|
+
tool = table(True)
|
|
152
|
+
ruff = table()
|
|
153
|
+
tool.append("ruff", ruff)
|
|
154
|
+
doc.append("tool", tool)
|
|
155
|
+
if not isinstance(ruff, Table):
|
|
156
|
+
raise TypeError(f"Expected table, got {type(ruff)}")
|
|
157
|
+
_apply_exclusions(ruff, exclude)
|
|
158
|
+
return ruff
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _apply_exclusions(tbl: Table, exclude: Iterable[str]) -> None:
|
|
162
|
+
"""Remove excluded keys from a ruff table, supporting dotted paths.
|
|
163
|
+
|
|
164
|
+
Keys can be simple (e.g. ``"target-version"``) to match top-level ruff
|
|
165
|
+
keys, or dotted (e.g. ``"lint.per-file-ignores"``) to reach into nested
|
|
166
|
+
sub-tables.
|
|
167
|
+
"""
|
|
168
|
+
for key_path in exclude:
|
|
169
|
+
parts = key_path.split(".")
|
|
170
|
+
target: Any = tbl
|
|
171
|
+
for part in parts[:-1]:
|
|
172
|
+
target = target.get(part) if hasattr(target, "get") else None
|
|
173
|
+
if target is None:
|
|
174
|
+
break
|
|
175
|
+
if target is not None and hasattr(target, "pop"):
|
|
176
|
+
leaf = parts[-1]
|
|
177
|
+
if leaf in target:
|
|
178
|
+
LOGGER.info(f"Excluding `{key_path}` from upstream ruff config.")
|
|
179
|
+
target.pop(leaf)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def toml_ruff_parse(toml_s: str, exclude: Iterable[str]) -> TOMLDocument:
|
|
183
|
+
"""Parse a TOML string for the tool.ruff section excluding certain ruff configs."""
|
|
184
|
+
ruff_toml: TOMLDocument = tomlkit.parse(toml_s)["tool"]["ruff"] # type: ignore[index,assignment]
|
|
185
|
+
for section in exclude:
|
|
186
|
+
LOGGER.info(f"Exluding section `lint.{section}` from ruff config.")
|
|
187
|
+
ruff_toml["lint"].pop(section, None) # type: ignore[union-attr]
|
|
188
|
+
return ruff_toml
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def merge_ruff_toml(
|
|
192
|
+
source: TOMLDocument, upstream_ruff_doc: TOMLDocument | Table | None
|
|
193
|
+
) -> TOMLDocument:
|
|
194
|
+
"""Merge the source and upstream tool ruff config with better whitespace preservation.""" # noqa: E501
|
|
195
|
+
if not upstream_ruff_doc:
|
|
196
|
+
LOGGER.warning("No upstream ruff config section found.")
|
|
197
|
+
return source
|
|
198
|
+
|
|
199
|
+
source_tool_ruff = get_ruff_tool_table(source)
|
|
200
|
+
|
|
201
|
+
def _recursive_update(source_table: Any, upstream: Any) -> None:
|
|
202
|
+
"""Recursively update a TOML table to preserve formatting of existing keys."""
|
|
203
|
+
if hasattr(upstream, "items") or isinstance(upstream, Mapping):
|
|
204
|
+
items = upstream.items()
|
|
205
|
+
else:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
for key, value in items:
|
|
209
|
+
if key in source_table:
|
|
210
|
+
if hasattr(source_table[key], "items") and (
|
|
211
|
+
hasattr(value, "items") or isinstance(value, Mapping)
|
|
212
|
+
):
|
|
213
|
+
# Structural fix: if the target is a proxy (dotted key),
|
|
214
|
+
# and we are adding NEW keys to it, we must convert it to a real
|
|
215
|
+
# table to ensure children get correct headers.
|
|
216
|
+
source_sub_keys = set(source_table[key].keys())
|
|
217
|
+
upstream_sub_keys = set(value.keys())
|
|
218
|
+
if not upstream_sub_keys.issubset(source_sub_keys):
|
|
219
|
+
current_val = source_table[key].unwrap()
|
|
220
|
+
# DELETE PROXY FIRST to avoid structural doubling
|
|
221
|
+
del source_table[key]
|
|
222
|
+
# ADD AS REAL TABLE
|
|
223
|
+
source_table.add(key, current_val)
|
|
224
|
+
|
|
225
|
+
_recursive_update(source_table[key], value)
|
|
226
|
+
else:
|
|
227
|
+
# Overwrite existing value
|
|
228
|
+
source_table[key] = (
|
|
229
|
+
value.unwrap() if hasattr(value, "unwrap") else value
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
# Add new key/value
|
|
233
|
+
source_table[key] = value.unwrap() if hasattr(value, "unwrap") else value
|
|
234
|
+
|
|
235
|
+
_recursive_update(source_tool_ruff, upstream_ruff_doc)
|
|
236
|
+
|
|
237
|
+
return source
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def sync(
|
|
241
|
+
args: Arguments,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Sync the upstream pyproject.toml file to the source directory."""
|
|
244
|
+
print("Syncing Ruff...")
|
|
245
|
+
if args.source.is_file():
|
|
246
|
+
_source_toml_path = args.source
|
|
247
|
+
else:
|
|
248
|
+
_source_toml_path = args.source / "pyproject.toml"
|
|
249
|
+
source_toml_file = TOMLFile(_source_toml_path.resolve(strict=True))
|
|
250
|
+
|
|
251
|
+
# NOTE: there's no particular reason to use async here.
|
|
252
|
+
async with httpx.AsyncClient() as client:
|
|
253
|
+
file_buffer = await download(args.upstream, client)
|
|
254
|
+
LOGGER.info(f"Downloaded upstream file from {args.upstream}")
|
|
255
|
+
|
|
256
|
+
upstream_ruff_toml = get_ruff_tool_table(
|
|
257
|
+
file_buffer.read(), create_if_missing=False, exclude=args.exclude
|
|
258
|
+
)
|
|
259
|
+
merged_toml = merge_ruff_toml(
|
|
260
|
+
source_toml_file.read(),
|
|
261
|
+
upstream_ruff_toml,
|
|
262
|
+
)
|
|
263
|
+
source_toml_file.write(merged_toml)
|
|
264
|
+
print(f"Updated {_source_toml_path.resolve().relative_to(pathlib.Path.cwd())}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
PARSER: Final[ArgumentParser] = _get_cli_parser()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
args = PARSER.parse_args()
|
|
272
|
+
config = get_config(args.source)
|
|
273
|
+
|
|
274
|
+
# Merge exclude: use CLI value if explicitly provided, else file config,
|
|
275
|
+
# else the built-in default.
|
|
276
|
+
exclude: Iterable[str]
|
|
277
|
+
if args.exclude is not None:
|
|
278
|
+
# User passed --exclude on the CLI — that takes precedence
|
|
279
|
+
exclude = args.exclude
|
|
280
|
+
elif "exclude" in config:
|
|
281
|
+
exclude = config["exclude"]
|
|
282
|
+
LOGGER.info(f"Using exclude from [tool.ruff-sync]: {list(exclude)}")
|
|
283
|
+
else:
|
|
284
|
+
exclude = _DEFAULT_EXCLUDE
|
|
285
|
+
|
|
286
|
+
# Convert non-raw github upstream url to the raw equivalent
|
|
287
|
+
args.upstream = github_url_to_raw_url(args.upstream)
|
|
288
|
+
|
|
289
|
+
asyncio.run(
|
|
290
|
+
sync(
|
|
291
|
+
Arguments(
|
|
292
|
+
upstream=args.upstream,
|
|
293
|
+
source=args.source,
|
|
294
|
+
exclude=exclude,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
main()
|