mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:47:01 +01:00
_analyzer.py 388 -> 226. The file walk, dotted-name map, import resolution and fingerprint go to _deps.py; the skip frozensets and the function regex to _scan_tables.py, which covered the last nine lines after the first cut landed at 259. _is_binary is the only name any test patches on _analyzer and it stays put, so no patch string needed editing. Only test_analyzer.py's import block had to be split. Pass count is 216 before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
455 lines
14 KiB
Python
455 lines
14 KiB
Python
"""Tests for python_pkg.code_tutor._analyzer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from python_pkg.code_tutor._analyzer import (
|
|
CodeItem,
|
|
_extract_other,
|
|
_extract_python,
|
|
_FunctionVisitor,
|
|
_is_binary,
|
|
_make_id,
|
|
_should_skip,
|
|
extract_items,
|
|
)
|
|
from python_pkg.code_tutor._deps import (
|
|
_build_dotted_map,
|
|
_match_deps,
|
|
codebase_fingerprint,
|
|
get_file_imports,
|
|
get_python_files,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _is_binary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_binary_true(tmp_path: Path) -> None:
|
|
f = tmp_path / "binary.bin"
|
|
f.write_bytes(b"hello\x00world")
|
|
assert _is_binary(f) is True
|
|
|
|
|
|
def test_is_binary_false(tmp_path: Path) -> None:
|
|
f = tmp_path / "text.py"
|
|
f.write_text("print('hello')", encoding="utf-8")
|
|
assert _is_binary(f) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _should_skip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_should_skip_dir(tmp_path: Path) -> None:
|
|
assert _should_skip(".venv/lib/site-packages/foo.py") is True
|
|
|
|
|
|
def test_should_skip_node_modules(tmp_path: Path) -> None:
|
|
assert _should_skip("node_modules/react/index.js") is True
|
|
|
|
|
|
def test_should_skip_suffix(tmp_path: Path) -> None:
|
|
assert _should_skip("assets/logo.png") is True
|
|
|
|
|
|
def test_should_skip_false(tmp_path: Path) -> None:
|
|
assert _should_skip("src/main.py") is False
|
|
|
|
|
|
def test_should_skip_pycache(tmp_path: Path) -> None:
|
|
assert _should_skip("__pycache__/foo.pyc") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _make_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_make_id_basic() -> None:
|
|
assert _make_id("src/utils.py", "helper") == "src.utils.helper"
|
|
|
|
|
|
def test_make_id_nested() -> None:
|
|
assert _make_id("pkg/sub/mod.py", "fn") == "pkg.sub.mod.fn"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _FunctionVisitor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_function_visitor_module_level() -> None:
|
|
source = "def my_func(x):\n return x\n"
|
|
tree = ast.parse(source)
|
|
visitor = _FunctionVisitor("mod.py")
|
|
visitor.visit(tree)
|
|
assert len(visitor.items) == 1
|
|
item = visitor.items[0]
|
|
assert item.name == "my_func"
|
|
assert item.class_name == ""
|
|
assert item.type == "function"
|
|
|
|
|
|
def test_function_visitor_class_method() -> None:
|
|
source = "class Foo:\n def bar(self):\n pass\n"
|
|
tree = ast.parse(source)
|
|
visitor = _FunctionVisitor("mod.py")
|
|
visitor.visit(tree)
|
|
assert len(visitor.items) == 1
|
|
item = visitor.items[0]
|
|
assert item.name == "bar"
|
|
assert item.class_name == "Foo"
|
|
|
|
|
|
def test_function_visitor_async() -> None:
|
|
source = "async def do_thing():\n pass\n"
|
|
tree = ast.parse(source)
|
|
visitor = _FunctionVisitor("mod.py")
|
|
visitor.visit(tree)
|
|
assert len(visitor.items) == 1
|
|
assert visitor.items[0].type == "async_function"
|
|
|
|
|
|
def test_function_visitor_class_stack_restored() -> None:
|
|
source = "class A:\n def m(self): pass\nclass B:\n def n(self): pass\n"
|
|
tree = ast.parse(source)
|
|
visitor = _FunctionVisitor("mod.py")
|
|
visitor.visit(tree)
|
|
names = {it.name: it.class_name for it in visitor.items}
|
|
assert names["m"] == "A"
|
|
assert names["n"] == "B"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_python
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_python_valid(tmp_path: Path) -> None:
|
|
f = tmp_path / "mod.py"
|
|
f.write_text("def foo():\n pass\n", encoding="utf-8")
|
|
items = _extract_python(f, "mod.py")
|
|
assert len(items) == 1
|
|
assert items[0].name == "foo"
|
|
|
|
|
|
def test_extract_python_syntax_error(tmp_path: Path) -> None:
|
|
f = tmp_path / "bad.py"
|
|
f.write_text("def (broken):\n", encoding="utf-8")
|
|
items = _extract_python(f, "bad.py")
|
|
assert items == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_other
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_other_match(tmp_path: Path) -> None:
|
|
f = tmp_path / "main.go"
|
|
f.write_text("func doThing(x int) {\n}\n", encoding="utf-8")
|
|
items = _extract_other(f, "main.go")
|
|
assert any(it.name == "doThing" for it in items)
|
|
|
|
|
|
def test_extract_other_no_match(tmp_path: Path) -> None:
|
|
f = tmp_path / "empty.go"
|
|
f.write_text("package main\n", encoding="utf-8")
|
|
items = _extract_other(f, "empty.go")
|
|
assert items == []
|
|
|
|
|
|
def test_extract_other_oserror(tmp_path: Path) -> None:
|
|
missing = tmp_path / "ghost.go"
|
|
items = _extract_other(missing, "ghost.go")
|
|
assert items == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# extract_items
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_items_py_file(tmp_path: Path) -> None:
|
|
(tmp_path / "a.py").write_text("def hello(): pass\n", encoding="utf-8")
|
|
items = extract_items(tmp_path)
|
|
assert any(it.name == "hello" for it in items)
|
|
|
|
|
|
def test_extract_items_other_lang(tmp_path: Path) -> None:
|
|
(tmp_path / "main.go").write_text("func run(x int) {}\n", encoding="utf-8")
|
|
items = extract_items(tmp_path)
|
|
assert any(it.name == "run" for it in items)
|
|
|
|
|
|
def test_extract_items_skips_binary(tmp_path: Path) -> None:
|
|
(tmp_path / "img.py").write_bytes(b"\x00\x01\x02\x03")
|
|
items = extract_items(tmp_path)
|
|
assert items == []
|
|
|
|
|
|
def test_extract_items_skips_should_skip(tmp_path: Path) -> None:
|
|
skip_dir = tmp_path / ".venv"
|
|
skip_dir.mkdir()
|
|
(skip_dir / "lib.py").write_text("def x(): pass\n", encoding="utf-8")
|
|
items = extract_items(tmp_path)
|
|
assert items == []
|
|
|
|
|
|
def test_extract_items_skips_directories(tmp_path: Path) -> None:
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
(sub / "mod.py").write_text("def f(): pass\n", encoding="utf-8")
|
|
items = extract_items(tmp_path)
|
|
assert any(it.name == "f" for it in items)
|
|
|
|
|
|
def test_extract_items_skips_unhandled_extension(tmp_path: Path) -> None:
|
|
(tmp_path / "readme.txt").write_text("hello world", encoding="utf-8")
|
|
items = extract_items(tmp_path)
|
|
assert items == []
|
|
|
|
|
|
def test_extract_items_is_binary_oserror(tmp_path: Path) -> None:
|
|
(tmp_path / "a.py").write_text("def f(): pass\n", encoding="utf-8")
|
|
with patch(
|
|
"python_pkg.code_tutor._analyzer._is_binary", side_effect=OSError("perm")
|
|
):
|
|
items = extract_items(tmp_path)
|
|
assert items == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_python_files
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_python_files(tmp_path: Path) -> None:
|
|
(tmp_path / "a.py").write_text("x = 1", encoding="utf-8")
|
|
(tmp_path / "b.py").write_text("y = 2", encoding="utf-8")
|
|
skip_dir = tmp_path / ".venv"
|
|
skip_dir.mkdir()
|
|
(skip_dir / "c.py").write_text("z = 3", encoding="utf-8")
|
|
files = get_python_files(tmp_path)
|
|
names = {f.name for f in files}
|
|
assert "a.py" in names
|
|
assert "b.py" in names
|
|
assert "c.py" not in names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _build_dotted_map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_dotted_map_normal(tmp_path: Path) -> None:
|
|
f = tmp_path / "pkg" / "mod.py"
|
|
f.parent.mkdir()
|
|
f.write_text("x = 1", encoding="utf-8")
|
|
result = _build_dotted_map(tmp_path, [f])
|
|
assert result[f] == "pkg.mod"
|
|
|
|
|
|
def test_build_dotted_map_value_error(tmp_path: Path) -> None:
|
|
other = tmp_path.parent / "other.py"
|
|
result = _build_dotted_map(tmp_path, [other])
|
|
assert other not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _match_deps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_match_deps_exact(tmp_path: Path) -> None:
|
|
f = tmp_path / "mod.py"
|
|
dotted_map = {f: "mod"}
|
|
path = tmp_path / "main.py"
|
|
result = _match_deps({"mod"}, dotted_map, path)
|
|
assert f in result
|
|
|
|
|
|
def test_match_deps_prefix(tmp_path: Path) -> None:
|
|
f = tmp_path / "mod.py"
|
|
dotted_map = {f: "mod"}
|
|
path = tmp_path / "main.py"
|
|
result = _match_deps({"pkg.mod"}, dotted_map, path)
|
|
assert f in result
|
|
|
|
|
|
def test_match_deps_suffix(tmp_path: Path) -> None:
|
|
f = tmp_path / "mod.py"
|
|
dotted_map = {f: "pkg.mod"}
|
|
path = tmp_path / "main.py"
|
|
result = _match_deps({"mod"}, dotted_map, path)
|
|
assert f in result
|
|
|
|
|
|
def test_match_deps_excludes_self(tmp_path: Path) -> None:
|
|
path = tmp_path / "mod.py"
|
|
dotted_map = {path: "mod"}
|
|
result = _match_deps({"mod"}, dotted_map, path)
|
|
assert path not in result
|
|
|
|
|
|
def test_match_deps_no_match(tmp_path: Path) -> None:
|
|
f = tmp_path / "other.py"
|
|
dotted_map = {f: "other"}
|
|
path = tmp_path / "main.py"
|
|
result = _match_deps({"unrelated"}, dotted_map, path)
|
|
assert f not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for get_file_imports
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_file_imports_import(tmp_path: Path) -> None:
|
|
f = tmp_path / "main.py"
|
|
dep = tmp_path / "utils.py"
|
|
f.write_text("import utils\n", encoding="utf-8")
|
|
dep.write_text("x = 1", encoding="utf-8")
|
|
result = get_file_imports(f, tmp_path, [f, dep])
|
|
assert dep in result
|
|
|
|
|
|
def test_get_file_imports_from_import(tmp_path: Path) -> None:
|
|
f = tmp_path / "main.py"
|
|
dep = tmp_path / "utils.py"
|
|
f.write_text("from utils import helper\n", encoding="utf-8")
|
|
dep.write_text("def helper(): pass\n", encoding="utf-8")
|
|
result = get_file_imports(f, tmp_path, [f, dep])
|
|
assert dep in result
|
|
|
|
|
|
def test_get_file_imports_from_import_none_module(tmp_path: Path) -> None:
|
|
# Relative import: node.module is None
|
|
f = tmp_path / "main.py"
|
|
f.write_text("from . import helper\n", encoding="utf-8")
|
|
result = get_file_imports(f, tmp_path, [f])
|
|
assert result == set()
|
|
|
|
|
|
def test_get_file_imports_syntax_error(tmp_path: Path) -> None:
|
|
f = tmp_path / "bad.py"
|
|
f.write_text("def (broken):\n", encoding="utf-8")
|
|
result = get_file_imports(f, tmp_path, [f])
|
|
assert result == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# codebase_fingerprint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_codebase_fingerprint_basic(tmp_path: Path) -> None:
|
|
(tmp_path / "a.py").write_text("x = 1", encoding="utf-8")
|
|
result = codebase_fingerprint(tmp_path)
|
|
assert len(result) == 16
|
|
assert result.isalnum()
|
|
|
|
|
|
def test_codebase_fingerprint_skips_dir(tmp_path: Path) -> None:
|
|
sub = tmp_path / "sub"
|
|
sub.mkdir()
|
|
result = codebase_fingerprint(tmp_path)
|
|
assert len(result) == 16
|
|
|
|
|
|
def test_codebase_fingerprint_skips_should_skip(tmp_path: Path) -> None:
|
|
venv = tmp_path / ".venv"
|
|
venv.mkdir()
|
|
(venv / "lib.py").write_text("x = 1", encoding="utf-8")
|
|
result_without = codebase_fingerprint(tmp_path)
|
|
(tmp_path / "real.py").write_text("y = 2", encoding="utf-8")
|
|
result_with = codebase_fingerprint(tmp_path)
|
|
assert result_without != result_with
|
|
|
|
|
|
def test_codebase_fingerprint_stat_oserror(tmp_path: Path) -> None:
|
|
(tmp_path / "source.py").write_text("x = 1", encoding="utf-8")
|
|
|
|
stat_calls: dict[str, int] = {}
|
|
real_stat = Path.stat
|
|
|
|
def patched_stat(self: Path, **kwargs: object) -> object:
|
|
key = str(self)
|
|
stat_calls[key] = stat_calls.get(key, 0) + 1
|
|
if stat_calls[key] >= 2 and key.endswith(".py"):
|
|
msg = "simulated stat failure"
|
|
raise OSError(msg)
|
|
return real_stat(self, **kwargs)
|
|
|
|
with patch.object(Path, "stat", patched_stat):
|
|
result = codebase_fingerprint(tmp_path)
|
|
|
|
assert len(result) == 16
|
|
|
|
|
|
def test_codebase_fingerprint_changes_on_modification(tmp_path: Path) -> None:
|
|
f = tmp_path / "mod.py"
|
|
f.write_text("x = 1", encoding="utf-8")
|
|
fp1 = codebase_fingerprint(tmp_path)
|
|
f.write_text("x = 2", encoding="utf-8")
|
|
fp2 = codebase_fingerprint(tmp_path)
|
|
assert fp1 != fp2
|
|
|
|
|
|
def test_extract_items_image_suffix(tmp_path: Path) -> None:
|
|
(tmp_path / "logo.png").write_bytes(b"\x89PNG\r\n")
|
|
items = extract_items(tmp_path)
|
|
assert items == []
|
|
|
|
|
|
def test_code_item_defaults() -> None:
|
|
item = CodeItem(
|
|
id="mod.fn",
|
|
file="mod.py",
|
|
type="function",
|
|
name="fn",
|
|
start_line=1,
|
|
end_line=5,
|
|
)
|
|
assert item.class_name == ""
|
|
assert item.depends_on == []
|
|
|
|
|
|
def test_codebase_fingerprint_stat_oserror_raises(tmp_path: Path) -> None:
|
|
"""Skip a file whose ``stat`` raises ``OSError`` (except branch).
|
|
|
|
``is_file()`` is forced True for the target so the loop reaches the
|
|
explicit ``path.stat()`` at fingerprint time; that stat then raises and
|
|
the ``except OSError`` handler drops the unreadable entry. Patching both
|
|
keeps this deterministic across Python versions (on some, ``is_file``
|
|
itself calls ``stat`` and would otherwise re-raise before line reached).
|
|
"""
|
|
(tmp_path / "source.py").write_text("x = 1", encoding="utf-8")
|
|
real_stat = Path.stat
|
|
real_is_file = Path.is_file
|
|
|
|
def failing_stat(self: Path, **kwargs: object) -> object:
|
|
"""Raise for the target file, delegate to the real stat otherwise."""
|
|
if str(self).endswith("source.py"):
|
|
msg = "simulated stat failure"
|
|
raise OSError(msg)
|
|
return real_stat(self, **kwargs)
|
|
|
|
def always_file(self: Path) -> bool:
|
|
"""Report the target as a regular file without calling stat."""
|
|
return str(self).endswith("source.py") or real_is_file(self)
|
|
|
|
with (
|
|
patch.object(Path, "stat", failing_stat),
|
|
patch.object(Path, "is_file", always_file),
|
|
):
|
|
result = codebase_fingerprint(tmp_path)
|
|
|
|
assert len(result) == 16
|