honestcode 0.5.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.
- honestcode/__init__.py +19 -0
- honestcode/cli.py +428 -0
- honestcode/graph/__init__.py +13 -0
- honestcode/graph/graph_store.py +276 -0
- honestcode/graph/watcher.py +116 -0
- honestcode/guards/__init__.py +104 -0
- honestcode/guards/guard.py +777 -0
- honestcode/guards/store.py +457 -0
- honestcode/guards/taxonomy.py +228 -0
- honestcode/honest/__init__.py +19 -0
- honestcode/honest/project_binding.py +200 -0
- honestcode/honest/router.py +205 -0
- honestcode/honest/symbol_index.py +446 -0
- honestcode/integrations/__init__.py +14 -0
- honestcode/integrations/pydantic_ai.py +304 -0
- honestcode/mcp/__init__.py +46 -0
- honestcode/mcp/knowledge_base.py +290 -0
- honestcode/mcp/server.py +357 -0
- honestcode/mcp/tools.py +2002 -0
- honestcode/py.typed +0 -0
- honestcode/sandbox/__init__.py +334 -0
- honestcode/structure/__init__.py +6 -0
- honestcode/structure/extractor.py +353 -0
- honestcode/structure/multi_lang.py +267 -0
- honestcode/structure/relations.py +47 -0
- honestcode/structure/utils.py +262 -0
- honestcode/verify/__init__.py +19 -0
- honestcode/verify/evidence.py +195 -0
- honestcode/verify/surface.py +497 -0
- honestcode/verify/verify.py +1074 -0
- honestcode-0.5.0.dist-info/METADATA +669 -0
- honestcode-0.5.0.dist-info/RECORD +36 -0
- honestcode-0.5.0.dist-info/WHEEL +5 -0
- honestcode-0.5.0.dist-info/entry_points.txt +3 -0
- honestcode-0.5.0.dist-info/licenses/LICENSE +21 -0
- honestcode-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""pydantic-ai integration for :mod:`honestcode.guards`.
|
|
2
|
+
|
|
3
|
+
Wraps tool execution so that an effect the framework has already carried out is
|
|
4
|
+
not carried out a second time — including across a crash and restart, which is
|
|
5
|
+
the case `pydantic-ai` cannot cover by itself.
|
|
6
|
+
|
|
7
|
+
Why this is a separate package from ``honestcode.guards``:
|
|
8
|
+
|
|
9
|
+
* ``guards`` is stdlib-only and framework-agnostic. Importing ``pydantic_ai``
|
|
10
|
+
into it would end that, and the isolation rule in ``tests/test_guards.py``
|
|
11
|
+
enforces it.
|
|
12
|
+
* The deferral primitives live in the framework. ``pydantic-ai`` already offers
|
|
13
|
+
in-process deferral (``CallDeferred``, ``ApprovalRequired``) from
|
|
14
|
+
``before_tool_execute``; those are the right tool for *a human should look at
|
|
15
|
+
this*. This integration is for the different question: *did the outside world
|
|
16
|
+
already change, and I no longer remember*. That question survives a process
|
|
17
|
+
restart, so it needs the durable store.
|
|
18
|
+
|
|
19
|
+
The hook, and why it works
|
|
20
|
+
--------------------------
|
|
21
|
+
|
|
22
|
+
``wrap_tool_execute`` wraps the whole lifecycle. Returning without calling
|
|
23
|
+
``handler()`` skips execution entirely and the returned value becomes the tool
|
|
24
|
+
result the model sees — verified against ``pydantic-ai`` 2.55.0: with
|
|
25
|
+
``handler`` called the tool runs once, with it skipped the tool runs zero times
|
|
26
|
+
and the substitute value reaches the model.
|
|
27
|
+
|
|
28
|
+
That is exactly the shape an idempotency guard needs. What this class adds over
|
|
29
|
+
the framework's own deferral is *memory that outlives the process*.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from collections.abc import Callable, Mapping
|
|
40
|
+
|
|
41
|
+
from honestcode.guards import (
|
|
42
|
+
EffectClass,
|
|
43
|
+
EffectStore,
|
|
44
|
+
SideEffectGuard,
|
|
45
|
+
classify,
|
|
46
|
+
durable_scope,
|
|
47
|
+
)
|
|
48
|
+
from honestcode.guards.guard import EffectNotRunnable, GuardError
|
|
49
|
+
|
|
50
|
+
try: # pragma: no cover - import guard only
|
|
51
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
52
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
53
|
+
except ImportError as exc: # pragma: no cover
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"honestcode.integrations.pydantic_ai requires pydantic-ai. "
|
|
56
|
+
"From a checkout, install it with: pip install -e '.[pydantic-ai]'"
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
60
|
+
from pydantic_ai.tools import ToolDefinition
|
|
61
|
+
|
|
62
|
+
__all__ = ["EffectGuardCapability", "ChargePolicy", "default_policy"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _as_mapping(value: Any) -> dict[str, Any] | None:
|
|
66
|
+
"""Coerce tool-call arguments into a mapping, or ``None`` if impossible.
|
|
67
|
+
|
|
68
|
+
``ToolCallPart.args`` is typed as a dict but arrives as a **JSON string**
|
|
69
|
+
while streaming — which is the normal case for a real provider, not an edge
|
|
70
|
+
case. Treating the string form as unkeyable made the guard pass every real
|
|
71
|
+
call straight through while reporting nothing, so this accepts both.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
stripped = value.strip()
|
|
77
|
+
if not stripped:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
parsed = json.loads(stripped)
|
|
81
|
+
except (ValueError, TypeError):
|
|
82
|
+
return None
|
|
83
|
+
return parsed if isinstance(parsed, dict) else None
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── policy ──────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ChargePolicy:
|
|
92
|
+
"""Which tool calls count as effects worth guarding, and what may run.
|
|
93
|
+
|
|
94
|
+
A guard that wraps every tool call is noise: ``read_file`` and
|
|
95
|
+
``list_directory`` are not effects, and recording them grows the store while
|
|
96
|
+
adding nothing. This decides what is worth a durable record.
|
|
97
|
+
|
|
98
|
+
The defaults are deliberately narrow — guard what can hurt, let reads
|
|
99
|
+
through untouched, refuse irreversible calls until someone opts in. Only the
|
|
100
|
+
caller knows which of their tools reach the outside world, so widening this
|
|
101
|
+
is their decision.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
approve_irreversible: bool = False
|
|
105
|
+
"""Whether irreversible effects may run.
|
|
106
|
+
|
|
107
|
+
Defaults to refusing, so the first attempt raises a retry asking for
|
|
108
|
+
approval rather than charging a card. The guard's own default, restated here
|
|
109
|
+
because this is the layer where an application is most likely to want the
|
|
110
|
+
blanket decision.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
approve: Callable[[str, Mapping[str, Any]], bool] | None = None
|
|
114
|
+
"""Optional per-call approval, consulted when ``approve_irreversible`` is
|
|
115
|
+
False. Lets an application decide on the specifics — an order's status, a
|
|
116
|
+
spend limit, an allowlist — instead of approving a whole class. Returning
|
|
117
|
+
True for one call does not approve the next."""
|
|
118
|
+
|
|
119
|
+
guard_classes: frozenset[EffectClass] = frozenset(
|
|
120
|
+
{EffectClass.REVERSIBLE, EffectClass.IRREVERSIBLE}
|
|
121
|
+
)
|
|
122
|
+
"""Effect classes that get a durable record. Reads are excluded by default."""
|
|
123
|
+
|
|
124
|
+
classify: Callable[[str, str], EffectClass] = classify
|
|
125
|
+
"""How to decide the class. Defaults to the shared action-name table."""
|
|
126
|
+
|
|
127
|
+
read_only_tools: frozenset[str] = frozenset()
|
|
128
|
+
"""Tool names the application declares to be reads.
|
|
129
|
+
|
|
130
|
+
The shared table describes generic names; it cannot know an application's
|
|
131
|
+
own vocabulary. Without a declaration here, a tool whose name the table does
|
|
132
|
+
not recognise falls to the conservative default — irreversible — and is then
|
|
133
|
+
refused unless approved, which for a local file read is a false positive
|
|
134
|
+
that costs an approval.
|
|
135
|
+
|
|
136
|
+
Declaring is a deliberate act, and that is the point: only the application
|
|
137
|
+
knows whether `search_docs` hits a local index or a paid API.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def applies_to(self, effect_class: EffectClass) -> bool:
|
|
141
|
+
return effect_class in self.guard_classes
|
|
142
|
+
|
|
143
|
+
def class_of(self, tool_name: str, target: str) -> EffectClass:
|
|
144
|
+
"""The effect class for a call, honouring declared reads."""
|
|
145
|
+
if tool_name in self.read_only_tools:
|
|
146
|
+
return EffectClass.PURE
|
|
147
|
+
return self.classify(tool_name, target)
|
|
148
|
+
|
|
149
|
+
def approves(self, tool_name: str, args: Mapping[str, Any]) -> bool:
|
|
150
|
+
if self.approve_irreversible:
|
|
151
|
+
return True
|
|
152
|
+
if self.approve is not None:
|
|
153
|
+
return bool(self.approve(tool_name, args))
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def default_policy() -> ChargePolicy:
|
|
158
|
+
return ChargePolicy()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── capability ──────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class EffectGuardCapability(AbstractCapability): # type: ignore[misc]
|
|
166
|
+
"""Make agent tool calls idempotent across a crash and restart.
|
|
167
|
+
|
|
168
|
+
Usage::
|
|
169
|
+
|
|
170
|
+
from honestcode.guards import EffectStore
|
|
171
|
+
from honestcode.integrations.pydantic_ai import EffectGuardCapability
|
|
172
|
+
|
|
173
|
+
cap = EffectGuardCapability(
|
|
174
|
+
store=EffectStore(".agent/effects.db"),
|
|
175
|
+
run_id="order-4711-fulfilment", # stable across retries
|
|
176
|
+
)
|
|
177
|
+
agent = Agent(model, tools=[charge_card], capabilities=[cap])
|
|
178
|
+
|
|
179
|
+
``run_id`` is the durability boundary and the field that matters most. It
|
|
180
|
+
must be the identifier the application can reproduce after a restart — a
|
|
181
|
+
workflow run id, a job id, an order id. Passing a fresh id on every attempt
|
|
182
|
+
turns the guard into a very expensive no-op, because a new id means every
|
|
183
|
+
retry looks like a new effect.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
store: EffectStore
|
|
187
|
+
run_id: str
|
|
188
|
+
policy: ChargePolicy = field(default_factory=default_policy)
|
|
189
|
+
|
|
190
|
+
receipts: dict[str, str] = field(default_factory=dict)
|
|
191
|
+
"""Effect id per tool call id, for observability after a run."""
|
|
192
|
+
|
|
193
|
+
unguarded_calls: list[str] = field(default_factory=list)
|
|
194
|
+
"""Tool names that passed through because their arguments were unkeyable.
|
|
195
|
+
|
|
196
|
+
Non-empty after a run means the guard did *not* protect those calls. That is
|
|
197
|
+
the failure mode this attribute exists to make visible: the first version of
|
|
198
|
+
this file let every real call through this way and recorded nothing.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __post_init__(self) -> None:
|
|
202
|
+
if not self.run_id:
|
|
203
|
+
raise ValueError("run_id must not be empty — it is the durability boundary")
|
|
204
|
+
self._guard = SideEffectGuard(session_id=f"pydantic-ai:{self.run_id}", store=self.store)
|
|
205
|
+
|
|
206
|
+
# ── the hook ────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async def wrap_tool_execute(
|
|
209
|
+
self,
|
|
210
|
+
ctx: Any,
|
|
211
|
+
*,
|
|
212
|
+
call: Any,
|
|
213
|
+
tool_def: ToolDefinition,
|
|
214
|
+
args: Any,
|
|
215
|
+
handler: Any,
|
|
216
|
+
) -> Any:
|
|
217
|
+
tool_name = getattr(call, "tool_name", "") or getattr(tool_def, "name", "")
|
|
218
|
+
call_args = _as_mapping(getattr(call, "args", None))
|
|
219
|
+
if call_args is None:
|
|
220
|
+
# Args are absent or unparseable. Without a mapping we cannot key
|
|
221
|
+
# the effect, and guessing risks two different effects sharing a
|
|
222
|
+
# key, so the call is let through unguarded — but silently, which is
|
|
223
|
+
# exactly how the first version of this file shipped a guard that
|
|
224
|
+
# never ran. Record the skip so it is visible in a test or a log.
|
|
225
|
+
self.unguarded_calls.append(tool_name)
|
|
226
|
+
return await handler(args)
|
|
227
|
+
|
|
228
|
+
target = _target_of(tool_name, call_args)
|
|
229
|
+
effect_class = self.policy.class_of(tool_name, target)
|
|
230
|
+
|
|
231
|
+
if not self.policy.applies_to(effect_class):
|
|
232
|
+
return await handler(args)
|
|
233
|
+
|
|
234
|
+
scope = durable_scope(self.run_id, tool_name, target)
|
|
235
|
+
approved = effect_class is not EffectClass.IRREVERSIBLE or self.policy.approves(
|
|
236
|
+
tool_name, call_args
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def call_handler() -> Any:
|
|
240
|
+
return await handler(args)
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
outcome = await self._guard.arun(
|
|
244
|
+
tool_name,
|
|
245
|
+
target=target,
|
|
246
|
+
payload=call_args,
|
|
247
|
+
effect_class=effect_class,
|
|
248
|
+
approve_irreversible=approved,
|
|
249
|
+
scope=scope,
|
|
250
|
+
# The handler is async, and arun writes the `executing` claim
|
|
251
|
+
# before awaiting it — so a crash while the tool is in flight
|
|
252
|
+
# leaves an unknown outcome rather than looking unstarted.
|
|
253
|
+
execute=call_handler,
|
|
254
|
+
)
|
|
255
|
+
except EffectNotRunnable as exc:
|
|
256
|
+
raise ModelRetry(_stuck_message(exc, tool_name, target)) from exc
|
|
257
|
+
except GuardError as exc:
|
|
258
|
+
raise ModelRetry(_refused_message(exc, tool_name, target)) from exc
|
|
259
|
+
|
|
260
|
+
tool_call_id = getattr(call, "tool_call_id", "")
|
|
261
|
+
if tool_call_id:
|
|
262
|
+
self.receipts[tool_call_id] = outcome.effect_id
|
|
263
|
+
|
|
264
|
+
if outcome.deduplicated:
|
|
265
|
+
return _receipt(tool_name, target, effect_class, outcome)
|
|
266
|
+
return outcome.value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _target_of(tool_name: str, args: Mapping[str, Any]) -> str:
|
|
270
|
+
"""A short, stable description of *what* the effect acts on.
|
|
271
|
+
|
|
272
|
+
Used in the scope and in receipts. Picks the first recognisable identifier
|
|
273
|
+
in the arguments rather than dumping them all, so the scope stays stable
|
|
274
|
+
against irrelevant argument churn.
|
|
275
|
+
"""
|
|
276
|
+
for key in ("target", "path", "customer", "customer_id", "order_id", "id", "name"):
|
|
277
|
+
if key in args and isinstance(args[key], str | int):
|
|
278
|
+
return f"{key}={args[key]}"
|
|
279
|
+
return tool_name
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _receipt(tool_name: str, target: str, effect_class: EffectClass, outcome: Any) -> str:
|
|
283
|
+
return (
|
|
284
|
+
f"[effect-guard] {tool_name}({target}) was NOT run again — this "
|
|
285
|
+
f"{effect_class.bare} effect already completed in run "
|
|
286
|
+
f"{outcome.record.effect_id}. The previous result stands. If you need a "
|
|
287
|
+
f"genuinely new effect, change the argument that identifies it."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _refused_message(exc: GuardError, tool_name: str, target: str) -> str:
|
|
292
|
+
return (
|
|
293
|
+
f"[effect-guard] {tool_name}({target}) was not run: {exc}. "
|
|
294
|
+
f"Resolve the condition and retry, or use a different action."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _stuck_message(exc: EffectNotRunnable, tool_name: str, target: str) -> str:
|
|
299
|
+
return (
|
|
300
|
+
f"[effect-guard] {tool_name}({target}) has an unknown outcome from an "
|
|
301
|
+
f"interrupted attempt ({exc.status}). It may already have reached the "
|
|
302
|
+
f"outside world. Do not retry it blindly — it needs to be reconciled "
|
|
303
|
+
f"with the external system first."
|
|
304
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""MCP server integration for HonestCode.
|
|
2
|
+
|
|
3
|
+
Import directly from submodules to avoid loading the MCP server eagerly:
|
|
4
|
+
|
|
5
|
+
from honestcode.mcp.tools import scan_file
|
|
6
|
+
from honestcode.mcp.server import main
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"APIKnowledgeBase",
|
|
13
|
+
"APISignature",
|
|
14
|
+
"check_api",
|
|
15
|
+
"check_symbol",
|
|
16
|
+
"choose_tool",
|
|
17
|
+
"execute_code",
|
|
18
|
+
"explore_call_graph",
|
|
19
|
+
"find_dead_code",
|
|
20
|
+
"find_similar_code",
|
|
21
|
+
"get_project_stats",
|
|
22
|
+
"index_project",
|
|
23
|
+
"load_package_apis",
|
|
24
|
+
"load_project_deps",
|
|
25
|
+
"main",
|
|
26
|
+
"mcp",
|
|
27
|
+
"scan_file",
|
|
28
|
+
"search_code",
|
|
29
|
+
"validate_types",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def __getattr__(name: str):
|
|
34
|
+
if name in ("main", "mcp"):
|
|
35
|
+
from honestcode.mcp.server import main, mcp
|
|
36
|
+
|
|
37
|
+
return {"main": main, "mcp": mcp}[name]
|
|
38
|
+
if name in ("APIKnowledgeBase", "APISignature"):
|
|
39
|
+
from honestcode.mcp.knowledge_base import APIKnowledgeBase, APISignature
|
|
40
|
+
|
|
41
|
+
return {"APIKnowledgeBase": APIKnowledgeBase, "APISignature": APISignature}[name]
|
|
42
|
+
if name in __all__:
|
|
43
|
+
from honestcode.mcp import tools
|
|
44
|
+
|
|
45
|
+
return getattr(tools, name)
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Knowledge base of dependency APIs for HonestCode.
|
|
3
|
+
|
|
4
|
+
Loads API signatures from installed packages via ``pydoc`` / AST inspection
|
|
5
|
+
and stores them for fast lookup during hallucination checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import logging
|
|
12
|
+
import pkgutil
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
__all__ = ["APISignature", "APIKnowledgeBase", "resolve_import_names"]
|
|
20
|
+
|
|
21
|
+
# Fallback for environments where importlib.metadata has no answer. Maps
|
|
22
|
+
# PyPI distribution name -> import name for the common offenders whose PyPI
|
|
23
|
+
# and import names differ; without this, ``pip install PyYAML`` would never
|
|
24
|
+
# load ``yaml`` APIs and every ``from yaml import safe_load`` would be
|
|
25
|
+
# reported as an undefined symbol.
|
|
26
|
+
_FALLBACK_DIST_TO_IMPORT: dict[str, str] = {
|
|
27
|
+
"pillow": "PIL",
|
|
28
|
+
"scikit-learn": "sklearn",
|
|
29
|
+
"scikit-image": "skimage",
|
|
30
|
+
"beautifulsoup4": "bs4",
|
|
31
|
+
"python-dateutil": "dateutil",
|
|
32
|
+
"opencv-python": "cv2",
|
|
33
|
+
"pyyaml": "yaml",
|
|
34
|
+
"pycryptodome": "Crypto",
|
|
35
|
+
"python-dotenv": "dotenv",
|
|
36
|
+
"gitpython": "git",
|
|
37
|
+
"pyserial": "serial",
|
|
38
|
+
"msgpack-python": "msgpack",
|
|
39
|
+
# The slim distribution and its import name differ by a hyphen.
|
|
40
|
+
"pydantic-ai-slim": "pydantic_ai",
|
|
41
|
+
"pydantic-ai": "pydantic_ai",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_import_to_distributions: dict[str, list[str]] | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalise_dist(name: str) -> str:
|
|
48
|
+
"""Normalize a distribution name per PEP 503 (``PyYAML`` -> ``pyyaml``)."""
|
|
49
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _import_distribution_map() -> dict[str, list[str]]:
|
|
53
|
+
global _import_to_distributions
|
|
54
|
+
if _import_to_distributions is None:
|
|
55
|
+
try:
|
|
56
|
+
from importlib.metadata import packages_distributions
|
|
57
|
+
|
|
58
|
+
_import_to_distributions = packages_distributions()
|
|
59
|
+
except Exception as e: # noqa: BLE001
|
|
60
|
+
logger.debug("packages_distributions unavailable: %s", e)
|
|
61
|
+
_import_to_distributions = {}
|
|
62
|
+
return _import_to_distributions
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_import_names(package_name: str) -> set[str]:
|
|
66
|
+
"""Return import-name candidates for a PyPI distribution name.
|
|
67
|
+
|
|
68
|
+
``load_package("PyYAML")`` must import ``yaml``, not ``PyYAML``. Candidates
|
|
69
|
+
come from :func:`importlib.metadata.packages_distributions` (import name ->
|
|
70
|
+
installed distributions, reversed here), with a static fallback table for
|
|
71
|
+
the well-known mismatches.
|
|
72
|
+
"""
|
|
73
|
+
candidates = {package_name, package_name.replace("-", "_")}
|
|
74
|
+
norm = _normalise_dist(package_name)
|
|
75
|
+
for import_name, dists in _import_distribution_map().items():
|
|
76
|
+
if any(_normalise_dist(d) == norm for d in dists):
|
|
77
|
+
candidates.add(import_name)
|
|
78
|
+
for dist, import_name in _FALLBACK_DIST_TO_IMPORT.items():
|
|
79
|
+
if _normalise_dist(dist) == norm:
|
|
80
|
+
candidates.add(import_name)
|
|
81
|
+
return candidates
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class APISignature:
|
|
86
|
+
name: str
|
|
87
|
+
signature: str = ""
|
|
88
|
+
doc: str = ""
|
|
89
|
+
module: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class APIKnowledgeBase:
|
|
93
|
+
"""Lightweight cache of public API signatures for known packages."""
|
|
94
|
+
|
|
95
|
+
def __init__(self):
|
|
96
|
+
self._lock = threading.RLock()
|
|
97
|
+
self.apis: dict[str, APISignature] = {}
|
|
98
|
+
self._loaded_packages: set[str] = set()
|
|
99
|
+
# Import-name roots seen so far — including packages that were
|
|
100
|
+
# *declared* (in requirements.txt / pyproject.toml) but failed to
|
|
101
|
+
# import. Imports from declared roots stay trusted even when the
|
|
102
|
+
# package is not installed, so missing installs never turn into
|
|
103
|
+
# false "undefined symbol" findings.
|
|
104
|
+
self._roots: set[str] = set()
|
|
105
|
+
# Module names whose member surface was actually enumerated (the
|
|
106
|
+
# package root and the submodules that imported successfully). Only
|
|
107
|
+
# these can be used to decide that ``mod.attr`` is *missing* — a
|
|
108
|
+
# declared-but-uninstalled package, or a submodule we never walked
|
|
109
|
+
# into, knows nothing about its members, so member checks must stay
|
|
110
|
+
# silent for them rather than assume an empty surface.
|
|
111
|
+
self._enumerated: set[str] = set()
|
|
112
|
+
|
|
113
|
+
def declare_package(self, package_name: str) -> None:
|
|
114
|
+
"""Register a declared dependency without importing it.
|
|
115
|
+
|
|
116
|
+
Imports from a declared package are trusted (the package is part of
|
|
117
|
+
the project's contract), but no member surface exists, so invented-API
|
|
118
|
+
checks stay silent for it — "not in the index" is not evidence of
|
|
119
|
+
absence when the package was never imported.
|
|
120
|
+
"""
|
|
121
|
+
import_names = resolve_import_names(package_name)
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._roots.update(n.split(".")[0] for n in import_names)
|
|
124
|
+
|
|
125
|
+
def load_package(self, package_name: str) -> int:
|
|
126
|
+
"""Extract public API signatures from an installed package.
|
|
127
|
+
|
|
128
|
+
*package_name* may be a PyPI distribution name (``PyYAML``) or an
|
|
129
|
+
import name (``yaml``); both resolve to the same module.
|
|
130
|
+
|
|
131
|
+
Imports happen outside the lock to avoid blocking concurrent readers,
|
|
132
|
+
but the shared cache is updated under the lock.
|
|
133
|
+
"""
|
|
134
|
+
with self._lock:
|
|
135
|
+
if package_name in self._loaded_packages:
|
|
136
|
+
return sum(1 for v in self.apis.values() if v.module.startswith(package_name))
|
|
137
|
+
|
|
138
|
+
import_names = resolve_import_names(package_name)
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._loaded_packages.add(package_name)
|
|
141
|
+
self._roots.update(n.split(".")[0] for n in import_names)
|
|
142
|
+
|
|
143
|
+
mod = None
|
|
144
|
+
last_error: Exception | None = None
|
|
145
|
+
|
|
146
|
+
def _candidate_rank(name: str) -> tuple:
|
|
147
|
+
# Order: the exact requested import name first (authoritative),
|
|
148
|
+
# then its underscore spelling, then other public candidates
|
|
149
|
+
# (e.g. "yaml" for "PyYAML"), private C-extension modules last
|
|
150
|
+
# (importing "_yaml" would key the whole API base under "_yaml"
|
|
151
|
+
# and make every "yaml.*" call look invented).
|
|
152
|
+
return (
|
|
153
|
+
name != package_name,
|
|
154
|
+
name != package_name.replace("-", "_"),
|
|
155
|
+
name.startswith("_"),
|
|
156
|
+
name,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
for candidate in sorted(import_names, key=_candidate_rank):
|
|
160
|
+
try:
|
|
161
|
+
mod = importlib.import_module(candidate)
|
|
162
|
+
break
|
|
163
|
+
except Exception as e: # noqa: BLE001
|
|
164
|
+
last_error = e
|
|
165
|
+
if mod is None:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"Cannot import %s (tried %s): %s",
|
|
168
|
+
package_name,
|
|
169
|
+
sorted(import_names),
|
|
170
|
+
last_error,
|
|
171
|
+
)
|
|
172
|
+
with self._lock:
|
|
173
|
+
# Allow a retry once the package is installed, but keep the
|
|
174
|
+
# declared root so imports from it are still trusted.
|
|
175
|
+
self._loaded_packages.discard(package_name)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
import_root = mod.__name__.split(".")[0]
|
|
179
|
+
new_apis: dict[str, APISignature] = {}
|
|
180
|
+
seen: set[str] = set()
|
|
181
|
+
enumerated: set[str] = {mod.__name__}
|
|
182
|
+
count = self._collect_from_module(mod, mod.__name__, mod.__name__, new_apis, seen)
|
|
183
|
+
|
|
184
|
+
# Recurse into submodules (one level) to enrich the base.
|
|
185
|
+
try:
|
|
186
|
+
paths = getattr(mod, "__path__", None)
|
|
187
|
+
if paths is not None:
|
|
188
|
+
for info in pkgutil.iter_modules(paths):
|
|
189
|
+
if info.name.startswith("_"):
|
|
190
|
+
continue
|
|
191
|
+
sub = f"{import_root}.{info.name}"
|
|
192
|
+
try:
|
|
193
|
+
submod = importlib.import_module(sub)
|
|
194
|
+
except Exception as e: # noqa: BLE001
|
|
195
|
+
logger.debug("Cannot import submodule %s: %s", sub, e)
|
|
196
|
+
continue
|
|
197
|
+
enumerated.add(sub)
|
|
198
|
+
count += self._collect_from_module(submod, sub, sub, new_apis, seen)
|
|
199
|
+
except Exception as e: # noqa: BLE001
|
|
200
|
+
logger.warning("Failed to enumerate submodules of %s: %s", package_name, e)
|
|
201
|
+
|
|
202
|
+
with self._lock:
|
|
203
|
+
self.apis.update(new_apis)
|
|
204
|
+
self._loaded_packages.add(import_root)
|
|
205
|
+
self._enumerated.update(enumerated)
|
|
206
|
+
|
|
207
|
+
logger.info("Loaded %d APIs from %s", count, package_name)
|
|
208
|
+
return count
|
|
209
|
+
|
|
210
|
+
def _collect_from_module(
|
|
211
|
+
self,
|
|
212
|
+
mod,
|
|
213
|
+
module_name: str,
|
|
214
|
+
api_module: str,
|
|
215
|
+
out: dict[str, APISignature],
|
|
216
|
+
seen: set[str],
|
|
217
|
+
) -> int:
|
|
218
|
+
"""Collect public attributes from a module into *out*.
|
|
219
|
+
|
|
220
|
+
Returns the number of newly added signatures.
|
|
221
|
+
"""
|
|
222
|
+
count = 0
|
|
223
|
+
try:
|
|
224
|
+
names = dir(mod)
|
|
225
|
+
except Exception as e: # noqa: BLE001
|
|
226
|
+
logger.warning("dir(%s) failed: %s", module_name, e)
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
for name in names:
|
|
230
|
+
if name.startswith("_"):
|
|
231
|
+
continue
|
|
232
|
+
full = f"{api_module}.{name}"
|
|
233
|
+
if full in seen:
|
|
234
|
+
continue
|
|
235
|
+
seen.add(full)
|
|
236
|
+
try:
|
|
237
|
+
obj = getattr(mod, name)
|
|
238
|
+
sig = self._signature(obj)
|
|
239
|
+
except Exception: # noqa: BLE001
|
|
240
|
+
sig = ""
|
|
241
|
+
out[full] = APISignature(name=full, signature=sig, module=api_module)
|
|
242
|
+
count += 1
|
|
243
|
+
return count
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _signature(obj) -> str:
|
|
247
|
+
try:
|
|
248
|
+
import inspect
|
|
249
|
+
|
|
250
|
+
return str(inspect.signature(obj))
|
|
251
|
+
except (TypeError, ValueError):
|
|
252
|
+
return ""
|
|
253
|
+
|
|
254
|
+
def get(self, name: str) -> APISignature | None:
|
|
255
|
+
with self._lock:
|
|
256
|
+
return self.apis.get(name)
|
|
257
|
+
|
|
258
|
+
def has(self, name: str) -> bool:
|
|
259
|
+
with self._lock:
|
|
260
|
+
return name in self.apis
|
|
261
|
+
|
|
262
|
+
def search(self, prefix: str, limit: int = 10) -> list[str]:
|
|
263
|
+
with self._lock:
|
|
264
|
+
return [k for k in self.apis if k.startswith(prefix)][:limit]
|
|
265
|
+
|
|
266
|
+
def all_names(self) -> set[str]:
|
|
267
|
+
with self._lock:
|
|
268
|
+
return set(self.apis.keys())
|
|
269
|
+
|
|
270
|
+
def roots(self) -> set[str]:
|
|
271
|
+
"""Import-name roots this KB knows about (loaded or declared)."""
|
|
272
|
+
with self._lock:
|
|
273
|
+
return set(self._roots)
|
|
274
|
+
|
|
275
|
+
def enumerated_modules(self) -> set[str]:
|
|
276
|
+
"""Module names whose members were actually enumerated.
|
|
277
|
+
|
|
278
|
+
Member checks (``mod.attr`` does not exist) are only sound for these
|
|
279
|
+
modules; everything else must stay silent — see the field docstring.
|
|
280
|
+
"""
|
|
281
|
+
with self._lock:
|
|
282
|
+
return set(self._enumerated)
|
|
283
|
+
|
|
284
|
+
def reset(self) -> None:
|
|
285
|
+
"""Drop every loaded API and root (used by tests and benchmarks)."""
|
|
286
|
+
with self._lock:
|
|
287
|
+
self.apis.clear()
|
|
288
|
+
self._loaded_packages.clear()
|
|
289
|
+
self._roots.clear()
|
|
290
|
+
self._enumerated.clear()
|