diffrag 0.2.3__tar.gz → 0.3.0__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 (26) hide show
  1. {diffrag-0.2.3 → diffrag-0.3.0}/PKG-INFO +1 -1
  2. {diffrag-0.2.3 → diffrag-0.3.0}/pyproject.toml +1 -1
  3. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/openai_compat.py +54 -31
  4. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/cli/main.py +1 -0
  5. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/config/settings.py +7 -0
  6. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/data/config.example.toml +4 -0
  7. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/reviewer.py +25 -6
  8. {diffrag-0.2.3 → diffrag-0.3.0}/LICENSE +0 -0
  9. {diffrag-0.2.3 → diffrag-0.3.0}/README.md +0 -0
  10. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/__init__.py +0 -0
  11. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/__main__.py +0 -0
  12. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/__init__.py +0 -0
  13. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/base.py +0 -0
  14. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/ai/ollama.py +0 -0
  15. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/cli/__init__.py +0 -0
  16. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/config/__init__.py +0 -0
  17. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/__init__.py +0 -0
  18. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/git.py +0 -0
  19. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/diff/models.py +0 -0
  20. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/exceptions.py +0 -0
  21. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/__init__.py +0 -0
  22. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/indexer.py +0 -0
  23. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/indexing/splitter.py +0 -0
  24. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/py.typed +0 -0
  25. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/__init__.py +0 -0
  26. {diffrag-0.2.3 → diffrag-0.3.0}/src/diffrag/review/prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: diffrag
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: AI-powered git diff analysis and code review with RAG context
5
5
  Keywords: code-review,git,ai,llm,rag,openai
6
6
  Author: Eduardo Nunez
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "diffrag"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  description = "AI-powered git diff analysis and code review with RAG context"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -1,7 +1,9 @@
1
1
  """OpenAI-compatible REST client (works with OpenWebUI, Ollama /v1, OpenAI, etc.)."""
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import logging
6
+ import random
5
7
 
6
8
  import httpx
7
9
 
@@ -10,6 +12,9 @@ from .base import ChatMessage, CompletionResponse, EmbeddingResponse
10
12
 
11
13
  log = logging.getLogger(__name__)
12
14
 
15
+ _MAX_RETRIES = 4
16
+ _RETRY_BASE_DELAY = 1.0
17
+
13
18
 
14
19
  class OpenAICompatClient:
15
20
  """HTTP client for any OpenAI-compatible ``/chat/completions`` + ``/embeddings`` API.
@@ -86,37 +91,55 @@ class OpenAICompatClient:
86
91
  content = ""
87
92
  model = self._model
88
93
  prompt_tokens = completion_tokens = 0
89
- try:
90
- async with (
91
- httpx.AsyncClient(timeout=self._timeout) as client,
92
- client.stream("POST", url, json=payload, headers=self._headers()) as response,
93
- ):
94
- if not response.is_success:
95
- await response.aread()
96
- response.raise_for_status()
97
- async for line in response.aiter_lines():
98
- if not line.startswith("data: "):
99
- continue
100
- chunk_str = line[6:]
101
- if chunk_str == "[DONE]":
102
- break
103
- try:
104
- chunk = json.loads(chunk_str)
105
- except json.JSONDecodeError:
106
- continue
107
- model = chunk.get("model", model)
108
- for choice in chunk.get("choices", []):
109
- content += choice.get("delta", {}).get("content", "")
110
- if usage := chunk.get("usage"):
111
- prompt_tokens = usage.get("prompt_tokens", 0)
112
- completion_tokens = usage.get("completion_tokens", 0)
113
- except httpx.HTTPStatusError as exc:
114
- raise AIClientError(
115
- f"API request to {url} failed with status {exc.response.status_code}: "
116
- f"{exc.response.text[:200]}"
117
- ) from exc
118
- except httpx.RequestError as exc:
119
- raise AIClientError(f"Network error reaching {url}: {exc}") from exc
94
+ for attempt in range(_MAX_RETRIES + 1):
95
+ content = ""
96
+ model = self._model
97
+ prompt_tokens = completion_tokens = 0
98
+ try:
99
+ async with (
100
+ httpx.AsyncClient(timeout=self._timeout) as client,
101
+ client.stream("POST", url, json=payload, headers=self._headers()) as response,
102
+ ):
103
+ if not response.is_success:
104
+ await response.aread()
105
+ response.raise_for_status()
106
+ async for line in response.aiter_lines():
107
+ if not line.startswith("data: "):
108
+ continue
109
+ chunk_str = line[6:]
110
+ if chunk_str == "[DONE]":
111
+ break
112
+ try:
113
+ chunk = json.loads(chunk_str)
114
+ except json.JSONDecodeError:
115
+ continue
116
+ model = chunk.get("model", model)
117
+ for choice in chunk.get("choices", []):
118
+ content += choice.get("delta", {}).get("content", "")
119
+ if usage := chunk.get("usage"):
120
+ prompt_tokens = usage.get("prompt_tokens", 0)
121
+ completion_tokens = usage.get("completion_tokens", 0)
122
+ break # success
123
+ except httpx.HTTPStatusError as exc:
124
+ if exc.response.status_code == 429 and attempt < _MAX_RETRIES:
125
+ delay = float(
126
+ exc.response.headers.get("retry-after", _RETRY_BASE_DELAY * 2**attempt)
127
+ )
128
+ delay += random.uniform(0, delay * 0.1)
129
+ log.warning(
130
+ "Rate-limited (429). Waiting %.1f s before retry %d/%d",
131
+ delay,
132
+ attempt + 1,
133
+ _MAX_RETRIES,
134
+ )
135
+ await asyncio.sleep(delay)
136
+ else:
137
+ raise AIClientError(
138
+ f"API request to {url} failed with status {exc.response.status_code}: "
139
+ f"{exc.response.text[:200]}"
140
+ ) from exc
141
+ except httpx.RequestError as exc:
142
+ raise AIClientError(f"Network error reaching {url}: {exc}") from exc
120
143
 
121
144
  return CompletionResponse(
122
145
  content=content,
@@ -97,6 +97,7 @@ def _build_reviewer(
97
97
  similarity_top_k=settings.review.similarity_top_k,
98
98
  max_prompt_tokens=settings.review.max_prompt_tokens,
99
99
  verbosity=verbosity,
100
+ concurrency=settings.review.concurrency,
100
101
  )
101
102
  return reviewer, None
102
103
 
@@ -117,18 +117,25 @@ class ReviewSettings:
117
117
  max_prompt_tokens: Approximate token budget per diff chunk before splitting.
118
118
  verbosity: Detail level of generated reviews. One of ``"brief"``,
119
119
  ``"standard"`` (default), or ``"detailed"``.
120
+ concurrency: Maximum number of file reviews sent to the AI concurrently.
121
+ Higher values reduce wall-clock time but increase peak API load.
120
122
  """
