diff --git a/README.md b/README.md index 8ef6d12..d9c7004 100644 --- a/README.md +++ b/README.md @@ -351,6 +351,52 @@ def classify(status): $ python -m pyflowchart example_match.py -f classify ``` +### try/except/else/finally (Beta) + +> ⚠️ **Beta feature:** `try`/`except` support is still in beta and may not work correctly in all cases. + +PyFlowchart translates `try`/`except`/`else`/`finally` blocks into a structured flowchart that shows all exception-handling paths. + +```python +# example_try.py +def fetch(url): + try: + data = requests.get(url) + except Timeout: + data = cached() + except Exception as e: + log(e) + else: + process(data) + finally: + close() +``` + +```sh +$ python -m pyflowchart example_try.py -f fetch +``` + +The generated flowchart represents the following structure: + +``` +[try body] + ↓ +exception raised? ──no──▶ [else body] + │ yes │ + ▼ │ +except Timeout? ──yes──▶ [handler body] + │ no │ +except Exception as e? ──yes──▶ [handler body] + │ no (unhandled) │ + └──────────────────────────►┤ + ▼ + [finally body] +``` + +Each `except` clause is rendered as a condition diamond. The `else` branch is taken when no exception is raised. All paths — handled exceptions, unhandled exceptions, and the no-exception path — converge into the `finally` block. When the `try` body contains multiple statements they are folded into a single operation node so that the `exception raised?` diamond covers the whole block; for clarity it is recommended to keep `try` bodies minimal (ideally a single statement). + +Python 3.11+ `except*` (ExceptionGroup) blocks are dispatched through the same mechanism. + ### output html and images Pass `-o output.html` to write the flowchart directly to an HTML file: diff --git a/README_zh-CN.md b/README_zh-CN.md index 6574428..dad1802 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -349,6 +349,52 @@ def classify(status): $ python -m pyflowchart example_match.py -f classify ``` +### try/except/else/finally(Beta) + +> ⚠️ **Beta 功能:** `try`/`except` 支持仍处于测试阶段,在某些情况下可能无法正常工作。 + +PyFlowchart 将 `try`/`except`/`else`/`finally` 语句块翻译为结构化流程图,呈现所有异常处理路径。 + +```python +# example_try.py +def fetch(url): + try: + data = requests.get(url) + except Timeout: + data = cached() + except Exception as e: + log(e) + else: + process(data) + finally: + close() +``` + +```sh +$ python -m pyflowchart example_try.py -f fetch +``` + +生成的流程图结构如下: + +``` +[try 语句体] + ↓ +是否发生异常?──否──▶ [else 语句体] + │ 是 │ + ▼ │ +except Timeout?──是──▶ [处理代码] + │ 否 │ +except Exception as e?──是──▶ [处理代码] + │ 否(未处理) │ + └──────────────────────►┤ + ▼ + [finally 语句体] +``` + +每个 `except` 子句被渲染为一个条件菱形节点。当没有异常被触发时,走 `else` 分支。无论是已处理异常、未处理异常还是无异常路径,最终都会汇聚到 `finally` 块。当 `try` 语句体包含多条语句时,它们会被折叠为单一操作节点,以确保整个语句块都被 `exception raised?` 菱形覆盖;为保持清晰,建议尽量保持 `try` 语句体简短(最理想是只有一条语句)。 + +Python 3.11+ 的 `except*`(ExceptionGroup)块也通过相同机制处理。 + ### 输出 HTML 与图片 传入 `-o output.html` 可将流程图直接写入 HTML 文件: diff --git a/pyflowchart/__init__.py b/pyflowchart/__init__.py index 695bacd..ab8b929 100644 --- a/pyflowchart/__init__.py +++ b/pyflowchart/__init__.py @@ -40,6 +40,9 @@ "Return", "Match", "MatchCase", + "Try", + "TryExceptCondition", + "ExceptHandlerCondition", # Parsing "ParseProcessGraph", "parse", diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index 5760617..075e60c 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -885,14 +885,172 @@ def simplify(self) -> None: if sys.version_info < (3, 10): Match = CommonOperation + +########### +# Try # +########### + +class TryExceptCondition(ConditionNode): + """ConditionNode representing 'exception raised?' in a try/except block.""" + + def __init__(self): + ConditionNode.__init__(self, cond="exception raised?") + + def fc_connection(self) -> str: + return "" + + +class ExceptHandlerCondition(ConditionNode): + """ConditionNode for each except clause: 'except {ExcType}?'""" + + def __init__(self, ast_handler: _ast.ExceptHandler): + ConditionNode.__init__(self, cond=self._cond_text(ast_handler)) + + @staticmethod + def _cond_text(handler: _ast.ExceptHandler) -> str: + if handler.type is None: + return "except" + type_name = astunparse.unparse(handler.type).strip() + if handler.name: + return f"except {type_name} as {handler.name}" + return f"except {type_name}" + + def fc_connection(self) -> str: + return "" + + +class Try(NodesGroup, AstNode): + """ + Try is an AstNode for _ast.Try (try/except statements in Python source code). + + Flowchart structure:: + + [try body] + ↓ + exception raised? → no → [else body (if any)] + ↓ yes + except {Type1}? → yes → [handler1 body] + ↓ no + except {Type2}? → yes → [handler2 body] + ↓ no + (unhandled) + + [finally body] ← all paths converge here + + Addresses: https://github.com/cdfmlr/pyflowchart/issues/18 + """ + + def __init__(self, ast_try: _ast.Try, **kwargs): + AstNode.__init__(self, ast_try, **kwargs) + + # "exception raised?" condition node + self.exc_cond = TryExceptCondition() + + # parse try body + # When there are multiple statements in the try body we fold them into a + # single OperationNode. Expanding them as separate nodes would only + # connect the *last* statement to the "exception raised?" diamond, + # leaving all preceding statements outside the exception-handling scope. + if len(ast_try.body) > 1: + body_text = '\n'.join( + astunparse.unparse(stmt).strip() for stmt in ast_try.body + ) + try_body_node = OperationNode(body_text) + NodesGroup.__init__(self, try_body_node) + try_body_node.connect(self.exc_cond) + else: + try_body = parse(ast_try.body, **kwargs) + if try_body.head is not None: + NodesGroup.__init__(self, try_body.head) + for tail in try_body.tails: + if isinstance(tail, Node): + tail.connect(self.exc_cond) + else: + NodesGroup.__init__(self, self.exc_cond) + + # yes-path: except handlers (chained) + self._parse_handlers(ast_try.handlers, **kwargs) + + # no-path: else body (runs only when no exception was raised) + self._parse_else(ast_try.orelse, **kwargs) + + # finally body — all current tails connect into it + self._parse_finally(ast_try.finalbody, **kwargs) + + def _parse_handlers(self, handlers, **kwargs) -> None: + """Chain except handlers as nested condition nodes on the yes-path of exc_cond.""" + if not handlers: + self.exc_cond.connect_yes(None) + self.append_tails(self.exc_cond.connection_yes.next_node) + return + + connect_fn = self.exc_cond.connect_yes + last_handler_cond = None + for handler in handlers: + handler_cond = ExceptHandlerCondition(handler) + connect_fn(handler_cond) + + body = parse(handler.body, **kwargs) + if body.head is not None: + handler_cond.connect_yes(body.head) + self.extend_tails(body.tails) + else: + handler_cond.connect_yes(None) + self.append_tails(handler_cond.connection_yes.next_node) + + connect_fn = handler_cond.connect_no + last_handler_cond = handler_cond + + # last handler's no-path: unhandled exception / implicit re-raise + if last_handler_cond is not None: + last_handler_cond.connect_no(None) + self.append_tails(last_handler_cond.connection_no.next_node) + + def _parse_else(self, orelse, **kwargs) -> None: + """Parse the else clause (no-path of exc_cond: no exception raised).""" + if orelse: + else_proc = parse(orelse, **kwargs) + if else_proc.head is not None: + self.exc_cond.connect_no(else_proc.head) + self.extend_tails(else_proc.tails) + return + + # no else body: virtual no-connection + self.exc_cond.connect_no(None) + self.append_tails(self.exc_cond.connection_no.next_node) + + def _parse_finally(self, finalbody, **kwargs) -> None: + """Parse the finally clause — all current tails connect into it.""" + if not finalbody: + return + + finally_proc = parse(finalbody, **kwargs) + if finally_proc.head is None: + return + + current_tails = list(self.tails) + self.tails = [] + for tail in current_tails: + if isinstance(tail, Node): + tail.connect(finally_proc.head) + self.extend_tails(finally_proc.tails) + + +# Python 3.11+ introduces TryStar for `except*` (exception groups). +# Its AST structure mirrors _ast.Try, so we reuse the same handler. +_ast_TryStar_t = _ast.AST # placeholder for Python < 3.11 +if sys.version_info >= (3, 11): + _ast_TryStar_t = _ast.TryStar + + # Sentence: common | func | cond | loop | ctrl # - func: def -# - cond: if +# - cond: if, try # - loop: for, while # - ctrl: break, continue, return, yield, call # - common: others # Special sentence: cond | loop | ctrl -# TODO: Try, With +# TODO: With __func_stmts = { _ast.FunctionDef: FunctionDef, @@ -901,7 +1059,9 @@ def simplify(self) -> None: __cond_stmts = { _ast.If: If, + _ast.Try: Try, # _ast_Match_t: Match, # need to check Python version, handle it later manually. + # _ast_TryStar_t: Try, # need to check Python version, handle it later manually. } __loop_stmts = { @@ -959,6 +1119,10 @@ def parse(ast_list: List[_ast.AST], **kwargs) -> ParseProcessGraph: if sys.version_info >= (3, 10) and isinstance(ast_object, _ast_Match_t): ast_node_class = Match + # special case: TryStar (`except*`) for Python 3.11+ + if sys.version_info >= (3, 11) and isinstance(ast_object, _ast_TryStar_t): + ast_node_class = Try + # special case: special stmt as a expr value. e.g. function call if isinstance(ast_object, _ast.Expr): if hasattr(ast_object, "value"): diff --git a/pyflowchart/test.py b/pyflowchart/test.py index 18bd830..994c270 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -616,6 +616,199 @@ def gen(n): ''' +def try_test(): + expr = ''' +try: + risky_op() +except ValueError: + handle() + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_TEST = ''' +sub5=>subroutine: risky_op() +cond2=>condition: exception raised? +cond7=>condition: except ValueError +sub11=>subroutine: handle() + +sub5->cond2 +cond2(yes)->cond7 +cond7(yes)->sub11 +''' + + +def try_finally_test(): + expr = ''' +try: + risky_op() +finally: + cleanup() + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_FINALLY_TEST = ''' +sub21=>subroutine: risky_op() +cond18=>condition: exception raised? +sub27=>subroutine: cleanup() + +sub21->cond18 +cond18(yes)->sub27 +cond18(no)->sub27 +''' + + +def try_full_test(): + """try / multiple except / else / finally — all four clauses present.""" + expr = ''' +try: + result = fetch() +except Timeout: + result = cached() +except Exception as e: + log(e) +else: + process(result) +finally: + close() + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_FULL_TEST = ''' +op5=>operation: result = fetch() +cond2=>condition: exception raised? +cond7=>condition: except Timeout +op11=>operation: result = cached() +sub26=>subroutine: close() +cond13=>condition: except Exception as e +sub17=>subroutine: log(e) +sub22=>subroutine: process(result) + +op5->cond2 +cond2(yes)->cond7 +cond7(yes)->op11 +op11->sub26 +cond7(no)->cond13 +cond13(yes)->sub17 +sub17->sub26 +cond13(no)->sub26 +cond2(no)->sub22 +sub22->sub26 +''' + + +def try_in_sequence_test(): + """do something -> try block -> other things (try is not the only statement).""" + expr = ''' +prepare() +try: + result = fetch() +except IOError: + result = default() +use(result) + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_IN_SEQUENCE_TEST = ''' +sub30=>subroutine: prepare() +op35=>operation: result = fetch() +cond32=>condition: exception raised? +cond37=>condition: except IOError +op41=>operation: result = default() +sub46=>subroutine: use(result) + +sub30->op35 +op35->cond32 +cond32(yes)->cond37 +cond37(yes)->op41 +op41->sub46 +cond37(no)->sub46 +cond32(no)->sub46 +''' + + +def try_in_loop_test(): + """try/except nested inside a for loop.""" + expr = ''' +for item in items: + try: + process(item) + except ValueError: + skip(item) + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_IN_LOOP_TEST = ''' +cond51=>condition: for item in items +sub58=>subroutine: process(item) +cond55=>condition: exception raised? +cond60=>condition: except ValueError +sub64=>subroutine: skip(item) + +cond51(yes)->sub58 +sub58->cond55 +cond55(yes)->cond60 +cond60(yes)->sub64 +sub64->cond51 +cond60(no)->cond51 +cond55(no)->cond51 +''' + + +def try_multiline_body_test(): + """Multiple statements in the try body are folded into a single operation node. + + All statements in the try body are joined into one OperationNode so that + the "exception raised?" condition covers the entire block, not just the + last statement. + """ + expr = ''' +try: + a = setup() + b = process(a) + c = finalize(b) +except ValueError: + handle() + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_TRY_MULTILINE_BODY_TEST = ''' +op3=>operation: a = setup() +b = process(a) +c = finalize(b) +cond2=>condition: exception raised? +cond5=>condition: except ValueError +sub9=>subroutine: handle() + +op3->cond2 +cond2(yes)->cond5 +cond5(yes)->sub9 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -717,6 +910,40 @@ def test_yield_from(self): print(got) self.assertEqualFlowchart(got, EXPECTED_YIELD_FROM_TEST) + def test_try(self): + got = try_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_TEST) + + def test_try_finally(self): + got = try_finally_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_FINALLY_TEST) + + def test_try_full(self): + """try with multiple except handlers, an else clause, and finally.""" + got = try_full_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_FULL_TEST) + + def test_try_in_sequence(self): + """try block is preceded and followed by other statements.""" + got = try_in_sequence_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_IN_SEQUENCE_TEST) + + def test_try_in_loop(self): + """try/except nested inside a for loop.""" + got = try_in_loop_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_IN_LOOP_TEST) + + def test_try_multiline_body(self): + """Multiple statements in try body are folded into a single node.""" + got = try_multiline_body_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_MULTILINE_BODY_TEST) + # ------------------------------------------------------------------ # # Tests for bug fixes # # ------------------------------------------------------------------ # @@ -785,7 +1012,8 @@ def test_public_api_all_complete(self): 'SubroutineNode', 'ConditionNode', 'TransparentNode', 'CondYN', 'AstNode', 'FunctionDef', 'Loop', 'If', 'CommonOperation', 'CallSubroutine', 'BreakContinueSubroutine', 'YieldOutput', 'Return', - 'Match', 'MatchCase', 'ParseProcessGraph', 'parse', 'output_html', + 'Match', 'MatchCase', 'Try', 'TryExceptCondition', 'ExceptHandlerCondition', + 'ParseProcessGraph', 'parse', 'output_html', ] for name in required: self.assertIn(name, pyflowchart.__all__, msg=f"'{name}' missing from __all__") diff --git a/pyproject.toml b/pyproject.toml index cc4d676..89bf7a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyflowchart" -version = "0.5.0b1" +version = "0.6.0b1" description = "Python codes to Flowcharts." readme = "README.md" license = { text = "MIT" } diff --git a/setup.py b/setup.py index dff7052..a1f851e 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='pyflowchart', - version='0.5.0b1', + version='0.6.0b1', url='https://github.com/cdfmlr/pyflowchart', license='MIT', author='CDFMLR',