diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3a0fdc3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# ============================================================================= +# Dependabot — Automated dependency updates +# ============================================================================= + +version: 2 + +updates: + # Python (pip) dependencies from pyproject.toml + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + commit-message: + prefix: "deps" + labels: + - "dependencies" + - "python" + open-pull-requests-limit: 10 + + # GitHub Actions versions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + commit-message: + prefix: "ci" + labels: + - "dependencies" + - "github-actions" + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3795ca4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,129 @@ +# ============================================================================= +# ExplainFlow — Continuous Integration +# ============================================================================= +# Runs on every push and pull request to main. +# Matrix: Python 3.9 · 3.10 · 3.11 · 3.12 × ubuntu-latest +# Jobs: lint → test (with coverage) → build verification +# ============================================================================= + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # Lint & Type-check (fast-fail gate) + # --------------------------------------------------------------------------- + lint: + name: "Lint & Type-check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dev dependencies + run: pip install -e ".[dev]" + + - name: Ruff (lint + format check) + run: | + ruff check src/ tests/ + ruff format --check src/ tests/ + + - name: Black (format check) + run: black --check src/ tests/ + + - name: Mypy (type-check) + run: mypy src/explainflow/ + + # --------------------------------------------------------------------------- + # Test matrix + # --------------------------------------------------------------------------- + test: + name: "Test · Python ${{ matrix.python-version }}" + needs: lint + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: pip install -e ".[all]" + + - name: Run tests with coverage + run: | + pytest tests/ \ + -v \ + --tb=short \ + --cov=explainflow \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + --cov-fail-under=20 + + - name: Upload coverage artifact + if: matrix.python-version == '3.12' + uses: actions/upload-artifact@v5 + with: + name: coverage-report + path: coverage.xml + retention-days: 7 + + # --------------------------------------------------------------------------- + # Build verification (ensures the package builds cleanly) + # --------------------------------------------------------------------------- + build-check: + name: "Build verification" + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + + - name: Install build tools + run: pip install build twine + + - name: Build distributions + run: python -m build + + - name: Check distributions + run: twine check dist/* + + - name: Verify package metadata + run: | + pip install dist/*.whl + python -c "import explainflow; print(f'✓ explainflow {explainflow.__version__} installed successfully')" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..f6a2fd4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +# ============================================================================= +# ExplainFlow — CodeQL Security Analysis +# ============================================================================= +# Runs GitHub's CodeQL semantic analysis engine to find security +# vulnerabilities in your Python code. Free for public repositories. +# +# Schedule: weekly + every push/PR to main. +# ============================================================================= + +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Run every Monday at 06:00 UTC + - cron: "0 6 * * 1" + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (Python) + runs-on: ubuntu-latest + permissions: + security-events: write # Required for CodeQL to upload results + contents: read + actions: read + strategy: + fail-fast: false + matrix: + language: ["python"] + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + # Python is an interpreted language — no build step required. + # CodeQL will auto-detect the source layout. + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d8d7541 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,168 @@ +# ============================================================================= +# ExplainFlow — Release & Publish to PyPI +# ============================================================================= +# Triggered by pushing a version tag (v*.*.*). +# Uses PyPI Trusted Publishing (OIDC) — no API tokens needed. +# +# Prerequisites (one-time setup): +# 1. Go to https://pypi.org/manage/account/publishing/ +# 2. Add a "pending publisher": +# • PyPI project name: explainflow +# • Owner: DevaVirathan +# • Repository: explainflow +# • Workflow name: release.yml +# • Environment: pypi +# 3. In your GitHub repo → Settings → Environments: +# • Create "pypi" environment with required reviewers (recommended) +# • Create "testpypi" environment (no reviewers needed) +# +# Repeat step 2 on https://test.pypi.org with environment "testpypi". +# ============================================================================= + +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # Run full test suite before publishing + # --------------------------------------------------------------------------- + test: + name: "Pre-release tests" + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: pip install -e ".[all]" + + - name: Run tests + run: pytest tests/ -v --tb=short + + # --------------------------------------------------------------------------- + # Build distribution packages + # --------------------------------------------------------------------------- + build: + name: "Build distribution 📦" + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install build tools + run: python -m pip install build --user + + - name: Build sdist and wheel + run: python -m build + + - name: Verify distributions + run: | + pip install twine + twine check dist/* + + - name: Store distribution packages + uses: actions/upload-artifact@v5 + with: + name: python-package-distributions + path: dist/ + + # --------------------------------------------------------------------------- + # Publish to TestPyPI (every tag push) + # --------------------------------------------------------------------------- + publish-to-testpypi: + name: "Publish to TestPyPI 🧪" + needs: build + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/explainflow + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download distributions + uses: actions/download-artifact@v6 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + # --------------------------------------------------------------------------- + # Publish to PyPI (only tag pushes, after TestPyPI succeeds) + # --------------------------------------------------------------------------- + publish-to-pypi: + name: "Publish to PyPI 🚀" + needs: publish-to-testpypi + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/explainflow + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download distributions + uses: actions/download-artifact@v6 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # --------------------------------------------------------------------------- + # Create GitHub Release with auto-generated notes + # --------------------------------------------------------------------------- + github-release: + name: "GitHub Release 📝" + needs: publish-to-pypi + runs-on: ubuntu-latest + permissions: + contents: write # Required to create releases + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Download distributions + uses: actions/download-artifact@v6 + with: + name: python-package-distributions + path: dist/ + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + "${{ github.ref_name }}" + dist/* + --repo "${{ github.repository }}" + --generate-notes + --title "ExplainFlow ${{ github.ref_name }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f277553..8a84078 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for your interest in contributing to ExplainFlow! 🎉 1. Clone the repository: ```bash -git clone https://github.com/yourusername/explainflow.git +git clone https://github.com/DevaVirathan/explainflow.git cd explainflow ``` diff --git a/ROADMAP.md b/ROADMAP.md index a589d9c..355cb76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,57 +107,57 @@ temp_filename = os.path.join(tempfile.gettempdir(), f"explainflow_frame_{i}.png" ## 📋 Implementation Roadmap ### Phase 1: Bug Fixes & Quick Wins (v0.2.0) -- [ ] Fix @trace decorator double execution -- [ ] Fix Windows temp file path -- [ ] Fix HTML escaping -- [ ] Implement video export (MP4) -- [ ] Add Windows font fallbacks +- [x] Fix @trace decorator double execution +- [x] Fix Windows temp file path +- [x] Fix HTML escaping +- [x] Implement video export (MP4) +- [x] Add Windows font fallbacks -**Target Release:** v0.2.0 +**Target Release:** v0.2.0 ✅ COMPLETE --- ### Phase 2: Core Visualization (v0.3.0) -- [ ] Memory/Heap diagram visualization -- [ ] Object reference arrows -- [ ] Data structure diagrams (lists, dicts) -- [ ] Object ID tracking -- [ ] Improved variable display for complex types +- [x] Memory/Heap diagram visualization +- [x] Object reference arrows +- [x] Data structure diagrams (lists, dicts) +- [x] Object ID tracking +- [x] Improved variable display for complex types -**Target Release:** v0.3.0 +**Target Release:** v0.3.0 ✅ COMPLETE --- ### Phase 3: Enhanced Interactivity (v0.4.0) -- [ ] Call stack visualization -- [ ] Step backward support -- [ ] Breakpoints -- [ ] Improved exception flow visualization -- [ ] Loop iteration counter +- [x] Call stack visualization +- [x] Step backward support +- [x] Breakpoints +- [x] Improved exception flow visualization +- [x] Loop iteration counter -**Target Release:** v0.4.0 +**Target Release:** v0.4.0 ✅ COMPLETE --- ### Phase 4: Integrations (v0.5.0) -- [ ] Jupyter Notebook integration -- [ ] Live web interface (WebSocket-based) -- [ ] Async/generator support -- [ ] Context manager tracing +- [x] Jupyter Notebook integration +- [x] Live web interface (WebSocket-based) +- [x] Async/generator support +- [x] Context manager tracing -**Target Release:** v0.5.0 +**Target Release:** v0.5.0 ✅ COMPLETE --- ### Phase 5: Polish (v1.0.0) -- [ ] VSCode extension -- [ ] Multi-file tracing -- [ ] Custom themes -- [ ] Special type support (NumPy, Pandas) -- [ ] Comprehensive documentation site -- [ ] Performance optimizations - -**Target Release:** v1.0.0 +- [x] VSCode extension +- [x] Multi-file tracing +- [x] Custom themes +- [x] Special type support (NumPy, Pandas) +- [x] Comprehensive documentation site +- [x] Performance optimizations + +**Target Release:** v1.0.0 ✅ COMPLETE --- diff --git a/merge_sort_trace.html b/merge_sort_trace.html new file mode 100644 index 0000000..a7c0b91 --- /dev/null +++ b/merge_sort_trace.html @@ -0,0 +1,324 @@ + + + + + + ExplainFlow - Code Execution Trace + + + +
+
+