121
123
 
122
124
  base_branch: str = "main"
123
125
  similarity_top_k: int = 5
124
126
  max_prompt_tokens: int = 6000
125
127
  verbosity: str = "standard"
128
+ concurrency: int = 3
126
129
 
127
130
  def __post_init__(self) -> None:
128
131
  if self.verbosity not in ("brief", "standard", "detailed"):
129
132
  raise ConfigurationError(
130
133
  f"Invalid verbosity {self.verbosity!r}. Choose 'brief', 'standard', or 'detailed'."
131
134
  )
135
+ if self.concurrency < 1:
136
+ raise ConfigurationError(
137
+ f"Invalid concurrency {self.concurrency!r}. Must be 1 or greater."
138
+ )
132
139
 
133
140
 
134
141
  @dataclass
@@ -51,3 +51,7 @@ similarity_top_k = 5
51
51
  max_prompt_tokens = 6000
52
52
  # Detail level of generated reviews: "brief" | "standard" | "detailed"
53
53
  verbosity = "standard"
54
+ # Maximum number of file reviews sent to the AI simultaneously.
55
+ # Higher values reduce wall-clock time; lower values reduce peak API load.
56
+ # Set to 1 to restore the original sequential behaviour.
57
+ concurrency = 3
@@ -1,5 +1,6 @@
1
1
  """CodeReviewer: orchestrates diff extraction, RAG retrieval, and LLM review."""
2
2
 
3
+ import asyncio
3
4
  import logging
4
5
  from collections.abc import Callable
5
6
  from dataclasses import dataclass, field
@@ -79,6 +80,7 @@ class CodeReviewer:
79
80
  indexer: Vector index for context retrieval.
80
81
  similarity_top_k: Number of context chunks retrieved per diff chunk.
81
82
  max_prompt_tokens: Approximate token budget per diff before hunk-splitting.
83
+ concurrency: Maximum number of file reviews sent to the AI simultaneously.
82
84
  """
83
85
 
84
86
  def __init__(
@@ -88,12 +90,14 @@ class CodeReviewer:
88
90
  similarity_top_k: int = 5,
89
91
  max_prompt_tokens: int = 6000,
90
92
  verbosity: str = "standard",
93
+ concurrency: int = 3,
91
94
  ) -> None:
92
95
  self._ai = ai_client
93
96
  self._indexer = indexer
94
97
  self._top_k = similarity_top_k
95
98
  self._max_prompt_tokens = max_prompt_tokens
96
99
  self._verbosity = verbosity
100
+ self._concurrency = concurrency
97
101
 
98
102
  # ------------------------------------------------------------------
99
103
  # Public API
@@ -157,13 +161,28 @@ class CodeReviewer:
157
161
  repo.path, on_progress=on_progress, commit_hash=commit_hash
158
162
  )
159
163
 
164
+ completed = 0
165
+ sem = asyncio.Semaphore(self._concurrency)
166
+
167
+ async def _bounded(fd: FileDiff) -> list[FileReview]:
168
+ nonlocal completed
169
+ async with sem:
170
+ _p(f"Reviewing {fd.path}...")
171
+ log.info("Reviewing %s (%s)", fd.path, fd.status)
172
+ result = await self._review_file(fd)
173
+ completed += 1
174
+ _p(f"Reviewed {fd.path} ({completed}/{n})")
175
+ return result
176
+
177
+ gathered = await asyncio.gather(
178
+ *[_bounded(fd) for fd in diff_result.file_diffs],
179
+ return_exceptions=True,
180
+ )
160
181
  file_reviews: list[FileReview] = []
161
- for i, file_diff in enumerate(diff_result.file_diffs, 1):
162
- _p(f"Reviewing {file_diff.path} ({i}/{n})...")
163
- log.info("Reviewing %s (%s)", file_diff.path, file_diff.status)
164
- reviews = await self._review_file(file_diff)
165
- file_reviews.extend(reviews)
166
- _p(f"Reviewed {file_diff.path} ({i}/{n})")
182
+ for r in gathered:
183
+ if isinstance(r, BaseException):
184
+ raise r
185
+ file_reviews.extend(r)
167
186
 
168
187
  if n > 1:
169
188
  _p("Consolidating review...")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes