diffsage 1.2.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 (67) hide show
  1. diffsage/__init__.py +0 -0
  2. diffsage/__main__.py +4 -0
  3. diffsage/cli.py +38 -0
  4. diffsage/commands/__init__.py +0 -0
  5. diffsage/commands/ask.py +62 -0
  6. diffsage/commands/auth.py +153 -0
  7. diffsage/commands/commit.py +125 -0
  8. diffsage/commands/config.py +216 -0
  9. diffsage/commands/doctor.py +18 -0
  10. diffsage/config/__init__.py +0 -0
  11. diffsage/config/defaults.py +20 -0
  12. diffsage/config/file_loader.py +21 -0
  13. diffsage/config/loader.py +8 -0
  14. diffsage/config/paths.py +61 -0
  15. diffsage/config/resolver.py +118 -0
  16. diffsage/config/schema.py +53 -0
  17. diffsage/config/scope.py +8 -0
  18. diffsage/config/settings.py +9 -0
  19. diffsage/exceptions/__init__.py +34 -0
  20. diffsage/exceptions/base.py +32 -0
  21. diffsage/exceptions/credentials.py +8 -0
  22. diffsage/exceptions/git.py +9 -0
  23. diffsage/exceptions/provider.py +17 -0
  24. diffsage/git/__init__.py +0 -0
  25. diffsage/git/client.py +155 -0
  26. diffsage/logging/__init__.py +0 -0
  27. diffsage/logging/formatter.py +0 -0
  28. diffsage/logging/logger.py +41 -0
  29. diffsage/models/__init__.py +0 -0
  30. diffsage/models/commit_message.py +9 -0
  31. diffsage/models/config.py +16 -0
  32. diffsage/models/credentials.py +8 -0
  33. diffsage/models/doctor.py +13 -0
  34. diffsage/models/git.py +32 -0
  35. diffsage/models/provider.py +20 -0
  36. diffsage/parsers/commit_message_parser.py +43 -0
  37. diffsage/prompts/__init__.py +0 -0
  38. diffsage/providers/__init__.py +0 -0
  39. diffsage/providers/base.py +8 -0
  40. diffsage/providers/factory.py +14 -0
  41. diffsage/providers/gemini_provider.py +77 -0
  42. diffsage/services/__init__.py +0 -0
  43. diffsage/services/ai_service.py +63 -0
  44. diffsage/services/commit_service.py +38 -0
  45. diffsage/services/config_service.py +109 -0
  46. diffsage/services/credentials_service.py +61 -0
  47. diffsage/services/doctor_service.py +47 -0
  48. diffsage/services/editor_service.py +75 -0
  49. diffsage/services/git_service.py +20 -0
  50. diffsage/services/prompt_service.py +60 -0
  51. diffsage/storage/__init__.py +0 -0
  52. diffsage/storage/base.py +0 -0
  53. diffsage/storage/config_repository.py +85 -0
  54. diffsage/storage/credentials_repository.py +121 -0
  55. diffsage/ui/__init__.py +0 -0
  56. diffsage/ui/ask_view.py +10 -0
  57. diffsage/ui/auth_view.py +51 -0
  58. diffsage/ui/base.py +15 -0
  59. diffsage/ui/commit_view.py +100 -0
  60. diffsage/ui/config_view.py +58 -0
  61. diffsage/ui/doctor_view.py +39 -0
  62. diffsage-1.2.0.dist-info/METADATA +471 -0
  63. diffsage-1.2.0.dist-info/RECORD +67 -0
  64. diffsage-1.2.0.dist-info/WHEEL +5 -0
  65. diffsage-1.2.0.dist-info/entry_points.txt +2 -0
  66. diffsage-1.2.0.dist-info/licenses/LICENSE +21 -0
  67. diffsage-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,85 @@
