ruff-sync 0.1.0.dev1__tar.gz → 0.1.0.dev2__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 (80) hide show
  1. ruff_sync-0.1.0.dev2/.agents/skills/release-notes-generation/SKILL.md +82 -0
  2. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/AGENTS.md +1 -0
  3. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/PKG-INFO +11 -6
  4. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/README.md +10 -5
  5. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/configuration.md +13 -1
  6. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/index.md +1 -0
  7. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/usage.md +7 -5
  8. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/pyproject.toml +1 -1
  9. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/src/ruff_sync/cli.py +50 -21
  10. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/src/ruff_sync/core.py +52 -43
  11. ruff_sync-0.1.0.dev2/tests/lifecycle_tomls/multi_upstream_final.toml +8 -0
  12. ruff_sync-0.1.0.dev2/tests/lifecycle_tomls/multi_upstream_initial.toml +7 -0
  13. ruff_sync-0.1.0.dev2/tests/lifecycle_tomls/multi_upstream_up1.toml +5 -0
  14. ruff_sync-0.1.0.dev2/tests/lifecycle_tomls/multi_upstream_up2.toml +5 -0
  15. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_basic.py +153 -99
  16. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_check.py +65 -6
  17. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_deprecation.py +1 -1
  18. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_e2e.py +51 -6
  19. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_scaffold.py +6 -6
  20. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/uv.lock +1 -1
  21. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/TESTING.md +0 -0
  22. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/SKILL.md +0 -0
  23. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/examples.md +0 -0
  24. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/templates/api-reference.md +0 -0
  25. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/templates/getting-started.md +0 -0
  26. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/templates/index.md +0 -0
  27. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/skills/mkdocs-generation/templates/mkdocs.yml +0 -0
  28. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.agents/workflows/add-test-case.md +0 -0
  29. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.git-blame-ignore-revs +0 -0
  30. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.github/dependabot.yml +0 -0
  31. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.github/workflows/ci.yaml +0 -0
  32. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.github/workflows/complexity.yaml +0 -0
  33. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.github/workflows/docs.yaml +0 -0
  34. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.gitignore +0 -0
  35. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/.pre-commit-config.yaml +0 -0
  36. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/LICENSE.md +0 -0
  37. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/codecov.yml +0 -0
  38. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/configs/fastapi/ruff.toml +0 -0
  39. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/configs/kitchen-sink/ruff.toml +0 -0
  40. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/assets/ruff_sync_banner.png +0 -0
  41. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/ci-integration.md +0 -0
  42. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/gen_ref_pages.py +0 -0
  43. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/installation.md +0 -0
  44. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/docs/troubleshooting.md +0 -0
  45. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/mkdocs.yml +0 -0
  46. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/scripts/check_dogfood.sh +0 -0
  47. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/scripts/gitclone_dogfood.sh +0 -0
  48. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/scripts/pull_dogfood.sh +0 -0
  49. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/skills-lock.json +0 -0
  50. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/src/ruff_sync/__init__.py +0 -0
  51. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/src/ruff_sync/__main__.py +0 -0
  52. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tasks.py +0 -0
  53. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/__init__.py +0 -0
  54. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/conftest.py +0 -0
  55. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_changes_final.toml +0 -0
  56. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_changes_initial.toml +0 -0
  57. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_changes_upstream.toml +0 -0
  58. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_dotted_keys_final.toml +0 -0
  59. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_dotted_keys_initial.toml +0 -0
  60. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_dotted_keys_upstream.toml +0 -0
  61. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_ruff_cfg_final.toml +0 -0
  62. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_ruff_cfg_initial.toml +0 -0
  63. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/no_ruff_cfg_upstream.toml +0 -0
  64. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/readme_excludes_final.toml +0 -0
  65. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/readme_excludes_initial.toml +0 -0
  66. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/readme_excludes_upstream.toml +0 -0
  67. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/standard_final.toml +0 -0
  68. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/standard_initial.toml +0 -0
  69. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/lifecycle_tomls/standard_upstream.toml +0 -0
  70. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/ruff.toml +0 -0
  71. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_config_validation.py +0 -0
  72. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_corner_cases.py +0 -0
  73. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_git_fetch.py +0 -0
  74. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_project.py +0 -0
  75. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_toml_operations.py +0 -0
  76. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_url_handling.py +0 -0
  77. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/test_whitespace.py +0 -0
  78. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/w_ruff_sync_cfg/pyproject.toml +0 -0
  79. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/wo_ruff_cfg/pyproject.toml +0 -0
  80. {ruff_sync-0.1.0.dev1 → ruff_sync-0.1.0.dev2}/tests/wo_ruff_sync_cfg/pyproject.toml +0 -0
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: release-notes-generation
3
+ description: Draft professional and categorized release notes for ruff-sync using GitHub CLI, git history, and the `invoke release` task.
4
+ ---
5
+
6
+ # Release Notes Generation
7
+
8
+ This skill guides you through drafting high-quality release notes for `ruff-sync`. It leverages the `invoke release` task and GitHub CLI for context.
9
+
10
+ ## Prerequisites
11
+
12
+ - **GitHub CLI (`gh`)**: Must be authenticated.
13
+ - **Invoke**: Dev tasks are defined in `tasks.py`.
14
+ - **Git**: Recent history and tags must be available.
15
+
16
+ ## Workflow
17
+
18
+ ### 1. Gather Context
19
+
20
+ Before drafting, understand what has changed since the last release.
21
+
22
+ ```bash
23
+ # Get the latest release tag and notes
24
+ gh release list --limit 1
25
+ gh release view <tag>
26
+
27
+ # List merged PRs since the last tag
28
+ # Replace <tag> with the tag found above
29
+ gh pr list --state merged --search "merged:><tag-date>"
30
+
31
+ # Or simple git log
32
+ git log <tag>..HEAD --oneline
33
+ ```
34
+
35
+ ### 2. Create Draft Release
36
+
37
+ Use the project's built-in release task to scaffold the release. This task automatically tags the release based on the version in `pyproject.toml` and uses GitHub's `--generate-notes` feature to create a starting point.
38
+
39
+ ```bash
40
+ # Create a draft release (default behavior of invoke release)
41
+ uv run invoke release --draft --skip-tests
42
+ ```
43
+
44
+ > [!NOTE]
45
+ > `invoke release` will:
46
+ > 1. Check if you are on `main`.
47
+ > 2. Check for clean git state.
48
+ > 3. Create a GitHub Release with `--generate-notes`.
49
+
50
+ ### 3. Refine Release Notes
51
+
52
+ GitHub's automatically generated notes are a good start but often lack professional categorization and narrative. Use the following structure for final refinement:
53
+
54
+ #### Categorization
55
+ - **🚀 Features**: New capabilities added to `ruff_sync`.
56
+ - **🐞 Bug Fixes**: Issues resolved in CLI, merging logic, or HTTP handling.
57
+ - **✨ Improvements**: Enhancements to existing features, performance, or logging.
58
+ - **📖 Documentation**: Updates to `README.md`, `docs/`, or docstrings.
59
+ - **🛠️ Maintenance**: Dependency updates, CI changes, or test refactoring.
60
+
61
+ #### Writing Style
62
+ - Use clear, action-oriented language (e.g., "Add support...", "Fix issue where...", "Refactor...").
63
+ - Link to PRs and contributors using their GitHub handles.
64
+ - Include a "Breaking Changes" section if applicable (use `[!WARNING]` alerts).
65
+
66
+ ### 4. Finalize
67
+
68
+ Once the notes are drafted and refined, you can view the draft on GitHub or update it via CLI.
69
+
70
+ ```bash
71
+ # View the draft notes
72
+ gh release view v<version>
73
+
74
+ # Edit the draft (opens your editor)
75
+ gh release edit v<version> --notes "your new notes"
76
+ ```
77
+
78
+ ## Tips
79
+
80
+ - **Consistency**: Refer to the `AGENTS.md` for project-specific terminology (e.g., "Upstream Layers").
81
+ - **Screenshots**: If the release includes significantly visible changes (e.g., new logging or CLI output formats), consider embedding a screenshot or recording in the notes.
82
+ - **Automated Summary**: You can ask the AI assistant to "Draft release notes based on the git log since <tag>" to get a structured summary before applying it to the release.
@@ -150,6 +150,7 @@ uv run coverage run -m pytest -vv
150
150
  - Use `pathlib` over `os.path` (enforced by `PTH` rules).
151
151
  - Prefer f-strings for logging (we ignore `G004`).
152
152
  - Do not create custom exception classes for simple errors (`TRY003` is ignored).
153
+ - **Prefer `NamedTuple` for return types** over plain tuples to improve readability and type safety.
153
154
 
154
155
  ### TOML Handling
155
156
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruff-sync
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.0.dev2
4
4
  Summary: Synchronize Ruff linter configuration across projects
5
5
  Project-URL: Homepage, https://github.com/Kilo59/ruff-sync
6
6
  Project-URL: Documentation, https://kilo59.github.io/ruff-sync/
@@ -163,6 +163,7 @@ Run `ruff-sync --help` for full details on all available options.
163
163
  ## Key Features
164
164
 
165
165
  - 🏗️ **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.
166
+ - 📂 **Upstream Layers** — Merge configurations from several sources sequentially (e.g., base company config + team-specific overrides).
166
167
  - 🌐 **GitHub & GitLab URL support** — Automatically converts GitHub/GitLab repository URLs, tree (directory) URLs, or blob (file) URLs to raw content URLs.
167
168
  - 🔍 **Smart configuration discovery** — Point at a directory and `ruff-sync` will automatically find your config. It checks `pyproject.toml`, `ruff.toml`, and `.ruff.toml` (in that order).
