pretty-release-notes 1.4.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.
Files changed (37) hide show
  1. pretty_release_notes/__init__.py +38 -0
  2. pretty_release_notes/__main__.py +6 -0
  3. pretty_release_notes/adapters/__init__.py +1 -0
  4. pretty_release_notes/adapters/cli_progress.py +25 -0
  5. pretty_release_notes/api.py +239 -0
  6. pretty_release_notes/core/__init__.py +1 -0
  7. pretty_release_notes/core/config.py +166 -0
  8. pretty_release_notes/core/config_loader.py +221 -0
  9. pretty_release_notes/core/execution.py +74 -0
  10. pretty_release_notes/core/interfaces.py +35 -0
  11. pretty_release_notes/database.py +152 -0
  12. pretty_release_notes/generator.py +317 -0
  13. pretty_release_notes/github_client.py +232 -0
  14. pretty_release_notes/main.py +245 -0
  15. pretty_release_notes/models/__init__.py +15 -0
  16. pretty_release_notes/models/_utils.py +47 -0
  17. pretty_release_notes/models/change.py +34 -0
  18. pretty_release_notes/models/commit.py +75 -0
  19. pretty_release_notes/models/issue.py +17 -0
  20. pretty_release_notes/models/pull_request.py +208 -0
  21. pretty_release_notes/models/release_notes.py +241 -0
  22. pretty_release_notes/models/release_notes_line.py +36 -0
  23. pretty_release_notes/models/repository.py +20 -0
  24. pretty_release_notes/openai_client.py +165 -0
  25. pretty_release_notes/prompt.txt +34 -0
  26. pretty_release_notes/py.typed +0 -0
  27. pretty_release_notes/setup_command.py +336 -0
  28. pretty_release_notes/ui.py +46 -0
  29. pretty_release_notes/web/__init__.py +1 -0
  30. pretty_release_notes/web/app.py +193 -0
  31. pretty_release_notes/web/server.py +11 -0
  32. pretty_release_notes-1.4.0.dist-info/METADATA +335 -0
  33. pretty_release_notes-1.4.0.dist-info/RECORD +37 -0
  34. pretty_release_notes-1.4.0.dist-info/WHEEL +5 -0
  35. pretty_release_notes-1.4.0.dist-info/entry_points.txt +2 -0
  36. pretty_release_notes-1.4.0.dist-info/licenses/LICENSE +7 -0
  37. pretty_release_notes-1.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,74 @@