🔍 ExplainFlow

+

Code Execution Trace

+
+ +
+
+

Source Code

+
1 
2def merge(arr, l, m, r):
3 n1 = m - l + 1
4 n2 = r - m
5 L = [0] * n1
6 R = [0] * n2
7 for i in range(n1):
8 L[i] = arr[l + i]
9 for j in range(n2):
10 R[j] = arr[m + 1 + j]
11 i = 0
12 j = 0
13 k = l
14 while i < n1 and j < n2:
15 if L[i] <= R[j]:
16 arr[k] = L[i]
17 i += 1
18 else:
19 arr[k] = R[j]
20 j += 1
21 k += 1
22 while i < n1:
23 arr[k] = L[i]
24 i += 1
25 k += 1
26 while j < n2:
27 arr[k] = R[j]
28 j += 1
29 k += 1
30 
31def mergeSort(arr, l, r):
32 if l < r:
33 m = l + (r - l) // 2
34 mergeSort(arr, l, m)
35 mergeSort(arr, m + 1, r)
36 merge(arr, l, m, r)
37 
38arr = [12, 11, 13, 5, 6, 7]
39print("Given array is:", arr)
40mergeSort(arr, 0, len(arr) - 1)
41print("Sorted array is:", arr)
42 
+
+ +
+
+
+ Step 1 + LINE + +
+
+
+ +
+ +
+

Variables

