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 +99 -0
- logify360/_base_url.py +86 -0
- logify360/cli/__init__.py +33 -0
- logify360/cli/__main__.py +10 -0
- logify360/cli/code_patcher.py +449 -0
- logify360/cli/colors.py +65 -0
- logify360/cli/env_writer.py +100 -0
- logify360/cli/framework_detect.py +183 -0
- logify360/cli/installer.py +143 -0
- logify360/cli/key_info.py +83 -0
- logify360/cli/main.py +557 -0
- logify360/cli/prompts.py +146 -0
- logify360/client.py +205 -0
- logify360/evals.py +186 -0
- logify360/headers.py +95 -0
- logify360/integrations/__init__.py +60 -0
- logify360/integrations/_proxy_client.py +133 -0
- logify360/integrations/anthropic.py +171 -0
- logify360/integrations/gemini.py +180 -0
- logify360/integrations/langchain.py +450 -0
- logify360/integrations/llama_index.py +486 -0
- logify360/integrations/openai.py +158 -0
- logify360/middleware.py +262 -0
- logify360/observe.py +430 -0
- logify360/parsers.py +218 -0
- logify360/resilience.py +530 -0
- logify360/sampler.py +243 -0
- logify360/span.py +306 -0
- logify360/trace.py +222 -0
- logify360/transport.py +455 -0
- logify360/trust.py +310 -0
- logify360/types.py +66 -0
- logify360-1.0.0.dist-info/METADATA +548 -0
- logify360-1.0.0.dist-info/RECORD +36 -0
- logify360-1.0.0.dist-info/WHEEL +4 -0
- logify360-1.0.0.dist-info/entry_points.txt +2 -0
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,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
|
+
]
|