168
169
  - 📥 **Git clone support** — If the URL starts with `git@` or uses the `ssh://`, `git://`, or `git+ssh://` schemes, `ruff-sync` will perform an efficient shallow clone (using `--filter=blob:none` and `--no-checkout`) to safely extract the configuration with minimal network traffic.
@@ -200,8 +201,9 @@ Here are all the possible values that can be provided in `[tool.ruff-sync]` alon
200
201
 
201
202
  ```toml
202
203
  [tool.ruff-sync]
203
- # The source of truth URL for your Ruff configuration. (Required, unless passed via CLI)
204
- upstream = "https://github.com/my-org/standards"
204
+ # The source of truth URL(s) for your Ruff configuration. (Required, unless passed via CLI)
205
+ # Accepts a single string URL or a list of URLs.
206
+ upstream = ["https://github.com/my-org/standards", "https://github.com/my-org/team-tweaks"]
205
207
 
206
208
  # A list of config keys to exclude from being synced. (Default: ["lint.per-file-ignores"])
207
209
  # Use simple names for top-level keys, and dotted paths for nested keys.
@@ -329,10 +331,13 @@ When you run `ruff-sync check`, it follows this process to determine if your pro
329
331
  ```mermaid
330
332
  flowchart TD
331
333
  Start([Start]) --> Local[Read Local Configuration]
332
- Local --> Upstream[Download Upstream Configuration]
333
- Upstream --> Extract[Extract tool.ruff section if needed]
334
+ Local --> Upstreams{For each Upstream}
335
+ Upstreams --> Download[Download/Clone Configuration]
336
+ Download --> Extract[Extract section if needed]
334
337
  Extract --> Exclude[Apply Exclusions]
335
- Exclude --> Merge[Perform in-memory Merge]
338
+ Exclude --> Merge[Merge into in-memory Doc]
339
+ Merge --> Upstreams
340
+ Upstreams -- Done --> Comparison
336
341
 
337
342
  subgraph Comparison [Comparison Logic]
338
343
  direction TB
@@ -132,6 +132,7 @@ Run `ruff-sync --help` for full details on all available options.
132
132
  ## Key Features
133
133
 
134
134
  - 🏗️ **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.
135
+ - 📂 **Upstream Layers** — Merge configurations from several sources sequentially (e.g., base company config + team-specific overrides).
135
136
  - 🌐 **GitHub & GitLab URL support** — Automatically converts GitHub/GitLab repository URLs, tree (directory) URLs, or blob (file) URLs to raw content URLs.
