Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion code2docs/analyzers/endpoint_detector.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Detect web framework endpoints (Flask, FastAPI, Django) from AST analysis."""

import os
import re
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -23,6 +24,36 @@ class Endpoint:
return_type: Optional[str] = None


# Directories never searched for Django urls.py: virtualenvs, vendored packages,
# VCS/worktree copies and build output. Walking them dominated runtime (~40s on
# a workspace with vendored site-packages). Any dot-directory is skipped too.
_SKIP_DIRS = frozenset(
{
"venv",
"env",
"node_modules",
"site-packages",
"__pycache__",
"dist",
"build",
"target",
"bower_components",
}
)


def _iter_urls_files(root: Path):
"""Yield ``urls.py`` files under *root*, pruning irrelevant directories."""
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d
for d in dirnames
if d not in _SKIP_DIRS and not d.startswith(".") and not d.endswith(".egg-info")
]
if "urls.py" in filenames:
yield Path(dirpath) / "urls.py"


class EndpointDetector:
"""Detects web endpoints from decorator patterns in source code."""

Expand Down Expand Up @@ -91,7 +122,7 @@ def _scan_django_urls(self, project_path: str) -> List[Endpoint]:
endpoints: List[Endpoint] = []
project = Path(project_path)

for urls_file in project.rglob("urls.py"):
for urls_file in _iter_urls_files(project):
try:
source = urls_file.read_text(encoding="utf-8")
for match in self.DJANGO_URL_PATTERN.finditer(source):
Expand Down
17 changes: 15 additions & 2 deletions code2docs/llm_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
and generators fall back to algorithm-based template output.
"""

import importlib.util
import logging
from typing import Optional

Expand All @@ -29,6 +30,18 @@ def _get_litellm():
return _litellm if _litellm is not False else None


def _litellm_installed() -> bool:
"""Cheap availability check: locate litellm without importing it.

Importing litellm costs ~10s, so it must only happen when a completion is
actually requested (see ``_get_litellm`` in ``LLMHelper.complete``).
"""
try:
return importlib.util.find_spec("litellm") is not None
except (ImportError, ValueError):
return False


class LLMHelper:
"""Thin wrapper around litellm for documentation generation.

Expand All @@ -45,7 +58,7 @@ def available(self) -> bool:
"""Check if LLM is configured and litellm is installed."""
if self._available is None:
self._available = (
self.config.enabled and bool(self.config.model) and _get_litellm() is not None
self.config.enabled and bool(self.config.model) and _litellm_installed()
)
if self._available:
logger.info("LLM enabled: model=%s", self.config.model)
Expand All @@ -54,7 +67,7 @@ def available(self) -> bool:
"LLM disabled (enabled=%s, model=%s, litellm=%s)",
self.config.enabled,
bool(self.config.model),
_get_litellm() is not None,
_litellm_installed(),
)
return self._available

Expand Down
14 changes: 14 additions & 0 deletions tests/test_analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,17 @@ def test_no_match(self):
)
ep = detector._parse_decorator(func.decorators[0], func)
assert ep is None


def test_endpoint_detector_skips_vendored_and_hidden_dirs(tmp_path):
from code2docs.analyzers.endpoint_detector import EndpointDetector

(tmp_path / "app").mkdir()
(tmp_path / "app" / "urls.py").write_text("urlpatterns = [path('real/', v)]\n")
for skipped in (".subactor/lib/site-packages/x", "venv/lib", "node_modules/y", ".worktrees/t1"):
d = tmp_path / skipped
d.mkdir(parents=True)
(d / "urls.py").write_text("urlpatterns = [path('vendored/', v)]\n")

paths = [e.path for e in EndpointDetector()._scan_django_urls(str(tmp_path))]
assert paths == ["real/"]
24 changes: 24 additions & 0 deletions tests/test_llm_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,27 @@ def test_getting_started_without_llm(self):
content = gen.generate()
assert "Getting Started" in content
assert "Installation" in content


# ── Performance regression tests ─────────────────────────────────────────


class TestLazyLitellmImport:
"""Availability checks must not import litellm (~10s)."""

def test_disabled_helper_never_imports_litellm(self):
from code2docs import llm_helper
from code2docs.config import LLMConfig

with patch("code2docs.llm_helper._get_litellm") as get_litellm:
assert LLMHelper(LLMConfig(enabled=False)).available is False
get_litellm.assert_not_called()

def test_enabled_availability_uses_find_spec_not_import(self):
from code2docs.config import LLMConfig

with patch("code2docs.llm_helper._litellm_installed", return_value=True), patch(
"code2docs.llm_helper._get_litellm"
) as get_litellm:
assert LLMHelper(LLMConfig(enabled=True, model="x")).available is True
get_litellm.assert_not_called()
Loading