logify360 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.
logify360/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """Logify360 - agent tracing, LLM usage capture, and cost observability.
2
+
3
+ Quick start::
4
+
5
+ from logify360 import Logify360Client
6
+
7
+ client = Logify360Client(api_key="sl_live_...")
8
+
9
+ with client.start_trace("rag_pipeline") as trace:
10
+ with trace.span("retrieval", span_type="retrieval") as span:
11
+ docs = retrieve(query)
12
+ span.end(output=docs)
13
+
14
+ with trace.span("generation", span_type="llm") as span:
15
+ from openai import OpenAI
16
+ response = OpenAI().chat.completions.create(...)
17
+ span.end(
18
+ output=response.choices[0].message.content,
19
+ prompt_tokens=response.usage.prompt_tokens,
20
+ completion_tokens=response.usage.completion_tokens,
21
+ total_tokens=response.usage.total_tokens,
22
+ )
23
+
24
+ For proxy-mode (zero-code) instrumentation, see
25
+ ``logify360.integrations.openai`` / ``anthropic`` / ``gemini``.
26
+ """
27
+
28
+ from .client import Logify360Client
29
+ from .evals import EvalsApi, score_confidence_interval
30
+ from .headers import with_cache, with_log_body, with_prompt_version, with_session, with_user
31
+ from .middleware import Logify360Middleware, install_logify360_middleware
32
+ from .observe import (
33
+ observe,
34
+ observe_anthropic,
35
+ observe_cohere,
36
+ observe_deepseek,
37
+ observe_gemini,
38
+ observe_groq,
39
+ observe_mistral,
40
+ observe_ollama,
41
+ observe_openai,
42
+ observe_openrouter,
43
+ observe_xai,
44
+ )
45
+ from .parsers import parse_anthropic_usage, parse_gemini_usage, parse_openai_usage
46
+ from .resilience import Fallback, Resilience, ResilienceError
47
+ from .span import SpanHandle
48
+ from .trace import TraceHandle
49
+ from .transport import Logify360TransportError
50
+ from .trust import (
51
+ attenuate_warrant,
52
+ handle_decoy_tool_calls,
53
+ is_decoy_tool_name,
54
+ mark_untrusted,
55
+ sign_warrant_request,
56
+ with_warrant,
57
+ )
58
+
59
+ __version__ = "0.11.0"
60
+
61
+ __all__ = [
62
+ "EvalsApi",
63
+ "Fallback",
64
+ "SpanHandle",
65
+ "Logify360Client",
66
+ "Logify360Middleware",
67
+ "Logify360TransportError",
68
+ "Resilience",
69
+ "ResilienceError",
70
+ "TraceHandle",
71
+ "__version__",
72
+ "attenuate_warrant",
73
+ "handle_decoy_tool_calls",
74
+ "install_logify360_middleware",
75
+ "is_decoy_tool_name",
76
+ "mark_untrusted",
77
+ "observe",
78
+ "observe_anthropic",
79
+ "observe_cohere",
80
+ "observe_deepseek",
81
+ "observe_gemini",
82
+ "observe_groq",
83
+ "observe_mistral",
84
+ "observe_ollama",
85
+ "observe_openai",
86
+ "observe_openrouter",
87
+ "observe_xai",
88
+ "parse_anthropic_usage",
89
+ "parse_gemini_usage",
90
+ "parse_openai_usage",
91
+ "score_confidence_interval",
92
+ "sign_warrant_request",
93
+ "with_cache",
94
+ "with_log_body",
95
+ "with_prompt_version",
96
+ "with_session",
97
+ "with_user",
98
+ "with_warrant",
99
+ ]
logify360/_base_url.py ADDED
@@ -0,0 +1,86 @@
1
+ """Which Logify360 deployment the SDK talks to (SDK-01).
2
+
3
+ Precedence: explicit argument > ``LOGIFY360_BASE_URL`` > hosted default. The
4
+ CLI wizard writes ``LOGIFY360_BASE_URL`` for self-hosted installs, so every
5
+ factory and the ingest transport must consult it - a helper that hardcodes the
6
+ hosted proxy silently sends a self-hoster's traffic and ``sl_live_*`` key to
7
+ ``api.logify360.com``.
8
+
9
+ The credential resolves the same way (explicit argument > ``LOGIFY360_API_KEY``)
10
+ for every provider factory, through ``_require_api_key``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ from typing import Optional
18
+
19
+ HOSTED_LOGIFY360_BASE_URL = "https://api.logify360.com"
20
+ HOSTED_LOGIFY360_APP_URL = "https://app.logify360.com"
21
+ HOSTED_LOGIFY360_DOCS_URL = "https://docs.logify360.com"
22
+ HOSTED_LOGIFY360_SITE_URL = "https://www.logify360.com"
23
+ DOCS_QUICK_START_URL = f"{HOSTED_LOGIFY360_DOCS_URL}/quick-start"
24
+ HOSTED_CLI_API_URL = "https://www.logify360.com"
25
+
26
+
27
+ def logify360_base_url(explicit: Optional[str] = None) -> str:
28
+ """Deployment root without a trailing slash."""
29
+ if explicit:
30
+ return explicit.rstrip("/")
31
+ configured = os.environ.get("LOGIFY360_BASE_URL", "").strip()
32
+ return (configured or HOSTED_LOGIFY360_BASE_URL).rstrip("/")
33
+
34
+
35
+ def logify360_proxy_url(path: str) -> str:
36
+ """``logify360_base_url()`` joined with a proxy route such as ``/proxy/openai/v1``."""
37
+ if not path.startswith("/"):
38
+ path = f"/{path}"
39
+ return f"{logify360_base_url()}{path}"
40
+
41
+
42
+ _API_KEY_CHARS = re.compile(r"[\x21-\x7e]+")
43
+
44
+
45
+ def _api_key_problem(api_key: str, source: str) -> Optional[str]:
46
+ """Why ``api_key`` cannot be sent, or ``None`` if it is printable ASCII without whitespace.
47
+
48
+ Stripping removes padding only; a key with an embedded CR, LF, NUL or space is still an
49
+ illegal header value, and the error h11 raises for it quotes the key. That text would
50
+ reach stderr, ``on_error`` hooks, the with-block log, the exception ``close()`` raises
51
+ and the wizard's screen, so the key is refused first. The message names ``source`` (the
52
+ argument or variable the key came from) and NEVER the key.
53
+ """
54
+ if _API_KEY_CHARS.fullmatch(api_key):
55
+ return None
56
+ return f"{source} contains whitespace or control characters"
57
+
58
+
59
+ def _checked_api_key(api_key: str, source: str) -> str:
60
+ """``api_key`` itself, or ``ValueError`` with ``_api_key_problem``'s message."""
61
+ problem = _api_key_problem(api_key, source)
62
+ if problem:
63
+ raise ValueError(f"[logify360] {problem}")
64
+ return api_key
65
+
66
+
67
+ def _require_api_key(api_key: Optional[str], caller: str) -> str:
68
+ """Explicit ``api_key``, else ``LOGIFY360_API_KEY``; ``ValueError`` naming ``caller``.
69
+
70
+ Surrounding whitespace is stripped: a key read from a file or a secret mount
71
+ carries a trailing newline, and sent as is it is an illegal header value.
72
+ A blank key (empty or whitespace-only) counts as unset, so a blank explicit
73
+ ``api_key`` falls through to the environment. What is left must be printable
74
+ ASCII without whitespace (``_checked_api_key``).
75
+ """
76
+ explicit = (api_key or "").strip()
77
+ resolved_key = explicit or os.environ.get("LOGIFY360_API_KEY", "").strip()
78
+ if not resolved_key:
79
+ raise ValueError(
80
+ "[logify360] LOGIFY360_API_KEY is not set. Pass api_key=... to "
81
+ f"{caller}() or set the LOGIFY360_API_KEY environment variable."
82
+ )
83
+ return _checked_api_key(resolved_key, "api_key" if explicit else "LOGIFY360_API_KEY")
84
+
85
+
86
+ __all__ = ["HOSTED_LOGIFY360_BASE_URL", "logify360_base_url", "logify360_proxy_url"]
@@ -0,0 +1,33 @@
1
+ """Logify360 onboarding CLI.
2
+
3
+ Installed as the ``logify360`` console command via ``[project.scripts]``::
4
+
5
+ pip install logify360
6
+ logify360 init
7
+
8
+ Walks a Python developer through connecting their app to Logify360:
9
+
10
+ 1. Detect the project type (poetry / pipenv / uv / pip) and LLM libraries
11
+ 2. Validate the pasted Logify360 key against the API and introspect which
12
+ provider keys are registered
13
+ 3. Write ``LOGIFY360_API_KEY`` into ``.env`` (idempotent, comment-preserving)
14
+ 4. Install the ``logify360`` provider extras into the project
15
+ 5. Patch ``OpenAI(...)`` / ``Anthropic(...)`` / ``genai.configure(...)`` to
16
+ route through the Logify360 proxy
17
+
18
+ The CLI uses only the standard library plus ``httpx`` (already a runtime
19
+ dependency of the SDK), so installing the wheel makes ``logify360 init`` work
20
+ out of the box with no extra weight.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __all__ = ["main"]
26
+
27
+
28
+ def main() -> int:
29
+ """Console-script entry point. Imported lazily so importing the package
30
+ does not pull the whole CLI graph for SDK-only users."""
31
+ from .main import main as _main
32
+
33
+ return _main()
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m logify360.cli`` as an alias for the ``logify360`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .main import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
@@ -0,0 +1,449 @@
1
+ """AST-based patcher that routes direct LLM SDK usage through Logify360.
2
+
3
+ Python counterpart of ``packages/cli/src/code-patcher.ts``. We detect usage
4
+ with the standard-library :mod:`ast` (precise, no third-party parser) and then
5
+ splice targeted edits into the original source by byte offset, so formatting,
6
+ comments, and unrelated code are left untouched.
7
+
8
+ Supported rewrites::
9
+
10
+ from openai import OpenAI from logify360.integrations.openai import create_openai
11
+ client = OpenAI(api_key=KEY) → client = create_openai()
12
+
13
+ from anthropic import AsyncAnthropic from logify360.integrations.anthropic import create_async_anthropic
14
+ client = AsyncAnthropic() → client = create_async_anthropic()
15
+
16
+ import google.generativeai as genai import google.generativeai as genai
17
+ genai.configure(api_key=KEY) → from logify360.integrations.gemini import configure_gemini
18
+ configure_gemini()
19
+
20
+ Scope (MVP, matching the Node CLI): top-level ``from X import Name`` /
21
+ ``import X`` plus their call sites. Dynamic imports and re-exports are not
22
+ rewritten. The patch is always *additive-safe*. A patched file still imports
23
+ and runs even in the rare shapes we leave for the user to finish by hand.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+ import os
30
+ from dataclasses import dataclass, field
31
+ from typing import Dict, List, Optional, Tuple
32
+
33
+ # api_key / base_url are supplied by the Logify360 factory from the environment,
34
+ # so we strip them from the original constructor call.
35
+ _STRIP_KWARGS = frozenset({"api_key", "base_url"})
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ProviderSpec:
40
+ provider: str
41
+ module: str
42
+ classes: Dict[str, str] # original class name → Logify360 factory name
43
+ logify360_module: str
44
+
45
+
46
+ CONSTRUCTOR_SPECS: Dict[str, ProviderSpec] = {
47
+ "openai": ProviderSpec(
48
+ provider="openai",
49
+ module="openai",
50
+ classes={"OpenAI": "create_openai", "AsyncOpenAI": "create_async_openai"},
51
+ logify360_module="logify360.integrations.openai",
52
+ ),
53
+ "anthropic": ProviderSpec(
54
+ provider="anthropic",
55
+ module="anthropic",
56
+ classes={
57
+ "Anthropic": "create_anthropic",
58
+ "AsyncAnthropic": "create_async_anthropic",
59
+ },
60
+ logify360_module="logify360.integrations.anthropic",
61
+ ),
62
+ }
63
+
64
+ _GEMINI_MODULE = "google.generativeai"
65
+ _GEMINI_LOGIFY360_IMPORT = "from logify360.integrations.gemini import configure_gemini"
66
+
67
+ _EXCLUDE_DIRS = frozenset(
68
+ {
69
+ ".venv",
70
+ "venv",
71
+ "env",
72
+ ".env",
73
+ ".git",
74
+ "__pycache__",
75
+ "node_modules",
76
+ ".mypy_cache",
77
+ ".pytest_cache",
78
+ ".ruff_cache",
79
+ "build",
80
+ "dist",
81
+ ".tox",
82
+ ".eggs",
83
+ "site-packages",
84
+ }
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class PatchPlan:
90
+ filepath: str
91
+ provider: str
92
+ changes: List[str] = field(default_factory=list)
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class PatchResult:
97
+ filepath: str
98
+ provider: str
99
+ patched: bool
100
+ reason: Optional[str] = None
101
+
102
+
103
+ # ── source-editing primitives ────────────────────────────────────────
104
+
105
+
106
+ def _byte_line_starts(encoded: bytes) -> List[int]:
107
+ starts = [0]
108
+ for i, byte in enumerate(encoded):
109
+ if byte == 0x0A: # newline
110
+ starts.append(i + 1)
111
+ return starts
112
+
113
+
114
+ def _pos_to_byte(line_starts: List[int], lineno: int, col_offset: int) -> int:
115
+ # ast col_offset is a UTF-8 byte offset within the line (CPython semantics).
116
+ return line_starts[lineno - 1] + col_offset
117
+
118
+
119
+ def _node_span(line_starts: List[int], node: ast.AST) -> Tuple[int, int]:
120
+ start = _pos_to_byte(line_starts, node.lineno, node.col_offset) # type: ignore[attr-defined]
121
+ end = _pos_to_byte(line_starts, node.end_lineno, node.end_col_offset) # type: ignore[attr-defined]
122
+ return start, end
123
+
124
+
125
+ def _apply_edits(src: str, edits: List[Tuple[int, int, str]]) -> str:
126
+ """Apply (start_byte, end_byte, replacement) edits bottom-up on UTF-8 bytes."""
127
+ encoded = bytearray(src.encode("utf-8"))
128
+ for start, end, replacement in sorted(edits, key=lambda e: e[0], reverse=True):
129
+ encoded[start:end] = replacement.encode("utf-8")
130
+ return encoded.decode("utf-8")
131
+
132
+
133
+ def _segment(src: str, node: ast.AST) -> str:
134
+ seg = ast.get_source_segment(src, node)
135
+ return seg if seg is not None else ""
136
+
137
+
138
+ def _render_kept_args(src: str, call: ast.Call) -> Tuple[Optional[str], Optional[str]]:
139
+ """Reconstruct a call's argument list minus api_key / base_url.
140
+
141
+ Returns ``(kept_args, None)`` when every argument is statically readable,
142
+ or ``(None, shape)`` naming the argument shape that makes the credential
143
+ unremovable.
144
+
145
+ B#2: this is the ONE place the patcher decides whether the customer's
146
+ provider credential can be taken out of the call, so the refusal lives
147
+ here rather than at each call site. It used to copy ``*args`` and
148
+ ``**kwargs`` through verbatim while stripping only *literal* ``api_key=`` /
149
+ ``base_url=`` keywords, so ``OpenAI(**cfg)`` became ``create_openai(**cfg)``
150
+ and integrations/openai.py then resolved ``api_key or env`` /
151
+ ``base_url or proxy`` - the app kept talking straight to the provider with
152
+ the raw key while the wizard reported success. A splat's contents are
153
+ unknowable at AST time, and every ``create_*`` factory is keyword-only
154
+ (integrations/openai.py), so a positional argument cannot be forwarded
155
+ either. Refuse both and let the caller report it.
156
+ """
157
+ if call.args:
158
+ starred = any(isinstance(a, ast.Starred) for a in call.args)
159
+ return None, "*args" if starred else "a positional argument"
160
+ parts: List[str] = []
161
+ for kw in call.keywords:
162
+ if kw.arg is None: # **kwargs - may carry api_key / base_url
163
+ return None, "**kwargs"
164
+ if kw.arg not in _STRIP_KWARGS:
165
+ parts.append(f"{kw.arg}={_segment(src, kw.value)}")
166
+ return ", ".join(parts), None
167
+
168
+
169
+ def _refusal_message(class_name: str, factory: str, shape: str) -> str:
170
+ """Wording for a call the patcher will not rewrite (see _render_kept_args)."""
171
+ return (
172
+ f"{class_name}(...) forwards {shape}, so api_key / base_url cannot be removed "
173
+ f"statically - patch this call by hand: call {factory}(...) with the "
174
+ "non-credential options only (api_key / base_url come from the environment)."
175
+ )
176
+
177
+
178
+ # ── constructor providers (OpenAI / Anthropic) ───────────────────────
179
+
180
+
181
+ def _patch_constructor(
182
+ src: str, spec: ProviderSpec
183
+ ) -> Tuple[Optional[str], List[str], Optional[str]]:
184
+ try:
185
+ tree = ast.parse(src)
186
+ except SyntaxError:
187
+ return None, [], None
188
+
189
+ line_starts = _byte_line_starts(src.encode("utf-8"))
190
+
191
+ # local class name → original class name (named imports)
192
+ named_bindings: Dict[str, str] = {}
193
+ named_import_nodes: List[ast.ImportFrom] = []
194
+ # local module alias → True (module imports: `import openai` / `import openai as oai`)
195
+ module_aliases: Dict[str, bool] = {}
196
+ module_import_nodes: List[ast.Import] = []
197
+
198
+ for node in ast.walk(tree):
199
+ if isinstance(node, ast.ImportFrom) and node.module == spec.module:
200
+ for alias in node.names:
201
+ if alias.name in spec.classes:
202
+ local = alias.asname or alias.name
203
+ named_bindings[local] = alias.name
204
+ if node not in named_import_nodes:
205
+ named_import_nodes.append(node)
206
+ elif isinstance(node, ast.Import):
207
+ for alias in node.names:
208
+ if alias.name == spec.module:
209
+ module_aliases[alias.asname or alias.name] = True
210
+ if node not in module_import_nodes:
211
+ module_import_nodes.append(node)
212
+
213
+ if not named_bindings and not module_aliases:
214
+ return None, [], None
215
+
216
+ edits: List[Tuple[int, int, str]] = []
217
+ used_factories: List[str] = []
218
+ call_count = 0
219
+
220
+ for node in ast.walk(tree):
221
+ if not isinstance(node, ast.Call):
222
+ continue
223
+ func = node.func
224
+ class_name: Optional[str] = None
225
+ if isinstance(func, ast.Name) and func.id in named_bindings:
226
+ class_name = named_bindings[func.id]
227
+ elif (
228
+ isinstance(func, ast.Attribute)
229
+ and isinstance(func.value, ast.Name)
230
+ and func.value.id in module_aliases
231
+ and func.attr in spec.classes
232
+ ):
233
+ class_name = func.attr
234
+ if class_name is None:
235
+ continue
236
+
237
+ factory = spec.classes[class_name]
238
+ if factory not in used_factories:
239
+ used_factories.append(factory)
240
+ kept, opaque = _render_kept_args(src, node)
241
+ if opaque is not None:
242
+ # Refuse the whole FILE: a partial rewrite would leave this call
243
+ # holding the raw key while the report claimed the file was patched.
244
+ return None, [], _refusal_message(class_name, factory, opaque)
245
+ start, end = _node_span(line_starts, node)
246
+ edits.append((start, end, f"{factory}({kept})"))
247
+ call_count += 1
248
+
249
+ if call_count == 0:
250
+ return None, [], None
251
+
252
+ changes: List[str] = []
253
+ import_line = f"from {spec.logify360_module} import {', '.join(used_factories)}"
254
+
255
+ # Prefer replacing a named import that imports ONLY provider classes,
256
+ # that yields the clean `from openai import OpenAI` → logify360 swap.
257
+ replaced_import = False
258
+ for node in named_import_nodes:
259
+ only_provider = all(a.name in spec.classes for a in node.names)
260
+ if only_provider and not replaced_import:
261
+ start, end = _node_span(line_starts, node)
262
+ edits.append((start, end, import_line))
263
+ replaced_import = True
264
+ changes.append(f"import: from {spec.module} → {import_line}")
265
+ break
266
+
267
+ if not replaced_import:
268
+ # Insert the Logify360 import right after the first provider import we saw.
269
+ anchor: Optional[ast.AST] = (
270
+ named_import_nodes[0]
271
+ if named_import_nodes
272
+ else (module_import_nodes[0] if module_import_nodes else None)
273
+ )
274
+ if anchor is not None:
275
+ _, end = _node_span(line_starts, anchor)
276
+ edits.append((end, end, f"\n{import_line}"))
277
+ changes.append(f"add import: {import_line}")
278
+
279
+ changes.append(
280
+ f"{call_count} × constructor → {', '.join(sorted(used_factories))}(...)"
281
+ )
282
+ return _apply_edits(src, edits), changes, None
283
+
284
+
285
+ # ── gemini (configure-call style) ────────────────────────────────────
286
+
287
+
288
+ def _patch_gemini(src: str) -> Tuple[Optional[str], List[str], Optional[str]]:
289
+ # genai.configure(...) drops ALL arguments, so there is no credential left
290
+ # to strip and nothing to refuse.
291
+ try:
292
+ tree = ast.parse(src)
293
+ except SyntaxError:
294
+ return None, [], None
295
+
296
+ line_starts = _byte_line_starts(src.encode("utf-8"))
297
+
298
+ module_aliases: Dict[str, bool] = {}
299
+ import_nodes: List[ast.Import] = []
300
+ for node in ast.walk(tree):
301
+ if isinstance(node, ast.Import):
302
+ for alias in node.names:
303
+ if alias.name == _GEMINI_MODULE:
304
+ module_aliases[alias.asname or alias.name.split(".")[0]] = True
305
+ if node not in import_nodes:
306
+ import_nodes.append(node)
307
+
308
+ if not module_aliases:
309
+ return None, [], None
310
+
311
+ edits: List[Tuple[int, int, str]] = []
312
+ call_count = 0
313
+ for node in ast.walk(tree):
314
+ if (
315
+ isinstance(node, ast.Call)
316
+ and isinstance(node.func, ast.Attribute)
317
+ and node.func.attr == "configure"
318
+ and isinstance(node.func.value, ast.Name)
319
+ and node.func.value.id in module_aliases
320
+ ):
321
+ start, end = _node_span(line_starts, node)
322
+ edits.append((start, end, "configure_gemini()"))
323
+ call_count += 1
324
+
325
+ if call_count == 0:
326
+ return None, [], None
327
+
328
+ _, end = _node_span(line_starts, import_nodes[0])
329
+ edits.append((end, end, f"\n{_GEMINI_LOGIFY360_IMPORT}"))
330
+
331
+ changes = [
332
+ f"add import: {_GEMINI_LOGIFY360_IMPORT}",
333
+ f"{call_count} × genai.configure(...) → configure_gemini()",
334
+ ]
335
+ return _apply_edits(src, edits), changes, None
336
+
337
+
338
+ def _patch_source(src: str, provider: str) -> Tuple[Optional[str], List[str], Optional[str]]:
339
+ """``(new_source, changes, refusal)``. Exactly one of new_source/refusal is set."""
340
+ if provider == "gemini":
341
+ return _patch_gemini(src)
342
+ spec = CONSTRUCTOR_SPECS.get(provider)
343
+ if spec is None:
344
+ return None, [], None
345
+ return _patch_constructor(src, spec)
346
+
347
+
348
+ # ── filesystem scanning ──────────────────────────────────────────────
349
+
350
+
351
+ def _candidate_files(root: str) -> List[str]:
352
+ out: List[str] = []
353
+ for dirpath, dirnames, filenames in os.walk(root):
354
+ dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIRS]
355
+ for name in filenames:
356
+ if name.endswith(".py"):
357
+ out.append(os.path.join(dirpath, name))
358
+ return out
359
+
360
+
361
+ def _prefilter(text: str, provider: str) -> bool:
362
+ if provider == "gemini":
363
+ return _GEMINI_MODULE in text and "configure" in text
364
+ spec = CONSTRUCTOR_SPECS.get(provider)
365
+ if spec is None:
366
+ return False
367
+ return spec.module in text and any(cls in text for cls in spec.classes)
368
+
369
+
370
+ def plan_patches(cwd: str, providers: List[str]) -> List[PatchPlan]:
371
+ plans: List[PatchPlan] = []
372
+ supported = [p for p in providers if p == "gemini" or p in CONSTRUCTOR_SPECS]
373
+ if not supported:
374
+ return plans
375
+
376
+ for filepath in _candidate_files(cwd):
377
+ try:
378
+ with open(filepath, encoding="utf-8") as fh:
379
+ src = fh.read()
380
+ except (OSError, UnicodeDecodeError):
381
+ continue
382
+ for provider in supported:
383
+ if not _prefilter(src, provider):
384
+ continue
385
+ new_src, changes, refusal = _patch_source(src, provider)
386
+ if refusal is not None:
387
+ # Listing it is the useful half of a preview; `changes` must not
388
+ # advertise a rewrite that apply_patches will refuse (B#2).
389
+ plans.append(
390
+ PatchPlan(
391
+ filepath=filepath,
392
+ provider=provider,
393
+ changes=[f"will NOT be patched - {refusal}"],
394
+ )
395
+ )
396
+ elif new_src is not None and changes:
397
+ plans.append(PatchPlan(filepath=filepath, provider=provider, changes=changes))
398
+ return plans
399
+
400
+
401
+ def apply_patches(plans: List[PatchPlan], *, dry_run: bool = False) -> List[PatchResult]:
402
+ results: List[PatchResult] = []
403
+ for plan in plans:
404
+ try:
405
+ with open(plan.filepath, encoding="utf-8") as fh:
406
+ src = fh.read()
407
+ except (OSError, UnicodeDecodeError) as exc:
408
+ results.append(
409
+ PatchResult(plan.filepath, plan.provider, patched=False, reason=str(exc))
410
+ )
411
+ continue
412
+ new_src, _changes, refusal = _patch_source(src, plan.provider)
413
+ if refusal is not None:
414
+ results.append(
415
+ PatchResult(plan.filepath, plan.provider, patched=False, reason=refusal)
416
+ )
417
+ continue
418
+ if new_src is None or new_src == src:
419
+ results.append(
420
+ PatchResult(plan.filepath, plan.provider, patched=False, reason="no change")
421
+ )
422
+ continue
423
+ if not dry_run:
424
+ try:
425
+ with open(plan.filepath, "w", encoding="utf-8") as fh:
426
+ fh.write(new_src)
427
+ except OSError as exc:
428
+ # Guarded for the same reason the read above is: a read-only
429
+ # file (EACCES) or one removed between plan and apply must
430
+ # surface as a REFUSAL - patch_report() then says "setup
431
+ # INCOMPLETE" and exits 1, mirroring packages/cli/src/index.ts
432
+ # :461-474 - instead of a PermissionError traceback out of the
433
+ # wizard (_cmd_init only catches PromptCancelledError).
434
+ results.append(
435
+ PatchResult(plan.filepath, plan.provider, patched=False, reason=str(exc))
436
+ )
437
+ continue
438
+ results.append(PatchResult(plan.filepath, plan.provider, patched=True))
439
+ return results
440
+
441
+
442
+ __all__ = [
443
+ "CONSTRUCTOR_SPECS",
444
+ "PatchPlan",
445
+ "PatchResult",
446
+ "ProviderSpec",
447
+ "apply_patches",
448
+ "plan_patches",
449
+ ]