Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ruff-sync"
version = "0.0.4"
version = "0.0.5.dev1"
description = "Synchronize Ruff linter configuration across projects"
keywords = ["ruff", "linter", "config", "synchronize", "python", "linting", "automation", "tomlkit"]
authors = [
Expand Down
95 changes: 80 additions & 15 deletions ruff_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from tomlkit.items import Table
from tomlkit.toml_file import TOMLFile

__version__ = "0.0.4"
__version__ = "0.0.5.dev1"

_DEFAULT_EXCLUDE: Final[set[str]] = {"lint.per-file-ignores"}
_GITHUB_REPO_PATH_PARTS_COUNT: Final[int] = 2
Expand Down Expand Up @@ -216,17 +216,35 @@ def _get_cli_parser() -> ArgumentParser:
return parser


def _get_target_path(path: str | None) -> str:
"""Resolve the target path for configuration files.

If the path indicates a .toml file, it's treated as a direct file path.
Otherwise, it appends 'pyproject.toml' to the path.
"""
if not path:
return "pyproject.toml"

# Use PurePosixPath to handle URL-style paths consistently
posix_path = pathlib.PurePosixPath(path.strip("/"))
if posix_path.suffix == ".toml":
return str(posix_path)

return str(posix_path / "pyproject.toml")


def _convert_github_url(url: URL, branch: str = "main", path: str = "") -> URL:
"""Convert a GitHub URL to its corresponding raw content URL.

Supports:
- Blob URLs: https://github.com/org/repo/blob/branch/path/to/file
- Repo URLs: https://github.com/org/repo (defaults to {branch}/{path}/pyproject.toml)
- Repo URLs: https://github.com/org/repo (defaults to {branch}/{path}/pyproject.toml if path
doesn't end in .toml)

Args:
url (URL): The GitHub URL to be converted.
branch (str): The default branch to use for repo URLs.
path (str): The directory prefix for pyproject.toml.
path (str): The directory prefix for pyproject.toml, or a direct path to a .toml file.

Returns:
URL: The corresponding raw content URL.
Expand All @@ -243,10 +261,10 @@ def _convert_github_url(url: URL, branch: str = "main", path: str = "") -> URL:
path_parts = [p for p in url.path.split("/") if p]
if len(path_parts) == _GITHUB_REPO_PATH_PARTS_COUNT:
org, repo = path_parts
target_path = f"{path.strip('/')}/pyproject.toml" if path else "pyproject.toml"
target_path = _get_target_path(path)
raw_url = url.copy_with(
host=_GITHUB_RAW_HOST,
path=f"/{org}/{repo}/{branch}/{target_path}",
path=str(pathlib.PurePosixPath("/", org, repo, branch, target_path)),
)
LOGGER.info(f"Converting GitHub repo URL to raw content URL: {raw_url}")
return raw_url
Expand All @@ -260,12 +278,13 @@ def _convert_gitlab_url(url: URL, branch: str = "main", path: str = "") -> URL:

Supports:
- Blob URLs: https://gitlab.com/org/repo/-/blob/branch/path/to/file
- Repo URLs: https://gitlab.com/org/repo (defaults to {branch}/{path}/pyproject.toml)
- Repo URLs: https://gitlab.com/org/repo (defaults to {branch}/{path}/pyproject.toml if path
doesn't end in .toml)

Args:
url (URL): The GitLab URL to be converted.
branch (str): The default branch to use for repo URLs.
path (str): The directory prefix for pyproject.toml.
path (str): The directory prefix for pyproject.toml, or a direct path to a .toml file.

Returns:
URL: The corresponding raw content URL.
Expand All @@ -283,8 +302,10 @@ def _convert_gitlab_url(url: URL, branch: str = "main", path: str = "") -> URL:
# Avoid empty paths or just a slash
path_prefix = url.path.rstrip("/")
if path_prefix:
target_path = f"{path.strip('/')}/pyproject.toml" if path else "pyproject.toml"
raw_url = url.copy_with(path=f"{path_prefix}/-/raw/{branch}/{target_path}")
target_path = _get_target_path(path)
raw_url = url.copy_with(
path=str(pathlib.PurePosixPath(path_prefix, "-", "raw", branch, target_path))
)
LOGGER.info(f"Converting GitLab repo URL to raw content URL: {raw_url}")
return raw_url

Expand All @@ -297,6 +318,32 @@ def is_git_url(url: URL) -> bool:
return str(url).startswith("git@") or url.scheme in ("ssh", "git", "git+ssh")


def to_git_url(url: URL) -> URL | None:
"""
Attempt to convert a browser or raw URL to a git (SSH) URL.

Supports GitHub and GitLab.
"""
if is_git_url(url):
return url

if url.host in _GITHUB_HOSTS or url.host == _GITHUB_RAW_HOST:
path_parts = [p for p in url.path.split("/") if p]
if len(path_parts) >= _GITHUB_REPO_PATH_PARTS_COUNT:
org, repo = path_parts[:_GITHUB_REPO_PATH_PARTS_COUNT]
repo = repo.removesuffix(".git")
return URL(f"git@github.com:{org}/{repo}.git")

if url.host in _GITLAB_HOSTS:
path = url.path.strip("/")
project_path = path.split("/-/")[0] if "/-/" in path else path
if project_path:
project_path = project_path.removesuffix(".git")
return URL(f"git@{url.host}:{project_path}.git")

return None


def resolve_raw_url(url: URL, branch: str = "main", path: str | None = None) -> URL:
"""Convert a GitHub or GitLab repository/blob URL to a raw content URL.

Expand Down Expand Up @@ -367,11 +414,7 @@ def _git_clone_and_read() -> str:
capture_output=True,
text=True,
)
target_path = (
pathlib.Path(path.strip("/")) / "pyproject.toml"
if path
else pathlib.Path("pyproject.toml")
)
target_path = pathlib.Path(_get_target_path(path))

