doc-code 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.
- doc_code/__init__.py +3 -0
- doc_code/ai.py +206 -0
- doc_code/cli.py +593 -0
- doc_code/config.py +362 -0
- doc_code/editor.py +496 -0
- doc_code/errors.py +21 -0
- doc_code/git.py +74 -0
- doc_code/py.typed +1 -0
- doc_code/scope.py +105 -0
- doc_code/symbols.py +594 -0
- doc_code-0.1.0.dist-info/METADATA +138 -0
- doc_code-0.1.0.dist-info/RECORD +16 -0
- doc_code-0.1.0.dist-info/WHEEL +5 -0
- doc_code-0.1.0.dist-info/entry_points.txt +2 -0
- doc_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- doc_code-0.1.0.dist-info/top_level.txt +1 -0
doc_code/config.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Configuration loading with explicit, auditable precedence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from ipaddress import ip_address
|
|
9
|
+
from math import isfinite
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from .errors import DocGubError
|
|
15
|
+
|
|
16
|
+
_OLLAMA_MODEL = "qwen2.5-coder:14b"
|
|
17
|
+
_OLLAMA_MODELS = (_OLLAMA_MODEL, "gemma4:e4b")
|
|
18
|
+
_PROVIDER_MODELS = {
|
|
19
|
+
"ollama": _OLLAMA_MODEL,
|
|
20
|
+
"openai": "gpt-5.6-sol",
|
|
21
|
+
"gemini": "gemini-3.6-flash",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Settings:
|
|
27
|
+
"""Store immutable operational settings."""
|
|
28
|
+
|
|
29
|
+
provider: str = "ollama"
|
|
30
|
+
model: str = _OLLAMA_MODEL
|
|
31
|
+
models: tuple[str, ...] = _OLLAMA_MODELS
|
|
32
|
+
endpoint: str | None = None
|
|
33
|
+
max_input_tokens: int = 12000
|
|
34
|
+
context_window_tokens: int = 32768
|
|
35
|
+
max_output_tokens: int = 800
|
|
36
|
+
temperature: float = 0.2
|
|
37
|
+
timeout_seconds: int = 60
|
|
38
|
+
selection: str = "changes"
|
|
39
|
+
coverage: str = "missing"
|
|
40
|
+
request_scope: str = "file"
|
|
41
|
+
language: str = "English"
|
|
42
|
+
python_format: str = "google"
|
|
43
|
+
javascript_format: str = "jsdoc"
|
|
44
|
+
output: str = "preview"
|
|
45
|
+
confirm: bool = True
|
|
46
|
+
max_files_per_request: int = 50
|
|
47
|
+
max_file_bytes: int = 100000
|
|
48
|
+
exclude: tuple[str, ...] = (
|
|
49
|
+
"**/node_modules/**",
|
|
50
|
+
"**/dist/**",
|
|
51
|
+
"**/build/**",
|
|
52
|
+
"**/*.min.js",
|
|
53
|
+
"**/package-lock.json",
|
|
54
|
+
)
|
|
55
|
+
include: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
"""Replace Ollama-only defaults when another provider is selected directly."""
|
|
59
|
+
if self.provider == "ollama":
|
|
60
|
+
return
|
|
61
|
+
if self.model == _OLLAMA_MODEL:
|
|
62
|
+
object.__setattr__(self, "model", _PROVIDER_MODELS.get(self.provider, self.model))
|
|
63
|
+
if self.models == _OLLAMA_MODELS:
|
|
64
|
+
object.__setattr__(self, "models", ())
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def model_candidates(self) -> tuple[str, ...]:
|
|
68
|
+
"""Return fallback models, or the primary model when no fallbacks exist."""
|
|
69
|
+
return self.models or (self.model,)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
TEMPLATE = """[ai]
|
|
73
|
+
provider = "ollama"
|
|
74
|
+
models = ["gemma4:e4b", "qwen2.5-coder:14b"]
|
|
75
|
+
max_input_tokens = 3048
|
|
76
|
+
context_window_tokens = 16384
|
|
77
|
+
max_output_tokens = 512
|
|
78
|
+
temperature = 0.1
|
|
79
|
+
timeout_seconds = 180
|
|
80
|
+
|
|
81
|
+
[documentation]
|
|
82
|
+
selection = "repository"
|
|
83
|
+
coverage = "missing"
|
|
84
|
+
request_scope = "symbol"
|
|
85
|
+
language = "English"
|
|
86
|
+
python_format = "google"
|
|
87
|
+
javascript_format = "jsdoc"
|
|
88
|
+
output = "apply"
|
|
89
|
+
confirm = true
|
|
90
|
+
|
|
91
|
+
[limits]
|
|
92
|
+
max_files_per_request = 50
|
|
93
|
+
max_file_bytes = 100000
|
|
94
|
+
exclude = [
|
|
95
|
+
"**/node_modules/**", "**/dist/**", "**/build/**", "**/*.min.js",
|
|
96
|
+
"**/package-lock.json", "*.md", "*.toml", "CNAME", "Makefile", "LICENSE", ".venv"
|
|
97
|
+
]
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
_SECTION_OPTIONS = {
|
|
101
|
+
"ai": {
|
|
102
|
+
"provider",
|
|
103
|
+
"model",
|
|
104
|
+
"models",
|
|
105
|
+
"endpoint",
|
|
106
|
+
"max_input_tokens",
|
|
107
|
+
"context_window_tokens",
|
|
108
|
+
"max_output_tokens",
|
|
109
|
+
"temperature",
|
|
110
|
+
"timeout_seconds",
|
|
111
|
+
},
|
|
112
|
+
"documentation": {
|
|
113
|
+
"selection",
|
|
114
|
+
"coverage",
|
|
115
|
+
"request_scope",
|
|
116
|
+
"language",
|
|
117
|
+
"python_format",
|
|
118
|
+
"javascript_format",
|
|
119
|
+
"output",
|
|
120
|
+
"confirm",
|
|
121
|
+
},
|
|
122
|
+
"limits": {"max_files_per_request", "max_file_bytes", "exclude", "include"},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_SEQUENCE_OPTIONS = ("models", "exclude", "include")
|
|
126
|
+
_POSITIVE_INTEGER_OPTIONS = (
|
|
127
|
+
"max_input_tokens",
|
|
128
|
+
"context_window_tokens",
|
|
129
|
+
"max_output_tokens",
|
|
130
|
+
"timeout_seconds",
|
|
131
|
+
"max_files_per_request",
|
|
132
|
+
"max_file_bytes",
|
|
133
|
+
)
|
|
134
|
+
_CHOICE_OPTIONS = {
|
|
135
|
+
"provider": {"openai", "gemini", "ollama"},
|
|
136
|
+
"selection": {"changes", "repository"},
|
|
137
|
+
"coverage": {"missing", "minimal", "all"},
|
|
138
|
+
"request_scope": {"file", "symbol"},
|
|
139
|
+
"python_format": {"google", "numpy", "sphinx"},
|
|
140
|
+
"output": {"preview", "apply"},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _configuration_exists(path: Path, required: bool) -> bool:
|
|
145
|
+
"""Validate a configuration path and return whether an optional file exists."""
|
|
146
|
+
try:
|
|
147
|
+
exists = path.exists()
|
|
148
|
+
is_file = path.is_file()
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
raise DocGubError(f"Unable to inspect configuration {path}: {exc}") from exc
|
|
151
|
+
if not exists and not required:
|
|
152
|
+
return False
|
|
153
|
+
if not exists:
|
|
154
|
+
raise DocGubError(f"Configuration file does not exist: {path}.")
|
|
155
|
+
if not is_file:
|
|
156
|
+
raise DocGubError(f"Configuration path is not a regular file: {path}.")
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
161
|
+
"""Decode a TOML object while preserving its path in domain errors."""
|
|
162
|
+
try:
|
|
163
|
+
with path.open("rb") as handle:
|
|
164
|
+
return tomllib.load(handle)
|
|
165
|
+
except tomllib.TOMLDecodeError as exc:
|
|
166
|
+
raise DocGubError(f"Invalid TOML configuration in {path}: {exc}") from exc
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
raise DocGubError(f"Unable to read configuration {path}: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _read(path: Path, *, required: bool = False) -> dict[str, Any]:
|
|
172
|
+
"""Read and flatten supported sections from a TOML configuration file."""
|
|
173
|
+
if not _configuration_exists(path, required):
|
|
174
|
+
return {}
|
|
175
|
+
raw = _read_toml(path)
|
|
176
|
+
unknown_sections = set(raw).difference(_SECTION_OPTIONS)
|
|
177
|
+
if unknown_sections:
|
|
178
|
+
raise DocGubError(
|
|
179
|
+
f"Unknown configuration section(s) in {path}: {', '.join(sorted(unknown_sections))}."
|
|
180
|
+
)
|
|
181
|
+
values: dict[str, Any] = {}
|
|
182
|
+
for section, options in _SECTION_OPTIONS.items():
|
|
183
|
+
configured = raw.get(section, {})
|
|
184
|
+
if not isinstance(configured, dict):
|
|
185
|
+
raise DocGubError(f"Configuration section [{section}] in {path} must be a table.")
|
|
186
|
+
unknown_options = set(configured).difference(options)
|
|
187
|
+
if unknown_options:
|
|
188
|
+
raise DocGubError(
|
|
189
|
+
f"Unknown option(s) in {path} [{section}]: {', '.join(sorted(unknown_options))}."
|
|
190
|
+
)
|
|
191
|
+
values.update(configured)
|
|
192
|
+
return values
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _env() -> dict[str, Any]:
|
|
196
|
+
"""Read supported ``DOC_CODE_*`` environment variables."""
|
|
197
|
+
names = (
|
|
198
|
+
"provider",
|
|
199
|
+
"model",
|
|
200
|
+
"endpoint",
|
|
201
|
+
"max_input_tokens",
|
|
202
|
+
"context_window_tokens",
|
|
203
|
+
"max_output_tokens",
|
|
204
|
+
"temperature",
|
|
205
|
+
"timeout_seconds",
|
|
206
|
+
"selection",
|
|
207
|
+
"coverage",
|
|
208
|
+
"request_scope",
|
|
209
|
+
"language",
|
|
210
|
+
"python_format",
|
|
211
|
+
"javascript_format",
|
|
212
|
+
"output",
|
|
213
|
+
"confirm",
|
|
214
|
+
"max_files_per_request",
|
|
215
|
+
"max_file_bytes",
|
|
216
|
+
)
|
|
217
|
+
return {
|
|
218
|
+
name: os.environ[f"DOC_CODE_{name.upper()}"]
|
|
219
|
+
for name in names
|
|
220
|
+
if f"DOC_CODE_{name.upper()}" in os.environ
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _positive_int(value: Any, name: str) -> int:
|
|
225
|
+
"""Return a positive integer configuration value."""
|
|
226
|
+
if isinstance(value, bool):
|
|
227
|
+
raise DocGubError(f"`{name}` must be a positive integer.")
|
|
228
|
+
try:
|
|
229
|
+
parsed = int(value)
|
|
230
|
+
except (TypeError, ValueError) as exc:
|
|
231
|
+
raise DocGubError(f"`{name}` must be a positive integer.") from exc
|
|
232
|
+
if parsed <= 0:
|
|
233
|
+
raise DocGubError(f"`{name}` must be positive.")
|
|
234
|
+
return parsed
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _boolean(value: Any, name: str) -> bool:
|
|
238
|
+
"""Normalize a TOML or environment boolean value."""
|
|
239
|
+
if isinstance(value, bool):
|
|
240
|
+
return value
|
|
241
|
+
if isinstance(value, str) and value.casefold() in {"true", "false"}:
|
|
242
|
+
return value.casefold() == "true"
|
|
243
|
+
raise DocGubError(f"`{name}` must be true or false.")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _choice(value: Any, name: str, options: set[str]) -> str:
|
|
247
|
+
"""Validate a string setting against its supported values."""
|
|
248
|
+
if not isinstance(value, str) or value not in options:
|
|
249
|
+
raise DocGubError(f"`{name}` must be one of: {', '.join(sorted(options))}.")
|
|
250
|
+
return value
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _non_empty_string(value: Any, name: str) -> str:
|
|
254
|
+
"""Validate a required string setting and normalize surrounding whitespace."""
|
|
255
|
+
if not isinstance(value, str) or not value.strip():
|
|
256
|
+
raise DocGubError(f"`{name}` must be a non-empty string.")
|
|
257
|
+
return value.strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _endpoint(value: Any, provider: str) -> str | None:
|
|
261
|
+
"""Validate an optional HTTP(S) endpoint."""
|
|
262
|
+
if value is None:
|
|
263
|
+
return None
|
|
264
|
+
endpoint = _non_empty_string(value, "endpoint")
|
|
265
|
+
parsed = urlparse(endpoint)
|
|
266
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
267
|
+
raise DocGubError("`endpoint` must be an absolute HTTP(S) URL.")
|
|
268
|
+
if provider in {"openai", "gemini"} and parsed.scheme != "https":
|
|
269
|
+
hostname = parsed.hostname or ""
|
|
270
|
+
try:
|
|
271
|
+
loopback = ip_address(hostname).is_loopback
|
|
272
|
+
except ValueError:
|
|
273
|
+
loopback = hostname.casefold() == "localhost"
|
|
274
|
+
if not loopback:
|
|
275
|
+
raise DocGubError(
|
|
276
|
+
"Authenticated provider endpoints must use HTTPS unless they are loopback URLs."
|
|
277
|
+
)
|
|
278
|
+
return endpoint
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load(repo_root: Path, config_path: Path | None = None, **overrides: Any) -> Settings:
|
|
282
|
+
"""Load global, repository, environment, explicit-file, and CLI settings in order."""
|
|
283
|
+
values: dict[str, Any] = asdict(Settings())
|
|
284
|
+
for layer in (
|
|
285
|
+
_read(Path.home() / ".config/doc-code/config.toml"),
|
|
286
|
+
_read(repo_root / ".doc-code.toml"),
|
|
287
|
+
_env(),
|
|
288
|
+
):
|
|
289
|
+
_apply_layer(values, layer)
|
|
290
|
+
if config_path:
|
|
291
|
+
_apply_layer(values, _read(config_path, required=True))
|
|
292
|
+
_apply_layer(values, {key: value for key, value in overrides.items() if value is not None})
|
|
293
|
+
return _validated_settings(values)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _normalize_sequences(values: dict[str, Any]) -> None:
|
|
297
|
+
"""Validate sequence settings and store immutable tuples."""
|
|
298
|
+
for name in _SEQUENCE_OPTIONS:
|
|
299
|
+
if not isinstance(values[name], (list, tuple)) or not all(
|
|
300
|
+
isinstance(item, str) and item for item in values[name]
|
|
301
|
+
):
|
|
302
|
+
raise DocGubError(f"`{name}` must be a list of non-empty strings.")
|
|
303
|
+
values[name] = tuple(values[name])
|
|
304
|
+
if len(values["models"]) > 3:
|
|
305
|
+
raise DocGubError("`models` accepts at most three candidates.")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _normalize_temperature(value: Any, provider: str) -> float:
|
|
309
|
+
"""Validate and return a sampling temperature supported by the selected provider."""
|
|
310
|
+
if isinstance(value, bool):
|
|
311
|
+
raise DocGubError("`temperature` must be a finite number.")
|
|
312
|
+
try:
|
|
313
|
+
temperature = float(value)
|
|
314
|
+
except (TypeError, ValueError) as exc:
|
|
315
|
+
raise DocGubError("`temperature` must be a finite number.") from exc
|
|
316
|
+
if not isfinite(temperature):
|
|
317
|
+
raise DocGubError("`temperature` must be a finite number.")
|
|
318
|
+
if temperature < 0:
|
|
319
|
+
raise DocGubError("`temperature` must be non-negative.")
|
|
320
|
+
if provider in {"openai", "gemini"} and temperature > 2:
|
|
321
|
+
raise DocGubError(f"`temperature` must be between 0 and 2 for {provider}.")
|
|
322
|
+
return temperature
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_token_budget(values: dict[str, Any]) -> None:
|
|
326
|
+
"""Ensure the configured request fits in the model context window."""
|
|
327
|
+
if values["max_input_tokens"] + values["max_output_tokens"] > values["context_window_tokens"]:
|
|
328
|
+
raise DocGubError(
|
|
329
|
+
"max_input_tokens + max_output_tokens must not exceed context_window_tokens."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _validated_settings(values: dict[str, Any]) -> Settings:
|
|
334
|
+
"""Normalize merged configuration values and build immutable settings."""
|
|
335
|
+
_normalize_sequences(values)
|
|
336
|
+
for name in _POSITIVE_INTEGER_OPTIONS:
|
|
337
|
+
values[name] = _positive_int(values[name], name)
|
|
338
|
+
for name, options in _CHOICE_OPTIONS.items():
|
|
339
|
+
values[name] = _choice(values[name], name, options)
|
|
340
|
+
values["temperature"] = _normalize_temperature(values["temperature"], values["provider"])
|
|
341
|
+
values["language"] = _non_empty_string(values["language"], "language")
|
|
342
|
+
values["model"] = _non_empty_string(values["model"], "model")
|
|
343
|
+
values["endpoint"] = _endpoint(values["endpoint"], values["provider"])
|
|
344
|
+
if values["javascript_format"] != "jsdoc":
|
|
345
|
+
raise DocGubError("`javascript_format` must be `jsdoc`.")
|
|
346
|
+
values["confirm"] = _boolean(values["confirm"], "confirm")
|
|
347
|
+
_validate_token_budget(values)
|
|
348
|
+
return Settings(**values)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _apply_layer(values: dict[str, Any], layer: dict[str, Any]) -> None:
|
|
352
|
+
"""Apply one precedence layer while resetting provider-specific lower-layer defaults."""
|
|
353
|
+
if not layer:
|
|
354
|
+
return
|
|
355
|
+
provider = layer.get("provider")
|
|
356
|
+
if provider is not None and provider != values["provider"]:
|
|
357
|
+
values["model"] = _PROVIDER_MODELS.get(provider, values["model"])
|
|
358
|
+
values["models"] = ()
|
|
359
|
+
values["endpoint"] = None
|
|
360
|
+
if "model" in layer and "models" not in layer:
|
|
361
|
+
values["models"] = ()
|
|
362
|
+
values.update(layer)
|