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 +7 -0
- codaviz/_version.py +10 -0
- codaviz/analysis/__init__.py +12 -0
- codaviz/analysis/complexity.py +63 -0
- codaviz/analysis/imports.py +171 -0
- codaviz/analysis/metrics.py +22 -0
- codaviz/analysis/project.py +106 -0
- codaviz/cli.py +76 -0
- codaviz/config.py +19 -0
- codaviz/discovery.py +119 -0
- codaviz/export/__init__.py +6 -0
- codaviz/export/csv.py +53 -0
- codaviz/export/json.py +14 -0
- codaviz/model.py +60 -0
- codaviz/report/__init__.py +12 -0
- codaviz/report/assets/app.js +340 -0
- codaviz/report/assets/echarts.min.js +45 -0
- codaviz/report/assets/styles.css +229 -0
- codaviz/report/payload.py +163 -0
- codaviz/report/render.py +79 -0
- codaviz/report/templates/report.html.j2 +67 -0
- codaviz-0.5.0.dist-info/METADATA +133 -0
- codaviz-0.5.0.dist-info/RECORD +26 -0
- codaviz-0.5.0.dist-info/WHEEL +4 -0
- codaviz-0.5.0.dist-info/entry_points.txt +3 -0
- codaviz-0.5.0.dist-info/licenses/LICENSE +202 -0
codaviz/__init__.py
ADDED
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
|
+
)
|
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)
|