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
2 changes: 1 addition & 1 deletion .claude/commands/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ Then review the result against the code:
dispatch it can't follow, the depth limit. List what it **got wrong**, if anything.
3. For each miss that's a pattern many codebases use (a framework's decorators, dependency injection, a dispatch
dict), propose where in `src/code_node/analysis/reader.py` (or `project.py` for entry points) it would be
followed, with a test for `tests/test_code_node.py`. Don't change code unless asked.
followed, with a test for `tests/unit/test_reader.py` (or `tests/unit/test_project.py`). Don't change code unless asked.
18 changes: 13 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ command is `code-node` and the import is `code_node`.

```bash
uv sync # .venv with every dependency group
uv run pytest # tests
uv run pytest # tests (-m unit, -m integration for one kind)
uv run ruff check . # lint
uv run ruff format . # format (examples/ and docs/ are left as they are)
uv run code-node examples/orders-service --no-open -o /tmp/page.html # try it
Expand Down Expand Up @@ -124,10 +124,18 @@ rendering and I/O (it uses `platforms/`, which is pure too) so another front end

## Tests

`tests/test_code_node.py` writes small projects into `tmp_path` and checks entry point discovery, the map (functions,
AWS calls, imports), the graph's limits, plain Python against `platform="lambda"`, a plugin platform, and the
command end to end (`main([...])` with `--no-open -o`). Add a test
there for each new kind of entry point or anything new the reader follows.
Small projects written into `tmp_path` (or passed to `map_code` as a dict), checked by what the map, graph and page
say. `tests/support.py` has what they share: `write(root, files)` and the small projects (`HANDLER`, `PACKAGE`,
`STORES`...). `pytest -m unit` or `-m integration` runs one kind (`make test-unit`, `make test-integration`); the
folder marks them, in `tests/conftest.py`.

| Path | What it checks |
|---|---|
| `tests/unit/` | One part on its own, in memory: `test_project.py` (files, module names, import paths, entry points), `test_reader.py` (what the reader follows and what names hold, imports inside functions, partial maps), `test_graph.py`, `test_platforms.py` (Lambda, FastAPI, Flask, Django, a plugin platform), `test_libraries.py`, `test_deploys.py` (SAM, serverless.yml, the IAM and YAML helpers), `test_report.py` (boxes, code panels, the walk, logos, editor links), `test_picker.py`, `test_history.py` |
| `tests/integration/` | End to end, with real files, processes and HTTP: `test_cli.py` (`main([...])` with `--no-open -o`, and `python -m code_node` as its own process; that the code read is never run, and broken code still gives a page), `test_serve.py` (the live page and its `/events` stream), `test_examples.py` (each example against `tests/snapshots/`, every entry point read to the end, each page whole and self-contained) |

Add a unit test for each new kind of entry point or anything new the reader follows, in the file for that part. A
new example gets a line in `SMOKE` in `test_examples.py`. `UPDATE_SNAPSHOTS=1 uv run pytest` rewrites the snapshots.

## Releasing

Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ OUT ?= /tmp/code-node-page.html
PORT ?= 8765

.DEFAULT_GOAL := help
.PHONY: help install test lint fix try serve mermaid shots site-graph logos figures docs docs-serve package check \
.PHONY: help install test test-unit test-integration lint fix try serve mermaid shots site-graph logos figures docs docs-serve package check \
tool clean

help: # list the targets, by group
@printf "\033[1mcode-node\033[0m make <target>\n"
@awk 'BEGIN {FS = ":.*## "} \
/^##@ / {printf "\n\033[1;35m%s\033[0m\n", substr($$0, 5); next} \
/^[a-z-]+:.*## / {printf " \033[1;36m%-13s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
/^[a-z-]+:.*## / {printf " \033[1;36m%-17s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@echo ""
@echo " REPO=<folder> picks the code for try, serve and mermaid (default $(REPO))"

Expand Down Expand Up @@ -44,6 +44,12 @@ fix: ## ruff check --fix and ruff format
test: ## run the tests
$(RUN) pytest -q

test-unit: ## run the unit tests (tests/unit/): each part on its own, in memory
$(RUN) pytest -q -m unit

test-integration: ## run the integration tests (tests/integration/): the command, the server and the examples
$(RUN) pytest -q -m integration

check: lint test try docs package ## everything CI runs

##@ Run
Expand Down
15 changes: 15 additions & 0 deletions makefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,21 @@ def test(options: Options) -> None:
python("-m", "pytest", "-q")


@task("test-unit", "🧩", "run the unit tests (tests/unit/): each part on its own, in memory", "Check")
def test_unit(options: Options) -> None:
python("-m", "pytest", "-q", "-m", "unit")


@task(
"test-integration",
"🔗",
"run the integration tests (tests/integration/): the command, the server and the examples",
"Check",
)
def test_integration(options: Options) -> None:
python("-m", "pytest", "-q", "-m", "integration")


TASKS["check"] = Task(
"check",
"✅",
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,9 @@ include = ["src/code_node", "tests", "README.md", "CHANGELOG.md", "LICENSE"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["tests"] # for tests/support.py
addopts = ["--strict-markers"]
markers = [
"unit: one part on its own, in memory (tests/unit/)",
"integration: the command, the server or an example end to end, with real files, processes or HTTP (tests/integration/)",
]
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
Marks each test by its folder, so `pytest -m unit` and `pytest -m integration` run one kind.
"""

from __future__ import annotations

from pathlib import Path

import pytest

HERE = Path(__file__).parent


def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
for item in items:
kind = item.path.relative_to(HERE).parts[0] if item.path.is_relative_to(HERE) else ""
if kind in ("unit", "integration"):
item.add_marker(kind)
215 changes: 215 additions & 0 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""
The command end to end, through main(): what it prints and the page it saves.
"""

from __future__ import annotations

import os
import re
import subprocess
import sys
from pathlib import Path

import pytest

from code_node.analysis.mapping import map_code
from code_node.cli import main
from code_node.project import read_project

from support import EXAMPLES, FAN, HANDLER, IMPORTED_FROM_SRC, PACKAGE, write


def test_the_page_opens_a_box_s_calls_with_javascript_and_works_without(tmp_path: Path) -> None:
write(tmp_path / "code", {"app.py": FAN})
out = tmp_path / "page.html"
assert main([str(tmp_path / "code"), "--no-open", "-o", str(out), "--functions", "4"]) == 0
page = out.read_text()
# each graph a box's calls open is a template the script swaps in, with the path down to it
assert '<div class="cgw" data-group="cn-graph0">' in page and page.count("<script>") == 1
assert '<template id="cn-graph0-v1">' in page and '<a class="dr" href="#cn-graph0-v' in page
assert '<a class="cgcb" href="#cn-graph0-main">← The whole graph</a>' in page
assert '<template id="cn-graph0-all">' in page and '<a class="cgta oj" href="#cn-graph0-all"' in page
# without JavaScript the links are hidden and the legend says where the rest are, as before
assert 'class="kp kh nj"' in page and "the Step by step tab has them all" in page
assert ".cnd .cgw:not(.js) :is(.oj,.dr),.cnd .cgw.js .nj{display:none}" in page
# a function drawn in several graphs has one panel of code
panels = re.findall(r'<aside class="cside" id="([^"]+)"', page)
assert len(panels) == len(set(panels)) and len(panels) < 2 * len(re.findall(r'<a class="cl" href="#cn-code-', page))
step = re.search(r'<aside class="cside" id="([^"]+)" aria-label="The code of step3\(\)"', page)
assert step is not None and page.count(f'href="#{step.group(1)}"') > 1
assert main([str(tmp_path / "code"), "--no-open", "-o", str(out), "--all"]) == 0
assert '<div class="cgw">' in out.read_text() and "<template id=" not in out.read_text() # nothing left out


def test_the_command_saves_a_page(tmp_path: Path, capsys) -> None:
write(tmp_path / "code", {"app.py": HANDLER})
out = tmp_path / "page.html"
assert main([str(tmp_path / "code"), "--no-open", "-o", str(out)]) == 0
page = out.read_text()
assert "How app.lambda_handler works" in page and "<svg" in page
# the page is its tabs, the entry point beside them, with no cards or findings
assert '<div class="cnd"><div class="tabs">' in page and "<code>app.lambda_handler</code>" in page
assert all(f">{tab}<small>" in page for tab in ("Graph", "Step by step"))
assert (
'class="cards"' not in page
and 'class="fd"' not in page
and ">Findings<" not in page
and 'class="t"' not in page
)
assert main([str(tmp_path / "code"), "--mermaid"]) == 0
assert "flowchart LR" in capsys.readouterr().out


def test_starting_from_a_file(tmp_path: Path, capsys) -> None:
root = write(tmp_path / "repo", {**PACKAGE, ".git/HEAD": "ref: main\n"})
out = tmp_path / "page.html"
assert main([str(root / "src/tool/cli.py"), "--no-open", "-o", str(out)]) == 0
assert "tool.cli.main: 3 functions" in capsys.readouterr().out
assert main([f"{root / 'src/tool/store.py'}:Store.save", "--no-open", "-o", str(out)]) == 0
assert "tool.store.Store.save: 2 functions" in capsys.readouterr().out
assert main([str(root), "-e", str(root / "src/tool/store.py"), "--no-open", "-o", str(out)]) == 0
assert "tool.store.Store.save" in capsys.readouterr().out


def test_the_command_lists_its_plugins(capsys) -> None:
assert main(["--plugins"]) == 0
out = capsys.readouterr().out
assert all(re.search(row, out) for row in (r"lambda +AWS Lambda", r"redis +Redis +redis", r"sam +AWS SAM"))


@pytest.mark.parametrize(
("name", "text"),
[
("pyproject.toml", '[tool.pyright]\nextraPaths = ["src"]\n'),
("pyproject.toml", '[tool.pytest.ini_options]\npythonpath = "src"\n'),
("pyproject.toml", '[tool.mypy]\nmypy_path = "src"\n'),
("pyproject.toml", '[tool.setuptools.packages.find]\nwhere = ["src"]\n'),
("pyproject.toml", '[tool.setuptools.package-dir]\n"" = "src"\n'),
("pyproject.toml", '[tool.poetry]\npackages = [{ include = "common", from = "src" }]\n'),
("pyproject.toml", '[[tool.poetry.packages]]\ninclude = "common"\nfrom = "src"\n'),
("pyrightconfig.json", '{"extraPaths": ["./src"]}'),
(".vscode/settings.json", '{\n // Pylance\n "python.analysis.extraPaths": ["${workspaceFolder}/src"],\n}\n'),
],
)
def test_imports_from_a_folder_the_project_puts_on_the_path(tmp_path: Path, name: str, text: str, capsys) -> None:
root = write(tmp_path / "repo", {**IMPORTED_FROM_SRC, name: text, ".git/HEAD": "ref: main\n"})
project = read_project(root)
assert project.paths == ["src/"] and "common" not in project.libraries()
out = tmp_path / "page.html"
assert main([str(root / "src/handlers/orders/app.py:handler"), "--no-open", "-o", str(out)]) == 0
assert "app.handler: 2 functions, 1 AWS calls" in capsys.readouterr().out


def test_imports_from_a_folder_given_with_path(tmp_path: Path, capsys) -> None:
root = write(tmp_path / "repo", {**IMPORTED_FROM_SRC, ".git/HEAD": "ref: main\n"})
out = tmp_path / "page.html"
file = str(root / "src/handlers/orders/app.py:handler")
assert main([file, "--no-open", "-o", str(out)]) == 0
assert "app.handler: 1 functions, 0 AWS calls" in capsys.readouterr().out # common.db read as a library
assert main([file, "--path", str(root / "src"), "--no-open", "-o", str(out)]) == 0
assert "app.handler: 2 functions, 1 AWS calls" in capsys.readouterr().out
assert main([file, "--path", str(tmp_path), "--no-open", "-o", str(out)]) == 2
assert "isn't a folder in" in capsys.readouterr().err
cmap = map_code(
{path: text for path, text in IMPORTED_FROM_SRC.items()},
"app.handler",
file="src/handlers/orders/app.py",
paths=["src/"],
)
assert {f.name for f in cmap.functions} >= {"app.handler", "common.db.save"}


def test_the_command_lists_entry_points_and_says_what_s_wrong(tmp_path: Path, capsys) -> None:
root = write(tmp_path / "code", {"app.py": HANDLER})
assert main([str(root), "--list"]) == 0
assert "app.lambda_handler" in capsys.readouterr().out
assert not list(root.glob("*.html")) # --list stops before drawing
assert main([str(tmp_path / "missing"), "--no-open"]) == 2
assert "isn't a folder or a .py file" in capsys.readouterr().err
out = tmp_path / "page.html"
assert main([str(root), "-e", "nope.nothing", "--no-open", "-o", str(out)]) == 0 # said, and a page that says so
assert "No file matches the entry point nope.nothing" in capsys.readouterr().out and out.exists()


# Each line would leave a file behind (or stop the process) if anything ran it
RUNS_IF_IMPORTED = {
"app.py": (
"import os\nimport subprocess\nimport sys\n\nopen('ran-at-import', 'w').write('x')\n"
"os.system('touch ran-os-system')\n\n\ndef lambda_handler(event, context):\n"
" open('ran-handler', 'w').write('x')\n subprocess.run(['touch', 'ran-subprocess'])\n helper.go()\n"
" sys.exit(3)\n\n\nimport helper\n"
),
"helper.py": "open('ran-helper', 'w').write('x')\n\n\ndef go():\n raise SystemExit(4)\n",
"setup.py": "open('ran-setup', 'w').write('x')\n",
"conftest.py": "open('ran-conftest', 'w').write('x')\n",
}


def test_the_code_it_reads_is_never_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = write(tmp_path / "code", RUNS_IF_IMPORTED)
monkeypatch.chdir(root) # where the code's relative paths would land
out = tmp_path / "page.html"
assert main([str(root), "--no-open", "-o", str(out)]) == 0
done = subprocess.run(
[sys.executable, "-m", "code_node", str(root), "--no-open", "-o", str(out)],
cwd=root,
capture_output=True,
text=True,
timeout=60,
)
assert done.returncode == 0, done.stderr
assert sorted(p.name for p in root.iterdir()) == sorted(RUNS_IF_IMPORTED) # nothing it would have made
assert "app.helper" not in sys.modules and "helper" not in sys.modules
page = out.read_text()
assert "How app.lambda_handler works" in page and "go()" in page # read, and followed into helper.py


def test_code_that_doesn_t_parse_still_gives_a_page(tmp_path: Path, capsys) -> None:
root = write(
tmp_path / "code",
{
"app.py": HANDLER.replace("import requests", "import requests\nimport broken"),
"broken.py": "def oops(:\n",
"binary.py": "\x00\x01not python",
},
)
out = tmp_path / "page.html"
assert main([str(root), "--no-open", "-o", str(out)]) == 0
assert "Traceback" not in capsys.readouterr().err
assert "How app.lambda_handler works" in out.read_text()


def test_the_command_runs_as_its_own_process(tmp_path: Path) -> None:
"""
`python -m code_node` as a user runs it, in a process of its own: no state shared with the tests.
"""
out = tmp_path / "page.html"
env = {**os.environ, "NO_COLOR": "1", "XDG_CACHE_HOME": str(tmp_path / "cache")}
done = subprocess.run(
[sys.executable, "-m", "code_node", str(EXAMPLES / "orders-service"), "--no-open", "-o", str(out)],
capture_output=True,
text=True,
env=env,
timeout=60,
)
assert done.returncode == 0, done.stderr
assert "orders.handler.lambda_handler" in done.stdout and "Saved" in done.stdout
assert (
out.read_text().startswith("<!doctype html>") and "How orders.handler.lambda_handler works" in out.read_text()
)
# without -o it saves to the cache folder
done = subprocess.run(
[sys.executable, "-m", "code_node", str(EXAMPLES / "orders-service"), "--no-open"],
capture_output=True,
text=True,
env=env,
timeout=60,
)
assert done.returncode == 0, done.stderr
assert [p.suffix for p in (tmp_path / "cache" / "code-node").iterdir()] == [".html"]
helped = subprocess.run([sys.executable, "-m", "code_node", "--help"], capture_output=True, text=True, timeout=60)
assert helped.returncode == 0 and "--serve" in helped.stdout
wrong = subprocess.run(
[sys.executable, "-m", "code_node", "--depth", "lots"], capture_output=True, text=True, timeout=60
)
assert wrong.returncode == 2 and "invalid int value" in wrong.stderr
Loading
Loading