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
+ [![codecov](https://codecov.io/gh/Kilo59/ruff-sync/graph/badge.svg?token=kMZw0XtoFW)](https://codecov.io/gh/Kilo59/ruff-sync)
14
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Kilo59/ruff-sync/main.svg)](https://results.pre-commit.ci/latest/github/Kilo59/ruff-sync/main)
15
+ [![Wily](https://img.shields.io/badge/%F0%9F%A6%8A%20wily-passing-brightgreen.svg)](https://wily.readthedocs.io/)
16
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ruff-sync = ruff_sync:main
@@ -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()