# Restore just the pyproject_toml file
restore_cmd = [
Expand Down Expand Up @@ -424,7 +467,29 @@ def _git_clone_and_read() -> str:
content = await asyncio.to_thread(_git_clone_and_read)
return StringIO(content)

return await download(url, client)
try:
return await download(url, client)
except httpx.HTTPStatusError as e:
msg = f"HTTP error {e.response.status_code} when downloading from {url}"
git_url = to_git_url(url)
if git_url:
# sys.argv[1] might be -v or something else when running via pytest
try:
cmd = sys.argv[1]
if cmd not in ("pull", "check"):
cmd = "pull"
except IndexError:
cmd = "pull"
msg += (
f"\n\n💡 Check the URL and your permissions. "
"You might want to try cloning via git instead:\n\n"
f" ruff-sync {cmd} {git_url}"
)
else:
msg += "\n\n💡 Check the URL and your permissions."

# Re-raise with a more helpful message while preserving the original exception context
raise httpx.HTTPStatusError(msg, request=e.request, response=e.response) from None


def is_ruff_toml_file(path_or_url: str) -> bool:
Expand Down
109 changes: 107 additions & 2 deletions tests/test_url_handling.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
from __future__ import annotations

import httpx
import pytest
from httpx import URL
from httpx import URL, AsyncClient

from ruff_sync import resolve_raw_url
from ruff_sync import fetch_upstream_config, is_ruff_toml_file, resolve_raw_url, to_git_url


@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


@pytest.mark.parametrize(
Expand Down Expand Up @@ -111,3 +134,85 @@ def test_raw_url_with_branch_and_path(input_url: str, branch: str, path: str, ex
url = URL(input_url)
result = resolve_raw_url(url, branch=branch, path=path)
assert str(result) == expected_url


@pytest.mark.parametrize(
"input_url, expected_git_url",
[
# GitHub Browser URLs
(
"https://github.com/pydantic/pydantic/blob/main/pyproject.toml",
"git@github.com:pydantic/pydantic.git",
),
(
"https://github.com/org/repo/blob/develop/config/ruff.toml",
"git@github.com:org/repo.git",
),
# GitHub Repo URLs
(
"https://github.com/pydantic/pydantic",
"git@github.com:pydantic/pydantic.git",
),
# GitHub Raw URLs
(
"https://raw.githubusercontent.com/pydantic/pydantic/main/pyproject.toml",
"git@github.com:pydantic/pydantic.git",
),
# GitLab Repo URLs
(
"https://gitlab.com/gitlab-org/gitlab",
"git@gitlab.com:gitlab-org/gitlab.git",
),
(
"https://gitlab.com/gitlab-org/nested/group/sub-a/sub-b/project",
"git@gitlab.com:gitlab-org/nested/group/sub-a/sub-b/project.git",
),
# GitLab Blob URLs
(
"https://gitlab.com/gitlab-org/gitlab/-/blob/master/pyproject.toml",
"git@gitlab.com:gitlab-org/gitlab.git",
),
# Already git URLs
(
"git@github.com:org/repo.git",
"git@github.com:org/repo.git",
),
# Non-matching URLs
(
"https://example.com/pyproject.toml",
None,
),
],
)
def test_to_git_url(input_url: str, expected_git_url: str | None):
url = URL(input_url)
result = to_git_url(url)
if expected_git_url is None:
assert result is None
else:
assert str(result) == expected_git_url


@pytest.mark.asyncio
async def test_fetch_upstream_config_http_error_with_git_suggestion(monkeypatch):
url = URL("https://github.com/org/repo/blob/main/pyproject.toml")

async def mock_get(*args, **kwargs):
# Create a mock response with 404 status
request = httpx.Request("GET", url)
response = httpx.Response(404, request=request)
response.raise_for_status()

# We need to mock the client's get method
# Since fetch_upstream_config uses the client passed to it

async with AsyncClient() as client:
monkeypatch.setattr(client, "get", mock_get)

with pytest.raises(httpx.HTTPStatusError) as excinfo:
await fetch_upstream_config(url, client, branch="main", path="")

error_msg = str(excinfo.value)
assert "HTTP error 404" in error_msg
assert "git@github.com:org/repo.git" in error_msg
assert "ruff-sync pull" in error_msg
31 changes: 0 additions & 31 deletions tests/test_url_parsing.py

This file was deleted.

2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading