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/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)