From c4450f4dc801475b08e87853e6d35bf00433baa6 Mon Sep 17 00:00:00 2001 From: Markham Anderson Date: Tue, 19 May 2026 13:00:54 -0700 Subject: [PATCH] feat: add JSON output format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐ŸŽฏ Motivation Consumers of python-abc output currently have to parse the human-readable text format. That makes automated use brittle and couples callers to the exact CLI spacing and display string. ๐Ÿฆ‹ What's Changed Add a `--format` option with `text` and `json` modes, keeping the existing text format as the default. Move text and JSON rendering into `python_abc/output.py`. Introduce `AnalysisResult` to impose a schema on the result tuples that had been passed around heretofore. This keeps sorting, text rendering, and JSON serialization working from named fields instead of tuple positions. Emit JSON with per-file status, vector components, magnitude, errors, and a summary. The JSON payload intentionally omits a `display` field. Document the JSON output mode in the README and add renderer tests. โ›”๏ธ Other Details `--format json` is rejected when `--debug` or `--verbose` is also provided because those modes print diagnostic output to stdout. ๐Ÿงช Testing `.venv/bin/pytest tests/test_output.py` `.venv/bin/python -m python_abc python_abc/vector.py --format json` `.venv/bin/ruff check python_abc/__main__.py python_abc/output.py tests/test_output.py` Full `.venv/bin/pytest` still has existing failures around `with ... as ...` assignment counting. ๐Ÿ”— References None. --- README.md | 29 ++++++++++++++++ python_abc/__main__.py | 30 +++++++++++------ python_abc/output.py | 75 ++++++++++++++++++++++++++++++++++++++++++ tests/test_output.py | 58 ++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 python_abc/output.py create mode 100644 tests/test_output.py diff --git a/README.md b/README.md index abb849f..e5c13a6 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ optional arguments: --debug DEBUG display AST output for each element in the parsed tree --sort SORT sort files from highest to lowest magnitude --verbose VERBOSE display marked-up file + --format {text,json} + output format ``` Given `file.py` that contains the following text: @@ -72,6 +74,33 @@ $ python -m python_abc /path/to/file.py /path/to/file.py <1, 7, 10> (12.2) ``` +You can also emit machine-readable JSON: + +```bash +$ python -m python_abc /path/to/file.py --format json +{ + "path": "/path/to/file.py", + "files": [ + { + "path": "/path/to/file.py", + "status": "ok", + "vector": { + "assignment": 1, + "branch": 7, + "condition": 10 + }, + "magnitude": 12.2, + "error": null + } + ], + "summary": { + "files": 1, + "parsed": 1, + "syntax_errors": 0 + } +} +``` + Passing the `verbose` flag will give more detail: ```bash diff --git a/python_abc/__main__.py b/python_abc/__main__.py index 6ed6b71..039ddce 100644 --- a/python_abc/__main__.py +++ b/python_abc/__main__.py @@ -6,6 +6,7 @@ from joblib import Parallel, delayed from python_abc.calculate import calculate_abc +from python_abc.output import AnalysisResult, render_json, render_text def main(): @@ -39,8 +40,18 @@ def main(): parser.add_argument( "--verbose", dest="verbose", action="store_true", help="display marked-up file", ) + parser.add_argument( + "--format", + dest="format", + choices=("text", "json"), + default="text", + help="output format", + ) args = vars(parser.parse_args()) + if args["format"] == "json" and (args["debug"] or args["verbose"]): + parser.error("--format json cannot be used with --debug or --verbose") + path = args["path"][0] files: List[str] = [] @@ -55,8 +66,6 @@ def main(): else: files.append(path) - max_path_length = max(len(file) for file in files) - def analyze_file(filename): with open(filename, "r") as f: source = f.read() @@ -64,22 +73,23 @@ def analyze_file(filename): try: abc_vector = calculate_abc(source, args["debug"], args["verbose"]) except SyntaxError: - return (filename, None, 0.0) + return AnalysisResult(filename, None, 0.0) else: - return (filename, abc_vector, abc_vector.get_magnitude_value()) + return AnalysisResult( + filename, abc_vector, abc_vector.get_magnitude_value() + ) output = Parallel(n_jobs=args["cores"])( delayed(analyze_file)(filename) for filename in files ) if args["sort"] is True: - output.sort(key=lambda x: x[2], reverse=True) + output.sort(key=lambda result: result.sort_magnitude, reverse=True) - for filename, vector, _ in output: - if vector is None: - print(f"{filename:<{max_path_length}} {'Unable to parse AST':>26}") - else: - print(f"{filename:<{max_path_length}} {vector.magnitude:>26}") + if args["format"] == "json": + print(render_json(path, output)) + else: + print(render_text(output)) if __name__ == "__main__": diff --git a/python_abc/output.py b/python_abc/output.py new file mode 100644 index 0000000..d25e51d --- /dev/null +++ b/python_abc/output.py @@ -0,0 +1,75 @@ +import json +from dataclasses import dataclass +from typing import List, Optional + +from python_abc import vector + + +@dataclass +class AnalysisResult: + path: str + vector: Optional[vector.Vector] + sort_magnitude: float + + @property + def status(self) -> str: + if self.vector is None: + return "syntax_error" + + return "ok" + + +def render_text(results: List[AnalysisResult]) -> str: + max_path_length = max((len(result.path) for result in results), default=0) + lines = [] + + for result in results: + if result.vector is None: + lines.append(f"{result.path:<{max_path_length}} {'Unable to parse AST':>26}") + else: + lines.append(f"{result.path:<{max_path_length}} {result.vector.magnitude:>26}") + + return "\n".join(lines) + + +def render_json(path: str, results: List[AnalysisResult]) -> str: + files = [] + + for result in results: + if result.vector is None: + files.append( + { + "path": result.path, + "status": result.status, + "vector": None, + "magnitude": None, + "error": "Unable to parse AST", + } + ) + else: + files.append( + { + "path": result.path, + "status": result.status, + "vector": { + "assignment": result.vector.assignment, + "branch": result.vector.branch, + "condition": result.vector.condition, + }, + "magnitude": result.vector.get_magnitude_value(), + "error": None, + } + ) + + syntax_errors = sum(1 for result in results if result.vector is None) + payload = { + "path": path, + "files": files, + "summary": { + "files": len(results), + "parsed": len(results) - syntax_errors, + "syntax_errors": syntax_errors, + }, + } + + return json.dumps(payload, indent=2) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..8dec5c3 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,58 @@ +import json + +from python_abc import vector +from python_abc.output import AnalysisResult, render_json, render_text + + +def test_render_text(): + results = [ + AnalysisResult("file.py", vector.Vector(1, 2, 3), 3.7), + AnalysisResult("bad.py", None, 0.0), + ] + + assert render_text(results) == "\n".join( + [ + "file.py <1, 2, 3> (3.7)", + "bad.py Unable to parse AST", + ] + ) + + +def test_render_json(): + results = [ + AnalysisResult("file.py", vector.Vector(1, 2, 3), 3.7), + AnalysisResult("bad.py", None, 0.0), + ] + + payload = json.loads(render_json("src", results)) + + assert payload == { + "path": "src", + "files": [ + { + "path": "file.py", + "status": "ok", + "vector": { + "assignment": 1, + "branch": 2, + "condition": 3, + }, + "magnitude": 3.7, + "error": None, + }, + { + "path": "bad.py", + "status": "syntax_error", + "vector": None, + "magnitude": None, + "error": "Unable to parse AST", + }, + ], + "summary": { + "files": 2, + "parsed": 1, + "syntax_errors": 1, + }, + } + + assert "display" not in payload["files"][0]