diffron 0.1.0__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.
diffron/lemonade.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ Lemonade integration for Diffron.
3
+
4
+ Provides automatic port detection and API client for Lemonade LLM server.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional, List
9
+ from openai import OpenAI
10
+
11
+ from .utils import scan_ports, is_port_open
12
+
13
+
14
+ DEFAULT_HOST = "localhost"
15
+ DEFAULT_PORT = 8020 # Default Lemonade port
16
+ DEFAULT_PORTS = [8020, 8000, 8001, 8080, 8081, 5000, 5001]
17
+ LEMONADE_API_KEY = "lemonade" # Default API key for Lemonade
18
+ DEFAULT_MODEL = "qwen2.5-it-3b-FLM" # Default Lemonade model
19
+
20
+
21
+ def detect_lemonade_port(
22
+ host: Optional[str] = None,
23
+ ports: Optional[List[int]] = None
24
+ ) -> Optional[int]:
25
+ """
26
+ Auto-detect the port where Lemonade is running.
27
+
28
+ Checks LEMONADE_SERVER_URL environment variable first, then scans common ports.
29
+
30
+ Args:
31
+ host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
32
+ ports: List of ports to scan. Defaults to common Lemonade ports.
33
+
34
+ Returns:
35
+ Port number if found, None otherwise.
36
+ """
37
+ # Check LEMONADE_SERVER_URL environment variable first (standard Lemonade env)
38
+ server_url = os.environ.get("LEMONADE_SERVER_URL")
39
+ if server_url:
40
+ try:
41
+ # Parse URL like "http://localhost:8020"
42
+ from urllib.parse import urlparse
43
+ parsed = urlparse(server_url)
44
+ if parsed.port:
45
+ port = parsed.port
46
+ if is_port_open(parsed.hostname or DEFAULT_HOST, port):
47
+ return port
48
+ except Exception:
49
+ pass
50
+
51
+ # Check DIFFRON_LEMONADE_PORT (Diffron-specific)
52
+ env_port = os.environ.get("DIFFRON_LEMONADE_PORT")
53
+ if env_port:
54
+ try:
55
+ port = int(env_port)
56
+ if is_port_open(host or DEFAULT_HOST, port):
57
+ return port
58
+ except ValueError:
59
+ pass
60
+
61
+ # Scan common ports
62
+ host = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
63
+ ports_to_scan = ports or DEFAULT_PORTS
64
+
65
+ open_ports = scan_ports(ports_to_scan, host)
66
+
67
+ if open_ports:
68
+ return open_ports[0] # Return first open port
69
+
70
+ return None
71
+
72
+
73
+ def get_lemonade_url() -> str:
74
+ """
75
+ Get the Lemonade server URL from environment or default.
76
+
77
+ Returns:
78
+ Lemonade server URL.
79
+ """
80
+ # Check LEMONADE_SERVER_URL first (standard)
81
+ server_url = os.environ.get("LEMONADE_SERVER_URL")
82
+ if server_url:
83
+ return server_url.strip().rstrip("/")
84
+
85
+ # Build URL from host/port
86
+ host = os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
87
+ port = detect_lemonade_port(host)
88
+
89
+ if port:
90
+ return f"http://{host}:{port}"
91
+
92
+ # Fallback to default
93
+ return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
94
+
95
+
96
+ def is_lemonade_running(
97
+ host: Optional[str] = None,
98
+ port: Optional[int] = None
99
+ ) -> bool:
100
+ """
101
+ Check if Lemonade is running.
102
+
103
+ Args:
104
+ host: Host to check. Defaults to DIFFRON_LEMONADE_HOST or localhost.
105
+ port: Port to check. Auto-detects if not provided.
106
+
107
+ Returns:
108
+ True if Lemonade is running, False otherwise.
109
+ """
110
+ if port is None:
111
+ port = detect_lemonade_port(host)
112
+ if port is None:
113
+ return False
114
+
115
+ host = host or os.environ.get("DIFFRON_LEMONADE_HOST", DEFAULT_HOST)
116
+ return is_port_open(host, port)
117
+
118
+
119
+ class LemonadeClient:
120
+ """
121
+ Client for interacting with Lemonade LLM server.
122
+
123
+ Automatically detects the running Lemonade instance and provides
124
+ a convenient interface for chat completions.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ base_url: Optional[str] = None,
130
+ api_key: Optional[str] = None,
131
+ model: Optional[str] = None,
132
+ ):
133
+ """
134
+ Initialize the Lemonade client.
135
+
136
+ Args:
137
+ base_url: Lemonade server URL. Auto-detects from LEMONADE_SERVER_URL or defaults to http://localhost:8020.
138
+ api_key: API key. Defaults to "lemonade".
139
+ model: Model name to use. Defaults to qwen2.5-it-3b-FLM.
140
+ """
141
+ # Get base URL from parameter, environment, or default
142
+ if base_url:
143
+ self.base_url = base_url.rstrip("/")
144
+ else:
145
+ self.base_url = get_lemonade_url()
146
+
147
+ # Parse host and port from base_url
148
+ from urllib.parse import urlparse
149
+ parsed = urlparse(self.base_url)
150
+ self.host = parsed.hostname or DEFAULT_HOST
151
+ self.port = parsed.port or DEFAULT_PORT
152
+
153
+ self.api_key = api_key or LEMONADE_API_KEY
154
+
155
+ # Model: parameter > environment > default
156
+ self.model = model or os.environ.get("DIFFRON_MODEL") or DEFAULT_MODEL
157
+
158
+ self.client = OpenAI(
159
+ base_url=f"{self.base_url}/api/v1",
160
+ api_key=self.api_key,
161
+ )
162
+
163
+ def chat_completion(
164
+ self,
165
+ messages: List[dict],
166
+ max_tokens: int = 100,
167
+ temperature: float = 0.2,
168
+ **kwargs
169
+ ) -> str:
170
+ """
171
+ Generate a chat completion.
172
+
173
+ Args:
174
+ messages: List of message dicts with 'role' and 'content'.
175
+ max_tokens: Maximum tokens to generate.
176
+ temperature: Sampling temperature.
177
+ **kwargs: Additional arguments to pass to the API.
178
+
179
+ Returns:
180
+ Generated response content.
181
+ """
182
+ response = self.client.chat.completions.create(
183
+ model=self.model,
184
+ messages=messages,
185
+ max_tokens=max_tokens,
186
+ temperature=temperature,
187
+ **kwargs,
188
+ )
189
+ return response.choices[0].message.content
190
+
191
+ def __repr__(self) -> str:
192
+ return f"LemonadeClient(base_url='{self.base_url}', model='{self.model}')"
diffron/lemonade.pyi ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Lemonade integration for Diffron.
3
+
4
+ Provides automatic port detection and API client for Lemonade LLM server.
5
+ """
6
+
7
+ from typing import Optional, List
8
+
9
+
10
+ DEFAULT_HOST: str
11
+ DEFAULT_PORTS: List[int]
12
+ LEMONADE_API_KEY: str
13
+
14
+
15
+ def detect_lemonade_port(
16
+ host: Optional[str] = None,
17
+ ports: Optional[List[int]] = None
18
+ ) -> Optional[int]:
19
+ """Auto-detect the port where Lemonade is running."""
20
+ ...
21
+
22
+
23
+ def is_lemonade_running(
24
+ host: Optional[str] = None,
25
+ port: Optional[int] = None
26
+ ) -> bool:
27
+ """Check if Lemonade is running."""
28
+ ...
29
+
30
+
31
+ class LemonadeClient:
32
+ """Client for interacting with Lemonade LLM server."""
33
+
34
+ host: str
35
+ port: int
36
+ api_key: str
37
+ model: str
38
+ base_url: str
39
+ client: OpenAI
40
+
41
+ def __init__(
42
+ self,
43
+ host: Optional[str] = None,
44
+ port: Optional[int] = None,
45
+ api_key: Optional[str] = None,
46
+ model: Optional[str] = None,
47
+ ):
48
+ """Initialize the Lemonade client."""
49
+ ...
50
+
51
+ def _detect_model(self) -> str:
52
+ """Auto-detect available models from Lemonade."""
53
+ ...
54
+
55
+ def chat_completion(
56
+ self,
57
+ messages: List[dict],
58
+ max_tokens: int = 100,
59
+ temperature: float = 0.2,
60
+ **kwargs
61
+ ) -> str:
62
+ """Generate a chat completion."""
63
+ ...
64
+
65
+ def __repr__(self) -> str:
66
+ ...
diffron/pr_gen.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ PR description generation for Diffron.
3
+
4
+ Generates PR titles and descriptions from branch diffs.
5
+ """
6
+
7
+ import os
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Tuple
10
+
11
+ from .lemonade import LemonadeClient
12
+ from .utils import get_branch_diff, get_commit_log, get_current_branch, find_default_branch
13
+
14
+
15
+ DEFAULT_MAX_CHARS = 5000
16
+ DEFAULT_MAX_TOKENS = 300
17
+ DEFAULT_TEMPERATURE = 0.3
18
+
19
+
20
+ @dataclass
21
+ class PRDescription:
22
+ """Represents a generated PR title and description."""
23
+
24
+ title: str
25
+ description: str
26
+
27
+ def format_output(self) -> str:
28
+ """
29
+ Format as TITLE + DESCRIPTION output.
30
+
31
+ Returns:
32
+ Formatted string with TITLE and DESCRIPTION sections.
33
+ """
34
+ return f"TITLE: {self.title}\n\nDESCRIPTION:\n{self.description}"
35
+
36
+ def to_github_cli(self) -> Tuple[str, str]:
37
+ """
38
+ Get title and body for GitHub CLI.
39
+
40
+ Returns:
41
+ Tuple of (title, body).
42
+ """
43
+ return self.title, self.description
44
+
45
+
46
+ def generate_pr_description(
47
+ branch: Optional[str] = None,
48
+ base: Optional[str] = None,
49
+ max_chars: Optional[int] = None,
50
+ max_tokens: Optional[int] = None,
51
+ temperature: Optional[float] = None,
52
+ client: Optional[LemonadeClient] = None,
53
+ ) -> PRDescription:
54
+ """
55
+ Generate a PR title and description from branch changes.
56
+
57
+ Args:
58
+ branch: Branch name to analyze. Defaults to current branch.
59
+ base: Base branch to compare against. Defaults to main/master.
60
+ max_chars: Maximum characters of diff to send. Defaults to 5000.
61
+ max_tokens: Maximum tokens to generate. Defaults to 300.
62
+ temperature: Sampling temperature. Defaults to 0.3.
63
+ client: LemonadeClient instance. Creates new one if not provided.
64
+
65
+ Returns:
66
+ PRDescription with generated title and description.
67
+
68
+ Raises:
69
+ ValueError: If no differences found between branches.
70
+ ConnectionError: If Lemonade is not running.
71
+ """
72
+ max_chars = max_chars or int(os.environ.get("DIFFRON_MAX_DIFF_CHARS", DEFAULT_MAX_CHARS))
73
+ max_tokens = max_tokens or DEFAULT_MAX_TOKENS
74
+ temperature = temperature if temperature is not None else DEFAULT_TEMPERATURE
75
+
76
+ # Get branch name if not provided
77
+ if branch is None:
78
+ branch = get_current_branch()
79
+ if branch is None:
80
+ raise ValueError("Not in a git repository or no current branch.")
81
+
82
+ # Get base branch if not provided
83
+ if base is None:
84
+ base = find_default_branch()
85
+
86
+ # Get commit log and diff
87
+ commit_log = get_commit_log(branch, base)
88
+ diff = get_branch_diff(branch, base, max_chars=max_chars)
89
+
90
+ if not diff.strip() and not commit_log.strip():
91
+ raise ValueError(f"No differences found between {base} and {branch}.")
92
+
93
+ # Create client if not provided
94
+ if client is None:
95
+ client = LemonadeClient()
96
+
97
+ # Build prompt
98
+ prompt = (
99
+ "Generate a GitHub PR title and description based on these changes.\n"
100
+ "Format strictly as:\n"
101
+ "TITLE: <concise, descriptive title>\n"
102
+ "DESCRIPTION: <detailed description with bullet points>\n\n"
103
+ "Requirements:\n"
104
+ "- TITLE should be under 80 characters\n"
105
+ "- DESCRIPTION should summarize the main changes\n"
106
+ "- Use bullet points for key changes\n"
107
+ "- Mention any breaking changes if applicable\n\n"
108
+ )
109
+
110
+ if commit_log:
111
+ prompt += f"Commits:\n{commit_log}\n\n"
112
+
113
+ if diff:
114
+ prompt += f"Diff:\n{diff}"
115
+
116
+ messages = [{"role": "user", "content": prompt}]
117
+
118
+ # Generate completion
119
+ response = client.chat_completion(
120
+ messages=messages,
121
+ max_tokens=max_tokens,
122
+ temperature=temperature,
123
+ )
124
+
125
+ # Parse response
126
+ title, description = _parse_pr_response(response)
127
+
128
+ return PRDescription(title=title, description=description)
129
+
130
+
131
+ def _parse_pr_response(response: str) -> Tuple[str, str]:
132
+ """
133
+ Parse PR response into title and description.
134
+
135
+ Args:
136
+ response: Raw response from LLM.
137
+
138
+ Returns:
139
+ Tuple of (title, description).
140
+ """
141
+ lines = response.strip().split("\n")
142
+
143
+ title = ""
144
+ description_lines = []
145
+ in_description = False
146
+
147
+ for line in lines:
148
+ if line.startswith("TITLE:"):
149
+ title = line.replace("TITLE:", "").strip()
150
+ elif line.startswith("DESCRIPTION:"):
151
+ in_description = True
152
+ desc_part = line.replace("DESCRIPTION:", "").strip()
153
+ if desc_part:
154
+ description_lines.append(desc_part)
155
+ elif in_description:
156
+ description_lines.append(line)
157
+
158
+ # Fallback: if no TITLE found, use first line
159
+ if not title and lines:
160
+ title = lines[0].strip()
161
+ description_lines = lines[1:]
162
+
163
+ # Fallback: if no DESCRIPTION found, use remaining lines
164
+ if not description_lines and len(lines) > 1:
165
+ description_lines = lines[1:]
166
+
167
+ description = "\n".join(description_lines).strip()
168
+
169
+ # Clean up markdown code blocks
170
+ if description.startswith("```"):
171
+ lines = description.split("\n")
172
+ description = "\n".join(
173
+ line for line in lines
174
+ if not line.startswith("```")
175
+ ).strip()
176
+
177
+ return title, description
178
+
179
+
180
+ def create_github_pr(
181
+ branch: Optional[str] = None,
182
+ base: Optional[str] = None,
183
+ auto_submit: bool = False,
184
+ ) -> PRDescription:
185
+ """
186
+ Generate PR and optionally create it via GitHub CLI.
187
+
188
+ Args:
189
+ branch: Branch name. Defaults to current branch.
190
+ base: Base branch. Defaults to main/master.
191
+ auto_submit: If True, creates PR via gh CLI automatically.
192
+
193
+ Returns:
194
+ Generated PRDescription.
195
+
196
+ Raises:
197
+ RuntimeError: If gh CLI is not available when auto_submit=True.
198
+ """
199
+ import subprocess
200
+
201
+ # Generate PR description
202
+ pr = generate_pr_description(branch=branch, base=base)
203
+
204
+ if auto_submit:
205
+ # Check if gh CLI is available
206
+ try:
207
+ result = subprocess.run(
208
+ ["gh", "--version"],
209
+ capture_output=True,
210
+ text=True,
211
+ timeout=10,
212
+ )
213
+ if result.returncode != 0:
214
+ raise RuntimeError("GitHub CLI (gh) is not installed or not in PATH.")
215
+ except FileNotFoundError:
216
+ raise RuntimeError("GitHub CLI (gh) is not installed or not in PATH.")
217
+
218
+ # Create PR
219
+ subprocess.run(
220
+ ["gh", "pr", "create", "--title", pr.title, "--body", pr.description],
221
+ timeout=60,
222
+ )
223
+
224
+ return pr
diffron/pr_gen.pyi ADDED
@@ -0,0 +1,56 @@
1
+ """
2
+ PR description generation for Diffron.
3
+
4
+ Generates PR titles and descriptions from branch diffs.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Tuple
9
+ from .lemonade import LemonadeClient
10
+
11
+
12
+ DEFAULT_MAX_CHARS: int
13
+ DEFAULT_MAX_TOKENS: int
14
+ DEFAULT_TEMPERATURE: float
15
+
16
+
17
+ @dataclass
18
+ class PRDescription:
19
+ """Represents a generated PR title and description."""
20
+
21
+ title: str
22
+ description: str
23
+
24
+ def format_output(self) -> str:
25
+ """Format as TITLE + DESCRIPTION output."""
26
+ ...
27
+
28
+ def to_github_cli(self) -> Tuple[str, str]:
29
+ """Get title and body for GitHub CLI."""
30
+ ...
31
+
32
+
33
+ def generate_pr_description(
34
+ branch: Optional[str] = None,
35
+ base: Optional[str] = None,
36
+ max_chars: Optional[int] = None,
37
+ max_tokens: Optional[int] = None,
38
+ temperature: Optional[float] = None,
39
+ client: Optional[LemonadeClient] = None,
40
+ ) -> PRDescription:
41
+ """Generate a PR title and description from branch changes."""
42
+ ...
43
+
44
+
45
+ def _parse_pr_response(response: str) -> Tuple[str, str]:
46
+ """Parse PR response into title and description."""
47
+ ...
48
+
49
+
50
+ def create_github_pr(
51
+ branch: Optional[str] = None,
52
+ base: Optional[str] = None,
53
+ auto_submit: bool = False,
54
+ ) -> PRDescription:
55
+ """Generate PR and optionally create it via GitHub CLI."""
56
+ ...
diffron/py.typed ADDED
File without changes