codaviz 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.
codaviz/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """codaviz — analyze and visualize complexity hotspots in Python codebases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from codaviz._version import __version__
6
+
7
+ __all__ = ["__version__"]
codaviz/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ """Resolve the installed package version (single source of truth: pyproject)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("codaviz")
9
+ except PackageNotFoundError: # running from a source tree without an install
10
+ __version__ = "0.0.0"
@@ -0,0 +1,12 @@
1
+ """Analysis orchestration: turn discovered files into a flat entity list.
2
+
3
+ Builds PACKAGE, MODULE (SLOC + maintainability index), and FUNCTION (cyclomatic
4
+ complexity) entities. Circular-import findings are computed separately by
5
+ ``analysis.imports`` (static AST, surfaced in the HTML report).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from codaviz.analysis.project import analyze_project
11
+
12
+ __all__ = ["analyze_project"]
@@ -0,0 +1,63 @@
1
+ """Per-function complexity: cyclomatic (mccabe) + cognitive (SonarSource).
2
+
3
+ Cyclomatic complexity comes from the ``mccabe`` library, so the numbers match
4
+ Ruff's C901 and ``python -m mccabe``. Cognitive complexity comes from the
5
+ ``cognitive_complexity`` library. End lines (for source snippets) come from the
6
+ AST, joined to mccabe's per-function results by line number.
7
+
8
+ Like Ruff/mccabe, nested closures and function-local-class methods are folded
9
+ into their enclosing function rather than listed separately.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ from dataclasses import dataclass
16
+
17
+ from cognitive_complexity.api import get_cognitive_complexity
18
+ from mccabe import PathGraphingAstVisitor
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class FunctionInfo:
23
+ """Complexity for a single function or method."""
24
+
25
+ name: str # qualified name, e.g. "A.method"
26
+ lineno: int
27
+ endline: int
28
+ complexity: int # cyclomatic (mccabe)
29
+ cognitive: int # cognitive (SonarSource)
30
+
31
+
32
+ def function_infos(source: str) -> list[FunctionInfo]:
33
+ """Return complexity records for the functions/methods in ``source``.
34
+
35
+ Raises ``SyntaxError`` if ``source`` is not valid Python.
36
+ """
37
+ tree = ast.parse(source)
38
+
39
+ visitor = PathGraphingAstVisitor()
40
+ visitor.preorder(tree, visitor)
41
+ cyclomatic = {g.lineno: (g.entity, g.complexity()) for g in visitor.graphs.values()}
42
+
43
+ nodes: dict[int, ast.FunctionDef | ast.AsyncFunctionDef] = {
44
+ node.lineno: node
45
+ for node in ast.walk(tree)
46
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
47
+ }
48
+
49
+ infos = []
50
+ for lineno, (name, complexity) in sorted(cyclomatic.items()):
51
+ node = nodes.get(lineno)
52
+ if node is None:
53
+ continue
54
+ infos.append(
55
+ FunctionInfo(
56
+ name=name,
57
+ lineno=lineno,
58
+ endline=node.end_lineno or lineno,
59
+ complexity=complexity,
60
+ cognitive=get_cognitive_complexity(node),
61
+ ),
62
+ )
63
+ return infos
@@ -0,0 +1,171 @@
1
+ """Static circular-import detection (no code execution).
2
+
3
+ Parses *module-level* import statements from the discovered files into a directed
4
+ graph of internal modules, then reports strongly-connected components of size > 1
5
+ (Tarjan). Imports inside functions/classes are ignored (they don't cause
6
+ import-time cycles), as are ``if TYPE_CHECKING:`` blocks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable, Iterator
16
+ from pathlib import Path
17
+
18
+
19
+ def find_import_cycles(root: Path, module_paths: Iterable[str]) -> list[list[str]]:
20
+ """Return circular-import groups as sorted lists of repo-relative module paths."""
21
+ root_is_package = (root / "__init__.py").is_file()
22
+ prefix = root.name + "." if root_is_package else ""
23
+
24
+ info: dict[str, tuple[str, bool]] = {}
25
+ for posix in module_paths:
26
+ name, is_pkg = _module_name(posix, prefix)
27
+ info[name] = (posix, is_pkg)
28
+
29
+ internal = set(info)
30
+ graph: dict[str, set[str]] = {name: set() for name in internal}
31
+ for name, (posix, is_pkg) in info.items():
32
+ try:
33
+ tree = ast.parse((root / posix).read_text(encoding="utf-8-sig"))
34
+ except (OSError, SyntaxError, ValueError):
35
+ continue
36
+ anchor = name if is_pkg else _parent(name)
37
+ for candidate in _imported(tree, anchor):
38
+ target = _resolve(candidate, internal)
39
+ if target is not None and target != name:
40
+ graph[name].add(target)
41
+
42
+ cycles = [sorted(info[n][0] for n in comp) for comp in _sccs(graph)]
43
+ cycles.sort(key=len, reverse=True)
44
+ return cycles
45
+
46
+
47
+ def _module_name(posix: str, prefix: str) -> tuple[str, bool]:
48
+ parts = posix.split("/")
49
+ is_pkg = parts[-1] == "__init__.py"
50
+ if is_pkg:
51
+ parts = parts[:-1]
52
+ else:
53
+ parts[-1] = parts[-1].removesuffix(".py")
54
+ dotted = ".".join(parts)
55
+ return (prefix + dotted if dotted else prefix.rstrip(".")), is_pkg
56
+
57
+
58
+ def _parent(name: str) -> str:
59
+ return name.rsplit(".", 1)[0] if "." in name else ""
60
+
61
+
62
+ def _imported(tree: ast.Module, anchor: str) -> Iterator[str]:
63
+ for node in _iter_import_nodes(tree.body):
64
+ if isinstance(node, ast.Import):
65
+ for alias in node.names:
66
+ yield alias.name
67
+ else: # ast.ImportFrom
68
+ if node.level == 0:
69
+ base = node.module
70
+ else:
71
+ base = _resolve_relative(anchor, node.level, node.module)
72
+ if base is None:
73
+ continue
74
+ yield base
75
+ for alias in node.names:
76
+ yield base + "." + alias.name
77
+
78
+
79
+ def _resolve_relative(anchor: str, level: int, module: str | None) -> str | None:
80
+ parts = anchor.split(".") if anchor else []
81
+ up = level - 1
82
+ if up > len(parts):
83
+ return None
84
+ base_parts = parts[: len(parts) - up] if up else list(parts)
85
+ if module:
86
+ base_parts += module.split(".")
87
+ return ".".join(base_parts) or None
88
+
89
+
90
+ def _resolve(candidate: str, internal: set[str]) -> str | None:
91
+ parts = candidate.split(".")
92
+ while parts:
93
+ name = ".".join(parts)
94
+ if name in internal:
95
+ return name
96
+ parts.pop()
97
+ return None
98
+
99
+
100
+ def _iter_import_nodes(body: list[ast.stmt]) -> Iterator[ast.Import | ast.ImportFrom]:
101
+ """Yield module-level import nodes, descending into module-level control flow."""
102
+ for node in body:
103
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
104
+ yield node
105
+ elif isinstance(node, ast.If):
106
+ if not _is_type_checking(node.test):
107
+ yield from _iter_import_nodes(node.body)
108
+ yield from _iter_import_nodes(node.orelse)
109
+ elif isinstance(node, ast.Try):
110
+ yield from _iter_import_nodes(node.body)
111
+ for handler in node.handlers:
112
+ yield from _iter_import_nodes(handler.body)
113
+ yield from _iter_import_nodes(node.orelse)
114
+ yield from _iter_import_nodes(node.finalbody)
115
+ elif isinstance(node, ast.With):
116
+ yield from _iter_import_nodes(node.body)
117
+
118
+
119
+ def _is_type_checking(test: ast.expr) -> bool:
120
+ if isinstance(test, ast.Name):
121
+ return test.id == "TYPE_CHECKING"
122
+ return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
123
+
124
+
125
+ def _sccs(graph: dict[str, set[str]]) -> list[list[str]]: # noqa: C901 - Tarjan SCC
126
+ """Tarjan's strongly-connected components (iterative); only components > 1 node."""
127
+ index: dict[str, int] = {}
128
+ low: dict[str, int] = {}
129
+ on_stack: set[str] = set()
130
+ stack: list[str] = []
131
+ components: list[list[str]] = []
132
+ counter = 0
133
+
134
+ for start, start_neighbors in graph.items():
135
+ if start in index:
136
+ continue
137
+ work = [(start, iter(start_neighbors))]
138
+ index[start] = low[start] = counter
139
+ counter += 1
140
+ stack.append(start)
141
+ on_stack.add(start)
142
+ while work:
143
+ node, successors = work[-1]
144
+ descended = False
145
+ for succ in successors:
146
+ if succ not in index:
147
+ index[succ] = low[succ] = counter
148
+ counter += 1
149
+ stack.append(succ)
150
+ on_stack.add(succ)
151
+ work.append((succ, iter(graph[succ])))
152
+ descended = True
153
+ break
154
+ if succ in on_stack:
155
+ low[node] = min(low[node], index[succ])
156
+ if descended:
157
+ continue
158
+ if low[node] == index[node]:
159
+ component = []
160
+ while True:
161
+ member = stack.pop()
162
+ on_stack.discard(member)
163
+ component.append(member)
164
+ if member == node:
165
+ break
166
+ if len(component) > 1:
167
+ components.append(component)
168
+ work.pop()
169
+ if work:
170
+ low[work[-1][0]] = min(low[work[-1][0]], low[node])
171
+ return components
@@ -0,0 +1,22 @@
1
+ """Per-module metrics via radon: source lines (raw) and maintainability index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from radon.metrics import mi_visit
6
+ from radon.raw import analyze as _raw_analyze
7
+
8
+
9
+ def source_lines(source: str) -> int:
10
+ """Return the SLOC (source lines of code) for a module's source text.
11
+
12
+ Raises ``SyntaxError`` if ``source`` is not valid Python.
13
+ """
14
+ return _raw_analyze(source).sloc
15
+
16
+
17
+ def maintainability_index(source: str) -> float:
18
+ """Return the maintainability index (0-100, higher is better) for ``source``.
19
+
20
+ Raises ``SyntaxError`` if ``source`` is not valid Python.
21
+ """
22
+ return round(mi_visit(source, multi=True), 2)
@@ -0,0 +1,106 @@
1
+ """Build the flat entity list for a project from discovered files.
2
+
3
+ Module entities carry SLOC + maintainability index; each function/method is its
4
+ own FUNCTION entity carrying cyclomatic complexity. Module-level CC aggregation
5
+ (max/sum over child functions) is derived by the report layer, not stored here,
6
+ so the flat list stays the single source of truth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from codaviz.analysis.complexity import function_infos
16
+ from codaviz.analysis.metrics import maintainability_index, source_lines
17
+ from codaviz.config import load_config
18
+ from codaviz.discovery import discover
19
+ from codaviz.model import Entity, EntityKind, Metrics
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Sequence
23
+
24
+
25
+ def analyze_project(root: Path) -> list[Entity]:
26
+ """Analyze the project rooted at ``root`` and return a flat entity list."""
27
+ config = load_config(root)
28
+ extra_excludes: Sequence[str] = config.get("exclude", [])
29
+ include_tests = bool(config.get("include-tests", False))
30
+ files = discover(root, include_tests=include_tests, extra_excludes=extra_excludes)
31
+
32
+ entities: dict[str, Entity] = {}
33
+ for rel in files:
34
+ module_id = rel.as_posix()
35
+ # utf-8-sig strips a BOM; strict decoding surfaces non-UTF-8 files so we
36
+ # skip them instead of silently analysing mangled text. The read sits
37
+ # inside the per-file boundary so a stale/unreadable file never aborts.
38
+ try:
39
+ source = (root / rel).read_text(encoding="utf-8-sig")
40
+ except (OSError, UnicodeDecodeError) as exc:
41
+ print(f"codaviz: skipping {module_id}: {exc}", file=sys.stderr)
42
+ continue
43
+ parent_id = _ensure_packages(rel, entities)
44
+ metrics, functions = _analyze_module(source, module_id)
45
+ entities[module_id] = Entity(
46
+ id=module_id,
47
+ name=rel.name,
48
+ kind=EntityKind.MODULE,
49
+ path=module_id,
50
+ parent_id=parent_id,
51
+ metrics=metrics,
52
+ )
53
+ for function in functions:
54
+ entities[function.id] = function
55
+ return list(entities.values())
56
+
57
+
58
+ def _analyze_module(source: str, module_id: str) -> tuple[Metrics, list[Entity]]:
59
+ """Compute module metrics and FUNCTION entities; degrade gracefully on errors."""
60
+ try:
61
+ metrics = Metrics(
62
+ sloc=source_lines(source),
63
+ mi=maintainability_index(source),
64
+ )
65
+ infos = function_infos(source)
66
+ except Exception as exc: # noqa: BLE001 - one bad file must not abort the run
67
+ print(f"codaviz: skipping metrics for {module_id}: {exc}", file=sys.stderr)
68
+ return Metrics(), []
69
+
70
+ functions = [
71
+ Entity(
72
+ id=f"{module_id}::{info.name}#{info.lineno}",
73
+ name=info.name,
74
+ kind=EntityKind.FUNCTION,
75
+ path=module_id,
76
+ parent_id=module_id,
77
+ lineno=info.lineno,
78
+ endline=info.endline,
79
+ metrics=Metrics(cc=info.complexity, cognitive=info.cognitive),
80
+ )
81
+ for info in infos
82
+ ]
83
+ return metrics, functions
84
+
85
+
86
+ def _ensure_packages(rel: Path, entities: dict[str, Entity]) -> str | None:
87
+ """Create PACKAGE entities for each ancestor dir of ``rel``; return parent id."""
88
+ parent = rel.parent
89
+ if parent == Path():
90
+ return None
91
+ parent_id: str | None = None
92
+ accumulated: list[str] = []
93
+ package_id = None
94
+ for part in parent.parts:
95
+ accumulated.append(part)
96
+ package_id = "/".join(accumulated)
97
+ if package_id not in entities:
98
+ entities[package_id] = Entity(
99
+ id=package_id,
100
+ name=part,
101
+ kind=EntityKind.PACKAGE,
102
+ path=package_id,
103
+ parent_id=parent_id,
104
+ )
105
+ parent_id = package_id
106
+ return package_id
codaviz/cli.py ADDED
@@ -0,0 +1,76 @@
1
+ """Command-line interface for codaviz."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ from cyclopts import App, Parameter
10
+
11
+ from codaviz import __version__
12
+ from codaviz.analysis import analyze_project
13
+ from codaviz.config import load_config
14
+ from codaviz.export.csv import to_csv
15
+ from codaviz.export.json import to_json
16
+ from codaviz.report import render_report
17
+
18
+ app = App(
19
+ name="codaviz",
20
+ help="Analyze and visualize complexity hotspots in Python codebases.",
21
+ version=__version__,
22
+ )
23
+
24
+
25
+ @app.default
26
+ def analyze(
27
+ path: Path = Path(),
28
+ *,
29
+ output: Annotated[Path | None, Parameter(name=["--output", "-o"])] = None,
30
+ output_format: Annotated[str, Parameter(name=["--format", "-f"])] = "html",
31
+ no_source: Annotated[bool, Parameter(negative="")] = False,
32
+ ) -> None:
33
+ """Analyze PATH and write a complexity report.
34
+
35
+ Parameters
36
+ ----------
37
+ path
38
+ Path to the Python project to analyze.
39
+ output
40
+ Output file path. Defaults to stdout for json/csv, report.html for html.
41
+ output_format
42
+ Output format: html, json, or csv.
43
+ no_source
44
+ Omit embedded source snippets from the report.
45
+ """
46
+ entities = analyze_project(path)
47
+
48
+ if output_format == "json":
49
+ text = to_json(entities)
50
+ elif output_format == "csv":
51
+ text = to_csv(entities)
52
+ elif output_format == "html":
53
+ config = load_config(path)
54
+ text = render_report(
55
+ entities,
56
+ root=str(path),
57
+ no_source=no_source,
58
+ max_complexity=int(config.get("max-complexity", 15)),
59
+ max_cognitive=int(config.get("max-cognitive", 15)),
60
+ )
61
+ if output is None:
62
+ output = Path("report.html") # HTML goes to a file, never stdout
63
+ else:
64
+ print(f"codaviz: unknown format {output_format!r}", file=sys.stderr)
65
+ raise SystemExit(2)
66
+
67
+ if output is None:
68
+ print(text)
69
+ else:
70
+ output.write_text(text, encoding="utf-8")
71
+ print(f"codaviz: wrote {output}", file=sys.stderr)
72
+
73
+
74
+ def main() -> None:
75
+ """Entry point for the ``codaviz`` console script."""
76
+ app()
codaviz/config.py ADDED
@@ -0,0 +1,19 @@
1
+ """Load codaviz configuration from the ``[tool.codaviz]`` table in pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import tomllib
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+
13
+ def load_config(root: Path) -> dict[str, Any]:
14
+ """Return the ``[tool.codaviz]`` table from ``root/pyproject.toml`` (or ``{}``)."""
15
+ pyproject = root / "pyproject.toml"
16
+ if not pyproject.is_file():
17
+ return {}
18
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
19
+ return data.get("tool", {}).get("codaviz", {})
codaviz/discovery.py ADDED
@@ -0,0 +1,119 @@
1
+ """Discover analysable Python files.
2
+
3
+ Inside a git repository we ask git for the project's ``.py`` files via
4
+ ``git ls-files --cached --others --exclude-standard``: this returns tracked
5
+ *and* uncommitted files while honouring ``.gitignore`` (so a fresh checkout
6
+ still works, and ignored trees like ``.venv`` are skipped). When the target is
7
+ not a git repository we fall back to a pruning filesystem walk. Smart excludes
8
+ (``.venv``, migrations, caches, ...) and ``[tool.codaviz]`` exclude globs are
9
+ applied in both cases. Test files are excluded unless ``include_tests`` is set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess # noqa: S404
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Sequence
21
+
22
+ DEFAULT_EXCLUDE_DIRS = frozenset({
23
+ ".git",
24
+ ".hg",
25
+ ".svn",
26
+ ".venv",
27
+ "venv",
28
+ ".env",
29
+ "env",
30
+ "__pycache__",
31
+ ".mypy_cache",
32
+ ".ruff_cache",
33
+ ".pytest_cache",
34
+ ".tox",
35
+ ".nox",
36
+ ".eggs",
37
+ "build",
38
+ "dist",
39
+ "node_modules",
40
+ "site-packages",
41
+ "migrations",
42
+ })
43
+
44
+ _TEST_DIR_NAMES = frozenset({"test", "tests"})
45
+
46
+
47
+ def discover(
48
+ root: Path,
49
+ *,
50
+ include_tests: bool = False,
51
+ extra_excludes: Sequence[str] = (),
52
+ ) -> list[Path]:
53
+ """Return repo-relative paths of analysable ``.py`` files under ``root``, sorted."""
54
+ git_files = _git_python_files(root)
55
+ if git_files is None:
56
+ print(
57
+ f"codaviz: {root} is not a git repository; "
58
+ "falling back to a filesystem walk.",
59
+ file=sys.stderr,
60
+ )
61
+ candidates = _walk_python_files(root)
62
+ else:
63
+ candidates = git_files
64
+ kept = [
65
+ rel
66
+ for rel in candidates
67
+ if not _is_excluded(rel, include_tests=include_tests, extra=extra_excludes)
68
+ ]
69
+ return sorted(kept)
70
+
71
+
72
+ def _git_python_files(root: Path) -> list[Path] | None:
73
+ """Return git-known ``.py`` paths (tracked + uncommitted, ignored excluded).
74
+
75
+ Returns ``None`` when ``root`` is not inside a git repository.
76
+ """
77
+ try:
78
+ result = subprocess.run(
79
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], # noqa: S607
80
+ cwd=root,
81
+ capture_output=True,
82
+ check=True,
83
+ text=True,
84
+ )
85
+ except (OSError, subprocess.CalledProcessError):
86
+ return None
87
+ files = (Path(name) for name in result.stdout.split("\0") if name)
88
+ return [p for p in files if p.suffix == ".py"]
89
+
90
+
91
+ def _walk_python_files(root: Path) -> list[Path]:
92
+ """Walk the tree for ``.py`` files, pruning excluded directories as we go."""
93
+ found: list[Path] = []
94
+ for dirpath, dirnames, filenames in root.walk():
95
+ dirnames[:] = [d for d in dirnames if d not in DEFAULT_EXCLUDE_DIRS]
96
+ found.extend(
97
+ (dirpath / name).relative_to(root)
98
+ for name in filenames
99
+ if name.endswith(".py")
100
+ )
101
+ return found
102
+
103
+
104
+ def _is_excluded(rel: Path, *, include_tests: bool, extra: Sequence[str]) -> bool:
105
+ parts = set(rel.parts)
106
+ if parts & DEFAULT_EXCLUDE_DIRS:
107
+ return True
108
+ if not include_tests and _looks_like_test(rel):
109
+ return True
110
+ return any(rel.match(pattern) for pattern in extra)
111
+
112
+
113
+ def _looks_like_test(rel: Path) -> bool:
114
+ if _TEST_DIR_NAMES & set(rel.parts):
115
+ return True
116
+ name = rel.name
117
+ return (
118
+ name == "conftest.py" or name.startswith("test_") or name.endswith("_test.py")
119
+ )
@@ -0,0 +1,6 @@
1
+ """Exporters: render the flat entity list to JSON or CSV.
2
+
3
+ Implemented in Tasks 4 (JSON) and 6 (CSV).
4
+ """
5
+
6
+ from __future__ import annotations
codaviz/export/csv.py ADDED
@@ -0,0 +1,53 @@
1
+ """Serialise the flat entity list to CSV (one row per entity)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from codaviz.model import Entity
11
+
12
+ FIELDS = (
13
+ "id",
14
+ "name",
15
+ "kind",
16
+ "path",
17
+ "parent_id",
18
+ "lineno",
19
+ "endline",
20
+ "sloc",
21
+ "cc",
22
+ "cognitive",
23
+ "mi",
24
+ )
25
+
26
+
27
+ def to_csv(entities: list[Entity]) -> str:
28
+ """Render the entity list as CSV with one row per entity.
29
+
30
+ Metrics are flattened into their own columns; absent values are blank.
31
+ """
32
+ out = io.StringIO()
33
+ writer = csv.DictWriter(
34
+ out,
35
+ fieldnames=FIELDS,
36
+ extrasaction="ignore",
37
+ lineterminator="\n",
38
+ )
39
+ writer.writeheader()
40
+ for entity in entities:
41
+ writer.writerow(
42
+ {
43
+ "id": entity.id,
44
+ "name": entity.name,
45
+ "kind": entity.kind.value,
46
+ "path": entity.path,
47
+ "parent_id": entity.parent_id,
48
+ "lineno": entity.lineno,
49
+ "endline": entity.endline,
50
+ **entity.metrics.as_dict(),
51
+ },
52
+ )
53
+ return out.getvalue()
codaviz/export/json.py ADDED
@@ -0,0 +1,14 @@
1
+ """Serialise the flat entity list to JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from codaviz.model import Entity
10
+
11
+
12
+ def to_json(entities: list[Entity], *, indent: int = 2) -> str:
13
+ """Render the entity list as a JSON array of objects."""
14
+ return json.dumps([entity.as_dict() for entity in entities], indent=indent)