1
+ from pathlib import Path
2
+
3
+ from tomlkit import TOMLDocument, document, parse, table
4
+
5
+
6
+ class ConfigRepository:
7
+ """Persists DiffSage configuration."""
8
+
9
+ _PATH_MAP = {
10
+ "provider": ("ai", "provider"),
11
+ "model": ("ai", "model"),
12
+ "timeout": ("network", "timeout"),
13
+ "max_retries": ("network", "max_retries"),
14
+ "log_level": ("logging", "level"),
15
+ }
16
+
17
+ def __init__(self, path: Path) -> None:
18
+ self._path = path
19
+
20
+ def _load(self) -> TOMLDocument:
21
+ if not self._path.exists():
22
+ return document()
23
+
24
+ content = self._path.read_text()
25
+ doc = parse(content)
26
+
27
+ return doc
28
+
29
+ def _save(self, doc: TOMLDocument) -> None:
30
+ self._path.parent.mkdir(parents=True, exist_ok=True)
31
+
32
+ content = doc.as_string()
33
+ self._path.write_text(content)
34
+
35
+ def list(self) -> dict[str, str | int]:
36
+ doc = self._load()
37
+
38
+ config = {}
39
+
40
+ for key, (section, option) in self._PATH_MAP.items():
41
+ if section in doc and option in doc[section]:
42
+ config[key] = doc[section][option]
43
+
44
+ return config
45
+
46
+ def get(self, key: str) -> str | int | None:
47
+ doc = self._load()
48
+
49
+ section, option = self._PATH_MAP[key]
50
+
51
+ if section not in doc:
52
+ return None
53
+
54
+ if option not in doc[section]:
55
+ return None
56
+
57
+ return doc[section][option]
58
+
59
+ def set(self, key: str, value: str | int) -> None:
60
+ doc = self._load()
61
+ section, option = self._PATH_MAP[key]
62
+
63
+ if section not in doc:
64
+ doc[section] = table()
65
+
66
+ doc[section][option] = value
67
+
68
+ self._save(doc)
69
+
70
+ def unset(self, key: str) -> None:
71
+ doc = self._load()
72
+ section, option = self._PATH_MAP[key]
73
+
74
+ if section not in doc:
75
+ return
76
+
77
+ if option not in doc[section]:
78
+ return
79
+
80
+ del doc[section][option]
81
+
82
+ if not doc[section]:
83
+ del doc[section]
84
+
85
+ self._save(doc)
@@ -0,0 +1,121 @@
1
+ from pathlib import Path
2
+
3
+ from tomlkit import document, parse, table
4
+
5
+ from diffsage.models.credentials import Credential
6
+
7
+
8
+ class CredentialsRepository:
9
+ """Persists DiffSage API keys configuration."""
10
+
11
+ def __init__(self, path: Path) -> None:
12
+ self._path = path
13
+
14
+ def save(self, credential: Credential) -> None:
15
+ if self._path.exists():
16
+ doc = parse(self._path.read_text())
17
+ else:
18
+ doc = document()
19
+
20
+ credentials = doc.get("credentials")
21
+
22
+ if "credentials" not in doc:
23
+ credentials = table()
24
+ doc["credentials"] = credentials
25
+
26
+ provider = credentials.get(credential.provider)
27
+
28
+ if provider is None:
29
+ provider = table()
30
+ credentials[credential.provider] = provider
31
+
32
+ profile = provider.get(credential.name)
33
+
34
+ if profile is None:
35
+ profile = table()
36
+ provider[credential.name] = profile
37
+
38
+ profile["api_key"] = credential.api_key
39
+
40
+ self._path.parent.mkdir(parents=True, exist_ok=True)
41
+ self._path.write_text(doc.as_string())
42
+
43
+ def get(self, provider: str, name: str = "default") -> Credential | None:
44
+ if not self._path.exists():
45
+ return None
46
+
47
+ doc = parse(self._path.read_text())
48
+ credentials = doc.get("credentials")
49
+
50
+ if credentials is None:
51
+ return None
52
+
53
+ provider_table = credentials.get(provider)
54
+ if provider_table is None:
55
+ return None
56
+
57
+ profile = provider_table.get(name)
58
+ if profile is None:
59
+ return None
60
+
61
+ api_key = profile.get("api_key")
62
+ if api_key is None:
63
+ return None
64
+
65
+ return Credential(
66
+ provider=provider,
67
+ name=name,
68
+ api_key=api_key,
69
+ )
70
+
71
+ def list(self) -> list[Credential]:
72
+ if not self._path.exists():
73
+ return []
74
+
75
+ doc = parse(self._path.read_text())
76
+ credentials = doc.get("credentials")
77
+
78
+ if credentials is None:
79
+ return []
80
+
81
+ result: list[Credential] = []
82
+
83
+ for provider_name, provider in credentials.items():
84
+ for profile_name, profile in provider.items():
85
+ api_key = profile.get("api_key")
86
+
87
+ if api_key is None:
88
+ continue
89
+
90
+ result.append(
91
+ Credential(provider=provider_name, name=profile_name, api_key=api_key)
92
+ )
93
+
94
+ return result
95
+
96
+ def delete(self, provider: str, name: str = "default") -> bool:
97
+ if not self._path.exists():
98
+ return False
99
+
100
+ doc = parse(self._path.read_text())
101
+ credentials = doc.get("credentials")
102
+
103
+ if credentials is None:
104
+ return False
105
+
106
+ provider_table = credentials.get(provider)
107
+
108
+ if provider_table is None:
109
+ return False
110
+
111
+ if provider_table.get(name) is None:
112
+ return False
113
+
114
+ del provider_table[name]
115
+
116
+ if not provider_table:
117
+ del credentials[provider]
118
+
119
+ self._path.write_text(doc.as_string())
120
+
121
+ return True
File without changes
@@ -0,0 +1,10 @@
1
+ from diffsage.models.provider import ProviderResponse
2
+ from diffsage.ui.base import BaseView
3
+
4
+
5
+ class AskView(BaseView):
6
+ """Handles terminal rendering for the ask command."""
7
+
8
+ def show_response(self, response: ProviderResponse) -> None:
9
+ self._console.print(f"[green]{response.content}[/green]")
10
+ self._console.print()
@@ -0,0 +1,51 @@
1
+ from rich.panel import Panel
2
+ from rich.table import Table
3
+
4
+ from diffsage.models.credentials import Credential
5
+ from diffsage.ui.base import BaseView
6
+
7
+
8
+ class AuthView(BaseView):
9
+ """Handles terminal rendering for the auth command."""
10
+
11
+ def show_success(self, message: str) -> None:
12
+ self._console.print(f"[bold green]✓ {message}[bold green]")
13
+
14
+ def show_credential(self, credential: Credential) -> None:
15
+ table = Table(show_header=False, box=False, expand=False)
16
+
17
+ table.add_row("Provider", credential.provider)
18
+ table.add_row("Profile", credential.name)
19
+ table.add_row("API Key", self._mask_api_key(credential.api_key))
20
+
21
+ self._console.print(
22
+ Panel(
23
+ table,
24
+ title="[bold cyan]DiffSage Credential[/bold cyan]",
25
+ expand=False,
26
+ )
27
+ )
28
+
29
+ def show_credentials(self, credentials: list[Credential]) -> None:
30
+ table = Table(show_header=False, box=False, expand=False)
31
+
32
+ for credential in credentials:
33
+ table.add_row(
34
+ credential.provider,
35
+ credential.name,
36
+ )
37
+
38
+ self._console.print(
39
+ Panel(
40
+ table,
41
+ title="[bold cyan]DiffSage Credential[/bold cyan]",
42
+ expand=False,
43
+ )
44
+ )
45
+
46
+ @staticmethod
47
+ def _mask_api_key(api_key: str) -> str:
48
+ if len(api_key) <= 8:
49
+ return "••••••••"
50
+
51
+ return f"{api_key[:4]}••••••••{api_key[-4:]}"
diffsage/ui/base.py ADDED
@@ -0,0 +1,15 @@
1
+ from rich.console import Console
2
+
3
+
4
+ class BaseView:
5
+ def __init__(self) -> None:
6
+ self._console = Console()
7
+
8
+ def show_error(self, message: str) -> None:
9
+ self._console.print(f"[red]✗ {message}[/red]")
10
+
11
+ def show_warning(self, message: str) -> None:
12
+ self._console.print(f"[yellow]! {message}[/yellow]")
13
+
14
+ def show_info(self, message: str) -> None:
15
+ self._console.print(message)
@@ -0,0 +1,100 @@
1
+ from contextlib import contextmanager
2
+
3
+ from rich.console import Group
4
+ from rich.panel import Panel
5
+ from rich.rule import Rule
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ from diffsage.models.commit_message import CommitMessage
10
+ from diffsage.ui.base import BaseView
11
+
12
+
13
+ class CommitView(BaseView):
14
+ """Handles terminal rendering for the commit command."""
15
+
16
+ @contextmanager
17
+ def generating(self):
18
+ with self._console.status("Generating commit message..."):
19
+ yield
20
+
21
+ def show_generated(self) -> None:
22
+ self._console.print("[green]✓ Commit message generated successfully.[/green]")
23
+
24
+ def show_not_git_repository(self):
25
+ self._console.print("[red]Not inside a Git repository[/red]")
26
+
27
+ def show_no_staged_changes(self):
28
+ self._console.print("[yellow]No staged changes found[/yellow]")
29
+
30
+ def show_commit(self, message: CommitMessage) -> None:
31
+ grid = Table.grid(expand=True)
32
+ grid.add_column(style="cyan", width=10)
33
+ grid.add_column()
34
+
35
+ if message.type:
36
+ grid.add_row("Type", message.type)
37
+
38
+ if message.scope:
39
+ grid.add_row("Scope", message.scope)
40
+
41
+ grid.add_row("Subject", message.subject)
42
+
43
+ body = Text()
44
+
45
+ if message.body:
46
+ for i, line in enumerate(message.body):
47
+ line = line.strip()
48
+ if not line:
49
+ continue
50
+
51
+ if line.startswith("- "):
52
+ line = line.removeprefix("- ")
53
+
54
+ body.append(f"• {line}")
55
+ if i < len(message.body) - 1:
56
+ body.append("\n")
57
+
58
+ content = Group(
59
+ grid,
60
+ Rule(style="cyan"),
61
+ body,
62
+ )
63
+ panel = Panel(
64
+ content,
65
+ title="[bold cyan]Commit Message[/bold cyan]",
66
+ expand=False,
67
+ )
68
+ else:
69
+ panel = Panel(
70
+ grid,
71
+ title="[bold cyan]Suggested Commit Message[/bold cyan]",
72
+ expand=False,
73
+ )
74
+
75
+ self._console.print()
76
+ self._console.print(panel)
77
+
78
+ def prompt_action(self) -> str:
79
+ self._console.print()
80
+
81
+ return self._console.input(
82
+ "[bold cyan][Y][/bold cyan] Commit (default) "
83
+ "[bold cyan][E][/bold cyan] Edit "
84
+ "[bold cyan][R][/bold cyan] Regenerate "
85
+ "[bold cyan][N][/bold cyan] Cancel: "
86
+ )
87
+
88
+ def show_success(self, message: str) -> None:
89
+ self._console.print()
90
+ self._console.print("[bold green]✓ Commit created successfully.[/bold green]")
91
+ self._console.print()
92
+ self._console.print("[dim]Commit:[/dim]")
93
+ self._console.print(message)
94
+ self._console.print()
95
+
96
+ def show_cancelled(self) -> None:
97
+ self._console.print("[yellow]Cancelled.[/yellow]")
98
+
99
+ def show_invalid_option(self) -> None:
100
+ self._console.print("[red]Invalid option. Please choose Y, E, R or N.[/red]")
@@ -0,0 +1,58 @@
1
+ from pathlib import Path
2
+
3
+ from rich.panel import Panel
4
+ from rich.table import Table
5
+
6
+ from diffsage.models.config import ConfigReport, ConfigValueReport
7
+ from diffsage.ui.base import BaseView
8
+
9
+
10
+ class ConfigView(BaseView):
11
+ """Handles terminal rendering for the config command."""
12
+
13
+ def _display_value(self, value: str | int | None) -> str:
14
+ if value is None:
15
+ return "None"
16
+
17
+ return str(value)
18
+
19
+ def show_configuration(self, report: ConfigReport) -> None:
20
+ table = Table(show_header=False, box=False, expand=False)
21
+
22
+ table.add_row("Provider", self._display_value(report.provider))
23
+ table.add_row("Model", self._display_value(report.model))
24
+ table.add_row("Timeout", self._display_value(report.timeout))
25
+ table.add_row("Max Retries", self._display_value(report.max_retries))
26
+ table.add_row("Log Level", self._display_value(report.log_level))
27
+
28
+ self._console.print(
29
+ Panel(
30
+ table,
31
+ title="[bold cyan]DiffSage Configuration[/bold cyan]",
32
+ expand=False,
33
+ )
34
+ )
35
+
36
+ def show_value(self, report: ConfigValueReport) -> None:
37
+ self._console.print(
38
+ Panel(
39
+ self._display_value(report.value),
40
+ title=f"[bold cyan]{report.key.replace('_', ' ').title()}[/bold cyan]",
41
+ expand=False,
42
+ )
43
+ )
44
+
45
+ def show_success(self, message: str) -> None:
46
+ self._console.print()
47
+ self._console.print(f"[bold green]✓ {message}[bold green]")
48
+ self._console.print()
49
+
50
+ def show_path(self, path: Path) -> None:
51
+ display = str(path).replace(str(Path.home()), "~")
52
+
53
+ self._console.print("[dim]Location:[/]")
54
+ self._console.print(
55
+ f"[cyan]{display}[/]",
56
+ no_wrap=True,
57
+ )
58
+ self._console.print()
@@ -0,0 +1,39 @@
1
+ import typer
2
+
3
+ from diffsage.config.paths import get_log_file_path
4
+ from diffsage.models.doctor import DoctorReport
5
+
6
+
7
+ class DoctorView:
8
+ def _status_icon(self, status: bool) -> str:
9
+ """Return a status icon for terminal output."""
10
+
11
+ return "✓" if status else "✗"
12
+
13
+ def show_report(self, report: DoctorReport) -> None:
14
+ typer.echo()
15
+ typer.echo("DiffSage Doctor")
16
+ typer.echo()
17
+
18
+ typer.echo(f"{self._status_icon(True)} Python Version : {report.python_version}")
19
+
20
+ git_version = report.git_version or "Not Found"
21
+ typer.echo(f"{self._status_icon(report.git_installed)} Git Installed : {git_version}")
22
+
23
+ virtual_env_status = "Active" if report.virtual_environment else "Inactive"
24
+ typer.echo(
25
+ f"{self._status_icon(report.virtual_environment)} Virtual Env. : {virtual_env_status}"
26
+ )
27
+
28
+ typer.echo(
29
+ f"{self._status_icon(report.configuration_loaded)} "
30
+ f"Configuration : {report.configuration_loaded}"
31
+ )
32
+
33
+ typer.echo()
34
+
35
+ typer.echo(f"Provider : {report.provider}")
36
+ typer.echo(f"Timeout : {report.timeout}")
37
+ typer.echo(f"Max Retries : {report.max_retries}")
38
+ typer.echo(f"Log Level : {report.log_level}")
39
+ typer.echo(f"Diffsage Log : {get_log_file_path()}")