diff --git a/code2docs/analyzers/endpoint_detector.py b/code2docs/analyzers/endpoint_detector.py index 1ba5c1b..658721c 100644 --- a/code2docs/analyzers/endpoint_detector.py +++ b/code2docs/analyzers/endpoint_detector.py @@ -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 @@ -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.""" @@ -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): diff --git a/code2docs/llm_helper.py b/code2docs/llm_helper.py index f89f3cc..3a3bb1c 100644 --- a/code2docs/llm_helper.py +++ b/code2docs/llm_helper.py @@ -4,6 +4,7 @@ and generators fall back to algorithm-based template output. """ +import importlib.util import logging from typing import Optional @@ -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. @@ -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) @@ -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 diff --git a/tests/test_analyzers.py b/tests/test_analyzers.py index 7af5706..1e3cb38 100644 --- a/tests/test_analyzers.py +++ b/tests/test_analyzers.py @@ -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/"] diff --git a/tests/test_llm_helper.py b/tests/test_llm_helper.py index 5e53d96..67add5f 100644 --- a/tests/test_llm_helper.py +++ b/tests/test_llm_helper.py @@ -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()