136
137
  - 🔍 **Smart configuration discovery** — Point at a directory and `ruff-sync` will automatically find your config. It checks `pyproject.toml`, `ruff.toml`, and `.ruff.toml` (in that order).
137
138
  - 📥 **Git clone support** — If the URL starts with `git@` or uses the `ssh://`, `git://`, or `git+ssh://` schemes, `ruff-sync` will perform an efficient shallow clone (using `--filter=blob:none` and `--no-checkout`) to safely extract the configuration with minimal network traffic.
@@ -169,8 +170,9 @@ Here are all the possible values that can be provided in `[tool.ruff-sync]` alon
169
170
 
170
171
  ```toml
171
172
  [tool.ruff-sync]
172
- # The source of truth URL for your Ruff configuration. (Required, unless passed via CLI)
173
- upstream = "https://github.com/my-org/standards"
173
+ # The source of truth URL(s) for your Ruff configuration. (Required, unless passed via CLI)
174
+ # Accepts a single string URL or a list of URLs.
175
+ upstream = ["https://github.com/my-org/standards", "https://github.com/my-org/team-tweaks"]
174
176
 
175
177
  # A list of config keys to exclude from being synced. (Default: ["lint.per-file-ignores"])
176
178
  # Use simple names for top-level keys, and dotted paths for nested keys.
@@ -298,10 +300,13 @@ When you run `ruff-sync check`, it follows this process to determine if your pro
298
300
  ```mermaid
299
301
  flowchart TD
300
302
  Start([Start]) --> Local[Read Local Configuration]
301
- Local --> Upstream[Download Upstream Configuration]
302
- Upstream --> Extract[Extract tool.ruff section if needed]
303
+ Local --> Upstreams{For each Upstream}
304
+ Upstreams --> Download[Download/Clone Configuration]
305
+ Download --> Extract[Extract section if needed]
303
306
  Extract --> Exclude[Apply Exclusions]
304
- Exclude --> Merge[Perform in-memory Merge]
307
+ Exclude --> Merge[Merge into in-memory Doc]
308
+ Merge --> Upstreams
309
+ Upstreams -- Done --> Comparison
305
310
 
306
311
  subgraph Comparison [Comparison Logic]
307
312
  direction TB
@@ -6,7 +6,7 @@
6
6
 
7
7
  | Key | Type | Default | Description |
8
8
  | :--- | :--- | :--- | :--- |
9
- | `upstream` | `str` | *Required* | The URL of the upstream `pyproject.toml` or `ruff.toml`. |
9
+ | `upstream` | `str \| list[str]` | *Required* | The URL(s) of the upstream `pyproject.toml` or `ruff.toml`. |
10
10
  | `to` | `str` | `"."` | The local directory or file where configuration should be merged. |
11
11
  | `exclude` | `list[str]` | `["lint.per-file-ignores"]` | A list of configuration keys to preserve locally. |
12
12
  | `branch` | `str` | `"main"` | The default branch to use when resolving repository URLs. |
@@ -58,6 +58,18 @@ If your projects are on different Python versions but share linting rules:
58
58
  exclude = ["target-version"]
59
59
  ```
60
60
 
61
+ #### Sequential merging of multiple sources
62
+
63
+ You can specify multiple upstream sources as a list. They will be merged in order, with later sources overriding earlier ones.
64
+
65
+ ```toml
66
+ [tool.ruff-sync]
67
+ upstream = [
68
+ "https://github.com/my-org/shared-config",
69
+ "https://github.com/my-org/team-overrides",
70
+ ]
71
+ ```
72
+
61
73
  ## Deprecation Notes
62
74
 
63
75
  - The key `source` in `[tool.ruff-sync]` is deprecated and will be removed in a future version. Use `to` instead.
@@ -15,6 +15,7 @@
15
15
  * **⚡ Fast & Lightweight**: Zero-config needed for most projects.
16
16
  * **✨ Formatting Preserved**: Uses `tomlkit` to keep your comments, indentation, and whitespace exactly as they are.
17
17
  * **🛡️ Smart Merging**: Safely merges nested tables (like `lint.per-file-ignores`) without overwriting local overrides.
18
+ * **📂 Upstream Layers**: Combine and merge configurations from several sources sequentially.
18
19
  * **🔗 Flexible Sources**: Sync from GitHub, GitLab, raw URLs, or local files.
19
20
  * **✅ CI Ready**: Built-in `check` command with semantic diffs for automated pipelines.
20
21
 
@@ -9,10 +9,10 @@
9
9
  The `pull` command downloads the upstream configuration and merges it into your local file.
10
10
 
11
11
  ```bash