+
+
+ + + + +
+
+ +
+ + + 1 / 236 + + + +
+
+ + + + diff --git a/pyproject.toml b/pyproject.toml index 4d74aba..6f9c7ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "explainflow" -version = "0.1.0" +version = "1.0.0" description = "Code Execution Visualizer & Explainer - Generate step-by-step visual explanations of Python code" readme = "README.md" license = {text = "MIT"} @@ -22,7 +22,7 @@ keywords = [ "step-by-step" ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Education", "License :: OSI Approved :: MIT License", @@ -57,8 +57,11 @@ video = [ cli = [ "typer>=0.9.0" ] +highlight = [ + "pygments>=2.16.0" +] all = [ - "explainflow[dev,video,cli]" + "explainflow[dev,video,cli,highlight]" ] [project.urls] @@ -79,8 +82,24 @@ target-version = ["py39", "py310", "py311", "py312"] [tool.ruff] line-length = 88 + +[tool.ruff.lint] select = ["E", "F", "W", "I", "N", "D", "UP"] -ignore = ["D100", "D104"] +ignore = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package + "D203", # 1 blank line before class (conflicts with D211) + "D213", # Multi-line summary second line (conflicts with D212) + "D407", # Missing dashed underline after section + "D413", # Missing blank line after last section + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D401", # First line should be in imperative mood +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D101", "D102", "D103", "E501"] +"src/explainflow/exporter.py" = ["E501"] [tool.mypy] python_version = "3.9" diff --git a/src/explainflow/__init__.py b/src/explainflow/__init__.py index a0f128b..f7ff62f 100644 --- a/src/explainflow/__init__.py +++ b/src/explainflow/__init__.py @@ -1,29 +1,52 @@ -""" -ExplainFlow - Code Execution Visualizer & Explainer +"""ExplainFlow - Code Execution Visualizer & Explainer. Generate step-by-step visual explanations of Python code execution. """ -__version__ = "0.1.0" +__version__ = "1.0.0" __author__ = "DevaVirathan" -from explainflow.models import ExecutionTrace, ExecutionStep, StepType, Variable from explainflow.core import explain, explain_function +from explainflow.exporter import ( + export_gif, + export_html, + export_image, + export_markdown, + export_video, +) +from explainflow.models import ( + ExecutionStep, + ExecutionTrace, + HeapObject, + StackFrame, + StepType, + Variable, +) from explainflow.tracer import Tracer, trace -from explainflow.visualizer import Visualizer -from explainflow.exporter import export_image, export_gif, export_html +from explainflow.visualizer import Visualizer, get_theme, register_theme __all__ = [ + # Core API "explain", "explain_function", + # Tracer "trace", "Tracer", + # Visualizer "Visualizer", + "register_theme", + "get_theme", + # Models "ExecutionTrace", "ExecutionStep", "StepType", "Variable", + "HeapObject", + "StackFrame", + # Exporters "export_image", "export_gif", "export_html", + "export_video", + "export_markdown", ] diff --git a/src/explainflow/cli.py b/src/explainflow/cli.py index 8bd22db..fe9fa59 100644 --- a/src/explainflow/cli.py +++ b/src/explainflow/cli.py @@ -1,5 +1,4 @@ -""" -CLI module for ExplainFlow. +"""CLI module for ExplainFlow. Provides command-line interface using Typer. """ @@ -8,7 +7,9 @@ import sys from pathlib import Path -from typing import Optional +from typing import Literal + +OutputMode = Literal["rich", "simple", "silent"] def main(): @@ -18,82 +19,85 @@ def main(): except ImportError: print("CLI requires typer. Install with: pip install explainflow[cli]") sys.exit(1) - + app = typer.Typer( name="explainflow", help="ExplainFlow - Code Execution Visualizer & Explainer", - add_completion=False + add_completion=False, ) - + @app.command() def run( file: Path = typer.Argument( ..., help="Python file to explain", exists=True, - readable=True + readable=True, ), - output: Optional[Path] = typer.Option( - None, - "--output", "-o", - help="Output file (png, gif, or html)" + output: Path | None = typer.Option( + None, "--output", "-o", help="Output file (png, gif, mp4, html, md)" ), theme: str = typer.Option( - "dark", - "--theme", "-t", - help="Color theme (dark, light, colorblind)" + "dark", "--theme", "-t", help="Color theme (dark, light, colorblind)" ), max_steps: int = typer.Option( - 1000, - "--max-steps", "-m", - help="Maximum execution steps" + 1000, "--max-steps", "-m", help="Maximum execution steps" ), fps: float = typer.Option( - 1.0, - "--fps", - help="Frames per second for GIF output" + 1.0, "--fps", help="Frames per second for GIF/video output" ), quiet: bool = typer.Option( - False, - "--quiet", "-q", - help="Suppress terminal output" + False, "--quiet", "-q", help="Suppress terminal output" ), simple: bool = typer.Option( - False, - "--simple", "-s", - help="Use simple output (no colors)" - ) + False, "--simple", "-s", help="Use simple output (no colors)" + ), + heap: bool = typer.Option(False, "--heap", help="Track heap objects"), + profile: bool = typer.Option(False, "--profile", help="Record per-step timing"), ): - """ - Run and explain a Python file step-by-step. - - Examples: + """Run and explain a Python file step-by-step. + + Examples explainflow run script.py explainflow run script.py -o output.png explainflow run script.py -o animation.gif --fps 2 explainflow run script.py -o trace.html + explainflow run script.py -o trace.mp4 --fps 1 + explainflow run script.py -o trace.md + explainflow run script.py --heap --profile """ - from explainflow import explain, export_image, export_gif, export_html - - # Read the file + from explainflow import ( + explain, + export_gif, + export_html, + export_image, + export_markdown, + export_video, + ) + code = file.read_text() - - # Determine output mode + + output_mode: OutputMode if quiet: output_mode = "silent" elif simple: output_mode = "simple" else: output_mode = "rich" - - # Execute and trace + typer.echo(f"🔍 Tracing execution of {file.name}...") - trace = explain(code, output=output_mode, max_steps=max_steps, theme=theme) - - # Export if requested + trace = explain( + code, + output=output_mode, + max_steps=max_steps, + theme=theme, + track_heap=heap, + profile=profile, + ) + if output: suffix = output.suffix.lower() - + if suffix == ".png": export_image(trace, str(output), theme=theme) typer.echo(f"✅ Exported to {output}") @@ -103,92 +107,75 @@ def run( elif suffix in (".html", ".htm"): export_html(trace, str(output), theme=theme) typer.echo(f"✅ Exported HTML to {output}") + elif suffix == ".mp4": + export_video(trace, str(output), fps=fps, theme=theme) + typer.echo(f"✅ Exported video to {output}") + elif suffix == ".md": + export_markdown(trace, str(output)) + typer.echo(f"✅ Exported Markdown to {output}") else: typer.echo(f"❌ Unknown output format: {suffix}", err=True) raise typer.Exit(1) - - # Show summary + if trace.success: typer.echo(f"✅ Execution completed: {len(trace.steps)} steps") else: typer.echo(f"❌ Execution failed: {trace.error_message}", err=True) raise typer.Exit(1) - + @app.command() def explain_code( - code: str = typer.Argument( - ..., - help="Python code string to explain" - ), - theme: str = typer.Option( - "dark", - "--theme", "-t", - help="Color theme" - ) + code: str = typer.Argument(..., help="Python code string to explain"), + theme: str = typer.Option("dark", "--theme", "-t", help="Color theme"), ): - """ - Explain a code snippet directly. - + """Explain a code snippet directly. + Example: explainflow explain-code "x = 5; y = 10; print(x + y)" """ from explainflow import explain - - # Replace semicolons with newlines for multi-statement code + code = code.replace("; ", "\n").replace(";", "\n") - explain(code, output="rich", theme=theme) - + @app.command() def watch( - file: Path = typer.Argument( - ..., - help="Python file to watch", - exists=True - ), - theme: str = typer.Option( - "dark", - "--theme", "-t", - help="Color theme" - ) + file: Path = typer.Argument(..., help="Python file to watch", exists=True), + theme: str = typer.Option("dark", "--theme", "-t", help="Color theme"), ): - """ - Watch a file and re-run explanation on changes. - + """Watch a file and re-run explanation on changes. + Example: explainflow watch script.py """ import time + from explainflow import explain - + typer.echo(f"👀 Watching {file.name} for changes... (Ctrl+C to stop)") - - last_mtime = 0 - + + last_mtime = 0.0 + try: while True: current_mtime = file.stat().st_mtime - if current_mtime != last_mtime: last_mtime = current_mtime - - # Clear screen typer.clear() - typer.echo(f"🔄 File changed, re-running...\n") - + typer.echo("🔄 File changed, re-running...\n") code = file.read_text() explain(code, output="rich", theme=theme) - time.sleep(0.5) except KeyboardInterrupt: typer.echo("\n👋 Stopped watching.") - + @app.command() def version(): """Show the version of ExplainFlow.""" from explainflow import __version__ + typer.echo(f"ExplainFlow version {__version__}") - + app() diff --git a/src/explainflow/core.py b/src/explainflow/core.py index 07cfa1f..99e0e39 100644 --- a/src/explainflow/core.py +++ b/src/explainflow/core.py @@ -1,5 +1,4 @@ -""" -Core module for ExplainFlow. +"""Core module for ExplainFlow. Contains the main explain() function. """ @@ -11,7 +10,6 @@ from explainflow.models import ExecutionTrace from explainflow.tracer import Tracer - OutputMode = Literal["rich", "simple", "silent"] @@ -20,21 +18,28 @@ def explain( output: OutputMode = "rich", max_steps: int = 1000, show_types: bool = True, - theme: str = "dark" + theme: str = "dark", + breakpoints: list[int] | None = None, + track_heap: bool = False, + track_call_stack: bool = True, + profile: bool = False, ) -> ExecutionTrace: - """ - Execute and explain code step-by-step. - + """Execute and explain code step-by-step. + Args: code: Python code string to explain output: Output mode - "rich" (terminal), "simple" (basic), "silent" (no output) max_steps: Maximum execution steps to trace show_types: Whether to show variable types - theme: Color theme ("dark", "light", "colorblind") - + theme: Color theme ("dark", "light", "colorblind", or custom) + breakpoints: Optional list of line numbers to pause at + track_heap: Whether to track heap objects + track_call_stack: Whether to track the call stack + profile: Whether to record execution timing per step + Returns: ExecutionTrace object containing all execution steps - + Example: >>> trace = explain(''' ... x = 5 @@ -42,31 +47,35 @@ def explain( ... result = x + y ... ''') """ - # Create tracer and execute code - tracer = Tracer(max_steps=max_steps) - trace = tracer.trace(code) - - # Visualize based on output mode + tracer = Tracer( + max_steps=max_steps, + breakpoints=set(breakpoints) if breakpoints else None, + track_heap=track_heap, + track_call_stack=track_call_stack, + profile=profile, + ) + trace_result = tracer.trace(code) + if output != "silent": from explainflow.visualizer import Visualizer + visualizer = Visualizer(theme=theme, show_types=show_types) if output == "rich": - visualizer.display_rich(trace) + visualizer.display_rich(trace_result) else: - visualizer.display_simple(trace) - - return trace + visualizer.display_simple(trace_result) + + return trace_result def explain_function(func, *args, **kwargs) -> ExecutionTrace: - """ - Explain a function execution with given arguments. - + """Explain a function execution with given arguments. + Args: func: Function to trace *args: Positional arguments to pass to function **kwargs: Keyword arguments to pass to function - + Returns: ExecutionTrace object """ diff --git a/src/explainflow/exporter.py b/src/explainflow/exporter.py index a389b50..a9f6640 100644 --- a/src/explainflow/exporter.py +++ b/src/explainflow/exporter.py @@ -1,5 +1,4 @@ -""" -Exporter module for ExplainFlow. +"""Exporter module for ExplainFlow. Handles exporting execution traces to images, GIFs, videos, and HTML. """ @@ -7,11 +6,17 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Union if TYPE_CHECKING: + from PIL.ImageFont import FreeTypeFont + from PIL.ImageFont import ImageFont as ImageFontModule + from explainflow.core import ExecutionTrace + # Type alias for fonts + FontType = Union[FreeTypeFont, ImageFontModule] + # Constants for image rendering DEFAULT_FONT_SIZE = 14 @@ -25,16 +30,15 @@ def export_image( - trace: "ExecutionTrace", + trace: ExecutionTrace, filename: str, theme: str = "dark", - step: Optional[int] = None, + step: int | None = None, width: int = 1200, - show_all_steps: bool = False + show_all_steps: bool = False, ) -> Path: - """ - Export execution trace as a PNG image. - + """Export execution trace as a PNG image. + Args: trace: ExecutionTrace to export filename: Output filename (should end with .png) @@ -42,136 +46,200 @@ def export_image( step: Specific step to export (None for final state) width: Image width in pixels show_all_steps: If True, create a long image with all steps - + Returns: Path to the created image file """ try: from PIL import Image, ImageDraw, ImageFont except ImportError: - raise ImportError("Pillow is required for image export. Install with: pip install pillow") - + raise ImportError( + "Pillow is required for image export. Install with: pip install pillow" + ) + from explainflow.visualizer import THEMES - + colors = THEMES.get(theme, THEMES["dark"]) - + # Calculate dimensions - code_lines = trace.code.split('\n') + code_lines = trace.code.split("\n") num_code_lines = len(code_lines) - + if show_all_steps: # Show all steps in one tall image num_steps = len(trace.steps) step_height = 150 # Height per step - height = HEADER_HEIGHT + (num_code_lines * CODE_LINE_HEIGHT) + (num_steps * step_height) + PADDING * 4 + height = ( + HEADER_HEIGHT + + (num_code_lines * CODE_LINE_HEIGHT) + + (num_steps * step_height) + + PADDING * 4 + ) else: # Single step or final state height = HEADER_HEIGHT + (num_code_lines * CODE_LINE_HEIGHT) + 300 + PADDING * 3 - + # Create image - img = Image.new('RGB', (width, height), colors["background"]) + img = Image.new("RGB", (width, height), colors["background"]) draw = ImageDraw.Draw(img) - + # Try to load a monospace font, fall back to default + code_font: FontType + font: FontType + header_font: FontType + try: - code_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", CODE_FONT_SIZE) - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", DEFAULT_FONT_SIZE) - header_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18) - except (OSError, IOError): + code_font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", CODE_FONT_SIZE + ) + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", DEFAULT_FONT_SIZE + ) + header_font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18 + ) + except OSError: try: # Try macOS fonts - code_font = ImageFont.truetype("/System/Library/Fonts/Menlo.ttc", CODE_FONT_SIZE) - font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", DEFAULT_FONT_SIZE) + code_font = ImageFont.truetype( + "/System/Library/Fonts/Menlo.ttc", CODE_FONT_SIZE + ) + font = ImageFont.truetype( + "/System/Library/Fonts/Helvetica.ttc", DEFAULT_FONT_SIZE + ) header_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 18) - except (OSError, IOError): - # Fall back to default - code_font = ImageFont.load_default() - font = ImageFont.load_default() - header_font = ImageFont.load_default() - + except OSError: + try: + # Try Windows fonts + code_font = ImageFont.truetype("consola.ttf", CODE_FONT_SIZE) + font = ImageFont.truetype("arial.ttf", DEFAULT_FONT_SIZE) + header_font = ImageFont.truetype("arialbd.ttf", 18) + except OSError: + try: + # Try Windows full path fonts + import os + + windir = os.environ.get("WINDIR", r"C:\Windows") + fonts_dir = os.path.join(windir, "Fonts") + code_font = ImageFont.truetype( + os.path.join(fonts_dir, "consola.ttf"), CODE_FONT_SIZE + ) + font = ImageFont.truetype( + os.path.join(fonts_dir, "arial.ttf"), DEFAULT_FONT_SIZE + ) + header_font = ImageFont.truetype( + os.path.join(fonts_dir, "arialbd.ttf"), 18 + ) + except OSError: + # Fall back to default + code_font = ImageFont.load_default() + font = ImageFont.load_default() + header_font = ImageFont.load_default() + y_offset = PADDING - + # Draw header - draw.text((PADDING, y_offset), "ExplainFlow - Code Execution Trace", fill=colors["header"], font=header_font) + draw.text( + (PADDING, y_offset), + "ExplainFlow - Code Execution Trace", + fill=colors["header"], + font=header_font, + ) y_offset += HEADER_HEIGHT - + # Draw code section draw.rectangle( - [(PADDING, y_offset), (width - PADDING, y_offset + num_code_lines * CODE_LINE_HEIGHT + PADDING)], + [ + (PADDING, y_offset), + (width - PADDING, y_offset + num_code_lines * CODE_LINE_HEIGHT + PADDING), + ], outline=colors["border"], - width=1 + width=1, ) - + code_y = y_offset + PADDING // 2 - + # Determine which line to highlight highlight_line = None current_step_data = None - + if step is not None and 1 <= step <= len(trace.steps): current_step_data = trace.steps[step - 1] highlight_line = current_step_data.line_number elif trace.steps: current_step_data = trace.steps[-1] highlight_line = current_step_data.line_number - + for i, line in enumerate(code_lines, 1): line_y = code_y + (i - 1) * CODE_LINE_HEIGHT - + # Highlight current line if i == highlight_line: draw.rectangle( - [(PADDING + 1, line_y - 2), (width - PADDING - 1, line_y + CODE_LINE_HEIGHT - 2)], - fill=colors["current_line"] + [ + (PADDING + 1, line_y - 2), + (width - PADDING - 1, line_y + CODE_LINE_HEIGHT - 2), + ], + fill=colors["current_line"], ) - + # Line number - draw.text((PADDING + 5, line_y), f"{i:3}", fill=colors["line_number"], font=code_font) - + draw.text( + (PADDING + 5, line_y), f"{i:3}", fill=colors["line_number"], font=code_font + ) + # Code line - draw.text((PADDING + 50, line_y), line, fill=colors["foreground"], font=code_font) - + draw.text( + (PADDING + 50, line_y), line, fill=colors["foreground"], font=code_font + ) + y_offset += num_code_lines * CODE_LINE_HEIGHT + PADDING * 2 - + # Draw step info if current_step_data: - _draw_step_info(draw, current_step_data, y_offset, width, colors, font, code_font) + _draw_step_info( + draw, current_step_data, y_offset, width, colors, font, code_font + ) y_offset += 200 - + # Draw all steps if requested if show_all_steps: for step_data in trace.steps: _draw_step_info(draw, step_data, y_offset, width, colors, font, code_font) y_offset += 150 - + # Save image output_path = Path(filename) img.save(output_path, "PNG") - + return output_path -def _draw_step_info(draw, step, y_offset: int, width: int, colors: dict, font, code_font) -> None: +def _draw_step_info( + draw, step, y_offset: int, width: int, colors: dict, font, code_font +) -> None: """Draw information about a single step.""" # Step header step_text = f"Step {step.step_number}: {step.step_type.value.upper()}" draw.text((PADDING, y_offset), step_text, fill=colors["header"], font=font) y_offset += LINE_HEIGHT - + # Line info line_text = f"Line {step.line_number}: {step.line_content.strip()}" draw.text((PADDING, y_offset), line_text, fill=colors["foreground"], font=code_font) y_offset += LINE_HEIGHT - + # Explanation - draw.text((PADDING, y_offset), f"→ {step.explanation}", fill=colors["success"], font=font) - y_offset += LINE_HEIGHT * 1.5 - + draw.text( + (PADDING, y_offset), f"→ {step.explanation}", fill=colors["success"], font=font + ) + y_offset += int(LINE_HEIGHT * 1.5) + # Variables if step.variables: draw.text((PADDING, y_offset), "Variables:", fill=colors["comment"], font=font) y_offset += LINE_HEIGHT - + for var in step.variables.values(): marker = "⟳ " if var.changed else " " color = colors["changed"] if var.changed else colors["variable"] @@ -181,16 +249,15 @@ def _draw_step_info(draw, step, y_offset: int, width: int, colors: dict, font, c def export_gif( - trace: "ExecutionTrace", + trace: ExecutionTrace, filename: str, fps: float = 1.0, theme: str = "dark", width: int = 1200, - loop: bool = True + loop: bool = True, ) -> Path: - """ - Export execution trace as an animated GIF. - + """Export execution trace as an animated GIF. + Args: trace: ExecutionTrace to export filename: Output filename (should end with .gif) @@ -198,31 +265,38 @@ def export_gif( theme: Color theme width: Image width in pixels loop: Whether the GIF should loop - + Returns: Path to the created GIF file """ try: from PIL import Image except ImportError: - raise ImportError("Pillow is required for GIF export. Install with: pip install pillow") - + raise ImportError( + "Pillow is required for GIF export. Install with: pip install pillow" + ) + # Generate frame for each step frames = [] temp_files = [] - + + import os + import tempfile + for i in range(1, len(trace.steps) + 1): - temp_filename = f"/tmp/explainflow_frame_{i}.png" + temp_filename = os.path.join( + tempfile.gettempdir(), f"explainflow_frame_{i}.png" + ) export_image(trace, temp_filename, theme=theme, step=i, width=width) frames.append(Image.open(temp_filename)) temp_files.append(temp_filename) - + if not frames: raise ValueError("No steps to export") - + # Calculate duration per frame in milliseconds duration = int(1000 / fps) - + # Save as GIF output_path = Path(filename) frames[0].save( @@ -230,75 +304,289 @@ def export_gif( save_all=True, append_images=frames[1:], duration=duration, - loop=0 if loop else 1 + loop=0 if loop else 1, ) - + # Clean up temp files - import os for temp_file in temp_files: try: os.remove(temp_file) except OSError: pass - + return output_path -def export_html( - trace: "ExecutionTrace", +def export_video( + trace: ExecutionTrace, filename: str, + fps: float = 1.0, theme: str = "dark", - interactive: bool = True + width: int = 1200, +) -> Path: + """Export execution trace as an MP4 video. + + Args: + trace: ExecutionTrace to export + filename: Output filename (should end with .mp4) + fps: Frames per second (lower = slower animation) + theme: Color theme + width: Image width in pixels + + Returns: + Path to the created video file + """ + try: + import imageio.v3 as iio + except ImportError: + raise ImportError( + "imageio is required for video export. " + "Install with: pip install explainflow[video]" + ) + + try: + from PIL import Image + except ImportError: + raise ImportError( + "Pillow is required for video export. Install with: pip install pillow" + ) + + import os + import tempfile + + import numpy as np + + # Generate frames as numpy arrays + frames = [] + temp_files = [] + + for i in range(1, len(trace.steps) + 1): + temp_filename = os.path.join( + tempfile.gettempdir(), f"explainflow_video_frame_{i}.png" + ) + export_image(trace, temp_filename, theme=theme, step=i, width=width) + img = Image.open(temp_filename).convert("RGB") + frame_array = np.array(img) + frames.append(frame_array) + temp_files.append(temp_filename) + + if not frames: + raise ValueError("No steps to export") + + # Ensure all frames have the same shape (pad if necessary) + max_h = max(f.shape[0] for f in frames) + max_w = max(f.shape[1] for f in frames) + + # Make dimensions even (required by many codecs) + max_h = max_h + (max_h % 2) + max_w = max_w + (max_w % 2) + + padded_frames = [] + for frame in frames: + h, w = frame.shape[:2] + if h != max_h or w != max_w: + padded = np.zeros((max_h, max_w, 3), dtype=np.uint8) + padded[:h, :w] = frame + padded_frames.append(padded) + else: + padded_frames.append(frame) + + frame_stack = np.stack(padded_frames, axis=0) + + # Write video + output_path = Path(filename) + iio.imwrite(str(output_path), frame_stack, fps=fps) + + # Clean up temp files + for temp_file in temp_files: + try: + os.remove(temp_file) + except OSError: + pass + + return output_path + + +def export_markdown( + trace: ExecutionTrace, + filename: str, + show_types: bool = True, ) -> Path: + """Export execution trace as a Markdown document. + + Args: + trace: ExecutionTrace to export + filename: Output filename (should end with .md) + show_types: Whether to show variable types + + Returns: + Path to the created Markdown file """ - Export execution trace as an interactive HTML file. - + lines = [] + lines.append("# ExplainFlow - Code Execution Trace\n") + + # Source code + lines.append("## Source Code\n") + lines.append("```python") + lines.append(trace.code.strip()) + lines.append("```\n") + + # Steps + lines.append("## Execution Steps\n") + + for step in trace.steps: + lines.append(f"### Step {step.step_number}: {step.step_type.value.upper()}\n") + lines.append(f"**Line {step.line_number}:** `{step.line_content.strip()}`\n") + lines.append(f"> {step.explanation}\n") + + if step.loop_iteration is not None: + lines.append(f"🔄 **Loop iteration:** {step.loop_iteration}\n") + if step.duration_ms > 0: + lines.append(f"⏱ **Duration:** {step.duration_ms:.2f}ms\n") + + if step.variables: + lines.append("**Variables:**\n") + lines.append("| Name | Value | Type | Changed | Object ID |") + lines.append("|------|-------|------|---------|-----------|") + for var in step.variables.values(): + changed = "⟳" if var.changed else "" + type_info = var.type_name if show_types else "" + obj_id_str = str(var.object_id) if var.object_id else "" + lines.append( + f"| `{var.name}` | `{var.repr_value}` | {type_info} | {changed} | {obj_id_str} |" + ) + lines.append("") + + if step.call_stack: + lines.append("**Call Stack:**\n") + for i, frame in enumerate(step.call_stack): + indent = "  " * i + rv = f" → `{frame.return_value}`" if frame.return_value else "" + lines.append( + f"- {indent}↳ **{frame.function_name}** (line {frame.line_number}){rv}" + ) + lines.append("") + + if step.heap_objects: + lines.append("**Heap Objects:**\n") + lines.append("| Object ID | Type | Value | Refs |") + lines.append("|-----------|------|-------|------|") + for oid, obj in step.heap_objects.items(): + ref_count: int = len(obj.children) if obj.children else 0 + lines.append( + f"| @{oid} | {obj.type_name} | `{obj.repr_value[:60]}` | {ref_count} |" + ) + lines.append("") + + # Summary + lines.append("## Summary\n") + lines.append(f"- **Total steps:** {len(trace.steps)}") + lines.append(f"- **Success:** {'✅ Yes' if trace.success else '❌ No'}") + + if trace.final_output: + lines.append("\n### Program Output\n") + lines.append("```") + lines.append(trace.final_output.rstrip()) + lines.append("```\n") + + if not trace.success: + lines.append("\n### Error\n") + lines.append(f"```\n{trace.error_message}\n```\n") + + if trace.final_variables: + lines.append("\n### Final Variables\n") + lines.append("| Name | Value | Type |") + lines.append("|------|-------|------|") + for var in trace.final_variables.values(): + lines.append(f"| `{var.name}` | `{var.repr_value}` | {var.type_name} |") + lines.append("") + + output_path = Path(filename) + output_path.write_text("\n".join(lines)) + + return output_path + + +def export_html( + trace: ExecutionTrace, + filename: str | None = None, + theme: str = "dark", + interactive: bool = True, + return_string: bool = False, +) -> Path | str: + """Export execution trace as an interactive HTML file or string. + Args: trace: ExecutionTrace to export - filename: Output filename (should end with .html) + filename: Output filename (should end with .html). Ignored when *return_string* is True. theme: Color theme interactive: Whether to include step-through controls - + return_string: If True, return the HTML as a string instead of writing to file. + Returns: - Path to the created HTML file + Path to the created HTML file, or the HTML string when *return_string* is True. """ - from explainflow.visualizer import THEMES - - colors = THEMES.get(theme, THEMES["dark"]) - + from explainflow.visualizer import get_theme + + colors = get_theme(theme) + # Escape HTML in code - import html - code_html = html.escape(trace.code) - code_lines = trace.code.split('\n') - + import html as _html + + code_lines = trace.code.split("\n") + # Build steps JSON steps_data = [] for step in trace.steps: - steps_data.append({ - "number": step.step_number, - "line": step.line_number, - "type": step.step_type.value, - "content": step.line_content, - "explanation": step.explanation, - "variables": { - name: { - "value": var.repr_value, - "type": var.type_name, - "changed": var.changed - } - for name, var in step.variables.items() + steps_data.append( + { + "number": step.step_number, + "line": step.line_number, + "type": step.step_type.value, + "content": step.line_content, + "explanation": step.explanation, + "variables": { + name: { + "value": var.repr_value, + "type": var.type_name, + "changed": var.changed, + "object_id": var.object_id, + } + for name, var in step.variables.items() + }, + "call_stack": [ + { + "function": sf.function_name, + "line": sf.line_number, + "return_value": sf.return_value, + } + for sf in step.call_stack + ], + "heap_objects": { + str(oid): { + "type": obj.type_name, + "repr": obj.repr_value, + "children": obj.children, + } + for oid, obj in step.heap_objects.items() + }, + "loop_iteration": step.loop_iteration, + "duration_ms": step.duration_ms, } - }) - + ) + import json + steps_json = json.dumps(steps_data) - + # Build code lines HTML code_lines_html = [] for i, line in enumerate(code_lines, 1): - escaped_line = html.escape(line) or " " - code_lines_html.append(f'
{i}{escaped_line}
') - + escaped_line = _html.escape(line) or " " + code_lines_html.append( + f'
{i}{escaped_line}
' + ) + html_content = f""" @@ -406,12 +694,33 @@ def export_html( }} .controls button:hover {{ opacity: 0.9; }} .controls button:disabled {{ opacity: 0.5; cursor: not-allowed; }} - .step-counter {{ - font-size: 16px; + .step-counter {{ + font-size: 16px; padding: 10px; min-width: 100px; text-align: center; }} + .call-stack {{ margin-top: 15px; }} + .call-stack h4 {{ color: {colors["header"]}; margin-bottom: 5px; }} + .stack-frame {{ + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 12px; + padding: 4px 8px; + border-left: 3px solid {colors["header"]}; + margin: 3px 0; + background: rgba(86, 156, 214, 0.08); + }} + .heap-panel {{ margin-top: 15px; }} + .heap-panel h4 {{ color: {colors["changed"]}; margin-bottom: 5px; }} + .heap-object {{ + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 12px; + padding: 4px 8px; + border-left: 3px solid {colors["changed"]}; + margin: 3px 0; + }} + .timing {{ font-size: 11px; color: {colors["comment"]}; margin-top: 6px; font-style: italic; }} + .loop-badge {{ display: inline-block; padding: 1px 6px; border-radius: 8px; background: {colors["success"]}20; color: {colors["success"]}; font-size: 11px; margin-left: 5px; }} @@ -420,30 +729,42 @@ def export_html(

🔍 ExplainFlow

Code Execution Trace

- +

Source Code

- {''.join(code_lines_html)} + {"".join(code_lines_html)}
- +
Step 1 LINE +
+
- +

Variables

+ + + +
- +
@@ -453,58 +774,112 @@ def export_html(
- +