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 ADDED
@@ -0,0 +1,3 @@
1
+ """Doc Code generates and safely applies AI-assisted source documentation."""
2
+
3
+ __version__ = "0.1.0"
doc_code/ai.py ADDED
@@ -0,0 +1,206 @@
1
+ """Provider calls and strict JSON response validation for documentation descriptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from math import ceil
8
+ from typing import Any, cast
9
+ from urllib.error import URLError
10
+ from urllib.parse import urlparse
11
+ from urllib.request import Request, urlopen
12
+
13
+ from .config import Settings
14
+ from .errors import AIProviderError, AITimeoutError, DocGubError, InvalidAIResponseError
15
+ from .symbols import Documentation, Symbol
16
+
17
+ _MAX_PROVIDER_RESPONSE_BYTES = 1_000_000
18
+
19
+
20
+ def estimate_tokens(text: str) -> int:
21
+ """Estimate tokens conservatively from the UTF-8 byte length."""
22
+ return ceil(len(text.encode("utf-8")) / 3)
23
+
24
+
25
+ def prompt(content: str, symbols: list[Symbol], language: str = "English") -> str:
26
+ """Build the strict JSON prompt sent to a provider."""
27
+ names = [{"symbol": item.name, "kind": item.kind, "arguments": item.args} for item in symbols]
28
+ return (
29
+ "Return only a JSON object mapping each requested symbol to an object with "
30
+ "`description` (a concise factual description) and `arguments` (an object mapping "
31
+ "every requested argument name to its specific description). Use an empty `arguments` "
32
+ "object when a symbol has no arguments. Do not include Markdown or code fences. Write "
33
+ f"all documentation text in {json.dumps(language, ensure_ascii=False)}. Requested symbols: "
34
+ + json.dumps(names)
35
+ + "\n\nSOURCE:\n"
36
+ + content
37
+ )
38
+
39
+
40
+ def _post(
41
+ url: str, payload: dict[str, Any], headers: dict[str, str], timeout: int
42
+ ) -> dict[str, Any]:
43
+ """Post JSON and convert transport or decoding failures to domain errors."""
44
+ parsed = urlparse(url)
45
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
46
+ raise AIProviderError("The AI provider endpoint must be an absolute HTTP(S) URL.")
47
+ try:
48
+ with urlopen(
49
+ Request(url, data=json.dumps(payload).encode(), headers=headers, method="POST"),
50
+ timeout=timeout,
51
+ ) as response: # nosec B310 - user-configured endpoint
52
+ body = response.read(_MAX_PROVIDER_RESPONSE_BYTES + 1)
53
+ if len(body) > _MAX_PROVIDER_RESPONSE_BYTES:
54
+ raise AIProviderError("The AI provider response exceeds the supported size.")
55
+ decoded = json.loads(body)
56
+ if not isinstance(decoded, dict):
57
+ raise TypeError("The AI provider response must be a JSON object.")
58
+ return cast(dict[str, Any], decoded)
59
+ except TimeoutError as exc:
60
+ raise AITimeoutError(f"Unable to contact the AI provider: {exc}") from exc
61
+ except URLError as exc:
62
+ if isinstance(exc.reason, TimeoutError):
63
+ raise AITimeoutError(f"Unable to contact the AI provider: {exc.reason}") from exc
64
+ raise AIProviderError(f"Unable to contact the AI provider: {exc}") from exc
65
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
66
+ raise AIProviderError("The AI provider returned an invalid response.") from exc
67
+
68
+
69
+ def documentation_for(
70
+ content: str, symbols: list[Symbol], settings: Settings
71
+ ) -> dict[str, Documentation]:
72
+ """Generate validated documentation for the requested symbols."""
73
+ body = prompt(content, symbols, settings.language)
74
+ tokens = estimate_tokens(body)
75
+ if (
76
+ tokens > settings.max_input_tokens
77
+ or tokens + settings.max_output_tokens > settings.context_window_tokens
78
+ ):
79
+ raise DocGubError(
80
+ "AI input exceeds configured token limits; narrow the scope or increase limits."
81
+ )
82
+ if settings.provider == "openai":
83
+ key = os.getenv("OPENAI_API_KEY")
84
+ if not key:
85
+ raise DocGubError("OPENAI_API_KEY is not configured.")
86
+ data = _post(
87
+ settings.endpoint or "https://api.openai.com/v1/chat/completions",
88
+ {
89
+ "model": settings.model,
90
+ "messages": [{"role": "user", "content": body}],
91
+ "max_completion_tokens": settings.max_output_tokens,
92
+ "temperature": settings.temperature,
93
+ },
94
+ {"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
95
+ settings.timeout_seconds,
96
+ )
97
+ answer = _provider_answer(data, "openai")
98
+ elif settings.provider == "gemini":
99
+ key = os.getenv("GEMINI_API_KEY")
100
+ if not key:
101
+ raise DocGubError("GEMINI_API_KEY is not configured.")
102
+ endpoint = settings.endpoint or (
103
+ "https://generativelanguage.googleapis.com/v1beta/models/"
104
+ f"{settings.model}:generateContent"
105
+ )
106
+ data = _post(
107
+ endpoint,
108
+ {"contents": [{"parts": [{"text": body}]}]},
109
+ {"Content-Type": "application/json", "x-goog-api-key": key},
110
+ settings.timeout_seconds,
111
+ )
112
+ answer = _provider_answer(data, "gemini")
113
+ else:
114
+ data = _post(
115
+ settings.endpoint or "http://localhost:11434/api/generate",
116
+ {
117
+ "model": settings.model,
118
+ "prompt": body,
119
+ "stream": False,
120
+ "options": {
121
+ "temperature": settings.temperature,
122
+ "num_ctx": settings.context_window_tokens,
123
+ "num_predict": settings.max_output_tokens,
124
+ },
125
+ },
126
+ {"Content-Type": "application/json"},
127
+ settings.timeout_seconds,
128
+ )
129
+ answer = _provider_answer(data, "ollama")
130
+ return _documentation_response(answer, symbols)
131
+
132
+
133
+ def _provider_answer(data: dict[str, Any], provider: str) -> str:
134
+ """Extract provider text while keeping malformed envelopes inside the domain boundary."""
135
+ try:
136
+ if provider == "openai":
137
+ answer = data["choices"][0]["message"]["content"]
138
+ elif provider == "gemini":
139
+ answer = data["candidates"][0]["content"]["parts"][0]["text"]
140
+ else:
141
+ answer = data["response"]
142
+ except (KeyError, IndexError, TypeError) as exc:
143
+ raise AIProviderError(f"The {provider} provider returned an invalid response.") from exc
144
+ if not isinstance(answer, str):
145
+ raise AIProviderError(f"The {provider} provider returned an invalid response.")
146
+ return answer
147
+
148
+
149
+ def _documentation_response(answer: str, symbols: list[Symbol]) -> dict[str, Documentation]:
150
+ """Validate and normalize the structured documentation returned by a provider."""
151
+ if not isinstance(answer, str):
152
+ raise InvalidAIResponseError("The AI returned documentation in an invalid format.")
153
+ try:
154
+ parsed = json.loads(answer)
155
+ except (json.JSONDecodeError, TypeError) as exc:
156
+ raise InvalidAIResponseError("The AI returned invalid documentation JSON.") from exc
157
+ if not isinstance(parsed, dict):
158
+ raise InvalidAIResponseError("The AI response must be a JSON object of documentation.")
159
+
160
+ requested = {symbol.name: symbol for symbol in symbols}
161
+ if set(parsed) != set(requested):
162
+ raise InvalidAIResponseError("The AI response must document every requested symbol.")
163
+ normalized: dict[str, Documentation] = {}
164
+ for name, value in parsed.items():
165
+ if not isinstance(name, str) or name not in requested:
166
+ raise InvalidAIResponseError("The AI response contains an unexpected symbol.")
167
+ normalized[name] = _normalize_documentation(name, value, requested[name])
168
+ return normalized
169
+
170
+
171
+ def _normalize_documentation(name: str, value: Any, symbol: Symbol) -> Documentation:
172
+ """Validate one symbol's generated description and argument mapping."""
173
+ if isinstance(value, str):
174
+ legacy_description = value.strip()
175
+ if not legacy_description:
176
+ raise InvalidAIResponseError("Each symbol must contain a non-empty description.")
177
+ return Documentation(legacy_description)
178
+ if not isinstance(value, dict):
179
+ raise InvalidAIResponseError("Each symbol must contain documentation details.")
180
+ structured_description = value.get("description")
181
+ arguments = value.get("arguments", {})
182
+ valid_arguments = isinstance(arguments, dict) and all(
183
+ isinstance(argument, str)
184
+ and isinstance(argument_description, str)
185
+ and bool(argument_description.strip())
186
+ for argument, argument_description in arguments.items()
187
+ )
188
+ if (
189
+ not isinstance(structured_description, str)
190
+ or not structured_description.strip()
191
+ or not valid_arguments
192
+ ):
193
+ raise InvalidAIResponseError(
194
+ "Each symbol must contain a non-empty description and argument descriptions."
195
+ )
196
+ if set(arguments) != set(symbol.args):
197
+ raise InvalidAIResponseError(
198
+ f"Documentation for `{name}` must describe every requested argument."
199
+ )
200
+ return Documentation(
201
+ structured_description.strip(),
202
+ {
203
+ argument: argument_description.strip()
204
+ for argument, argument_description in arguments.items()
205
+ },
206
+ )