From 5c6a2637ed11507ce1d3ed71f509d876a42aab6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:29:46 +0000 Subject: [PATCH 1/9] Initial plan From df7bde9feb39dae813b81624066cc5a972c22a0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:34:47 +0000 Subject: [PATCH 2/9] Modernize pyflowchart: Python 3.9+ conditional astunparse, async def/for support Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyflowchart/ast_node.py | 4 ++- pyflowchart/test.py | 62 +++++++++++++++++++++++++++++++++++++++++ setup.py | 2 +- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index f4bf6dd..fc5adf0 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -894,7 +894,8 @@ def simplify(self) -> None: # TODO: Try, With __func_stmts = { - _ast.FunctionDef: FunctionDef + _ast.FunctionDef: FunctionDef, + _ast.AsyncFunctionDef: FunctionDef, } __cond_stmts = { @@ -905,6 +906,7 @@ def simplify(self) -> None: __loop_stmts = { _ast.For: Loop, _ast.While: Loop, + _ast.AsyncFor: Loop, } __ctrl_stmts = { diff --git a/pyflowchart/test.py b/pyflowchart/test.py index dd50aac..520d513 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -518,6 +518,58 @@ def test_match(a, b, c): ''' +def async_func_test(): + expr = ''' +async def fetch(url, retries=3): + result = await get(url) + return result + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_ASYNC_FUNC_TEST = ''' +st3=>start: start fetch +io5=>inputoutput: input: url, retries +op8=>operation: result = await get(url) +io13=>inputoutput: output: result +e11=>end: end function return + +st3->io5 +io5->op8 +op8->io13 +io13->e11 +''' + + +def async_for_test(): + expr = ''' +async for item in aiter(): + print(item) + process(item) +done() + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_ASYNC_FOR_TEST = ''' +cond3=>condition: async for item in aiter() +sub12=>subroutine: print(item) +sub14=>subroutine: process(item) +sub18=>subroutine: done() + +cond3(yes)->sub12 +sub12->sub14 +sub14(left)->cond3 +cond3(no)->sub18 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -599,6 +651,16 @@ def test_match(self): print(got) self.assertEqualFlowchart(got, EXPECTED_MATCH_TEST_PY_GE_310) + def test_async_func(self): + got = async_func_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_ASYNC_FUNC_TEST) + + def test_async_for(self): + got = async_for_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_ASYNC_FOR_TEST) + if __name__ == '__main__': # print(flowchart_translate_test()) diff --git a/setup.py b/setup.py index e958e42..99bcb61 100644 --- a/setup.py +++ b/setup.py @@ -23,5 +23,5 @@ "Topic :: Utilities", ], python_requires='>=3.6', - install_requires=['astunparse', 'chardet'], + install_requires=['astunparse; python_version < "3.9"', 'chardet'], ) From 886478385bfe224ef0c17a914b08acb70e9d54c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:05:25 +0000 Subject: [PATCH 3/9] Comprehensive modernization: pyproject.toml, bug fixes, new statement support Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyflowchart/__main__.py | 10 +++++++- pyflowchart/ast_node.py | 32 +++++++++++++---------- pyflowchart/test.py | 56 +++++++++++++++++++++++++++++++++++++++++ setup.py | 13 ++++++++++ 4 files changed, 97 insertions(+), 14 deletions(-) diff --git a/pyflowchart/__main__.py b/pyflowchart/__main__.py index 8e33ef1..b6b0fe8 100644 --- a/pyflowchart/__main__.py +++ b/pyflowchart/__main__.py @@ -93,7 +93,11 @@ def main(code_file, field, inner, output_file, simplify, conds_align): output(flowchart.flowchart(), output_file, field) -if __name__ == '__main__': +def cli(): + """Entry point for the ``pyflowchart`` command-line tool. + + Registered as the ``pyflowchart`` console script in pyproject.toml / setup.py. + """ parser = argparse.ArgumentParser(description='Python code to flowchart.') # code_file: open as binary, detect encoding and decode in main later @@ -113,3 +117,7 @@ def main(code_file, field, inner, output_file, simplify, conds_align): args.inner = True main(args.code_file, args.field, args.inner, args.output, args.no_simplify, args.conds_align) + + +if __name__ == '__main__': + cli() diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index fc5adf0..a552246 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -97,7 +97,7 @@ class FunctionDefStart(AstNode, StartNode): standing for the start of a function. """ - def __init__(self, ast_function_def: _ast.FunctionDef, **kwargs): + def __init__(self, ast_function_def: _ast.stmt, **kwargs): AstNode.__init__(self, ast_function_def, **kwargs) StartNode.__init__(self, ast_function_def.name) @@ -108,7 +108,7 @@ class FunctionDefEnd(AstNode, EndNode): standing for the end of a function. """ - def __init__(self, ast_function_def: _ast.FunctionDef, **kwargs): + def __init__(self, ast_function_def: _ast.stmt, **kwargs): AstNode.__init__(self, ast_function_def, **kwargs) EndNode.__init__(self, ast_function_def.name) @@ -119,13 +119,13 @@ class FunctionDefArgsInput(AstNode, InputOutputNode): standing for the args (input) of a function. """ - def __init__(self, ast_function_def: _ast.FunctionDef, **kwargs): + def __init__(self, ast_function_def: _ast.stmt, **kwargs): AstNode.__init__(self, ast_function_def, **kwargs) InputOutputNode.__init__(self, InputOutputNode.INPUT, self.func_args_str()) def func_args_str(self): # TODO(important): handle defaults, vararg, kwonlyargs, kw_defaults, kwarg - assert isinstance(self.ast_object, _ast.FunctionDef) or \ + assert isinstance(self.ast_object, (_ast.FunctionDef, _ast.AsyncFunctionDef)) or \ hasattr(self.ast_object, "args") args = [] for arg in self.ast_object.args.args: @@ -141,7 +141,7 @@ class FunctionDef(NodesGroup, AstNode): This class is a NodesGroup with FunctionDefStart & FunctionDefArgsInput & function-body & FunctionDefEnd. """ - def __init__(self, ast_func: _ast.FunctionDef, **kwargs): # _ast.For | _ast.While + def __init__(self, ast_func: _ast.stmt, **kwargs): # _ast.FunctionDef | _ast.AsyncFunctionDef """ FunctionDef.__init__ makes a NodesGroup object with following Nodes chain: FunctionDef -> FunctionDefStart -> FunctionDefArgsInput -> [function-body] -> FunctionDefEnd @@ -175,7 +175,7 @@ def parse_func_body(self, **kwargs) -> Tuple[Node, List[Node]]: - body_head - body_tails """ - assert isinstance(self.ast_object, _ast.FunctionDef) or \ + assert isinstance(self.ast_object, (_ast.FunctionDef, _ast.AsyncFunctionDef)) or \ hasattr(self.ast_object, "body") p = parse(self.ast_object.body, **kwargs) return p.head, p.tails @@ -254,14 +254,12 @@ def parse_loop_body(self, **kwargs) -> None: """ Parse and Connect loop-body (a node graph) to self.cond_node (LoopCondition), extend `self.tails` with tails got. """ - assert isinstance(self.ast_object, _ast.For) or \ - isinstance(self.ast_object, _ast.While) or \ + assert isinstance(self.ast_object, (_ast.For, _ast.While, _ast.AsyncFor)) or \ hasattr(self.ast_object, "body") - progress = parse(self.ast_object.body, **kwargs) + process = parse(self.ast_object.body, **kwargs) - if progress.head is not None: - process = parse(self.ast_object.body, **kwargs) + if process.head is not None: # head self.cond_node.connect_yes(process.head) # tails connect back to cond @@ -559,7 +557,13 @@ class ReturnOutput(AstNode, InputOutputNode): def __init__(self, ast_return: _ast.Return, **kwargs): AstNode.__init__(self, ast_return, **kwargs) - InputOutputNode.__init__(self, InputOutputNode.OUTPUT, self.ast_to_source().lstrip("return")) + # ast_to_source() gives "return " or bare "return". + # Strip the "return" keyword: use prefix removal to avoid lstrip()'s + # character-set semantics (lstrip("return") strips individual chars, not the word). + source = self.ast_to_source() + if source.startswith("return"): + source = source[len("return"):].lstrip() + InputOutputNode.__init__(self, InputOutputNode.OUTPUT, source) class ReturnEnd(AstNode, EndNode): @@ -818,7 +822,7 @@ def __init__(self, ast_match: _ast_Match_t, **kwargs): debug(f"Match.__init__() replace head: self.head before: {type(self.head)}: {self.head.__dict__}") self.head = self.head.connections[0].next_node debug(f"Match.__init__() replace head self.head after: {type(self.head)}: {self.head.__dict__}") - except IndexError or AttributeError: + except (IndexError, AttributeError): self.head = CommonOperation(ast_match) self.tails = [self.head] @@ -912,8 +916,10 @@ def simplify(self) -> None: __ctrl_stmts = { _ast.Break: BreakContinueSubroutine, _ast.Continue: BreakContinueSubroutine, + _ast.Raise: BreakContinueSubroutine, _ast.Return: Return, _ast.Yield: YieldOutput, + _ast.YieldFrom: YieldOutput, _ast.Call: CallSubroutine, } diff --git a/pyflowchart/test.py b/pyflowchart/test.py index 520d513..207d4fd 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -570,6 +570,52 @@ def async_for_test(): ''' +def raise_test(): + expr = ''' +x = 1 +raise ValueError('oops') +y = 2 + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_RAISE_TEST = ''' +op2=>operation: x = 1 +sub4=>subroutine: raise ValueError('oops') + +op2->sub4 +''' + + +def yield_from_test(): + expr = ''' +def gen(n): + yield from range(n) + yield from [1, 2, 3] + ''' + expr_ast = ast.parse(expr) + p = parse(expr_ast.body) + flow = Flowchart(p.head).flowchart() + return flow + + +EXPECTED_YIELD_FROM_TEST = ''' +st11=>start: start gen +io13=>inputoutput: input: n +io16=>inputoutput: output: yield from range(n) +io18=>inputoutput: output: yield from [1, 2, 3] +e20=>end: end gen + +st11->io13 +io13->io16 +io16->io18 +io18->e20 +''' + + class PyflowchartTestCase(unittest.TestCase): def assertEqualFlowchart(self, got: str, expected: str): return self.assertEqual( @@ -661,6 +707,16 @@ def test_async_for(self): print(got) self.assertEqualFlowchart(got, EXPECTED_ASYNC_FOR_TEST) + def test_raise(self): + got = raise_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_RAISE_TEST) + + def test_yield_from(self): + got = yield_from_test() + print(got) + self.assertEqualFlowchart(got, EXPECTED_YIELD_FROM_TEST) + if __name__ == '__main__': # print(flowchart_translate_test()) diff --git a/setup.py b/setup.py index 99bcb61..21a302c 100644 --- a/setup.py +++ b/setup.py @@ -19,9 +19,22 @@ "Environment :: Console", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Code Generators", "Topic :: Utilities", ], python_requires='>=3.6', install_requires=['astunparse; python_version < "3.9"', 'chardet'], + entry_points={ + 'console_scripts': [ + 'pyflowchart=pyflowchart.__main__:cli', + ], + }, ) From 074547198d83474f3e9f59defec2747971f90b56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:34:15 +0000 Subject: [PATCH 4/9] Plan: comprehensive deep review - all issues catalogued Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 21a302c..671be63 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Code Generators", "Topic :: Utilities", ], From c2ad3cc464fc1d976a98033bd57ac6e5da0ae16a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:44:16 +0000 Subject: [PATCH 5/9] Deep review: fix all bugs, add pyproject.toml, CI matrix, 6 new tests Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- .github/workflows/TestPyPi-publish.yml | 60 ++++++-------- .github/workflows/codeql-analysis.yml | 61 ++++---------- .github/workflows/release-publish.yml | 47 +++++------ .github/workflows/tests.yml | 43 ++++++++++ .gitignore | 4 +- README.md | 2 +- pyflowchart/__init__.py | 38 ++++++++- pyflowchart/__main__.py | 2 +- pyflowchart/ast_node.py | 20 ++--- pyflowchart/flowchart.py | 16 ++-- pyflowchart/test.py | 109 +++++++++++++++++++++++++ pyproject.toml | 50 ++++++++++++ 12 files changed, 321 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 pyproject.toml diff --git a/.github/workflows/TestPyPi-publish.yml b/.github/workflows/TestPyPi-publish.yml index e3dddb1..e3f3a3a 100644 --- a/.github/workflows/TestPyPi-publish.yml +++ b/.github/workflows/TestPyPi-publish.yml @@ -1,41 +1,35 @@ -# This workflow will build & upload a Python Package to TestPyPi using Twine -# whenever a PR is merged or a commit is made to the master branch. +# Build and upload to TestPyPI on every push to master. +name: Build and Publish Package to TestPyPI -name: Build and Publish Package to TestPyPi - -# This workflow will run when a PR is merged or when a commit is made directly to the master branch. -# ref: https://github.community/t/trigger-workflow-only-on-pull-request-merge/17359/3 on: push: - branches: [ master ] + branches: [master] jobs: build-and-publish: - name: Build and publish distributions to TestPyPI + name: Build and publish to TestPyPI runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # required for trusted publishing + steps: - - uses: actions/checkout@master - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: 🔨Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - skip_existing: true + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build + run: python -m pip install --upgrade build + + - name: Build sdist and wheel + run: python -m build --sdist --wheel --outdir dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + skip-existing: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5705f45..cfa355f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,22 +1,8 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: push: -# branches: [ master ] pull_request: - # The branches below must be a subset of the branches above -# branches: [ master ] schedule: - cron: '42 10 * * 6' @@ -24,44 +10,27 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write strategy: fail-fast: false matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + language: ['python'] steps: - - name: Checkout repository - uses: actions/checkout@v2 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + - name: Autobuild + uses: github/codeql-action/autobuild@v3 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 62b6e60..81c763b 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -1,7 +1,5 @@ -# This workflow will upload a Python Package using Twine when a release is published -# For more information see: https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ - -name: Upload Release to PyPi +# Build and upload to PyPI when a GitHub Release is published. +name: Upload Release to PyPI on: release: @@ -9,30 +7,23 @@ on: jobs: deploy: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build + run: python -m pip install --upgrade build + + - name: Build sdist and wheel + run: python -m build --sdist --wheel --outdir dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..53493ae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +# Run the test suite across the full supported Python version range on every +# push and pull-request so regressions are caught early. +name: Tests + +on: + push: + pull_request: + +jobs: + test: + name: "Python ${{ matrix.python-version }}" + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true # ensures pre-release versions (e.g. RC builds) are accepted + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: pytest pyflowchart/test.py -v diff --git a/.gitignore b/.gitignore index ddddfa0..39f2813 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ dev_test -# poetry & pyproject.toml based env is not ready & for internal experiment only. -# keeps setup.py for a while. -pyproject.toml +# poetry lock file is not committed for a library; setup.py / pyproject.toml are the source of truth. poetry.lock .idea diff --git a/README.md b/README.md index fdde8e5..7e14f22 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Want to **flowchart your Python code in `example.py`?** Run this: $ python -m pyflowchart example.py ``` -> ⚠️ PyFlowchart works with **Python 3.7+**. To check your Python version, run [`python --version`](https://docs.python.org/3/using/cmdline.html#cmdoption-version). +> ⚠️ PyFlowchart works with **Python 3.6+**. To check your Python version, run [`python --version`](https://docs.python.org/3/using/cmdline.html#cmdoption-version). > > If you have both Python 2 and Python 3 installed, you may need to use `python3` instead of `python`. This is becoming less common as [Python 2 is sunsetting](https://www.python.org/doc/sunset-python-2/). diff --git a/pyflowchart/__init__.py b/pyflowchart/__init__.py index abeb67e..695bacd 100644 --- a/pyflowchart/__init__.py +++ b/pyflowchart/__init__.py @@ -2,8 +2,8 @@ PyFlowchart -------- -PyFlowchart is a package to write flowchart in Python -or translate Python source codes into flowchart. +PyFlowchart is a package to write flowcharts in Python +or translate Python source codes into flowcharts. Copyright 2020 CDFMLR. All rights reserved. Use of this source code is governed by a MIT @@ -14,3 +14,37 @@ from .ast_node import * from .flowchart import * from .output_html import * + +__all__ = [ + # Core node classes — for building flowcharts programmatically + "Node", + "Connection", + "NodesGroup", + "StartNode", + "EndNode", + "OperationNode", + "InputOutputNode", + "SubroutineNode", + "ConditionNode", + "TransparentNode", + "CondYN", + # AST-backed node classes + "AstNode", + "FunctionDef", + "Loop", + "If", + "CommonOperation", + "CallSubroutine", + "BreakContinueSubroutine", + "YieldOutput", + "Return", + "Match", + "MatchCase", + # Parsing + "ParseProcessGraph", + "parse", + # Main flowchart class + "Flowchart", + # HTML output helper + "output_html", +] diff --git a/pyflowchart/__main__.py b/pyflowchart/__main__.py index b6b0fe8..61320c3 100644 --- a/pyflowchart/__main__.py +++ b/pyflowchart/__main__.py @@ -40,7 +40,7 @@ def detect_decode(file_content: bytes) -> str: encoding = detect_result.get("encoding") confidence = detect_result.get("confidence") - if confidence < 0.9: + if not confidence or confidence < 0.9: encoding = "UTF-8" # decode file content by detected encoding diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index a552246..e9216c1 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -8,9 +8,8 @@ """ import _ast -import typing import warnings -from typing import Tuple +from typing import List, Tuple from pyflowchart.node import * @@ -708,10 +707,10 @@ def __init__(self, ast_match_case: _ast_match_case_t, subject: _ast.AST, **kwarg self.parse_body(**kwargs) def parse_body(self, **kwargs) -> None: - assert isinstance(self.ast_object, _ast.match_case) or \ + assert isinstance(self.ast_object, _ast_match_case_t) or \ hasattr(self.ast_object, "body") - progress = parse(self.ast_object.body) + progress = parse(self.ast_object.body, **kwargs) if progress.head is not None: self.cond_node.connect_yes(progress.head) @@ -801,11 +800,8 @@ def __init__(self, ast_match: _ast_Match_t, **kwargs): # A Cond for match_case should be represented as "if {subject} match case {pattern}" self.subject = ast_match.subject - # self.head = TransparentNode(self) - # fuck the multi inheritance,,, my brain is buffer overflowing - # god bless the jetbrains helped me figure out this overstep - # well, never mind. I believe that NodesGroup.__init__() - # is the right way to set it up as well as self.head properly. + # NodesGroup.__init__() is the correct way to initialise the head here. + # The transparent_head acts as a placeholder until cases are parsed. # Each case is a condition node. # Since we have not parsed any case body, (nor I want to peek one), @@ -835,7 +831,7 @@ def parse_cases(self, **kwargs) -> None: """ Parse and Connect cases of the match """ - assert isinstance(self.ast_object, _ast.Match) or \ + assert isinstance(self.ast_object, _ast_Match_t) or \ hasattr(self.ast_object, "cases") last_case = self.head # at first, it's a transparent node @@ -959,11 +955,11 @@ def parse(ast_list: List[_ast.AST], **kwargs) -> ParseProcessGraph: ast_node_class = __special_stmts.get(type(ast_object), CommonOperation) # special case: Match for Python 3.10+ - if sys.version_info >= (3, 10) and type(ast_object) == _ast_Match_t: + if sys.version_info >= (3, 10) and isinstance(ast_object, _ast_Match_t): ast_node_class = Match # special case: special stmt as a expr value. e.g. function call - if type(ast_object) == _ast.Expr: + if isinstance(ast_object, _ast.Expr): if hasattr(ast_object, "value"): ast_node_class = __special_stmts.get(type(ast_object.value), CommonOperation) else: # ast_object has no value attribute diff --git a/pyflowchart/flowchart.py b/pyflowchart/flowchart.py index 7d02d8d..e6c7678 100644 --- a/pyflowchart/flowchart.py +++ b/pyflowchart/flowchart.py @@ -95,8 +95,13 @@ def g(self): field_ast = Flowchart.find_field_from_ast(code_ast, field) - assert hasattr(field_ast, "body") - assert field_ast.body, f"{field}: nothing to parse. Check given code and field please." + if not hasattr(field_ast, "body"): + raise ValueError(f"field {field!r} has no body.") + if not field_ast.body: + raise ValueError( + f"{field!r}: nothing to parse. " + "Check that the field path points to a valid function or class." + ) f = field_ast.body if inner else [field_ast] p = parse(f, simplify=simplify, conds_align=conds_align) @@ -147,11 +152,12 @@ def g(self): field_list = field.split('.') try: for fd in field_list: - for ao in ast_obj.body: # raises AttributeError: ast_obj along the field path has no body + for ao in ast_obj.body: # raises AttributeError if ast_obj has no body if hasattr(ao, 'name') and ao.name == fd: ast_obj = ao - assert ast_obj.name == field_list[-1], "field not found" - except (AttributeError, AssertionError): + if not (hasattr(ast_obj, 'name') and ast_obj.name == field_list[-1]): + raise ValueError("field not found") + except (AttributeError, ValueError): ast_obj.body = [] return ast_obj diff --git a/pyflowchart/test.py b/pyflowchart/test.py index 207d4fd..2218434 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -717,6 +717,115 @@ def test_yield_from(self): print(got) self.assertEqualFlowchart(got, EXPECTED_YIELD_FROM_TEST) + # ------------------------------------------------------------------ # + # Tests for bug fixes # + # ------------------------------------------------------------------ # + + def test_from_code_invalid_field_raises_value_error(self): + """from_code() must raise ValueError (not AssertionError) for a field + that does not exist in the given code. Previously this was an assert + that was silently skipped under ``python -O``. + """ + code = 'def foo(): pass' + with self.assertRaises(ValueError): + Flowchart.from_code(code, field='nonexistent') + + def test_from_code_empty_raises_value_error(self): + """from_code() must raise ValueError when the parsed body is empty.""" + with self.assertRaises(ValueError): + Flowchart.from_code('', field='', inner=True) + + def test_find_field_invalid_path_returns_empty_body(self): + """find_field_from_ast() must return an AST node whose body is [] + for a field path that does not exist. This exercises the control-flow + branch that previously relied on a bare ``assert`` (broken under -O). + """ + code_ast = ast.parse('def foo(): pass') + result = Flowchart.find_field_from_ast(code_ast, 'no.such.path') + self.assertEqual(result.body, []) + + def test_detect_decode_utf8(self): + """detect_decode() must correctly decode plain UTF-8 bytes.""" + from pyflowchart.__main__ import detect_decode + src = 'print("héllo")' + result = detect_decode(src.encode('utf-8')) + self.assertEqual(result, src) + + def test_detect_decode_low_confidence_falls_back_to_utf8(self): + """detect_decode() must not crash and must fall back to UTF-8 when + chardet returns a low or zero confidence score (including when the + detected encoding is None — the historical TypeError bug). + """ + from pyflowchart.__main__ import detect_decode + # Empty bytes: chardet returns encoding=None, confidence=0.0 — + # previously caused TypeError: '<' not supported between NoneType and float + result = detect_decode(b'') + self.assertEqual(result, '') + # Bytes that chardet is uncertain about should also not crash + result2 = detect_decode(b'\xff\xfe') + self.assertIsInstance(result2, str) + + def test_public_api_all_complete(self): + """__all__ in pyflowchart/__init__.py must expose every public symbol + and must not leak internal helpers (time, uuid, itertools, List, …). + """ + import pyflowchart + + # All names declared in __all__ must actually be importable + for name in pyflowchart.__all__: + self.assertTrue( + hasattr(pyflowchart, name), + msg=f"'{name}' is in __all__ but not importable from pyflowchart", + ) + + # Key public symbols must be present + required = [ + 'Flowchart', 'Node', 'Connection', 'NodesGroup', + 'StartNode', 'EndNode', 'OperationNode', 'InputOutputNode', + 'SubroutineNode', 'ConditionNode', 'TransparentNode', 'CondYN', + 'AstNode', 'FunctionDef', 'Loop', 'If', 'CommonOperation', + 'CallSubroutine', 'BreakContinueSubroutine', 'YieldOutput', 'Return', + 'Match', 'MatchCase', 'ParseProcessGraph', 'parse', 'output_html', + ] + for name in required: + self.assertIn(name, pyflowchart.__all__, msg=f"'{name}' missing from __all__") + + # Internal names must not be re-exported + internal = ['time', 'uuid', 'itertools', 'debug', 'AsNode', + 'TypeVar', 'List', 'Optional', 'Tuple'] + for name in internal: + self.assertNotIn(name, pyflowchart.__all__, msg=f"internal '{name}' leaked into __all__") + + def test_match_simplify_kwarg_forwarded_into_case_bodies(self): + """simplify=False must propagate into match-case bodies so that nested + if-statements are *not* collapsed. This was broken because parse() was + called without **kwargs inside MatchCase.parse_body(). + """ + if sys.version_info < (3, 10): + warnings.warn("match kwargs test requires python >= 3.10") + return + + code = ''' +def fn(a, b): + match a: + case 1: + if b > 0: + print(b) +''' + # With simplify=True the inner "if b > 0: print(b)" is collapsed into + # a single operation node; with simplify=False it stays as a condition + # node followed by a separate operation node. + fc_simplified = Flowchart.from_code(code, field='fn', simplify=True).flowchart() + fc_full = Flowchart.from_code(code, field='fn', simplify=False).flowchart() + + cond_simplified = len(re.findall(r'=>condition:', fc_simplified)) + cond_full = len(re.findall(r'=>condition:', fc_full)) + + self.assertGreater( + cond_full, cond_simplified, + msg="simplify=False should produce more condition nodes than simplify=True inside match-case bodies", + ) + if __name__ == '__main__': # print(flowchart_translate_test()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bb67b61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyflowchart" +version = "0.4.0-alpha.4" +description = "Python codes to Flowcharts." +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "CDFMLR", email = "cdfmlr@outlook.com" }] +requires-python = ">=3.6" +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Code Generators", + "Topic :: Utilities", +] +dependencies = [ + "astunparse; python_version < '3.9'", + "chardet", +] + +[project.optional-dependencies] +dev = ["pytest"] + +[project.urls] +Homepage = "https://github.com/cdfmlr/pyflowchart" +"Bug Tracker" = "https://github.com/cdfmlr/pyflowchart/issues" + +[project.scripts] +pyflowchart = "pyflowchart.__main__:cli" + +[tool.setuptools.packages.find] +where = ["."] + +[tool.pytest.ini_options] +testpaths = ["pyflowchart"] +python_files = ["test.py"] From 4dc16593c9fd18ce3b4b9a5d6fc459d7ab3099e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:21:20 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Address=20review:=20CI=20covers=20Python=20?= =?UTF-8?q?3.7=E2=80=933.14,=20requires-python=20>=3D3.7,=20README=20updat?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- .github/workflows/tests.yml | 30 +++++++++++++++++++++--------- README.md | 2 +- pyproject.toml | 3 +-- setup.py | 3 +-- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 53493ae..d322c6c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,8 @@ # Run the test suite across the full supported Python version range on every # push and pull-request so regressions are caught early. +# +# Python 3.7 requires ubuntu-22.04; the hosted tool cache for ubuntu-24.04 +# (ubuntu-latest) does not include Python 3.7. name: Tests on: @@ -9,21 +12,30 @@ on: jobs: test: name: "Python ${{ matrix.python-version }}" - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} permissions: contents: read strategy: fail-fast: false matrix: - python-version: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "3.12" - - "3.13" - - "3.14" + include: + - python-version: "3.7" + os: ubuntu-22.04 + - python-version: "3.8" + os: ubuntu-latest + - python-version: "3.9" + os: ubuntu-latest + - python-version: "3.10" + os: ubuntu-latest + - python-version: "3.11" + os: ubuntu-latest + - python-version: "3.12" + os: ubuntu-latest + - python-version: "3.13" + os: ubuntu-latest + - python-version: "3.14" + os: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 7e14f22..cb5405f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Want to **flowchart your Python code in `example.py`?** Run this: $ python -m pyflowchart example.py ``` -> ⚠️ PyFlowchart works with **Python 3.6+**. To check your Python version, run [`python --version`](https://docs.python.org/3/using/cmdline.html#cmdoption-version). +> ⚠️ PyFlowchart requires **Python 3.7+** and is CI-tested on Python **3.7 through 3.14**. To check your Python version, run [`python --version`](https://docs.python.org/3/using/cmdline.html#cmdoption-version). > > If you have both Python 2 and Python 3 installed, you may need to use `python3` instead of `python`. This is becoming less common as [Python 2 is sunsetting](https://www.python.org/doc/sunset-python-2/). diff --git a/pyproject.toml b/pyproject.toml index bb67b61..04740fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,13 +9,12 @@ description = "Python codes to Flowcharts." readme = "README.md" license = { text = "MIT" } authors = [{ name = "CDFMLR", email = "cdfmlr@outlook.com" }] -requires-python = ">=3.6" +requires-python = ">=3.7" classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", diff --git a/setup.py b/setup.py index 671be63..a908d48 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,6 @@ "Environment :: Console", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -31,7 +30,7 @@ "Topic :: Software Development :: Code Generators", "Topic :: Utilities", ], - python_requires='>=3.6', + python_requires='>=3.7', install_requires=['astunparse; python_version < "3.9"', 'chardet'], entry_points={ 'console_scripts': [ From 76e46c60a67de7cb28ed107db4f126a703ae35c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:31:51 +0000 Subject: [PATCH 7/9] Review fixes: restore informative Match comment, shorten test name Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyflowchart/ast_node.py | 5 +++-- pyflowchart/test.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyflowchart/ast_node.py b/pyflowchart/ast_node.py index e9216c1..5760617 100644 --- a/pyflowchart/ast_node.py +++ b/pyflowchart/ast_node.py @@ -800,8 +800,9 @@ def __init__(self, ast_match: _ast_Match_t, **kwargs): # A Cond for match_case should be represented as "if {subject} match case {pattern}" self.subject = ast_match.subject - # NodesGroup.__init__() is the correct way to initialise the head here. - # The transparent_head acts as a placeholder until cases are parsed. + # self.head = TransparentNode(self) + # Note: direct head assignment hits multi-inheritance MRO issues; + # NodesGroup.__init__() is the correct way to set self.head properly. # Each case is a condition node. # Since we have not parsed any case body, (nor I want to peek one), diff --git a/pyflowchart/test.py b/pyflowchart/test.py index 2218434..a4c064c 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -796,7 +796,7 @@ def test_public_api_all_complete(self): for name in internal: self.assertNotIn(name, pyflowchart.__all__, msg=f"internal '{name}' leaked into __all__") - def test_match_simplify_kwarg_forwarded_into_case_bodies(self): + def test_match_kwargs_forwarded(self): """simplify=False must propagate into match-case bodies so that nested if-statements are *not* collapsed. This was broken because parse() was called without **kwargs inside MatchCase.parse_body(). From a98d6c8b86d3d77a34479962b7afc82d5c928682 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:39:13 +0000 Subject: [PATCH 8/9] shorten long test names per review feedback Co-authored-by: cdfmlr <45259230+cdfmlr@users.noreply.github.com> --- pyflowchart/test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyflowchart/test.py b/pyflowchart/test.py index a4c064c..18bd830 100644 --- a/pyflowchart/test.py +++ b/pyflowchart/test.py @@ -721,7 +721,7 @@ def test_yield_from(self): # Tests for bug fixes # # ------------------------------------------------------------------ # - def test_from_code_invalid_field_raises_value_error(self): + def test_invalid_field_raises(self): """from_code() must raise ValueError (not AssertionError) for a field that does not exist in the given code. Previously this was an assert that was silently skipped under ``python -O``. @@ -730,12 +730,12 @@ def test_from_code_invalid_field_raises_value_error(self): with self.assertRaises(ValueError): Flowchart.from_code(code, field='nonexistent') - def test_from_code_empty_raises_value_error(self): + def test_empty_code_raises(self): """from_code() must raise ValueError when the parsed body is empty.""" with self.assertRaises(ValueError): Flowchart.from_code('', field='', inner=True) - def test_find_field_invalid_path_returns_empty_body(self): + def test_find_field_invalid(self): """find_field_from_ast() must return an AST node whose body is [] for a field path that does not exist. This exercises the control-flow branch that previously relied on a bare ``assert`` (broken under -O). @@ -751,7 +751,7 @@ def test_detect_decode_utf8(self): result = detect_decode(src.encode('utf-8')) self.assertEqual(result, src) - def test_detect_decode_low_confidence_falls_back_to_utf8(self): + def test_detect_decode_low_confidence(self): """detect_decode() must not crash and must fall back to UTF-8 when chardet returns a low or zero confidence score (including when the detected encoding is None — the historical TypeError bug). From aa46854ca704aa13e9b1e8877d6bb03966e11d8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:55:54 +0000 Subject: [PATCH 9/9] bump version to 0.5.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 04740fb..cc4d676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyflowchart" -version = "0.4.0-alpha.4" +version = "0.5.0b1" description = "Python codes to Flowcharts." readme = "README.md" license = { text = "MIT" } diff --git a/setup.py b/setup.py index a908d48..dff7052 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='pyflowchart', - version='0.4.0-alpha.4', + version='0.5.0b1', url='https://github.com/cdfmlr/pyflowchart', license='MIT', author='CDFMLR',