12
- ruff-sync pull [UPSTREAM_URL] [--to PATH] [--exclude KEY...] [--init]
12
+ ruff-sync pull [UPSTREAM_URL...] [--to PATH] [--exclude KEY...] [--init]
13
13
  ```
14
14
 
15
- * **`UPSTREAM_URL`**: The URL to the source `pyproject.toml` or `ruff.toml`. Optional if defined in your local config.
15
+ * **`UPSTREAM_URL...`**: One or more URLs to the source `pyproject.toml` or `ruff.toml`. Optional if defined in your local config.
16
16
  * **`--to`**: Where to save the merged config (defaults to `.`).
17
17
  * **`--exclude`**: Dotted paths of keys to keep local (e.g., `lint.isort`).
18
18
  * **`--init`**: Create a new `pyproject.toml` if it doesn't exist.
@@ -22,7 +22,7 @@ ruff-sync pull [UPSTREAM_URL] [--to PATH] [--exclude KEY...] [--init]
22
22
  The `check` command verifies if your local configuration matches the upstream one.
23
23
 
24
24
  ```bash
25
- ruff-sync check [UPSTREAM_URL] [--semantic] [--diff]
25
+ ruff-sync check [UPSTREAM_URL...] [--semantic] [--diff]
26
26
  ```
27
27
 
28
28
  * **`--semantic`**: Ignore "non-functional" differences like whitespace, comments, or key order.
@@ -92,7 +92,9 @@ graph TD
92
92
  I --> L[Extract tool.ruff]
93
93
  K --> L
94
94
  L --> M[Apply Exclusions]
95
- M --> N[Merge into local TOML]
96
- N --> O[Save File]
95
+ M --> N[Merge into in-memory TOML]
96
+ N --> Loop{More Upstreams?}
97
+ Loop -- Yes --> F
98
+ Loop -- No --> O[Save File]
97
99
  O --> P[End]
98
100
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ruff-sync"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.0.dev2"
4
4
  description = "Synchronize Ruff linter configuration across projects"
5
5
  keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit"]
6
6
  authors = [
@@ -44,7 +44,7 @@ __all__: Final[list[str]] = [
44
44
  "main",
45
45
  ]
46
46
 
47
- __version__ = "0.1.0.dev1"
47
+ __version__ = "0.1.0.dev2"
48
48
 
49
49
  LOGGER = logging.getLogger(__name__)
50
50
 
@@ -79,7 +79,7 @@ class Arguments(NamedTuple):
79
79
  """CLI arguments for the ruff-sync tool."""
80
80
 
81
81
  command: str
82
- upstream: URL
82
+ upstream: tuple[URL, ...]
83
83
  to: pathlib.Path
84
84
  exclude: Iterable[str]
85
85
  verbose: int
@@ -102,6 +102,16 @@ class Arguments(NamedTuple):
102
102
  return set(cls._fields) | {"source"}
103
103
 
104
104
 
105
+ class ResolvedArgs(NamedTuple):
106
+ """Internal container for resolved arguments."""
107
+
108
+ upstream: tuple[URL, ...]
109
+ to: pathlib.Path
110
+ exclude: Iterable[str]
111
+ branch: str
112
+ path: str
113
+
114
+
105
115
  @lru_cache(maxsize=1)
106
116
  def get_config(
107
117
  source: pathlib.Path,
@@ -173,9 +183,9 @@ def _get_cli_parser() -> ArgumentParser:
173
183
  common_parser.add_argument(
174
184
  "upstream",
175
185
  type=URL,
176
- nargs="?",
177
- help=f"The URL to download the {RuffConfigFileName.PYPROJECT_TOML} file from."
178
- " Optional if defined in [tool.ruff-sync].",
186
+ nargs="*",
187
+ help=f"One or more URLs to download the {RuffConfigFileName.PYPROJECT_TOML} file from."
188
+ " Optional if defined in [tool.ruff-sync]. Upstreams are merged sequentially.",
179
189
  )
180
190
  common_parser.add_argument(
181
191
  "--to",
@@ -253,20 +263,41 @@ def _get_cli_parser() -> ArgumentParser:
253
263
  PARSER: Final[ArgumentParser] = _get_cli_parser()
254
264
 
255
265
 
256
- def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> URL:
257
- """Resolve upstream URL from CLI or config."""
266
+ def _resolve_upstream(args: Any, config: Mapping[str, Any]) -> tuple[URL, ...]:
267
+ """Resolve upstream URL(s) from CLI or config."""
258
268
  if args.upstream:
259
- return cast("URL", args.upstream)
269
+ upstreams = tuple(cast("Iterable[URL]", args.upstream))
270
+ # Log CLI upstreams for consistency with config sourcing
271
+ summary = (
272
+ f"{upstreams[0]}... ({len(upstreams)} total)"
273
+ if len(upstreams) > 1
274
+ else str(upstreams[0])
275
+ )
276
+ LOGGER.info(f"📂 Using upstream(s) from CLI: {summary}")
277
+ return upstreams
260
278
  if "upstream" in config:
261
279
  config_upstream = config["upstream"]
262
- if not isinstance(config_upstream, str):
263
- PARSER.error(
264
- " upstream in [tool.ruff-sync] must be a string, "
265
- f"got {type(config_upstream).__name__}"
266
- )
267
- upstream = URL(config_upstream)
268
- LOGGER.info(f"📂 Using upstream from [tool.ruff-sync]: {upstream}")
269
- return upstream
280
+ if isinstance(config_upstream, str):
281
+ upstream = (URL(config_upstream),)
282
+ LOGGER.info(f"📂 Using upstream from [tool.ruff-sync]: {upstream[0]}")
283
+ return upstream
284
+ if isinstance(config_upstream, list):
285
+ if not config_upstream:
286
+ PARSER.error(" [tool.ruff-sync].upstream list cannot be empty.")
287
+ if not all(isinstance(u, str) for u in config_upstream):
288
+ PARSER.error(
289
+ "❌ all items in [tool.ruff-sync].upstream must be strings, "
290
+ f"got {[type(u).__name__ for u in config_upstream]}"
291
+ )
292
+ upstreams = tuple(URL(u) for u in config_upstream)
293
+ LOGGER.info(f"📂 Using {len(upstreams)} upstreams from [tool.ruff-sync]")
294
+ return upstreams
295
+
296
+ PARSER.error(
297
+ "❌ upstream in [tool.ruff-sync] must be a string or a list of strings, "
298
+ f"got {type(config_upstream).__name__}"
299
+ )
300
+
270
301
  PARSER.error(
271
302
  "❌ the following arguments are required: upstream "
272
303
  f"(or define it in [tool.ruff-sync] in {RuffConfigFileName.PYPROJECT_TOML}) 💥"
@@ -323,16 +354,14 @@ def _resolve_to(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path)
323
354
  return initial_to
324
355
 
325
356
 
326
- def _resolve_args(
327
- args: Any, config: Mapping[str, Any], initial_to: pathlib.Path
328
- ) -> tuple[URL, pathlib.Path, Iterable[str], str, str]:
357
+ def _resolve_args(args: Any, config: Mapping[str, Any], initial_to: pathlib.Path) -> ResolvedArgs:
329
358
  """Resolve upstream, to, exclude, branch, and path from CLI and config."""
330
359
  upstream = _resolve_upstream(args, config)
331
360
  to = _resolve_to(args, config, initial_to)
332
361
  exclude = _resolve_exclude(args, config)
333
362
  branch = _resolve_branch(args, config)
334
363
  path = _resolve_path(args, config)
335
- return upstream, to, exclude, branch, path
364
+ return ResolvedArgs(upstream, to, exclude, branch, path)
336
365
 
337
366
 
338
367
  def main() -> int:
@@ -372,7 +401,7 @@ def main() -> int:
372
401
  upstream, to_val, exclude, branch, path = _resolve_args(args, config, initial_to)
373
402
 
374
403
  # Convert non-raw github/gitlab upstream url to the raw equivalent
375
- upstream = resolve_raw_url(upstream, branch=branch, path=path)
404
+ upstream = tuple(resolve_raw_url(u, branch=branch, path=path) for u in upstream)
376
405
 
377
406
  # Create Arguments object
378
407
  exec_args = Arguments(
@@ -95,7 +95,7 @@ class FetchResult(NamedTuple):
95
95
  class Config(TypedDict, total=False):
96
96
  """Configuration schema for [tool.ruff-sync] in pyproject.toml."""
97
97
 
98
- upstream: str
98
+ upstream: str | list[str]
99
99
  to: str
100
100
  source: str # Deprecated
101
101
  exclude: list[str]
@@ -107,12 +107,14 @@ class Config(TypedDict, total=False):
107
107
  init: bool
108
108
 
109
109
 
110
- def resolve_target_path(to: pathlib.Path, upstream_url: str | URL | None = None) -> pathlib.Path:
110
+ def resolve_target_path(
111
+ to: pathlib.Path, upstreams: Iterable[str | URL] | None = None
112
+ ) -> pathlib.Path:
111
113
  """Resolve the target path for configuration files.
112
114
 
113
115
  If 'to' is a file, it's used directly.
114
116
  Otherwise, it looks for existing ruff/pyproject.toml in the 'to' directory.
115
- If none found, it defaults to pyproject.toml unless the upstream is a ruff.toml.
117
+ If none found, it defaults to pyproject.toml unless the first upstream is a ruff.toml.
116
118
  """
117
119
  if to.is_file():
118
120
  return to
@@ -123,8 +125,11 @@ def resolve_target_path(to: pathlib.Path, upstream_url: str | URL | None = None)
123
125
  if candidate.exists():
124
126
  return candidate
125
127
 
128
+ # Use the first upstream URL as a hint for the default file name
129
+ first_upstream = next(iter(upstreams), None) if upstreams else None
130
+
126
131
  # If upstream is specified and is a ruff.toml, default to ruff.toml
127
- if upstream_url and is_ruff_toml_file(upstream_url):
132
+ if first_upstream and is_ruff_toml_file(first_upstream):
128
133
  return to / RuffConfigFileName.RUFF_TOML
129
134
 
130
135
  return to / RuffConfigFileName.PYPROJECT_TOML
@@ -686,6 +691,34 @@ def merge_ruff_toml(
686
691
  return source
687
692
 
688
693
 
694
+ async def _merge_multiple_upstreams(
695
+ target_doc: TOMLDocument,
696
+ is_target_ruff_toml: bool,
697
+ args: Arguments,
698
+ client: httpx.AsyncClient,
699
+ ) -> TOMLDocument:
700
+ """Sequentially fetch and merge all upstreams into the target document."""
701
+ for upstream_url in args.upstream:
702
+ fetch_result = await fetch_upstream_config(
703
+ upstream_url, client, branch=args.branch, path=args.path
704
+ )
705
+ LOGGER.info(f"Loaded upstream file from {fetch_result.resolved_upstream}")
706
+
707
+ is_upstream_ruff_toml = is_ruff_toml_file(fetch_result.resolved_upstream)
708
+
709
+ upstream_ruff_toml = get_ruff_config(
710
+ fetch_result.buffer.read(),
711
+ is_ruff_toml=is_upstream_ruff_toml,
712
+ create_if_missing=False,
713
+ exclude=args.exclude,
714
+ )
715
+
716
+ target_doc = merge_ruff_toml(
717
+ target_doc, upstream_ruff_toml, is_ruff_toml=is_target_ruff_toml
718
+ )
719
+ return target_doc
720
+
721
+
689
722
  async def check(
690
723
  args: Arguments,
691
724
  ) -> int:
@@ -720,30 +753,19 @@ async def check(
720
753
  source_toml_file = TOMLFile(_source_toml_path)
721
754
  source_doc = source_toml_file.read()
722
755
 
756
+ # Create a copy for comparison
757
+ source_doc_copy = tomlkit.parse(source_doc.as_string())
758
+ merged_doc = source_doc_copy
759
+
723
760
  async with httpx.AsyncClient() as client:
724
- fetch_result = await fetch_upstream_config(
725
- args.upstream, client, branch=args.branch, path=args.path
761
+ merged_doc = await _merge_multiple_upstreams(
762
+ merged_doc,
763
+ is_target_ruff_toml=is_ruff_toml_file(_source_toml_path.name),
764
+ args=args,
765
+ client=client,
726
766
  )
727
- LOGGER.info(f"Loaded upstream file from {fetch_result.resolved_upstream}")
728
767
 
729
- is_upstream_ruff_toml = is_ruff_toml_file(fetch_result.resolved_upstream)
730
768
  is_source_ruff_toml = is_ruff_toml_file(_source_toml_path.name)
731
-
732
- upstream_ruff_toml = get_ruff_config(
733
- fetch_result.buffer.read(),
734
- is_ruff_toml=is_upstream_ruff_toml,
735
- create_if_missing=False,
736
- exclude=args.exclude,
737
- )
738
-
739
- # Create a copy for comparison
740
- source_doc_copy = tomlkit.parse(source_doc.as_string())
741
- merged_doc = merge_ruff_toml(
742
- source_doc_copy,
743
- upstream_ruff_toml,
744
- is_ruff_toml=is_source_ruff_toml,
745
- )
746
-
747
769
  if args.semantic:
748
770
  if is_source_ruff_toml:
749
771
  source_ruff = source_doc
@@ -837,28 +859,15 @@ async def pull(
837
859
  )
838
860
  return 1
839
861
 
840
- # NOTE: there's no particular reason to use async here.
841
862
  async with httpx.AsyncClient() as client:
842
- fetch_result = await fetch_upstream_config(
843
- args.upstream, client, branch=args.branch, path=args.path
863
+ source_doc = await _merge_multiple_upstreams(
864
+ source_doc,
865
+ is_target_ruff_toml=is_ruff_toml_file(_source_toml_path.name),
866
+ args=args,
867
+ client=client,
844
868
  )
845
- LOGGER.info(f"Loaded upstream file from {fetch_result.resolved_upstream}")
846
869
 
847
- is_upstream_ruff_toml = is_ruff_toml_file(fetch_result.resolved_upstream)
848
- is_source_ruff_toml = is_ruff_toml_file(_source_toml_path.name)
849
-
850
- upstream_ruff_toml = get_ruff_config(
851
- fetch_result.buffer.read(),
852
- is_ruff_toml=is_upstream_ruff_toml,
853
- create_if_missing=False,
854
- exclude=args.exclude,
855
- )
856
- merged_toml = merge_ruff_toml(
857
- source_doc,
858
- upstream_ruff_toml,
859
- is_ruff_toml=is_source_ruff_toml,
860
- )
861
- source_toml_file.write(merged_toml)
870
+ source_toml_file.write(source_doc)
862
871
  try:
863
872
  rel_path = _source_toml_path.resolve().relative_to(pathlib.Path.cwd())
864
873
  except ValueError:
@@ -0,0 +1,8 @@
1
+ [tool.ruff]
2
+ target-version = "py312"
3
+ line-length = 100
4
+
5
+ [tool.ruff.lint]
6
+ select = ["E", "F", "I"]
7
+ ignore = ["E501"]
8
+ extend-select = ["UP"]
@@ -0,0 +1,7 @@
1
+ [tool.ruff]
2
+ target-version = "py310"
3
+ line-length = 88
4
+
5
+ [tool.ruff.lint]
6
+ select = ["E", "F"]
7
+ ignore = ["E501"]
@@ -0,0 +1,5 @@
1
+ [tool.ruff]
2
+ line-length = 100
3
+
4
+ [tool.ruff.lint]
5
+ select = ["E", "F", "I"]
@@ -0,0 +1,5 @@
1
+ [tool.ruff]
2
+ target-version = "py312"
3
+
4
+ [tool.ruff.lint]
5
+ extend-select = ["UP"]