From 5ca4ff10b070975faaad67deb2b53b813887d6aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:45:38 +0000 Subject: [PATCH 1/8] Initial plan From b20630acb158c853793783df8a2b4f4befec9ef2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:47:27 +0000 Subject: [PATCH 2/8] Add stale issue/PR management GitHub Actions workflow Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- .github/workflows/stale.yml | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..050dd32 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,71 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Issues inactive for 60 days are marked stale + days-before-issue-stale: 60 + # Stale issues are closed after another 14 days without activity + days-before-issue-close: 14 + + # Pull requests follow the same schedule + days-before-pr-stale: 60 + days-before-pr-close: 14 + + stale-issue-label: 'stale' + close-issue-label: 'wontfix' + + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. Here are some hints that may help: + + - If this is a **bug report**, please check whether the issue still + reproduces on the latest version of pyflowchart. If it does, feel + free to leave a comment so it stays open. + - If this is a **question**, you may find an answer in the + [README](https://github.com/cdfmlr/pyflowchart#readme) or by + searching existing issues. + - If this is a **feature request** that you no longer need or that + you think is out of scope, feel free to close it yourself. + + The issue will be closed in 14 days if no further activity occurs. + Thank you for your contributions! + + close-issue-message: > + This issue was closed automatically because it has been stale for 14 + days with no further activity. If you believe this was done in error, + or if the issue is still relevant, please reopen it and leave a comment. + + stale-pr-label: 'stale' + + stale-pr-message: > + This pull request has been automatically marked as stale because it + has not had recent activity. It will be closed in 14 days if no + further activity occurs. Thank you for your contribution! + + close-pr-message: > + This pull request was closed automatically because it has been stale + for 14 days with no further activity. Please reopen it if you would + like to continue working on it. + + # Never auto-close issues that carry these labels + exempt-issue-labels: 'pinned,security' + exempt-pr-labels: 'pinned,security' + + # Only process a reasonable number of items per run to stay within + # API rate limits + operations-per-run: 100 From 00e535af3337036e923916767ab1335508cabbde Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:56:14 +0000 Subject: [PATCH 3/8] Add try/except/else/finally flowchart support (fix issue #18); remove stale workflow Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- .github/workflows/stale.yml | 71 ---------------- pyflowchart/__init__.py | 3 + pyflowchart/ast_node.py | 157 +++++++++++++++++++++++++++++++++++- pyflowchart/test.py | 62 +++++++++++++- 4 files changed, 219 insertions(+), 74 deletions(-) delete mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index 050dd32..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Mark stale issues and pull requests - -on: - schedule: - - cron: '30 1 * * *' - workflow_dispatch: - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - - steps: - - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - # Issues inactive for 60 days are marked stale - days-before-issue-stale: 60 - # Stale issues are closed after another 14 days without activity - days-before-issue-close: 14 - - # Pull requests follow the same schedule - days-before-pr-stale: 60 - days-before-pr-close: 14 - - stale-issue-label: 'stale' - close-issue-label: 'wontfix' - - stale-issue-message: > - This issue has been automatically marked as stale because it has not had - recent activity. Here are some hints that may help: - - - If this is a **bug report**, please check whether the issue still - reproduces on the latest version of pyflowchart. If it does, feel - free to leave a comment so it stays open. - - If this is a **question**, you may find an answer in the - [README](https://github.com/cdfmlr/pyflowchart#readme) or by - searching existing issues. - - If this is a **feature request** that you no longer need or that - you think is out of scope, feel free to close it yourself. - - The issue will be closed in 14 days if no further activity occurs. - Thank you for your contributions! - - close-issue-message: > - This issue was closed automatically because it has been stale for 14 - days with no further activity. If you believe this was done in error, - or if the issue is still relevant, please reopen it and leave a comment. - - stale-pr-label: 'stale' - - stale-pr-message: > - This pull request has been automatically marked as stale because it - has not had recent activity. It will be closed in 14 days if no - further activity occurs. Thank you for your contribution! - - close-pr-message: > - This pull request was closed automatically because it has been stale - for 14 days with no further activity. Please reopen it if you would - like to continue working on it. - - # Never auto-close issues that carry these labels - exempt-issue-labels: 'pinned,security' - exempt-pr-labels: 'pinned,security' - - # Only process a reasonable number of items per run to stay within - # API rate limits - operations-per-run: 100 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..93e2e3a 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -885,14 +885,161 @@ 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 + 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 +1048,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 +1108,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..e1684b6 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -616,6 +616,55 @@ 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 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -717,6 +766,16 @@ 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) + # ------------------------------------------------------------------ # # Tests for bug fixes # # ------------------------------------------------------------------ # @@ -785,7 +844,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__") From 5f64dbd6f519890355bdf405b6c1f364c88cab46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 04:26:14 +0000 Subject: [PATCH 4/8] Add complex try/except test cases: full clauses, sequence, and loop Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyflowchart/test.py | 127 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/pyflowchart/test.py b/pyflowchart/test.py index e1684b6..a4829f6 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -665,6 +665,115 @@ def try_finally_test(): ''' +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 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -776,6 +885,24 @@ def test_try_finally(self): 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) + # ------------------------------------------------------------------ # # Tests for bug fixes # # ------------------------------------------------------------------ # From 677feff5a1b344e2f713d065391674b882041ffc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 05:53:10 +0000 Subject: [PATCH 5/8] docs: document try/except/else/finally as beta feature in README (EN + ZH) Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ README_zh-CN.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/README.md b/README.md index 8ef6d12..9c52149 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. + +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..9cf72a9 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` 块。 + +Python 3.11+ 的 `except*`(ExceptionGroup)块也通过相同机制处理。 + ### 输出 HTML 与图片 传入 `-o output.html` 可将流程图直接写入 HTML 文件: From c9163a8afb45f010e4ec338167c67963e5b644d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:17:37 +0000 Subject: [PATCH 6/8] test+docs: pin try-multiline-body behavior and document recommendation for minimal try bodies Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- README.md | 17 +++++++++++++++++ README_zh-CN.md | 17 +++++++++++++++++ pyflowchart/test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/README.md b/README.md index 9c52149..10f1151 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,23 @@ Each `except` clause is rendered as a condition diamond. The `else` branch is ta Python 3.11+ `except*` (ExceptionGroup) blocks are dispatched through the same mechanism. +#### Multi-line try bodies + +When the `try` body contains **multiple statements**, each statement is rendered as a separate node (just like any other sequence of statements) — they are **not** folded into a single node: + +```python +try: + a = setup() # → operation node + b = process(a) # → operation node + c = finalize(b)# → operation node +except ValueError: + handle() +``` + +This keeps the flowchart faithful to the code, but can produce large, hard-to-read diagrams when the `try` body is complex. + +> 💡 **Recommendation:** For clarity, apply `try`/`except` to a **minimal, ideally single-statement** atomic block rather than wrapping large sections of code. A `try` block that guards one operation is both better Python style and produces a cleaner flowchart. + ### 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 9cf72a9..2a658d9 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -395,6 +395,23 @@ except Exception as e?──是──▶ [处理代码] Python 3.11+ 的 `except*`(ExceptionGroup)块也通过相同机制处理。 +#### 多语句 try 语句体 + +当 `try` 语句体包含**多条语句**时,每条语句都会被渲染为一个独立节点(与普通语句序列的处理方式相同),**不会**被折叠为单一节点: + +```python +try: + a = setup() # → 操作节点 + b = process(a) # → 操作节点 + c = finalize(b)# → 操作节点 +except ValueError: + handle() +``` + +这样可以忠实地反映代码逻辑,但当 `try` 语句体较复杂时,生成的流程图会变得庞大且难以阅读。 + +> 💡 **建议:** 为了保持清晰,应将 `try`/`except` 应用于**尽可能小的、最好是单语句的**原子操作,而不是包裹大段代码。只保护一条操作的 `try` 块既符合 Python 最佳实践,也能生成更简洁的流程图。 + ### 输出 HTML 与图片 传入 `-o output.html` 可将流程图直接写入 HTML 文件: diff --git a/pyflowchart/test.py b/pyflowchart/test.py index a4829f6..70f8cd2 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -774,6 +774,44 @@ def try_in_loop_test(): ''' +def try_multiline_body_test(): + """Multiple statements in the try body are each expanded as separate nodes. + + The try body is NOT folded into a single operation node. Each statement + becomes its own node (operation, subroutine, …), connected in sequence, + and the last node feeds into the "exception raised?" condition diamond. + This mirrors how any ordinary statement sequence is rendered. + """ + 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 = ''' +op5=>operation: a = setup() +op7=>operation: b = process(a) +op9=>operation: c = finalize(b) +cond2=>condition: exception raised? +cond11=>condition: except ValueError +sub15=>subroutine: handle() + +op5->op7 +op7->op9 +op9->cond2 +cond2(yes)->cond11 +cond11(yes)->sub15 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -903,6 +941,12 @@ def test_try_in_loop(self): print(got) self.assertEqualFlowchart(got, EXPECTED_TRY_IN_LOOP_TEST) + def test_try_multiline_body(self): + """Multiple statements in try body each expand into separate nodes (not folded).""" + got = try_multiline_body_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_TRY_MULTILINE_BODY_TEST) + # ------------------------------------------------------------------ # # Tests for bug fixes # # ------------------------------------------------------------------ # From c0a85e8164408ec753211f8b7a2c170a5d98d4fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:34:22 +0000 Subject: [PATCH 7/8] fix: fold multi-statement try body into single node for correct exception scope Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- README.md | 19 +------------------ README_zh-CN.md | 19 +------------------ pyflowchart/ast_node.py | 27 +++++++++++++++++++-------- pyflowchart/test.py | 31 ++++++++++++++----------------- 4 files changed, 35 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 10f1151..d9c7004 100644 --- a/README.md +++ b/README.md @@ -393,27 +393,10 @@ except Exception as e? ──yes──▶ [handler body] [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. +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. -#### Multi-line try bodies - -When the `try` body contains **multiple statements**, each statement is rendered as a separate node (just like any other sequence of statements) — they are **not** folded into a single node: - -```python -try: - a = setup() # → operation node - b = process(a) # → operation node - c = finalize(b)# → operation node -except ValueError: - handle() -``` - -This keeps the flowchart faithful to the code, but can produce large, hard-to-read diagrams when the `try` body is complex. - -> 💡 **Recommendation:** For clarity, apply `try`/`except` to a **minimal, ideally single-statement** atomic block rather than wrapping large sections of code. A `try` block that guards one operation is both better Python style and produces a cleaner flowchart. - ### 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 2a658d9..dad1802 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -391,27 +391,10 @@ except Exception as e?──是──▶ [处理代码] [finally 语句体] ``` -每个 `except` 子句被渲染为一个条件菱形节点。当没有异常被触发时,走 `else` 分支。无论是已处理异常、未处理异常还是无异常路径,最终都会汇聚到 `finally` 块。 +每个 `except` 子句被渲染为一个条件菱形节点。当没有异常被触发时,走 `else` 分支。无论是已处理异常、未处理异常还是无异常路径,最终都会汇聚到 `finally` 块。当 `try` 语句体包含多条语句时,它们会被折叠为单一操作节点,以确保整个语句块都被 `exception raised?` 菱形覆盖;为保持清晰,建议尽量保持 `try` 语句体简短(最理想是只有一条语句)。 Python 3.11+ 的 `except*`(ExceptionGroup)块也通过相同机制处理。 -#### 多语句 try 语句体 - -当 `try` 语句体包含**多条语句**时,每条语句都会被渲染为一个独立节点(与普通语句序列的处理方式相同),**不会**被折叠为单一节点: - -```python -try: - a = setup() # → 操作节点 - b = process(a) # → 操作节点 - c = finalize(b)# → 操作节点 -except ValueError: - handle() -``` - -这样可以忠实地反映代码逻辑,但当 `try` 语句体较复杂时,生成的流程图会变得庞大且难以阅读。 - -> 💡 **建议:** 为了保持清晰,应将 `try`/`except` 应用于**尽可能小的、最好是单语句的**原子操作,而不是包裹大段代码。只保护一条操作的 `try` 块既符合 Python 最佳实践,也能生成更简洁的流程图。 - ### 输出 HTML 与图片 传入 `-o output.html` 可将流程图直接写入 HTML 文件: diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index 93e2e3a..075e60c 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -947,15 +947,26 @@ def __init__(self, ast_try: _ast.Try, **kwargs): self.exc_cond = TryExceptCondition() # parse try body - 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) + # 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: - NodesGroup.__init__(self, self.exc_cond) + 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) diff --git a/pyflowchart/test.py b/pyflowchart/test.py index 70f8cd2..994c270 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -775,12 +775,11 @@ def try_in_loop_test(): def try_multiline_body_test(): - """Multiple statements in the try body are each expanded as separate nodes. + """Multiple statements in the try body are folded into a single operation node. - The try body is NOT folded into a single operation node. Each statement - becomes its own node (operation, subroutine, …), connected in sequence, - and the last node feeds into the "exception raised?" condition diamond. - This mirrors how any ordinary statement sequence is rendered. + 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: @@ -797,18 +796,16 @@ def try_multiline_body_test(): EXPECTED_TRY_MULTILINE_BODY_TEST = ''' -op5=>operation: a = setup() -op7=>operation: b = process(a) -op9=>operation: c = finalize(b) +op3=>operation: a = setup() +b = process(a) +c = finalize(b) cond2=>condition: exception raised? -cond11=>condition: except ValueError -sub15=>subroutine: handle() - -op5->op7 -op7->op9 -op9->cond2 -cond2(yes)->cond11 -cond11(yes)->sub15 +cond5=>condition: except ValueError +sub9=>subroutine: handle() + +op3->cond2 +cond2(yes)->cond5 +cond5(yes)->sub9 ''' @@ -942,7 +939,7 @@ def test_try_in_loop(self): self.assertEqualFlowchart(got, EXPECTED_TRY_IN_LOOP_TEST) def test_try_multiline_body(self): - """Multiple statements in try body each expand into separate nodes (not folded).""" + """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) From d4d0ffc871d3246b00ae7949ef7c27140a098698 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:22:23 +0000 Subject: [PATCH 8/8] Bump version to v0.6.0b1 Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyproject.toml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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',