diff --git a/.agents/TESTING.md b/.agents/TESTING.md index 110ed088..67799a2e 100644 --- a/.agents/TESTING.md +++ b/.agents/TESTING.md @@ -48,6 +48,22 @@ def test_merge_scenarios(source, upstream, expected_keys): # ... test logic ... ``` +### 3.3 No Autouse Fixtures +`autouse=True` fixtures are **never allowed**. They hide setup logic and can cause non-obvious side effects or dependencies between tests. All fixtures used by a test must be explicitly requested in the test function's arguments. + +### 3.4 Main Entry Point +Every test file **must** end with a main entry point block. This ensures each file is independently executable as a script (`python tests/test_foo.py`). + +```python +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) +``` + +**Why this matters:** +1. **Direct Execution**: Developers can run a single test file using standard Python without needing to remember complex `pytest` filter flags. +2. **IDE Workflow Integration**: Many IDEs (like VS Code or PyCharm) allow you to run the "Current File" with a single click or keyboard shortcut. Having a main block ensures this works out of the box with the correct verbosity and scope. +3. **Cleaner Diffs**: By terminating the file with this standard block, it prevents "no newline at end of file" warnings and ensures that new tests added above it produce clean, isolated diff segments. It also ensures that when debugging with `--icdiff` or similar tools, the output is scoped correctly to the specific file. + ## 4. Handling TOML and `tomlkit` `tomlkit` is central to this project but its dynamic type system can be tricky for mypy. diff --git a/README.md b/README.md index e6ea4736..0485db24 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ **Keep your Ruff config consistent across multiple projects.** -`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, or any raw URL) and merges it into your local project, preserving your comments, formatting, and project-specific overrides. +`ruff-sync` is a CLI tool that pulls a canonical [Ruff](https://docs.astral.sh/ruff/) configuration from an upstream `pyproject.toml` or `ruff.toml` (hosted anywhere — GitHub, GitLab, or any raw URL) and merges it into your local project, preserving your comments, formatting, and project-specific overrides. --- @@ -139,13 +139,13 @@ Run `ruff-sync --help` for full details on all available options. ## Key Features -- **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. -- **GitHub & GitLab URL support** — Automatically converts GitHub/GitLab repository URLs or blob URLs to raw content URLs. -- **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. -- **Selective exclusions** — Keep project-specific overrides (like `per-file-ignores` or `target-version`) from being clobbered by the upstream config. -- **Works with any host** — GitHub, GitLab, Bitbucket, private SSH servers, or any raw URL that serves a `pyproject.toml`. -- **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. ([See detailed logic](#detailed-check-logic)) -- **Semantic mode** — Use `--semantic` to ignore cosmetic differences (comments, whitespace) and only fail on real value changes. +- 🏗️ **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. +- 🌐 **GitHub & GitLab URL support** — Automatically converts GitHub/GitLab repository URLs or blob URLs to raw content URLs. +- 📥 **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. +- 🛡️ **Selective exclusions** — Keep project-specific overrides (like `per-file-ignores` or `target-version`) from being clobbered by the upstream config. +- 🌍 **Works with any host** — GitHub, GitLab, Bitbucket, private SSH servers, or any raw URL that serves a `pyproject.toml` or `ruff.toml`. +- 🤖 **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. ([See detailed logic](#detailed-check-logic)) +- 🧠 **Semantic mode** — Use `--semantic` to ignore cosmetic differences (comments, whitespace) and only fail on real value changes. ## Configuration @@ -172,16 +172,25 @@ This sets the default upstream and exclusions so you don't need to pass them on ### Advanced Configuration -For more complex setups, you can also configure the default branch and parent directory used when resolving repository URLs (e.g. `https://github.com/my-org/standards`): +Here are all the possible values that can be provided in `[tool.ruff-sync]` along with their explanations and defaults: ```toml [tool.ruff-sync] +# The source of truth URL for your Ruff configuration. (Required, unless passed via CLI) upstream = "https://github.com/my-org/standards" -# Use a specific branch or tag (default: "main") +# A list of config keys to exclude from being synced. (Default: ["lint.per-file-ignores"]) +# Use simple names for top-level keys, and dotted paths for nested keys. +exclude = [ + "target-version", + "lint.per-file-ignores", +] + +# The branch, tag, or commit hash to use when resolving a Git repository URL. (Default: "main") branch = "develop" -# Specify a parent directory if pyproject.toml is not at the repo root +# A directory prefix to use when looking for a configuration file in a repository. (Default: "") +# Useful if the upstream pyproject.toml is not at the repository root. path = "config/ruff" ``` @@ -229,6 +238,18 @@ git diff pyproject.toml # review the changes git commit -am "sync ruff config from upstream" ``` +## Bootstrapping a New Project + +By default, `ruff-sync` requires an existing configuration file (`pyproject.toml` or `ruff.toml`) to merge into. If you are starting a fresh project and want to initialize it with your organization's Ruff settings, you can use the `--init` flag to scaffold a new file automatically. + +```console +# Create a new pyproject.toml (or ruff.toml) pre-configured with upstream settings +ruff-sync pull https://github.com/my-org/standards --init +``` + +`ruff-sync` seamlessly supports both `pyproject.toml` and standalone `ruff.toml` (or `.ruff.toml`) files. If your upstream source or your local target is a `ruff.toml`, it will automatically adapt and sync the root configuration rather than looking for a `[tool.ruff]` section. + + ## Detailed Check Logic When you run `ruff-sync check`, it follows this process to determine if your project has drifted from the upstream source: @@ -267,37 +288,17 @@ flowchart TD style SemanticNode fill:#f4f4f4,color:#363636,stroke:#dbdbdb ``` -## Contributing - -This project uses: - -- [uv](https://docs.astral.sh/uv/) for dependency management -- [Ruff](https://docs.astral.sh/ruff/) for linting and formatting -- [mypy](https://mypy-lang.org/) for type checking (strict mode) -- [pytest](https://docs.pytest.org/) for testing - -```console -# Setup -uv sync --group dev - -# Run checks -uv run ruff check . --fix # lint -uv run ruff format . # format -uv run mypy . # type check -uv run pytest -vv # test -``` - ## Dogfooding -To see `ruff-sync` in action, you can "dogfood" it on this project's own config. +To see `ruff-sync` in action, you can ["dogfood" it on this project's own config](./scripts). -**Check if this project is in sync with its upstream:** +[**Check if this project is in sync with its upstream:**](./scripts/check_dogfood.sh) ```console ./scripts/check_dogfood.sh ``` -**Or sync from a large upstream like Pydantic's config:** +[**Or sync from a large upstream like Pydantic's config:**](./scripts/pull_dogfood.sh) ```console # Using a HTTP URL diff --git a/pyproject.toml b/pyproject.toml index 7c9c0874..7c2bdbf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ruff-sync" -version = "0.0.3" +version = "0.0.4.dev1" description = "Synchronize Ruff linter configuration across projects" keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit"] authors = [ diff --git a/ruff_sync.py b/ruff_sync.py index a02004a7..61c9f291 100644 --- a/ruff_sync.py +++ b/ruff_sync.py @@ -14,6 +14,7 @@ from functools import lru_cache from io import StringIO from typing import Any, ClassVar, Final, Literal, NamedTuple, TypedDict, cast, overload +from urllib.parse import urlparse import httpx import tomlkit @@ -22,7 +23,7 @@ from tomlkit.items import Table from tomlkit.toml_file import TOMLFile -__version__ = "0.0.3" +__version__ = "0.0.4.dev1" _DEFAULT_EXCLUDE: Final[set[str]] = {"lint.per-file-ignores"} _GITHUB_REPO_PATH_PARTS_COUNT: Final[int] = 2 @@ -65,6 +66,7 @@ class Arguments(NamedTuple): path: str = "" semantic: bool = False diff: bool = True + init: bool = False @classmethod @lru_cache(maxsize=1) @@ -81,12 +83,14 @@ class Config(TypedDict, total=False): path: str semantic: bool diff: bool + init: bool @lru_cache(maxsize=1) def get_config( source: pathlib.Path, ) -> Config: + """Read [tool.ruff-sync] configuration from pyproject.toml.""" local_toml = source / "pyproject.toml" # TODO: use pydantic to validate the toml file cfg_result: dict[str, Any] = {} @@ -94,8 +98,9 @@ def get_config( toml = tomlkit.parse(local_toml.read_text()) config = toml.get("tool", {}).get("ruff-sync") if config: + allowed_keys = set(Config.__annotations__.keys()) for arg, value in config.items(): - if arg in Arguments.fields(): + if arg in allowed_keys: cfg_result[arg] = value else: LOGGER.warning(f"Unknown ruff-sync configuration: {arg}") @@ -175,11 +180,16 @@ def _get_cli_parser() -> ArgumentParser: ) # Pull subcommand (the default action) - subparsers.add_parser( + pull_parser = subparsers.add_parser( "pull", parents=[common_parser], help="Pull and apply upstream ruff configuration", ) + pull_parser.add_argument( + "--init", + action="store_true", + help="Create a new configuration file if it does not exist in the target directory.", + ) # Check subcommand check_parser = subparsers.add_parser( @@ -417,34 +427,62 @@ def _git_clone_and_read() -> str: return await download(url, client) +def is_ruff_toml_file(path_or_url: str) -> bool: + """Return True if the path or URL indicates a ruff.toml file. + + This handles: + - Plain paths (e.g. "ruff.toml", ".ruff.toml", "configs/ruff.toml") + - URLs with query strings or fragments (e.g. "ruff.toml?ref=main", "ruff.toml#L10") + by examining only the path component (or the part before any query/fragment). + """ + parsed = urlparse(path_or_url) + + # If it's a URL with a scheme/netloc, use the parsed path component. + # Otherwise, fall back to stripping any query/fragment from the raw string. + if parsed.scheme or parsed.netloc: + path = parsed.path + else: + path = path_or_url.split("?", 1)[0].split("#", 1)[0] + + return pathlib.Path(path).name in ("ruff.toml", ".ruff.toml") + + @overload -def get_ruff_tool_table( +def get_ruff_config( toml: str | TOMLDocument, + is_ruff_toml: bool = ..., create_if_missing: Literal[True] = ..., exclude: Iterable[str] = ..., -) -> Table: ... +) -> TOMLDocument | Table: ... @overload -def get_ruff_tool_table( +def get_ruff_config( toml: str | TOMLDocument, + is_ruff_toml: bool = ..., create_if_missing: Literal[False] = ..., exclude: Iterable[str] = ..., -) -> Table | None: ... +) -> TOMLDocument | Table | None: ... -def get_ruff_tool_table( +def get_ruff_config( toml: str | TOMLDocument, + is_ruff_toml: bool = False, create_if_missing: bool = True, exclude: Iterable[str] = (), -) -> Table | None: - """Get the tool.ruff section from a TOML string. - If it does not exist, create it. +) -> TOMLDocument | Table | None: + """Get the ruff section or document from a TOML string. + If it does not exist and it is a pyproject.toml, create it. """ if isinstance(toml, str): doc: TOMLDocument = tomlkit.parse(toml) else: doc = toml + + if is_ruff_toml: + _apply_exclusions(doc, exclude) + return doc + try: tool: Table = doc["tool"] # type: ignore[assignment] ruff = tool["ruff"] @@ -463,7 +501,11 @@ def get_ruff_tool_table( return ruff -def _apply_exclusions(tbl: Table, exclude: Iterable[str]) -> None: +# Alias for backward compatibility in internal tools/tests if they exist +get_ruff_tool_table = get_ruff_config + + +def _apply_exclusions(tbl: Table | TOMLDocument, exclude: Iterable[str]) -> None: """Remove excluded keys from a ruff table, supporting dotted paths. Keys can be simple (e.g. ``"target-version"``) to match top-level ruff @@ -536,14 +578,20 @@ def _recursive_update(source_table: Any, upstream: Any) -> None: def merge_ruff_toml( - source: TOMLDocument, upstream_ruff_doc: TOMLDocument | Table | None + source: TOMLDocument, + upstream_ruff_doc: TOMLDocument | Table | None, + is_ruff_toml: bool = False, ) -> TOMLDocument: """Merge the source and upstream tool ruff config with better whitespace preservation.""" if not upstream_ruff_doc: LOGGER.warning("No upstream ruff config section found.") return source - source_tool_ruff = get_ruff_tool_table(source) + if is_ruff_toml: + _recursive_update(source, upstream_ruff_doc) + return source + + source_tool_ruff = get_ruff_config(source, create_if_missing=True) _recursive_update(source_tool_ruff, upstream_ruff_doc) @@ -561,14 +609,32 @@ def merge_ruff_toml( return source +def _resolve_target_path(args: Arguments) -> pathlib.Path: + if args.source.is_file(): + return args.source + if (args.source / "ruff.toml").exists(): + return args.source / "ruff.toml" + if (args.source / ".ruff.toml").exists(): + return args.source / ".ruff.toml" + if args.upstream and is_ruff_toml_file(str(args.upstream)): + return args.source / "ruff.toml" + return args.source / "pyproject.toml" + + async def check( args: Arguments, ) -> int: - """Check if the local pyproject.toml is in sync with the upstream.""" + """Check if the local pyproject.toml / ruff.toml is in sync with the upstream.""" print("🔍 Checking Ruff sync status...") - _source_toml_path = args.source if args.source.is_file() else args.source / "pyproject.toml" - _source_toml_path = _source_toml_path.resolve(strict=True) + _source_toml_path = _resolve_target_path(args).resolve(strict=False) + if not _source_toml_path.exists(): + print( + f"❌ Configuration file {_source_toml_path} does not exist. " + "Run 'ruff-sync pull' to create it." + ) + return 1 + source_toml_file = TOMLFile(_source_toml_path) source_doc = source_toml_file.read() @@ -578,17 +644,31 @@ async def check( ) LOGGER.info(f"Loaded upstream file from {args.upstream}") - upstream_ruff_toml = get_ruff_tool_table( - file_buffer.read(), create_if_missing=False, exclude=args.exclude + is_upstream_ruff_toml = is_ruff_toml_file(str(args.upstream)) + is_source_ruff_toml = is_ruff_toml_file(_source_toml_path.name) + + upstream_ruff_toml = get_ruff_config( + file_buffer.read(), + is_ruff_toml=is_upstream_ruff_toml, + create_if_missing=False, + exclude=args.exclude, ) # Create a copy for comparison source_doc_copy = tomlkit.parse(source_doc.as_string()) - merged_doc = merge_ruff_toml(source_doc_copy, upstream_ruff_toml) + merged_doc = merge_ruff_toml( + source_doc_copy, + upstream_ruff_toml, + is_ruff_toml=is_source_ruff_toml, + ) if args.semantic: - source_ruff = source_doc.get("tool", {}).get("ruff") - merged_ruff = merged_doc.get("tool", {}).get("ruff") + if is_source_ruff_toml: + source_ruff = source_doc + merged_ruff = merged_doc + else: + source_ruff = source_doc.get("tool", {}).get("ruff") + merged_ruff = merged_doc.get("tool", {}).get("ruff") # Compare unwrapped versions source_val = source_ruff.unwrap() if source_ruff is not None else None @@ -633,10 +713,29 @@ async def check( async def pull( args: Arguments, ) -> int: - """Pull the upstream ruff config and apply it to the source pyproject.toml.""" + """Pull the upstream ruff config and apply it to the source.""" print("🔄 Syncing Ruff...") - _source_toml_path = args.source if args.source.is_file() else args.source / "pyproject.toml" - source_toml_file = TOMLFile(_source_toml_path.resolve(strict=True)) + _source_toml_path = _resolve_target_path(args).resolve(strict=False) + + source_toml_file = TOMLFile(_source_toml_path) + if _source_toml_path.exists(): + source_doc = source_toml_file.read() + elif args.init: + LOGGER.info(f"✨ Target file {_source_toml_path} does not exist, creating it.") + source_doc = tomlkit.document() + # Scaffold the file immediately to ensure we can write to the enclosing directory + try: + _source_toml_path.parent.mkdir(parents=True, exist_ok=True) + _source_toml_path.touch() + except OSError as e: + print(f"❌ Failed to create {_source_toml_path}: {e}", file=sys.stderr) + return 1 + else: + print( + f"❌ Configuration file {_source_toml_path} does not exist. " + "Pass the '--init' flag to create it." + ) + return 1 # NOTE: there's no particular reason to use async here. async with httpx.AsyncClient() as client: @@ -645,15 +744,26 @@ async def pull( ) LOGGER.info(f"Loaded upstream file from {args.upstream}") - upstream_ruff_toml = get_ruff_tool_table( - file_buffer.read(), create_if_missing=False, exclude=args.exclude + is_upstream_ruff_toml = is_ruff_toml_file(str(args.upstream)) + is_source_ruff_toml = is_ruff_toml_file(_source_toml_path.name) + + upstream_ruff_toml = get_ruff_config( + file_buffer.read(), + is_ruff_toml=is_upstream_ruff_toml, + create_if_missing=False, + exclude=args.exclude, ) merged_toml = merge_ruff_toml( - source_toml_file.read(), + source_doc, upstream_ruff_toml, + is_ruff_toml=is_source_ruff_toml, ) source_toml_file.write(merged_toml) - print(f"✅ Updated {_source_toml_path.resolve().relative_to(pathlib.Path.cwd())}") + try: + rel_path = _source_toml_path.resolve().relative_to(pathlib.Path.cwd()) + except ValueError: + rel_path = _source_toml_path.resolve() + print(f"✅ Updated {rel_path}") return 0 @@ -736,7 +846,6 @@ def main() -> int: sys.argv.append("pull") args = PARSER.parse_args() - config: Config = get_config(args.source) # Configure logging log_level = { @@ -750,6 +859,8 @@ def main() -> int: LOGGER.addHandler(handler) LOGGER.propagate = False # Avoid double logging if root is also configured + config: Config = get_config(args.source) + upstream, exclude, branch, path = _resolve_args(args, config) # Convert non-raw github/gitlab upstream url to the raw equivalent @@ -766,6 +877,7 @@ def main() -> int: path=path, semantic=getattr(args, "semantic", False), diff=getattr(args, "diff", True), + init=getattr(args, "init", False), ) if exec_args.command == "check": diff --git a/tests/test_basic.py b/tests/test_basic.py index 3ac9b06f..308f7d6c 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -136,7 +136,7 @@ def test_apply_exclusions_dotted_path(): assert "per-file-ignores" not in ruff_tbl["lint"] # type: ignore[operator] # Other keys should be untouched assert ruff_tbl["lint"]["select"] == ["F", "E"] # type: ignore[index] - assert ruff_tbl["target-version"] == "py310" # type: ignore[comparison-overlap] + assert ruff_tbl["target-version"] == "py310" def test_apply_exclusions_missing_key_is_noop(): @@ -145,7 +145,7 @@ def test_apply_exclusions_missing_key_is_noop(): ruff_tbl = ruff_sync.get_ruff_tool_table( full_toml, exclude=["nonexistent", "lint.also-missing"] ) - assert ruff_tbl["target-version"] == "py310" # type: ignore[comparison-overlap] + assert ruff_tbl["target-version"] == "py310" def test_apply_exclusions_mixed(): diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 00000000..90061eb6 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from ruff_sync import LOGGER, get_config + +if TYPE_CHECKING: + import pathlib + + +@pytest.fixture +def clean_config_cache(): + """Ensure get_config cache is clear before and after each test.""" + # Ensure LOGGER can be captured by caplog + original_propagate = LOGGER.propagate + LOGGER.propagate = True + get_config.cache_clear() + yield + get_config.cache_clear() + LOGGER.propagate = original_propagate + + +def test_get_config_warns_on_unknown_key( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, clean_config_cache: None +): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff-sync] +upstream = "https://github.com/org/repo" +unknown_key = "value" +""" + ) + + # We need to ensure the logger is set up to capture the warning + # In ruff_sync.py, get_config is called before handlers are added in main() + # But in tests, caplog should catch it if the level is right. + + with caplog.at_level(logging.WARNING): + config = get_config(tmp_path) + + assert "Unknown ruff-sync configuration: unknown_key" in caplog.text + assert "upstream" in config + assert "unknown_key" not in config + + +def test_get_config_warns_on_command_key( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, clean_config_cache: None +): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff-sync] +command = "pull" +""" + ) + + with caplog.at_level(logging.WARNING): + config = get_config(tmp_path) + + assert "Unknown ruff-sync configuration: command" in caplog.text + assert "command" not in config + + +def test_get_config_passes_allowed_keys( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, clean_config_cache: None +): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[tool.ruff-sync] +upstream = "https://github.com/org/repo" +exclude = ["lint.per-file-ignores"] +branch = "develop" +""" + ) + + with caplog.at_level(logging.WARNING): + config = get_config(tmp_path) + + assert "Unknown ruff-sync configuration" not in caplog.text + assert config["upstream"] == "https://github.com/org/repo" + assert config["exclude"] == ["lint.per-file-ignores"] + assert config["branch"] == "develop" + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 00000000..733bea19 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import pathlib +from typing import TYPE_CHECKING + +import pytest +import respx +from httpx import URL + +import ruff_sync + +if TYPE_CHECKING: + from collections.abc import Generator + + from pyfakefs.fake_filesystem import FakeFilesystem + + +@pytest.fixture +def mock_http(toml_s: str) -> Generator[respx.MockRouter, None, None]: + with respx.mock(base_url="https://example.com/", assert_all_called=False) as respx_mock: + respx_mock.get("/pyproject.toml").respond( + 200, + content_type="text/plain", + content=toml_s, + ) + respx_mock.get("/ruff.toml").respond( + 200, + content_type="text/plain", + content='target-version = "py310"\nline-length = 99\n', + ) + yield respx_mock + + +@pytest.fixture +def toml_s() -> str: + """A sample pyproject.toml file with ruff config.""" + return """[tool.ruff] +target-version = "py38" +line-length = 120 +[tool.ruff.lint] +select = ["F", "ASYNC"] +ignore = ["W191", "E111"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = [ + "F401", # unused import + "F403", # star imports +] +""" + + +@pytest.mark.asyncio +async def test_pull_without_init_fails_on_missing_file( + mock_http: respx.MockRouter, fs: FakeFilesystem, capsys: pytest.CaptureFixture[str] +): + target_dir = pathlib.Path(fs.create_dir("empty_dir").path) # type: ignore[arg-type] + + upstream = URL("https://example.com/pyproject.toml") + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=False, + ) + ) + + assert result == 1 + captured = capsys.readouterr() + assert "Configuration file" in captured.out + assert "does not exist" in captured.out + assert "Pass the '--init' flag" in captured.out + assert not (target_dir / "pyproject.toml").exists() + + +@pytest.mark.asyncio +async def test_pull_with_init_scaffolds_pyproject_toml( + mock_http: respx.MockRouter, fs: FakeFilesystem +): + """Test that pull scaffolds a pyproject.toml if --init is passed.""" + target_dir = pathlib.Path(fs.create_dir("empty_dir").path) # type: ignore[arg-type] + + upstream = URL("https://example.com/pyproject.toml") + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=True, + ) + ) + + assert result == 0 + scaffolded_file = target_dir / "pyproject.toml" + assert scaffolded_file.exists() + + content = scaffolded_file.read_text() + assert "[tool.ruff]" in content + assert 'target-version = "py38"' in content + + +@pytest.mark.asyncio +async def test_pull_with_init_scaffolds_ruff_toml(mock_http: respx.MockRouter, fs: FakeFilesystem): + """Test that pull scaffolds a ruff.toml if upstream is ruff.toml and --init is passed.""" + target_dir = pathlib.Path(fs.create_dir("empty_dir").path) # type: ignore[arg-type] + + upstream = URL("https://example.com/ruff.toml") + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=True, + ) + ) + + assert result == 0 + scaffolded_file = target_dir / "ruff.toml" + assert scaffolded_file.exists() + + content = scaffolded_file.read_text() + assert "[tool.ruff]" not in content + assert 'target-version = "py310"' in content + assert "line-length = 99" in content + + +@pytest.mark.asyncio +async def test_pull_init_uses_existing_pyproject_toml( + mock_http: respx.MockRouter, fs: FakeFilesystem, capsys: pytest.CaptureFixture[str] +) -> None: + # Arrange: existing pyproject.toml, no ruff.toml or .ruff.toml + directory = fs.create_dir("project_with_pyproject") + target_dir = pathlib.Path(directory.path) # type: ignore[arg-type] + pyproject_path = target_dir / "pyproject.toml" + + fs.create_file(str(pyproject_path), contents="[tool.ruff]\nline-length = 79\n") + + upstream = URL("https://example.org/pyproject.toml") + mock_http.get(str(upstream)).respond(200, text="[tool.ruff]\nline-length = 88\n") + + # Act + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=True, + ) + ) + + # Assert: command succeeded, existing pyproject.toml was used as target + assert result == 0 + captured = capsys.readouterr() + assert "pyproject.toml" in captured.out + + assert pyproject_path.exists() + assert (target_dir / "ruff.toml").exists() is False + assert (target_dir / ".ruff.toml").exists() is False + + contents = pyproject_path.read_text() + assert "line-length = 88" in contents + + +@pytest.mark.asyncio +async def test_pull_prefers_dot_ruff_toml_over_pyproject_toml( + mock_http: respx.MockRouter, fs: FakeFilesystem, capsys: pytest.CaptureFixture[str] +) -> None: + # Arrange: both pyproject.toml and .ruff.toml exist + directory = fs.create_dir("project_with_pyproject_and_dot_ruff") + target_dir = pathlib.Path(directory.path) # type: ignore[arg-type] + pyproject_path = target_dir / "pyproject.toml" + dot_ruff_path = target_dir / ".ruff.toml" + + fs.create_file(str(pyproject_path), contents="[tool.ruff]\nline-length = 79\n") + fs.create_file(str(dot_ruff_path), contents='target-version = "py310"\nline-length = 100\n') + + upstream = URL("https://example.org/ruff.toml") + mock_http.get(str(upstream)).respond(200, text='target-version = "py310"\nline-length = 88\n') + + # Act: no --init, should update existing config, preferring .ruff.toml + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=False, + ) + ) + + # Assert: command succeeded and .ruff.toml was selected and updated + assert result == 0 + captured = capsys.readouterr() + assert ".ruff.toml" in captured.out + + assert pyproject_path.exists() + assert dot_ruff_path.exists() + + pyproject_contents = pyproject_path.read_text() + dot_ruff_contents = dot_ruff_path.read_text() + + # pyproject.toml should remain unchanged + assert "line-length = 79" in pyproject_contents + # .ruff.toml should be updated with the upstream contents + assert "line-length = 88" in dot_ruff_contents + + +@pytest.mark.asyncio +async def test_pull_updates_existing_dot_ruff_toml( + mock_http: respx.MockRouter, fs: FakeFilesystem, capsys: pytest.CaptureFixture[str] +) -> None: + # Arrange: only .ruff.toml exists + directory = fs.create_dir("project_with_dot_ruff_only") + target_dir = pathlib.Path(directory.path) # type: ignore[arg-type] + dot_ruff_path = target_dir / ".ruff.toml" + + fs.create_file(str(dot_ruff_path), contents='target-version = "py310"\nline-length = 79\n') + + upstream = URL("https://example.org/ruff.toml") + mock_http.get(str(upstream)).respond(200, text='target-version = "py310"\nline-length = 120\n') + + # Act + result = await ruff_sync.pull( + ruff_sync.Arguments( + command="pull", + upstream=upstream, + source=target_dir, + exclude=(), + verbose=0, + init=False, + ) + ) + + # Assert: command succeeded, .ruff.toml was updated, and no new files created + assert result == 0 + captured = capsys.readouterr() + assert ".ruff.toml" in captured.out + + assert dot_ruff_path.exists() + assert (target_dir / "ruff.toml").exists() is False + assert (target_dir / "pyproject.toml").exists() is False + + contents = dot_ruff_path.read_text() + assert "line-length = 120" in contents + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/test_url_parsing.py b/tests/test_url_parsing.py new file mode 100644 index 00000000..ebbd3ed6 --- /dev/null +++ b/tests/test_url_parsing.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +from ruff_sync import is_ruff_toml_file + + +@pytest.mark.parametrize( + "path_or_url,expected", + [ + ("ruff.toml", True), + (".ruff.toml", True), + ("configs/ruff.toml", True), + ("pyproject.toml", False), + ("https://example.com/ruff.toml", True), + ("https://example.com/ruff.toml?ref=main", True), + ("https://example.com/ruff.toml#L10", True), + ("https://example.com/path/to/ruff.toml?query=1#frag", True), + ("https://example.com/pyproject.toml?file=ruff.toml", False), + ("https://example.com/ruff.toml/other", False), + # Case where it's not a URL but has query/fragment characters + ("ruff.toml?raw=1", True), + ("ruff.toml#section", True), + ], +) +def test_is_ruff_toml_file(path_or_url: str, expected: bool): + assert is_ruff_toml_file(path_or_url) is expected + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/uv.lock b/uv.lock index dfcde184..a26bd279 100644 --- a/uv.lock +++ b/uv.lock @@ -990,7 +990,7 @@ wheels = [ [[package]] name = "ruff-sync" -version = "0.0.3" +version = "0.0.4.dev1" source = { editable = "." } dependencies = [ { name = "httpx" },