comit-cli 1.0.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.
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from pathlib import Path
7
+ from typing import List, Optional, Set
8
+
9
+ from comit.commit.context import CommitContext, ChangedFile
10
+
11
+
12
+ class ValidationSeverity(str, Enum):
13
+ INFO = "INFO"
14
+ WARNING = "WARNING"
15
+ ERROR = "ERROR"
16
+
17
+
18
+ @dataclass
19
+ class ValidationFinding:
20
+ severity: ValidationSeverity
21
+ category: str
22
+ message: str
23
+ details: Optional[str] = None
24
+
25
+
26
+ @dataclass
27
+ class ValidationResult:
28
+ message: str
29
+ is_valid: bool = True
30
+ findings: List[ValidationFinding] = field(default_factory=list)
31
+
32
+ @property
33
+ def has_findings(self) -> bool:
34
+ return bool(self.findings)
35
+
36
+ @property
37
+ def has_warnings(self) -> bool:
38
+ return any(f.severity in (ValidationSeverity.WARNING, ValidationSeverity.ERROR) for f in self.findings)
39
+
40
+ @property
41
+ def has_errors(self) -> bool:
42
+ return any(f.severity == ValidationSeverity.ERROR for f in self.findings)
43
+
44
+
45
+ DEFAULT_CONVENTIONAL_TYPES = {
46
+ "feat",
47
+ "fix",
48
+ "docs",
49
+ "style",
50
+ "refactor",
51
+ "perf",
52
+ "test",
53
+ "build",
54
+ "ci",
55
+ "chore",
56
+ "revert",
57
+ }
58
+
59
+ DOC_EXTENSIONS = {
60
+ ".md",
61
+ ".markdown",
62
+ ".mdown",
63
+ ".rst",
64
+ ".txt",
65
+ ".adoc",
66
+ ".asciidoc",
67
+ ".doc",
68
+ ".docx",
69
+ ".pdf",
70
+ }
71
+
72
+ DOC_FILENAMES = {
73
+ "readme",
74
+ "license",
75
+ "contributing",
76
+ "changelog",
77
+ "authors",
78
+ "code_of_conduct",
79
+ "notice",
80
+ "todo",
81
+ }
82
+
83
+ TEST_EXTENSIONS = {
84
+ ".spec.js",
85
+ ".spec.ts",
86
+ ".spec.jsx",
87
+ ".spec.tsx",
88
+ ".test.js",
89
+ ".test.ts",
90
+ ".test.jsx",
91
+ ".test.tsx",
92
+ ".test.py",
93
+ }
94
+
95
+ CONVENTIONAL_REGEX = re.compile(
96
+ r"^(?P<type>[a-zA-Z0-9_-]+)(?:\((?P<scope>[^()\r\n]+)\))?(?P<breaking>!)?:\s*(?P<subject>.*)$"
97
+ )
98
+
99
+ FILE_PATH_PATTERN = re.compile(
100
+ r"(?:\b|['\"`])(?P<path>(?:[a-zA-Z0-9_\-\.]+[/\\])+[a-zA-Z0-9_\-\.]+|[a-zA-Z0-9_\-]+\.[a-zA-Z0-9_\-]+)(?:\b|['\"`])"
101
+ )
102
+
103
+
104
+ def _is_doc_file(path_str: str) -> bool:
105
+ norm_path = path_str.replace("\\", "/").lower()
106
+ path_obj = Path(norm_path)
107
+ if any(part in ("docs", "doc", "documentation") for part in path_obj.parts):
108
+ return True
109
+ if path_obj.suffix.lower() in DOC_EXTENSIONS:
110
+ return True
111
+ if path_obj.stem.lower() in DOC_FILENAMES:
112
+ return True
113
+ return False
114
+
115
+
116
+ def _is_test_file(path_str: str) -> bool:
117
+ norm_path = path_str.replace("\\", "/").lower()
118
+ path_obj = Path(norm_path)
119
+ if any(part in ("tests", "test", "testing", "__tests__", "spec", "specs") for part in path_obj.parts):
120
+ return True
121
+ stem = path_obj.stem.lower()
122
+ name = path_obj.name.lower()
123
+ if stem.startswith("test_") or stem.endswith("_test") or stem.endswith("_spec"):
124
+ return True
125
+ if any(name.endswith(ext) for ext in TEST_EXTENSIONS):
126
+ return True
127
+ return False
128
+
129
+
130
+ def _extract_referenced_files(text: str) -> Set[str]:
131
+ referenced = set()
132
+ for match in FILE_PATH_PATTERN.finditer(text):
133
+ candidate = match.group("path").strip("'\"`")
134
+ if candidate.endswith((".", ",")):
135
+ candidate = candidate[:-1]
136
+ if not candidate:
137
+ continue
138
+ p = Path(candidate)
139
+ suffix = p.suffix.lower()
140
+ if suffix in (
141
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".json", ".toml", ".yaml", ".yml",
142
+ ".md", ".rst", ".txt", ".html", ".css", ".rs", ".go", ".c", ".cpp",
143
+ ".h", ".java", ".kt", ".rb", ".php", ".sh", ".bat", ".ps1", ".sql",
144
+ ".env", ".lock", ".cfg", ".ini",
145
+ ) or "/" in candidate or "\\" in candidate:
146
+ referenced.add(candidate.replace("\\", "/").lower())
147
+ return referenced
148
+
149
+
150
+ class CommitValidator:
151
+ def __init__(self, allowed_types: Optional[Set[str]] = None):
152
+ self.allowed_types = set(allowed_types) if allowed_types else DEFAULT_CONVENTIONAL_TYPES
153
+
154
+ def validate(self, message: str, context: Optional[CommitContext] = None) -> ValidationResult:
155
+ result = ValidationResult(message=message)
156
+
157
+ if not message or not message.strip():
158
+ result.is_valid = False
159
+ result.findings.append(
160
+ ValidationFinding(
161
+ severity=ValidationSeverity.ERROR,
162
+ category="syntax",
163
+ message="Commit message is empty.",
164
+ )
165
+ )
166
+ return result
167
+
168
+ lines = message.strip().splitlines()
169
+ first_line = lines[0].strip()
170
+
171
+ match = CONVENTIONAL_REGEX.match(first_line)
172
+ msg_type: Optional[str] = None
173
+ msg_scope: Optional[str] = None
174
+ msg_subject: Optional[str] = None
175
+
176
+ if match:
177
+ msg_type = match.group("type").lower()
178
+ msg_scope = match.group("scope")
179
+ msg_subject = match.group("subject").strip()
180
+
181
+ if not msg_subject:
182
+ result.is_valid = False
183
+ result.findings.append(
184
+ ValidationFinding(
185
+ severity=ValidationSeverity.ERROR,
186
+ category="syntax",
187
+ message=f"Commit message type '{msg_type}' is missing a subject description.",
188
+ )
189
+ )
190
+ else:
191
+ result.findings.append(
192
+ ValidationFinding(
193
+ severity=ValidationSeverity.INFO,
194
+ category="syntax",
195
+ message="Commit message does not follow Conventional Commit format (type: description).",
196
+ )
197
+ )
198
+
199
+ if context and context.changed_files:
200
+ staged_paths = [f.path.replace("\\", "/").lower() for f in context.changed_files]
201
+ staged_basenames = [Path(p).name.lower() for p in staged_paths]
202
+
203
+ is_all_docs = all(_is_doc_file(p) for p in staged_paths)
204
+ is_all_tests = all(_is_test_file(p) for p in staged_paths)
205
+ has_any_docs = any(_is_doc_file(p) for p in staged_paths)
206
+ has_any_tests = any(_is_test_file(p) for p in staged_paths)
207
+
208
+ if msg_type:
209
+ if is_all_docs and msg_type in ("feat", "fix", "perf", "refactor"):
210
+ result.findings.append(
211
+ ValidationFinding(
212
+ severity=ValidationSeverity.WARNING,
213
+ category="context_mismatch",
214
+ message=f"Commit message type '{msg_type}' does not match documentation-only staged changes.",
215
+ details=f"All {len(staged_paths)} staged file(s) are documentation files.",
216
+ )
217
+ )
218
+ elif msg_type == "docs" and not has_any_docs:
219
+ result.findings.append(
220
+ ValidationFinding(
221
+ severity=ValidationSeverity.WARNING,
222
+ category="context_mismatch",
223
+ message="Commit message type is 'docs' but no documentation files are staged.",
224
+ )
225
+ )
226
+
227
+ if is_all_tests and msg_type in ("feat", "fix", "perf"):
228
+ result.findings.append(
229
+ ValidationFinding(
230
+ severity=ValidationSeverity.WARNING,
231
+ category="context_mismatch",
232
+ message=f"Commit message type '{msg_type}' does not match test-only staged changes.",
233
+ details=f"All {len(staged_paths)} staged file(s) are test files.",
234
+ )
235
+ )
236
+ elif msg_type == "test" and not has_any_tests:
237
+ result.findings.append(
238
+ ValidationFinding(
239
+ severity=ValidationSeverity.WARNING,
240
+ category="context_mismatch",
241
+ message="Commit message type is 'test' but no test files are staged.",
242
+ )
243
+ )
244
+
245
+ referenced_files = _extract_referenced_files(message)
246
+ for ref in referenced_files:
247
+ ref_basename = Path(ref).name.lower()
248
+ matches_staged = any(
249
+ ref == staged_p or staged_p.endswith(f"/{ref}") or ref_basename == base
250
+ for staged_p, base in zip(staged_paths, staged_basenames)
251
+ )
252
+ if not matches_staged:
253
+ result.findings.append(
254
+ ValidationFinding(
255
+ severity=ValidationSeverity.WARNING,
256
+ category="file_mismatch",
257
+ message=f"Commit message references '{ref}' which is not in staged changes.",
258
+ )
259
+ )
260
+
261
+ return result
262
+
263
+
264
+ def validate_commit_message(
265
+ message: str,
266
+ context: Optional[CommitContext] = None,
267
+ allowed_types: Optional[Set[str]] = None,
268
+ ) -> ValidationResult:
269
+ validator = CommitValidator(allowed_types=allowed_types)
270
+ return validator.validate(message=message, context=context)
comit/config.py ADDED
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional, Dict, Any
8
+ from dotenv import load_dotenv, find_dotenv
9
+
10
+ load_dotenv(find_dotenv(usecwd=True))
11
+
12
+ DEFAULT_PROVIDER = "groq"
13
+ DEFAULT_GROQ_MODEL = "qwen/qwen3.8-27b"
14
+ DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
15
+ DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
16
+ DEFAULT_OLLAMA_MODEL = "llama3.2"
17
+ DEFAULT_OLLAMA_HOST = "http://localhost:11434"
18
+
19
+ DEFAULT_MODELS = {
20
+ "groq": DEFAULT_GROQ_MODEL,
21
+ "gemini": DEFAULT_GEMINI_MODEL,
22
+ "openai": DEFAULT_OPENAI_MODEL,
23
+ "ollama": DEFAULT_OLLAMA_MODEL,
24
+ }
25
+
26
+ SUPPORTED_PROVIDERS = ("groq", "gemini", "openai", "ollama")
27
+
28
+
29
+ def get_config_dir() -> Path:
30
+ if sys.platform == "win32":
31
+ appdata = os.getenv("APPDATA")
32
+ if appdata:
33
+ return Path(appdata) / "comit"
34
+ xdg = os.getenv("XDG_CONFIG_HOME")
35
+ if xdg:
36
+ return Path(xdg) / "comit"
37
+ return Path.home() / ".config" / "comit"
38
+
39
+
40
+ def get_config_path() -> Path:
41
+ return get_config_dir() / "config.json"
42
+
43
+
44
+ def load_user_config() -> Dict[str, Any]:
45
+ config_file = get_config_path()
46
+ if not config_file.is_file():
47
+ return {}
48
+ try:
49
+ with open(config_file, "r", encoding="utf-8") as f:
50
+ return json.load(f)
51
+ except Exception:
52
+ return {}
53
+
54
+
55
+ def save_user_config(data: Dict[str, Any]) -> None:
56
+ config_dir = get_config_dir()
57
+ config_dir.mkdir(parents=True, exist_ok=True)
58
+ config_file = get_config_path()
59
+ with open(config_file, "w", encoding="utf-8") as f:
60
+ json.dump(data, f, indent=2)
61
+
62
+
63
+ def set_user_config_value(key: str, value: Any) -> None:
64
+ data = load_user_config()
65
+ data[key] = value
66
+ save_user_config(data)
67
+
68
+
69
+ def reset_user_config() -> None:
70
+ config_file = get_config_path()
71
+ if config_file.is_file():
72
+ config_file.unlink()
73
+
74
+
75
+ def normalize_api_key(key: Optional[str]) -> Optional[str]:
76
+ if not key:
77
+ return None
78
+ val = str(key).strip()
79
+ if not val:
80
+ return None
81
+ while (len(val) >= 2) and (
82
+ (val.startswith('"') and val.endswith('"')) or
83
+ (val.startswith("'") and val.endswith("'"))
84
+ ):
85
+ val = val[1:-1].strip()
86
+ return val if val else None
87
+
88
+
89
+ def get_provider(override_provider: Optional[str] = None) -> str:
90
+ if override_provider and override_provider.strip():
91
+ return override_provider.strip().lower()
92
+
93
+ for var in ("COMIT_PROVIDER", "comit_provider"):
94
+ val = os.getenv(var)
95
+ if val and val.strip():
96
+ return val.strip().lower()
97
+
98
+ user_cfg = load_user_config()
99
+ cfg_val = user_cfg.get("provider")
100
+ if cfg_val and str(cfg_val).strip():
101
+ return str(cfg_val).strip().lower()
102
+
103
+ return DEFAULT_PROVIDER
104
+
105
+
106
+ def get_api_key(
107
+ override_key: Optional[str] = None,
108
+ provider: Optional[str] = None,
109
+ ) -> Optional[str]:
110
+ p = (provider or get_provider()).strip().lower()
111
+
112
+ if override_key:
113
+ normalized = normalize_api_key(override_key)
114
+ if normalized:
115
+ return normalized
116
+
117
+ if p == "ollama":
118
+ return None
119
+
120
+ env_vars_map = {
121
+ "groq": ("GROQ_API_KEY", "groq_api_key", "Groq_Api_Key", "GROQ_KEY"),
122
+ "gemini": ("GEMINI_API_KEY", "gemini_api_key", "Gemini_Api_Key"),
123
+ "openai": ("OPENAI_API_KEY", "openai_api_key", "Openai_Api_Key"),
124
+ }
125
+ vars_to_check = env_vars_map.get(p, (f"{p.upper()}_API_KEY",))
126
+
127
+ for var in vars_to_check:
128
+ val = os.getenv(var)
129
+ if val:
130
+ normalized = normalize_api_key(val)
131
+ if normalized:
132
+ return normalized
133
+
134
+ for k, v in os.environ.items():
135
+ if k.strip().lower() in [var.lower() for var in vars_to_check] and v:
136
+ normalized = normalize_api_key(v)
137
+ if normalized:
138
+ return normalized
139
+
140
+ user_cfg = load_user_config()
141
+ cfg_keys = [f"{p}_api_key", f"{p}_key"]
142
+ if p == "groq":
143
+ cfg_keys.extend(["groq_api_key", "api_key"])
144
+ for key in cfg_keys:
145
+ cfg_val = user_cfg.get(key)
146
+ if cfg_val:
147
+ normalized = normalize_api_key(cfg_val)
148
+ if normalized:
149
+ return normalized
150
+
151
+ return None
152
+
153
+
154
+ def get_model(
155
+ override_model: Optional[str] = None,
156
+ provider: Optional[str] = None,
157
+ ) -> str:
158
+ p = (provider or get_provider()).strip().lower()
159
+
160
+ if override_model and override_model.strip():
161
+ return override_model.strip()
162
+
163
+ for var in ("COMIT_MODEL", "comit_model"):
164
+ val = os.getenv(var)
165
+ if val and val.strip():
166
+ return val.strip()
167
+
168
+ env_vars_map = {
169
+ "groq": ("GROQ_MODEL", "groq_model", "Groq_Model"),
170
+ "gemini": ("GEMINI_MODEL", "gemini_model", "Gemini_Model"),
171
+ "openai": ("OPENAI_MODEL", "openai_model", "Openai_Model"),
172
+ "ollama": ("OLLAMA_MODEL", "ollama_model", "Ollama_Model"),
173
+ }
174
+ vars_to_check = env_vars_map.get(p, (f"{p.upper()}_MODEL",))
175
+
176
+ for var in vars_to_check:
177
+ val = os.getenv(var)
178
+ if val and val.strip():
179
+ return val.strip()
180
+
181
+ user_cfg = load_user_config()
182
+ cfg_val = (
183
+ user_cfg.get(f"{p}_model")
184
+ or (user_cfg.get("model") if user_cfg.get("provider") == p or not user_cfg.get("provider") else None)
185
+ or (user_cfg.get("groq_model") if p == "groq" else None)
186
+ )
187
+ if cfg_val and str(cfg_val).strip():
188
+ return str(cfg_val).strip()
189
+
190
+ return DEFAULT_MODELS.get(p, DEFAULT_GROQ_MODEL)
191
+
192
+
193
+ def get_ollama_host(override_host: Optional[str] = None) -> str:
194
+ if override_host and override_host.strip():
195
+ return override_host.strip()
196
+
197
+ for var in ("OLLAMA_HOST", "ollama_host"):
198
+ val = os.getenv(var)
199
+ if val and val.strip():
200
+ return val.strip()
201
+
202
+ user_cfg = load_user_config()
203
+ cfg_val = user_cfg.get("ollama_host")
204
+ if cfg_val and str(cfg_val).strip():
205
+ return str(cfg_val).strip()
206
+
207
+ return DEFAULT_OLLAMA_HOST
208
+
209
+
210
+ def mask_api_key(key: Optional[str]) -> str:
211
+ clean = normalize_api_key(key)
212
+ if not clean:
213
+ return "Not configured"
214
+ if len(clean) <= 8:
215
+ return "*" * len(clean)
216
+ return f"{clean[:4]}{'*' * 12}{clean[-4:]}"
217
+
218
+
219
+ def get_config_summary(provider: Optional[str] = None) -> Dict[str, Any]:
220
+ p = get_provider(provider)
221
+ model = get_model(provider=p)
222
+ api_key = get_api_key(provider=p)
223
+ ollama_host = get_ollama_host()
224
+ config_path = str(get_config_path())
225
+ user_cfg = load_user_config()
226
+
227
+ key_source = "not set"
228
+ if p != "ollama":
229
+ env_vars = {
230
+ "groq": ("GROQ_API_KEY", "groq_api_key", "Groq_Api_Key", "GROQ_KEY"),
231
+ "gemini": ("GEMINI_API_KEY", "gemini_api_key", "Gemini_Api_Key"),
232
+ "openai": ("OPENAI_API_KEY", "openai_api_key", "Openai_Api_Key"),
233
+ }.get(p, (f"{p.upper()}_API_KEY",))
234
+ for var in env_vars:
235
+ if os.getenv(var):
236
+ key_source = f"environment ({var})"
237
+ break
238
+ if key_source == "not set" and (user_cfg.get(f"{p}_api_key") or (p == "groq" and user_cfg.get("groq_api_key"))):
239
+ key_source = "user configuration"
240
+
241
+ model_source = "default"
242
+ for var in ("COMIT_MODEL", "comit_model"):
243
+ if os.getenv(var):
244
+ model_source = f"environment ({var})"
245
+ break
246
+ if model_source == "default":
247
+ provider_model_vars = {
248
+ "groq": ("GROQ_MODEL", "groq_model"),
249
+ "gemini": ("GEMINI_MODEL", "gemini_model"),
250
+ "openai": ("OPENAI_MODEL", "openai_model"),
251
+ "ollama": ("OLLAMA_MODEL", "ollama_model"),
252
+ }.get(p, ())
253
+ for var in provider_model_vars:
254
+ if os.getenv(var):
255
+ model_source = f"environment ({var})"
256
+ break
257
+ if model_source == "default" and (user_cfg.get(f"{p}_model") or user_cfg.get("model") or (p == "groq" and user_cfg.get("groq_model"))):
258
+ model_source = "user configuration"
259
+
260
+ provider_source = "default"
261
+ for var in ("COMIT_PROVIDER", "comit_provider"):
262
+ if os.getenv(var):
263
+ provider_source = f"environment ({var})"
264
+ break
265
+ if provider_source == "default" and user_cfg.get("provider"):
266
+ provider_source = "user configuration"
267
+
268
+ return {
269
+ "provider": p,
270
+ "provider_source": provider_source,
271
+ "model": model,
272
+ "model_source": model_source,
273
+ "api_key_masked": mask_api_key(api_key) if p != "ollama" else "N/A (local)",
274
+ "api_key_source": key_source if p != "ollama" else "N/A",
275
+ "api_key_set": bool(api_key) if p != "ollama" else True,
276
+ "ollama_host": ollama_host,
277
+ "config_file": config_path,
278
+ "config_exists": get_config_path().is_file(),
279
+ }