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,61 @@
1
+ from pathlib import Path
2
+
3
+ from platformdirs import user_config_path, user_log_path
4
+
5
+ APP_NAME = "DiffSage"
6
+
7
+
8
+ def get_global_config_path() -> Path:
9
+ """Return the global DiffSage configuration file path."""
10
+
11
+ return user_config_path("DiffSage") / "config.toml"
12
+
13
+
14
+ def find_repository_root() -> Path | None:
15
+ """Return the Git repository root or None if not inside a repository."""
16
+ current = Path.cwd()
17
+
18
+ while True:
19
+ if (current / ".git").is_dir():
20
+ return current
21
+
22
+ if current.parent == current:
23
+ return None
24
+
25
+ current = current.parent
26
+
27
+
28
+ def get_local_config_path() -> Path | None:
29
+ """Return the local repository configuration file path."""
30
+
31
+ root = find_repository_root()
32
+
33
+ if root is None:
34
+ return None
35
+
36
+ return root / ".diffsage.toml"
37
+
38
+
39
+ def get_log_directory() -> Path:
40
+ path = user_log_path(APP_NAME)
41
+ path.mkdir(parents=True, exist_ok=True)
42
+ return path
43
+
44
+
45
+ def get_log_file_path() -> Path:
46
+ return get_log_directory() / "diffsage.log"
47
+
48
+
49
+ def ensure_global_config_dir() -> None:
50
+ """Create the global configuration directory if it does not exist."""
51
+
52
+ get_global_config_path().parent.mkdir(
53
+ parents=True,
54
+ exist_ok=True,
55
+ )
56
+
57
+
58
+ def get_credentials_path() -> Path:
59
+ """Return the global DiffSage credentials file path."""
60
+
61
+ return user_config_path(APP_NAME) / "credentials.toml"
@@ -0,0 +1,118 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ from diffsage.config.defaults import DEFAULT_CONFIG
8
+ from diffsage.config.file_loader import load_config
9
+ from diffsage.config.paths import get_global_config_path, get_local_config_path
10
+ from diffsage.config.schema import DiffSageConfig, PartialDiffSageConfig
11
+ from diffsage.config.scope import ConfigScope
12
+ from diffsage.config.settings import Settings
13
+ from diffsage.exceptions import ConfigError
14
+
15
+
16
+ def _merge_dict(
17
+ base: dict,
18
+ override: dict,
19
+ ) -> dict:
20
+ """Recursively merge two dictionaries."""
21
+
22
+ merged = base.copy()
23
+
24
+ for key, value in override.items():
25
+ if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
26
+ merged[key] = _merge_dict(merged[key], value)
27
+ else:
28
+ merged[key] = value
29
+
30
+ return merged
31
+
32
+
33
+ def _merge_config(
34
+ base: DiffSageConfig,
35
+ override: PartialDiffSageConfig,
36
+ ) -> DiffSageConfig:
37
+ """Merge two configuration models."""
38
+
39
+ merged = _merge_dict(
40
+ base.model_dump(),
41
+ override.model_dump(exclude_unset=True),
42
+ )
43
+
44
+ return DiffSageConfig.model_validate(merged)
45
+
46
+
47
+ def _to_settings(config: DiffSageConfig) -> Settings:
48
+ """Convert a configuration model into runtime settings."""
49
+
50
+ return Settings(
51
+ provider=config.ai.provider,
52
+ ai_model=config.ai.model,
53
+ timeout=config.network.timeout,
54
+ max_retries=config.network.max_retries,
55
+ log_level=config.logging.level,
56
+ )
57
+
58
+
59
+ def resolve_settings() -> Settings:
60
+ """Resolve application settings from all configuration sources."""
61
+
62
+ load_dotenv()
63
+
64
+ config = DEFAULT_CONFIG
65
+ global_config_path = get_global_config_path()
66
+
67
+ if global_config_path.exists():
68
+ config = _merge_config(
69
+ config,
70
+ load_config(global_config_path),
71
+ )
72
+
73
+ local_config_path = get_local_config_path()
74
+
75
+ if local_config_path is not None and local_config_path.exists():
76
+ config = _merge_config(config, load_config(local_config_path))
77
+
78
+ settings = _to_settings(config)
79
+
80
+ try:
81
+ return Settings(
82
+ provider=os.getenv(
83
+ "DIFFSAGE_PROVIDER",
84
+ settings.provider,
85
+ ),
86
+ ai_model=os.getenv(
87
+ "DIFFSAGE_AI_MODEL",
88
+ settings.ai_model,
89
+ ),
90
+ timeout=int(
91
+ os.getenv(
92
+ "DIFFSAGE_TIMEOUT",
93
+ str(settings.timeout),
94
+ )
95
+ ),
96
+ max_retries=int(
97
+ os.getenv(
98
+ "DIFFSAGE_MAX_RETRIES",
99
+ str(settings.max_retries),
100
+ )
101
+ ),
102
+ log_level=os.getenv(
103
+ "DIFFSAGE_LOG_LEVEL",
104
+ settings.log_level,
105
+ ),
106
+ )
107
+
108
+ except ValueError as error:
109
+ raise ConfigError("Invalid configuration value.") from error
110
+
111
+
112
+ def resolve_config_path(scope: ConfigScope) -> Path:
113
+ """Return the configuration path for the given scope"""
114
+
115
+ if scope is ConfigScope.GLOBAL:
116
+ return get_global_config_path()
117
+
118
+ return get_local_config_path()
@@ -0,0 +1,53 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class AIConfig(BaseModel):
5
+ provider: str
6
+ model: str
7
+
8
+
9
+ class NetworkConfig(BaseModel):
10
+ timeout: int
11
+ max_retries: int
12
+
13
+
14
+ class LoggingConfig(BaseModel):
15
+ level: str
16
+
17
+
18
+ # Reserved for future multi-provider support.
19
+ #
20
+ # class ProviderCredentials(BaseModel):
21
+ # api_key: str | None = None
22
+ #
23
+ #
24
+ # class ProvidersConfig(BaseModel):
25
+ # gemini: ProviderCredentials
26
+ # openai: ProviderCredentials
27
+ # anthropic: ProviderCredentials
28
+
29
+
30
+ class DiffSageConfig(BaseModel):
31
+ ai: AIConfig
32
+ network: NetworkConfig
33
+ logging: LoggingConfig
34
+
35
+
36
+ class PartialAIConfig(BaseModel):
37
+ provider: str | None = None
38
+ model: str | None = None
39
+
40
+
41
+ class PartialNetworkConfig(BaseModel):
42
+ timeout: int | None = None
43
+ max_retries: int | None = None
44
+
45
+
46
+ class PartialLoggingConfig(BaseModel):
47
+ level: str | None = None
48
+
49
+
50
+ class PartialDiffSageConfig(BaseModel):
51
+ ai: PartialAIConfig | None = None
52
+ network: PartialNetworkConfig | None = None
53
+ logging: PartialLoggingConfig | None = None
@@ -0,0 +1,8 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ConfigScope(str, Enum):
5
+ """Scope of a configuration operation."""
6
+
7
+ GLOBAL = "global"
8
+ LOCAL = "local"
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Settings(BaseModel):
5
+ provider: str
6
+ ai_model: str
7
+ timeout: int
8
+ max_retries: int
9
+ log_level: str
@@ -0,0 +1,34 @@
1
+ from .base import (
2
+ ConfigError,
3
+ DiffSageError,
4
+ GitError,
5
+ InvalidCommitMessageError,
6
+ InvalidConfigurationValueError,
7
+ ProviderError,
8
+ UnknownConfigurationKeyError,
9
+ )
10
+ from .credentials import CredentialNotFoundError
11
+ from .git import NoStagedChangesError, NotGitRepositoryError
12
+ from .provider import (
13
+ AuthenticationError,
14
+ ModelNotFoundError,
15
+ ProviderUnavailableError,
16
+ RateLimitError,
17
+ )
18
+
19
+ __all__ = [
20
+ "DiffSageError",
21
+ "ProviderError",
22
+ "AuthenticationError",
23
+ "RateLimitError",
24
+ "ModelNotFoundError",
25
+ "ProviderUnavailableError",
26
+ "ConfigError",
27
+ "GitError",
28
+ "UnknownConfigurationKeyError",
29
+ "InvalidConfigurationValueError",
30
+ "InvalidCommitMessageError",
31
+ "NoStagedChangesError",
32
+ "NotGitRepositoryError",
33
+ "CredentialNotFoundError",
34
+ ]
@@ -0,0 +1,32 @@
1
+ class DiffSageError(Exception):
2
+ """Base exception for all DiffSage errors."""
3
+
4
+
5
+ class ProviderError(DiffSageError):
6
+ """Raised when an AI provider fails."""
7
+
8
+
9
+ class GitError(DiffSageError):
10
+ """Raised when git operations fail."""
11
+
12
+
13
+ class InvalidCommitMessageError(DiffSageError):
14
+ """Raised when an AI-generated commit message cannot be parsed."""
15
+
16
+
17
+ class ConfigError(DiffSageError):
18
+ """Raised when configuration is invalid."""
19
+
20
+
21
+ class UnknownConfigurationKeyError(ConfigError):
22
+ """Raised when configuration key does not exist."""
23
+
24
+ def __init__(self, key: str) -> None:
25
+ super().__init__(f"Unknown configuration key: '{key}'")
26
+
27
+
28
+ class InvalidConfigurationValueError(ConfigError):
29
+ """Raised when configuration value is of invalid type."""
30
+
31
+ def __init__(self, value: str) -> None:
32
+ super().__init__(f"Invalid configuration value: '{value}'")
@@ -0,0 +1,8 @@
1
+ from diffsage.exceptions.base import DiffSageError
2
+
3
+
4
+ class CredentialNotFoundError(DiffSageError):
5
+ """Raised when a required provider credential cannot be found."""
6
+
7
+ def __init__(self, provider: str, name: str = "default") -> None:
8
+ super().__init__(f"Credential not found for provider '{provider}' and profile '{name}'.")
@@ -0,0 +1,9 @@
1
+ from diffsage.exceptions.base import GitError
2
+
3
+
4
+ class NotGitRepositoryError(GitError):
5
+ """Raised when the current directory is not a Git repository."""
6
+
7
+
8
+ class NoStagedChangesError(GitError):
9
+ """Raised when there are no staged changes to commit."""
@@ -0,0 +1,17 @@
1
+ from diffsage.exceptions.base import ProviderError
2
+
3
+
4
+ class AuthenticationError(ProviderError):
5
+ """Raised when authentication with a provider fails."""
6
+
7
+
8
+ class RateLimitError(ProviderError):
9
+ """Raised when a provider rate limit is exceeded."""
10
+
11
+
12
+ class ModelNotFoundError(ProviderError):
13
+ """Raised when the requested model is unavailable."""
14
+
15
+
16
+ class ProviderUnavailableError(ProviderError):
17
+ """Raised when the provider service is unavailable."""
File without changes
diffsage/git/client.py ADDED
@@ -0,0 +1,155 @@
1
+ import subprocess
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from diffsage.models.git import GitCommit, GitStatus
6
+
7
+
8
+ class GitClient:
9
+ """Low-level client for executing Git commands."""
10
+
11
+ def __init__(self, repo_path: Path | str | None = None) -> None:
12
+ self._repo_path = Path(repo_path) if repo_path else Path.cwd()
13
+
14
+ def _run_git_command(self, args: list[str]) -> subprocess.CompletedProcess[str]:
15
+ """Execute a Git command and return the completed process."""
16
+
17
+ return subprocess.run(
18
+ ["git", *args],
19
+ cwd=self._repo_path,
20
+ capture_output=True,
21
+ text=True,
22
+ check=True,
23
+ )
24
+
25
+ def is_git_repository(self) -> bool:
26
+ """Return True if the repository path is inside a Git working tree."""
27
+ try:
28
+ result = self._run_git_command(["rev-parse", "--is-inside-work-tree"])
29
+ return result.stdout.strip() == "true"
30
+
31
+ except subprocess.CalledProcessError:
32
+ return False
33
+
34
+ def repository_root(self) -> Path:
35
+ """Return the root directory of the Git repository."""
36
+
37
+ result = self._run_git_command(["rev-parse", "--show-toplevel"])
38
+ return Path(result.stdout.strip())
39
+
40
+ def current_branch(self) -> str:
41
+ """Return the name of the current Git branch."""
42
+
43
+ result = self._run_git_command(
44
+ ["branch", "--show-current"],
45
+ )
46
+ return result.stdout.strip()
47
+
48
+ def current_commit(self) -> str:
49
+ """Return the hash of the current commit."""
50
+
51
+ result = self._run_git_command(
52
+ ["rev-parse", "HEAD"],
53
+ )
54
+ return result.stdout.strip()
55
+
56
+ def status(self) -> GitStatus:
57
+ """Return the current repository status."""
58
+
59
+ result = self._run_git_command(
60
+ ["status", "--porcelain"],
61
+ )
62
+
63
+ untracked = []
64
+ modified = []
65
+ added = []
66
+ deleted = []
67
+
68
+ # Parse Git porcelain status codes.
69
+ for line in result.stdout.splitlines():
70
+ status = line[:2]
71
+ path = line[3:]
72
+
73
+ match status:
74
+ case "??":
75
+ untracked.append(path)
76
+ case " M":
77
+ modified.append(path)
78
+ case "A ":
79
+ added.append(path)
80
+ case " D":
81
+ deleted.append(path)
82
+
83
+ return GitStatus(
84
+ modified=modified,
85
+ added=added,
86
+ deleted=deleted,
87
+ untracked=untracked,
88
+ )
89
+
90
+ def staged_diff(self) -> str:
91
+ """Returns the staged diff"""
92
+
93
+ result = self._run_git_command(
94
+ ["diff", "--staged"],
95
+ )
96
+ return result.stdout.strip()
97
+
98
+ def unstaged_diff(self) -> str:
99
+ """Returns the unstaged diff"""
100
+
101
+ result = self._run_git_command(
102
+ ["diff"],
103
+ )
104
+ return result.stdout.strip()
105
+
106
+ def recent_commits(self, limit: int = 10) -> list[GitCommit]:
107
+ """Return the most recent commits."""
108
+
109
+ result = self._run_git_command(
110
+ ["log", f"-{limit}", "--pretty=format:%H%x09%an%x09%s%x09%aI"],
111
+ )
112
+
113
+ commits: list[GitCommit] = []
114
+
115
+ for line in result.stdout.splitlines():
116
+ hash_, author, message, date = line.split("\t")
117
+
118
+ commit = GitCommit(
119
+ hash=hash_, author=author, message=message, date=datetime.fromisoformat(date)
120
+ )
121
+ commits.append(commit)
122
+
123
+ return commits
124
+
125
+ def branches(self) -> list[str]:
126
+ """Return local branch names."""
127
+
128
+ result = self._run_git_command(
129
+ ["branch", "--format=%(refname:short)"],
130
+ )
131
+
132
+ return result.stdout.splitlines()
133
+
134
+ def tags(self) -> list[str]:
135
+ """Return tag names."""
136
+
137
+ result = self._run_git_command(
138
+ ["tag", "--format=%(refname:short)"],
139
+ )
140
+ return result.stdout.splitlines()
141
+
142
+ def commit(self, message: str) -> None:
143
+ """Create a git commit with the provided commit message."""
144
+
145
+ lines = message.split("\n", 1)
146
+
147
+ subject = lines[0]
148
+ body = lines[1].strip() if len(lines) > 1 else ""
149
+
150
+ command = ["commit", "-m", subject]
151
+
152
+ if body:
153
+ command.extend(["-m", body])
154
+
155
+ self._run_git_command(command)
File without changes
File without changes
@@ -0,0 +1,41 @@
1
+ import logging
2
+
3
+ from diffsage.config.paths import get_log_directory
4
+
5
+ _CONFIGURED = False
6
+
7
+
8
+ def configure_logging(level: int = logging.INFO) -> None:
9
+ """Configure the application's logging."""
10
+
11
+ global _CONFIGURED
12
+
13
+ if _CONFIGURED:
14
+ return
15
+
16
+ root_logger = logging.getLogger()
17
+ root_logger.setLevel(level)
18
+
19
+ log_file = get_log_directory() / "diffsage.log"
20
+
21
+ # console_handler = logging.StreamHandler()
22
+ file_handler = logging.FileHandler(log_file)
23
+
24
+ formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
25
+
26
+ # console_handler.setFormatter(formatter)
27
+ file_handler.setFormatter(formatter)
28
+
29
+ # root_logger.addHandler(console_handler)
30
+ root_logger.addHandler(file_handler)
31
+
32
+ logging.getLogger("httpx").setLevel(logging.WARNING)
33
+ logging.getLogger("google_genai").setLevel(logging.WARNING)
34
+
35
+ _CONFIGURED = True
36
+
37
+
38
+ def get_logger(name: str) -> logging.Logger:
39
+ """Return a logger."""
40
+
41
+ return logging.getLogger(name)
File without changes
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class CommitMessage:
6
+ type: str | None = None
7
+ scope: str | None = None
8
+ subject: str = ""
9
+ body: list[str] = field(default_factory=list)
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class ConfigReport:
6
+ provider: str | None
7
+ model: str | None
8
+ timeout: int | None
9
+ max_retries: int | None
10
+ log_level: str | None
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ConfigValueReport:
15
+ key: str
16
+ value: str | int | None
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class Credential:
6
+ provider: str
7
+ name: str
8
+ api_key: str
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class DoctorReport(BaseModel):
5
+ python_version: str
6
+ git_installed: bool
7
+ git_version: str | None
8
+ provider: str
9
+ timeout: int
10
+ max_retries: int
11
+ log_level: str
12
+ virtual_environment: bool
13
+ configuration_loaded: bool
diffsage/models/git.py ADDED
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class GitStatus:
7
+ """Represents the current status of a Git repository."""
8
+
9
+ modified: list[str]
10
+ added: list[str]
11
+ deleted: list[str]
12
+ untracked: list[str]
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class GitCommit:
17
+ """Represents a commit history in a Git repository"""
18
+
19
+ hash: str
20
+ author: str
21
+ message: str
22
+ date: datetime
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class CommitContext:
27
+ """Represents the information needed to generate a commit message."""
28
+
29
+ branch: str
30
+ staged_diff: str
31
+ unstaged_diff: str
32
+ recent_commits: list[GitCommit]
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class ProviderRequest:
6
+ prompt: str
7
+ model: str
8
+ temperature: float
9
+ max_tokens: int
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class ProviderResponse:
14
+ content: str
15
+ provider: str
16
+ model: str
17
+ input_tokens: int
18
+ output_tokens: int
19
+ finish_reason: str
20
+ latency_ms: int