Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f314362
#30 feat: Add GitHub Actions workflow for running tests and ignore py…
Asifdotexe Feb 8, 2026
902f231
#30 refactor(ui) updated the UI
Asifdotexe Feb 8, 2026
10b8a2a
#30 feat: Add AST-based Python flowchart generation with comprehensiv…
Asifdotexe Feb 8, 2026
64d2f36
#30 feat: Extract themes and examples into a new resources module and…
Asifdotexe Feb 9, 2026
98dd796
#30 refactor(app): remove generate chart button
Asifdotexe Feb 9, 2026
2d8d1c9
#30 refactor: fix grammer
Asifdotexe Feb 9, 2026
07623d8
#30 test: Implement flowchart generation for `break`, `continue`, and…
Asifdotexe Feb 9, 2026
7a854dc
#30 feat: add node type for break, continue and pass blocks
Asifdotexe Feb 9, 2026
07c8c0a
#30 refactor: add OS error handling
Asifdotexe Feb 10, 2026
7ceef37
test: add test to verify break and continue node coloring in generate…
Asifdotexe Feb 10, 2026
9ca826c
#30 refactor(test): replace print statements with pytest assertions
Asifdotexe Feb 10, 2026
c3fddb4
#30 chore: update pre-commit config file
Asifdotexe Feb 10, 2026
60a660f
#30 style: enforce PEP-8
Asifdotexe Feb 10, 2026
8fa3973
#30 docs: add module docstrings
Asifdotexe Feb 10, 2026
b85384f
#30 ci: added a ci workflow for pylint
Asifdotexe Feb 10, 2026
85b8363
#30 ci: consolidated the pre-commit hook
Asifdotexe Feb 10, 2026
d7be57a
#30 remove unused import
Asifdotexe Feb 10, 2026
ba9ec66
#30 refactor: comply with constant naming conversation
Asifdotexe Feb 10, 2026
5e7229e
#30 chore: move pylintrc config to pyproject.toml
Asifdotexe Feb 10, 2026
e9f25bf
#30 refactor: make the function names snake_case
Asifdotexe Feb 10, 2026
9ab2596
#30 ci: sanitize body content
Asifdotexe Feb 10, 2026
ed87671
#30 fix: issues reported by pylint
Asifdotexe Feb 10, 2026
27870ba
#30 refactor: import textwrap at the top of the module
Asifdotexe Feb 10, 2026
26aa5be
#30 refactor: add project root for smoke testing
Asifdotexe Feb 10, 2026
078e2b7
#30 refactor: Extract core flowchart generation logic to a new module…
Asifdotexe Feb 11, 2026
6c6dd10
#30 refactor: Synchronize code input text area with session state usi…
Asifdotexe Feb 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@
"forwardPorts": [
8501
]
}
}
71 changes: 71 additions & 0 deletions .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Pylint

on: [pull_request]

jobs:
pylint:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install

- name: Run Pylint
id: pylint
continue-on-error: true
run: |
# Run pylint on source, app.py, and tests
# Redirect output to file, also capture stderr
poetry run pylint src/serpent app.py tests > pylint_report.txt 2>&1

- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
try {
const report = fs.readFileSync('pylint_report.txt', 'utf8');
const MAX_LENGTH = 60000; // GitHub comment limit is ~65536
let bodyContent = report;

if (report.length > MAX_LENGTH) {
bodyContent = report.substring(0, MAX_LENGTH) + "\n\n... (Output truncated due to length)";
}

if (!bodyContent.trim()) {
bodyContent = "No output captured from Pylint.";
}

// Sanitize bodyContent to avoid breaking the template literal
bodyContent = bodyContent.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');

const outcome = '${{ steps.pylint.outcome }}';
const icon = outcome === 'success' ? '✅' : '⚠️';
const summary = outcome === 'success' ? 'Pylint passed' : 'Pylint found issues';

await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `### ${icon} ${summary}\n\n<details>\n<summary>View Pylint Report</summary>\n\n\`\`\`\n${bodyContent}\n\`\`\`\n</details>`
});
Comment on lines +58 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Template-literal injection risk — pylint output may contain backticks or ${.

bodyContent is interpolated directly into a JS template literal on line 59. Pylint output frequently contains code snippets with backticks (`) or expressions like ${...}, which would break the template literal or cause unintended evaluation.

Use string concatenation or escape backticks before interpolation.

🔧 Proposed fix
+              // Escape backticks and dollar-braces so the template literal is safe
+              bodyContent = bodyContent.replace(/`/g, '\\`').replace(/\$/g, '\\$');
+
               await github.rest.issues.createComment({
                 issue_number: context.issue.number,
                 owner: context.repo.owner,
                 repo: context.repo.repo,
                 body: `### ${icon} ${summary}\n\n<details>\n<summary>View Pylint Report</summary>\n\n\`\`\`\n${bodyContent}\n\`\`\`\n</details>`
               });
🤖 Prompt for AI Agents
In @.github/workflows/pylint.yml around lines 55 - 60, The template literal
passed into github.rest.issues.createComment interpolates bodyContent directly
(see bodyContent and github.rest.issues.createComment) which allows backticks or
${...} from pylint to break the literal; fix by either constructing the comment
body with regular string concatenation (e.g., join pieces with '+' so
bodyContent is appended as a normal string) or sanitize bodyContent before
interpolation by escaping backticks and `${` sequences (e.g.,
replace(/`/g,'\\`') and replace(/\$\{/g,'\\${')) and then use the sanitized
variable in the createComment call.


// If we want to fail the workflow if pylint failed:
if (outcome === 'failure') {
core.setFailed('Pylint found issues.');
}
} catch (error) {
core.setFailed(error.message);
}
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: "3.10"

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
.idea/
SERPENT.egg-info/
build/
.pytest_cache/
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ repos:
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-toml
- id: check-json
- id: pretty-format-json
args: [--autofix]

- repo: https://github.com/psf/black
rev: 25.9.0
Expand Down
5 changes: 0 additions & 5 deletions .pylintrc

This file was deleted.

263 changes: 263 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""
Launch the Streamlit web application for converting Python functions into flowcharts.
"""

import ast
import logging
import re
from typing import Any
import shutil
from pathlib import Path

import streamlit as st
from PIL import Image
from streamlit_extras.badges import badge
from streamlit_extras.stylable_container import stylable_container

from serpent.core import generate_graphviz_flowchart
from serpent.resources import EXAMPLES, THEMES

assets_dir = Path(__file__).parent / "assets"
try:
LOGO = Image.open(assets_dir / "serpent_logo_transparent.png")
ICON = (
Image.open(assets_dir / "serpent_logo_compact.png")
if (assets_dir / "serpent_logo_compact.png").exists()
else LOGO
)
except (FileNotFoundError, OSError):
LOGO = None
ICON = None

# Page Configuration
st.set_page_config(
page_title="SERPENT",
page_icon=ICON or "🐍",
layout="wide",
initial_sidebar_state="expanded",
)

# Custom CSS for cleaner look
st.markdown(
"""
<style>
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
.stButton>button {width: 100%;}
/* Hide top padding */
.block-container {padding-top: 2rem;}
</style>
""",
unsafe_allow_html=True,
)


def _render_sidebar() -> tuple[str, str, str]:
"""Render the sidebar and return settings."""
with st.sidebar:
if LOGO:
st.image(LOGO, width="stretch")
else:
st.title("SERPENT 🐍")
Comment thread
Asifdotexe marked this conversation as resolved.

st.write("Turn your Python functions into clear flowcharts.")
st.divider()

def update_example():
"""Callback to update code input when example changes."""
ex = st.session_state.example_selector
if ex != "(Custom)":
st.session_state.code_area_widget = EXAMPLES[ex]
st.session_state.code_input = EXAMPLES[ex]

st.subheader("⚙️ Settings")

selected_example = st.selectbox(
"Load Example",
["(Custom)"] + list(EXAMPLES.keys()),
index=0,
help="Select an example to see how it works.",
key="example_selector",
on_change=update_example,
)

st.caption("Appearance")
rankdir = st.selectbox(
"Orientation",
options=["TB", "LR"],
format_func=lambda x: "Top-Down" if x == "TB" else "Left-Right",
)

theme_name = st.selectbox("Theme", options=list(THEMES.keys()))

st.divider()

with st.expander("About & Help"):
st.markdown(
"""
**How to use:**
1. Paste your Python function.
2. The flowchart updates automatically.
3. Download the result.

**Tips:**
- Works best with single functions.
- Supports `if/else`, `loops`, `break/continue`.
"""
)

st.caption("Created by Asif Sayyed")
badge(
type="github",
name="Asifdotexe/SERPENT",
url="https://github.com/Asifdotexe/SERPENT",
)
return selected_example, rankdir, theme_name


def _render_input_area(selected_example: str) -> tuple[str, str]:
"""Render the code input area."""
st.subheader("📝 Input Code")

if selected_example != "(Custom)":
default_code = EXAMPLES[selected_example]
else:
# Keep previous input if possible, else empty
default_code = "def my_func():\n pass"

# Initial state
if "code_input" not in st.session_state:
st.session_state.code_input = default_code

def _sync_code_input():
"""Sync widget state to session state code_input."""
st.session_state.code_input = st.session_state.code_area_widget

# We handle updates via callback now, so we remove the imperative check
code = st.text_area(
"Python Code",
value=st.session_state.code_input,
height=400,
label_visibility="collapsed",
key="code_area_widget",
on_change=_sync_code_input,
)

chart_title = st.text_input("Chart Title", placeholder="Enter a title (optional)")
return code, chart_title


def _render_output_area(
code: str, chart_title: str, rankdir: str, theme_name: str
) -> None:
"""Render the flowchart output area."""
valid_graph = None
st.subheader("🖼️ Flowchart")

with stylable_container(
key="output_container",
css_styles="""
{
border: 1px solid rgba(49, 51, 63, 0.2);
border-radius: 0.5rem;
padding: 1rem;
min-height: 480px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.05);
}
""",
):
if not code.strip():
st.info("Waiting for code input...")
else:
try:
# Validate
ast.parse(code)

# Generate
final_title = chart_title or "Flowchart"
selected_theme = THEMES[theme_name]

graph = generate_graphviz_flowchart(
code,
title=final_title,
rankdir=rankdir,
style_config=selected_theme,
)

st.graphviz_chart(graph, width="stretch")

# Store for download buttons below
valid_graph = graph

except SyntaxError as e:
st.error(f"Syntax Error: {e}")
valid_graph = None
except Exception as e:
st.error(f"An error occurred: {e}")
logging.exception("Graph generation failed")
valid_graph = None
return valid_graph


def _render_download_buttons(valid_graph: Any, chart_title: str) -> None:
"""Render download buttons if graph is valid."""
if not valid_graph:
return

st.divider()
d_col1, d_col2 = st.columns([1, 1])

# Sanitize filename
safe_title = re.sub(
r"[^a-z0-9_\-]", "", (chart_title or "flowchart").lower().replace(" ", "_")
)

with d_col1:
if shutil.which("dot"):
try:
png_bytes = valid_graph.pipe(format="png")
st.download_button(
"📥 Download PNG",
data=png_bytes,
file_name=f"{safe_title}.png",
mime="image/png",
width="stretch",
)
except Exception:
st.warning("Could not generate PNG (Check Graphviz installation).")

with d_col2:
st.download_button(
"📄 Download DOT",
data=valid_graph.source,
file_name=f"{safe_title}.dot",
mime="text/vnd.graphviz",
width="stretch",
)


def main() -> None:
"""Main application entry point."""
selected_example, rankdir, theme_name = _render_sidebar()

col_header, _ = st.columns([3, 1])
with col_header:
st.title("Flowchart Generator")

input_col, output_col = st.columns(2)

with input_col:
code, chart_title = _render_input_area(selected_example)

with output_col:
valid_graph = _render_output_area(code, chart_title, rankdir, theme_name)

# Download Area (Full width below columns)
_render_download_buttons(valid_graph, chart_title)


if __name__ == "__main__":
main()
Loading