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]