1
+ """Execution strategies for parallel processing."""
2
+
3
+ import threading
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Callable
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from typing import Any
8
+
9
+
10
+ class ExecutionStrategy(ABC):
11
+ """Abstract strategy for parallel execution."""
12
+
13
+ @abstractmethod
14
+ def execute_parallel(
15
+ self,
16
+ tasks: list[Callable[[], Any]],
17
+ ) -> list[Any]:
18
+ """Execute tasks in parallel and return results."""
19
+ pass
20
+
21
+
22
+ class ThreadingStrategy(ExecutionStrategy):
23
+ """Original threading implementation for backward compatibility."""
24
+
25
+ def execute_parallel(
26
+ self,
27
+ tasks: list[Callable[[], Any]],
28
+ ) -> list[Any]:
29
+ threads = []
30
+ results = [None] * len(tasks)
31
+
32
+ def run_task(index: int, task: Callable[[], Any]):
33
+ results[index] = task()
34
+
35
+ for i, task in enumerate(tasks):
36
+ thread = threading.Thread(target=run_task, args=(i, task))
37
+ threads.append(thread)
38
+ thread.start()
39
+
40
+ for thread in threads:
41
+ thread.join()
42
+
43
+ return results
44
+
45
+
46
+ class ThreadPoolStrategy(ExecutionStrategy):
47
+ """Thread pool implementation for better resource management."""
48
+
49
+ def __init__(self, max_workers: int = 10):
50
+ self.max_workers = max_workers
51
+
52
+ def execute_parallel(
53
+ self,
54
+ tasks: list[Callable[[], Any]],
55
+ ) -> list[Any]:
56
+ if not tasks:
57
+ return []
58
+
59
+ results = []
60
+ with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
61
+ futures = [executor.submit(task) for task in tasks]
62
+ for future in as_completed(futures):
63
+ results.append(future.result())
64
+ return results
65
+
66
+
67
+ class SequentialStrategy(ExecutionStrategy):
68
+ """Sequential execution for debugging or environments without threading."""
69
+
70
+ def execute_parallel(
71
+ self,
72
+ tasks: list[Callable[[], Any]],
73
+ ) -> list[Any]:
74
+ return [task() for task in tasks]
@@ -0,0 +1,35 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class ProgressEvent:
8
+ type: str # "info", "success", "error", "markdown"
9
+ message: str
10
+ metadata: dict[str, Any] | None = None
11
+
12
+
13
+ class ProgressReporter(ABC):
14
+ @abstractmethod
15
+ def report(self, event: ProgressEvent) -> None:
16
+ """Report a progress event."""
17
+ pass
18
+
19
+
20
+ class NullProgressReporter(ProgressReporter):
21
+ """No-op reporter for library usage."""
22
+
23
+ def report(self, event: ProgressEvent) -> None:
24
+ pass
25
+
26
+
27
+ class CompositeProgressReporter(ProgressReporter):
28
+ """Combine multiple reporters."""
29
+
30
+ def __init__(self, reporters: list[ProgressReporter]):
31
+ self.reporters = reporters
32
+
33
+ def report(self, event: ProgressEvent) -> None:
34
+ for reporter in self.reporters:
35
+ reporter.report(event)
@@ -0,0 +1,152 @@
1
+ import sqlite3
2
+ import threading
3
+ from contextlib import contextmanager
4
+ from csv import DictReader, DictWriter
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .models import Repository
10
+
11
+
12
+ class Database:
13
+ def __init__(self, path: Path):
14
+ self.path = path
15
+
16
+ def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
17
+ pass
18
+
19
+ def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
20
+ pass
21
+
22
+ def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
23
+ pass
24
+
25
+
26
+ class CSVDatabase(Database):
27
+ columns = ["owner", "repo", "pr_no", "sentence"]
28
+
29
+ def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
30
+ if not self.path.exists():
31
+ return None
32
+
33
+ sentence = None
34
+ with open(self.path) as f:
35
+ reader = DictReader(f, fieldnames=self.columns)
36
+ for row in reader:
37
+ if row["owner"] == repository.owner and row["repo"] == repository.name and row["pr_no"] == pr_no:
38
+ sentence = row["sentence"]
39
+
40
+ return sentence
41
+
42
+ def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
43
+ write_header = not self.path.exists()
44
+ with open(self.path, "a") as f:
45
+ writer = DictWriter(f, self.columns)
46
+
47
+ if write_header:
48
+ writer.writeheader()
49
+
50
+ writer.writerow(
51
+ {
52
+ "owner": repository.owner,
53
+ "repo": repository.name,
54
+ "pr_no": pr_no,
55
+ "sentence": sentence,
56
+ }
57
+ )
58
+
59
+ def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
60
+ with open(self.path) as f:
61
+ reader = DictReader(f, fieldnames=self.columns)
62
+ rows = [
63
+ row
64
+ for row in reader
65
+ if row["owner"] != repository.owner or row["repo"] != repository.name or row["pr_no"] != pr_no
66
+ ]
67
+
68
+ with open(self.path, "w") as f:
69
+ writer = DictWriter(f, self.columns)
70
+ writer.writeheader()
71
+ writer.writerows(rows)
72
+
73
+
74
+ class SQLiteDatabase(Database):
75
+ """Thread-safe SQLite database with thread-local connections."""
76
+
77
+ def __init__(self, path: Path):
78
+ self.path = path
79
+ self._lock = threading.Lock()
80
+ self._local = threading.local()
81
+
82
+ @property
83
+ def connection(self):
84
+ """Thread-local database connection."""
85
+ if not hasattr(self._local, "conn"):
86
+ self._local.conn = sqlite3.connect(self.path)
87
+ self._local.cursor = self._local.conn.cursor()
88
+ self._create_table()
89
+ return self._local.conn
90
+
91
+ @property
92
+ def cursor(self):
93
+ """Thread-local cursor."""
94
+ _ = self.connection # Ensure connection exists
95
+ return self._local.cursor
96
+
97
+ @contextmanager
98
+ def transaction(self):
99
+ """Context manager for transactions with locking."""
100
+ with self._lock:
101
+ try:
102
+ yield self.cursor
103
+ self.connection.commit()
104
+ except Exception:
105
+ self.connection.rollback()
106
+ raise
107
+
108
+ def get_sentence(self, repository: "Repository", pr_no: str) -> str | None:
109
+ # Read operations don't need locking, just use thread-local connection
110
+ self.cursor.execute(
111
+ "SELECT sentence FROM sentences WHERE owner = ? AND repo = ? AND pr_no = ? ORDER BY rowid DESC LIMIT 1",
112
+ (repository.owner, repository.name, pr_no),
113
+ )
114
+ result = self.cursor.fetchone()
115
+ return result[0] if result else None
116
+
117
+ def store_sentence(self, repository: "Repository", pr_no: str, sentence: str) -> None:
118
+ with self.transaction():
119
+ self.cursor.execute(
120
+ "INSERT INTO sentences (owner, repo, pr_no, sentence) VALUES (?, ?, ?, ?)",
121
+ (repository.owner, repository.name, pr_no, sentence),
122
+ )
123
+
124
+ def delete_sentence(self, repository: "Repository", pr_no: str) -> None:
125
+ with self.transaction():
126
+ self.cursor.execute(
127
+ "DELETE FROM sentences WHERE owner = ? AND repo = ? AND pr_no = ?",
128
+ (repository.owner, repository.name, pr_no),
129
+ )
130
+
131
+ def _create_table(self):
132
+ """Create table and index if they don't exist."""
133
+ self.cursor.execute("CREATE TABLE IF NOT EXISTS sentences (owner TEXT, repo TEXT, pr_no TEXT, sentence TEXT)")
134
+ self.cursor.execute("CREATE INDEX IF NOT EXISTS idx_owner_repo_pr_no ON sentences (owner, repo, pr_no)")
135
+ self.connection.commit()
136
+
137
+
138
+ def get_db(db_type: str, db_name: str) -> Database:
139
+ db_path = Path(db_name)
140
+
141
+ # If path is relative (not absolute), use ~/.pretty-release-notes/ as base directory
142
+ if not db_path.is_absolute():
143
+ base_dir = Path.home() / ".pretty-release-notes"
144
+ base_dir.mkdir(parents=True, exist_ok=True)
145
+ db_path = base_dir / db_name
146
+
147
+ if db_type == "csv":
148
+ return CSVDatabase(db_path.with_suffix(".csv"))
149
+ elif db_type == "sqlite":
150
+ return SQLiteDatabase(db_path.with_suffix(".sqlite"))
151
+ else:
152
+ raise ValueError(f"Invalid database type: {db_type}")
@@ -0,0 +1,317 @@
1
+ import time
2
+ from collections.abc import Callable
3
+ from functools import partial
4
+ from typing import Any, cast
5
+
6
+ from requests import HTTPError
7
+
8
+ from .core.config import ReleaseNotesConfig
9
+ from .core.execution import ExecutionStrategy, ThreadPoolStrategy
10
+ from .core.interfaces import NullProgressReporter, ProgressEvent, ProgressReporter
11
+ from .database import get_db
12
+ from .github_client import GitHubClient
13
+ from .models import ReleaseNotes, ReleaseNotesLine, Repository
14
+ from .openai_client import format_model_name, get_chat_response
15
+
16
+
17
+ def _unwrap_retry_error(error: Exception) -> Exception:
18
+ while True:
19
+ last_attempt = getattr(error, "last_attempt", None)
20
+ exception_getter = cast(Callable[[], object] | None, getattr(last_attempt, "exception", None))
21
+ if exception_getter is None:
22
+ return error
23
+ last_exception: object = exception_getter()
24
+ if not isinstance(last_exception, Exception) or last_exception is error:
25
+ return error
26
+ error = last_exception
27
+
28
+
29
+ def _format_error(error: Exception) -> str:
30
+ unwrapped_error = _unwrap_retry_error(error)
31
+ error_message = str(unwrapped_error).strip()
32
+ if not error_message:
33
+ return unwrapped_error.__class__.__name__
34
+ return f"{unwrapped_error.__class__.__name__}: {error_message}"
35
+
36
+
37
+ def _normalize_whitespace(value: str) -> str:
38
+ return " ".join(value.split())
39
+
40
+
41
+ def _describe_change(change: object) -> str:
42
+ title = getattr(change, "title", None)
43
+ if isinstance(title, str) and title.strip():
44
+ pr_id = getattr(change, "id", None)
45
+ prefix = f"PR #{pr_id}" if pr_id is not None else "PR"
46
+ return f"{prefix}: {_normalize_whitespace(title)}"
47
+
48
+ message = getattr(change, "message", None)
49
+ if isinstance(message, str) and message.strip():
50
+ commit_id = getattr(change, "id", None)
51
+ short_commit_id = str(commit_id)[:7] if commit_id else None
52
+ prefix = f"Commit {short_commit_id}" if short_commit_id else "Commit"
53
+ first_line = _normalize_whitespace(message.splitlines()[0])
54
+ return f"{prefix}: {first_line}"
55
+
56
+ html_url = getattr(change, "html_url", None)
57
+ if isinstance(html_url, str) and html_url.strip():
58
+ return html_url
59
+
60
+ return type(change).__name__
61
+
62
+
63
+ class ReleaseNotesGenerator:
64
+ def __init__(
65
+ self,
66
+ config: ReleaseNotesConfig,
67
+ progress_reporter: ProgressReporter | None = None,
68
+ execution_strategy: ExecutionStrategy | None = None,
69
+ ):
70
+ self.config = config
71
+ self.github = GitHubClient(config.github.token)
72
+ self.repository: Repository | None = None
73
+ self.progress = progress_reporter or NullProgressReporter()
74
+ self.execution = execution_strategy or ThreadPoolStrategy()
75
+
76
+ # Store config values for easy access
77
+ self.exclude_change_types = config.filters.exclude_change_types
78
+ self.exclude_change_labels = config.filters.exclude_change_labels
79
+ self.exclude_authors = config.filters.exclude_authors
80
+ self.grouping = config.grouping
81
+ self.llm_api_key = config.llm.api_key
82
+ self.llm_model = config.llm.model
83
+ self.max_patch_size = config.llm.max_patch_size
84
+ self.reasoning_effort = config.llm.reasoning_effort
85
+ self.prompt_path = config.prompt_path
86
+ self.db_type = config.database.type
87
+ self.db_name = config.database.name
88
+ self.db_read_enabled = config.database.read_enabled
89
+ self.db_write_enabled = config.database.write_enabled
90
+ self.force_use_commits = config.force_use_commits
91
+
92
+ def initialize_repository(self, owner: str, name: str):
93
+ self.repository = self.github.get_repository(owner, name)
94
+
95
+ def generate(self, tag: str, previous_tag_name: str | None = None) -> str:
96
+ """Generate release notes for a given tag.
97
+
98
+ Args:
99
+ tag: Git tag for the release
100
+ previous_tag_name: Optional previous tag to use as starting point
101
+ """
102
+ assert self.repository is not None, "Repository must be initialized before generating release notes"
103
+ release = self._get_release(tag)
104
+ old_body = release["body"]
105
+
106
+ self.progress.report(
107
+ ProgressEvent(
108
+ type="release_notes",
109
+ message=old_body,
110
+ metadata={"heading": "Current Release Notes"},
111
+ )
112
+ )
113
+
114
+ if not previous_tag_name:
115
+ previous_tag_name = get_previous_tag_name(old_body, self.repository)
116
+
117
+ try:
118
+ gh_notes = self.github.generate_release_notes(self.repository, tag, previous_tag_name)
119
+ new_body = gh_notes["body"]
120
+ self.progress.report(
121
+ ProgressEvent(
122
+ type="release_notes",
123
+ message=new_body,
124
+ metadata={"heading": "Regenerated by GitHub"},
125
+ )
126
+ )
127
+ except HTTPError as e:
128
+ if e.response.status_code not in [403, 404]:
129
+ raise e
130
+
131
+ self.progress.report(
132
+ ProgressEvent(
133
+ type="error",
134
+ message="No permission to regenerate release notes, trying to proceed with old ones.",
135
+ )
136
+ )
137
+ new_body = old_body
138
+
139
+ self.progress.report(ProgressEvent(type="markdown", message="# Rewriting ..."))
140
+
141
+ release_notes = ReleaseNotes.from_string(new_body)
142
+ pr_start_time = time.time()
143
+ self._get_prs_for_lines(release_notes.lines)
144
+ pr_end_time = time.time()
145
+ self.progress.report(
146
+ ProgressEvent(
147
+ type="success",
148
+ message=f"Downloaded PRs in {pr_end_time - pr_start_time:.2f} seconds.",
149
+ )
150
+ )
151
+
152
+ if self.force_use_commits or (
153
+ not any(line.change for line in release_notes.lines) and "Full Changelog" in new_body
154
+ ):
155
+ release_notes.lines = self._get_commit_lines(tag, previous_tag_name) + release_notes.lines
156
+
157
+ process_start_time = time.time()
158
+ self._process_lines(release_notes.lines)
159
+ process_end_time = time.time()
160
+ self.progress.report(
161
+ ProgressEvent(
162
+ type="success",
163
+ message=f"Processed lines in {process_end_time - process_start_time:.2f} seconds.",
164
+ )
165
+ )
166
+
167
+ reviewers_start_time = time.time()
168
+ release_notes.load_reviewers()
169
+ reviewers_end_time = time.time()
170
+ self.progress.report(
171
+ ProgressEvent(
172
+ type="success",
173
+ message=f"Loaded reviewers in {reviewers_end_time - reviewers_start_time:.2f} seconds.",
174
+ )
175
+ )
176
+
177
+ return release_notes.serialize(
178
+ self.exclude_change_types,
179
+ self.exclude_change_labels,
180
+ self.exclude_authors,
181
+ grouping=self.grouping,
182
+ model_name=format_model_name(self.llm_model),
183
+ )
184
+
185
+ def _get_prs_for_lines(self, lines: list["ReleaseNotesLine"]) -> None:
186
+ """Download info for all PRs in parallel."""
187
+ tasks: list[Callable[[], Any]] = [
188
+ partial(self._get_pr_for_line, line) for line in lines if line.pr_no and not line.is_new_contributor
189
+ ]
190
+
191
+ if tasks:
192
+ self.execution.execute_parallel(tasks)
193
+
194
+ def _get_pr_for_line(self, line: "ReleaseNotesLine"):
195
+ """Download info for a single PR."""
196
+ assert self.repository is not None, "Repository must be initialized"
197
+ if line.pr_no and not line.is_new_contributor:
198
+ line.change = self.github.get_pr(self.repository, line.pr_no)
199
+
200
+ def _process_lines(self, lines: list["ReleaseNotesLine"]) -> None:
201
+ """Process all lines in parallel."""
202
+ prompt_template = self.prompt_path.read_text()
203
+ tasks: list[Callable[[], Any]] = [partial(self._process_line, line, prompt_template) for line in lines]
204
+ if tasks:
205
+ self.execution.execute_parallel(tasks)
206
+
207
+ def update_on_github(self, new_body: str, tag: str):
208
+ """Update release notes on GitHub."""
209
+ assert self.repository is not None, "Repository must be initialized before updating release notes"
210
+ release = self._get_release(tag)
211
+ try:
212
+ self.github.update_release(self.repository, release["id"], new_body)
213
+ self.progress.report(ProgressEvent(type="success", message="Release notes updated successfully."))
214
+ except HTTPError as e:
215
+ if e.response.status_code != 403:
216
+ raise e
217
+
218
+ self.progress.report(
219
+ ProgressEvent(
220
+ type="error",
221
+ message="No permission to update release notes, skipping.",
222
+ )
223
+ )
224
+
225
+ def _get_commit_lines(self, tag: str, previous_tag_name: str | None = None):
226
+ assert self.repository is not None, "Repository must be initialized"
227
+ if previous_tag_name:
228
+ commits = self.github.get_diff_commits(self.repository, tag, previous_tag_name)
229
+ else:
230
+ commits = self.github.get_tag_commits(self.repository, tag)
231
+
232
+ return (
233
+ [
234
+ # Add the same heading line for the commits, that is automatically added by GitHub for PRs
235
+ ReleaseNotesLine(
236
+ original_line="## What's Changed",
237
+ )
238
+ ]
239
+ + [
240
+ ReleaseNotesLine(
241
+ original_line="",
242
+ change=commit,
243
+ )
244
+ for commit in commits
245
+ ]
246
+ + [
247
+ # Add an empty line to separate commits from full changelog
248
+ ReleaseNotesLine(
249
+ original_line="",
250
+ )
251
+ ]
252
+ )
253
+
254
+ def _process_line(self, line: "ReleaseNotesLine", prompt_template: str):
255
+ assert self.repository is not None, "Repository must be initialized"
256
+ needs_db = self.db_read_enabled or self.db_write_enabled
257
+ db = get_db(self.db_type, self.db_name) if needs_db else None
258
+
259
+ if not line.change or line.is_new_contributor:
260
+ self.progress.report(ProgressEvent(type="markdown", message=str(line)))
261
+ return
262
+
263
+ if line.change.conventional_type in self.exclude_change_types:
264
+ return
265
+
266
+ if line.change.labels and line.change.labels & self.exclude_change_labels:
267
+ return
268
+
269
+ change_id = line.change.get_summary_key()
270
+ if db and self.db_read_enabled:
271
+ stored_sentence = db.get_sentence(self.repository, change_id)
272
+ if stored_sentence:
273
+ line.sentence = stored_sentence
274
+ self.progress.report(ProgressEvent(type="markdown", message=str(line)))
275
+ return
276
+
277
+ prompt = line.change.get_prompt(
278
+ prompt_template=prompt_template,
279
+ max_patch_size=self.max_patch_size,
280
+ )
281
+
282
+ try:
283
+ change_summary = get_chat_response(
284
+ content=prompt,
285
+ model=self.llm_model,
286
+ api_key=self.llm_api_key,
287
+ reasoning_effort=self.reasoning_effort,
288
+ )
289
+ except Exception as e:
290
+ error_msg = (
291
+ f"LLM API error ({format_model_name(self.llm_model)}) "
292
+ f"for {_describe_change(line.change)}: {_format_error(e)}"
293
+ )
294
+ self.progress.report(ProgressEvent(type="error", message=error_msg))
295
+ return
296
+
297
+ if not change_summary:
298
+ return
299
+
300
+ change_summary = change_summary.lstrip(" -")
301
+ if db and self.db_write_enabled:
302
+ db.store_sentence(self.repository, change_id, change_summary)
303
+ line.sentence = change_summary
304
+ self.progress.report(ProgressEvent(type="markdown", message=str(line)))
305
+
306
+ def _get_release(self, tag: str):
307
+ assert self.repository is not None, "Repository must be initialized"
308
+ return self.github.get_release(self.repository, tag)
309
+
310
+
311
+ def get_previous_tag_name(release_notes_body: str, repository: Repository) -> str | None:
312
+ compare_url = f"https://github.com/{repository.owner}/{repository.name}/compare/"
313
+ pos = release_notes_body.find(compare_url)
314
+ if pos != -1:
315
+ return release_notes_body[pos + len(compare_url) :].split("...")[0]
316
+
317
+ return None