diff --git a/.gitignore b/.gitignore index 4548897..56f3529 100644 --- a/.gitignore +++ b/.gitignore @@ -111,4 +111,5 @@ generated_readme.md python_runtime # Ignore Claude AI files -.claude/ \ No newline at end of file +.claude/ +.claude_code/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 4708e9f..ef6a004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,10 @@ PyFlowGraph is a universal node-based visual scripting editor built with Python - **python_code_editor.py**: Core editor widget with line numbers and smart indentation - **python_syntax_highlighter.py**: Python syntax highlighting implementation +### Event System + +- **event_system.py**: Event-driven execution system for interactive applications with live mode support + ### Utilities - **color_utils.py**: Color manipulation utilities @@ -73,6 +77,7 @@ PyFlowGraph is a universal node-based visual scripting editor built with Python - Nodes execute when all input dependencies are satisfied - Each node runs in an isolated subprocess for security - Pin values are serialized/deserialized as JSON between nodes +- Supports both **Batch Mode** (traditional sequential execution) and **Live Mode** (event-driven interactive execution) ### Graph Persistence @@ -93,10 +98,16 @@ PyFlowGraph is a universal node-based visual scripting editor built with Python The README mentions `socket_type.py` and `default_graphs.py` but these files don't exist in the current codebase. The socket type functionality appears to be implemented directly in other modules. +## Testing + +- **test_execution_flow.py**: Simple test script for validating node execution flow and architecture +- No formal test suite exists - testing is primarily done through example graphs in the `examples/` directory +- Use `python test_execution_flow.py` to run basic execution tests + ## Development Notes - This is an experimental AI-generated codebase for learning purposes -- No formal test suite exists - testing is done through example graphs -- The application uses PySide6 for the Qt-based GUI +- The application uses PySide6 for the Qt-based GUI - Font Awesome integration provides professional iconography - All nodes execute in isolated environments for security +- Dependencies are managed via `requirements.txt` (PySide6, Nuitka for compilation) diff --git a/FINAL_TEST_SUMMARY.md b/FINAL_TEST_SUMMARY.md new file mode 100644 index 0000000..1474e53 --- /dev/null +++ b/FINAL_TEST_SUMMARY.md @@ -0,0 +1,71 @@ +# Final Test Summary - GUI Loading Bug Investigation + +## Issue Report +**Original Problem**: "Any node that has a GUI doesn't load correctly" from `text_processing_pipeline.md` + +## Investigation Results + +After comprehensive testing, I discovered that the issue is **NOT** a GUI rendering problem, but a **pin categorization bug**. + +### ✅ What Works Correctly + +1. **GUI Components ARE Loading**: All GUI nodes show: + - Widgets created properly (3-4 widgets per node) + - Proxy widgets visible and correctly sized + - GUI code executing without errors + - GUI state being applied correctly + +2. **Examples from text_processing_pipeline.md**: + - "Text Input Source": 3 widgets, 276×317px, visible ✅ + - "Text Cleaner & Normalizer": 4 widgets, 250×123px, visible ✅ + - "Keyword & Phrase Extractor": 2 widgets, 250×96px, visible ✅ + - "Processing Report Generator": 3 widgets, 276×313px, visible ✅ + +### ❌ The Real Bug: Pin Direction Categorization + +**Root Cause**: Nodes loaded from markdown have pins, but the pins lack proper `pin_direction` attributes. + +**Evidence**: +- Node shows "Total pins: 9" ✅ +- But "Input pins: 0" and "Output pins: 0" ❌ +- Pin direction filtering `[p for p in pins if p.pin_direction == 'input']` returns empty arrays + +**This explains the reported symptoms**: +1. **"GUI doesn't show"** → Actually, connections don't work because pins aren't categorized properly +2. **"Pins stuck in top-left"** → Pin positioning fails when pin_direction is undefined +3. **"Zero height nodes"** → Layout calculations fail without proper pin categorization + +### Test Files Created + +1. **`test_gui_loading_bugs.py`** - Basic GUI loading tests (7 tests) +2. **`test_gui_rendering.py`** - Visual rendering verification (5 tests) +3. **`test_specific_gui_bugs.py`** - Targeted bug reproduction (3 tests) +4. **`test_pin_creation_bug.py`** - Root cause identification (3 tests) + +### Recommended Fix + +The issue is in the pin creation/categorization during markdown deserialization. Need to investigate: + +1. **`node.py`** - `update_pins_from_code()` method +2. **`pin.py`** - Pin direction assignment during creation +3. **`node_graph.py`** - Pin handling during `deserialize()` + +The pin direction attributes (`pin_direction = "input"/"output"`) are not being set correctly when nodes are loaded from markdown format. + +### Test Commands + +To reproduce the bug: +```bash +python test_pin_creation_bug.py +``` + +To verify GUI components work correctly: +```bash +python test_gui_rendering.py +``` + +## Conclusion + +The "GUI loading bug" is actually a **pin categorization bug** that makes the nodes appear broken because connections don't work properly. The GUI components themselves are loading and rendering correctly. + +**Next Steps**: Fix the pin direction assignment during markdown deserialization process. \ No newline at end of file diff --git a/TEST_GUI_LOADING.md b/TEST_GUI_LOADING.md new file mode 100644 index 0000000..4bcd59e --- /dev/null +++ b/TEST_GUI_LOADING.md @@ -0,0 +1,145 @@ +# GUI Loading Tests for PyFlowGraph + +This document describes the unit tests created to detect GUI-related loading issues in markdown graphs. + +## Problem Statement + +The issue reported was: "any node that has a GUI doesn't load correctly" when loading markdown graphs. This suggests systematic problems with GUI component initialization during the markdown-to-graph deserialization process. + +## Test Files Created + +### 1. `test_gui_loading_bugs.py` - Core Bug Detection Tests + +This is the main test file focused specifically on GUI loading issues. It contains targeted tests for: + +- **Basic GUI Loading**: Verifies that nodes with GUI components load and rebuild successfully +- **Zero Height Bug**: Tests for the specific bug mentioned in git commits where nodes had zero height after loading +- **GUI Code Execution Errors**: Ensures that syntax errors in GUI code are handled gracefully +- **Proxy Widget Creation**: Verifies that QGraphicsProxyWidget objects are properly created for GUI nodes +- **GUI State Handling**: Tests that saved GUI state is properly applied during loading +- **Reroute Node Loading**: Ensures reroute nodes don't cause GUI-related errors +- **Real File Loading**: Tests loading actual markdown example files + +### 2. `test_gui_loading.py` - Comprehensive Test Suite + +This is a more extensive test suite that includes: + +- Complex GUI layout testing +- Malformed JSON handling +- Missing GUI state handlers +- FileOperations integration testing +- GUI refresh mechanisms + +## Key Testing Areas + +### GUI Component Lifecycle + +1. **Loading Phase**: Markdown → JSON → Node deserialization +2. **GUI Rebuild Phase**: Executing `gui_code` to create Qt widgets +3. **State Application Phase**: Applying saved `gui_state` to widgets +4. **Rendering Phase**: QGraphicsProxyWidget integration + +### Common Failure Points Tested + +1. **Syntax Errors in GUI Code**: Invalid Python code in GUI Definition sections +2. **Missing Dependencies**: Qt widgets not properly imported +3. **Widget Creation Failures**: Errors during widget instantiation +4. **State Application Errors**: GUI state not matching widget structure +5. **Height/Sizing Issues**: Nodes with zero or negative dimensions +6. **Proxy Widget Failures**: QGraphicsProxyWidget not created properly + +### Error Handling Verification + +The tests verify that: +- Invalid GUI code doesn't crash the application +- Missing GUI components are handled gracefully +- Malformed metadata doesn't prevent loading +- Error nodes still maintain basic functionality + +## Running the Tests + +### Quick GUI Bug Detection +```bash +python test_gui_loading_bugs.py +``` + +### Comprehensive GUI Testing +```bash +python test_gui_loading.py +``` + +### Test Output Interpretation + +- **All tests pass**: No GUI loading bugs detected +- **Test failures**: Specific GUI loading issues identified +- **Error output**: Details about what failed and where + +## Test Strategy + +### Unit Test Approach +- Each test focuses on a specific aspect of GUI loading +- Tests use synthetic markdown content to isolate issues +- Real file testing validates against actual usage + +### Synthetic Test Data +- Minimal markdown content that exercises specific features +- Controlled scenarios for reproducing bugs +- Known-good and known-bad test cases + +### Error Simulation +- Deliberately malformed GUI code +- Missing required components +- Invalid metadata structures + +## Integration with Development Workflow + +### Pre-commit Testing +Add to git hooks or CI/CD pipeline: +```bash +python test_gui_loading_bugs.py && echo "GUI loading tests passed" +``` + +### Regression Testing +Run these tests whenever: +- GUI-related code is modified +- Markdown loading logic is changed +- Node serialization/deserialization is updated +- Qt widget handling is modified + +### Bug Reproduction +When GUI loading issues are reported: +1. Create a test case that reproduces the issue +2. Fix the underlying problem +3. Verify the test now passes +4. Add the test to the suite permanently + +## Future Enhancements + +### Additional Test Coverage +- Performance testing for large graphs with many GUI nodes +- Memory leak detection during repeated load/unload cycles +- Cross-platform GUI rendering differences +- Complex widget interaction testing + +### Automated Testing +- Integration with CI/CD systems +- Automated testing of example files +- Performance benchmarking +- Visual regression testing + +## Maintenance + +### Updating Tests +When new GUI features are added: +1. Add corresponding test cases +2. Update test documentation +3. Verify backwards compatibility + +### Test Data Maintenance +- Keep synthetic test markdown in sync with format changes +- Update expected behaviors when GUI system evolves +- Maintain test examples that cover edge cases + +## Conclusion + +These test suites provide comprehensive coverage for GUI loading issues in PyFlowGraph's markdown format. They serve as both regression prevention and debugging tools, helping maintain reliable GUI functionality as the codebase evolves. \ No newline at end of file diff --git a/TEST_RUNNER_README.md b/TEST_RUNNER_README.md new file mode 100644 index 0000000..025ed0a --- /dev/null +++ b/TEST_RUNNER_README.md @@ -0,0 +1,85 @@ +# Test Runner Scripts + +This directory contains helper scripts to easily run the GUI loading tests. + +## Quick Start + +### Option 1: Quick Test (Recommended) +```batch +run_quick_test.bat +``` +- Runs the 2 most important tests +- Identifies the core issues quickly +- Shows clear diagnosis and recommendations +- Takes ~1-2 minutes + +### Option 2: Interactive Test Menu +```batch +run_tests.bat +``` +- Interactive menu to choose specific tests +- Run individual test suites +- Option to run all tests +- More detailed testing options + +## Test Files Overview + +### Core Issue Detection +- **`test_specific_gui_bugs.py`** - Tests your exact reported issue with text_processing_pipeline.md +- **`test_pin_creation_bug.py`** - Identifies the root cause (pin direction bug) + +### Comprehensive Testing +- **`test_gui_rendering.py`** - Verifies visual GUI rendering works +- **`test_gui_loading.py`** - Full GUI loading test suite +- **`test_gui_loading_bugs.py`** - Basic GUI bug detection +- **`test_execution_flow.py`** - Original execution test + +## Test Results Interpretation + +### ✅ If Tests Pass +- **GUI Tests Pass**: GUI components are working correctly +- **Pin Tests Pass**: Pin creation and categorization is working + +### ❌ If Tests Fail +- **GUI Tests Fail**: GUI rendering issues detected +- **Pin Tests Fail**: Pin direction categorization bug (likely root cause) + +## Current Known Issue + +Based on test results, the issue is: +- **NOT** a GUI rendering problem +- **IS** a pin direction categorization bug during markdown loading +- Nodes have pins but `pin_direction` attributes aren't set properly +- This makes connections fail, causing GUI to appear broken + +## Commands Quick Reference + +```batch +# Quick diagnosis (recommended) +run_quick_test.bat + +# Interactive menu +run_tests.bat + +# Run specific tests manually +python test_specific_gui_bugs.py # Your reported issue +python test_pin_creation_bug.py # Root cause +python test_gui_rendering.py # Visual verification +python test_gui_loading.py # Comprehensive suite +``` + +## Troubleshooting + +If tests fail to run: +1. Ensure you're in the PyFlowGraph directory +2. Check that Python is in your PATH +3. Verify PySide6 is installed: `pip install PySide6` +4. Make sure virtual environment is activated if used + +## Next Steps + +Once you confirm the pin direction bug: +1. Investigate `node.py` - `update_pins_from_code()` method +2. Check `pin.py` - Pin direction assignment during creation +3. Review `node_graph.py` - Pin handling during `deserialize()` +4. Focus on markdown loading vs JSON loading differences \ No newline at end of file diff --git a/examples/data_analysis_dashboard.json b/examples/data_analysis_dashboard.json deleted file mode 100644 index f76ee77..0000000 --- a/examples/data_analysis_dashboard.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "nodes": [ - { - "uuid": "data-generator", - "title": "Sample Data Generator", - "pos": [100, 200], - "size": [280, 200], - "code": "import random\nfrom typing import List, Dict\n\n@node_entry\ndef generate_sample_data(num_records: int, data_type: str) -> List[Dict]:\n data = []\n \n if data_type == \"Sales\":\n products = [\"Laptop\", \"Phone\", \"Tablet\", \"Monitor\", \"Keyboard\", \"Mouse\"]\n for i in range(num_records):\n data.append({\n \"id\": i + 1,\n \"product\": random.choice(products),\n \"quantity\": random.randint(1, 10),\n \"price\": round(random.uniform(50, 2000), 2),\n \"date\": f\"2024-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}\"\n })\n elif data_type == \"Weather\":\n cities = [\"New York\", \"London\", \"Tokyo\", \"Sydney\", \"Paris\", \"Berlin\"]\n for i in range(num_records):\n data.append({\n \"id\": i + 1,\n \"city\": random.choice(cities),\n \"temperature\": round(random.uniform(-10, 40), 1),\n \"humidity\": random.randint(30, 90),\n \"date\": f\"2024-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}\"\n })\n else: # Survey\n for i in range(num_records):\n data.append({\n \"id\": i + 1,\n \"age\": random.randint(18, 80),\n \"satisfaction\": random.randint(1, 10),\n \"category\": random.choice([\"A\", \"B\", \"C\"]),\n \"score\": round(random.uniform(0, 100), 1)\n })\n \n print(f\"Generated {len(data)} {data_type.lower()} records\")\n print(f\"Sample record: {data[0] if data else 'None'}\")\n return data", - "gui_code": "from PySide6.QtWidgets import QLabel, QSpinBox, QComboBox, QPushButton\n\nlayout.addWidget(QLabel('Number of Records:', parent))\nwidgets['num_records'] = QSpinBox(parent)\nwidgets['num_records'].setRange(10, 1000)\nwidgets['num_records'].setValue(100)\nlayout.addWidget(widgets['num_records'])\n\nlayout.addWidget(QLabel('Data Type:', parent))\nwidgets['data_type'] = QComboBox(parent)\nwidgets['data_type'].addItems(['Sales', 'Weather', 'Survey'])\nlayout.addWidget(widgets['data_type'])\n\nwidgets['generate_btn'] = QPushButton('Generate Data', parent)\nlayout.addWidget(widgets['generate_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'num_records': widgets['num_records'].value(),\n 'data_type': widgets['data_type'].currentText()\n }\n\ndef set_initial_state(widgets, state):\n widgets['num_records'].setValue(state.get('num_records', 100))\n widgets['data_type'].setCurrentText(state.get('data_type', 'Sales'))", - "gui_state": { - "num_records": 100, - "data_type": "Sales" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "statistics-calculator", - "title": "Statistics Calculator", - "pos": [450, 100], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\nimport statistics\n\n@node_entry\ndef calculate_statistics(data: List[Dict]) -> Tuple[Dict, int, str]:\n if not data:\n return {}, 0, \"No data provided\"\n \n stats = {}\n total_records = len(data)\n \n # Get numeric columns\n numeric_cols = []\n sample_record = data[0]\n for key, value in sample_record.items():\n if isinstance(value, (int, float)) and key != 'id':\n numeric_cols.append(key)\n \n # Calculate statistics for numeric columns\n for col in numeric_cols:\n values = [record[col] for record in data if isinstance(record[col], (int, float))]\n if values:\n stats[f\"{col}_mean\"] = round(statistics.mean(values), 2)\n stats[f\"{col}_median\"] = round(statistics.median(values), 2)\n stats[f\"{col}_min\"] = min(values)\n stats[f\"{col}_max\"] = max(values)\n if len(values) > 1:\n stats[f\"{col}_stdev\"] = round(statistics.stdev(values), 2)\n \n # Get categorical columns for summary\n categorical_summary = \"\"\n for key, value in sample_record.items():\n if isinstance(value, str) and key not in ['id', 'date']:\n unique_values = set(record[key] for record in data)\n categorical_summary += f\"{key}: {len(unique_values)} unique values; \"\n \n print(\"\\n=== STATISTICAL ANALYSIS ===\")\n print(f\"Total records: {total_records}\")\n for key, value in stats.items():\n print(f\"{key}: {value}\")\n if categorical_summary:\n print(f\"Categorical data: {categorical_summary}\")\n \n return stats, total_records, categorical_summary", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "trend-analyzer", - "title": "Trend Analyzer", - "pos": [450, 400], - "size": [300, 200], - "code": "from typing import List, Dict, Tuple\nfrom collections import Counter\n\n@node_entry\ndef analyze_trends(data: List[Dict]) -> Tuple[Dict, Dict, str]:\n if not data:\n return {}, {}, \"No data to analyze\"\n \n trends = {}\n patterns = {}\n \n # Date-based trends (if date field exists)\n if 'date' in data[0]:\n monthly_counts = Counter()\n for record in data:\n if 'date' in record:\n month = record['date'][:7] # Extract YYYY-MM\n monthly_counts[month] += 1\n trends['monthly_distribution'] = dict(monthly_counts)\n \n # Categorical distributions\n for key, value in data[0].items():\n if isinstance(value, str) and key not in ['id', 'date']:\n distribution = Counter(record[key] for record in data)\n patterns[f\"{key}_distribution\"] = dict(distribution.most_common(5))\n \n # Correlation analysis for numeric fields\n numeric_fields = [k for k, v in data[0].items() \n if isinstance(v, (int, float)) and k != 'id']\n \n correlations = \"\"\n if len(numeric_fields) >= 2:\n # Simple correlation analysis\n field1, field2 = numeric_fields[0], numeric_fields[1]\n values1 = [record[field1] for record in data]\n values2 = [record[field2] for record in data]\n \n # Calculate basic correlation indicator\n avg1, avg2 = sum(values1)/len(values1), sum(values2)/len(values2)\n covariance = sum((x - avg1) * (y - avg2) for x, y in zip(values1, values2)) / len(values1)\n \n if covariance > 0:\n correlations = f\"Positive relationship between {field1} and {field2}\"\n elif covariance < 0:\n correlations = f\"Negative relationship between {field1} and {field2}\"\n else:\n correlations = f\"No clear relationship between {field1} and {field2}\"\n \n print(\"\\n=== TREND ANALYSIS ===\")\n print(f\"Trends: {trends}\")\n print(f\"Patterns: {patterns}\")\n print(f\"Correlations: {correlations}\")\n \n return trends, patterns, correlations", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "dashboard-display", - "title": "Analytics Dashboard", - "pos": [850, 250], - "size": [400, 350], - "code": "from typing import Dict\n\n@node_entry\ndef create_dashboard(stats: Dict, record_count: int, categorical_info: str, trends: Dict, patterns: Dict, correlations: str) -> str:\n dashboard = \"\\n\" + \"=\"*50 + \"\\n\"\n dashboard += \" ANALYTICS DASHBOARD\\n\"\n dashboard += \"=\"*50 + \"\\n\\n\"\n \n # Overview section\n dashboard += f\"📊 OVERVIEW\\n\"\n dashboard += f\" Total Records: {record_count:,}\\n\\n\"\n \n # Statistics section\n if stats:\n dashboard += f\"📈 STATISTICS\\n\"\n for key, value in stats.items():\n dashboard += f\" {key.replace('_', ' ').title()}: {value}\\n\"\n dashboard += \"\\n\"\n \n # Trends section\n if trends:\n dashboard += f\"📅 TRENDS\\n\"\n for key, value in trends.items():\n dashboard += f\" {key.replace('_', ' ').title()}:\\n\"\n if isinstance(value, dict):\n for k, v in list(value.items())[:3]: # Show top 3\n dashboard += f\" {k}: {v}\\n\"\n dashboard += \"\\n\"\n \n # Patterns section\n if patterns:\n dashboard += f\"🔍 PATTERNS\\n\"\n for key, value in patterns.items():\n dashboard += f\" {key.replace('_', ' ').title()}:\\n\"\n for k, v in value.items():\n dashboard += f\" {k}: {v}\\n\"\n dashboard += \"\\n\"\n \n # Insights section\n if correlations:\n dashboard += f\"💡 INSIGHTS\\n\"\n dashboard += f\" {correlations}\\n\\n\"\n \n if categorical_info:\n dashboard += f\"📋 CATEGORICAL DATA\\n\"\n dashboard += f\" {categorical_info}\\n\\n\"\n \n dashboard += \"=\"*50\n \n print(dashboard)\n return dashboard", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Analytics Dashboard', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['dashboard_display'] = QTextEdit(parent)\nwidgets['dashboard_display'].setMinimumHeight(250)\nwidgets['dashboard_display'].setReadOnly(True)\nwidgets['dashboard_display'].setPlainText('Generate data and run analysis to see dashboard...')\nfont = QFont('Courier New', 9)\nwidgets['dashboard_display'].setFont(font)\nlayout.addWidget(widgets['dashboard_display'])\n\nwidgets['export_btn'] = QPushButton('Export Report', parent)\nlayout.addWidget(widgets['export_btn'])\n\nwidgets['refresh_btn'] = QPushButton('Refresh Analysis', parent)\nlayout.addWidget(widgets['refresh_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n dashboard = outputs.get('output_1', 'No dashboard data')\n widgets['dashboard_display'].setPlainText(dashboard)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "data-generator", - "start_pin_name": "exec_out", - "end_node_uuid": "statistics-calculator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "data-generator", - "start_pin_name": "output_1", - "end_node_uuid": "statistics-calculator", - "end_pin_name": "data" - }, - { - "start_node_uuid": "data-generator", - "start_pin_name": "exec_out", - "end_node_uuid": "trend-analyzer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "data-generator", - "start_pin_name": "output_1", - "end_node_uuid": "trend-analyzer", - "end_pin_name": "data" - }, - { - "start_node_uuid": "statistics-calculator", - "start_pin_name": "exec_out", - "end_node_uuid": "dashboard-display", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "trend-analyzer", - "start_pin_name": "exec_out", - "end_node_uuid": "dashboard-display", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "statistics-calculator", - "start_pin_name": "output_1", - "end_node_uuid": "dashboard-display", - "end_pin_name": "stats" - }, - { - "start_node_uuid": "statistics-calculator", - "start_pin_name": "output_2", - "end_node_uuid": "dashboard-display", - "end_pin_name": "record_count" - }, - { - "start_node_uuid": "statistics-calculator", - "start_pin_name": "output_3", - "end_node_uuid": "dashboard-display", - "end_pin_name": "categorical_info" - }, - { - "start_node_uuid": "trend-analyzer", - "start_pin_name": "output_1", - "end_node_uuid": "dashboard-display", - "end_pin_name": "trends" - }, - { - "start_node_uuid": "trend-analyzer", - "start_pin_name": "output_2", - "end_node_uuid": "dashboard-display", - "end_pin_name": "patterns" - }, - { - "start_node_uuid": "trend-analyzer", - "start_pin_name": "output_3", - "end_node_uuid": "dashboard-display", - "end_pin_name": "correlations" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/data_analysis_dashboard.md b/examples/data_analysis_dashboard.md new file mode 100644 index 0000000..146e694 --- /dev/null +++ b/examples/data_analysis_dashboard.md @@ -0,0 +1,464 @@ +# Data Analysis Dashboard + +A comprehensive data analysis and visualization system that demonstrates the complete lifecycle of data processing from generation through statistical analysis to presentation. This workflow showcases how different types of data (sales, weather, survey) can be dynamically generated, analyzed for statistical patterns and trends, and presented in a professional dashboard format. + +The system emphasizes real-time analytics capabilities where users can adjust data parameters and immediately see the impact on statistical calculations, trend analysis, and correlation findings. Each component works together to create a complete business intelligence pipeline that transforms raw data into actionable insights through visual presentation and quantitative analysis. + +## Node: Sample Data Generator (ID: data-generator) + +Generates structured test datasets with configurable record counts (10-1000) and three predefined schemas: Sales (product, quantity, price, date), Weather (city, temperature, humidity, date), and Survey (age, satisfaction, category, score). Uses Python's random module to create realistic value distributions within appropriate ranges for each data type. + +Implements domain-specific data generation logic with realistic constraints: sales prices between $50-2000, temperatures between -10°C to 40°C, satisfaction scores 1-10, and random date assignment within 2024. Returns List[Dict] where each dictionary represents a record with consistent field types and naming conventions. + +The node serves as a data source for testing downstream analytics components without requiring external datasets. Output format is standardized with 'id' fields for record identification and consistent data types (int, float, str) suitable for statistical analysis and trend detection algorithms. + +### Metadata + +```json +{ + "uuid": "data-generator", + "title": "Sample Data Generator", + "pos": [ + 100.0, + 200.0 + ], + "size": [ + 250, + 190 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "num_records": 100, + "data_type": "Sales" + } +} +``` + +### Logic + +```python +import random +from typing import List, Dict + +@node_entry +def generate_sample_data(num_records: int, data_type: str) -> List[Dict]: + data = [] + + if data_type == "Sales": + products = ["Laptop", "Phone", "Tablet", "Monitor", "Keyboard", "Mouse"] + for i in range(num_records): + data.append({ + "id": i + 1, + "product": random.choice(products), + "quantity": random.randint(1, 10), + "price": round(random.uniform(50, 2000), 2), + "date": f"2024-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}" + }) + elif data_type == "Weather": + cities = ["New York", "London", "Tokyo", "Sydney", "Paris", "Berlin"] + for i in range(num_records): + data.append({ + "id": i + 1, + "city": random.choice(cities), + "temperature": round(random.uniform(-10, 40), 1), + "humidity": random.randint(30, 90), + "date": f"2024-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}" + }) + else: # Survey + for i in range(num_records): + data.append({ + "id": i + 1, + "age": random.randint(18, 80), + "satisfaction": random.randint(1, 10), + "category": random.choice(["A", "B", "C"]), + "score": round(random.uniform(0, 100), 1) + }) + + print(f"Generated {len(data)} {data_type.lower()} records") + print(f"Sample record: {data[0] if data else 'None'}") + return data +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QSpinBox, QComboBox, QPushButton + +layout.addWidget(QLabel('Number of Records:', parent)) +widgets['num_records'] = QSpinBox(parent) +widgets['num_records'].setRange(10, 1000) +widgets['num_records'].setValue(100) +layout.addWidget(widgets['num_records']) + +layout.addWidget(QLabel('Data Type:', parent)) +widgets['data_type'] = QComboBox(parent) +widgets['data_type'].addItems(['Sales', 'Weather', 'Survey']) +layout.addWidget(widgets['data_type']) + +widgets['generate_btn'] = QPushButton('Generate Data', parent) +layout.addWidget(widgets['generate_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'num_records': widgets['num_records'].value(), + 'data_type': widgets['data_type'].currentText() + } + +def set_initial_state(widgets, state): + widgets['num_records'].setValue(state.get('num_records', 100)) + widgets['data_type'].setCurrentText(state.get('data_type', 'Sales')) +``` + + +## Node: Statistics Calculator (ID: statistics-calculator) + +Performs statistical analysis on List[Dict] input by automatically detecting numeric fields (excluding 'id') and calculating mean, median, min, max, and standard deviation using Python's statistics module. Processes each numeric column independently and returns results as a dictionary with keys formatted as '{column}_{statistic}'. + +Generates categorical data summaries by identifying string fields (excluding 'id' and 'date'), counting unique values per category, and creating frequency distributions. Handles variable data schemas dynamically without requiring predefined field specifications. + +Returns three outputs: statistics dictionary with calculated metrics, total record count (int), and categorical summary string describing unique value counts. Designed to work with any tabular data structure and provides the statistical foundation for downstream trend analysis and dashboard display components. + +### Metadata + +```json +{ + "uuid": "statistics-calculator", + "title": "Statistics Calculator", + "pos": [ + 450.0, + 100.0 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple +import statistics + +@node_entry +def calculate_statistics(data: List[Dict]) -> Tuple[Dict, int, str]: + if not data: + return {}, 0, "No data provided" + + stats = {} + total_records = len(data) + + # Get numeric columns + numeric_cols = [] + sample_record = data[0] + for key, value in sample_record.items(): + if isinstance(value, (int, float)) and key != 'id': + numeric_cols.append(key) + + # Calculate statistics for numeric columns + for col in numeric_cols: + values = [record[col] for record in data if isinstance(record[col], (int, float))] + if values: + stats[f"{col}_mean"] = round(statistics.mean(values), 2) + stats[f"{col}_median"] = round(statistics.median(values), 2) + stats[f"{col}_min"] = min(values) + stats[f"{col}_max"] = max(values) + if len(values) > 1: + stats[f"{col}_stdev"] = round(statistics.stdev(values), 2) + + # Get categorical columns for summary + categorical_summary = "" + for key, value in sample_record.items(): + if isinstance(value, str) and key not in ['id', 'date']: + unique_values = set(record[key] for record in data) + categorical_summary += f"{key}: {len(unique_values)} unique values; " + + print("\n=== STATISTICAL ANALYSIS ===") + print(f"Total records: {total_records}") + for key, value in stats.items(): + print(f"{key}: {value}") + if categorical_summary: + print(f"Categorical data: {categorical_summary}") + + return stats, total_records, categorical_summary +``` + + +## Node: Trend Analyzer (ID: trend-analyzer) + +Analyzes temporal patterns by extracting YYYY-MM substrings from 'date' fields and counting record frequency per month using Counter. Creates categorical frequency distributions for string fields, returning the top 5 most common values for each category with their occurrence counts. + +Implements basic correlation analysis between the first two numeric fields found in the dataset. Calculates covariance using standard formula: Σ(x-μx)(y-μy)/n, then determines relationship direction (positive/negative/neutral) based on covariance sign. Does not calculate correlation coefficients, only directional relationships. + +Returns three outputs: trends dictionary containing monthly distributions, patterns dictionary with categorical frequency data, and correlations string describing numeric field relationships. Processing is conditional on data structure - temporal analysis requires 'date' fields, correlation analysis requires at least two numeric fields. + +### Metadata + +```json +{ + "uuid": "trend-analyzer", + "title": "Trend Analyzer", + "pos": [ + 450.0, + 400.0 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple +from collections import Counter + +@node_entry +def analyze_trends(data: List[Dict]) -> Tuple[Dict, Dict, str]: + if not data: + return {}, {}, "No data to analyze" + + trends = {} + patterns = {} + + # Date-based trends (if date field exists) + if 'date' in data[0]: + monthly_counts = Counter() + for record in data: + if 'date' in record: + month = record['date'][:7] # Extract YYYY-MM + monthly_counts[month] += 1 + trends['monthly_distribution'] = dict(monthly_counts) + + # Categorical distributions + for key, value in data[0].items(): + if isinstance(value, str) and key not in ['id', 'date']: + distribution = Counter(record[key] for record in data) + patterns[f"{key}_distribution"] = dict(distribution.most_common(5)) + + # Correlation analysis for numeric fields + numeric_fields = [k for k, v in data[0].items() + if isinstance(v, (int, float)) and k != 'id'] + + correlations = "" + if len(numeric_fields) >= 2: + # Simple correlation analysis + field1, field2 = numeric_fields[0], numeric_fields[1] + values1 = [record[field1] for record in data] + values2 = [record[field2] for record in data] + + # Calculate basic correlation indicator + avg1, avg2 = sum(values1)/len(values1), sum(values2)/len(values2) + covariance = sum((x - avg1) * (y - avg2) for x, y in zip(values1, values2)) / len(values1) + + if covariance > 0: + correlations = f"Positive relationship between {field1} and {field2}" + elif covariance < 0: + correlations = f"Negative relationship between {field1} and {field2}" + else: + correlations = f"No clear relationship between {field1} and {field2}" + + print("\n=== TREND ANALYSIS ===") + print(f"Trends: {trends}") + print(f"Patterns: {patterns}") + print(f"Correlations: {correlations}") + + return trends, patterns, correlations +``` + + +## Node: Analytics Dashboard (ID: dashboard-display) + +Formats analytical results into a structured text report using string concatenation with emoji section headers and consistent indentation. Takes six inputs from upstream analysis nodes and combines them into a single formatted dashboard string with sections for overview, statistics, trends, patterns, and insights. + +Handles variable data presence gracefully - only displays sections when corresponding data exists. Processes statistical dictionaries by replacing underscores with spaces and applying title case formatting. Limits trend displays to top 3 items and includes all pattern data with hierarchical indentation. + +Outputs a single formatted string suitable for display in QTextEdit widgets. The report format is fixed-width text designed for monospace fonts, with consistent spacing and emoji-based visual organization. Includes integration points for export and refresh functionality through GUI action buttons. + +### Metadata + +```json +{ + "uuid": "dashboard-display", + "title": "Analytics Dashboard", + "pos": [ + 850.0, + 250.0 + ], + "size": [ + 276, + 589 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import Dict + +@node_entry +def create_dashboard(stats: Dict, record_count: int, categorical_info: str, trends: Dict, patterns: Dict, correlations: str) -> str: + dashboard = "\n" + "="*50 + "\n" + dashboard += " ANALYTICS DASHBOARD\n" + dashboard += "="*50 + "\n\n" + + # Overview section + dashboard += f"📊 OVERVIEW\n" + dashboard += f" Total Records: {record_count:,}\n\n" + + # Statistics section + if stats: + dashboard += f"📈 STATISTICS\n" + for key, value in stats.items(): + dashboard += f" {key.replace('_', ' ').title()}: {value}\n" + dashboard += "\n" + + # Trends section + if trends: + dashboard += f"📅 TRENDS\n" + for key, value in trends.items(): + dashboard += f" {key.replace('_', ' ').title()}:\n" + if isinstance(value, dict): + for k, v in list(value.items())[:3]: # Show top 3 + dashboard += f" {k}: {v}\n" + dashboard += "\n" + + # Patterns section + if patterns: + dashboard += f"🔍 PATTERNS\n" + for key, value in patterns.items(): + dashboard += f" {key.replace('_', ' ').title()}:\n" + for k, v in value.items(): + dashboard += f" {k}: {v}\n" + dashboard += "\n" + + # Insights section + if correlations: + dashboard += f"💡 INSIGHTS\n" + dashboard += f" {correlations}\n\n" + + if categorical_info: + dashboard += f"📋 CATEGORICAL DATA\n" + dashboard += f" {categorical_info}\n\n" + + dashboard += "="*50 + + print(dashboard) + return dashboard +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Analytics Dashboard', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['dashboard_display'] = QTextEdit(parent) +widgets['dashboard_display'].setMinimumHeight(250) +widgets['dashboard_display'].setReadOnly(True) +widgets['dashboard_display'].setPlainText('Generate data and run analysis to see dashboard...') +font = QFont('Courier New', 9) +widgets['dashboard_display'].setFont(font) +layout.addWidget(widgets['dashboard_display']) + +widgets['export_btn'] = QPushButton('Export Report', parent) +layout.addWidget(widgets['export_btn']) + +widgets['refresh_btn'] = QPushButton('Refresh Analysis', parent) +layout.addWidget(widgets['refresh_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + dashboard = outputs.get('output_1', 'No dashboard data') + widgets['dashboard_display'].setPlainText(dashboard) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "statistics-calculator", + "start_pin_name": "exec_out", + "end_node_uuid": "dashboard-display", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "statistics-calculator", + "start_pin_name": "output_1", + "end_node_uuid": "dashboard-display", + "end_pin_name": "stats" + }, + { + "start_node_uuid": "statistics-calculator", + "start_pin_name": "output_2", + "end_node_uuid": "dashboard-display", + "end_pin_name": "record_count" + }, + { + "start_node_uuid": "statistics-calculator", + "start_pin_name": "output_3", + "end_node_uuid": "dashboard-display", + "end_pin_name": "categorical_info" + }, + { + "start_node_uuid": "trend-analyzer", + "start_pin_name": "output_1", + "end_node_uuid": "dashboard-display", + "end_pin_name": "trends" + }, + { + "start_node_uuid": "trend-analyzer", + "start_pin_name": "output_2", + "end_node_uuid": "dashboard-display", + "end_pin_name": "patterns" + }, + { + "start_node_uuid": "trend-analyzer", + "start_pin_name": "output_3", + "end_node_uuid": "dashboard-display", + "end_pin_name": "correlations" + } +] +``` diff --git a/examples/file_organizer_automation.json b/examples/file_organizer_automation.json deleted file mode 100644 index 930ab39..0000000 --- a/examples/file_organizer_automation.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "nodes": [ - { - "uuid": "folder-scanner", - "title": "Folder Scanner", - "pos": [100, 200], - "size": [280, 200], - "code": "import os\nfrom typing import List\n\n@node_entry\ndef scan_folder(folder_path: str) -> List[str]:\n if not os.path.exists(folder_path):\n print(f\"Error: Folder '{folder_path}' does not exist\")\n return []\n \n files = []\n for item in os.listdir(folder_path):\n item_path = os.path.join(folder_path, item)\n if os.path.isfile(item_path):\n files.append(item)\n \n print(f\"Found {len(files)} files in '{folder_path}'\")\n for file in files[:10]: # Show first 10\n print(f\" - {file}\")\n if len(files) > 10:\n print(f\" ... and {len(files) - 10} more files\")\n \n return files", - "gui_code": "from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton, QFileDialog\n\nlayout.addWidget(QLabel('Folder to Organize:', parent))\nwidgets['folder_path'] = QLineEdit(parent)\nwidgets['folder_path'].setPlaceholderText('Select or enter folder path...')\nlayout.addWidget(widgets['folder_path'])\n\nwidgets['browse_btn'] = QPushButton('Browse Folder', parent)\nlayout.addWidget(widgets['browse_btn'])\n\nwidgets['scan_btn'] = QPushButton('Scan Folder', parent)\nlayout.addWidget(widgets['scan_btn'])\n\n# Connect browse button\ndef browse_folder():\n folder = QFileDialog.getExistingDirectory(parent, 'Select Folder to Organize')\n if folder:\n widgets['folder_path'].setText(folder)\n\nwidgets['browse_btn'].clicked.connect(browse_folder)", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'folder_path': widgets['folder_path'].text()\n }\n\ndef set_initial_state(widgets, state):\n widgets['folder_path'].setText(state.get('folder_path', ''))", - "gui_state": { - "folder_path": "" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "file-categorizer", - "title": "File Type Categorizer", - "pos": [450, 150], - "size": [300, 250], - "code": "import os\nfrom typing import Dict, List\n\n@node_entry\ndef categorize_files(files: List[str]) -> Dict[str, List[str]]:\n categories = {\n 'Images': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'],\n 'Documents': ['.pdf', '.doc', '.docx', '.txt', '.rtf', '.odt'],\n 'Spreadsheets': ['.xls', '.xlsx', '.csv', '.ods'],\n 'Audio': ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'],\n 'Video': ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv'],\n 'Archives': ['.zip', '.rar', '.7z', '.tar', '.gz'],\n 'Code': ['.py', '.js', '.html', '.css', '.cpp', '.java', '.c'],\n 'Other': []\n }\n \n result = {cat: [] for cat in categories.keys()}\n \n for file in files:\n file_ext = os.path.splitext(file)[1].lower()\n categorized = False\n \n for category, extensions in categories.items():\n if file_ext in extensions:\n result[category].append(file)\n categorized = True\n break\n \n if not categorized:\n result['Other'].append(file)\n \n # Print summary\n print(\"\\n=== FILE CATEGORIZATION RESULTS ===\")\n for category, file_list in result.items():\n if file_list:\n print(f\"{category}: {len(file_list)} files\")\n for file in file_list[:3]: # Show first 3\n print(f\" - {file}\")\n if len(file_list) > 3:\n print(f\" ... and {len(file_list) - 3} more\")\n \n return result", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "folder-creator", - "title": "Folder Structure Creator", - "pos": [820, 200], - "size": [280, 200], - "code": "import os\nfrom typing import Dict, List\n\n@node_entry\ndef create_folders(base_path: str, categorized_files: Dict[str, List[str]]) -> str:\n if not os.path.exists(base_path):\n return f\"Error: Base path '{base_path}' does not exist\"\n \n organized_folder = os.path.join(base_path, \"Organized_Files\")\n \n try:\n # Create main organized folder\n if not os.path.exists(organized_folder):\n os.makedirs(organized_folder)\n print(f\"Created main folder: {organized_folder}\")\n \n # Create subfolders for each category\n created_folders = []\n for category, files in categorized_files.items():\n if files: # Only create folder if there are files\n category_folder = os.path.join(organized_folder, category)\n if not os.path.exists(category_folder):\n os.makedirs(category_folder)\n created_folders.append(category)\n print(f\"Created subfolder: {category}\")\n \n result = f\"Successfully created organized structure with {len(created_folders)} categories\"\n print(result)\n return result\n \n except Exception as e:\n error_msg = f\"Error creating folders: {str(e)}\"\n print(error_msg)\n return error_msg", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "file-mover", - "title": "File Organizer & Mover", - "pos": [1170, 150], - "size": [320, 300], - "code": "import os\nimport shutil\nfrom typing import Dict, List\n\n@node_entry\ndef organize_files(base_path: str, categorized_files: Dict[str, List[str]], dry_run: bool) -> str:\n organized_folder = os.path.join(base_path, \"Organized_Files\")\n \n if dry_run:\n print(\"\\n=== DRY RUN MODE - NO FILES WILL BE MOVED ===\")\n else:\n print(\"\\n=== ORGANIZING FILES ===\")\n \n moved_count = 0\n errors = []\n \n for category, files in categorized_files.items():\n if not files:\n continue\n \n category_folder = os.path.join(organized_folder, category)\n \n for file in files:\n source_path = os.path.join(base_path, file)\n dest_path = os.path.join(category_folder, file)\n \n try:\n if os.path.exists(source_path):\n if dry_run:\n print(f\"Would move: {file} -> {category}/\")\n else:\n # Handle file name conflicts\n if os.path.exists(dest_path):\n base, ext = os.path.splitext(file)\n counter = 1\n while os.path.exists(dest_path):\n new_name = f\"{base}_{counter}{ext}\"\n dest_path = os.path.join(category_folder, new_name)\n counter += 1\n \n shutil.move(source_path, dest_path)\n print(f\"Moved: {file} -> {category}/\")\n \n moved_count += 1\n else:\n errors.append(f\"File not found: {file}\")\n \n except Exception as e:\n errors.append(f\"Error moving {file}: {str(e)}\")\n \n # Summary\n action = \"would be moved\" if dry_run else \"moved\"\n result = f\"Successfully {action}: {moved_count} files\"\n if errors:\n result += f\"\\nErrors: {len(errors)}\"\n for error in errors[:5]: # Show first 5 errors\n result += f\"\\n - {error}\"\n \n print(f\"\\n=== ORGANIZATION COMPLETE ===\")\n print(result)\n return result", - "gui_code": "from PySide6.QtWidgets import QLabel, QCheckBox, QPushButton, QTextEdit\nfrom PySide6.QtCore import Qt\n\nwidgets['dry_run'] = QCheckBox('Dry Run (Preview Only)', parent)\nwidgets['dry_run'].setChecked(True)\nwidgets['dry_run'].setToolTip('Check this to preview changes without actually moving files')\nlayout.addWidget(widgets['dry_run'])\n\nwidgets['organize_btn'] = QPushButton('Start Organization', parent)\nlayout.addWidget(widgets['organize_btn'])\n\nwidgets['result_display'] = QTextEdit(parent)\nwidgets['result_display'].setMinimumHeight(150)\nwidgets['result_display'].setReadOnly(True)\nwidgets['result_display'].setPlainText('Click \"Start Organization\" to begin...')\nlayout.addWidget(widgets['result_display'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'dry_run': widgets['dry_run'].isChecked()\n }\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['result_display'].setPlainText(result)\n\ndef set_initial_state(widgets, state):\n widgets['dry_run'].setChecked(state.get('dry_run', True))", - "gui_state": { - "dry_run": true - }, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "folder-scanner", - "start_pin_name": "exec_out", - "end_node_uuid": "file-categorizer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "folder-scanner", - "start_pin_name": "output_1", - "end_node_uuid": "file-categorizer", - "end_pin_name": "files" - }, - { - "start_node_uuid": "file-categorizer", - "start_pin_name": "exec_out", - "end_node_uuid": "folder-creator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "folder-scanner", - "start_pin_name": "folder_path", - "end_node_uuid": "folder-creator", - "end_pin_name": "base_path" - }, - { - "start_node_uuid": "file-categorizer", - "start_pin_name": "output_1", - "end_node_uuid": "folder-creator", - "end_pin_name": "categorized_files" - }, - { - "start_node_uuid": "folder-creator", - "start_pin_name": "exec_out", - "end_node_uuid": "file-mover", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "folder-scanner", - "start_pin_name": "folder_path", - "end_node_uuid": "file-mover", - "end_pin_name": "base_path" - }, - { - "start_node_uuid": "file-categorizer", - "start_pin_name": "output_1", - "end_node_uuid": "file-mover", - "end_pin_name": "categorized_files" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/file_organizer_automation.md b/examples/file_organizer_automation.md new file mode 100644 index 0000000..f916454 --- /dev/null +++ b/examples/file_organizer_automation.md @@ -0,0 +1,417 @@ +# File Organizer Automation + +An intelligent file organization system that automatically scans directories, categorizes files by type and properties, applies organizational rules, and provides detailed operation reports. This workflow demonstrates automated file management capabilities including pattern recognition, rule-based categorization, and bulk file operations with comprehensive logging and verification. + +The system showcases enterprise-level file management automation where large directories can be systematically organized according to customizable rules, with real-time feedback and detailed reporting. Each component handles a specific aspect of file organization, from initial scanning through intelligent categorization to final organization with comprehensive audit trails. + +## Node: Folder Scanner (ID: folder-scanner) + +Scans a specified directory path using os.listdir() and os.path.isfile() to identify all files (excluding subdirectories) in the target folder. Takes a folder path string input and returns a List[str] containing just the filenames, not full paths. Includes error handling for non-existent directories. + +Implements basic file discovery by iterating through directory contents and filtering for files only. Displays up to 10 sample filenames in console output for verification, with count summary for larger directories. No recursive scanning - operates only on the immediate directory level. + +Provides the base file list for downstream categorization and organization operations. The GUI includes a folder browser dialog for path selection and displays the selected path in a text field for manual editing if needed. + +### Metadata + +```json +{ + "uuid": "folder-scanner", + "title": "Folder Scanner", + "pos": [ + 100.0, + 200.0 + ], + "size": [ + 250, + 182 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "folder_path": "" + } +} +``` + +### Logic + +```python +import os +from typing import List + +@node_entry +def scan_folder(folder_path: str) -> List[str]: + if not os.path.exists(folder_path): + print(f"Error: Folder '{folder_path}' does not exist") + return [] + + files = [] + for item in os.listdir(folder_path): + item_path = os.path.join(folder_path, item) + if os.path.isfile(item_path): + files.append(item) + + print(f"Found {len(files)} files in '{folder_path}'") + for file in files[:10]: # Show first 10 + print(f" - {file}") + if len(files) > 10: + print(f" ... and {len(files) - 10} more files") + + return files +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton, QFileDialog + +layout.addWidget(QLabel('Folder to Organize:', parent)) +widgets['folder_path'] = QLineEdit(parent) +widgets['folder_path'].setPlaceholderText('Select or enter folder path...') +layout.addWidget(widgets['folder_path']) + +widgets['browse_btn'] = QPushButton('Browse Folder', parent) +layout.addWidget(widgets['browse_btn']) + +widgets['scan_btn'] = QPushButton('Scan Folder', parent) +layout.addWidget(widgets['scan_btn']) + +# Connect browse button +def browse_folder(): + folder = QFileDialog.getExistingDirectory(parent, 'Select Folder to Organize') + if folder: + widgets['folder_path'].setText(folder) + +widgets['browse_btn'].clicked.connect(browse_folder) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'folder_path': widgets['folder_path'].text() + } + +def set_initial_state(widgets, state): + widgets['folder_path'].setText(state.get('folder_path', '')) +``` + + +## Node: File Type Categorizer (ID: file-categorizer) + +Categorizes files by extension using predefined mappings for Images (.jpg, .png, etc.), Documents (.pdf, .doc, etc.), Spreadsheets (.xls, .csv, etc.), Audio (.mp3, .wav, etc.), Video (.mp4, .avi, etc.), Archives (.zip, .rar, etc.), and Code (.py, .js, etc.). Uses os.path.splitext() to extract file extensions and case-insensitive matching. + +Processes List[str] input and returns Dict[str, List[str]] where keys are category names and values are lists of filenames belonging to each category. Files with unrecognized extensions are placed in an 'Other' category. Extension matching is exact - no fuzzy matching or MIME type detection. + +Provides categorization statistics in console output showing file counts per category and sample filenames. Categories with zero files are included in the output dictionary but remain empty lists. + +### Metadata + +```json +{ + "uuid": "file-categorizer", + "title": "File Type Categorizer", + "pos": [ + 450.0, + 150.0 + ], + "size": [ + 250, + 118 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import os +from typing import Dict, List + +@node_entry +def categorize_files(files: List[str]) -> Dict[str, List[str]]: + categories = { + 'Images': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.svg'], + 'Documents': ['.pdf', '.doc', '.docx', '.txt', '.rtf', '.odt'], + 'Spreadsheets': ['.xls', '.xlsx', '.csv', '.ods'], + 'Audio': ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'], + 'Video': ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv'], + 'Archives': ['.zip', '.rar', '.7z', '.tar', '.gz'], + 'Code': ['.py', '.js', '.html', '.css', '.cpp', '.java', '.c'], + 'Other': [] + } + + result = {cat: [] for cat in categories.keys()} + + for file in files: + file_ext = os.path.splitext(file)[1].lower() + categorized = False + + for category, extensions in categories.items(): + if file_ext in extensions: + result[category].append(file) + categorized = True + break + + if not categorized: + result['Other'].append(file) + + # Print summary + print("\n=== FILE CATEGORIZATION RESULTS ===") + for category, file_list in result.items(): + if file_list: + print(f"{category}: {len(file_list)} files") + for file in file_list[:3]: # Show first 3 + print(f" - {file}") + if len(file_list) > 3: + print(f" ... and {len(file_list) - 3} more") + + return result +``` + + +## Node: Folder Structure Creator (ID: folder-creator) + +Creates directory structure for file organization by creating an 'Organized_Files' folder in the base path, then creating subfolders for each non-empty category. Uses os.makedirs() with existence checking to avoid errors when folders already exist. + +Takes base_path string and categorized_files Dict[str, List[str]] as inputs. Only creates subfolders for categories that contain files - empty categories are skipped. Returns status string indicating success or failure with folder creation count. + +Includes error handling for permission issues and invalid paths. Console output shows each folder creation action. The organized folder structure becomes the target for the subsequent file moving operations. + +### Metadata + +```json +{ + "uuid": "folder-creator", + "title": "Folder Structure Creator", + "pos": [ + 820.0, + 200.0 + ], + "size": [ + 250, + 143 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import os +from typing import Dict, List + +@node_entry +def create_folders(base_path: str, categorized_files: Dict[str, List[str]]) -> str: + if not os.path.exists(base_path): + return f"Error: Base path '{base_path}' does not exist" + + organized_folder = os.path.join(base_path, "Organized_Files") + + try: + # Create main organized folder + if not os.path.exists(organized_folder): + os.makedirs(organized_folder) + print(f"Created main folder: {organized_folder}") + + # Create subfolders for each category + created_folders = [] + for category, files in categorized_files.items(): + if files: # Only create folder if there are files + category_folder = os.path.join(organized_folder, category) + if not os.path.exists(category_folder): + os.makedirs(category_folder) + created_folders.append(category) + print(f"Created subfolder: {category}") + + result = f"Successfully created organized structure with {len(created_folders)} categories" + print(result) + return result + + except Exception as e: + error_msg = f"Error creating folders: {str(e)}" + print(error_msg) + return error_msg +``` + + +## Node: File Organizer & Mover (ID: file-mover) + +Moves files from the source directory to categorized subfolders within the 'Organized_Files' directory using shutil.move(). Implements dry-run mode for safe preview without actual file operations. Handles filename conflicts by appending numeric suffixes (_1, _2, etc.) to duplicate names. + +Processes base_path, categorized_files dictionary, and dry_run boolean flag. In dry-run mode, only prints intended actions without moving files. In live mode, performs actual file moves with error handling for missing files and permission issues. Returns summary string with move counts and any errors encountered. + +Includes GUI checkbox for dry-run toggle and text area for displaying operation results. Error handling captures and reports up to 5 specific error messages. File operations are performed sequentially with individual error isolation to prevent batch failures. + +### Metadata + +```json +{ + "uuid": "file-mover", + "title": "File Organizer & Mover", + "pos": [ + 1170.0, + 150.0 + ], + "size": [ + 276, + 418 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": { + "dry_run": true + } +} +``` + +### Logic + +```python +import os +import shutil +from typing import Dict, List + +@node_entry +def organize_files(base_path: str, categorized_files: Dict[str, List[str]], dry_run: bool) -> str: + organized_folder = os.path.join(base_path, "Organized_Files") + + if dry_run: + print("\n=== DRY RUN MODE - NO FILES WILL BE MOVED ===") + else: + print("\n=== ORGANIZING FILES ===") + + moved_count = 0 + errors = [] + + for category, files in categorized_files.items(): + if not files: + continue + + category_folder = os.path.join(organized_folder, category) + + for file in files: + source_path = os.path.join(base_path, file) + dest_path = os.path.join(category_folder, file) + + try: + if os.path.exists(source_path): + if dry_run: + print(f"Would move: {file} -> {category}/") + else: + # Handle file name conflicts + if os.path.exists(dest_path): + base, ext = os.path.splitext(file) + counter = 1 + while os.path.exists(dest_path): + new_name = f"{base}_{counter}{ext}" + dest_path = os.path.join(category_folder, new_name) + counter += 1 + + shutil.move(source_path, dest_path) + print(f"Moved: {file} -> {category}/") + + moved_count += 1 + else: + errors.append(f"File not found: {file}") + + except Exception as e: + errors.append(f"Error moving {file}: {str(e)}") + + # Summary + action = "would be moved" if dry_run else "moved" + result = f"Successfully {action}: {moved_count} files" + if errors: + result += f"\nErrors: {len(errors)}" + for error in errors[:5]: # Show first 5 errors + result += f"\n - {error}" + + print(f"\n=== ORGANIZATION COMPLETE ===") + print(result) + return result +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QCheckBox, QPushButton, QTextEdit +from PySide6.QtCore import Qt + +widgets['dry_run'] = QCheckBox('Dry Run (Preview Only)', parent) +widgets['dry_run'].setChecked(True) +widgets['dry_run'].setToolTip('Check this to preview changes without actually moving files') +layout.addWidget(widgets['dry_run']) + +widgets['organize_btn'] = QPushButton('Start Organization', parent) +layout.addWidget(widgets['organize_btn']) + +widgets['result_display'] = QTextEdit(parent) +widgets['result_display'].setMinimumHeight(150) +widgets['result_display'].setReadOnly(True) +widgets['result_display'].setPlainText('Click "Start Organization" to begin...') +layout.addWidget(widgets['result_display']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'dry_run': widgets['dry_run'].isChecked() + } + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['result_display'].setPlainText(result) + +def set_initial_state(widgets, state): + widgets['dry_run'].setChecked(state.get('dry_run', True)) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "file-categorizer", + "start_pin_name": "exec_out", + "end_node_uuid": "folder-creator", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "file-categorizer", + "start_pin_name": "output_1", + "end_node_uuid": "folder-creator", + "end_pin_name": "categorized_files" + }, + { + "start_node_uuid": "folder-creator", + "start_pin_name": "exec_out", + "end_node_uuid": "file-mover", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "file-categorizer", + "start_pin_name": "output_1", + "end_node_uuid": "file-mover", + "end_pin_name": "categorized_files" + } +] +``` diff --git a/examples/interactive_game_engine.json b/examples/interactive_game_engine.json deleted file mode 100644 index e59f385..0000000 --- a/examples/interactive_game_engine.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "nodes": [ - { - "uuid": "game-start", - "title": "Game Start", - "pos": [100, 200], - "size": [250, 150], - "code": "@node_entry\ndef start_game() -> str:\n print(\"=== ADVENTURE GAME STARTED ===\")\n print(\"You find yourself at a crossroads...\")\n return \"Welcome, adventurer!\"", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#1e7e34", - "body": "#155724" - } - }, - { - "uuid": "player-choice", - "title": "Player Choice Hub", - "pos": [450, 100], - "size": [300, 200], - "code": "@node_entry\ndef handle_choice(welcome_msg: str, choice: str) -> str:\n print(f\"Player chose: {choice}\")\n return f\"Choice: {choice}\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QComboBox, QPushButton\n\nlayout.addWidget(QLabel('Choose your path:', parent))\nwidgets['choice'] = QComboBox(parent)\nwidgets['choice'].addItems(['Forest Path', 'Mountain Trail', 'Cave Entrance', 'River Crossing'])\nlayout.addWidget(widgets['choice'])\n\nwidgets['execute_btn'] = QPushButton('Make Choice', parent)\nlayout.addWidget(widgets['execute_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'choice': widgets['choice'].currentText()\n }\n\ndef set_initial_state(widgets, state):\n if 'choice' in state:\n widgets['choice'].setCurrentText(state['choice'])", - "gui_state": { - "choice": "Forest Path" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "condition-checker", - "title": "Condition Router", - "pos": [850, 200], - "size": [250, 150], - "code": "@node_entry\ndef route_choice(player_choice: str) -> str:\n choice = player_choice.split(': ')[1] if ': ' in player_choice else player_choice\n \n if choice == 'Forest Path':\n return 'forest'\n elif choice == 'Mountain Trail':\n return 'mountain' \n elif choice == 'Cave Entrance':\n return 'cave'\n else:\n return 'river'", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "forest-encounter", - "title": "Forest Adventure", - "pos": [1200, 50], - "size": [280, 200], - "code": "@node_entry\ndef forest_adventure(route: str) -> str:\n if route == 'forest':\n import random\n encounters = [\n \"You meet a friendly fairy who gives you a magic potion!\",\n \"A wise old tree shares ancient knowledge with you.\",\n \"You discover a hidden treasure chest full of gold!\",\n \"A pack of wolves surrounds you, but they're actually friendly!\"\n ]\n result = random.choice(encounters)\n print(f\"Forest: {result}\")\n return result\n return \"This path is not for you.\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit\nfrom PySide6.QtCore import Qt\n\nwidgets['result_text'] = QTextEdit(parent)\nwidgets['result_text'].setMinimumHeight(120)\nwidgets['result_text'].setReadOnly(True)\nwidgets['result_text'].setPlainText('Waiting for forest adventure...')\nlayout.addWidget(widgets['result_text'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['result_text'].setPlainText(f'FOREST ENCOUNTER:\\n\\n{result}')", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "mountain-encounter", - "title": "Mountain Challenge", - "pos": [1200, 300], - "size": [280, 200], - "code": "@node_entry\ndef mountain_adventure(route: str) -> str:\n if route == 'mountain':\n import random\n challenges = [\n \"You climb to a peak and see a magnificent dragon!\",\n \"An avalanche blocks your path, but you find a secret tunnel.\",\n \"A mountain goat guides you to a hidden monastery.\",\n \"You discover ancient ruins with mysterious symbols.\"\n ]\n result = random.choice(challenges)\n print(f\"Mountain: {result}\")\n return result\n return \"This path is not for you.\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit\nfrom PySide6.QtCore import Qt\n\nwidgets['result_text'] = QTextEdit(parent)\nwidgets['result_text'].setMinimumHeight(120)\nwidgets['result_text'].setReadOnly(True)\nwidgets['result_text'].setPlainText('Waiting for mountain adventure...')\nlayout.addWidget(widgets['result_text'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['result_text'].setPlainText(f'MOUNTAIN CHALLENGE:\\n\\n{result}')", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - }, - { - "uuid": "cave-encounter", - "title": "Cave Exploration", - "pos": [1200, 550], - "size": [280, 200], - "code": "@node_entry\ndef cave_adventure(route: str) -> str:\n if route == 'cave':\n import random\n mysteries = [\n \"You find an underground lake with glowing fish!\",\n \"Ancient cave paintings tell the story of your quest.\",\n \"A sleeping dragon guards a pile of magical artifacts.\",\n \"Crystal formations create beautiful music in the wind.\"\n ]\n result = random.choice(mysteries)\n print(f\"Cave: {result}\")\n return result\n return \"This path is not for you.\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit\nfrom PySide6.QtCore import Qt\n\nwidgets['result_text'] = QTextEdit(parent)\nwidgets['result_text'].setMinimumHeight(120)\nwidgets['result_text'].setReadOnly(True)\nwidgets['result_text'].setPlainText('Waiting for cave exploration...')\nlayout.addWidget(widgets['result_text'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['result_text'].setPlainText(f'CAVE EXPLORATION:\\n\\n{result}')", - "gui_state": {}, - "colors": { - "title": "#6f42c1", - "body": "#563d7c" - } - }, - { - "uuid": "river-encounter", - "title": "River Adventure", - "pos": [1200, 800], - "size": [280, 200], - "code": "@node_entry\ndef river_adventure(route: str) -> str:\n if route == 'river':\n import random\n adventures = [\n \"A magical boat appears to ferry you across!\",\n \"Mermaids surface and offer you a quest.\",\n \"You spot a message in a bottle floating downstream.\",\n \"A wise old turtle shares secrets of the river.\"\n ]\n result = random.choice(adventures)\n print(f\"River: {result}\")\n return result\n return \"This path is not for you.\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit\nfrom PySide6.QtCore import Qt\n\nwidgets['result_text'] = QTextEdit(parent)\nwidgets['result_text'].setMinimumHeight(120)\nwidgets['result_text'].setReadOnly(True)\nwidgets['result_text'].setPlainText('Waiting for river adventure...')\nlayout.addWidget(widgets['result_text'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['result_text'].setPlainText(f'RIVER ADVENTURE:\\n\\n{result}')", - "gui_state": {}, - "colors": { - "title": "#17a2b8", - "body": "#117a8b" - } - }, - { - "uuid": "game-end", - "title": "Adventure Complete", - "pos": [1600, 400], - "size": [300, 250], - "code": "@node_entry\ndef end_adventure(adventure_result: str) -> str:\n print(\"\\n=== ADVENTURE COMPLETED ===\")\n print(f\"Your adventure: {adventure_result}\")\n print(\"Thank you for playing!\")\n return f\"Quest completed! {adventure_result}\"", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Adventure Summary', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['summary_text'] = QTextEdit(parent)\nwidgets['summary_text'].setMinimumHeight(150)\nwidgets['summary_text'].setReadOnly(True)\nwidgets['summary_text'].setPlainText('Complete your adventure to see the summary...')\nlayout.addWidget(widgets['summary_text'])\n\nwidgets['play_again_btn'] = QPushButton('Play Again', parent)\nlayout.addWidget(widgets['play_again_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n result = outputs.get('output_1', 'No result')\n widgets['summary_text'].setPlainText(f'{result}\\n\\nYour adventure has concluded. What path will you choose next time?')", - "gui_state": {}, - "colors": { - "title": "#ffc107", - "body": "#e0a800" - } - } - ], - "connections": [ - { - "start_node_uuid": "game-start", - "start_pin_name": "exec_out", - "end_node_uuid": "player-choice", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "game-start", - "start_pin_name": "output_1", - "end_node_uuid": "player-choice", - "end_pin_name": "welcome_msg" - }, - { - "start_node_uuid": "player-choice", - "start_pin_name": "exec_out", - "end_node_uuid": "condition-checker", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "player-choice", - "start_pin_name": "output_1", - "end_node_uuid": "condition-checker", - "end_pin_name": "player_choice" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "exec_out", - "end_node_uuid": "forest-encounter", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "exec_out", - "end_node_uuid": "mountain-encounter", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "exec_out", - "end_node_uuid": "cave-encounter", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "exec_out", - "end_node_uuid": "river-encounter", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "output_1", - "end_node_uuid": "forest-encounter", - "end_pin_name": "route" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "output_1", - "end_node_uuid": "mountain-encounter", - "end_pin_name": "route" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "output_1", - "end_node_uuid": "cave-encounter", - "end_pin_name": "route" - }, - { - "start_node_uuid": "condition-checker", - "start_pin_name": "output_1", - "end_node_uuid": "river-encounter", - "end_pin_name": "route" - }, - { - "start_node_uuid": "forest-encounter", - "start_pin_name": "exec_out", - "end_node_uuid": "game-end", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "mountain-encounter", - "start_pin_name": "exec_out", - "end_node_uuid": "game-end", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "cave-encounter", - "start_pin_name": "exec_out", - "end_node_uuid": "game-end", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "river-encounter", - "start_pin_name": "exec_out", - "end_node_uuid": "game-end", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "forest-encounter", - "start_pin_name": "output_1", - "end_node_uuid": "game-end", - "end_pin_name": "adventure_result" - }, - { - "start_node_uuid": "mountain-encounter", - "start_pin_name": "output_1", - "end_node_uuid": "game-end", - "end_pin_name": "adventure_result" - }, - { - "start_node_uuid": "cave-encounter", - "start_pin_name": "output_1", - "end_node_uuid": "game-end", - "end_pin_name": "adventure_result" - }, - { - "start_node_uuid": "river-encounter", - "start_pin_name": "output_1", - "end_node_uuid": "game-end", - "end_pin_name": "adventure_result" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/interactive_game_engine.md b/examples/interactive_game_engine.md new file mode 100644 index 0000000..9638d36 --- /dev/null +++ b/examples/interactive_game_engine.md @@ -0,0 +1,607 @@ +# Interactive Game Engine + +A branching narrative game system demonstrating conditional logic flow control through interactive choice selection. Implements a choose-your-own-adventure structure with GUI-based player input, string-based routing logic, and randomized encounter outcomes across multiple parallel execution paths. + +## Node: Game Start (ID: game-start) + +Initializes the game session by returning a welcome string and printing startup messages to console. Simple entry point node with no inputs that outputs "Welcome, adventurer!" string for downstream processing. Serves as the execution trigger for the entire game flow. + +### Metadata + +```json +{ + "uuid": "game-start", + "title": "Game Start", + "pos": [ + 100.0, + 200.0 + ], + "size": [ + 250, + 118 + ], + "colors": { + "title": "#1e7e34", + "body": "#155724" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def start_game() -> str: + print("=== ADVENTURE GAME STARTED ===") + print("You find yourself at a crossroads...") + return "Welcome, adventurer!" +``` + + +## Node: Player Choice Hub (ID: player-choice) + +Provides player input interface using QComboBox with four predefined path options: Forest Path, Mountain Trail, Cave Entrance, River Crossing. Takes welcome message string as input and outputs formatted choice string "Choice: {selection}". GUI includes dropdown selector and execution button for choice confirmation. + +Implements basic state management for choice persistence and processes user selection through get_values() function. Choice selection is validated through currentText() method and formatted into standardized output string for downstream routing logic. + +### Metadata + +```json +{ + "uuid": "player-choice", + "title": "Player Choice Hub", + "pos": [ + 467.49006250000014, + 192.69733124999993 + ], + "size": [ + 250, + 219 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "choice": "Forest Path" + } +} +``` + +### Logic + +```python +@node_entry +def handle_choice(welcome_msg: str, choice: str) -> str: + print(f"Player chose: {choice}") + return f"Choice: {choice}" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QComboBox, QPushButton + +layout.addWidget(QLabel('Choose your path:', parent)) +widgets['choice'] = QComboBox(parent) +widgets['choice'].addItems(['Forest Path', 'Mountain Trail', 'Cave Entrance', 'River Crossing']) +layout.addWidget(widgets['choice']) + +widgets['execute_btn'] = QPushButton('Make Choice', parent) +layout.addWidget(widgets['execute_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'choice': widgets['choice'].currentText() + } + +def set_initial_state(widgets, state): + if 'choice' in state: + widgets['choice'].setCurrentText(state['choice']) +``` + + +## Node: Condition Router (ID: condition-checker) + +Parses formatted choice string using string.split() to extract route identifier, then maps choice text to single-word route codes: 'forest', 'mountain', 'cave', or 'river'. Uses if-elif-else conditional logic to convert human-readable choice names into routing tokens. + +Handles input format "Choice: {path_name}" by splitting on ': ' delimiter and extracting the second element. Returns lowercase route identifier strings that are consumed by multiple downstream adventure nodes for conditional execution. + +### Metadata + +```json +{ + "uuid": "condition-checker", + "title": "Condition Router", + "pos": [ + 850.0, + 200.0 + ], + "size": [ + 250, + 118 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def route_choice(player_choice: str) -> str: + choice = player_choice.split(': ')[1] if ': ' in player_choice else player_choice + + if choice == 'Forest Path': + return 'forest' + elif choice == 'Mountain Trail': + return 'mountain' + elif choice == 'Cave Entrance': + return 'cave' + else: + return 'river' +``` + + +## Node: Forest Adventure (ID: forest-encounter) + +Executes forest-specific encounter logic when route input equals 'forest'. Uses random.choice() to select from four predefined encounter strings stored in a list. Returns randomized adventure outcome string or default rejection message for non-forest routes. + +Includes QTextEdit GUI component for displaying encounter results with read-only formatting. Updates display through set_values() function that formats output as "FOREST ENCOUNTER:\n\n{result}". Adventure outcomes are deterministic but randomly selected on each execution. + +### Metadata + +```json +{ + "uuid": "forest-encounter", + "title": "Forest Adventure", + "pos": [ + 1269.96025, + -168.62578124999987 + ], + "size": [ + 276, + 310 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def forest_adventure(route: str) -> str: + if route == 'forest': + import random + encounters = [ + "You meet a friendly fairy who gives you a magic potion!", + "A wise old tree shares ancient knowledge with you.", + "You discover a hidden treasure chest full of gold!", + "A pack of wolves surrounds you, but they're actually friendly!" + ] + result = random.choice(encounters) + print(f"Forest: {result}") + return result + return "This path is not for you." +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit +from PySide6.QtCore import Qt + +widgets['result_text'] = QTextEdit(parent) +widgets['result_text'].setMinimumHeight(120) +widgets['result_text'].setReadOnly(True) +widgets['result_text'].setPlainText('Waiting for forest adventure...') +layout.addWidget(widgets['result_text']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['result_text'].setPlainText(f'FOREST ENCOUNTER:\n\n{result}') +``` + + +## Node: Mountain Challenge (ID: mountain-encounter) + +Executes mountain-specific encounter logic when route input equals 'mountain'. Uses random.choice() to select from four predefined challenge strings. Returns randomized adventure outcome string or default rejection message for non-mountain routes. + +Includes QTextEdit GUI component for displaying challenge results with read-only formatting. Updates display through set_values() function that formats output as "MOUNTAIN CHALLENGE:\n\n{result}". Challenge outcomes are randomly selected from predefined list on each execution. + +### Metadata + +```json +{ + "uuid": "mountain-encounter", + "title": "Mountain Challenge", + "pos": [ + 1353.91255, + 193.31061875000012 + ], + "size": [ + 276, + 310 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def mountain_adventure(route: str) -> str: + if route == 'mountain': + import random + challenges = [ + "You climb to a peak and see a magnificent dragon!", + "An avalanche blocks your path, but you find a secret tunnel.", + "A mountain goat guides you to a hidden monastery.", + "You discover ancient ruins with mysterious symbols." + ] + result = random.choice(challenges) + print(f"Mountain: {result}") + return result + return "This path is not for you." +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit +from PySide6.QtCore import Qt + +widgets['result_text'] = QTextEdit(parent) +widgets['result_text'].setMinimumHeight(120) +widgets['result_text'].setReadOnly(True) +widgets['result_text'].setPlainText('Waiting for mountain adventure...') +layout.addWidget(widgets['result_text']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['result_text'].setPlainText(f'MOUNTAIN CHALLENGE:\n\n{result}') +``` + + +## Node: Cave Exploration (ID: cave-encounter) + +Executes cave-specific encounter logic when route input equals 'cave'. Uses random.choice() to select from four predefined mystery strings. Returns randomized exploration outcome string or default rejection message for non-cave routes. + +Includes QTextEdit GUI component for displaying exploration results with read-only formatting. Updates display through set_values() function that formats output as "CAVE EXPLORATION:\n\n{result}". Mystery outcomes are randomly selected from predefined list on each execution. + +### Metadata + +```json +{ + "uuid": "cave-encounter", + "title": "Cave Exploration", + "pos": [ + 1252.4701874999998, + 541.2549687499998 + ], + "size": [ + 276, + 310 + ], + "colors": { + "title": "#6f42c1", + "body": "#563d7c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def cave_adventure(route: str) -> str: + if route == 'cave': + import random + mysteries = [ + "You find an underground lake with glowing fish!", + "Ancient cave paintings tell the story of your quest.", + "A sleeping dragon guards a pile of magical artifacts.", + "Crystal formations create beautiful music in the wind." + ] + result = random.choice(mysteries) + print(f"Cave: {result}") + return result + return "This path is not for you." +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit +from PySide6.QtCore import Qt + +widgets['result_text'] = QTextEdit(parent) +widgets['result_text'].setMinimumHeight(120) +widgets['result_text'].setReadOnly(True) +widgets['result_text'].setPlainText('Waiting for cave exploration...') +layout.addWidget(widgets['result_text']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['result_text'].setPlainText(f'CAVE EXPLORATION:\n\n{result}') +``` + + +## Node: River Adventure (ID: river-encounter) + +Executes river-specific encounter logic when route input equals 'river'. Uses random.choice() to select from four predefined adventure strings. Returns randomized water-based outcome string or default rejection message for non-river routes. + +Includes QTextEdit GUI component for displaying adventure results with read-only formatting. Updates display through set_values() function that formats output as "RIVER ADVENTURE:\n\n{result}". Adventure outcomes are randomly selected from predefined list on each execution. + +### Metadata + +```json +{ + "uuid": "river-encounter", + "title": "River Adventure", + "pos": [ + 1103.8046562500003, + 911.9364000000002 + ], + "size": [ + 276, + 310 + ], + "colors": { + "title": "#17a2b8", + "body": "#117a8b" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def river_adventure(route: str) -> str: + if route == 'river': + import random + adventures = [ + "A magical boat appears to ferry you across!", + "Mermaids surface and offer you a quest.", + "You spot a message in a bottle floating downstream.", + "A wise old turtle shares secrets of the river." + ] + result = random.choice(adventures) + print(f"River: {result}") + return result + return "This path is not for you." +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit +from PySide6.QtCore import Qt + +widgets['result_text'] = QTextEdit(parent) +widgets['result_text'].setMinimumHeight(120) +widgets['result_text'].setReadOnly(True) +widgets['result_text'].setPlainText('Waiting for river adventure...') +layout.addWidget(widgets['result_text']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['result_text'].setPlainText(f'RIVER ADVENTURE:\n\n{result}') +``` + + +## Node: Adventure Complete (ID: game-end) + +Finalizes the game session by taking adventure result string input and formatting it into completion message "Quest completed! {adventure_result}". Prints summary information to console and displays formatted results in QTextEdit GUI component. + +Includes "Play Again" button for game restart functionality and displays completion message with additional narrative text. Serves as the terminal node for all adventure paths, consolidating various encounter outcomes into final game state. + +### Metadata + +```json +{ + "uuid": "game-end", + "title": "Adventure Complete", + "pos": [ + 1834.3668375000002, + -12.765474999999753 + ], + "size": [ + 276, + 372 + ], + "colors": { + "title": "#ffc107", + "body": "#e0a800" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def end_adventure(adventure_result: str) -> str: + print("\n=== ADVENTURE COMPLETED ===") + print(f"Your adventure: {adventure_result}") + print("Thank you for playing!") + return f"Quest completed! {adventure_result}" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Adventure Summary', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['summary_text'] = QTextEdit(parent) +widgets['summary_text'].setMinimumHeight(150) +widgets['summary_text'].setReadOnly(True) +widgets['summary_text'].setPlainText('Complete your adventure to see the summary...') +layout.addWidget(widgets['summary_text']) + +widgets['play_again_btn'] = QPushButton('Play Again', parent) +layout.addWidget(widgets['play_again_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + result = outputs.get('output_1', 'No result') + widgets['summary_text'].setPlainText(f'{result}\n\nYour adventure has concluded. What path will you choose next time?') +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "game-start", + "start_pin_name": "exec_out", + "end_node_uuid": "player-choice", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "game-start", + "start_pin_name": "output_1", + "end_node_uuid": "player-choice", + "end_pin_name": "welcome_msg" + }, + { + "start_node_uuid": "player-choice", + "start_pin_name": "exec_out", + "end_node_uuid": "condition-checker", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "player-choice", + "start_pin_name": "output_1", + "end_node_uuid": "condition-checker", + "end_pin_name": "player_choice" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "exec_out", + "end_node_uuid": "forest-encounter", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "exec_out", + "end_node_uuid": "mountain-encounter", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "exec_out", + "end_node_uuid": "cave-encounter", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "exec_out", + "end_node_uuid": "river-encounter", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "output_1", + "end_node_uuid": "forest-encounter", + "end_pin_name": "route" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "output_1", + "end_node_uuid": "mountain-encounter", + "end_pin_name": "route" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "output_1", + "end_node_uuid": "cave-encounter", + "end_pin_name": "route" + }, + { + "start_node_uuid": "condition-checker", + "start_pin_name": "output_1", + "end_node_uuid": "river-encounter", + "end_pin_name": "route" + }, + { + "start_node_uuid": "forest-encounter", + "start_pin_name": "exec_out", + "end_node_uuid": "game-end", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "forest-encounter", + "start_pin_name": "output_1", + "end_node_uuid": "game-end", + "end_pin_name": "adventure_result" + } +] +``` diff --git a/examples/password_generator_tool.json b/examples/password_generator_tool.json deleted file mode 100644 index c5ebc38..0000000 --- a/examples/password_generator_tool.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "nodes": [ - { - "uuid": "config-input", - "title": "Password Configuration", - "pos": [100, 200], - "size": [300, 250], - "code": "from typing import Tuple\n\n@node_entry\ndef configure_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> Tuple[int, bool, bool, bool, bool]:\n print(f\"Password config: {length} chars, Upper: {include_uppercase}, Lower: {include_lowercase}, Numbers: {include_numbers}, Symbols: {include_symbols}\")\n return length, include_uppercase, include_lowercase, include_numbers, include_symbols", - "gui_code": "from PySide6.QtWidgets import QLabel, QSpinBox, QCheckBox, QPushButton\n\nlayout.addWidget(QLabel('Password Length:', parent))\nwidgets['length'] = QSpinBox(parent)\nwidgets['length'].setRange(4, 128)\nwidgets['length'].setValue(12)\nlayout.addWidget(widgets['length'])\n\nwidgets['uppercase'] = QCheckBox('Include Uppercase (A-Z)', parent)\nwidgets['uppercase'].setChecked(True)\nlayout.addWidget(widgets['uppercase'])\n\nwidgets['lowercase'] = QCheckBox('Include Lowercase (a-z)', parent)\nwidgets['lowercase'].setChecked(True)\nlayout.addWidget(widgets['lowercase'])\n\nwidgets['numbers'] = QCheckBox('Include Numbers (0-9)', parent)\nwidgets['numbers'].setChecked(True)\nlayout.addWidget(widgets['numbers'])\n\nwidgets['symbols'] = QCheckBox('Include Symbols (!@#$%)', parent)\nwidgets['symbols'].setChecked(False)\nlayout.addWidget(widgets['symbols'])\n\nwidgets['generate_btn'] = QPushButton('Generate Password', parent)\nlayout.addWidget(widgets['generate_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'length': widgets['length'].value(),\n 'include_uppercase': widgets['uppercase'].isChecked(),\n 'include_lowercase': widgets['lowercase'].isChecked(),\n 'include_numbers': widgets['numbers'].isChecked(),\n 'include_symbols': widgets['symbols'].isChecked()\n }\n\ndef set_initial_state(widgets, state):\n widgets['length'].setValue(state.get('length', 12))\n widgets['uppercase'].setChecked(state.get('include_uppercase', True))\n widgets['lowercase'].setChecked(state.get('include_lowercase', True))\n widgets['numbers'].setChecked(state.get('include_numbers', True))\n widgets['symbols'].setChecked(state.get('include_symbols', False))", - "gui_state": { - "length": 12, - "include_uppercase": true, - "include_lowercase": true, - "include_numbers": true, - "include_symbols": false - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "password-generator", - "title": "Password Generator Engine", - "pos": [500, 200], - "size": [280, 150], - "code": "import random\nimport string\n\n@node_entry\ndef generate_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> str:\n charset = ''\n \n if include_uppercase:\n charset += string.ascii_uppercase\n if include_lowercase:\n charset += string.ascii_lowercase\n if include_numbers:\n charset += string.digits\n if include_symbols:\n charset += '!@#$%^&*()_+-=[]{}|;:,.<>?'\n \n if not charset:\n return \"Error: No character types selected!\"\n \n password = ''.join(random.choice(charset) for _ in range(length))\n print(f\"Generated password: {password}\")\n return password", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "strength-analyzer", - "title": "Password Strength Analyzer", - "pos": [870, 150], - "size": [300, 200], - "code": "import re\nfrom typing import Tuple\n\n@node_entry\ndef analyze_strength(password: str) -> Tuple[str, int, str]:\n score = 0\n feedback = []\n \n # Length check\n if len(password) >= 12:\n score += 25\n elif len(password) >= 8:\n score += 15\n feedback.append(\"Consider using 12+ characters\")\n else:\n feedback.append(\"Password too short (8+ recommended)\")\n \n # Character variety\n if re.search(r'[A-Z]', password):\n score += 20\n else:\n feedback.append(\"Add uppercase letters\")\n \n if re.search(r'[a-z]', password):\n score += 20\n else:\n feedback.append(\"Add lowercase letters\")\n \n if re.search(r'[0-9]', password):\n score += 20\n else:\n feedback.append(\"Add numbers\")\n \n if re.search(r'[!@#$%^&*()_+\\-=\\[\\]{}|;:,.<>?]', password):\n score += 15\n else:\n feedback.append(\"Add symbols for extra security\")\n \n # Determine strength level\n if score >= 80:\n strength = \"Very Strong\"\n elif score >= 60:\n strength = \"Strong\"\n elif score >= 40:\n strength = \"Moderate\"\n elif score >= 20:\n strength = \"Weak\"\n else:\n strength = \"Very Weak\"\n \n feedback_text = \"; \".join(feedback) if feedback else \"Excellent password!\"\n \n print(f\"Password strength: {strength} (Score: {score}/100)\")\n print(f\"Feedback: {feedback_text}\")\n \n return strength, score, feedback_text", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "output-display", - "title": "Password Output & Copy", - "pos": [1250, 200], - "size": [350, 300], - "code": "@node_entry\ndef display_result(password: str, strength: str, score: int, feedback: str) -> str:\n result = f\"Generated Password: {password}\\n\"\n result += f\"Strength: {strength} ({score}/100)\\n\"\n result += f\"Feedback: {feedback}\"\n print(\"\\n=== PASSWORD GENERATION COMPLETE ===\")\n print(result)\n return result", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton, QLineEdit\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Generated Password', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['password_field'] = QLineEdit(parent)\nwidgets['password_field'].setReadOnly(True)\nwidgets['password_field'].setPlaceholderText('Password will appear here...')\nlayout.addWidget(widgets['password_field'])\n\nwidgets['copy_btn'] = QPushButton('Copy to Clipboard', parent)\nlayout.addWidget(widgets['copy_btn'])\n\nwidgets['strength_display'] = QTextEdit(parent)\nwidgets['strength_display'].setMinimumHeight(120)\nwidgets['strength_display'].setReadOnly(True)\nwidgets['strength_display'].setPlainText('Generate a password to see strength analysis...')\nlayout.addWidget(widgets['strength_display'])\n\nwidgets['new_password_btn'] = QPushButton('Generate New Password', parent)\nlayout.addWidget(widgets['new_password_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n # Extract password from the result string\n result = outputs.get('output_1', '')\n lines = result.split('\\n')\n if lines:\n password_line = lines[0]\n if 'Generated Password: ' in password_line:\n password = password_line.replace('Generated Password: ', '')\n widgets['password_field'].setText(password)\n \n widgets['strength_display'].setPlainText(result)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "config-input", - "start_pin_name": "exec_out", - "end_node_uuid": "password-generator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "config-input", - "start_pin_name": "output_1", - "end_node_uuid": "password-generator", - "end_pin_name": "length" - }, - { - "start_node_uuid": "config-input", - "start_pin_name": "output_2", - "end_node_uuid": "password-generator", - "end_pin_name": "include_uppercase" - }, - { - "start_node_uuid": "config-input", - "start_pin_name": "output_3", - "end_node_uuid": "password-generator", - "end_pin_name": "include_lowercase" - }, - { - "start_node_uuid": "config-input", - "start_pin_name": "output_4", - "end_node_uuid": "password-generator", - "end_pin_name": "include_numbers" - }, - { - "start_node_uuid": "config-input", - "start_pin_name": "output_5", - "end_node_uuid": "password-generator", - "end_pin_name": "include_symbols" - }, - { - "start_node_uuid": "password-generator", - "start_pin_name": "exec_out", - "end_node_uuid": "strength-analyzer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "password-generator", - "start_pin_name": "output_1", - "end_node_uuid": "strength-analyzer", - "end_pin_name": "password" - }, - { - "start_node_uuid": "strength-analyzer", - "start_pin_name": "exec_out", - "end_node_uuid": "output-display", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "password-generator", - "start_pin_name": "output_1", - "end_node_uuid": "output-display", - "end_pin_name": "password" - }, - { - "start_node_uuid": "strength-analyzer", - "start_pin_name": "output_1", - "end_node_uuid": "output-display", - "end_pin_name": "strength" - }, - { - "start_node_uuid": "strength-analyzer", - "start_pin_name": "output_2", - "end_node_uuid": "output-display", - "end_pin_name": "score" - }, - { - "start_node_uuid": "strength-analyzer", - "start_pin_name": "output_3", - "end_node_uuid": "output-display", - "end_pin_name": "feedback" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/password_generator_tool.md b/examples/password_generator_tool.md new file mode 100644 index 0000000..204d10e --- /dev/null +++ b/examples/password_generator_tool.md @@ -0,0 +1,400 @@ +# Password Generator Tool + +Password generation workflow with configurable parameters, random character selection, strength scoring algorithm, and GUI output display. Implements user-defined character set selection, random.choice() generation, regex-based strength analysis, and formatted result presentation. + +## Node: Password Configuration (ID: config-input) + +Collects password generation parameters through QSpinBox (length 4-128) and QCheckBox widgets for character set selection. Returns Tuple[int, bool, bool, bool, bool] containing length and boolean flags for uppercase, lowercase, numbers, and symbols inclusion. + +GUI state management handles default values: length=12, uppercase=True, lowercase=True, numbers=True, symbols=False. Uses standard get_values() and set_initial_state() functions for parameter persistence and retrieval. + +### Metadata + +```json +{ + "uuid": "config-input", + "title": "Password Configuration", + "pos": [100, 200], + "size": [300, 250], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "length": 12, + "include_uppercase": true, + "include_lowercase": true, + "include_numbers": true, + "include_symbols": false + } +} +``` + +### Logic + +```python +from typing import Tuple + +@node_entry +def configure_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> Tuple[int, bool, bool, bool, bool]: + print(f"Password config: {length} chars, Upper: {include_uppercase}, Lower: {include_lowercase}, Numbers: {include_numbers}, Symbols: {include_symbols}") + return length, include_uppercase, include_lowercase, include_numbers, include_symbols +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QSpinBox, QCheckBox, QPushButton + +layout.addWidget(QLabel('Password Length:', parent)) +widgets['length'] = QSpinBox(parent) +widgets['length'].setRange(4, 128) +widgets['length'].setValue(12) +layout.addWidget(widgets['length']) + +widgets['uppercase'] = QCheckBox('Include Uppercase (A-Z)', parent) +widgets['uppercase'].setChecked(True) +layout.addWidget(widgets['uppercase']) + +widgets['lowercase'] = QCheckBox('Include Lowercase (a-z)', parent) +widgets['lowercase'].setChecked(True) +layout.addWidget(widgets['lowercase']) + +widgets['numbers'] = QCheckBox('Include Numbers (0-9)', parent) +widgets['numbers'].setChecked(True) +layout.addWidget(widgets['numbers']) + +widgets['symbols'] = QCheckBox('Include Symbols (!@#$%)', parent) +widgets['symbols'].setChecked(False) +layout.addWidget(widgets['symbols']) + +widgets['generate_btn'] = QPushButton('Generate Password', parent) +layout.addWidget(widgets['generate_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'length': widgets['length'].value(), + 'include_uppercase': widgets['uppercase'].isChecked(), + 'include_lowercase': widgets['lowercase'].isChecked(), + 'include_numbers': widgets['numbers'].isChecked(), + 'include_symbols': widgets['symbols'].isChecked() + } + +def set_initial_state(widgets, state): + widgets['length'].setValue(state.get('length', 12)) + widgets['uppercase'].setChecked(state.get('include_uppercase', True)) + widgets['lowercase'].setChecked(state.get('include_lowercase', True)) + widgets['numbers'].setChecked(state.get('include_numbers', True)) + widgets['symbols'].setChecked(state.get('include_symbols', False)) +``` + +## Node: Password Generator Engine (ID: password-generator) + +Constructs character set by concatenating string.ascii_uppercase, string.ascii_lowercase, string.digits, and custom symbol string based on boolean input flags. Uses random.choice() with list comprehension to generate password of specified length. + +Includes error handling for empty character sets, returning "Error: No character types selected!" when no character categories are enabled. Character set construction is conditional based on input parameters, symbols include '!@#$%^&*()_+-=[]{}|;:,.<>?' set. + +### Metadata + +```json +{ + "uuid": "password-generator", + "title": "Password Generator Engine", + "pos": [500, 200], + "size": [280, 150], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import random +import string + +@node_entry +def generate_password(length: int, include_uppercase: bool, include_lowercase: bool, include_numbers: bool, include_symbols: bool) -> str: + charset = '' + + if include_uppercase: + charset += string.ascii_uppercase + if include_lowercase: + charset += string.ascii_lowercase + if include_numbers: + charset += string.digits + if include_symbols: + charset += '!@#$%^&*()_+-=[]{}|;:,.<>?' + + if not charset: + return "Error: No character types selected!" + + password = ''.join(random.choice(charset) for _ in range(length)) + print(f"Generated password: {password}") + return password +``` + +## Node: Password Strength Analyzer (ID: strength-analyzer) + +Analyzes password strength using regex pattern matching and point-based scoring system. Length scoring: 25 points for >=12 chars, 15 points for >=8 chars. Character variety scoring: 20 points each for uppercase (A-Z), lowercase (a-z), numbers (0-9), 15 points for symbols. + +Uses re.search() with specific patterns to detect character categories. Score thresholds: >=80 Very Strong, >=60 Strong, >=40 Moderate, >=20 Weak, <20 Very Weak. Returns Tuple[str, int, str] containing strength label, numerical score, and feedback text. + +Feedback generation uses list accumulation for missing elements, joined with semicolons. Provides specific recommendations for improving password complexity based on detected deficiencies. + +### Metadata + +```json +{ + "uuid": "strength-analyzer", + "title": "Password Strength Analyzer", + "pos": [870, 150], + "size": [300, 200], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import re +from typing import Tuple + +@node_entry +def analyze_strength(password: str) -> Tuple[str, int, str]: + score = 0 + feedback = [] + + # Length check + if len(password) >= 12: + score += 25 + elif len(password) >= 8: + score += 15 + feedback.append("Consider using 12+ characters") + else: + feedback.append("Password too short (8+ recommended)") + + # Character variety + if re.search(r'[A-Z]', password): + score += 20 + else: + feedback.append("Add uppercase letters") + + if re.search(r'[a-z]', password): + score += 20 + else: + feedback.append("Add lowercase letters") + + if re.search(r'[0-9]', password): + score += 20 + else: + feedback.append("Add numbers") + + if re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password): + score += 15 + else: + feedback.append("Add symbols for extra security") + + # Determine strength level + if score >= 80: + strength = "Very Strong" + elif score >= 60: + strength = "Strong" + elif score >= 40: + strength = "Moderate" + elif score >= 20: + strength = "Weak" + else: + strength = "Very Weak" + + feedback_text = "; ".join(feedback) if feedback else "Excellent password!" + + print(f"Password strength: {strength} (Score: {score}/100)") + print(f"Feedback: {feedback_text}") + + return strength, score, feedback_text +``` + +## Node: Password Output & Copy (ID: output-display) + +Formats password generation results into display string combining password, strength rating, score, and feedback. Uses string concatenation to create structured output: "Generated Password: {password}\nStrength: {strength} ({score}/100)\nFeedback: {feedback}". + +GUI implementation includes QLineEdit for password display (read-only), QTextEdit for strength analysis, and QPushButton components for copy and regeneration actions. String parsing in set_values() extracts password from formatted result using string.split() and string replacement operations. + +Handles multiple input parameters (password, strength, score, feedback) and consolidates them into single formatted output string for display and further processing. + +### Metadata + +```json +{ + "uuid": "output-display", + "title": "Password Output & Copy", + "pos": [1250, 200], + "size": [350, 300], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def display_result(password: str, strength: str, score: int, feedback: str) -> str: + result = f"Generated Password: {password}\n" + result += f"Strength: {strength} ({score}/100)\n" + result += f"Feedback: {feedback}" + print("\n=== PASSWORD GENERATION COMPLETE ===") + print(result) + return result +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton, QLineEdit +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Generated Password', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['password_field'] = QLineEdit(parent) +widgets['password_field'].setReadOnly(True) +widgets['password_field'].setPlaceholderText('Password will appear here...') +layout.addWidget(widgets['password_field']) + +widgets['copy_btn'] = QPushButton('Copy to Clipboard', parent) +layout.addWidget(widgets['copy_btn']) + +widgets['strength_display'] = QTextEdit(parent) +widgets['strength_display'].setMinimumHeight(120) +widgets['strength_display'].setReadOnly(True) +widgets['strength_display'].setPlainText('Generate a password to see strength analysis...') +layout.addWidget(widgets['strength_display']) + +widgets['new_password_btn'] = QPushButton('Generate New Password', parent) +layout.addWidget(widgets['new_password_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + # Extract password from the result string + result = outputs.get('output_1', '') + lines = result.split('\n') + if lines: + password_line = lines[0] + if 'Generated Password: ' in password_line: + password = password_line.replace('Generated Password: ', '') + widgets['password_field'].setText(password) + + widgets['strength_display'].setPlainText(result) +``` + +## Connections + +```json +[ + { + "start_node_uuid": "config-input", + "start_pin_name": "exec_out", + "end_node_uuid": "password-generator", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "config-input", + "start_pin_name": "output_1", + "end_node_uuid": "password-generator", + "end_pin_name": "length" + }, + { + "start_node_uuid": "config-input", + "start_pin_name": "output_2", + "end_node_uuid": "password-generator", + "end_pin_name": "include_uppercase" + }, + { + "start_node_uuid": "config-input", + "start_pin_name": "output_3", + "end_node_uuid": "password-generator", + "end_pin_name": "include_lowercase" + }, + { + "start_node_uuid": "config-input", + "start_pin_name": "output_4", + "end_node_uuid": "password-generator", + "end_pin_name": "include_numbers" + }, + { + "start_node_uuid": "config-input", + "start_pin_name": "output_5", + "end_node_uuid": "password-generator", + "end_pin_name": "include_symbols" + }, + { + "start_node_uuid": "password-generator", + "start_pin_name": "exec_out", + "end_node_uuid": "strength-analyzer", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "password-generator", + "start_pin_name": "output_1", + "end_node_uuid": "strength-analyzer", + "end_pin_name": "password" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_name": "exec_out", + "end_node_uuid": "output-display", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "password-generator", + "start_pin_name": "output_1", + "end_node_uuid": "output-display", + "end_pin_name": "password" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_name": "output_1", + "end_node_uuid": "output-display", + "end_pin_name": "strength" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_name": "output_2", + "end_node_uuid": "output-display", + "end_pin_name": "score" + }, + { + "start_node_uuid": "strength-analyzer", + "start_pin_name": "output_3", + "end_node_uuid": "output-display", + "end_pin_name": "feedback" + } +] +``` \ No newline at end of file diff --git a/examples/personal_finance_tracker.json b/examples/personal_finance_tracker.json deleted file mode 100644 index 32fd661..0000000 --- a/examples/personal_finance_tracker.json +++ /dev/null @@ -1,168 +0,0 @@ -{ - "nodes": [ - { - "uuid": "transaction-input", - "title": "Transaction Input & Parser", - "pos": [100, 200], - "size": [300, 300], - "code": "import datetime\nfrom typing import List, Dict, Tuple\n\n@node_entry\ndef parse_transactions(transactions_text: str, starting_balance: float) -> Tuple[List[Dict], float]:\n transactions = []\n lines = [line.strip() for line in transactions_text.split('\\n') if line.strip()]\n \n for line in lines:\n # Expected format: \"date,amount,category,description\"\n # Example: \"2024-01-15,-50.00,Food,Grocery shopping\"\n try:\n parts = [part.strip() for part in line.split(',')]\n if len(parts) >= 4:\n date_str = parts[0]\n amount = float(parts[1])\n category = parts[2]\n description = ','.join(parts[3:]) # In case description has commas\n \n # Validate date\n try:\n date_obj = datetime.datetime.strptime(date_str, \"%Y-%m-%d\")\n date_formatted = date_obj.strftime(\"%Y-%m-%d\")\n except:\n date_formatted = date_str # Keep original if parsing fails\n \n # Categorize transaction type\n transaction_type = \"Expense\" if amount < 0 else \"Income\"\n \n transactions.append({\n 'date': date_formatted,\n 'amount': amount,\n 'category': category,\n 'description': description,\n 'type': transaction_type,\n 'original_line': line\n })\n else:\n print(f\"Skipping invalid line: {line}\")\n except ValueError as e:\n print(f\"Error parsing line '{line}': {e}\")\n \n # Sort by date\n transactions.sort(key=lambda x: x['date'])\n \n print(f\"\\n=== TRANSACTION PARSING ===\")\n print(f\"Starting balance: ${starting_balance:.2f}\")\n print(f\"Parsed {len(transactions)} transactions\")\n \n total_income = sum(t['amount'] for t in transactions if t['amount'] > 0)\n total_expenses = sum(abs(t['amount']) for t in transactions if t['amount'] < 0)\n \n print(f\"Total income: ${total_income:.2f}\")\n print(f\"Total expenses: ${total_expenses:.2f}\")\n \n return transactions, starting_balance", - "gui_code": "from PySide6.QtWidgets import QLabel, QLineEdit, QTextEdit, QPushButton, QDoubleSpinBox\n\nlayout.addWidget(QLabel('Starting Balance ($):', parent))\nwidgets['starting_balance'] = QDoubleSpinBox(parent)\nwidgets['starting_balance'].setRange(-999999, 999999)\nwidgets['starting_balance'].setValue(1000.00)\nwidgets['starting_balance'].setDecimals(2)\nlayout.addWidget(widgets['starting_balance'])\n\nlayout.addWidget(QLabel('Transactions (date,amount,category,description):', parent))\nwidgets['transactions_text'] = QTextEdit(parent)\nwidgets['transactions_text'].setMinimumHeight(180)\nwidgets['transactions_text'].setPlaceholderText('Example:\\n2024-01-15,-50.00,Food,Grocery shopping\\n2024-01-16,2500.00,Salary,Monthly paycheck\\n2024-01-17,-25.50,Transport,Gas station')\nlayout.addWidget(widgets['transactions_text'])\n\nwidgets['parse_btn'] = QPushButton('Parse Transactions', parent)\nlayout.addWidget(widgets['parse_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'transactions_text': widgets['transactions_text'].toPlainText(),\n 'starting_balance': widgets['starting_balance'].value()\n }\n\ndef set_initial_state(widgets, state):\n widgets['transactions_text'].setPlainText(state.get('transactions_text', ''))\n widgets['starting_balance'].setValue(state.get('starting_balance', 1000.0))", - "gui_state": { - "transactions_text": "", - "starting_balance": 1000.0 - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "category-analyzer", - "title": "Category & Pattern Analyzer", - "pos": [470, 150], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\nfrom collections import defaultdict\nimport datetime\n\n@node_entry\ndef analyze_categories(transactions: List[Dict]) -> Tuple[Dict, Dict, Dict]:\n if not transactions:\n return {}, {}, {}\n \n # Category analysis\n category_totals = defaultdict(float)\n category_counts = defaultdict(int)\n monthly_spending = defaultdict(float)\n \n for transaction in transactions:\n amount = transaction['amount']\n category = transaction['category']\n date = transaction['date']\n \n # Category totals (separate income and expenses)\n if amount < 0: # Expense\n category_totals[category] += abs(amount)\n category_counts[category] += 1\n \n # Monthly analysis\n try:\n month = date[:7] # Extract YYYY-MM\n if amount < 0:\n monthly_spending[month] += abs(amount)\n except:\n pass\n \n # Convert to regular dicts and sort\n category_summary = dict(sorted(category_totals.items(), key=lambda x: x[1], reverse=True))\n monthly_summary = dict(sorted(monthly_spending.items()))\n \n # Calculate patterns\n patterns = {}\n if category_summary:\n total_expenses = sum(category_summary.values())\n largest_category = max(category_summary.items(), key=lambda x: x[1])\n \n patterns['total_expenses'] = total_expenses\n patterns['largest_category'] = largest_category[0]\n patterns['largest_amount'] = largest_category[1]\n patterns['largest_percentage'] = (largest_category[1] / total_expenses) * 100\n patterns['category_count'] = len(category_summary)\n patterns['avg_per_category'] = total_expenses / len(category_summary) if category_summary else 0\n \n print(f\"\\n=== CATEGORY ANALYSIS ===\")\n print(f\"Expense categories: {len(category_summary)}\")\n if patterns:\n print(f\"Largest category: {patterns['largest_category']} (${patterns['largest_amount']:.2f})\")\n print(f\"Total expenses: ${patterns['total_expenses']:.2f}\")\n \n return category_summary, monthly_summary, patterns", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "budget-calculator", - "title": "Budget & Balance Calculator", - "pos": [470, 450], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\n\n@node_entry\ndef calculate_budget(transactions: List[Dict], starting_balance: float) -> Tuple[float, float, float, float, Dict]:\n if not transactions:\n return starting_balance, 0, 0, starting_balance, {}\n \n total_income = sum(t['amount'] for t in transactions if t['amount'] > 0)\n total_expenses = sum(abs(t['amount']) for t in transactions if t['amount'] < 0)\n net_change = total_income - total_expenses\n final_balance = starting_balance + net_change\n \n # Calculate running balance for each transaction\n running_balance = starting_balance\n balance_history = []\n \n for transaction in sorted(transactions, key=lambda x: x['date']):\n running_balance += transaction['amount']\n balance_history.append({\n 'date': transaction['date'],\n 'balance': round(running_balance, 2),\n 'transaction': transaction['description'],\n 'amount': transaction['amount']\n })\n \n # Financial health indicators\n health_metrics = {\n 'income_expense_ratio': total_income / total_expenses if total_expenses > 0 else float('inf'),\n 'savings_rate': (net_change / total_income * 100) if total_income > 0 else 0,\n 'avg_daily_spending': total_expenses / 30 if total_expenses > 0 else 0,\n 'balance_trend': 'Increasing' if net_change > 0 else 'Decreasing',\n 'lowest_balance': min(h['balance'] for h in balance_history) if balance_history else starting_balance\n }\n \n print(f\"\\n=== BUDGET CALCULATION ===\")\n print(f\"Starting: ${starting_balance:.2f}\")\n print(f\"Income: ${total_income:.2f}\")\n print(f\"Expenses: ${total_expenses:.2f}\")\n print(f\"Net change: ${net_change:.2f}\")\n print(f\"Final balance: ${final_balance:.2f}\")\n print(f\"Savings rate: {health_metrics['savings_rate']:.1f}%\")\n \n return total_income, total_expenses, net_change, final_balance, health_metrics", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "financial-dashboard", - "title": "Personal Finance Dashboard", - "pos": [850, 300], - "size": [400, 400], - "code": "from typing import List, Dict\n\n@node_entry\ndef create_finance_dashboard(transactions: List[Dict], starting_balance: float, category_summary: Dict, monthly_summary: Dict, patterns: Dict, total_income: float, total_expenses: float, net_change: float, final_balance: float, health_metrics: Dict) -> str:\n dashboard = \"\\n\" + \"=\"*65 + \"\\n\"\n dashboard += \" PERSONAL FINANCE DASHBOARD\\n\"\n dashboard += \"=\"*65 + \"\\n\\n\"\n \n # Account Overview\n dashboard += f\"💰 ACCOUNT OVERVIEW\\n\"\n dashboard += f\" Starting Balance: ${starting_balance:10,.2f}\\n\"\n dashboard += f\" Final Balance: ${final_balance:10,.2f}\\n\"\n dashboard += f\" Net Change: ${net_change:10,.2f}\\n\"\n if net_change >= 0:\n dashboard += f\" Status: 📈 POSITIVE\\n\\n\"\n else:\n dashboard += f\" Status: 📉 NEGATIVE\\n\\n\"\n \n # Income vs Expenses\n dashboard += f\"📊 INCOME vs EXPENSES\\n\"\n dashboard += f\" Total Income: ${total_income:10,.2f}\\n\"\n dashboard += f\" Total Expenses: ${total_expenses:10,.2f}\\n\"\n if total_expenses > 0:\n ratio = total_income / total_expenses\n dashboard += f\" Income/Expense Ratio: {ratio:9.2f}\\n\"\n dashboard += \"\\n\"\n \n # Financial Health\n if health_metrics:\n dashboard += f\"🏥 FINANCIAL HEALTH\\n\"\n dashboard += f\" Savings Rate: {health_metrics['savings_rate']:8.1f}%\\n\"\n dashboard += f\" Avg Daily Spending: ${health_metrics['avg_daily_spending']:8.2f}\\n\"\n dashboard += f\" Balance Trend: {health_metrics['balance_trend']}\\n\"\n dashboard += f\" Lowest Balance: ${health_metrics['lowest_balance']:10,.2f}\\n\\n\"\n \n # Top Spending Categories\n if category_summary:\n dashboard += f\"🛒 TOP SPENDING CATEGORIES\\n\"\n for i, (category, amount) in enumerate(list(category_summary.items())[:5], 1):\n percentage = (amount / total_expenses * 100) if total_expenses > 0 else 0\n dashboard += f\" {i}. {category:<15} ${amount:8.2f} ({percentage:4.1f}%)\\n\"\n dashboard += \"\\n\"\n \n # Monthly Spending Trend\n if monthly_summary:\n dashboard += f\"📅 MONTHLY SPENDING\\n\"\n for month, amount in monthly_summary.items():\n dashboard += f\" {month}: ${amount:10,.2f}\\n\"\n dashboard += \"\\n\"\n \n # Recent Transactions\n if transactions:\n dashboard += f\"📝 RECENT TRANSACTIONS\\n\"\n recent = sorted(transactions, key=lambda x: x['date'], reverse=True)[:5]\n for t in recent:\n sign = \"+\" if t['amount'] > 0 else \"\"\n dashboard += f\" {t['date']} {sign}${t['amount']:8.2f} {t['category']:<10} {t['description'][:20]}\\n\"\n dashboard += \"\\n\"\n \n # Financial Insights\n dashboard += f\"💡 INSIGHTS & RECOMMENDATIONS\\n\"\n \n if health_metrics.get('savings_rate', 0) < 0:\n dashboard += f\" • ⚠️ You're spending more than earning\\n\"\n elif health_metrics.get('savings_rate', 0) < 10:\n dashboard += f\" • 💡 Try to save at least 10% of income\\n\"\n else:\n dashboard += f\" • ✅ Good savings rate!\\n\"\n \n if category_summary and patterns:\n largest_cat = patterns.get('largest_category', '')\n largest_pct = patterns.get('largest_percentage', 0)\n if largest_pct > 40:\n dashboard += f\" • ⚠️ {largest_cat} represents {largest_pct:.1f}% of expenses\\n\"\n \n if health_metrics.get('lowest_balance', 0) < 0:\n dashboard += f\" • ⚠️ Account went negative (${health_metrics['lowest_balance']:.2f})\\n\"\n \n dashboard += \"\\n\" + \"=\"*65\n \n print(dashboard)\n return dashboard", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Finance Dashboard', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['dashboard_display'] = QTextEdit(parent)\nwidgets['dashboard_display'].setMinimumHeight(280)\nwidgets['dashboard_display'].setReadOnly(True)\nwidgets['dashboard_display'].setPlainText('Enter transactions to generate financial dashboard...')\nfont = QFont('Courier New', 9)\nwidgets['dashboard_display'].setFont(font)\nlayout.addWidget(widgets['dashboard_display'])\n\nwidgets['export_btn'] = QPushButton('Export Report', parent)\nlayout.addWidget(widgets['export_btn'])\n\nwidgets['budget_alert_btn'] = QPushButton('Set Budget Alerts', parent)\nlayout.addWidget(widgets['budget_alert_btn'])\n\nwidgets['new_period_btn'] = QPushButton('Start New Period', parent)\nlayout.addWidget(widgets['new_period_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n dashboard = outputs.get('output_1', 'No dashboard data')\n widgets['dashboard_display'].setPlainText(dashboard)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "transaction-input", - "start_pin_name": "exec_out", - "end_node_uuid": "category-analyzer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "output_1", - "end_node_uuid": "category-analyzer", - "end_pin_name": "transactions" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "exec_out", - "end_node_uuid": "budget-calculator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "output_1", - "end_node_uuid": "budget-calculator", - "end_pin_name": "transactions" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "output_2", - "end_node_uuid": "budget-calculator", - "end_pin_name": "starting_balance" - }, - { - "start_node_uuid": "category-analyzer", - "start_pin_name": "exec_out", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "exec_out", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "output_1", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "transactions" - }, - { - "start_node_uuid": "transaction-input", - "start_pin_name": "output_2", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "starting_balance" - }, - { - "start_node_uuid": "category-analyzer", - "start_pin_name": "output_1", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "category_summary" - }, - { - "start_node_uuid": "category-analyzer", - "start_pin_name": "output_2", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "monthly_summary" - }, - { - "start_node_uuid": "category-analyzer", - "start_pin_name": "output_3", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "patterns" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "output_1", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "total_income" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "output_2", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "total_expenses" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "output_3", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "net_change" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "output_4", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "final_balance" - }, - { - "start_node_uuid": "budget-calculator", - "start_pin_name": "output_5", - "end_node_uuid": "financial-dashboard", - "end_pin_name": "health_metrics" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/personal_finance_tracker.md b/examples/personal_finance_tracker.md new file mode 100644 index 0000000..9a1f342 --- /dev/null +++ b/examples/personal_finance_tracker.md @@ -0,0 +1,569 @@ +# Personal Finance Tracker + +Financial transaction processing system with CSV parsing, category analysis, balance calculations, and dashboard reporting. Implements comma-separated value parsing, defaultdict aggregation, running balance computation, and formatted financial reporting with health metrics. + +## Node: Transaction Input & Parser (ID: transaction-input) + +Parses CSV-formatted transaction data using string.split(',') on newline-separated input. Expected format: "date,amount,category,description". Uses datetime.strptime() for date validation with "%Y-%m-%d" format. Handles parsing errors with try-except blocks and validates minimum 4 comma-separated fields per line. + +Categorizes transactions by amount sign: negative values = "Expense", positive = "Income". Sorts results by date using lambda key function. Returns Tuple[List[Dict], float] containing parsed transaction dictionaries and starting balance. Each transaction dict includes date, amount, category, description, type, and original_line fields. + +GUI includes QDoubleSpinBox for starting balance (-999999 to 999999 range) and QTextEdit for transaction input with CSV format examples in placeholder text. + +### Metadata + +```json +{ + "uuid": "transaction-input", + "title": "Transaction Input & Parser", + "pos": [ + 75.66600000000005, + 221.29225 + ], + "size": [ + 280, + 435 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "transactions_text": "", + "starting_balance": 1000.0 + } +} +``` + +### Logic + +```python +import datetime +from typing import List, Dict, Tuple + +@node_entry +def parse_transactions(transactions_text: str, starting_balance: float) -> Tuple[List[Dict], float]: + transactions = [] + lines = [line.strip() for line in transactions_text.split('\n') if line.strip()] + + for line in lines: + # Expected format: "date,amount,category,description" + # Example: "2024-01-15,-50.00,Food,Grocery shopping" + try: + parts = [part.strip() for part in line.split(',')] + if len(parts) >= 4: + date_str = parts[0] + amount = float(parts[1]) + category = parts[2] + description = ','.join(parts[3:]) # In case description has commas + + # Validate date + try: + date_obj = datetime.datetime.strptime(date_str, "%Y-%m-%d") + date_formatted = date_obj.strftime("%Y-%m-%d") + except: + date_formatted = date_str # Keep original if parsing fails + + # Categorize transaction type + transaction_type = "Expense" if amount < 0 else "Income" + + transactions.append({ + 'date': date_formatted, + 'amount': amount, + 'category': category, + 'description': description, + 'type': transaction_type, + 'original_line': line + }) + else: + print(f"Skipping invalid line: {line}") + except ValueError as e: + print(f"Error parsing line '{line}': {e}") + + # Sort by date + transactions.sort(key=lambda x: x['date']) + + print(f"\n=== TRANSACTION PARSING ===") + print(f"Starting balance: ${starting_balance:.2f}") + print(f"Parsed {len(transactions)} transactions") + + total_income = sum(t['amount'] for t in transactions if t['amount'] > 0) + total_expenses = sum(abs(t['amount']) for t in transactions if t['amount'] < 0) + + print(f"Total income: ${total_income:.2f}") + print(f"Total expenses: ${total_expenses:.2f}") + + return transactions, starting_balance +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QTextEdit, QPushButton, QDoubleSpinBox + +layout.addWidget(QLabel('Starting Balance ($):', parent)) +widgets['starting_balance'] = QDoubleSpinBox(parent) +widgets['starting_balance'].setRange(-999999, 999999) +widgets['starting_balance'].setValue(1000.00) +widgets['starting_balance'].setDecimals(2) +layout.addWidget(widgets['starting_balance']) + +layout.addWidget(QLabel('Transactions (date,amount,category,description):', parent)) +widgets['transactions_text'] = QTextEdit(parent) +widgets['transactions_text'].setMinimumHeight(180) +widgets['transactions_text'].setPlaceholderText('Example:\n2024-01-15,-50.00,Food,Grocery shopping\n2024-01-16,2500.00,Salary,Monthly paycheck\n2024-01-17,-25.50,Transport,Gas station') +layout.addWidget(widgets['transactions_text']) + +widgets['parse_btn'] = QPushButton('Parse Transactions', parent) +layout.addWidget(widgets['parse_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'transactions_text': widgets['transactions_text'].toPlainText(), + 'starting_balance': widgets['starting_balance'].value() + } + +def set_initial_state(widgets, state): + widgets['transactions_text'].setPlainText(state.get('transactions_text', '')) + widgets['starting_balance'].setValue(state.get('starting_balance', 1000.0)) +``` + + +## Node: Category & Pattern Analyzer (ID: category-analyzer) + +Analyzes spending patterns using defaultdict for category aggregation. Processes only negative amounts (expenses), accumulating totals and counts per category. Extracts monthly spending by parsing YYYY-MM substring from date fields using slice notation. + +Sorts category results by amount in descending order using sorted() with reverse=True. Calculates pattern metrics including total expenses, largest category identification, percentage calculations, and category averages. Returns Tuple[Dict, Dict, Dict] for category summary, monthly summary, and patterns. + +Pattern analysis includes largest_category identification, percentage of total expenses, category count, and average spending per category. Monthly analysis creates time-series data for spending trends over YYYY-MM periods. + +### Metadata + +```json +{ + "uuid": "category-analyzer", + "title": "Category & Pattern Analyzer", + "pos": [ + 508.0218749999999, + 110.45725000000002 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple +from collections import defaultdict +import datetime + +@node_entry +def analyze_categories(transactions: List[Dict]) -> Tuple[Dict, Dict, Dict]: + if not transactions: + return {}, {}, {} + + # Category analysis + category_totals = defaultdict(float) + category_counts = defaultdict(int) + monthly_spending = defaultdict(float) + + for transaction in transactions: + amount = transaction['amount'] + category = transaction['category'] + date = transaction['date'] + + # Category totals (separate income and expenses) + if amount < 0: # Expense + category_totals[category] += abs(amount) + category_counts[category] += 1 + + # Monthly analysis + try: + month = date[:7] # Extract YYYY-MM + if amount < 0: + monthly_spending[month] += abs(amount) + except: + pass + + # Convert to regular dicts and sort + category_summary = dict(sorted(category_totals.items(), key=lambda x: x[1], reverse=True)) + monthly_summary = dict(sorted(monthly_spending.items())) + + # Calculate patterns + patterns = {} + if category_summary: + total_expenses = sum(category_summary.values()) + largest_category = max(category_summary.items(), key=lambda x: x[1]) + + patterns['total_expenses'] = total_expenses + patterns['largest_category'] = largest_category[0] + patterns['largest_amount'] = largest_category[1] + patterns['largest_percentage'] = (largest_category[1] / total_expenses) * 100 + patterns['category_count'] = len(category_summary) + patterns['avg_per_category'] = total_expenses / len(category_summary) if category_summary else 0 + + print(f"\n=== CATEGORY ANALYSIS ===") + print(f"Expense categories: {len(category_summary)}") + if patterns: + print(f"Largest category: {patterns['largest_category']} (${patterns['largest_amount']:.2f})") + print(f"Total expenses: ${patterns['total_expenses']:.2f}") + + return category_summary, monthly_summary, patterns +``` + + +## Node: Budget & Balance Calculator (ID: budget-calculator) + +Calculates financial metrics by separating positive (income) and negative (expense) amounts using sum() with conditional list comprehensions. Computes net change as income minus expenses, final balance as starting balance plus net change. Generates running balance history by iterating through date-sorted transactions. + +Creates health metrics including income/expense ratio, savings rate percentage ((net_change/total_income)*100), average daily spending (total_expenses/30), balance trend determination, and minimum balance tracking. Returns Tuple[float, float, float, float, Dict] for income, expenses, net change, final balance, and health metrics. + +Running balance calculation maintains chronological transaction processing, creating balance history list with date, balance, transaction description, and amount for each entry. Handles division by zero for ratio calculations using conditional expressions. + +### Metadata + +```json +{ + "uuid": "budget-calculator", + "title": "Budget & Balance Calculator", + "pos": [ + 497.37575000000004, + 456.08349999999996 + ], + "size": [ + 250, + 218 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple + +@node_entry +def calculate_budget(transactions: List[Dict], starting_balance: float) -> Tuple[float, float, float, float, Dict]: + if not transactions: + return starting_balance, 0, 0, starting_balance, {} + + total_income = sum(t['amount'] for t in transactions if t['amount'] > 0) + total_expenses = sum(abs(t['amount']) for t in transactions if t['amount'] < 0) + net_change = total_income - total_expenses + final_balance = starting_balance + net_change + + # Calculate running balance for each transaction + running_balance = starting_balance + balance_history = [] + + for transaction in sorted(transactions, key=lambda x: x['date']): + running_balance += transaction['amount'] + balance_history.append({ + 'date': transaction['date'], + 'balance': round(running_balance, 2), + 'transaction': transaction['description'], + 'amount': transaction['amount'] + }) + + # Financial health indicators + health_metrics = { + 'income_expense_ratio': total_income / total_expenses if total_expenses > 0 else float('inf'), + 'savings_rate': (net_change / total_income * 100) if total_income > 0 else 0, + 'avg_daily_spending': total_expenses / 30 if total_expenses > 0 else 0, + 'balance_trend': 'Increasing' if net_change > 0 else 'Decreasing', + 'lowest_balance': min(h['balance'] for h in balance_history) if balance_history else starting_balance + } + + print(f"\n=== BUDGET CALCULATION ===") + print(f"Starting: ${starting_balance:.2f}") + print(f"Income: ${total_income:.2f}") + print(f"Expenses: ${total_expenses:.2f}") + print(f"Net change: ${net_change:.2f}") + print(f"Final balance: ${final_balance:.2f}") + print(f"Savings rate: {health_metrics['savings_rate']:.1f}%") + + return total_income, total_expenses, net_change, final_balance, health_metrics +``` + + +## Node: Personal Finance Dashboard (ID: financial-dashboard) + +Formats comprehensive financial report using string concatenation with fixed-width formatting. Creates sections for account overview, income vs expenses, financial health, top spending categories, monthly trends, recent transactions, and automated insights. Uses f-string formatting with width specifiers for column alignment. + +Implementes conditional logic for financial insights: negative savings rate warnings, category concentration alerts (>40% threshold), and negative balance warnings. Recent transactions display shows top 5 sorted by date in reverse chronological order. Category percentages calculated as (category_amount/total_expenses)*100. + +GUI integration includes QTextEdit with Courier New monospace font for formatted display, export functionality, budget alert setup, and new period initialization. Dashboard output includes visual indicators and actionable recommendations based on calculated financial health metrics. + +### Metadata + +```json +{ + "uuid": "financial-dashboard", + "title": "Personal Finance Dashboard", + "pos": [ + 913.87675, + 318.2505 + ], + "size": [ + 276, + 753 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict + +@node_entry +def create_finance_dashboard(transactions: List[Dict], starting_balance: float, category_summary: Dict, monthly_summary: Dict, patterns: Dict, total_income: float, total_expenses: float, net_change: float, final_balance: float, health_metrics: Dict) -> str: + dashboard = "\n" + "="*65 + "\n" + dashboard += " PERSONAL FINANCE DASHBOARD\n" + dashboard += "="*65 + "\n\n" + + # Account Overview + dashboard += f"💰 ACCOUNT OVERVIEW\n" + dashboard += f" Starting Balance: ${starting_balance:10,.2f}\n" + dashboard += f" Final Balance: ${final_balance:10,.2f}\n" + dashboard += f" Net Change: ${net_change:10,.2f}\n" + if net_change >= 0: + dashboard += f" Status: 📈 POSITIVE\n\n" + else: + dashboard += f" Status: 📉 NEGATIVE\n\n" + + # Income vs Expenses + dashboard += f"📊 INCOME vs EXPENSES\n" + dashboard += f" Total Income: ${total_income:10,.2f}\n" + dashboard += f" Total Expenses: ${total_expenses:10,.2f}\n" + if total_expenses > 0: + ratio = total_income / total_expenses + dashboard += f" Income/Expense Ratio: {ratio:9.2f}\n" + dashboard += "\n" + + # Financial Health + if health_metrics: + dashboard += f"🏥 FINANCIAL HEALTH\n" + dashboard += f" Savings Rate: {health_metrics['savings_rate']:8.1f}%\n" + dashboard += f" Avg Daily Spending: ${health_metrics['avg_daily_spending']:8.2f}\n" + dashboard += f" Balance Trend: {health_metrics['balance_trend']}\n" + dashboard += f" Lowest Balance: ${health_metrics['lowest_balance']:10,.2f}\n\n" + + # Top Spending Categories + if category_summary: + dashboard += f"🛒 TOP SPENDING CATEGORIES\n" + for i, (category, amount) in enumerate(list(category_summary.items())[:5], 1): + percentage = (amount / total_expenses * 100) if total_expenses > 0 else 0 + dashboard += f" {i}. {category:<15} ${amount:8.2f} ({percentage:4.1f}%)\n" + dashboard += "\n" + + # Monthly Spending Trend + if monthly_summary: + dashboard += f"📅 MONTHLY SPENDING\n" + for month, amount in monthly_summary.items(): + dashboard += f" {month}: ${amount:10,.2f}\n" + dashboard += "\n" + + # Recent Transactions + if transactions: + dashboard += f"📝 RECENT TRANSACTIONS\n" + recent = sorted(transactions, key=lambda x: x['date'], reverse=True)[:5] + for t in recent: + sign = "+" if t['amount'] > 0 else "" + dashboard += f" {t['date']} {sign}${t['amount']:8.2f} {t['category']:<10} {t['description'][:20]}\n" + dashboard += "\n" + + # Financial Insights + dashboard += f"💡 INSIGHTS & RECOMMENDATIONS\n" + + if health_metrics.get('savings_rate', 0) < 0: + dashboard += f" • ⚠️ You're spending more than earning\n" + elif health_metrics.get('savings_rate', 0) < 10: + dashboard += f" • 💡 Try to save at least 10% of income\n" + else: + dashboard += f" • ✅ Good savings rate!\n" + + if category_summary and patterns: + largest_cat = patterns.get('largest_category', '') + largest_pct = patterns.get('largest_percentage', 0) + if largest_pct > 40: + dashboard += f" • ⚠️ {largest_cat} represents {largest_pct:.1f}% of expenses\n" + + if health_metrics.get('lowest_balance', 0) < 0: + dashboard += f" • ⚠️ Account went negative (${health_metrics['lowest_balance']:.2f})\n" + + dashboard += "\n" + "="*65 + + print(dashboard) + return dashboard +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Finance Dashboard', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['dashboard_display'] = QTextEdit(parent) +widgets['dashboard_display'].setMinimumHeight(280) +widgets['dashboard_display'].setReadOnly(True) +widgets['dashboard_display'].setPlainText('Enter transactions to generate financial dashboard...') +font = QFont('Courier New', 9) +widgets['dashboard_display'].setFont(font) +layout.addWidget(widgets['dashboard_display']) + +widgets['export_btn'] = QPushButton('Export Report', parent) +layout.addWidget(widgets['export_btn']) + +widgets['budget_alert_btn'] = QPushButton('Set Budget Alerts', parent) +layout.addWidget(widgets['budget_alert_btn']) + +widgets['new_period_btn'] = QPushButton('Start New Period', parent) +layout.addWidget(widgets['new_period_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + dashboard = outputs.get('output_1', 'No dashboard data') + widgets['dashboard_display'].setPlainText(dashboard) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "transaction-input", + "start_pin_name": "exec_out", + "end_node_uuid": "category-analyzer", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "output_1", + "end_node_uuid": "category-analyzer", + "end_pin_name": "transactions" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "exec_out", + "end_node_uuid": "budget-calculator", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "output_1", + "end_node_uuid": "budget-calculator", + "end_pin_name": "transactions" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "output_2", + "end_node_uuid": "budget-calculator", + "end_pin_name": "starting_balance" + }, + { + "start_node_uuid": "category-analyzer", + "start_pin_name": "exec_out", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "output_1", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "transactions" + }, + { + "start_node_uuid": "transaction-input", + "start_pin_name": "output_2", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "starting_balance" + }, + { + "start_node_uuid": "category-analyzer", + "start_pin_name": "output_1", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "category_summary" + }, + { + "start_node_uuid": "category-analyzer", + "start_pin_name": "output_2", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "monthly_summary" + }, + { + "start_node_uuid": "category-analyzer", + "start_pin_name": "output_3", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "patterns" + }, + { + "start_node_uuid": "budget-calculator", + "start_pin_name": "output_1", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "total_income" + }, + { + "start_node_uuid": "budget-calculator", + "start_pin_name": "output_2", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "total_expenses" + }, + { + "start_node_uuid": "budget-calculator", + "start_pin_name": "output_3", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "net_change" + }, + { + "start_node_uuid": "budget-calculator", + "start_pin_name": "output_4", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "final_balance" + }, + { + "start_node_uuid": "budget-calculator", + "start_pin_name": "output_5", + "end_node_uuid": "financial-dashboard", + "end_pin_name": "health_metrics" + } +] +``` diff --git a/examples/recipe_nutrition_calculator.json b/examples/recipe_nutrition_calculator.json deleted file mode 100644 index 380a3dd..0000000 --- a/examples/recipe_nutrition_calculator.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "nodes": [ - { - "uuid": "recipe-input", - "title": "Recipe Input & Parser", - "pos": [100, 200], - "size": [300, 300], - "code": "import re\nfrom typing import List, Tuple, Dict\n\n@node_entry\ndef parse_recipe(recipe_name: str, servings: int, ingredients_text: str) -> Tuple[str, int, List[Dict]]:\n # Parse ingredients from text\n ingredients = []\n lines = [line.strip() for line in ingredients_text.split('\\n') if line.strip()]\n \n for line in lines:\n # Try to extract quantity, unit, and ingredient name\n # Pattern: number unit ingredient (e.g., \"2 cups flour\")\n match = re.match(r'([\\d.]+)\\s*([a-zA-Z]*)?\\s+(.+)', line)\n \n if match:\n quantity = float(match.group(1))\n unit = match.group(2) if match.group(2) else \"item\"\n name = match.group(3).strip()\n else:\n # If no quantity found, assume 1 item\n quantity = 1.0\n unit = \"item\"\n name = line\n \n ingredients.append({\n 'name': name,\n 'quantity': quantity,\n 'unit': unit,\n 'original_line': line\n })\n \n print(f\"\\n=== RECIPE PARSING ===\")\n print(f\"Recipe: {recipe_name}\")\n print(f\"Servings: {servings}\")\n print(f\"Parsed {len(ingredients)} ingredients:\")\n for ing in ingredients:\n print(f\" - {ing['quantity']} {ing['unit']} {ing['name']}\")\n \n return recipe_name, servings, ingredients", - "gui_code": "from PySide6.QtWidgets import QLabel, QLineEdit, QSpinBox, QTextEdit, QPushButton\n\nlayout.addWidget(QLabel('Recipe Name:', parent))\nwidgets['recipe_name'] = QLineEdit(parent)\nwidgets['recipe_name'].setPlaceholderText('Enter recipe name...')\nlayout.addWidget(widgets['recipe_name'])\n\nlayout.addWidget(QLabel('Number of Servings:', parent))\nwidgets['servings'] = QSpinBox(parent)\nwidgets['servings'].setRange(1, 20)\nwidgets['servings'].setValue(4)\nlayout.addWidget(widgets['servings'])\n\nlayout.addWidget(QLabel('Ingredients (one per line):', parent))\nwidgets['ingredients_text'] = QTextEdit(parent)\nwidgets['ingredients_text'].setMinimumHeight(150)\nwidgets['ingredients_text'].setPlaceholderText('Example:\\n2 cups flour\\n3 eggs\\n1 cup milk\\n1 tsp salt')\nlayout.addWidget(widgets['ingredients_text'])\n\nwidgets['parse_btn'] = QPushButton('Parse Recipe', parent)\nlayout.addWidget(widgets['parse_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'recipe_name': widgets['recipe_name'].text(),\n 'servings': widgets['servings'].value(),\n 'ingredients_text': widgets['ingredients_text'].toPlainText()\n }\n\ndef set_initial_state(widgets, state):\n widgets['recipe_name'].setText(state.get('recipe_name', ''))\n widgets['servings'].setValue(state.get('servings', 4))\n widgets['ingredients_text'].setPlainText(state.get('ingredients_text', ''))", - "gui_state": { - "recipe_name": "", - "servings": 4, - "ingredients_text": "" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "nutrition-database", - "title": "Nutrition Database Lookup", - "pos": [470, 150], - "size": [320, 250], - "code": "from typing import List, Dict\n\n@node_entry\ndef lookup_nutrition(ingredients: List[Dict]) -> List[Dict]:\n # Simplified nutrition database (calories per 100g/100ml/1 item)\n nutrition_db = {\n 'flour': {'calories': 364, 'protein': 10.3, 'carbs': 76.3, 'fat': 1.0, 'unit_conversion': {'cup': 125}},\n 'eggs': {'calories': 155, 'protein': 13.0, 'carbs': 1.1, 'fat': 11.0, 'unit_conversion': {'item': 50}},\n 'milk': {'calories': 42, 'protein': 3.4, 'carbs': 5.0, 'fat': 1.0, 'unit_conversion': {'cup': 240}},\n 'butter': {'calories': 717, 'protein': 0.9, 'carbs': 0.1, 'fat': 81.0, 'unit_conversion': {'tbsp': 14, 'cup': 227}},\n 'sugar': {'calories': 387, 'protein': 0, 'carbs': 100, 'fat': 0, 'unit_conversion': {'cup': 200, 'tbsp': 12}},\n 'salt': {'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, 'unit_conversion': {'tsp': 6}},\n 'chicken breast': {'calories': 165, 'protein': 31.0, 'carbs': 0, 'fat': 3.6, 'unit_conversion': {'item': 200}},\n 'rice': {'calories': 130, 'protein': 2.7, 'carbs': 28, 'fat': 0.3, 'unit_conversion': {'cup': 195}},\n 'cheese': {'calories': 113, 'protein': 7.0, 'carbs': 1.0, 'fat': 9.0, 'unit_conversion': {'cup': 113}},\n 'oil': {'calories': 884, 'protein': 0, 'carbs': 0, 'fat': 100, 'unit_conversion': {'tbsp': 14}},\n 'bread': {'calories': 265, 'protein': 9.0, 'carbs': 49, 'fat': 3.2, 'unit_conversion': {'slice': 25}},\n 'potato': {'calories': 77, 'protein': 2.0, 'carbs': 17, 'fat': 0.1, 'unit_conversion': {'item': 150}}\n }\n \n enriched_ingredients = []\n \n for ingredient in ingredients:\n name = ingredient['name'].lower()\n quantity = ingredient['quantity']\n unit = ingredient['unit'].lower()\n \n # Find matching nutrition data\n nutrition = None\n matched_name = None\n \n for db_name, db_nutrition in nutrition_db.items():\n if db_name in name or any(word in name for word in db_name.split()):\n nutrition = db_nutrition\n matched_name = db_name\n break\n \n if nutrition:\n # Convert to grams\n if unit in nutrition['unit_conversion']:\n grams = quantity * nutrition['unit_conversion'][unit]\n elif unit in ['g', 'gram', 'grams']:\n grams = quantity\n elif unit in ['kg', 'kilogram']:\n grams = quantity * 1000\n elif unit in ['lb', 'pound']:\n grams = quantity * 453.592\n elif unit in ['oz', 'ounce']:\n grams = quantity * 28.3495\n else:\n grams = quantity * 100 # Default assumption\n \n # Calculate nutrition per ingredient\n factor = grams / 100 # Nutrition data is per 100g\n \n enriched_ingredient = ingredient.copy()\n enriched_ingredient.update({\n 'matched_food': matched_name,\n 'grams': round(grams, 1),\n 'calories': round(nutrition['calories'] * factor, 1),\n 'protein': round(nutrition['protein'] * factor, 1),\n 'carbs': round(nutrition['carbs'] * factor, 1),\n 'fat': round(nutrition['fat'] * factor, 1)\n })\n else:\n # Unknown ingredient\n enriched_ingredient = ingredient.copy()\n enriched_ingredient.update({\n 'matched_food': 'Unknown',\n 'grams': 0,\n 'calories': 0,\n 'protein': 0,\n 'carbs': 0,\n 'fat': 0\n })\n \n enriched_ingredients.append(enriched_ingredient)\n \n print(f\"\\n=== NUTRITION LOOKUP ===\")\n for ing in enriched_ingredients:\n if ing['matched_food'] != 'Unknown':\n print(f\"{ing['name']}: {ing['calories']} cal, {ing['protein']}g protein\")\n else:\n print(f\"{ing['name']}: No nutrition data found\")\n \n return enriched_ingredients", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "nutrition-calculator", - "title": "Nutrition Calculator", - "pos": [850, 200], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\n\n@node_entry\ndef calculate_nutrition(recipe_name: str, servings: int, ingredients: List[Dict]) -> Tuple[Dict, Dict, str]:\n # Calculate total nutrition\n total = {\n 'calories': 0,\n 'protein': 0,\n 'carbs': 0,\n 'fat': 0,\n 'grams': 0\n }\n \n for ingredient in ingredients:\n total['calories'] += ingredient.get('calories', 0)\n total['protein'] += ingredient.get('protein', 0)\n total['carbs'] += ingredient.get('carbs', 0)\n total['fat'] += ingredient.get('fat', 0)\n total['grams'] += ingredient.get('grams', 0)\n \n # Round totals\n for key in total:\n total[key] = round(total[key], 1)\n \n # Calculate per serving\n per_serving = {\n 'calories': round(total['calories'] / servings, 1),\n 'protein': round(total['protein'] / servings, 1),\n 'carbs': round(total['carbs'] / servings, 1),\n 'fat': round(total['fat'] / servings, 1),\n 'grams': round(total['grams'] / servings, 1)\n }\n \n # Generate nutrition analysis\n analysis = f\"Recipe: {recipe_name}\\n\"\n analysis += f\"Total weight: {total['grams']}g\\n\"\n analysis += f\"Servings: {servings}\\n\\n\"\n \n # Calorie distribution\n if total['calories'] > 0:\n protein_cal = total['protein'] * 4\n carbs_cal = total['carbs'] * 4\n fat_cal = total['fat'] * 9\n \n protein_pct = round((protein_cal / total['calories']) * 100, 1)\n carbs_pct = round((carbs_cal / total['calories']) * 100, 1)\n fat_pct = round((fat_cal / total['calories']) * 100, 1)\n \n analysis += f\"Macronutrient distribution:\\n\"\n analysis += f\"Protein: {protein_pct}%, Carbs: {carbs_pct}%, Fat: {fat_pct}%\\n\\n\"\n \n # Health assessment\n if per_serving['calories'] < 200:\n analysis += \"Light meal/snack\\n\"\n elif per_serving['calories'] < 500:\n analysis += \"Moderate meal\\n\"\n else:\n analysis += \"Hearty meal\\n\"\n \n if per_serving['protein'] >= 20:\n analysis += \"High protein content\\n\"\n \n if per_serving['fat'] > 20:\n analysis += \"High fat content\\n\"\n \n print(f\"\\n=== NUTRITION CALCULATION ===\")\n print(f\"Total: {total['calories']} cal, {total['protein']}g protein\")\n print(f\"Per serving: {per_serving['calories']} cal, {per_serving['protein']}g protein\")\n print(f\"Analysis: {analysis}\")\n \n return total, per_serving, analysis", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "nutrition-report", - "title": "Nutrition Report Generator", - "pos": [1220, 200], - "size": [380, 350], - "code": "from typing import Dict, List\n\n@node_entry\ndef generate_nutrition_report(recipe_name: str, servings: int, ingredients: List[Dict], total_nutrition: Dict, per_serving: Dict, analysis: str) -> str:\n report = \"\\n\" + \"=\"*70 + \"\\n\"\n report += \" NUTRITION REPORT\\n\"\n report += \"=\"*70 + \"\\n\\n\"\n \n # Recipe Overview\n report += f\"🍽️ RECIPE: {recipe_name.upper()}\\n\"\n report += f\"👥 Servings: {servings}\\n\"\n report += f\"⚖️ Total Weight: {total_nutrition['grams']}g\\n\\n\"\n \n # Ingredients List\n report += f\"📋 INGREDIENTS\\n\"\n for i, ing in enumerate(ingredients, 1):\n if ing.get('matched_food', 'Unknown') != 'Unknown':\n report += f\" {i:2d}. {ing['original_line']}\\n\"\n report += f\" ({ing['grams']}g, {ing['calories']} cal, {ing['protein']}g protein)\\n\"\n else:\n report += f\" {i:2d}. {ing['original_line']} (nutrition data unavailable)\\n\"\n report += \"\\n\"\n \n # Total Nutrition\n report += f\"📊 TOTAL NUTRITION\\n\"\n report += f\" Calories: {total_nutrition['calories']:8.1f} kcal\\n\"\n report += f\" Protein: {total_nutrition['protein']:8.1f} g\\n\"\n report += f\" Carbohydrates:{total_nutrition['carbs']:8.1f} g\\n\"\n report += f\" Fat: {total_nutrition['fat']:8.1f} g\\n\\n\"\n \n # Per Serving\n report += f\"🍽️ PER SERVING\\n\"\n report += f\" Calories: {per_serving['calories']:8.1f} kcal\\n\"\n report += f\" Protein: {per_serving['protein']:8.1f} g\\n\"\n report += f\" Carbohydrates:{per_serving['carbs']:8.1f} g\\n\"\n report += f\" Fat: {per_serving['fat']:8.1f} g\\n\"\n report += f\" Weight: {per_serving['grams']:8.1f} g\\n\\n\"\n \n # Macronutrient Breakdown\n if total_nutrition['calories'] > 0:\n protein_cal = total_nutrition['protein'] * 4\n carbs_cal = total_nutrition['carbs'] * 4\n fat_cal = total_nutrition['fat'] * 9\n \n protein_pct = (protein_cal / total_nutrition['calories']) * 100\n carbs_pct = (carbs_cal / total_nutrition['calories']) * 100\n fat_pct = (fat_cal / total_nutrition['calories']) * 100\n \n report += f\"📈 MACRONUTRIENT BREAKDOWN\\n\"\n report += f\" Protein: {protein_pct:5.1f}% ({protein_cal:.0f} kcal)\\n\"\n report += f\" Carbohydrates:{carbs_pct:5.1f}% ({carbs_cal:.0f} kcal)\\n\"\n report += f\" Fat: {fat_pct:5.1f}% ({fat_cal:.0f} kcal)\\n\\n\"\n \n # Analysis\n report += f\"💡 ANALYSIS\\n\"\n for line in analysis.split('\\n'):\n if line.strip():\n report += f\" • {line.strip()}\\n\"\n \n report += \"\\n\" + \"=\"*70\n \n print(report)\n return report", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Nutrition Report', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['report_display'] = QTextEdit(parent)\nwidgets['report_display'].setMinimumHeight(250)\nwidgets['report_display'].setReadOnly(True)\nwidgets['report_display'].setPlainText('Enter recipe ingredients to generate nutrition report...')\nfont = QFont('Courier New', 9)\nwidgets['report_display'].setFont(font)\nlayout.addWidget(widgets['report_display'])\n\nwidgets['save_report_btn'] = QPushButton('Save Report', parent)\nlayout.addWidget(widgets['save_report_btn'])\n\nwidgets['scale_recipe_btn'] = QPushButton('Scale Recipe', parent)\nlayout.addWidget(widgets['scale_recipe_btn'])\n\nwidgets['new_recipe_btn'] = QPushButton('New Recipe', parent)\nlayout.addWidget(widgets['new_recipe_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n report = outputs.get('output_1', 'No report data')\n widgets['report_display'].setPlainText(report)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "recipe-input", - "start_pin_name": "exec_out", - "end_node_uuid": "nutrition-database", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "recipe-input", - "start_pin_name": "output_3", - "end_node_uuid": "nutrition-database", - "end_pin_name": "ingredients" - }, - { - "start_node_uuid": "nutrition-database", - "start_pin_name": "exec_out", - "end_node_uuid": "nutrition-calculator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "recipe-input", - "start_pin_name": "output_1", - "end_node_uuid": "nutrition-calculator", - "end_pin_name": "recipe_name" - }, - { - "start_node_uuid": "recipe-input", - "start_pin_name": "output_2", - "end_node_uuid": "nutrition-calculator", - "end_pin_name": "servings" - }, - { - "start_node_uuid": "nutrition-database", - "start_pin_name": "output_1", - "end_node_uuid": "nutrition-calculator", - "end_pin_name": "ingredients" - }, - { - "start_node_uuid": "nutrition-calculator", - "start_pin_name": "exec_out", - "end_node_uuid": "nutrition-report", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "recipe-input", - "start_pin_name": "output_1", - "end_node_uuid": "nutrition-report", - "end_pin_name": "recipe_name" - }, - { - "start_node_uuid": "recipe-input", - "start_pin_name": "output_2", - "end_node_uuid": "nutrition-report", - "end_pin_name": "servings" - }, - { - "start_node_uuid": "nutrition-database", - "start_pin_name": "output_1", - "end_node_uuid": "nutrition-report", - "end_pin_name": "ingredients" - }, - { - "start_node_uuid": "nutrition-calculator", - "start_pin_name": "output_1", - "end_node_uuid": "nutrition-report", - "end_pin_name": "total_nutrition" - }, - { - "start_node_uuid": "nutrition-calculator", - "start_pin_name": "output_2", - "end_node_uuid": "nutrition-report", - "end_pin_name": "per_serving" - }, - { - "start_node_uuid": "nutrition-calculator", - "start_pin_name": "output_3", - "end_node_uuid": "nutrition-report", - "end_pin_name": "analysis" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/recipe_nutrition_calculator.md b/examples/recipe_nutrition_calculator.md new file mode 100644 index 0000000..d55ae79 --- /dev/null +++ b/examples/recipe_nutrition_calculator.md @@ -0,0 +1,542 @@ +# Recipe Nutrition Calculator + +Recipe nutrition analysis workflow with regex-based ingredient parsing, nutrition database lookup, macronutrient calculations, and formatted report generation. Implements text parsing with re.match(), dictionary-based food database, mathematical nutrition aggregation, and string formatting for professional dietary reports. + +## Node: Recipe Input & Parser (ID: recipe-input) + +Parses ingredient text using re.match() with pattern `([\\d.]+)\\s*([a-zA-Z]*)?\\s+(.+)` to extract quantity (float), unit (string), and ingredient name from lines. Handles missing quantities by defaulting to 1.0 item. Returns Tuple[str, int, List[Dict]] containing recipe name, servings count, and ingredient dictionaries. + +Each ingredient dictionary contains 'name', 'quantity', 'unit', and 'original_line' fields. Processing uses string.split('\\n') for line separation and string.strip() for whitespace removal. GUI includes QSpinBox for servings (1-20 range) and QTextEdit for ingredient input with regex parsing on execution. + +### Metadata + +```json +{ + "uuid": "recipe-input", + "title": "Recipe Input & Parser", + "pos": [ + 100.0, + 200.0 + ], + "size": [ + 276, + 512 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "recipe_name": "", + "servings": 4, + "ingredients_text": "" + } +} +``` + +### Logic + +```python +import re +from typing import List, Tuple, Dict + +@node_entry +def parse_recipe(recipe_name: str, servings: int, ingredients_text: str) -> Tuple[str, int, List[Dict]]: + # Parse ingredients from text + ingredients = [] + lines = [line.strip() for line in ingredients_text.split('\n') if line.strip()] + + for line in lines: + # Try to extract quantity, unit, and ingredient name + # Pattern: number unit ingredient (e.g., "2 cups flour") + match = re.match(r'([\d.]+)\s*([a-zA-Z]*)?\s+(.+)', line) + + if match: + quantity = float(match.group(1)) + unit = match.group(2) if match.group(2) else "item" + name = match.group(3).strip() + else: + # If no quantity found, assume 1 item + quantity = 1.0 + unit = "item" + name = line + + ingredients.append({ + 'name': name, + 'quantity': quantity, + 'unit': unit, + 'original_line': line + }) + + print(f"\n=== RECIPE PARSING ===") + print(f"Recipe: {recipe_name}") + print(f"Servings: {servings}") + print(f"Parsed {len(ingredients)} ingredients:") + for ing in ingredients: + print(f" - {ing['quantity']} {ing['unit']} {ing['name']}") + + return recipe_name, servings, ingredients +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QSpinBox, QTextEdit, QPushButton + +layout.addWidget(QLabel('Recipe Name:', parent)) +widgets['recipe_name'] = QLineEdit(parent) +widgets['recipe_name'].setPlaceholderText('Enter recipe name...') +layout.addWidget(widgets['recipe_name']) + +layout.addWidget(QLabel('Number of Servings:', parent)) +widgets['servings'] = QSpinBox(parent) +widgets['servings'].setRange(1, 20) +widgets['servings'].setValue(4) +layout.addWidget(widgets['servings']) + +layout.addWidget(QLabel('Ingredients (one per line):', parent)) +widgets['ingredients_text'] = QTextEdit(parent) +widgets['ingredients_text'].setMinimumHeight(150) +widgets['ingredients_text'].setPlaceholderText('Example:\n2 cups flour\n3 eggs\n1 cup milk\n1 tsp salt') +layout.addWidget(widgets['ingredients_text']) + +widgets['parse_btn'] = QPushButton('Parse Recipe', parent) +layout.addWidget(widgets['parse_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'recipe_name': widgets['recipe_name'].text(), + 'servings': widgets['servings'].value(), + 'ingredients_text': widgets['ingredients_text'].toPlainText() + } + +def set_initial_state(widgets, state): + widgets['recipe_name'].setText(state.get('recipe_name', '')) + widgets['servings'].setValue(state.get('servings', 4)) + widgets['ingredients_text'].setPlainText(state.get('ingredients_text', '')) +``` + + +## Node: Nutrition Database Lookup (ID: nutrition-database) + +Matches ingredient names against hardcoded nutrition_db dictionary using string.lower() and substring matching. Database contains calories, protein, carbs, fat per 100g plus unit_conversion factors for common measurements. Implements unit conversion logic for cups, tablespoons, grams, kilograms, pounds, ounces with mathematical scaling. + +Calculates nutrition values using factor = grams / 100 multiplication against database values. Returns enriched ingredient list with added 'matched_food', 'grams', 'calories', 'protein', 'carbs', 'fat' fields. Unknown ingredients receive zero nutrition values and 'Unknown' matched_food designation. + +### Metadata + +```json +{ + "uuid": "nutrition-database", + "title": "Nutrition Database Lookup", + "pos": [ + 470.0, + 150.0 + ], + "size": [ + 250, + 68 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict + +@node_entry +def lookup_nutrition(ingredients: List[Dict]) -> List[Dict]: + # Simplified nutrition database (calories per 100g/100ml/1 item) + nutrition_db = { + 'flour': {'calories': 364, 'protein': 10.3, 'carbs': 76.3, 'fat': 1.0, 'unit_conversion': {'cup': 125}}, + 'eggs': {'calories': 155, 'protein': 13.0, 'carbs': 1.1, 'fat': 11.0, 'unit_conversion': {'item': 50}}, + 'milk': {'calories': 42, 'protein': 3.4, 'carbs': 5.0, 'fat': 1.0, 'unit_conversion': {'cup': 240}}, + 'butter': {'calories': 717, 'protein': 0.9, 'carbs': 0.1, 'fat': 81.0, 'unit_conversion': {'tbsp': 14, 'cup': 227}}, + 'sugar': {'calories': 387, 'protein': 0, 'carbs': 100, 'fat': 0, 'unit_conversion': {'cup': 200, 'tbsp': 12}}, + 'salt': {'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, 'unit_conversion': {'tsp': 6}}, + 'chicken breast': {'calories': 165, 'protein': 31.0, 'carbs': 0, 'fat': 3.6, 'unit_conversion': {'item': 200}}, + 'rice': {'calories': 130, 'protein': 2.7, 'carbs': 28, 'fat': 0.3, 'unit_conversion': {'cup': 195}}, + 'cheese': {'calories': 113, 'protein': 7.0, 'carbs': 1.0, 'fat': 9.0, 'unit_conversion': {'cup': 113}}, + 'oil': {'calories': 884, 'protein': 0, 'carbs': 0, 'fat': 100, 'unit_conversion': {'tbsp': 14}}, + 'bread': {'calories': 265, 'protein': 9.0, 'carbs': 49, 'fat': 3.2, 'unit_conversion': {'slice': 25}}, + 'potato': {'calories': 77, 'protein': 2.0, 'carbs': 17, 'fat': 0.1, 'unit_conversion': {'item': 150}} + } + + enriched_ingredients = [] + + for ingredient in ingredients: + name = ingredient['name'].lower() + quantity = ingredient['quantity'] + unit = ingredient['unit'].lower() + + # Find matching nutrition data + nutrition = None + matched_name = None + + for db_name, db_nutrition in nutrition_db.items(): + if db_name in name or any(word in name for word in db_name.split()): + nutrition = db_nutrition + matched_name = db_name + break + + if nutrition: + # Convert to grams + if unit in nutrition['unit_conversion']: + grams = quantity * nutrition['unit_conversion'][unit] + elif unit in ['g', 'gram', 'grams']: + grams = quantity + elif unit in ['kg', 'kilogram']: + grams = quantity * 1000 + elif unit in ['lb', 'pound']: + grams = quantity * 453.592 + elif unit in ['oz', 'ounce']: + grams = quantity * 28.3495 + else: + grams = quantity * 100 # Default assumption + + # Calculate nutrition per ingredient + factor = grams / 100 # Nutrition data is per 100g + + enriched_ingredient = ingredient.copy() + enriched_ingredient.update({ + 'matched_food': matched_name, + 'grams': round(grams, 1), + 'calories': round(nutrition['calories'] * factor, 1), + 'protein': round(nutrition['protein'] * factor, 1), + 'carbs': round(nutrition['carbs'] * factor, 1), + 'fat': round(nutrition['fat'] * factor, 1) + }) + else: + # Unknown ingredient + enriched_ingredient = ingredient.copy() + enriched_ingredient.update({ + 'matched_food': 'Unknown', + 'grams': 0, + 'calories': 0, + 'protein': 0, + 'carbs': 0, + 'fat': 0 + }) + + enriched_ingredients.append(enriched_ingredient) + + print(f"\n=== NUTRITION LOOKUP ===") + for ing in enriched_ingredients: + if ing['matched_food'] != 'Unknown': + print(f"{ing['name']}: {ing['calories']} cal, {ing['protein']}g protein") + else: + print(f"{ing['name']}: No nutrition data found") + + return enriched_ingredients +``` + + +## Node: Nutrition Calculator (ID: nutrition-calculator) + +Aggregates nutrition values from enriched ingredients using sum() operations on calories, protein, carbs, fat, grams fields. Calculates per-serving values using division by servings count with round() for decimal precision. Implements macronutrient percentage calculations using 4 cal/g for protein and carbs, 9 cal/g for fat. + +Returns Tuple[Dict, Dict, str] containing total nutrition dictionary, per-serving dictionary, and analysis string. Analysis includes calorie distribution percentages, meal classification based on per-serving calories (<200 light, <500 moderate, >500 hearty), and high protein/fat content flags (>=20g protein, >20g fat). + +### Metadata + +```json +{ + "uuid": "nutrition-calculator", + "title": "Nutrition Calculator", + "pos": [ + 850.0, + 200.0 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple + +@node_entry +def calculate_nutrition(recipe_name: str, servings: int, ingredients: List[Dict]) -> Tuple[Dict, Dict, str]: + # Calculate total nutrition + total = { + 'calories': 0, + 'protein': 0, + 'carbs': 0, + 'fat': 0, + 'grams': 0 + } + + for ingredient in ingredients: + total['calories'] += ingredient.get('calories', 0) + total['protein'] += ingredient.get('protein', 0) + total['carbs'] += ingredient.get('carbs', 0) + total['fat'] += ingredient.get('fat', 0) + total['grams'] += ingredient.get('grams', 0) + + # Round totals + for key in total: + total[key] = round(total[key], 1) + + # Calculate per serving + per_serving = { + 'calories': round(total['calories'] / servings, 1), + 'protein': round(total['protein'] / servings, 1), + 'carbs': round(total['carbs'] / servings, 1), + 'fat': round(total['fat'] / servings, 1), + 'grams': round(total['grams'] / servings, 1) + } + + # Generate nutrition analysis + analysis = f"Recipe: {recipe_name}\n" + analysis += f"Total weight: {total['grams']}g\n" + analysis += f"Servings: {servings}\n\n" + + # Calorie distribution + if total['calories'] > 0: + protein_cal = total['protein'] * 4 + carbs_cal = total['carbs'] * 4 + fat_cal = total['fat'] * 9 + + protein_pct = round((protein_cal / total['calories']) * 100, 1) + carbs_pct = round((carbs_cal / total['calories']) * 100, 1) + fat_pct = round((fat_cal / total['calories']) * 100, 1) + + analysis += f"Macronutrient distribution:\n" + analysis += f"Protein: {protein_pct}%, Carbs: {carbs_pct}%, Fat: {fat_pct}%\n\n" + + # Health assessment + if per_serving['calories'] < 200: + analysis += "Light meal/snack\n" + elif per_serving['calories'] < 500: + analysis += "Moderate meal\n" + else: + analysis += "Hearty meal\n" + + if per_serving['protein'] >= 20: + analysis += "High protein content\n" + + if per_serving['fat'] > 20: + analysis += "High fat content\n" + + print(f"\n=== NUTRITION CALCULATION ===") + print(f"Total: {total['calories']} cal, {total['protein']}g protein") + print(f"Per serving: {per_serving['calories']} cal, {per_serving['protein']}g protein") + print(f"Analysis: {analysis}") + + return total, per_serving, analysis +``` + + +## Node: Nutrition Report Generator (ID: nutrition-report) + +Formats nutrition data into structured report using string concatenation with fixed-width formatting. Creates sections for recipe overview, ingredient breakdown, total nutrition, per-serving values, macronutrient percentages, and analysis text. Uses f-string formatting with width specifiers for column alignment (:8.1f for numeric fields). + +Implements conditional display logic - only shows ingredient nutrition when matched_food != 'Unknown'. Calculates macronutrient percentages using protein*4 + carbs*4 + fat*9 calorie conversion. Report includes QTextEdit display with Courier New monospace font and action buttons for save/scale/new recipe functionality. + +### Metadata + +```json +{ + "uuid": "nutrition-report", + "title": "Nutrition Report Generator", + "pos": [ + 1220.0, + 200.0 + ], + "size": [ + 276, + 623 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import Dict, List + +@node_entry +def generate_nutrition_report(recipe_name: str, servings: int, ingredients: List[Dict], total_nutrition: Dict, per_serving: Dict, analysis: str) -> str: + report = "\n" + "="*70 + "\n" + report += " NUTRITION REPORT\n" + report += "="*70 + "\n\n" + + # Recipe Overview + report += f"🍽️ RECIPE: {recipe_name.upper()}\n" + report += f"👥 Servings: {servings}\n" + report += f"⚖️ Total Weight: {total_nutrition['grams']}g\n\n" + + # Ingredients List + report += f"📋 INGREDIENTS\n" + for i, ing in enumerate(ingredients, 1): + if ing.get('matched_food', 'Unknown') != 'Unknown': + report += f" {i:2d}. {ing['original_line']}\n" + report += f" ({ing['grams']}g, {ing['calories']} cal, {ing['protein']}g protein)\n" + else: + report += f" {i:2d}. {ing['original_line']} (nutrition data unavailable)\n" + report += "\n" + + # Total Nutrition + report += f"📊 TOTAL NUTRITION\n" + report += f" Calories: {total_nutrition['calories']:8.1f} kcal\n" + report += f" Protein: {total_nutrition['protein']:8.1f} g\n" + report += f" Carbohydrates:{total_nutrition['carbs']:8.1f} g\n" + report += f" Fat: {total_nutrition['fat']:8.1f} g\n\n" + + # Per Serving + report += f"🍽️ PER SERVING\n" + report += f" Calories: {per_serving['calories']:8.1f} kcal\n" + report += f" Protein: {per_serving['protein']:8.1f} g\n" + report += f" Carbohydrates:{per_serving['carbs']:8.1f} g\n" + report += f" Fat: {per_serving['fat']:8.1f} g\n" + report += f" Weight: {per_serving['grams']:8.1f} g\n\n" + + # Macronutrient Breakdown + if total_nutrition['calories'] > 0: + protein_cal = total_nutrition['protein'] * 4 + carbs_cal = total_nutrition['carbs'] * 4 + fat_cal = total_nutrition['fat'] * 9 + + protein_pct = (protein_cal / total_nutrition['calories']) * 100 + carbs_pct = (carbs_cal / total_nutrition['calories']) * 100 + fat_pct = (fat_cal / total_nutrition['calories']) * 100 + + report += f"📈 MACRONUTRIENT BREAKDOWN\n" + report += f" Protein: {protein_pct:5.1f}% ({protein_cal:.0f} kcal)\n" + report += f" Carbohydrates:{carbs_pct:5.1f}% ({carbs_cal:.0f} kcal)\n" + report += f" Fat: {fat_pct:5.1f}% ({fat_cal:.0f} kcal)\n\n" + + # Analysis + report += f"💡 ANALYSIS\n" + for line in analysis.split('\n'): + if line.strip(): + report += f" • {line.strip()}\n" + + report += "\n" + "="*70 + + print(report) + return report +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Nutrition Report', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['report_display'] = QTextEdit(parent) +widgets['report_display'].setMinimumHeight(250) +widgets['report_display'].setReadOnly(True) +widgets['report_display'].setPlainText('Enter recipe ingredients to generate nutrition report...') +font = QFont('Courier New', 9) +widgets['report_display'].setFont(font) +layout.addWidget(widgets['report_display']) + +widgets['save_report_btn'] = QPushButton('Save Report', parent) +layout.addWidget(widgets['save_report_btn']) + +widgets['scale_recipe_btn'] = QPushButton('Scale Recipe', parent) +layout.addWidget(widgets['scale_recipe_btn']) + +widgets['new_recipe_btn'] = QPushButton('New Recipe', parent) +layout.addWidget(widgets['new_recipe_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + report = outputs.get('output_1', 'No report data') + widgets['report_display'].setPlainText(report) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "recipe-input", + "start_pin_name": "output_1", + "end_node_uuid": "nutrition-calculator", + "end_pin_name": "recipe_name" + }, + { + "start_node_uuid": "recipe-input", + "start_pin_name": "output_2", + "end_node_uuid": "nutrition-calculator", + "end_pin_name": "servings" + }, + { + "start_node_uuid": "nutrition-calculator", + "start_pin_name": "exec_out", + "end_node_uuid": "nutrition-report", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "recipe-input", + "start_pin_name": "output_1", + "end_node_uuid": "nutrition-report", + "end_pin_name": "recipe_name" + }, + { + "start_node_uuid": "recipe-input", + "start_pin_name": "output_2", + "end_node_uuid": "nutrition-report", + "end_pin_name": "servings" + }, + { + "start_node_uuid": "nutrition-calculator", + "start_pin_name": "output_1", + "end_node_uuid": "nutrition-report", + "end_pin_name": "total_nutrition" + }, + { + "start_node_uuid": "nutrition-calculator", + "start_pin_name": "output_2", + "end_node_uuid": "nutrition-report", + "end_pin_name": "per_serving" + }, + { + "start_node_uuid": "nutrition-calculator", + "start_pin_name": "output_3", + "end_node_uuid": "nutrition-report", + "end_pin_name": "analysis" + } +] +``` diff --git a/examples/social_media_scheduler.json b/examples/social_media_scheduler.json deleted file mode 100644 index ecad185..0000000 --- a/examples/social_media_scheduler.json +++ /dev/null @@ -1,183 +0,0 @@ -{ - "nodes": [ - { - "uuid": "content-creator", - "title": "Content Creator & Editor", - "pos": [100, 200], - "size": [320, 300], - "code": "import datetime\nfrom typing import Tuple\n\n@node_entry\ndef create_content(content_text: str, platform: str, content_type: str, hashtags: str, schedule_time: str) -> Tuple[str, str, str, str, str]:\n # Process hashtags\n processed_hashtags = [tag.strip() for tag in hashtags.split(',') if tag.strip()]\n if not any(tag.startswith('#') for tag in processed_hashtags):\n processed_hashtags = ['#' + tag for tag in processed_hashtags]\n hashtag_text = ' '.join(processed_hashtags[:10]) # Limit to 10 hashtags\n \n # Optimize content for platform\n if platform == \"Twitter\":\n max_length = 280 - len(hashtag_text) - 1\n if len(content_text) > max_length:\n content_text = content_text[:max_length-3] + \"...\"\n elif platform == \"Instagram\":\n max_length = 2200\n if len(content_text) > max_length:\n content_text = content_text[:max_length-3] + \"...\"\n elif platform == \"LinkedIn\":\n max_length = 3000\n if len(content_text) > max_length:\n content_text = content_text[:max_length-3] + \"...\"\n \n # Combine content with hashtags\n final_content = f\"{content_text}\\n\\n{hashtag_text}\" if hashtag_text else content_text\n \n # Validate schedule time\n try:\n datetime.datetime.strptime(schedule_time, \"%Y-%m-%d %H:%M\")\n schedule_status = \"Valid\"\n except:\n schedule_status = \"Invalid format (use YYYY-MM-DD HH:MM)\"\n \n print(f\"Content created for {platform}\")\n print(f\"Type: {content_type}\")\n print(f\"Length: {len(final_content)} characters\")\n print(f\"Hashtags: {len(processed_hashtags)}\")\n print(f\"Schedule: {schedule_time} ({schedule_status})\")\n \n return final_content, platform, content_type, hashtag_text, schedule_status", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QComboBox, QLineEdit, QPushButton, QDateTimeEdit\nfrom PySide6.QtCore import QDateTime\n\nlayout.addWidget(QLabel('Platform:', parent))\nwidgets['platform'] = QComboBox(parent)\nwidgets['platform'].addItems(['Twitter', 'Instagram', 'LinkedIn', 'Facebook'])\nlayout.addWidget(widgets['platform'])\n\nlayout.addWidget(QLabel('Content Type:', parent))\nwidgets['content_type'] = QComboBox(parent)\nwidgets['content_type'].addItems(['Post', 'Story', 'Article', 'Promotion', 'Update'])\nlayout.addWidget(widgets['content_type'])\n\nlayout.addWidget(QLabel('Content:', parent))\nwidgets['content_text'] = QTextEdit(parent)\nwidgets['content_text'].setMinimumHeight(100)\nwidgets['content_text'].setPlaceholderText('Write your content here...')\nlayout.addWidget(widgets['content_text'])\n\nlayout.addWidget(QLabel('Hashtags (comma-separated):', parent))\nwidgets['hashtags'] = QLineEdit(parent)\nwidgets['hashtags'].setPlaceholderText('marketing, social, business')\nlayout.addWidget(widgets['hashtags'])\n\nlayout.addWidget(QLabel('Schedule Time (YYYY-MM-DD HH:MM):', parent))\nwidgets['schedule_time'] = QLineEdit(parent)\nwidgets['schedule_time'].setPlaceholderText('2024-12-25 14:30')\nlayout.addWidget(widgets['schedule_time'])\n\nwidgets['create_btn'] = QPushButton('Create Content', parent)\nlayout.addWidget(widgets['create_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'content_text': widgets['content_text'].toPlainText(),\n 'platform': widgets['platform'].currentText(),\n 'content_type': widgets['content_type'].currentText(),\n 'hashtags': widgets['hashtags'].text(),\n 'schedule_time': widgets['schedule_time'].text()\n }\n\ndef set_initial_state(widgets, state):\n widgets['content_text'].setPlainText(state.get('content_text', ''))\n widgets['platform'].setCurrentText(state.get('platform', 'Twitter'))\n widgets['content_type'].setCurrentText(state.get('content_type', 'Post'))\n widgets['hashtags'].setText(state.get('hashtags', ''))\n widgets['schedule_time'].setText(state.get('schedule_time', ''))", - "gui_state": { - "content_text": "", - "platform": "Twitter", - "content_type": "Post", - "hashtags": "", - "schedule_time": "" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "engagement-optimizer", - "title": "Engagement Optimizer", - "pos": [480, 150], - "size": [300, 250], - "code": "import re\nfrom typing import Tuple\n\n@node_entry\ndef optimize_engagement(content: str, platform: str) -> Tuple[int, str, str]:\n score = 0\n suggestions = []\n \n # Content length scoring\n content_length = len(content)\n if platform == \"Twitter\":\n if 100 <= content_length <= 280:\n score += 20\n else:\n suggestions.append(\"Optimize length for Twitter (100-280 chars)\")\n elif platform == \"Instagram\":\n if 150 <= content_length <= 300:\n score += 20\n else:\n suggestions.append(\"Instagram posts perform better with 150-300 characters\")\n elif platform == \"LinkedIn\":\n if 200 <= content_length <= 600:\n score += 20\n else:\n suggestions.append(\"LinkedIn content works best with 200-600 characters\")\n \n # Hashtag analysis\n hashtags = re.findall(r'#\\w+', content)\n if platform == \"Instagram\":\n if 5 <= len(hashtags) <= 11:\n score += 15\n else:\n suggestions.append(\"Use 5-11 hashtags for Instagram\")\n elif platform == \"Twitter\":\n if 1 <= len(hashtags) <= 3:\n score += 15\n else:\n suggestions.append(\"Use 1-3 hashtags for Twitter\")\n elif platform == \"LinkedIn\":\n if 1 <= len(hashtags) <= 5:\n score += 15\n else:\n suggestions.append(\"Use 1-5 hashtags for LinkedIn\")\n \n # Engagement elements\n if any(word in content.lower() for word in ['?', 'what', 'how', 'why', 'when']):\n score += 15\n else:\n suggestions.append(\"Add questions to encourage engagement\")\n \n if any(word in content.lower() for word in ['share', 'comment', 'like', 'follow', 'subscribe']):\n score += 10\n else:\n suggestions.append(\"Include call-to-action words\")\n \n # Readability\n sentences = re.split(r'[.!?]+', content)\n avg_sentence_length = sum(len(s.split()) for s in sentences if s.strip()) / max(len([s for s in sentences if s.strip()]), 1)\n \n if avg_sentence_length <= 20:\n score += 10\n else:\n suggestions.append(\"Use shorter sentences for better readability\")\n \n # Special characters and emojis\n if re.search(r'[!@#$%^&*()_+{}|:<>?]', content) or any(ord(char) > 127 for char in content):\n score += 10\n else:\n suggestions.append(\"Add emojis or special characters for visual appeal\")\n \n # Generate performance prediction\n if score >= 70:\n performance = \"High engagement expected\"\n elif score >= 50:\n performance = \"Good engagement potential\"\n elif score >= 30:\n performance = \"Moderate engagement expected\"\n else:\n performance = \"Low engagement predicted\"\n \n suggestion_text = '; '.join(suggestions) if suggestions else \"Content optimized for engagement!\"\n \n print(f\"\\n=== ENGAGEMENT ANALYSIS ===\")\n print(f\"Platform: {platform}\")\n print(f\"Engagement score: {score}/80\")\n print(f\"Performance prediction: {performance}\")\n print(f\"Suggestions: {suggestion_text}\")\n \n return score, performance, suggestion_text", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "schedule-manager", - "title": "Schedule Manager", - "pos": [840, 200], - "size": [280, 250], - "code": "import datetime\nfrom typing import Tuple\n\n@node_entry\ndef manage_schedule(content: str, platform: str, schedule_time: str, schedule_status: str) -> Tuple[str, str, str]:\n if schedule_status != \"Valid\":\n return \"Error\", \"Invalid schedule time format\", \"Failed\"\n \n try:\n scheduled_dt = datetime.datetime.strptime(schedule_time, \"%Y-%m-%d %H:%M\")\n current_dt = datetime.datetime.now()\n \n if scheduled_dt <= current_dt:\n return \"Error\", \"Cannot schedule in the past\", \"Failed\"\n \n # Calculate time until posting\n time_diff = scheduled_dt - current_dt\n days = time_diff.days\n hours, remainder = divmod(time_diff.seconds, 3600)\n minutes, _ = divmod(remainder, 60)\n \n time_until = f\"{days}d {hours}h {minutes}m\"\n \n # Determine optimal posting time recommendations\n hour = scheduled_dt.hour\n weekday = scheduled_dt.weekday() # 0=Monday, 6=Sunday\n \n optimal_recommendations = []\n \n if platform == \"Instagram\":\n if not (11 <= hour <= 13 or 17 <= hour <= 19):\n optimal_recommendations.append(\"Instagram: Best times are 11AM-1PM or 5PM-7PM\")\n elif platform == \"Twitter\":\n if not (8 <= hour <= 10 or 19 <= hour <= 21):\n optimal_recommendations.append(\"Twitter: Best times are 8AM-10AM or 7PM-9PM\")\n elif platform == \"LinkedIn\":\n if weekday >= 5: # Weekend\n optimal_recommendations.append(\"LinkedIn: Weekdays perform better than weekends\")\n if not (8 <= hour <= 10 or 17 <= hour <= 18):\n optimal_recommendations.append(\"LinkedIn: Best times are 8AM-10AM or 5PM-6PM\")\n \n recommendations = '; '.join(optimal_recommendations) if optimal_recommendations else \"Scheduled at optimal time!\"\n \n print(f\"\\n=== SCHEDULE MANAGEMENT ===\")\n print(f\"Platform: {platform}\")\n print(f\"Scheduled for: {schedule_time}\")\n print(f\"Time until posting: {time_until}\")\n print(f\"Recommendations: {recommendations}\")\n \n return \"Scheduled\", time_until, recommendations\n \n except Exception as e:\n error_msg = f\"Scheduling error: {str(e)}\"\n print(error_msg)\n return \"Error\", error_msg, \"Failed\"", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "post-dashboard", - "title": "Social Media Dashboard", - "pos": [1190, 200], - "size": [380, 350], - "code": "from typing import Tuple\n\n@node_entry\ndef create_dashboard(content: str, platform: str, content_type: str, hashtags: str, engagement_score: int, performance_prediction: str, suggestions: str, schedule_status: str, time_until: str, recommendations: str) -> str:\n dashboard = \"\\n\" + \"=\"*60 + \"\\n\"\n dashboard += \" SOCIAL MEDIA POST DASHBOARD\\n\"\n dashboard += \"=\"*60 + \"\\n\\n\"\n \n # Post Overview\n dashboard += f\"📱 POST OVERVIEW\\n\"\n dashboard += f\" Platform: {platform}\\n\"\n dashboard += f\" Content Type: {content_type}\\n\"\n dashboard += f\" Character Count: {len(content)}\\n\"\n hashtag_count = len([tag for tag in hashtags.split() if tag.startswith('#')])\n dashboard += f\" Hashtags: {hashtag_count}\\n\\n\"\n \n # Content Preview\n dashboard += f\"📝 CONTENT PREVIEW\\n\"\n preview = content[:150] + \"...\" if len(content) > 150 else content\n dashboard += f\" {preview}\\n\\n\"\n \n # Engagement Analysis\n dashboard += f\"📊 ENGAGEMENT ANALYSIS\\n\"\n dashboard += f\" Score: {engagement_score}/80\\n\"\n dashboard += f\" Prediction: {performance_prediction}\\n\"\n if suggestions != \"Content optimized for engagement!\":\n dashboard += f\" Suggestions: {suggestions}\\n\"\n dashboard += \"\\n\"\n \n # Schedule Information\n dashboard += f\"⏰ SCHEDULE STATUS\\n\"\n dashboard += f\" Status: {schedule_status}\\n\"\n if schedule_status == \"Scheduled\":\n dashboard += f\" Time until posting: {time_until}\\n\"\n if recommendations != \"Scheduled at optimal time!\":\n dashboard += f\" Timing notes: {recommendations}\\n\"\n elif schedule_status == \"Error\":\n dashboard += f\" Issue: {time_until}\\n\"\n dashboard += \"\\n\"\n \n # Action Items\n dashboard += f\"✅ NEXT STEPS\\n\"\n if schedule_status == \"Scheduled\":\n dashboard += f\" • Content ready for posting\\n\"\n dashboard += f\" • Monitor engagement after posting\\n\"\n dashboard += f\" • Prepare follow-up content\\n\"\n else:\n dashboard += f\" • Fix scheduling issues\\n\"\n dashboard += f\" • Review content optimization\\n\"\n dashboard += f\" • Test posting setup\\n\"\n \n dashboard += \"\\n\" + \"=\"*60\n \n print(dashboard)\n return dashboard", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Social Media Dashboard', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['dashboard_display'] = QTextEdit(parent)\nwidgets['dashboard_display'].setMinimumHeight(220)\nwidgets['dashboard_display'].setReadOnly(True)\nwidgets['dashboard_display'].setPlainText('Create content to see dashboard...')\nfont = QFont('Courier New', 9)\nwidgets['dashboard_display'].setFont(font)\nlayout.addWidget(widgets['dashboard_display'])\n\nwidgets['post_now_btn'] = QPushButton('Post Now', parent)\nlayout.addWidget(widgets['post_now_btn'])\n\nwidgets['edit_content_btn'] = QPushButton('Edit Content', parent)\nlayout.addWidget(widgets['edit_content_btn'])\n\nwidgets['duplicate_btn'] = QPushButton('Duplicate for Other Platform', parent)\nlayout.addWidget(widgets['duplicate_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n dashboard = outputs.get('output_1', 'No dashboard data')\n widgets['dashboard_display'].setPlainText(dashboard)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "content-creator", - "start_pin_name": "exec_out", - "end_node_uuid": "engagement-optimizer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_1", - "end_node_uuid": "engagement-optimizer", - "end_pin_name": "content" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_2", - "end_node_uuid": "engagement-optimizer", - "end_pin_name": "platform" - }, - { - "start_node_uuid": "engagement-optimizer", - "start_pin_name": "exec_out", - "end_node_uuid": "schedule-manager", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_1", - "end_node_uuid": "schedule-manager", - "end_pin_name": "content" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_2", - "end_node_uuid": "schedule-manager", - "end_pin_name": "platform" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "schedule_time", - "end_node_uuid": "schedule-manager", - "end_pin_name": "schedule_time" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_5", - "end_node_uuid": "schedule-manager", - "end_pin_name": "schedule_status" - }, - { - "start_node_uuid": "schedule-manager", - "start_pin_name": "exec_out", - "end_node_uuid": "post-dashboard", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_1", - "end_node_uuid": "post-dashboard", - "end_pin_name": "content" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_2", - "end_node_uuid": "post-dashboard", - "end_pin_name": "platform" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_3", - "end_node_uuid": "post-dashboard", - "end_pin_name": "content_type" - }, - { - "start_node_uuid": "content-creator", - "start_pin_name": "output_4", - "end_node_uuid": "post-dashboard", - "end_pin_name": "hashtags" - }, - { - "start_node_uuid": "engagement-optimizer", - "start_pin_name": "output_1", - "end_node_uuid": "post-dashboard", - "end_pin_name": "engagement_score" - }, - { - "start_node_uuid": "engagement-optimizer", - "start_pin_name": "output_2", - "end_node_uuid": "post-dashboard", - "end_pin_name": "performance_prediction" - }, - { - "start_node_uuid": "engagement-optimizer", - "start_pin_name": "output_3", - "end_node_uuid": "post-dashboard", - "end_pin_name": "suggestions" - }, - { - "start_node_uuid": "schedule-manager", - "start_pin_name": "output_1", - "end_node_uuid": "post-dashboard", - "end_pin_name": "schedule_status" - }, - { - "start_node_uuid": "schedule-manager", - "start_pin_name": "output_2", - "end_node_uuid": "post-dashboard", - "end_pin_name": "time_until" - }, - { - "start_node_uuid": "schedule-manager", - "start_pin_name": "output_3", - "end_node_uuid": "post-dashboard", - "end_pin_name": "recommendations" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/social_media_scheduler.md b/examples/social_media_scheduler.md new file mode 100644 index 0000000..c251b7c --- /dev/null +++ b/examples/social_media_scheduler.md @@ -0,0 +1,600 @@ +# Social Media Scheduler + +Social media content management workflow with platform-specific character limits, engagement scoring algorithms, datetime scheduling validation, and dashboard report generation. Implements string length checking, regex pattern matching, datetime.strptime() parsing, and formatted text output for multi-platform posting optimization. + +## Node: Content Creator & Editor (ID: content-creator) + +Processes social media content with platform-specific character limits: Twitter 280, Instagram 2200, LinkedIn 3000 characters. Uses string.split(',') to parse hashtags, adds '#' prefix if missing, limits to 10 hashtags using slice [:10]. Implements string truncation with [...3] + "..." for content overflow. + +Validates schedule time using datetime.strptime() with "%Y-%m-%d %H:%M" format. Returns Tuple[str, str, str, str, str] containing final_content, platform, content_type, hashtag_text, schedule_status. GUI includes QComboBox for platform/type selection, QTextEdit for content, QLineEdit for hashtags and scheduling. + +### Metadata + +```json +{ + "uuid": "content-creator", + "title": "Content Creator & Editor", + "pos": [ + -0.37774999999993497, + 200.00000000000003 + ], + "size": [ + 276, + 664 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "content_text": "", + "platform": "Twitter", + "content_type": "Post", + "hashtags": "", + "schedule_time": "" + } +} +``` + +### Logic + +```python +import datetime +from typing import Tuple + +@node_entry +def create_content(content_text: str, platform: str, content_type: str, hashtags: str, schedule_time: str) -> Tuple[str, str, str, str, str]: + # Process hashtags + processed_hashtags = [tag.strip() for tag in hashtags.split(',') if tag.strip()] + if not any(tag.startswith('#') for tag in processed_hashtags): + processed_hashtags = ['#' + tag for tag in processed_hashtags] + hashtag_text = ' '.join(processed_hashtags[:10]) # Limit to 10 hashtags + + # Optimize content for platform + if platform == "Twitter": + max_length = 280 - len(hashtag_text) - 1 + if len(content_text) > max_length: + content_text = content_text[:max_length-3] + "..." + elif platform == "Instagram": + max_length = 2200 + if len(content_text) > max_length: + content_text = content_text[:max_length-3] + "..." + elif platform == "LinkedIn": + max_length = 3000 + if len(content_text) > max_length: + content_text = content_text[:max_length-3] + "..." + + # Combine content with hashtags + final_content = f"{content_text}\n\n{hashtag_text}" if hashtag_text else content_text + + # Validate schedule time + try: + datetime.datetime.strptime(schedule_time, "%Y-%m-%d %H:%M") + schedule_status = "Valid" + except: + schedule_status = "Invalid format (use YYYY-MM-DD HH:MM)" + + print(f"Content created for {platform}") + print(f"Type: {content_type}") + print(f"Length: {len(final_content)} characters") + print(f"Hashtags: {len(processed_hashtags)}") + print(f"Schedule: {schedule_time} ({schedule_status})") + + return final_content, platform, content_type, hashtag_text, schedule_status +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QComboBox, QLineEdit, QPushButton, QDateTimeEdit +from PySide6.QtCore import QDateTime + +layout.addWidget(QLabel('Platform:', parent)) +widgets['platform'] = QComboBox(parent) +widgets['platform'].addItems(['Twitter', 'Instagram', 'LinkedIn', 'Facebook']) +layout.addWidget(widgets['platform']) + +layout.addWidget(QLabel('Content Type:', parent)) +widgets['content_type'] = QComboBox(parent) +widgets['content_type'].addItems(['Post', 'Story', 'Article', 'Promotion', 'Update']) +layout.addWidget(widgets['content_type']) + +layout.addWidget(QLabel('Content:', parent)) +widgets['content_text'] = QTextEdit(parent) +widgets['content_text'].setMinimumHeight(100) +widgets['content_text'].setPlaceholderText('Write your content here...') +layout.addWidget(widgets['content_text']) + +layout.addWidget(QLabel('Hashtags (comma-separated):', parent)) +widgets['hashtags'] = QLineEdit(parent) +widgets['hashtags'].setPlaceholderText('marketing, social, business') +layout.addWidget(widgets['hashtags']) + +layout.addWidget(QLabel('Schedule Time (YYYY-MM-DD HH:MM):', parent)) +widgets['schedule_time'] = QLineEdit(parent) +widgets['schedule_time'].setPlaceholderText('2024-12-25 14:30') +layout.addWidget(widgets['schedule_time']) + +widgets['create_btn'] = QPushButton('Create Content', parent) +layout.addWidget(widgets['create_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'content_text': widgets['content_text'].toPlainText(), + 'platform': widgets['platform'].currentText(), + 'content_type': widgets['content_type'].currentText(), + 'hashtags': widgets['hashtags'].text(), + 'schedule_time': widgets['schedule_time'].text() + } + +def set_initial_state(widgets, state): + widgets['content_text'].setPlainText(state.get('content_text', '')) + widgets['platform'].setCurrentText(state.get('platform', 'Twitter')) + widgets['content_type'].setCurrentText(state.get('content_type', 'Post')) + widgets['hashtags'].setText(state.get('hashtags', '')) + widgets['schedule_time'].setText(state.get('schedule_time', '')) +``` + + +## Node: Engagement Optimizer (ID: engagement-optimizer) + +Calculates engagement score (0-80) using platform-specific length ranges, hashtag counts, and content analysis. Uses re.findall(r'#\\w+') to count hashtags, checks for question words using any() with list comprehension, analyzes call-to-action terms with string.lower() matching. + +Implements readability scoring with re.split(r'[.!?]+') for sentence parsing and average word count calculation. Detects special characters and emojis using ord(char) > 127 for Unicode. Returns Tuple[int, str, str] containing score, performance prediction (High/Good/Moderate/Low), and suggestion text joined with '; '. + +### Metadata + +```json +{ + "uuid": "engagement-optimizer", + "title": "Engagement Optimizer", + "pos": [ + 461.7495, + 52.66400000000007 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import re +from typing import Tuple + +@node_entry +def optimize_engagement(content: str, platform: str) -> Tuple[int, str, str]: + score = 0 + suggestions = [] + + # Content length scoring + content_length = len(content) + if platform == "Twitter": + if 100 <= content_length <= 280: + score += 20 + else: + suggestions.append("Optimize length for Twitter (100-280 chars)") + elif platform == "Instagram": + if 150 <= content_length <= 300: + score += 20 + else: + suggestions.append("Instagram posts perform better with 150-300 characters") + elif platform == "LinkedIn": + if 200 <= content_length <= 600: + score += 20 + else: + suggestions.append("LinkedIn content works best with 200-600 characters") + + # Hashtag analysis + hashtags = re.findall(r'#\w+', content) + if platform == "Instagram": + if 5 <= len(hashtags) <= 11: + score += 15 + else: + suggestions.append("Use 5-11 hashtags for Instagram") + elif platform == "Twitter": + if 1 <= len(hashtags) <= 3: + score += 15 + else: + suggestions.append("Use 1-3 hashtags for Twitter") + elif platform == "LinkedIn": + if 1 <= len(hashtags) <= 5: + score += 15 + else: + suggestions.append("Use 1-5 hashtags for LinkedIn") + + # Engagement elements + if any(word in content.lower() for word in ['?', 'what', 'how', 'why', 'when']): + score += 15 + else: + suggestions.append("Add questions to encourage engagement") + + if any(word in content.lower() for word in ['share', 'comment', 'like', 'follow', 'subscribe']): + score += 10 + else: + suggestions.append("Include call-to-action words") + + # Readability + sentences = re.split(r'[.!?]+', content) + avg_sentence_length = sum(len(s.split()) for s in sentences if s.strip()) / max(len([s for s in sentences if s.strip()]), 1) + + if avg_sentence_length <= 20: + score += 10 + else: + suggestions.append("Use shorter sentences for better readability") + + # Special characters and emojis + if re.search(r'[!@#$%^&*()_+{}|:<>?]', content) or any(ord(char) > 127 for char in content): + score += 10 + else: + suggestions.append("Add emojis or special characters for visual appeal") + + # Generate performance prediction + if score >= 70: + performance = "High engagement expected" + elif score >= 50: + performance = "Good engagement potential" + elif score >= 30: + performance = "Moderate engagement expected" + else: + performance = "Low engagement predicted" + + suggestion_text = '; '.join(suggestions) if suggestions else "Content optimized for engagement!" + + print(f"\n=== ENGAGEMENT ANALYSIS ===") + print(f"Platform: {platform}") + print(f"Engagement score: {score}/80") + print(f"Performance prediction: {performance}") + print(f"Suggestions: {suggestion_text}") + + return score, performance, suggestion_text +``` + + +## Node: Schedule Manager (ID: schedule-manager) + +Validates scheduled posting time using datetime.strptime() parsing and compares against datetime.now() to prevent past scheduling. Calculates time difference using divmod() for days/hours/minutes countdown display. Implements platform-specific optimal time checking: Instagram 11AM-1PM/5PM-7PM, Twitter 8AM-10AM/7PM-9PM, LinkedIn 8AM-10AM/5PM-6PM weekdays. + +Uses datetime.weekday() for weekend detection (LinkedIn weekday preference). Returns Tuple[str, str, str] containing schedule status, time_until countdown string, and timing recommendations. Error handling captures scheduling failures with try-except blocks and returns error status messages. + +### Metadata + +```json +{ + "uuid": "schedule-manager", + "title": "Schedule Manager", + "pos": [ + 794.37375, + 406.83899999999994 + ], + "size": [ + 250, + 193 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import datetime +from typing import Tuple + +@node_entry +def manage_schedule(content: str, platform: str, schedule_time: str, schedule_status: str) -> Tuple[str, str, str]: + if schedule_status != "Valid": + return "Error", "Invalid schedule time format", "Failed" + + try: + scheduled_dt = datetime.datetime.strptime(schedule_time, "%Y-%m-%d %H:%M") + current_dt = datetime.datetime.now() + + if scheduled_dt <= current_dt: + return "Error", "Cannot schedule in the past", "Failed" + + # Calculate time until posting + time_diff = scheduled_dt - current_dt + days = time_diff.days + hours, remainder = divmod(time_diff.seconds, 3600) + minutes, _ = divmod(remainder, 60) + + time_until = f"{days}d {hours}h {minutes}m" + + # Determine optimal posting time recommendations + hour = scheduled_dt.hour + weekday = scheduled_dt.weekday() # 0=Monday, 6=Sunday + + optimal_recommendations = [] + + if platform == "Instagram": + if not (11 <= hour <= 13 or 17 <= hour <= 19): + optimal_recommendations.append("Instagram: Best times are 11AM-1PM or 5PM-7PM") + elif platform == "Twitter": + if not (8 <= hour <= 10 or 19 <= hour <= 21): + optimal_recommendations.append("Twitter: Best times are 8AM-10AM or 7PM-9PM") + elif platform == "LinkedIn": + if weekday >= 5: # Weekend + optimal_recommendations.append("LinkedIn: Weekdays perform better than weekends") + if not (8 <= hour <= 10 or 17 <= hour <= 18): + optimal_recommendations.append("LinkedIn: Best times are 8AM-10AM or 5PM-6PM") + + recommendations = '; '.join(optimal_recommendations) if optimal_recommendations else "Scheduled at optimal time!" + + print(f"\n=== SCHEDULE MANAGEMENT ===") + print(f"Platform: {platform}") + print(f"Scheduled for: {schedule_time}") + print(f"Time until posting: {time_until}") + print(f"Recommendations: {recommendations}") + + return "Scheduled", time_until, recommendations + + except Exception as e: + error_msg = f"Scheduling error: {str(e)}" + print(error_msg) + return "Error", error_msg, "Failed" +``` + + +## Node: Social Media Dashboard (ID: post-dashboard) + +Formats consolidated social media data into structured dashboard using string concatenation with section headers. Implements content preview with string slicing [:150] + "..." for truncation. Calculates hashtag count using list comprehension with .startswith('#') filtering on .split() results. + +Displays engagement metrics, schedule status, and recommendations with conditional formatting based on status values. Creates action item lists using conditional logic for scheduled vs error states. Returns single formatted dashboard string with fixed-width layout for QTextEdit display using Courier New monospace font. + +### Metadata + +```json +{ + "uuid": "post-dashboard", + "title": "Social Media Dashboard", + "pos": [ + 1339.04575, + 190.87475 + ], + "size": [ + 276, + 693 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import Tuple + +@node_entry +def create_dashboard(content: str, platform: str, content_type: str, hashtags: str, engagement_score: int, performance_prediction: str, suggestions: str, schedule_status: str, time_until: str, recommendations: str) -> str: + dashboard = "\n" + "="*60 + "\n" + dashboard += " SOCIAL MEDIA POST DASHBOARD\n" + dashboard += "="*60 + "\n\n" + + # Post Overview + dashboard += f"📱 POST OVERVIEW\n" + dashboard += f" Platform: {platform}\n" + dashboard += f" Content Type: {content_type}\n" + dashboard += f" Character Count: {len(content)}\n" + hashtag_count = len([tag for tag in hashtags.split() if tag.startswith('#')]) + dashboard += f" Hashtags: {hashtag_count}\n\n" + + # Content Preview + dashboard += f"📝 CONTENT PREVIEW\n" + preview = content[:150] + "..." if len(content) > 150 else content + dashboard += f" {preview}\n\n" + + # Engagement Analysis + dashboard += f"📊 ENGAGEMENT ANALYSIS\n" + dashboard += f" Score: {engagement_score}/80\n" + dashboard += f" Prediction: {performance_prediction}\n" + if suggestions != "Content optimized for engagement!": + dashboard += f" Suggestions: {suggestions}\n" + dashboard += "\n" + + # Schedule Information + dashboard += f"⏰ SCHEDULE STATUS\n" + dashboard += f" Status: {schedule_status}\n" + if schedule_status == "Scheduled": + dashboard += f" Time until posting: {time_until}\n" + if recommendations != "Scheduled at optimal time!": + dashboard += f" Timing notes: {recommendations}\n" + elif schedule_status == "Error": + dashboard += f" Issue: {time_until}\n" + dashboard += "\n" + + # Action Items + dashboard += f"✅ NEXT STEPS\n" + if schedule_status == "Scheduled": + dashboard += f" • Content ready for posting\n" + dashboard += f" • Monitor engagement after posting\n" + dashboard += f" • Prepare follow-up content\n" + else: + dashboard += f" • Fix scheduling issues\n" + dashboard += f" • Review content optimization\n" + dashboard += f" • Test posting setup\n" + + dashboard += "\n" + "="*60 + + print(dashboard) + return dashboard +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Social Media Dashboard', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['dashboard_display'] = QTextEdit(parent) +widgets['dashboard_display'].setMinimumHeight(220) +widgets['dashboard_display'].setReadOnly(True) +widgets['dashboard_display'].setPlainText('Create content to see dashboard...') +font = QFont('Courier New', 9) +widgets['dashboard_display'].setFont(font) +layout.addWidget(widgets['dashboard_display']) + +widgets['post_now_btn'] = QPushButton('Post Now', parent) +layout.addWidget(widgets['post_now_btn']) + +widgets['edit_content_btn'] = QPushButton('Edit Content', parent) +layout.addWidget(widgets['edit_content_btn']) + +widgets['duplicate_btn'] = QPushButton('Duplicate for Other Platform', parent) +layout.addWidget(widgets['duplicate_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + dashboard = outputs.get('output_1', 'No dashboard data') + widgets['dashboard_display'].setPlainText(dashboard) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "content-creator", + "start_pin_name": "exec_out", + "end_node_uuid": "engagement-optimizer", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_1", + "end_node_uuid": "engagement-optimizer", + "end_pin_name": "content" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_2", + "end_node_uuid": "engagement-optimizer", + "end_pin_name": "platform" + }, + { + "start_node_uuid": "engagement-optimizer", + "start_pin_name": "exec_out", + "end_node_uuid": "schedule-manager", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_1", + "end_node_uuid": "schedule-manager", + "end_pin_name": "content" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_2", + "end_node_uuid": "schedule-manager", + "end_pin_name": "platform" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_5", + "end_node_uuid": "schedule-manager", + "end_pin_name": "schedule_status" + }, + { + "start_node_uuid": "schedule-manager", + "start_pin_name": "exec_out", + "end_node_uuid": "post-dashboard", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_1", + "end_node_uuid": "post-dashboard", + "end_pin_name": "content" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_2", + "end_node_uuid": "post-dashboard", + "end_pin_name": "platform" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_3", + "end_node_uuid": "post-dashboard", + "end_pin_name": "content_type" + }, + { + "start_node_uuid": "content-creator", + "start_pin_name": "output_4", + "end_node_uuid": "post-dashboard", + "end_pin_name": "hashtags" + }, + { + "start_node_uuid": "engagement-optimizer", + "start_pin_name": "output_1", + "end_node_uuid": "post-dashboard", + "end_pin_name": "engagement_score" + }, + { + "start_node_uuid": "engagement-optimizer", + "start_pin_name": "output_2", + "end_node_uuid": "post-dashboard", + "end_pin_name": "performance_prediction" + }, + { + "start_node_uuid": "engagement-optimizer", + "start_pin_name": "output_3", + "end_node_uuid": "post-dashboard", + "end_pin_name": "suggestions" + }, + { + "start_node_uuid": "schedule-manager", + "start_pin_name": "output_1", + "end_node_uuid": "post-dashboard", + "end_pin_name": "schedule_status" + }, + { + "start_node_uuid": "schedule-manager", + "start_pin_name": "output_2", + "end_node_uuid": "post-dashboard", + "end_pin_name": "time_until" + }, + { + "start_node_uuid": "schedule-manager", + "start_pin_name": "output_3", + "end_node_uuid": "post-dashboard", + "end_pin_name": "recommendations" + } +] +``` diff --git a/examples/text_processing_pipeline.json b/examples/text_processing_pipeline.json deleted file mode 100644 index 0768b85..0000000 --- a/examples/text_processing_pipeline.json +++ /dev/null @@ -1,200 +0,0 @@ -{ - "nodes": [ - { - "uuid": "text-input", - "title": "Text Input Source", - "pos": [100, 200], - "size": [300, 250], - "code": "@node_entry\ndef provide_text(input_text: str, source_type: str) -> str:\n if source_type == \"Manual\":\n result = input_text\n elif source_type == \"Lorem Ipsum\":\n result = \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\"\n elif source_type == \"Sample Article\":\n result = \"Artificial Intelligence has revolutionized many industries. Machine learning algorithms can process vast amounts of data quickly. Natural language processing enables computers to understand human text. Deep learning models achieve remarkable accuracy in image recognition. The future of AI looks promising with continued research and development.\"\n else: # Technical Text\n result = \"Python is a high-level programming language. It supports object-oriented programming paradigms. The syntax is designed to be readable and concise. Libraries like NumPy and Pandas facilitate data analysis. Django and Flask are popular web frameworks. Python's versatility makes it suitable for various applications.\"\n \n print(f\"Text source: {source_type}\")\n print(f\"Text length: {len(result)} characters\")\n print(f\"Preview: {result[:100]}...\")\n \n return result", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QComboBox, QPushButton\n\nlayout.addWidget(QLabel('Text Source:', parent))\nwidgets['source_type'] = QComboBox(parent)\nwidgets['source_type'].addItems(['Manual', 'Lorem Ipsum', 'Sample Article', 'Technical Text'])\nlayout.addWidget(widgets['source_type'])\n\nlayout.addWidget(QLabel('Enter your text (for Manual mode):', parent))\nwidgets['input_text'] = QTextEdit(parent)\nwidgets['input_text'].setMinimumHeight(120)\nwidgets['input_text'].setPlaceholderText('Type your text here...')\nlayout.addWidget(widgets['input_text'])\n\nwidgets['process_btn'] = QPushButton('Process Text', parent)\nlayout.addWidget(widgets['process_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'input_text': widgets['input_text'].toPlainText(),\n 'source_type': widgets['source_type'].currentText()\n }\n\ndef set_initial_state(widgets, state):\n widgets['input_text'].setPlainText(state.get('input_text', ''))\n widgets['source_type'].setCurrentText(state.get('source_type', 'Manual'))", - "gui_state": { - "input_text": "", - "source_type": "Manual" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "text-cleaner", - "title": "Text Cleaner & Normalizer", - "pos": [470, 150], - "size": [280, 200], - "code": "import re\nimport string\n\n@node_entry\ndef clean_text(text: str, remove_punctuation: bool, convert_lowercase: bool, remove_numbers: bool) -> str:\n cleaned = text\n \n # Remove extra whitespace\n cleaned = re.sub(r'\\s+', ' ', cleaned.strip())\n \n # Convert to lowercase\n if convert_lowercase:\n cleaned = cleaned.lower()\n \n # Remove punctuation\n if remove_punctuation:\n cleaned = cleaned.translate(str.maketrans('', '', string.punctuation))\n \n # Remove numbers\n if remove_numbers:\n cleaned = re.sub(r'\\d+', '', cleaned)\n \n # Clean up extra spaces again\n cleaned = re.sub(r'\\s+', ' ', cleaned.strip())\n \n print(f\"Original length: {len(text)}\")\n print(f\"Cleaned length: {len(cleaned)}\")\n print(f\"Cleaning options: Lowercase={convert_lowercase}, No punctuation={remove_punctuation}, No numbers={remove_numbers}\")\n \n return cleaned", - "gui_code": "from PySide6.QtWidgets import QLabel, QCheckBox, QPushButton\n\nwidgets['remove_punctuation'] = QCheckBox('Remove Punctuation', parent)\nwidgets['remove_punctuation'].setChecked(False)\nlayout.addWidget(widgets['remove_punctuation'])\n\nwidgets['convert_lowercase'] = QCheckBox('Convert to Lowercase', parent)\nwidgets['convert_lowercase'].setChecked(True)\nlayout.addWidget(widgets['convert_lowercase'])\n\nwidgets['remove_numbers'] = QCheckBox('Remove Numbers', parent)\nwidgets['remove_numbers'].setChecked(False)\nlayout.addWidget(widgets['remove_numbers'])\n\nwidgets['clean_btn'] = QPushButton('Clean Text', parent)\nlayout.addWidget(widgets['clean_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'remove_punctuation': widgets['remove_punctuation'].isChecked(),\n 'convert_lowercase': widgets['convert_lowercase'].isChecked(),\n 'remove_numbers': widgets['remove_numbers'].isChecked()\n }\n\ndef set_initial_state(widgets, state):\n widgets['remove_punctuation'].setChecked(state.get('remove_punctuation', False))\n widgets['convert_lowercase'].setChecked(state.get('convert_lowercase', True))\n widgets['remove_numbers'].setChecked(state.get('remove_numbers', False))", - "gui_state": { - "remove_punctuation": false, - "convert_lowercase": true, - "remove_numbers": false - }, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "text-analyzer", - "title": "Text Statistics Analyzer", - "pos": [470, 400], - "size": [280, 250], - "code": "import re\nfrom typing import Tuple\nfrom collections import Counter\n\n@node_entry\ndef analyze_text(text: str) -> Tuple[int, int, int, int, float, str]:\n # Basic counts\n char_count = len(text)\n word_count = len(text.split())\n sentence_count = len(re.findall(r'[.!?]+', text))\n paragraph_count = len([p for p in text.split('\\n\\n') if p.strip()])\n \n # Average word length\n words = text.split()\n avg_word_length = sum(len(word.strip('.,!?;:')) for word in words) / len(words) if words else 0\n \n # Most common words (top 5)\n word_freq = Counter(word.lower().strip('.,!?;:') for word in words if len(word) > 2)\n top_words = ', '.join([f\"{word}({count})\" for word, count in word_freq.most_common(5)])\n \n print(\"\\n=== TEXT ANALYSIS ===\")\n print(f\"Characters: {char_count}\")\n print(f\"Words: {word_count}\")\n print(f\"Sentences: {sentence_count}\")\n print(f\"Paragraphs: {paragraph_count}\")\n print(f\"Average word length: {avg_word_length:.1f}\")\n print(f\"Most frequent words: {top_words}\")\n \n return char_count, word_count, sentence_count, paragraph_count, round(avg_word_length, 1), top_words", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "keyword-extractor", - "title": "Keyword & Phrase Extractor", - "pos": [820, 200], - "size": [300, 250], - "code": "import re\nfrom typing import Tuple, List\nfrom collections import Counter\n\n@node_entry\ndef extract_keywords(text: str, min_word_length: int) -> Tuple[List[str], List[str], List[str]]:\n # Common stop words\n stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'this', 'that', 'these', 'those'}\n \n # Extract words\n words = re.findall(r'\\b[a-zA-Z]+\\b', text.lower())\n \n # Filter keywords (non-stop words, minimum length)\n keywords = [word for word in words if word not in stop_words and len(word) >= min_word_length]\n keyword_freq = Counter(keywords)\n top_keywords = [word for word, count in keyword_freq.most_common(10)]\n \n # Extract potential phrases (2-3 word combinations)\n phrase_pattern = r'\\b(?:[a-zA-Z]+\\s+){1,2}[a-zA-Z]+\\b'\n phrases = re.findall(phrase_pattern, text.lower())\n filtered_phrases = []\n for phrase in phrases:\n words_in_phrase = phrase.split()\n if len(words_in_phrase) >= 2 and not any(word in stop_words for word in words_in_phrase[:2]):\n filtered_phrases.append(phrase.strip())\n \n phrase_freq = Counter(filtered_phrases)\n top_phrases = [phrase for phrase, count in phrase_freq.most_common(5)]\n \n # Extract capitalized words (potential proper nouns)\n proper_nouns = list(set(re.findall(r'\\b[A-Z][a-zA-Z]+\\b', text)))\n proper_nouns = [noun for noun in proper_nouns if len(noun) > 2][:10]\n \n print(\"\\n=== KEYWORD EXTRACTION ===\")\n print(f\"Top keywords: {', '.join(top_keywords[:5])}\")\n print(f\"Key phrases: {', '.join(top_phrases[:3])}\")\n print(f\"Proper nouns: {', '.join(proper_nouns[:5])}\")\n \n return top_keywords, top_phrases, proper_nouns", - "gui_code": "from PySide6.QtWidgets import QLabel, QSpinBox, QPushButton\n\nlayout.addWidget(QLabel('Minimum Keyword Length:', parent))\nwidgets['min_word_length'] = QSpinBox(parent)\nwidgets['min_word_length'].setRange(3, 10)\nwidgets['min_word_length'].setValue(4)\nlayout.addWidget(widgets['min_word_length'])\n\nwidgets['extract_btn'] = QPushButton('Extract Keywords', parent)\nlayout.addWidget(widgets['extract_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'min_word_length': widgets['min_word_length'].value()\n }\n\ndef set_initial_state(widgets, state):\n widgets['min_word_length'].setValue(state.get('min_word_length', 4))", - "gui_state": { - "min_word_length": 4 - }, - "colors": { - "title": "#6f42c1", - "body": "#563d7c" - } - }, - { - "uuid": "report-generator", - "title": "Processing Report Generator", - "pos": [1190, 250], - "size": [350, 300], - "code": "from typing import List\n\n@node_entry\ndef generate_report(original_text: str, cleaned_text: str, char_count: int, word_count: int, sentence_count: int, paragraph_count: int, avg_word_length: float, top_words: str, keywords: List[str], phrases: List[str], proper_nouns: List[str]) -> str:\n report = \"\\n\" + \"=\"*60 + \"\\n\"\n report += \" TEXT PROCESSING REPORT\\n\"\n report += \"=\"*60 + \"\\n\\n\"\n \n # Text Overview\n report += \"📊 TEXT OVERVIEW\\n\"\n report += f\" • Characters: {char_count:,}\\n\"\n report += f\" • Words: {word_count:,}\\n\"\n report += f\" • Sentences: {sentence_count}\\n\"\n report += f\" • Paragraphs: {paragraph_count}\\n\"\n report += f\" • Average word length: {avg_word_length} characters\\n\\n\"\n \n # Processing Summary\n report += \"🔧 PROCESSING SUMMARY\\n\"\n original_words = len(original_text.split())\n cleaned_words = len(cleaned_text.split())\n report += f\" • Original text: {len(original_text)} characters, {original_words} words\\n\"\n report += f\" • Cleaned text: {len(cleaned_text)} characters, {cleaned_words} words\\n\"\n report += f\" • Reduction: {len(original_text) - len(cleaned_text)} characters\\n\\n\"\n \n # Frequency Analysis\n report += \"📈 FREQUENCY ANALYSIS\\n\"\n report += f\" • Most common words: {top_words}\\n\\n\"\n \n # Keywords and Phrases\n report += \"🔍 EXTRACTED KEYWORDS\\n\"\n report += f\" • Key terms: {', '.join(keywords[:8])}\\n\"\n if phrases:\n report += f\" • Key phrases: {', '.join(phrases[:4])}\\n\"\n if proper_nouns:\n report += f\" • Proper nouns: {', '.join(proper_nouns[:6])}\\n\"\n report += \"\\n\"\n \n # Text Sample\n report += \"📝 PROCESSED TEXT SAMPLE\\n\"\n sample = cleaned_text[:200] + \"...\" if len(cleaned_text) > 200 else cleaned_text\n report += f\" {sample}\\n\\n\"\n \n report += \"=\"*60\n \n print(report)\n return report", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Text Processing Report', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['report_display'] = QTextEdit(parent)\nwidgets['report_display'].setMinimumHeight(200)\nwidgets['report_display'].setReadOnly(True)\nwidgets['report_display'].setPlainText('Process text to generate report...')\nfont = QFont('Courier New', 9)\nwidgets['report_display'].setFont(font)\nlayout.addWidget(widgets['report_display'])\n\nwidgets['save_report_btn'] = QPushButton('Save Report', parent)\nlayout.addWidget(widgets['save_report_btn'])\n\nwidgets['new_analysis_btn'] = QPushButton('New Analysis', parent)\nlayout.addWidget(widgets['new_analysis_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n report = outputs.get('output_1', 'No report data')\n widgets['report_display'].setPlainText(report)", - "gui_state": {}, - "colors": { - "title": "#17a2b8", - "body": "#117a8b" - } - } - ], - "connections": [ - { - "start_node_uuid": "text-input", - "start_pin_name": "exec_out", - "end_node_uuid": "text-cleaner", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "text-input", - "start_pin_name": "output_1", - "end_node_uuid": "text-cleaner", - "end_pin_name": "text" - }, - { - "start_node_uuid": "text-cleaner", - "start_pin_name": "exec_out", - "end_node_uuid": "text-analyzer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "text-cleaner", - "start_pin_name": "output_1", - "end_node_uuid": "text-analyzer", - "end_pin_name": "text" - }, - { - "start_node_uuid": "text-cleaner", - "start_pin_name": "exec_out", - "end_node_uuid": "keyword-extractor", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "text-cleaner", - "start_pin_name": "output_1", - "end_node_uuid": "keyword-extractor", - "end_pin_name": "text" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "exec_out", - "end_node_uuid": "report-generator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "keyword-extractor", - "start_pin_name": "exec_out", - "end_node_uuid": "report-generator", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "text-input", - "start_pin_name": "output_1", - "end_node_uuid": "report-generator", - "end_pin_name": "original_text" - }, - { - "start_node_uuid": "text-cleaner", - "start_pin_name": "output_1", - "end_node_uuid": "report-generator", - "end_pin_name": "cleaned_text" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_1", - "end_node_uuid": "report-generator", - "end_pin_name": "char_count" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_2", - "end_node_uuid": "report-generator", - "end_pin_name": "word_count" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_3", - "end_node_uuid": "report-generator", - "end_pin_name": "sentence_count" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_4", - "end_node_uuid": "report-generator", - "end_pin_name": "paragraph_count" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_5", - "end_node_uuid": "report-generator", - "end_pin_name": "avg_word_length" - }, - { - "start_node_uuid": "text-analyzer", - "start_pin_name": "output_6", - "end_node_uuid": "report-generator", - "end_pin_name": "top_words" - }, - { - "start_node_uuid": "keyword-extractor", - "start_pin_name": "output_1", - "end_node_uuid": "report-generator", - "end_pin_name": "keywords" - }, - { - "start_node_uuid": "keyword-extractor", - "start_pin_name": "output_2", - "end_node_uuid": "report-generator", - "end_pin_name": "phrases" - }, - { - "start_node_uuid": "keyword-extractor", - "start_pin_name": "output_3", - "end_node_uuid": "report-generator", - "end_pin_name": "proper_nouns" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/text_processing_pipeline.md b/examples/text_processing_pipeline.md new file mode 100644 index 0000000..195958f --- /dev/null +++ b/examples/text_processing_pipeline.md @@ -0,0 +1,632 @@ +# Text Processing Pipeline + +Text analysis workflow with regex-based cleaning, statistical counting, keyword extraction, and report generation. Implements string.split(), re.sub(), Counter frequency analysis, and formatted output for comprehensive text processing and analysis. + +## Node: Text Input Source (ID: text-input) + +Provides text input through QComboBox selection or manual QTextEdit entry. Implements conditional text selection using if-elif statements for source_type values: "Manual", "Lorem Ipsum", "Sample Article", "Technical Text". Returns single string output with predefined text samples or user input. + +Uses len() function for character counting and string slicing [:100] for preview display. GUI includes QTextEdit with placeholder text and QComboBox with addItems() for source selection. State management handles text content and source type persistence. + +### Metadata + +```json +{ + "uuid": "text-input", + "title": "Text Input Source", + "pos": [ + -170.71574999999999, + 230.41750000000002 + ], + "size": [ + 276, + 437 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "input_text": "", + "source_type": "Manual" + } +} +``` + +### Logic + +```python +@node_entry +def provide_text(input_text: str, source_type: str) -> str: + if source_type == "Manual": + result = input_text + elif source_type == "Lorem Ipsum": + result = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + elif source_type == "Sample Article": + result = "Artificial Intelligence has revolutionized many industries. Machine learning algorithms can process vast amounts of data quickly. Natural language processing enables computers to understand human text. Deep learning models achieve remarkable accuracy in image recognition. The future of AI looks promising with continued research and development." + else: # Technical Text + result = "Python is a high-level programming language. It supports object-oriented programming paradigms. The syntax is designed to be readable and concise. Libraries like NumPy and Pandas facilitate data analysis. Django and Flask are popular web frameworks. Python's versatility makes it suitable for various applications." + + print(f"Text source: {source_type}") + print(f"Text length: {len(result)} characters") + print(f"Preview: {result[:100]}...") + + return result +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QComboBox, QPushButton + +layout.addWidget(QLabel('Text Source:', parent)) +widgets['source_type'] = QComboBox(parent) +widgets['source_type'].addItems(['Manual', 'Lorem Ipsum', 'Sample Article', 'Technical Text']) +layout.addWidget(widgets['source_type']) + +layout.addWidget(QLabel('Enter your text (for Manual mode):', parent)) +widgets['input_text'] = QTextEdit(parent) +widgets['input_text'].setMinimumHeight(120) +widgets['input_text'].setPlaceholderText('Type your text here...') +layout.addWidget(widgets['input_text']) + +widgets['process_btn'] = QPushButton('Process Text', parent) +layout.addWidget(widgets['process_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'input_text': widgets['input_text'].toPlainText(), + 'source_type': widgets['source_type'].currentText() + } + +def set_initial_state(widgets, state): + widgets['input_text'].setPlainText(state.get('input_text', '')) + widgets['source_type'].setCurrentText(state.get('source_type', 'Manual')) +``` + + +## Node: Text Cleaner & Normalizer (ID: text-cleaner) + +Performs text preprocessing using re.sub(r'\\s+', ' ') for whitespace normalization, string.lower() for case conversion, str.maketrans() with string.punctuation for punctuation removal, and re.sub(r'\\d+', '') for number removal. Boolean flags control each cleaning operation. + +Uses str.strip() for leading/trailing whitespace removal and sequential regex operations for text transformation. Returns single cleaned string output. GUI includes QCheckBox widgets for remove_punctuation, convert_lowercase, and remove_numbers options with isChecked() state management. + +### Metadata + +```json +{ + "uuid": "text-cleaner", + "title": "Text Cleaner & Normalizer", + "pos": [ + 351.37175, + -53.797249999999984 + ], + "size": [ + 250, + 293 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": { + "remove_punctuation": false, + "convert_lowercase": true, + "remove_numbers": false + } +} +``` + +### Logic + +```python +import re +import string + +@node_entry +def clean_text(text: str, remove_punctuation: bool, convert_lowercase: bool, remove_numbers: bool) -> str: + cleaned = text + + # Remove extra whitespace + cleaned = re.sub(r'\s+', ' ', cleaned.strip()) + + # Convert to lowercase + if convert_lowercase: + cleaned = cleaned.lower() + + # Remove punctuation + if remove_punctuation: + cleaned = cleaned.translate(str.maketrans('', '', string.punctuation)) + + # Remove numbers + if remove_numbers: + cleaned = re.sub(r'\d+', '', cleaned) + + # Clean up extra spaces again + cleaned = re.sub(r'\s+', ' ', cleaned.strip()) + + print(f"Original length: {len(text)}") + print(f"Cleaned length: {len(cleaned)}") + print(f"Cleaning options: Lowercase={convert_lowercase}, No punctuation={remove_punctuation}, No numbers={remove_numbers}") + + return cleaned +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QCheckBox, QPushButton + +widgets['remove_punctuation'] = QCheckBox('Remove Punctuation', parent) +widgets['remove_punctuation'].setChecked(False) +layout.addWidget(widgets['remove_punctuation']) + +widgets['convert_lowercase'] = QCheckBox('Convert to Lowercase', parent) +widgets['convert_lowercase'].setChecked(True) +layout.addWidget(widgets['convert_lowercase']) + +widgets['remove_numbers'] = QCheckBox('Remove Numbers', parent) +widgets['remove_numbers'].setChecked(False) +layout.addWidget(widgets['remove_numbers']) + +widgets['clean_btn'] = QPushButton('Clean Text', parent) +layout.addWidget(widgets['clean_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'remove_punctuation': widgets['remove_punctuation'].isChecked(), + 'convert_lowercase': widgets['convert_lowercase'].isChecked(), + 'remove_numbers': widgets['remove_numbers'].isChecked() + } + +def set_initial_state(widgets, state): + widgets['remove_punctuation'].setChecked(state.get('remove_punctuation', False)) + widgets['convert_lowercase'].setChecked(state.get('convert_lowercase', True)) + widgets['remove_numbers'].setChecked(state.get('remove_numbers', False)) +``` + + +## Node: Text Statistics Analyzer (ID: text-analyzer) + +Calculates text metrics using len() for character count, string.split() for word count, re.findall(r'[.!?]+') for sentence detection, and split('\\n\\n') for paragraph counting. Computes average word length using sum() and len() with string.strip() for punctuation removal. + +Implements word frequency analysis using Counter with word.lower().strip() normalization and most_common(5) for top terms. Returns Tuple[int, int, int, int, float, str] containing character, word, sentence, paragraph counts, average word length, and formatted top words string. + +### Metadata + +```json +{ + "uuid": "text-analyzer", + "title": "Text Statistics Analyzer", + "pos": [ + 883.678, + 372.62425 + ], + "size": [ + 250, + 243 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +import re +from typing import Tuple +from collections import Counter + +@node_entry +def analyze_text(text: str) -> Tuple[int, int, int, int, float, str]: + # Basic counts + char_count = len(text) + word_count = len(text.split()) + sentence_count = len(re.findall(r'[.!?]+', text)) + paragraph_count = len([p for p in text.split('\n\n') if p.strip()]) + + # Average word length + words = text.split() + avg_word_length = sum(len(word.strip('.,!?;:')) for word in words) / len(words) if words else 0 + + # Most common words (top 5) + word_freq = Counter(word.lower().strip('.,!?;:') for word in words if len(word) > 2) + top_words = ', '.join([f"{word}({count})" for word, count in word_freq.most_common(5)]) + + print("\n=== TEXT ANALYSIS ===") + print(f"Characters: {char_count}") + print(f"Words: {word_count}") + print(f"Sentences: {sentence_count}") + print(f"Paragraphs: {paragraph_count}") + print(f"Average word length: {avg_word_length:.1f}") + print(f"Most frequent words: {top_words}") + + return char_count, word_count, sentence_count, paragraph_count, round(avg_word_length, 1), top_words +``` + + +## Node: Keyword & Phrase Extractor (ID: keyword-extractor) + +Extracts keywords using re.findall(r'\\b[a-zA-Z]+\\b') for word extraction, filters against stop_words set using list comprehension, and applies min_word_length threshold. Uses Counter.most_common(10) for frequency ranking. Detects phrases with regex pattern r'\\b(?:[a-zA-Z]+\\s+){1,2}[a-zA-Z]+\\b' for 2-3 word combinations. + +Identifies proper nouns using re.findall(r'\\b[A-Z][a-zA-Z]+\\b') for capitalized words. Returns Tuple[List[str], List[str], List[str]] containing top keywords, phrases, and proper nouns. GUI includes QSpinBox for min_word_length configuration (3-10 range). + +### Metadata + +```json +{ + "uuid": "keyword-extractor", + "title": "Keyword & Phrase Extractor", + "pos": [ + 824.5626250000001, + -92.00799999999998 + ], + "size": [ + 250, + 242 + ], + "colors": { + "title": "#6f42c1", + "body": "#563d7c" + }, + "gui_state": { + "min_word_length": 4 + } +} +``` + +### Logic + +```python +import re +from typing import Tuple, List +from collections import Counter + +@node_entry +def extract_keywords(text: str, min_word_length: int) -> Tuple[List[str], List[str], List[str]]: + # Common stop words + stop_words = {'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'this', 'that', 'these', 'those'} + + # Extract words + words = re.findall(r'\b[a-zA-Z]+\b', text.lower()) + + # Filter keywords (non-stop words, minimum length) + keywords = [word for word in words if word not in stop_words and len(word) >= min_word_length] + keyword_freq = Counter(keywords) + top_keywords = [word for word, count in keyword_freq.most_common(10)] + + # Extract potential phrases (2-3 word combinations) + phrase_pattern = r'\b(?:[a-zA-Z]+\s+){1,2}[a-zA-Z]+\b' + phrases = re.findall(phrase_pattern, text.lower()) + filtered_phrases = [] + for phrase in phrases: + words_in_phrase = phrase.split() + if len(words_in_phrase) >= 2 and not any(word in stop_words for word in words_in_phrase[:2]): + filtered_phrases.append(phrase.strip()) + + phrase_freq = Counter(filtered_phrases) + top_phrases = [phrase for phrase, count in phrase_freq.most_common(5)] + + # Extract capitalized words (potential proper nouns) + proper_nouns = list(set(re.findall(r'\b[A-Z][a-zA-Z]+\b', text))) + proper_nouns = [noun for noun in proper_nouns if len(noun) > 2][:10] + + print("\n=== KEYWORD EXTRACTION ===") + print(f"Top keywords: {', '.join(top_keywords[:5])}") + print(f"Key phrases: {', '.join(top_phrases[:3])}") + print(f"Proper nouns: {', '.join(proper_nouns[:5])}") + + return top_keywords, top_phrases, proper_nouns +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QSpinBox, QPushButton + +layout.addWidget(QLabel('Minimum Keyword Length:', parent)) +widgets['min_word_length'] = QSpinBox(parent) +widgets['min_word_length'].setRange(3, 10) +widgets['min_word_length'].setValue(4) +layout.addWidget(widgets['min_word_length']) + +widgets['extract_btn'] = QPushButton('Extract Keywords', parent) +layout.addWidget(widgets['extract_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'min_word_length': widgets['min_word_length'].value() + } + +def set_initial_state(widgets, state): + widgets['min_word_length'].setValue(state.get('min_word_length', 4)) +``` + + +## Node: Processing Report Generator (ID: report-generator) + +Formats comprehensive text analysis report using string concatenation with section headers and f-string formatting. Combines statistical metrics, processing summaries, frequency analysis, and keyword extraction results. Uses len() calculations for character reduction analysis and string.join() for list formatting. + +Implements conditional display logic for phrases and proper_nouns using if statements. Creates text preview using string slicing [:200] with "..." truncation. Returns single formatted report string for QTextEdit display with Courier New monospace font and read-only configuration. + +### Metadata + +```json +{ + "uuid": "report-generator", + "title": "Processing Report Generator", + "pos": [ + 1465.2783750000003, + 246.95825000000002 + ], + "size": [ + 276, + 664 + ], + "colors": { + "title": "#17a2b8", + "body": "#117a8b" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List + +@node_entry +def generate_report(original_text: str, cleaned_text: str, char_count: int, word_count: int, sentence_count: int, paragraph_count: int, avg_word_length: float, top_words: str, keywords: List[str], phrases: List[str], proper_nouns: List[str]) -> str: + report = "\n" + "="*60 + "\n" + report += " TEXT PROCESSING REPORT\n" + report += "="*60 + "\n\n" + + # Text Overview + report += "📊 TEXT OVERVIEW\n" + report += f" • Characters: {char_count:,}\n" + report += f" • Words: {word_count:,}\n" + report += f" • Sentences: {sentence_count}\n" + report += f" • Paragraphs: {paragraph_count}\n" + report += f" • Average word length: {avg_word_length} characters\n\n" + + # Processing Summary + report += "🔧 PROCESSING SUMMARY\n" + original_words = len(original_text.split()) + cleaned_words = len(cleaned_text.split()) + report += f" • Original text: {len(original_text)} characters, {original_words} words\n" + report += f" • Cleaned text: {len(cleaned_text)} characters, {cleaned_words} words\n" + report += f" • Reduction: {len(original_text) - len(cleaned_text)} characters\n\n" + + # Frequency Analysis + report += "📈 FREQUENCY ANALYSIS\n" + report += f" • Most common words: {top_words}\n\n" + + # Keywords and Phrases + report += "🔍 EXTRACTED KEYWORDS\n" + report += f" • Key terms: {', '.join(keywords[:8])}\n" + if phrases: + report += f" • Key phrases: {', '.join(phrases[:4])}\n" + if proper_nouns: + report += f" • Proper nouns: {', '.join(proper_nouns[:6])}\n" + report += "\n" + + # Text Sample + report += "📝 PROCESSED TEXT SAMPLE\n" + sample = cleaned_text[:200] + "..." if len(cleaned_text) > 200 else cleaned_text + report += f" {sample}\n\n" + + report += "="*60 + + print(report) + return report +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Text Processing Report', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['report_display'] = QTextEdit(parent) +widgets['report_display'].setMinimumHeight(200) +widgets['report_display'].setReadOnly(True) +widgets['report_display'].setPlainText('Process text to generate report...') +font = QFont('Courier New', 9) +widgets['report_display'].setFont(font) +layout.addWidget(widgets['report_display']) + +widgets['save_report_btn'] = QPushButton('Save Report', parent) +layout.addWidget(widgets['save_report_btn']) + +widgets['new_analysis_btn'] = QPushButton('New Analysis', parent) +layout.addWidget(widgets['new_analysis_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + report = outputs.get('output_1', 'No report data') + widgets['report_display'].setPlainText(report) +``` + + +## Node: Reroute (ID: c6b89a70-f130-4d9a-bc20-49ce9dfdb32b) + +A simple organizational node that facilitates clean data flow routing within the text processing pipeline, allowing the cleaned text output to be efficiently distributed to multiple downstream analysis components without complex connection patterns. + +### Metadata + +```json +{ + "uuid": "c6b89a70-f130-4d9a-bc20-49ce9dfdb32b", + "title": "", + "pos": [ + 874.503125, + 258.5487499999999 + ], + "size": [ + 200, + 150 + ], + "is_reroute": true, + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python + +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "text-input", + "start_pin_name": "exec_out", + "end_node_uuid": "text-cleaner", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "text-input", + "start_pin_name": "output_1", + "end_node_uuid": "text-cleaner", + "end_pin_name": "text" + }, + { + "start_node_uuid": "text-cleaner", + "start_pin_name": "exec_out", + "end_node_uuid": "text-analyzer", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "text-cleaner", + "start_pin_name": "output_1", + "end_node_uuid": "text-analyzer", + "end_pin_name": "text" + }, + { + "start_node_uuid": "text-cleaner", + "start_pin_name": "exec_out", + "end_node_uuid": "keyword-extractor", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "text-cleaner", + "start_pin_name": "output_1", + "end_node_uuid": "keyword-extractor", + "end_pin_name": "text" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "exec_out", + "end_node_uuid": "report-generator", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "text-input", + "start_pin_name": "output_1", + "end_node_uuid": "report-generator", + "end_pin_name": "original_text" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_1", + "end_node_uuid": "report-generator", + "end_pin_name": "char_count" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_2", + "end_node_uuid": "report-generator", + "end_pin_name": "word_count" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_3", + "end_node_uuid": "report-generator", + "end_pin_name": "sentence_count" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_4", + "end_node_uuid": "report-generator", + "end_pin_name": "paragraph_count" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_5", + "end_node_uuid": "report-generator", + "end_pin_name": "avg_word_length" + }, + { + "start_node_uuid": "text-analyzer", + "start_pin_name": "output_6", + "end_node_uuid": "report-generator", + "end_pin_name": "top_words" + }, + { + "start_node_uuid": "keyword-extractor", + "start_pin_name": "output_1", + "end_node_uuid": "report-generator", + "end_pin_name": "keywords" + }, + { + "start_node_uuid": "keyword-extractor", + "start_pin_name": "output_2", + "end_node_uuid": "report-generator", + "end_pin_name": "phrases" + }, + { + "start_node_uuid": "keyword-extractor", + "start_pin_name": "output_3", + "end_node_uuid": "report-generator", + "end_pin_name": "proper_nouns" + }, + { + "start_node_uuid": "text-cleaner", + "start_pin_name": "output_1", + "end_node_uuid": "c6b89a70-f130-4d9a-bc20-49ce9dfdb32b", + "end_pin_name": "input" + }, + { + "start_node_uuid": "c6b89a70-f130-4d9a-bc20-49ce9dfdb32b", + "start_pin_name": "output", + "end_node_uuid": "report-generator", + "end_pin_name": "cleaned_text" + } +] +``` diff --git a/examples/weather_data_processor.json b/examples/weather_data_processor.json deleted file mode 100644 index 09ad32f..0000000 --- a/examples/weather_data_processor.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "nodes": [ - { - "uuid": "weather-simulator", - "title": "Weather Data Simulator", - "pos": [100, 200], - "size": [300, 300], - "code": "import random\nimport datetime\nfrom typing import List, Dict, Tuple\n\n@node_entry\ndef simulate_weather_data(city: str, days: int, season: str) -> Tuple[str, List[Dict]]:\n # Temperature ranges by season (Celsius)\n temp_ranges = {\n 'Spring': (10, 20),\n 'Summer': (20, 35),\n 'Fall': (5, 18),\n 'Winter': (-5, 10)\n }\n \n base_temp_range = temp_ranges.get(season, (10, 25))\n \n weather_data = []\n current_date = datetime.datetime.now()\n \n for i in range(days):\n date = current_date + datetime.timedelta(days=i)\n \n # Generate realistic weather patterns\n base_temp = random.uniform(base_temp_range[0], base_temp_range[1])\n temp_variation = random.uniform(-3, 3) # Daily variation\n temperature = round(base_temp + temp_variation, 1)\n \n # Humidity tends to be higher in summer and with rain\n base_humidity = 60 if season == 'Summer' else 70\n humidity = max(30, min(95, base_humidity + random.randint(-20, 20)))\n \n # Wind speed\n wind_speed = round(random.uniform(5, 25), 1)\n \n # Precipitation (higher chance in fall/winter)\n precip_chance = 0.4 if season in ['Fall', 'Winter'] else 0.2\n precipitation = round(random.uniform(0, 15), 1) if random.random() < precip_chance else 0\n \n # Weather conditions\n if precipitation > 10:\n condition = 'Heavy Rain'\n elif precipitation > 2:\n condition = 'Light Rain'\n elif humidity > 85:\n condition = 'Cloudy'\n elif temperature > 28:\n condition = 'Hot'\n elif temperature < 5:\n condition = 'Cold'\n else:\n condition = 'Clear'\n \n weather_data.append({\n 'date': date.strftime('%Y-%m-%d'),\n 'temperature': temperature,\n 'humidity': humidity,\n 'wind_speed': wind_speed,\n 'precipitation': precipitation,\n 'condition': condition,\n 'day_of_week': date.strftime('%A')\n })\n \n print(f\"\\n=== WEATHER SIMULATION ===\")\n print(f\"City: {city}\")\n print(f\"Season: {season}\")\n print(f\"Generated {len(weather_data)} days of data\")\n print(f\"Date range: {weather_data[0]['date']} to {weather_data[-1]['date']}\")\n \n return city, weather_data", - "gui_code": "from PySide6.QtWidgets import QLabel, QLineEdit, QSpinBox, QComboBox, QPushButton\n\nlayout.addWidget(QLabel('City:', parent))\nwidgets['city'] = QLineEdit(parent)\nwidgets['city'].setPlaceholderText('Enter city name...')\nwidgets['city'].setText('New York')\nlayout.addWidget(widgets['city'])\n\nlayout.addWidget(QLabel('Number of Days:', parent))\nwidgets['days'] = QSpinBox(parent)\nwidgets['days'].setRange(7, 365)\nwidgets['days'].setValue(30)\nlayout.addWidget(widgets['days'])\n\nlayout.addWidget(QLabel('Season:', parent))\nwidgets['season'] = QComboBox(parent)\nwidgets['season'].addItems(['Spring', 'Summer', 'Fall', 'Winter'])\nlayout.addWidget(widgets['season'])\n\nwidgets['simulate_btn'] = QPushButton('Generate Weather Data', parent)\nlayout.addWidget(widgets['simulate_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {\n 'city': widgets['city'].text(),\n 'days': widgets['days'].value(),\n 'season': widgets['season'].currentText()\n }\n\ndef set_initial_state(widgets, state):\n widgets['city'].setText(state.get('city', 'New York'))\n widgets['days'].setValue(state.get('days', 30))\n widgets['season'].setCurrentText(state.get('season', 'Spring'))", - "gui_state": { - "city": "New York", - "days": 30, - "season": "Spring" - }, - "colors": { - "title": "#007bff", - "body": "#0056b3" - } - }, - { - "uuid": "weather-analyzer", - "title": "Weather Statistics Analyzer", - "pos": [470, 150], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\nfrom collections import Counter\nimport statistics\n\n@node_entry\ndef analyze_weather(weather_data: List[Dict]) -> Tuple[Dict, Dict, Dict]:\n if not weather_data:\n return {}, {}, {}\n \n # Temperature statistics\n temperatures = [day['temperature'] for day in weather_data]\n temp_stats = {\n 'avg': round(statistics.mean(temperatures), 1),\n 'min': min(temperatures),\n 'max': max(temperatures),\n 'median': round(statistics.median(temperatures), 1)\n }\n \n if len(temperatures) > 1:\n temp_stats['std_dev'] = round(statistics.stdev(temperatures), 1)\n \n # Weather conditions analysis\n conditions = [day['condition'] for day in weather_data]\n condition_counts = Counter(conditions)\n \n # Environmental analysis\n humidity_vals = [day['humidity'] for day in weather_data]\n wind_vals = [day['wind_speed'] for day in weather_data]\n precip_vals = [day['precipitation'] for day in weather_data]\n \n env_stats = {\n 'avg_humidity': round(statistics.mean(humidity_vals), 1),\n 'avg_wind': round(statistics.mean(wind_vals), 1),\n 'total_precipitation': round(sum(precip_vals), 1),\n 'rainy_days': len([p for p in precip_vals if p > 0]),\n 'hottest_day': max(weather_data, key=lambda x: x['temperature']),\n 'coldest_day': min(weather_data, key=lambda x: x['temperature']),\n 'windiest_day': max(weather_data, key=lambda x: x['wind_speed'])\n }\n \n print(f\"\\n=== WEATHER ANALYSIS ===\")\n print(f\"Temperature: {temp_stats['min']}°C to {temp_stats['max']}°C (avg: {temp_stats['avg']}°C)\")\n print(f\"Most common condition: {condition_counts.most_common(1)[0][0]}\")\n print(f\"Rainy days: {env_stats['rainy_days']}/{len(weather_data)}\")\n \n return temp_stats, dict(condition_counts), env_stats", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#28a745", - "body": "#1e7e34" - } - }, - { - "uuid": "trend-detector", - "title": "Weather Trend Detector", - "pos": [470, 450], - "size": [300, 250], - "code": "from typing import List, Dict, Tuple\n\n@node_entry\ndef detect_trends(weather_data: List[Dict]) -> Tuple[str, str, List[str]]:\n if len(weather_data) < 3:\n return \"Insufficient data\", \"No patterns\", []\n \n temperatures = [day['temperature'] for day in weather_data]\n precip_values = [day['precipitation'] for day in weather_data]\n \n # Temperature trend analysis\n temp_trend = \"Stable\"\n if len(temperatures) >= 5:\n early_avg = sum(temperatures[:len(temperatures)//3]) / (len(temperatures)//3)\n late_avg = sum(temperatures[-len(temperatures)//3:]) / (len(temperatures)//3)\n \n if late_avg > early_avg + 2:\n temp_trend = \"Warming\"\n elif late_avg < early_avg - 2:\n temp_trend = \"Cooling\"\n \n # Precipitation pattern\n total_precip = sum(precip_values)\n rainy_days = len([p for p in precip_values if p > 0])\n \n if total_precip > len(weather_data) * 3: # More than 3mm per day on average\n precip_pattern = \"Wet period\"\n elif rainy_days < len(weather_data) * 0.2: # Less than 20% rainy days\n precip_pattern = \"Dry period\"\n else:\n precip_pattern = \"Normal precipitation\"\n \n # Weather insights\n insights = []\n \n # Temperature extremes\n max_temp = max(temperatures)\n min_temp = min(temperatures)\n if max_temp - min_temp > 20:\n insights.append(f\"High temperature variability ({max_temp - min_temp:.1f}°C range)\")\n \n # Consecutive patterns\n hot_streak = 0\n cold_streak = 0\n rain_streak = 0\n current_hot = 0\n current_cold = 0\n current_rain = 0\n \n for day in weather_data:\n if day['temperature'] > 25:\n current_hot += 1\n current_cold = 0\n elif day['temperature'] < 10:\n current_cold += 1\n current_hot = 0\n else:\n current_hot = 0\n current_cold = 0\n \n if day['precipitation'] > 1:\n current_rain += 1\n else:\n current_rain = 0\n \n hot_streak = max(hot_streak, current_hot)\n cold_streak = max(cold_streak, current_cold)\n rain_streak = max(rain_streak, current_rain)\n \n if hot_streak >= 3:\n insights.append(f\"Heat wave detected ({hot_streak} consecutive hot days)\")\n \n if cold_streak >= 3:\n insights.append(f\"Cold snap detected ({cold_streak} consecutive cold days)\")\n \n if rain_streak >= 3:\n insights.append(f\"Rainy period detected ({rain_streak} consecutive rainy days)\")\n \n # Weekly patterns\n weekend_temps = [day['temperature'] for day in weather_data if day['day_of_week'] in ['Saturday', 'Sunday']]\n weekday_temps = [day['temperature'] for day in weather_data if day['day_of_week'] not in ['Saturday', 'Sunday']]\n \n if weekend_temps and weekday_temps:\n weekend_avg = sum(weekend_temps) / len(weekend_temps)\n weekday_avg = sum(weekday_temps) / len(weekday_temps)\n \n if abs(weekend_avg - weekday_avg) > 3:\n insights.append(f\"Weekend/weekday temperature difference: {abs(weekend_avg - weekday_avg):.1f}°C\")\n \n print(f\"\\n=== TREND DETECTION ===\")\n print(f\"Temperature trend: {temp_trend}\")\n print(f\"Precipitation pattern: {precip_pattern}\")\n print(f\"Insights: {len(insights)} patterns detected\")\n \n return temp_trend, precip_pattern, insights", - "gui_code": "", - "gui_get_values_code": "", - "gui_state": {}, - "colors": { - "title": "#fd7e14", - "body": "#e8590c" - } - }, - { - "uuid": "weather-report", - "title": "Weather Report Generator", - "pos": [850, 300], - "size": [400, 350], - "code": "from typing import List, Dict\n\n@node_entry\ndef generate_weather_report(city: str, weather_data: List[Dict], temp_stats: Dict, conditions: Dict, env_stats: Dict, temp_trend: str, precip_pattern: str, insights: List[str]) -> str:\n if not weather_data:\n return \"No weather data available\"\n \n report = \"\\n\" + \"=\"*70 + \"\\n\"\n report += \" WEATHER ANALYSIS REPORT\\n\"\n report += \"=\"*70 + \"\\n\\n\"\n \n # Location and Period\n report += f\"🌍 LOCATION: {city.upper()}\\n\"\n report += f\"📅 PERIOD: {weather_data[0]['date']} to {weather_data[-1]['date']}\\n\"\n report += f\"📊 DATASET: {len(weather_data)} days\\n\\n\"\n \n # Temperature Summary\n if temp_stats:\n report += f\"🌡️ TEMPERATURE ANALYSIS\\n\"\n report += f\" Average: {temp_stats['avg']:6.1f}°C\\n\"\n report += f\" Range: {temp_stats['min']:6.1f}°C to {temp_stats['max']:6.1f}°C\\n\"\n report += f\" Median: {temp_stats['median']:6.1f}°C\\n\"\n if 'std_dev' in temp_stats:\n report += f\" Variation: {temp_stats['std_dev']:6.1f}°C std dev\\n\"\n report += f\" Trend: {temp_trend}\\n\\n\"\n \n # Environmental Conditions\n if env_stats:\n report += f\"🌦️ ENVIRONMENTAL CONDITIONS\\n\"\n report += f\" Avg Humidity: {env_stats['avg_humidity']:6.1f}%\\n\"\n report += f\" Avg Wind Speed: {env_stats['avg_wind']:6.1f} km/h\\n\"\n report += f\" Total Rainfall: {env_stats['total_precipitation']:6.1f} mm\\n\"\n report += f\" Rainy Days: {env_stats['rainy_days']:6d} days\\n\"\n report += f\" Pattern: {precip_pattern}\\n\\n\"\n \n # Weather Conditions Distribution\n if conditions:\n report += f\"☁️ WEATHER CONDITIONS\\n\"\n total_days = sum(conditions.values())\n for condition, count in sorted(conditions.items(), key=lambda x: x[1], reverse=True):\n percentage = (count / total_days) * 100\n report += f\" {condition:<12} {count:3d} days ({percentage:4.1f}%)\\n\"\n report += \"\\n\"\n \n # Notable Weather Events\n if env_stats:\n report += f\"📋 NOTABLE EVENTS\\n\"\n hottest = env_stats.get('hottest_day', {})\n coldest = env_stats.get('coldest_day', {})\n windiest = env_stats.get('windiest_day', {})\n \n if hottest:\n report += f\" Hottest Day: {hottest['date']} ({hottest['temperature']}°C)\\n\"\n if coldest:\n report += f\" Coldest Day: {coldest['date']} ({coldest['temperature']}°C)\\n\"\n if windiest:\n report += f\" Windiest Day: {windiest['date']} ({windiest['wind_speed']} km/h)\\n\"\n report += \"\\n\"\n \n # Weather Patterns & Insights\n if insights:\n report += f\"🔍 WEATHER PATTERNS\\n\"\n for insight in insights:\n report += f\" • {insight}\\n\"\n report += \"\\n\"\n \n # Recent Weather (last 5 days)\n report += f\"📅 RECENT WEATHER (Last 5 Days)\\n\"\n recent_days = weather_data[-5:] if len(weather_data) >= 5 else weather_data\n for day in recent_days:\n report += f\" {day['date']} {day['temperature']:4.1f}°C {day['condition']:<12} \"\n if day['precipitation'] > 0:\n report += f\"({day['precipitation']}mm rain)\\n\"\n else:\n report += \"\\n\"\n \n report += \"\\n\" + \"=\"*70\n \n print(report)\n return report", - "gui_code": "from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton\nfrom PySide6.QtCore import Qt\nfrom PySide6.QtGui import QFont\n\ntitle_label = QLabel('Weather Analysis Report', parent)\ntitle_font = QFont()\ntitle_font.setPointSize(14)\ntitle_font.setBold(True)\ntitle_label.setFont(title_font)\nlayout.addWidget(title_label)\n\nwidgets['report_display'] = QTextEdit(parent)\nwidgets['report_display'].setMinimumHeight(250)\nwidgets['report_display'].setReadOnly(True)\nwidgets['report_display'].setPlainText('Generate weather data to see analysis report...')\nfont = QFont('Courier New', 9)\nwidgets['report_display'].setFont(font)\nlayout.addWidget(widgets['report_display'])\n\nwidgets['export_csv_btn'] = QPushButton('Export Data as CSV', parent)\nlayout.addWidget(widgets['export_csv_btn'])\n\nwidgets['compare_btn'] = QPushButton('Compare with Other Cities', parent)\nlayout.addWidget(widgets['compare_btn'])\n\nwidgets['forecast_btn'] = QPushButton('Generate Forecast', parent)\nlayout.addWidget(widgets['forecast_btn'])", - "gui_get_values_code": "def get_values(widgets):\n return {}\n\ndef set_values(widgets, outputs):\n report = outputs.get('output_1', 'No report data')\n widgets['report_display'].setPlainText(report)", - "gui_state": {}, - "colors": { - "title": "#6c757d", - "body": "#545b62" - } - } - ], - "connections": [ - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "exec_out", - "end_node_uuid": "weather-analyzer", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "output_2", - "end_node_uuid": "weather-analyzer", - "end_pin_name": "weather_data" - }, - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "exec_out", - "end_node_uuid": "trend-detector", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "output_2", - "end_node_uuid": "trend-detector", - "end_pin_name": "weather_data" - }, - { - "start_node_uuid": "weather-analyzer", - "start_pin_name": "exec_out", - "end_node_uuid": "weather-report", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "trend-detector", - "start_pin_name": "exec_out", - "end_node_uuid": "weather-report", - "end_pin_name": "exec_in" - }, - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "output_1", - "end_node_uuid": "weather-report", - "end_pin_name": "city" - }, - { - "start_node_uuid": "weather-simulator", - "start_pin_name": "output_2", - "end_node_uuid": "weather-report", - "end_pin_name": "weather_data" - }, - { - "start_node_uuid": "weather-analyzer", - "start_pin_name": "output_1", - "end_node_uuid": "weather-report", - "end_pin_name": "temp_stats" - }, - { - "start_node_uuid": "weather-analyzer", - "start_pin_name": "output_2", - "end_node_uuid": "weather-report", - "end_pin_name": "conditions" - }, - { - "start_node_uuid": "weather-analyzer", - "start_pin_name": "output_3", - "end_node_uuid": "weather-report", - "end_pin_name": "env_stats" - }, - { - "start_node_uuid": "trend-detector", - "start_pin_name": "output_1", - "end_node_uuid": "weather-report", - "end_pin_name": "temp_trend" - }, - { - "start_node_uuid": "trend-detector", - "start_pin_name": "output_2", - "end_node_uuid": "weather-report", - "end_pin_name": "precip_pattern" - }, - { - "start_node_uuid": "trend-detector", - "start_pin_name": "output_3", - "end_node_uuid": "weather-report", - "end_pin_name": "insights" - } - ], - "requirements": [] -} \ No newline at end of file diff --git a/examples/weather_data_processor.md b/examples/weather_data_processor.md new file mode 100644 index 0000000..077a8fe --- /dev/null +++ b/examples/weather_data_processor.md @@ -0,0 +1,603 @@ +# Weather Data Processor + +Weather data simulation and analysis workflow with seasonal parameter modeling, statistical calculations, trend detection algorithms, and formatted report generation. Implements random.uniform() data generation, statistics module calculations, Counter frequency analysis, and datetime-based time series processing. + +## Node: Weather Data Simulator (ID: weather-simulator) + +Generates weather data using random.uniform() with seasonal temperature ranges, datetime.timedelta() for date sequences, and conditional logic for weather patterns. Uses dictionary lookup for season-based temperature ranges: Spring (10-20°C), Summer (20-35°C), Fall (5-18°C), Winter (-5-10°C). Applies random.randint() for humidity and precipitation probability calculations. + +Implements condition classification using if-elif logic based on precipitation thresholds, temperature ranges, and humidity levels. Returns Tuple[str, List[Dict]] containing city name and weather data list. Each dictionary includes date, temperature, humidity, wind_speed, precipitation, condition, and day_of_week fields generated using datetime.strftime() formatting. + +### Metadata + +```json +{ + "uuid": "weather-simulator", + "title": "Weather Data Simulator", + "pos": [ + 100.0, + 200.0 + ], + "size": [ + 250, + 342 + ], + "colors": { + "title": "#007bff", + "body": "#0056b3" + }, + "gui_state": { + "city": "New York", + "days": 30, + "season": "Spring" + } +} +``` + +### Logic + +```python +import random +import datetime +from typing import List, Dict, Tuple + +@node_entry +def simulate_weather_data(city: str, days: int, season: str) -> Tuple[str, List[Dict]]: + # Temperature ranges by season (Celsius) + temp_ranges = { + 'Spring': (10, 20), + 'Summer': (20, 35), + 'Fall': (5, 18), + 'Winter': (-5, 10) + } + + base_temp_range = temp_ranges.get(season, (10, 25)) + + weather_data = [] + current_date = datetime.datetime.now() + + for i in range(days): + date = current_date + datetime.timedelta(days=i) + + # Generate realistic weather patterns + base_temp = random.uniform(base_temp_range[0], base_temp_range[1]) + temp_variation = random.uniform(-3, 3) # Daily variation + temperature = round(base_temp + temp_variation, 1) + + # Humidity tends to be higher in summer and with rain + base_humidity = 60 if season == 'Summer' else 70 + humidity = max(30, min(95, base_humidity + random.randint(-20, 20))) + + # Wind speed + wind_speed = round(random.uniform(5, 25), 1) + + # Precipitation (higher chance in fall/winter) + precip_chance = 0.4 if season in ['Fall', 'Winter'] else 0.2 + precipitation = round(random.uniform(0, 15), 1) if random.random() < precip_chance else 0 + + # Weather conditions + if precipitation > 10: + condition = 'Heavy Rain' + elif precipitation > 2: + condition = 'Light Rain' + elif humidity > 85: + condition = 'Cloudy' + elif temperature > 28: + condition = 'Hot' + elif temperature < 5: + condition = 'Cold' + else: + condition = 'Clear' + + weather_data.append({ + 'date': date.strftime('%Y-%m-%d'), + 'temperature': temperature, + 'humidity': humidity, + 'wind_speed': wind_speed, + 'precipitation': precipitation, + 'condition': condition, + 'day_of_week': date.strftime('%A') + }) + + print(f"\n=== WEATHER SIMULATION ===") + print(f"City: {city}") + print(f"Season: {season}") + print(f"Generated {len(weather_data)} days of data") + print(f"Date range: {weather_data[0]['date']} to {weather_data[-1]['date']}") + + return city, weather_data +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QSpinBox, QComboBox, QPushButton + +layout.addWidget(QLabel('City:', parent)) +widgets['city'] = QLineEdit(parent) +widgets['city'].setPlaceholderText('Enter city name...') +widgets['city'].setText('New York') +layout.addWidget(widgets['city']) + +layout.addWidget(QLabel('Number of Days:', parent)) +widgets['days'] = QSpinBox(parent) +widgets['days'].setRange(7, 365) +widgets['days'].setValue(30) +layout.addWidget(widgets['days']) + +layout.addWidget(QLabel('Season:', parent)) +widgets['season'] = QComboBox(parent) +widgets['season'].addItems(['Spring', 'Summer', 'Fall', 'Winter']) +layout.addWidget(widgets['season']) + +widgets['simulate_btn'] = QPushButton('Generate Weather Data', parent) +layout.addWidget(widgets['simulate_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'city': widgets['city'].text(), + 'days': widgets['days'].value(), + 'season': widgets['season'].currentText() + } + +def set_initial_state(widgets, state): + widgets['city'].setText(state.get('city', 'New York')) + widgets['days'].setValue(state.get('days', 30)) + widgets['season'].setCurrentText(state.get('season', 'Spring')) +``` + + +## Node: Weather Statistics Analyzer (ID: weather-analyzer) + +Calculates weather statistics using statistics.mean(), statistics.median(), min(), max(), and statistics.stdev() on temperature lists. Implements Counter for weather condition frequency analysis and list comprehensions for data extraction. Uses max() and min() with key lambda functions to identify extreme weather days. + +Returns Tuple[Dict, Dict, Dict] containing temperature statistics, condition counts, and environmental statistics. Environmental analysis includes avg_humidity, avg_wind, total_precipitation using sum(), rainy day counting with conditional list comprehension, and extreme day identification through key-based sorting. + +### Metadata + +```json +{ + "uuid": "weather-analyzer", + "title": "Weather Statistics Analyzer", + "pos": [ + 470.0, + 150.0 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#28a745", + "body": "#1e7e34" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple +from collections import Counter +import statistics + +@node_entry +def analyze_weather(weather_data: List[Dict]) -> Tuple[Dict, Dict, Dict]: + if not weather_data: + return {}, {}, {} + + # Temperature statistics + temperatures = [day['temperature'] for day in weather_data] + temp_stats = { + 'avg': round(statistics.mean(temperatures), 1), + 'min': min(temperatures), + 'max': max(temperatures), + 'median': round(statistics.median(temperatures), 1) + } + + if len(temperatures) > 1: + temp_stats['std_dev'] = round(statistics.stdev(temperatures), 1) + + # Weather conditions analysis + conditions = [day['condition'] for day in weather_data] + condition_counts = Counter(conditions) + + # Environmental analysis + humidity_vals = [day['humidity'] for day in weather_data] + wind_vals = [day['wind_speed'] for day in weather_data] + precip_vals = [day['precipitation'] for day in weather_data] + + env_stats = { + 'avg_humidity': round(statistics.mean(humidity_vals), 1), + 'avg_wind': round(statistics.mean(wind_vals), 1), + 'total_precipitation': round(sum(precip_vals), 1), + 'rainy_days': len([p for p in precip_vals if p > 0]), + 'hottest_day': max(weather_data, key=lambda x: x['temperature']), + 'coldest_day': min(weather_data, key=lambda x: x['temperature']), + 'windiest_day': max(weather_data, key=lambda x: x['wind_speed']) + } + + print(f"\n=== WEATHER ANALYSIS ===") + print(f"Temperature: {temp_stats['min']}°C to {temp_stats['max']}°C (avg: {temp_stats['avg']}°C)") + print(f"Most common condition: {condition_counts.most_common(1)[0][0]}") + print(f"Rainy days: {env_stats['rainy_days']}/{len(weather_data)}") + + return temp_stats, dict(condition_counts), env_stats +``` + + +## Node: Weather Trend Detector (ID: trend-detector) + +Detects weather trends using list slicing for early/late period comparison, calculates temperature averages with sum() and len(), and identifies trends with +/-2°C thresholds. Analyzes precipitation patterns using total rainfall calculations and rainy day percentage thresholds (>3mm/day = wet, <20% rainy days = dry). + +Implements consecutive pattern detection using loops with counter variables for hot streaks (>25°C), cold streaks (<10°C), and rain streaks (>1mm). Compares weekend vs weekday temperatures using list comprehensions with day_of_week filtering. Returns Tuple[str, str, List[str]] containing temperature trend, precipitation pattern, and insights list. + +### Metadata + +```json +{ + "uuid": "trend-detector", + "title": "Weather Trend Detector", + "pos": [ + 470.0, + 450.0 + ], + "size": [ + 250, + 168 + ], + "colors": { + "title": "#fd7e14", + "body": "#e8590c" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict, Tuple + +@node_entry +def detect_trends(weather_data: List[Dict]) -> Tuple[str, str, List[str]]: + if len(weather_data) < 3: + return "Insufficient data", "No patterns", [] + + temperatures = [day['temperature'] for day in weather_data] + precip_values = [day['precipitation'] for day in weather_data] + + # Temperature trend analysis + temp_trend = "Stable" + if len(temperatures) >= 5: + early_avg = sum(temperatures[:len(temperatures)//3]) / (len(temperatures)//3) + late_avg = sum(temperatures[-len(temperatures)//3:]) / (len(temperatures)//3) + + if late_avg > early_avg + 2: + temp_trend = "Warming" + elif late_avg < early_avg - 2: + temp_trend = "Cooling" + + # Precipitation pattern + total_precip = sum(precip_values) + rainy_days = len([p for p in precip_values if p > 0]) + + if total_precip > len(weather_data) * 3: # More than 3mm per day on average + precip_pattern = "Wet period" + elif rainy_days < len(weather_data) * 0.2: # Less than 20% rainy days + precip_pattern = "Dry period" + else: + precip_pattern = "Normal precipitation" + + # Weather insights + insights = [] + + # Temperature extremes + max_temp = max(temperatures) + min_temp = min(temperatures) + if max_temp - min_temp > 20: + insights.append(f"High temperature variability ({max_temp - min_temp:.1f}°C range)") + + # Consecutive patterns + hot_streak = 0 + cold_streak = 0 + rain_streak = 0 + current_hot = 0 + current_cold = 0 + current_rain = 0 + + for day in weather_data: + if day['temperature'] > 25: + current_hot += 1 + current_cold = 0 + elif day['temperature'] < 10: + current_cold += 1 + current_hot = 0 + else: + current_hot = 0 + current_cold = 0 + + if day['precipitation'] > 1: + current_rain += 1 + else: + current_rain = 0 + + hot_streak = max(hot_streak, current_hot) + cold_streak = max(cold_streak, current_cold) + rain_streak = max(rain_streak, current_rain) + + if hot_streak >= 3: + insights.append(f"Heat wave detected ({hot_streak} consecutive hot days)") + + if cold_streak >= 3: + insights.append(f"Cold snap detected ({cold_streak} consecutive cold days)") + + if rain_streak >= 3: + insights.append(f"Rainy period detected ({rain_streak} consecutive rainy days)") + + # Weekly patterns + weekend_temps = [day['temperature'] for day in weather_data if day['day_of_week'] in ['Saturday', 'Sunday']] + weekday_temps = [day['temperature'] for day in weather_data if day['day_of_week'] not in ['Saturday', 'Sunday']] + + if weekend_temps and weekday_temps: + weekend_avg = sum(weekend_temps) / len(weekend_temps) + weekday_avg = sum(weekday_temps) / len(weekday_temps) + + if abs(weekend_avg - weekday_avg) > 3: + insights.append(f"Weekend/weekday temperature difference: {abs(weekend_avg - weekday_avg):.1f}°C") + + print(f"\n=== TREND DETECTION ===") + print(f"Temperature trend: {temp_trend}") + print(f"Precipitation pattern: {precip_pattern}") + print(f"Insights: {len(insights)} patterns detected") + + return temp_trend, precip_pattern, insights +``` + + +## Node: Weather Report Generator (ID: weather-report) + +Formats comprehensive weather report using string concatenation with f-string formatting and fixed-width column alignment. Combines statistical data, trend analysis, and weather insights into structured sections. Uses sum() for total calculations, sorted() with key functions for condition ranking, and percentage calculations for weather distributions. + +Implements conditional formatting for displaying statistics, creates recent weather displays using list slicing [-5:], and formats extreme weather events with dictionary access. Returns single formatted report string for QTextEdit display with Courier New monospace font and professional layout structure including headers, statistics, and event summaries. + +### Metadata + +```json +{ + "uuid": "weather-report", + "title": "Weather Report Generator", + "pos": [ + 850.0, + 300.0 + ], + "size": [ + 276, + 673 + ], + "colors": { + "title": "#6c757d", + "body": "#545b62" + }, + "gui_state": {} +} +``` + +### Logic + +```python +from typing import List, Dict + +@node_entry +def generate_weather_report(city: str, weather_data: List[Dict], temp_stats: Dict, conditions: Dict, env_stats: Dict, temp_trend: str, precip_pattern: str, insights: List[str]) -> str: + if not weather_data: + return "No weather data available" + + report = "\n" + "="*70 + "\n" + report += " WEATHER ANALYSIS REPORT\n" + report += "="*70 + "\n\n" + + # Location and Period + report += f"🌍 LOCATION: {city.upper()}\n" + report += f"📅 PERIOD: {weather_data[0]['date']} to {weather_data[-1]['date']}\n" + report += f"📊 DATASET: {len(weather_data)} days\n\n" + + # Temperature Summary + if temp_stats: + report += f"🌡️ TEMPERATURE ANALYSIS\n" + report += f" Average: {temp_stats['avg']:6.1f}°C\n" + report += f" Range: {temp_stats['min']:6.1f}°C to {temp_stats['max']:6.1f}°C\n" + report += f" Median: {temp_stats['median']:6.1f}°C\n" + if 'std_dev' in temp_stats: + report += f" Variation: {temp_stats['std_dev']:6.1f}°C std dev\n" + report += f" Trend: {temp_trend}\n\n" + + # Environmental Conditions + if env_stats: + report += f"🌦️ ENVIRONMENTAL CONDITIONS\n" + report += f" Avg Humidity: {env_stats['avg_humidity']:6.1f}%\n" + report += f" Avg Wind Speed: {env_stats['avg_wind']:6.1f} km/h\n" + report += f" Total Rainfall: {env_stats['total_precipitation']:6.1f} mm\n" + report += f" Rainy Days: {env_stats['rainy_days']:6d} days\n" + report += f" Pattern: {precip_pattern}\n\n" + + # Weather Conditions Distribution + if conditions: + report += f"☁️ WEATHER CONDITIONS\n" + total_days = sum(conditions.values()) + for condition, count in sorted(conditions.items(), key=lambda x: x[1], reverse=True): + percentage = (count / total_days) * 100 + report += f" {condition:<12} {count:3d} days ({percentage:4.1f}%)\n" + report += "\n" + + # Notable Weather Events + if env_stats: + report += f"📋 NOTABLE EVENTS\n" + hottest = env_stats.get('hottest_day', {}) + coldest = env_stats.get('coldest_day', {}) + windiest = env_stats.get('windiest_day', {}) + + if hottest: + report += f" Hottest Day: {hottest['date']} ({hottest['temperature']}°C)\n" + if coldest: + report += f" Coldest Day: {coldest['date']} ({coldest['temperature']}°C)\n" + if windiest: + report += f" Windiest Day: {windiest['date']} ({windiest['wind_speed']} km/h)\n" + report += "\n" + + # Weather Patterns & Insights + if insights: + report += f"🔍 WEATHER PATTERNS\n" + for insight in insights: + report += f" • {insight}\n" + report += "\n" + + # Recent Weather (last 5 days) + report += f"📅 RECENT WEATHER (Last 5 Days)\n" + recent_days = weather_data[-5:] if len(weather_data) >= 5 else weather_data + for day in recent_days: + report += f" {day['date']} {day['temperature']:4.1f}°C {day['condition']:<12} " + if day['precipitation'] > 0: + report += f"({day['precipitation']}mm rain)\n" + else: + report += "\n" + + report += "\n" + "="*70 + + print(report) + return report +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QTextEdit, QPushButton +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont + +title_label = QLabel('Weather Analysis Report', parent) +title_font = QFont() +title_font.setPointSize(14) +title_font.setBold(True) +title_label.setFont(title_font) +layout.addWidget(title_label) + +widgets['report_display'] = QTextEdit(parent) +widgets['report_display'].setMinimumHeight(250) +widgets['report_display'].setReadOnly(True) +widgets['report_display'].setPlainText('Generate weather data to see analysis report...') +font = QFont('Courier New', 9) +widgets['report_display'].setFont(font) +layout.addWidget(widgets['report_display']) + +widgets['export_csv_btn'] = QPushButton('Export Data as CSV', parent) +layout.addWidget(widgets['export_csv_btn']) + +widgets['compare_btn'] = QPushButton('Compare with Other Cities', parent) +layout.addWidget(widgets['compare_btn']) + +widgets['forecast_btn'] = QPushButton('Generate Forecast', parent) +layout.addWidget(widgets['forecast_btn']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {} + +def set_values(widgets, outputs): + report = outputs.get('output_1', 'No report data') + widgets['report_display'].setPlainText(report) +``` + + +## Connections + +```json +[ + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "exec_out", + "end_node_uuid": "weather-analyzer", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "output_2", + "end_node_uuid": "weather-analyzer", + "end_pin_name": "weather_data" + }, + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "exec_out", + "end_node_uuid": "trend-detector", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "output_2", + "end_node_uuid": "trend-detector", + "end_pin_name": "weather_data" + }, + { + "start_node_uuid": "weather-analyzer", + "start_pin_name": "exec_out", + "end_node_uuid": "weather-report", + "end_pin_name": "exec_in" + }, + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "output_1", + "end_node_uuid": "weather-report", + "end_pin_name": "city" + }, + { + "start_node_uuid": "weather-simulator", + "start_pin_name": "output_2", + "end_node_uuid": "weather-report", + "end_pin_name": "weather_data" + }, + { + "start_node_uuid": "weather-analyzer", + "start_pin_name": "output_1", + "end_node_uuid": "weather-report", + "end_pin_name": "temp_stats" + }, + { + "start_node_uuid": "weather-analyzer", + "start_pin_name": "output_2", + "end_node_uuid": "weather-report", + "end_pin_name": "conditions" + }, + { + "start_node_uuid": "weather-analyzer", + "start_pin_name": "output_3", + "end_node_uuid": "weather-report", + "end_pin_name": "env_stats" + }, + { + "start_node_uuid": "trend-detector", + "start_pin_name": "output_1", + "end_node_uuid": "weather-report", + "end_pin_name": "temp_trend" + }, + { + "start_node_uuid": "trend-detector", + "start_pin_name": "output_2", + "end_node_uuid": "weather-report", + "end_pin_name": "precip_pattern" + }, + { + "start_node_uuid": "trend-detector", + "start_pin_name": "output_3", + "end_node_uuid": "weather-report", + "end_pin_name": "insights" + } +] +``` diff --git a/execution_controller.py b/execution_controller.py new file mode 100644 index 0000000..91d21ce --- /dev/null +++ b/execution_controller.py @@ -0,0 +1,130 @@ +# execution_controller.py +# Execution controller for managing batch and live mode execution + +from PySide6.QtWidgets import QPushButton, QLabel +from graph_executor import GraphExecutor +from event_system import LiveGraphExecutor +from ui_utils import ButtonStyleManager + + +class ExecutionController: + """Manages execution modes and controls for the node graph.""" + + def __init__(self, graph, output_log, get_venv_path_callback, + main_exec_button: QPushButton, status_label: QLabel): + self.graph = graph + self.output_log = output_log + self.get_venv_path_callback = get_venv_path_callback + self.main_exec_button = main_exec_button + self.status_label = status_label + + # Execution systems + self.executor = GraphExecutor(graph, output_log, get_venv_path_callback) + self.live_executor = LiveGraphExecutor(graph, output_log, get_venv_path_callback) + + # Execution state + self.live_mode = False + self.live_active = False + + # Initialize UI + self._update_ui_for_batch_mode() + + def on_mode_changed(self, mode_id): + """Handle radio button change between Batch (0) and Live (1) modes.""" + self.live_mode = mode_id == 1 + self.output_log.clear() + + if self.live_mode: + self._update_ui_for_live_mode() + else: + self._update_ui_for_batch_mode() + + def on_main_button_clicked(self): + """Handle the main execution button based on current mode and state.""" + if not self.live_mode: + # Batch mode execution + self._execute_batch_mode() + else: + # Live mode - toggle between start/pause + if not self.live_active: + self._start_live_mode() + else: + self._pause_live_mode() + + def _update_ui_for_batch_mode(self): + """Update UI elements for batch mode.""" + self.live_executor.set_live_mode(False) + self.live_active = False + self.main_exec_button.setText("▶️ Execute Graph") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("batch", "ready")) + self.status_label.setText("Ready") + self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") + + self.output_log.append("📦 === BATCH MODE SELECTED ===") + self.output_log.append("Click 'Execute Graph' to run entire graph at once") + + def _update_ui_for_live_mode(self): + """Update UI elements for live mode.""" + self.live_executor.set_live_mode(True) + self.live_active = False + self.main_exec_button.setText("🔥 Start Live Mode") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("live", "ready")) + self.status_label.setText("Live Ready") + self.status_label.setStyleSheet("color: #FF9800; font-weight: bold;") + + self.output_log.append("🎯 === LIVE MODE SELECTED ===") + self.output_log.append("📋 Click 'Start Live Mode' to activate interactive execution") + self.output_log.append("💡 Then use buttons inside nodes to control flow!") + + def _execute_batch_mode(self): + """Execute graph in batch mode.""" + self.output_log.clear() + self.output_log.append("▶️ === BATCH EXECUTION STARTED ===") + + # Update button state during execution + self.main_exec_button.setText("⏳ Executing...") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("batch", "executing")) + self.status_label.setText("Executing") + self.status_label.setStyleSheet("color: #607D8B; font-weight: bold;") + + try: + self.executor.execute() + self.output_log.append("✅ === BATCH EXECUTION FINISHED ===") + except Exception as e: + self.output_log.append(f"❌ === EXECUTION FAILED: {e} ===") + finally: + # Restore button state + self.main_exec_button.setText("▶️ Execute Graph") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("batch", "ready")) + self.status_label.setText("Ready") + self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") + + def _start_live_mode(self): + """Start live interactive mode.""" + self.output_log.clear() + self.output_log.append("🔥 === LIVE MODE ACTIVATED ===") + self.output_log.append("✨ Interactive execution enabled!") + self.output_log.append("🎮 Click buttons inside nodes to trigger execution") + self.output_log.append("📋 Graph state has been reset and is ready for interaction") + + self.live_active = True + self.live_executor.restart_graph() + + # Update button to pause state + self.main_exec_button.setText("⏸️ Pause Live Mode") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("live", "active")) + self.status_label.setText("Live Active") + self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") + + def _pause_live_mode(self): + """Pause live mode.""" + self.live_active = False + self.live_executor.set_live_mode(False) + + self.main_exec_button.setText("🔥 Resume Live Mode") + self.main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("live", "paused")) + self.status_label.setText("Live Paused") + self.status_label.setStyleSheet("color: #F44336; font-weight: bold;") + + self.output_log.append("⏸️ Live mode paused - node buttons are now inactive") + self.output_log.append("Click 'Resume Live Mode' to reactivate") \ No newline at end of file diff --git a/file_operations.py b/file_operations.py new file mode 100644 index 0000000..a6a026b --- /dev/null +++ b/file_operations.py @@ -0,0 +1,169 @@ +# file_operations.py +# File operations manager for PyFlowGraph supporting both .md and .json formats + +import json +import os +from PySide6.QtWidgets import QFileDialog +from PySide6.QtCore import QSettings +from flow_format import FlowFormatHandler, extract_title_from_filename + + +class FileOperationsManager: + """Manages file operations for loading and saving graphs in multiple formats.""" + + def __init__(self, parent_window, graph, output_log): + self.parent_window = parent_window + self.graph = graph + self.output_log = output_log + self.settings = QSettings("PyFlowGraph", "NodeEditor") + + # Current file state + self.current_file_path = None + self.current_graph_name = "untitled" + self.current_requirements = [] + + def get_current_venv_path(self, venv_parent_dir): + """Provides the full path to the venv for the current graph.""" + return os.path.join(venv_parent_dir, self.current_graph_name) + + def update_window_title(self): + """Updates the window title to show the current graph name.""" + if self.current_graph_name == "untitled": + self.parent_window.setWindowTitle("PyFlowGraph - Untitled") + else: + self.parent_window.setWindowTitle(f"PyFlowGraph - {self.current_graph_name}") + + def new_scene(self): + """Create a new empty scene.""" + self.graph.clear_graph() + self.current_graph_name = "untitled" + self.current_requirements = [] + self.current_file_path = None + self.update_window_title() + self.output_log.append("New scene created.") + + def save(self): + """Save the current graph.""" + if not self.current_file_path: + file_path, _ = QFileDialog.getSaveFileName( + self.parent_window, + "Save Graph As...", + "", + "Flow Files (*.md);;JSON Files (*.json)" + ) + if not file_path: + return False + self.current_file_path = file_path + + self.current_graph_name = os.path.splitext(os.path.basename(self.current_file_path))[0] + self.update_window_title() + return self._save_file(self.current_file_path) + + def save_as(self): + """Save the current graph with a new filename.""" + file_path, _ = QFileDialog.getSaveFileName( + self.parent_window, + "Save Graph As...", + "", + "Flow Files (*.md);;JSON Files (*.json)" + ) + if not file_path: + return False + + self.current_file_path = file_path + self.current_graph_name = os.path.splitext(os.path.basename(self.current_file_path))[0] + self.update_window_title() + return self._save_file(self.current_file_path) + + def load(self, file_path=None): + """Load a graph from file.""" + if not file_path: + file_path, _ = QFileDialog.getOpenFileName( + self.parent_window, + "Load Graph", + "", + "Flow Files (*.md);;JSON Files (*.json);;All Files (*.*)" + ) + + if file_path and os.path.exists(file_path): + self.current_file_path = file_path + self.current_graph_name = os.path.splitext(os.path.basename(file_path))[0] + self.update_window_title() + + data = self._load_file(file_path) + if data: + self.graph.deserialize(data) + self.current_requirements = data.get("requirements", []) + self.settings.setValue("last_file_path", file_path) + + # Let the graph's built-in deferred sizing fix handle the rendering + # The original fix from v0.5.0 already handles proper timing for node sizing + pass + + self.output_log.append(f"Graph loaded from {file_path}") + self.output_log.append("Dependencies loaded. Please verify the environment via the 'Run' menu.") + return True + + return False + + def load_last_file(self): + """Load the last opened file or default graph.""" + last_file = self.settings.value("last_file_path", None) + if last_file and os.path.exists(last_file): + return self.load(file_path=last_file) + else: + return self.load_initial_graph("examples/password_generator_tool.md") + + def load_initial_graph(self, file_path): + """Load the initial default graph.""" + if os.path.exists(file_path): + return self.load(file_path=file_path) + else: + self.output_log.append(f"Default graph file not found: '{file_path}'. Starting with an empty canvas.") + return False + + def _save_file(self, file_path: str): + """Save the graph to either .md or .json format based on file extension.""" + try: + data = self.graph.serialize() + data["requirements"] = self.current_requirements + + if file_path.lower().endswith('.md'): + # Save as .md format + handler = FlowFormatHandler() + title = extract_title_from_filename(file_path) + description = f"Graph created with PyFlowGraph containing {len(data.get('nodes', []))} nodes." + content = handler.json_to_flow(data, title, description) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + else: + # Save as JSON format + with open(file_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + + self.settings.setValue("last_file_path", file_path) + self.output_log.append(f"Graph saved to {file_path}") + return True + + except Exception as e: + self.output_log.append(f"Error saving file {file_path}: {str(e)}") + return False + + def _load_file(self, file_path: str): + """Load a graph from either .md or .json format based on file extension.""" + try: + if file_path.lower().endswith('.md'): + # Load .md format + handler = FlowFormatHandler() + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + data = handler.flow_to_json(content) + else: + # Load JSON format + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + return data + except Exception as e: + self.output_log.append(f"Error loading file {file_path}: {str(e)}") + return None \ No newline at end of file diff --git a/flow_format.py b/flow_format.py new file mode 100644 index 0000000..383858b --- /dev/null +++ b/flow_format.py @@ -0,0 +1,211 @@ +# flow_format.py +# Handler for .md file format based on the FlowSpec specification + +import json +import re +from typing import Dict, List, Any, Optional, Tuple +from markdown_it import MarkdownIt + + +class FlowFormatHandler: + """Handles conversion between JSON graph format and .md markdown format.""" + + def __init__(self): + self.md = MarkdownIt() + + def json_to_flow(self, json_data: Dict[str, Any], title: str = "Untitled Graph", + description: str = "") -> str: + """Convert JSON graph data to .md markdown format.""" + + flow_content = f"# {title}\n\n" + if description: + flow_content += f"{description}\n\n" + + # Add nodes + for node in json_data.get("nodes", []): + flow_content += self._node_to_flow(node) + flow_content += "\n" + + # Add connections + flow_content += "## Connections\n\n" + flow_content += "```json\n" + flow_content += json.dumps(json_data.get("connections", []), indent=2) + flow_content += "\n```\n" + + return flow_content + + def _node_to_flow(self, node: Dict[str, Any]) -> str: + """Convert a single node to .md format.""" + uuid = node.get("uuid", "") + title = node.get("title", "") + + content = f"## Node: {title} (ID: {uuid})\n\n" + + # Add description if available (could be extracted from comments in code) + content += "Node description goes here.\n\n" + + # Metadata section + metadata = { + "uuid": uuid, + "title": title, + "pos": node.get("pos", [0, 0]), + "size": node.get("size", [200, 150]) + } + + # Include is_reroute flag if this is a reroute node + if node.get("is_reroute", False): + metadata["is_reroute"] = True + + # Always include colors (even if empty) for consistency + metadata["colors"] = node.get("colors", {}) + + # Always include gui_state (even if empty) for consistency + metadata["gui_state"] = node.get("gui_state", {}) + + content += "### Metadata\n\n" + content += "```json\n" + content += json.dumps(metadata, indent=2) + content += "\n```\n\n" + + # Logic section + content += "### Logic\n\n" + content += "```python\n" + content += node.get("code", "") + content += "\n```\n\n" + + # GUI Definition (include even if empty for consistency) + gui_code = node.get("gui_code", "") + if gui_code.strip(): # Only include section if there's actual content + content += "### GUI Definition\n\n" + content += "```python\n" + content += gui_code + content += "\n```\n\n" + + # GUI State Handler (include even if empty for consistency) + gui_get_values_code = node.get("gui_get_values_code", "") + if gui_get_values_code.strip(): # Only include section if there's actual content + content += "### GUI State Handler\n\n" + content += "```python\n" + content += gui_get_values_code + content += "\n```\n\n" + + return content + + def flow_to_json(self, flow_content: str) -> Dict[str, Any]: + """Convert .md markdown content to JSON graph format.""" + + tokens = self.md.parse(flow_content) + + graph_data = { + "nodes": [], + "connections": [], + "requirements": [] + } + + current_node = None + current_section = None + current_component = None + + i = 0 + while i < len(tokens): + token = tokens[i] + + if token.type == "heading_open": + level = int(token.tag[1]) # h1 -> 1, h2 -> 2, etc. + + # Get the heading content + if i + 1 < len(tokens) and tokens[i + 1].type == "inline": + heading_text = tokens[i + 1].content + + if level == 1: + # Graph title - we can ignore this for JSON conversion + pass + elif level == 2: + if heading_text == "Connections": + current_section = "connections" + current_node = None + else: + # Node header: "Node: Title (ID: uuid)" + match = re.match(r"Node:\s*(.*?)\s*\(ID:\s*(.*?)\)", heading_text) + if match: + title, uuid = match.groups() + current_node = { + "uuid": uuid.strip(), + "title": title.strip(), + "pos": [0, 0], + "size": [200, 150], + "code": "", + "gui_code": "", + "gui_get_values_code": "", + "gui_state": {}, + "colors": {}, + "is_reroute": False # Default to False, will be updated from metadata + } + graph_data["nodes"].append(current_node) + current_section = "node" + elif level == 3 and current_node is not None: + current_component = heading_text.lower() + + elif token.type == "fence" and token.info: + language = token.info.strip() + content = token.content.strip() + + if current_section == "connections" and language == "json": + try: + graph_data["connections"] = json.loads(content) + except json.JSONDecodeError: + pass # Skip invalid JSON + + elif current_section == "node" and current_node is not None: + if current_component == "metadata" and language == "json": + try: + metadata = json.loads(content) + current_node.update({ + "pos": metadata.get("pos", [0, 0]), + "size": metadata.get("size", [200, 150]), + "colors": metadata.get("colors", {}), + "gui_state": metadata.get("gui_state", {}), + "is_reroute": metadata.get("is_reroute", False) + }) + except json.JSONDecodeError: + pass + + elif current_component == "logic" and language == "python": + current_node["code"] = content + + elif current_component == "gui definition" and language == "python": + current_node["gui_code"] = content + + elif current_component == "gui state handler" and language == "python": + current_node["gui_get_values_code"] = content + + i += 1 + + return graph_data + + +def load_flow_file(file_path: str) -> Dict[str, Any]: + """Load a .md file and return JSON graph data.""" + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + handler = FlowFormatHandler() + return handler.flow_to_json(content) + + +def save_flow_file(file_path: str, json_data: Dict[str, Any], + title: str = "Untitled Graph", description: str = "") -> None: + """Save JSON graph data as a .md file.""" + handler = FlowFormatHandler() + content = handler.json_to_flow(json_data, title, description) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + +def extract_title_from_filename(file_path: str) -> str: + """Extract a title from a file path.""" + import os + name = os.path.splitext(os.path.basename(file_path))[0] + # Convert underscores to spaces and title case + return name.replace('_', ' ').title() \ No newline at end of file diff --git a/flow_spec.md b/flow_spec.md new file mode 100644 index 0000000..a24007e --- /dev/null +++ b/flow_spec.md @@ -0,0 +1,187 @@ +# FlowSpec: The .md File Format Specification + +**Version:** 1.0 +**File Extension:** .md + +## 1. Introduction & Philosophy + +FlowSpec is a structured, document-based file format for defining node-based graphs and workflows. It is designed to be human-readable, version-control friendly, and easily parsed by both humans and AI models. + +**Core Philosophy:** "the document is the graph." + +### Guiding Principles: +- **Readability First**: Clear structure for human authors and reviewers +- **Structured & Unambiguous**: Rigid structure allowing deterministic parsing +- **Version Control Native**: Clean diffs in Git and other VCS +- **Language Agnostic**: Code blocks can contain any programming language +- **LLM Friendly**: Descriptive format ideal for AI interaction + +## 2. Core Concepts + +- **Graph**: The entire document represents a single graph (Level 1 Heading) +- **Node**: A major section (Level 2 Heading) representing a graph node +- **Component**: A subsection (Level 3 Heading) within a node +- **Data Block**: Machine-readable data in fenced code blocks + +## 3. File Structure Specification + +### 3.1 Graph Header +Every .md file MUST begin with a single Level 1 Heading (#). + +```markdown +# Graph Title + +Optional graph description goes here. +``` + +### 3.2 Node Definitions +Each node MUST use this exact format: + +```markdown +## Node: (ID: ) + +Optional node description. + +### Metadata +```json +{ + "uuid": "unique-identifier", + "title": "Human-Readable-Title", + "pos": [100, 200], + "size": [300, 250] +} +``` + +### Logic +```python +@node_entry +def node_function(input_param: str) -> str: + return f"Processed: {input_param}" +``` +``` + +### 3.3 Required Components +- **Metadata**: JSON object with uuid, title, and optional pos/size +- **Logic**: Python function with @node_entry decorator + +### 3.4 Optional Components +- **GUI Definition**: Python code for user interface widgets +- **GUI State Handler**: Functions for widget state management + +### 3.5 Connections Section +The file MUST contain exactly one Connections section: + +```markdown +## Connections +```json +[ + { + "start_node_uuid": "node1", + "start_pin_name": "output_1", + "end_node_uuid": "node2", + "end_pin_name": "input_param" + } +] +``` +``` + +## 4. Simple Example + +```markdown +# Hello World Pipeline + +A basic two-node pipeline demonstrating the .md format. + +## Node: Text Generator (ID: generator) + +Creates a simple text message. + +### Metadata +```json +{ + "uuid": "generator", + "title": "Text Generator", + "pos": [100, 100], + "size": [200, 150] +} +``` + +### Logic +```python +@node_entry +def generate_text() -> str: + return "Hello, World!" +``` + +## Node: Text Printer (ID: printer) + +Prints the received text message. + +### Metadata +```json +{ + "uuid": "printer", + "title": "Text Printer", + "pos": [400, 100], + "size": [200, 150] +} +``` + +### Logic +```python +@node_entry +def print_text(message: str) -> str: + print(f"Received: {message}") + return message +``` + +## Connections +```json +[ + { + "start_node_uuid": "generator", + "start_pin_name": "output_1", + "end_node_uuid": "printer", + "end_pin_name": "message" + } +] +``` +``` + +## 5. Parser Implementation + +A parser should use markdown-it-py to tokenize the document: + +### 5.1 Algorithm +1. **Tokenize**: Parse file into token stream (don't render to HTML) +2. **State Machine**: Track current node and component being parsed +3. **Section Detection**: + - `h1`: Graph title + - `h2`: Node header (regex: `Node: (.*) \(ID: (.*)\)`) or "Connections" + - `h3`: Component type (Metadata, Logic, etc.) +4. **Data Extraction**: Extract `content` from `fence` tokens based on `info` language tag +5. **Graph Construction**: Build in-memory graph from collected data + +### 5.2 Token Types +- `heading_open` with `h1/h2/h3` tags +- `fence` with `info` property for language detection +- `inline` for text content + +### 5.3 Validation Rules +- Exactly one h1 heading +- Each node must have unique uuid +- Metadata and Logic components are required +- Connections section is required +- JSON must be valid in metadata and connections + +## 6. Extension Points + +The format supports extension through: +- Additional component types (### Custom Component) +- Custom metadata fields +- Multiple programming languages in Logic blocks +- Custom connection properties + +--- + +*This specification ensures .md files are both human-readable documents and structured data formats suitable for programmatic processing.* \ No newline at end of file diff --git a/node_editor_window.py b/node_editor_window.py index 6aa824a..9b55669 100644 --- a/node_editor_window.py +++ b/node_editor_window.py @@ -1,98 +1,95 @@ # node_editor_window.py -# The main application window. -# Now correctly saves and restores the view's center point for robust panning. +# The main application window - refactored for better maintainability -import json import os -from PySide6.QtWidgets import QMainWindow, QMenuBar, QFileDialog, QTextEdit, QDockWidget, QInputDialog, QToolBar, QStyle, QWidget, QHBoxLayout, QRadioButton, QPushButton, QButtonGroup, QLabel -from PySide6.QtGui import QAction, QFont, QTransform, QIcon, QPainter, QColor +from PySide6.QtWidgets import (QMainWindow, QTextEdit, QDockWidget, QInputDialog, + QToolBar, QWidget, QHBoxLayout) +from PySide6.QtGui import QAction from PySide6.QtCore import Qt, QPointF, QSettings + from node_graph import NodeGraph from node_editor_view import NodeEditorView -from graph_executor import GraphExecutor -from event_system import LiveGraphExecutor from environment_manager import EnvironmentManagerDialog from settings_dialog import SettingsDialog - -def create_fa_icon(char_code, color="white", font_style="regular"): - """Creates a QIcon from a Font Awesome character code.""" - from PySide6.QtGui import QPixmap - - pixmap = QPixmap(32, 32) - pixmap.fill(Qt.transparent) - painter = QPainter(pixmap) - - if font_style == "solid": - font = QFont("Font Awesome 6 Free Solid") - else: - font = QFont("Font Awesome 7 Free Regular") - - font.setPixelSize(24) - painter.setFont(font) - painter.setPen(QColor(color)) - painter.drawText(pixmap.rect(), Qt.AlignCenter, char_code) - painter.end() - return QIcon(pixmap) +# Import our new modular components +from ui_utils import create_fa_icon, create_execution_control_widget +from file_operations import FileOperationsManager +from execution_controller import ExecutionController +from view_state_manager import ViewStateManager class NodeEditorWindow(QMainWindow): + """Main application window for PyFlowGraph node editor.""" + def __init__(self, parent=None): super().__init__(parent) self.setGeometry(100, 100, 1800, 1000) - # --- Settings and Environment Configuration --- + # Initialize core components + self._setup_core_components() + self._setup_ui() + self._setup_managers() + + # Load initial state + self.file_ops.load_last_file() + + def _setup_core_components(self): + """Initialize the core graph and view components.""" self.settings = QSettings("PyFlowGraph", "NodeEditor") self.venv_parent_dir = self.settings.value("venv_parent_dir", os.path.join(os.getcwd(), "venvs")) - self.current_graph_name = "untitled" - self.update_window_title() - self.current_requirements = [] - self.current_file_path = None - + + # Core graph components self.graph = NodeGraph(self) self.view = NodeEditorView(self.graph, self) self.setCentralWidget(self.view) + # Output log self.output_log = QTextEdit() self.output_log.setReadOnly(True) dock = QDockWidget("Output Log") dock.setWidget(self.output_log) self.addDockWidget(Qt.BottomDockWidgetArea, dock) - # Dual execution system: batch (traditional) and live (interactive) - self.executor = GraphExecutor(self.graph, self.output_log, self.get_current_venv_path) - self.live_executor = LiveGraphExecutor(self.graph, self.output_log, self.get_current_venv_path) - self.live_mode = False - self.live_active = False # Whether live mode is currently active - + def _setup_ui(self): + """Setup the user interface elements.""" self._create_actions() self._create_menus() self._create_toolbar() - self.load_last_file() + def _setup_managers(self): + """Initialize the manager components.""" + # File operations manager + self.file_ops = FileOperationsManager(self, self.graph, self.output_log) + + # View state manager + self.view_state = ViewStateManager(self.view, self.file_ops) + + # Execution controller (initialized after toolbar creation) + self.execution_ctrl = ExecutionController( + self.graph, + self.output_log, + self._get_current_venv_path, + self.exec_widget.main_exec_button, + self.exec_widget.status_label + ) - def get_current_venv_path(self): + def _get_current_venv_path(self): """Provides the full path to the venv for the current graph.""" - return os.path.join(self.venv_parent_dir, self.current_graph_name) - - def update_window_title(self): - """Updates the window title to show the current graph name.""" - if self.current_graph_name == "untitled": - self.setWindowTitle("PyFlowGraph - Untitled") - else: - self.setWindowTitle(f"PyFlowGraph - {self.current_graph_name}") + return self.file_ops.get_current_venv_path(self.venv_parent_dir) def _create_actions(self): - self.action_new = QAction(create_fa_icon("\uf15b", "lightblue"), "&New Scene", self) # fa-file + """Create all the action objects for menus and toolbars.""" + self.action_new = QAction(create_fa_icon("\uf15b", "lightblue"), "&New Scene", self) self.action_new.triggered.connect(self.on_new_scene) - self.action_save = QAction(create_fa_icon("\uf0c7", "orange"), "&Save Graph...", self) # fa-save + self.action_save = QAction(create_fa_icon("\uf0c7", "orange"), "&Save Graph...", self) self.action_save.triggered.connect(self.on_save) - self.action_save_as = QAction(create_fa_icon("\uf0c5", "orange"), "Save &As...", self) # fa-copy + self.action_save_as = QAction(create_fa_icon("\uf0c5", "orange"), "Save &As...", self) self.action_save_as.triggered.connect(self.on_save_as) - self.action_load = QAction(create_fa_icon("\uf07c", "yellow"), "&Load Graph...", self) # fa-folder-open + self.action_load = QAction(create_fa_icon("\uf07c", "yellow"), "&Load Graph...", self) self.action_load.triggered.connect(self.on_load) self.action_settings = QAction("Settings...", self) @@ -101,15 +98,17 @@ def _create_actions(self): self.action_manage_env = QAction("&Manage Environment...", self) self.action_manage_env.triggered.connect(self.on_manage_env) - # Remove old execution actions - we'll use custom widgets instead - self.action_add_node = QAction("Add &Node...", self) self.action_add_node.triggered.connect(self.on_add_node) + self.action_exit = QAction("E&xit", self) self.action_exit.triggered.connect(self.close) def _create_menus(self): + """Create the menu bar and menus.""" menu_bar = self.menuBar() + + # File menu file_menu = menu_bar.addMenu("&File") file_menu.addAction(self.action_new) file_menu.addAction(self.action_load) @@ -117,16 +116,19 @@ def _create_menus(self): file_menu.addAction(self.action_save_as) file_menu.addSeparator() file_menu.addAction(self.action_exit) + + # Edit menu edit_menu = menu_bar.addMenu("&Edit") edit_menu.addAction(self.action_add_node) edit_menu.addSeparator() edit_menu.addAction(self.action_settings) + + # Run menu run_menu = menu_bar.addMenu("&Run") run_menu.addAction(self.action_manage_env) - run_menu.addSeparator() - # Execution controls are now in toolbar only for better UX def _create_toolbar(self): + """Create the main toolbar.""" toolbar = QToolBar("Main Toolbar") self.addToolBar(toolbar) @@ -138,362 +140,71 @@ def _create_toolbar(self): toolbar.addSeparator() # Create execution control widget - self._create_execution_controls(toolbar) - - def _create_execution_controls(self, toolbar): - """Create the execution mode and control widget.""" - # Container widget - exec_widget = QWidget() - layout = QHBoxLayout(exec_widget) - layout.setContentsMargins(10, 5, 10, 5) - layout.setSpacing(15) - - # Mode selection label - mode_label = QLabel("Execution Mode:") - mode_label.setStyleSheet("font-weight: bold; color: #E0E0E0;") - layout.addWidget(mode_label) - - # Radio buttons for mode selection - self.mode_button_group = QButtonGroup() - - self.batch_radio = QRadioButton("Batch") - self.batch_radio.setToolTip("Traditional one-shot execution of entire graph") - self.batch_radio.setChecked(True) # Default mode - self.batch_radio.setStyleSheet( - """ - QRadioButton { color: #E0E0E0; font-weight: bold; } - QRadioButton::indicator::checked { background-color: #4CAF50; } - """ - ) - - self.live_radio = QRadioButton("Live") - self.live_radio.setToolTip("Interactive mode with event-driven execution") - self.live_radio.setStyleSheet( - """ - QRadioButton { color: #E0E0E0; font-weight: bold; } - QRadioButton::indicator::checked { background-color: #FF9800; } - """ + self.exec_widget = create_execution_control_widget( + self._on_mode_changed, + self._on_main_button_clicked ) + toolbar.addWidget(self.exec_widget) - self.mode_button_group.addButton(self.batch_radio, 0) - self.mode_button_group.addButton(self.live_radio, 1) - self.mode_button_group.idClicked.connect(self.on_mode_changed) - - layout.addWidget(self.batch_radio) - layout.addWidget(self.live_radio) - - # Separator - separator = QLabel("|") - separator.setStyleSheet("color: #666; font-size: 16px;") - layout.addWidget(separator) - - # Main execution button - changes based on mode - self.main_exec_button = QPushButton("▶️ Execute Graph") - self.main_exec_button.setMinimumSize(140, 35) - self.main_exec_button.setStyleSheet(self._get_button_style("batch")) - self.main_exec_button.clicked.connect(self.on_main_button_clicked) - self.main_exec_button.setShortcut("F5") - layout.addWidget(self.main_exec_button) - - # Status indicator - self.status_label = QLabel("Ready") - self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 12px;") - layout.addWidget(self.status_label) - - # Add to toolbar - toolbar.addWidget(exec_widget) - - def _get_button_style(self, mode, state="ready"): - """Get stylesheet for the main button based on mode and state.""" - if mode == "batch": - if state == "ready": - return """ - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background-color: #45a049; - } - QPushButton:pressed { - background-color: #3d8b40; - } - """ - else: # executing - return """ - QPushButton { - background-color: #607D8B; - color: white; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - """ - else: # live mode - if state == "ready": - return """ - QPushButton { - background-color: #FF9800; - color: white; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background-color: #F57C00; - } - QPushButton:pressed { - background-color: #E65100; - } - """ - elif state == "active": - return """ - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background-color: #45a049; - } - """ - else: # paused - return """ - QPushButton { - background-color: #F44336; - color: white; - border: none; - border-radius: 6px; - font-weight: bold; - font-size: 12px; - } - QPushButton:hover { - background-color: #da190b; - } - """ - - def save_view_state(self): - """Saves the current view's transform (zoom) and center point (pan) to QSettings.""" - if self.current_file_path: - self.settings.beginGroup(f"view_state/{self.current_file_path}") - self.settings.setValue("transform", self.view.transform()) - # Save the scene coordinates of the center of the view - center_point = self.view.mapToScene(self.view.viewport().rect().center()) - self.settings.setValue("center_point", center_point) - self.settings.endGroup() - - def load_view_state(self): - """Loads and applies the view's transform and center point from QSettings.""" - if self.current_file_path: - self.settings.beginGroup(f"view_state/{self.current_file_path}") - transform_data = self.settings.value("transform") - center_point = self.settings.value("center_point") - self.settings.endGroup() - - if isinstance(transform_data, QTransform): - self.view.setTransform(transform_data) - - if isinstance(center_point, QPointF): - # Use centerOn to robustly set the pan position - self.view.centerOn(center_point) - - def closeEvent(self, event): - """Save the view state of the current file before closing.""" - self.save_view_state() - event.accept() + def _on_mode_changed(self, mode_id): + """Handle execution mode changes.""" + self.execution_ctrl.on_mode_changed(mode_id) - def load_last_file(self): - last_file = self.settings.value("last_file_path", None) - if last_file and os.path.exists(last_file): - self.on_load(file_path=last_file) - else: - self.load_initial_graph("examples/password_generator_tool.json") + def _on_main_button_clicked(self): + """Handle main execution button clicks.""" + self.execution_ctrl.on_main_button_clicked() + # File operation handlers def on_new_scene(self): - self.save_view_state() - self.graph.clear_graph() - self.current_graph_name = "untitled" - self.current_requirements = [] - self.current_file_path = None - self.update_window_title() + """Create a new scene.""" + self.view_state.save_view_state() + self.file_ops.new_scene() self.view.resetTransform() - self.output_log.append("New scene created.") - def load_initial_graph(self, file_path): - if os.path.exists(file_path): - self.on_load(file_path=file_path) - else: - self.output_log.append(f"Default graph file not found: '{file_path}'. Starting with an empty canvas.") + def on_save(self): + """Save the current graph.""" + self.file_ops.save() + def on_save_as(self): + """Save the current graph with a new filename.""" + self.file_ops.save_as() + + def on_load(self, file_path=None): + """Load a graph from file.""" + if not file_path: + self.view_state.save_view_state() + + if self.file_ops.load(file_path): + self.view_state.load_view_state() + + # Settings and environment handlers def on_settings(self): + """Open the settings dialog.""" dialog = SettingsDialog(self) if dialog.exec(): self.venv_parent_dir = self.settings.value("venv_parent_dir") self.output_log.append(f"Default venv directory updated to: {self.venv_parent_dir}") def on_manage_env(self): - venv_path = self.get_current_venv_path() - dialog = EnvironmentManagerDialog(venv_path, self.current_requirements, self) + """Open the environment manager dialog.""" + venv_path = self._get_current_venv_path() + dialog = EnvironmentManagerDialog(venv_path, self.file_ops.current_requirements, self) if dialog.exec(): - _, self.current_requirements = dialog.get_results() + _, self.file_ops.current_requirements = dialog.get_results() self.output_log.append("Environment requirements updated.") - def on_save(self): - if not self.current_file_path: - file_path, _ = QFileDialog.getSaveFileName(self, "Save Graph As...", "", "JSON Files (*.json)") - if not file_path: - return - self.current_file_path = file_path - - self.current_graph_name = os.path.splitext(os.path.basename(self.current_file_path))[0] - self.update_window_title() - data = self.graph.serialize() - data["requirements"] = self.current_requirements - with open(self.current_file_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - self.settings.setValue("last_file_path", self.current_file_path) - self.output_log.append(f"Graph saved to {self.current_file_path}") - - def on_save_as(self): - file_path, _ = QFileDialog.getSaveFileName(self, "Save Graph As...", "", "JSON Files (*.json)") - if not file_path: - return - - self.current_file_path = file_path - self.current_graph_name = os.path.splitext(os.path.basename(self.current_file_path))[0] - self.update_window_title() - data = self.graph.serialize() - data["requirements"] = self.current_requirements - with open(self.current_file_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=4, ensure_ascii=False) - self.settings.setValue("last_file_path", self.current_file_path) - self.output_log.append(f"Graph saved to {self.current_file_path}") - - def on_load(self, file_path=None): - if not file_path: - self.save_view_state() - file_path, _ = QFileDialog.getOpenFileName(self, "Load Graph", "", "JSON Files (*.json)") - - if file_path and os.path.exists(file_path): - self.current_file_path = file_path - self.current_graph_name = os.path.splitext(os.path.basename(file_path))[0] - self.update_window_title() - with open(file_path, "r", encoding="utf-8") as f: - data = json.load(f) - self.graph.deserialize(data) - self.current_requirements = data.get("requirements", []) - self.settings.setValue("last_file_path", file_path) - self.load_view_state() - self.output_log.append(f"Graph loaded from {file_path}") - self.output_log.append("Dependencies loaded. Please verify the environment via the 'Run' menu.") - - def on_mode_changed(self, mode_id): - """Handle radio button change between Batch (0) and Live (1) modes.""" - self.live_mode = mode_id == 1 - self.output_log.clear() - - if self.live_mode: - # Switch to live mode - self.live_executor.set_live_mode(True) - self.live_active = False - self.main_exec_button.setText("🔥 Start Live Mode") - self.main_exec_button.setStyleSheet(self._get_button_style("live", "ready")) - self.status_label.setText("Live Ready") - self.status_label.setStyleSheet("color: #FF9800; font-weight: bold;") - - self.output_log.append("🎯 === LIVE MODE SELECTED ===") - self.output_log.append("📋 Click 'Start Live Mode' to activate interactive execution") - self.output_log.append("💡 Then use buttons inside nodes to control flow!") - else: - # Switch to batch mode - self.live_executor.set_live_mode(False) - self.live_active = False - self.main_exec_button.setText("▶️ Execute Graph") - self.main_exec_button.setStyleSheet(self._get_button_style("batch", "ready")) - self.status_label.setText("Ready") - self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") - - self.output_log.append("📦 === BATCH MODE SELECTED ===") - self.output_log.append("Click 'Execute Graph' to run entire graph at once") - - def on_main_button_clicked(self): - """Handle the main execution button based on current mode and state.""" - if not self.live_mode: - # Batch mode execution - self._execute_batch_mode() - else: - # Live mode - toggle between start/pause - if not self.live_active: - self._start_live_mode() - else: - self._pause_live_mode() - - def _execute_batch_mode(self): - """Execute graph in batch mode.""" - self.output_log.clear() - self.output_log.append("▶️ === BATCH EXECUTION STARTED ===") - - # Update button state during execution - self.main_exec_button.setText("⏳ Executing...") - self.main_exec_button.setStyleSheet(self._get_button_style("batch", "executing")) - self.status_label.setText("Executing") - self.status_label.setStyleSheet("color: #607D8B; font-weight: bold;") - - try: - self.executor.execute() - self.output_log.append("✅ === BATCH EXECUTION FINISHED ===") - except Exception as e: - self.output_log.append(f"❌ === EXECUTION FAILED: {e} ===") - finally: - # Restore button state - self.main_exec_button.setText("▶️ Execute Graph") - self.main_exec_button.setStyleSheet(self._get_button_style("batch", "ready")) - self.status_label.setText("Ready") - self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") - - def _start_live_mode(self): - """Start live interactive mode.""" - self.output_log.clear() - self.output_log.append("🔥 === LIVE MODE ACTIVATED ===") - self.output_log.append("✨ Interactive execution enabled!") - self.output_log.append("🎮 Click buttons inside nodes to trigger execution") - self.output_log.append("📋 Graph state has been reset and is ready for interaction") - - self.live_active = True - self.live_executor.restart_graph() - - # Update button to pause state - self.main_exec_button.setText("⏸️ Pause Live Mode") - self.main_exec_button.setStyleSheet(self._get_button_style("live", "active")) - self.status_label.setText("Live Active") - self.status_label.setStyleSheet("color: #4CAF50; font-weight: bold;") - - def _pause_live_mode(self): - """Pause live mode.""" - self.live_active = False - self.live_executor.set_live_mode(False) - - self.main_exec_button.setText("🔥 Resume Live Mode") - self.main_exec_button.setStyleSheet(self._get_button_style("live", "paused")) - self.status_label.setText("Live Paused") - self.status_label.setStyleSheet("color: #F44336; font-weight: bold;") - - self.output_log.append("⏸️ Live mode paused - node buttons are now inactive") - self.output_log.append("Click 'Resume Live Mode' to reactivate") - def on_add_node(self, scene_pos=None): + """Add a new node to the graph.""" title, ok = QInputDialog.getText(self, "Add Node", "Enter Node Title:") if ok and title: if not isinstance(scene_pos, QPointF): scene_pos = self.view.mapToScene(self.view.viewport().rect().center()) node = self.graph.create_node(title, pos=(scene_pos.x(), scene_pos.y())) - node.set_code("from typing import Tuple\n\n" "@node_entry\n" "def node_function(input_1: str) -> Tuple[str, int]:\n" " return 'hello', len(input_1)") + node.set_code("from typing import Tuple\n\n" "@node_entry\n" + "def node_function(input_1: str) -> Tuple[str, int]:\n" + " return 'hello', len(input_1)") + + def closeEvent(self, event): + """Handle application close event.""" + self.view_state.save_view_state() + event.accept() \ No newline at end of file diff --git a/node_graph.py b/node_graph.py index 22485be..9f08a04 100644 --- a/node_graph.py +++ b/node_graph.py @@ -91,11 +91,11 @@ def deserialize(self, data, offset=QPointF(0, 0)): node = self.create_node("", pos=(new_pos.x(), new_pos.y()), is_reroute=True) else: node = self.create_node(node_data["title"], pos=(new_pos.x(), new_pos.y())) - if "size" in node_data: - node.width, node.height = node_data["size"] node.set_code(node_data.get("code", "")) node.set_gui_code(node_data.get("gui_code", "")) node.set_gui_get_values_code(node_data.get("gui_get_values_code", "")) + if "size" in node_data: + node.width, node.height = node_data["size"] colors = node_data.get("colors", {}) if "title" in colors: node.color_title_bar = QColor(colors["title"]) @@ -127,7 +127,13 @@ def deserialize(self, data, offset=QPointF(0, 0)): def final_load_update(self, nodes_to_update): """A helper method called by a timer to run the final layout pass.""" for node in nodes_to_update: - node.fit_size_to_content() + # Force a complete layout rebuild like manual resize does + node._update_layout() + # Update all pin connections like manual resize does + for pin in node.pins: + pin.update_connections() + # Force node visual update + node.update() self.update() # --- Other methods remain the same --- diff --git a/requirements.txt b/requirements.txt index 212a708..2b1a52b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyside6 -nuitka \ No newline at end of file +nuitka +markdown-it-py diff --git a/run_gui_test.bat b/run_gui_test.bat new file mode 100644 index 0000000..884ff90 --- /dev/null +++ b/run_gui_test.bat @@ -0,0 +1,16 @@ +@echo off +echo Running GUI Loading Fix Test... +echo. + +REM Activate virtual environment if it exists +if exist "venv\Scripts\activate.bat" ( + echo Activating virtual environment... + call venv\Scripts\activate.bat +) + +REM Run the test +python test_gui_loading_fix.py + +echo. +echo Test completed. Press any key to exit... +pause > nul \ No newline at end of file diff --git a/run_quick_test.bat b/run_quick_test.bat new file mode 100644 index 0000000..bb82e82 --- /dev/null +++ b/run_quick_test.bat @@ -0,0 +1,54 @@ +@echo off +:: Quick Test Runner - Run the most important tests +:: This script runs the tests that identify the core issues + +echo ================================================ +echo PyFlowGraph Quick Test Runner +echo ================================================ +echo Running the key tests to identify GUI/pin issues... +echo. + +echo [1/2] Testing for specific GUI bugs (text_processing_pipeline.md)... +echo ================================================ +python test_specific_gui_bugs.py +set gui_result=%errorlevel% +echo. + +echo [2/2] Testing for pin creation bugs (root cause)... +echo ================================================ +python test_pin_creation_bug.py +set pin_result=%errorlevel% +echo. + +echo ================================================ +echo TEST RESULTS SUMMARY +echo ================================================ + +if %gui_result%==0 ( + echo ✓ GUI Bug Tests: PASSED - GUI components are working correctly +) else ( + echo ✗ GUI Bug Tests: FAILED - Found GUI rendering issues +) + +if %pin_result%==0 ( + echo ✓ Pin Creation Tests: PASSED - Pins are created properly +) else ( + echo ✗ Pin Creation Tests: FAILED - Found pin categorization bug ^(ROOT CAUSE^) +) + +echo. +if %pin_result% neq 0 ( + echo DIAGNOSIS: The issue is pin categorization during markdown loading, + echo not GUI rendering. Nodes have pins but they lack proper pin_direction + echo attributes, which makes connections fail and GUI appear broken. + echo. + echo RECOMMENDATION: Fix pin direction assignment in markdown deserialization. +) else if %gui_result% neq 0 ( + echo DIAGNOSIS: Found GUI rendering issues that need investigation. +) else ( + echo STATUS: No major issues detected in this test run. +) + +echo. +echo To run more comprehensive tests, use: run_tests.bat +echo. diff --git a/run_tests.bat b/run_tests.bat new file mode 100644 index 0000000..03492f3 --- /dev/null +++ b/run_tests.bat @@ -0,0 +1,140 @@ +@echo off +:: PyFlowGraph Test Runner +:: Helper script to run various GUI and pin creation tests + +echo ================================================ +echo PyFlowGraph Test Suite Runner +echo ================================================ +echo. + +:menu +echo Select which tests to run: +echo. +echo 1. Quick GUI Bug Detection (recommended) +echo 2. Pin Creation Bug Test (root cause) +echo 3. GUI Rendering Tests (visual verification) +echo 4. Comprehensive GUI Tests (full suite) +echo 5. All Tests (run everything) +echo 6. Original Execution Flow Test +echo. +echo 0. Exit +echo. +set /p choice="Enter your choice (0-6): " + +if "%choice%"=="0" goto :exit +if "%choice%"=="1" goto :quick +if "%choice%"=="2" goto :pin_test +if "%choice%"=="3" goto :rendering +if "%choice%"=="4" goto :comprehensive +if "%choice%"=="5" goto :all_tests +if "%choice%"=="6" goto :original +goto :invalid + +:quick +echo. +echo ================================================ +echo Running Quick GUI Bug Detection Tests +echo ================================================ +echo This tests the specific issues you reported +echo. +python test_specific_gui_bugs.py +echo. +pause +goto :menu + +:pin_test +echo. +echo ================================================ +echo Running Pin Creation Bug Tests +echo ================================================ +echo This identifies the root cause of the issues +echo. +python test_pin_creation_bug.py +echo. +pause +goto :menu + +:rendering +echo. +echo ================================================ +echo Running GUI Rendering Tests +echo ================================================ +echo This verifies visual GUI components work correctly +echo. +python test_gui_rendering.py +echo. +pause +goto :menu + +:comprehensive +echo. +echo ================================================ +echo Running Comprehensive GUI Tests +echo ================================================ +echo This is the full GUI loading test suite +echo. +python test_gui_loading.py +echo. +pause +goto :menu + +:all_tests +echo. +echo ================================================ +echo Running ALL Tests +echo ================================================ +echo This will run every test file in sequence +echo. + +echo --- Basic GUI Loading Tests --- +python test_gui_loading_bugs.py +echo. + +echo --- Specific GUI Bug Tests --- +python test_specific_gui_bugs.py +echo. + +echo --- GUI Rendering Tests --- +python test_gui_rendering.py +echo. + +echo --- Pin Creation Bug Tests --- +python test_pin_creation_bug.py +echo. + +echo --- Comprehensive GUI Tests --- +python test_gui_loading.py +echo. + +echo --- Original Execution Flow Test --- +python test_execution_flow.py +echo. + +echo ================================================ +echo All tests completed! +echo ================================================ +pause +goto :menu + +:original +echo. +echo ================================================ +echo Running Original Execution Flow Test +echo ================================================ +echo This is the original test from the codebase +echo. +python test_execution_flow.py +echo. +pause +goto :menu + +:invalid +echo. +echo Invalid choice. Please select 0-6. +echo. +goto :menu + +:exit +echo. +echo Exiting test runner... +exit /b 0 \ No newline at end of file diff --git a/test_gui_loading.py b/test_gui_loading.py new file mode 100644 index 0000000..75bfdee --- /dev/null +++ b/test_gui_loading.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 + +""" +Unit tests for GUI-related loading issues in markdown graphs. +Tests various scenarios where nodes with GUI components fail to load correctly. +""" + +import sys +import os +import tempfile +import unittest +from unittest.mock import Mock, patch +import json + +# Add project root to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication, QGraphicsView +from PySide6.QtCore import QTimer, Qt +from PySide6.QtTest import QTest + +from node import Node +from node_graph import NodeGraph +from flow_format import FlowFormatHandler, load_flow_file +from file_operations import FileOperationsManager + + +class TestGUILoading(unittest.TestCase): + """Test suite for GUI-related loading issues in markdown graphs.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def setUp(self): + """Set up test fixtures.""" + self.graph = NodeGraph() + self.handler = FlowFormatHandler() + self.view = QGraphicsView(self.graph) + from PySide6.QtWidgets import QTextEdit + self.file_ops = FileOperationsManager(None, self.graph, QTextEdit()) + + def tearDown(self): + """Clean up after each test.""" + # Clear the graph + for node in list(self.graph.nodes): + self.graph.remove_node(node) + + def create_test_markdown_file(self, content): + """Helper to create a temporary markdown file for testing.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f: + f.write(content) + return f.name + + def test_valid_gui_code_loading(self): + """Test that valid GUI code loads without errors.""" + markdown_content = '''# Test Graph + +## Node: Test Node (ID: test-node-1) + +Node with valid GUI components. + +### Metadata + +```json +{ + "uuid": "test-node-1", + "title": "Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": { + "test_value": "initial" + } +} +``` + +### Logic + +```python +@node_entry +def test_func(input_val: str = "default") -> str: + return f"processed: {input_val}" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton + +widgets['label'] = QLabel('Input:', parent) +layout.addWidget(widgets['label']) + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) + +widgets['button'] = QPushButton('Process', parent) +layout.addWidget(widgets['button']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {'input_val': widgets['input'].text()} + +def set_values(widgets, outputs): + if 'output_1' in outputs: + widgets['input'].setText(str(outputs['output_1'])) + +def set_initial_state(widgets, state): + if 'test_value' in state: + widgets['input'].setText(state['test_value']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + # Verify node was created + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + # Verify GUI components are properly loaded + self.assertIsNotNone(node.gui_code) + self.assertIsNotNone(node.gui_get_values_code) + + # Force GUI rebuild and check for widgets + node.rebuild_gui() + self.assertGreater(len(node.gui_widgets), 0) + self.assertIn('label', node.gui_widgets) + self.assertIn('input', node.gui_widgets) + self.assertIn('button', node.gui_widgets) + + def test_invalid_gui_code_handling(self): + """Test that invalid GUI code is handled gracefully.""" + markdown_content = '''# Test Graph + +## Node: Invalid GUI Node (ID: invalid-gui-1) + +Node with invalid GUI code. + +### Metadata + +```json +{ + "uuid": "invalid-gui-1", + "title": "Invalid GUI Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +# This will cause a syntax error +from PySide6.QtWidgets import QLabel +invalid_syntax_here !!! +widgets['label'] = QLabel('Test', parent) +layout.addWidget(widgets['label']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + # Verify node was created despite invalid GUI code + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + # GUI should fail gracefully - check for error label + node.rebuild_gui() + # Should have at least one widget (error label) + self.assertGreater(len(node.gui_widgets), 0) + + def test_missing_gui_state_handler(self): + """Test nodes with GUI definition but missing state handler.""" + markdown_content = '''# Test Graph + +## Node: Missing Handler Node (ID: missing-handler-1) + +Node with GUI but no state handler. + +### Metadata + +```json +{ + "uuid": "missing-handler-1", + "title": "Missing Handler Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": { + "initial_value": "test" + } +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + node.rebuild_gui() + + # Should handle missing state handler gracefully + self.assertIn('input', node.gui_widgets) + + # These should return empty/default values without crashing + values = node.get_gui_values() + self.assertEqual(values, {}) + + def test_node_height_after_gui_loading(self): + """Test that nodes maintain proper height after GUI loading.""" + markdown_content = '''# Test Graph + +## Node: Height Test Node (ID: height-test-1) + +Node to test height preservation. + +### Metadata + +```json +{ + "uuid": "height-test-1", + "title": "Height Test Node", + "pos": [100, 100], + "size": [250, 200], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QVBoxLayout + +for i in range(5): + widgets[f'label_{i}'] = QLabel(f'Label {i}', parent) + layout.addWidget(widgets[f'label_{i}']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + + # Check initial height from metadata + self.assertEqual(node.height, 200) + + # Rebuild GUI and verify height is maintained + node.rebuild_gui() + self.assertGreater(node.height, 0) + self.assertGreaterEqual(node.height, 150) # Should be at least minimum height + + def test_gui_state_application(self): + """Test that GUI state is properly applied after loading.""" + markdown_content = '''# Test Graph + +## Node: State Test Node (ID: state-test-1) + +Node to test state application. + +### Metadata + +```json +{ + "uuid": "state-test-1", + "title": "State Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": { + "input_text": "saved value", + "checkbox_state": true, + "slider_value": 75 + } +} +``` + +### Logic + +```python +@node_entry +def test_func(text: str = "", flag: bool = False, value: int = 0) -> str: + return f"{text}_{flag}_{value}" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QCheckBox, QSlider + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) + +widgets['checkbox'] = QCheckBox('Enable', parent) +layout.addWidget(widgets['checkbox']) + +widgets['slider'] = QSlider(Qt.Horizontal, parent) +widgets['slider'].setRange(0, 100) +layout.addWidget(widgets['slider']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return { + 'text': widgets['input'].text(), + 'flag': widgets['checkbox'].isChecked(), + 'value': widgets['slider'].value() + } + +def set_values(widgets, outputs): + pass # Not used in this test + +def set_initial_state(widgets, state): + if 'input_text' in state: + widgets['input'].setText(state['input_text']) + if 'checkbox_state' in state: + widgets['checkbox'].setChecked(state['checkbox_state']) + if 'slider_value' in state: + widgets['slider'].setValue(state['slider_value']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + node.rebuild_gui() + + # Apply the GUI state (during deserialization, gui_state is passed to apply_gui_state) + gui_state = {"input_text": "saved value", "checkbox_state": True, "slider_value": 75} + node.apply_gui_state(gui_state) + + # Verify state was applied correctly + self.assertEqual(node.gui_widgets['input'].text(), "saved value") + self.assertTrue(node.gui_widgets['checkbox'].isChecked()) + self.assertEqual(node.gui_widgets['slider'].value(), 75) + + def test_complex_gui_layout_loading(self): + """Test loading of complex GUI layouts with nested containers.""" + markdown_content = '''# Test Graph + +## Node: Complex GUI Node (ID: complex-gui-1) + +Node with complex GUI layout. + +### Metadata + +```json +{ + "uuid": "complex-gui-1", + "title": "Complex GUI Node", + "pos": [100, 100], + "size": [300, 250], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def complex_func() -> str: + return "complex" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit, QHBoxLayout, QVBoxLayout, QWidget, QGroupBox + +# Create a group box +widgets['group'] = QGroupBox('Settings', parent) +group_layout = QVBoxLayout(widgets['group']) + +# Add some controls to the group +widgets['input1'] = QLineEdit() +widgets['input2'] = QLineEdit() +group_layout.addWidget(QLabel('Input 1:')) +group_layout.addWidget(widgets['input1']) +group_layout.addWidget(QLabel('Input 2:')) +group_layout.addWidget(widgets['input2']) + +layout.addWidget(widgets['group']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + node.rebuild_gui() + + # Verify complex layout was created successfully + self.assertIn('group', node.gui_widgets) + self.assertIn('input1', node.gui_widgets) + self.assertIn('input2', node.gui_widgets) + + def test_proxy_widget_creation(self): + """Test that QGraphicsProxyWidget is properly created for GUI nodes.""" + markdown_content = '''# Test Graph + +## Node: Proxy Test Node (ID: proxy-test-1) + +Node to test proxy widget creation. + +### Metadata + +```json +{ + "uuid": "proxy-test-1", + "title": "Proxy Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel + +widgets['label'] = QLabel('Test Label', parent) +layout.addWidget(widgets['label']) +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + node.rebuild_gui() + + # Verify proxy widget exists and is properly configured + self.assertIsNotNone(node.proxy_widget) + self.assertIsNotNone(node.proxy_widget.widget()) + + def test_reroute_node_loading(self): + """Test that reroute nodes load correctly without GUI issues.""" + markdown_content = '''# Test Graph + +## Node: Reroute (ID: reroute-1) + +Simple reroute node. + +### Metadata + +```json +{ + "uuid": "reroute-1", + "title": "Reroute", + "pos": [200, 200], + "size": [50, 50], + "colors": {}, + "gui_state": {}, + "is_reroute": true +} +``` + +### Logic + +```python +# Reroute nodes don't have logic code +``` + +## Connections + +```json +[] +``` +''' + + # Load from markdown + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + # Verify reroute node was created + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + # Reroute nodes should not have GUI components + self.assertEqual(node.gui_code, "") + self.assertEqual(len(node.gui_widgets), 0) + + def test_malformed_gui_state_json(self): + """Test handling of malformed GUI state JSON in metadata.""" + markdown_content = '''# Test Graph + +## Node: Malformed State Node (ID: malformed-1) + +Node with malformed gui_state in metadata. + +### Metadata + +```json +{ + "uuid": "malformed-1", + "title": "Malformed State Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": "this is not valid JSON" +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +## Connections + +```json +[] +``` +''' + + # This should handle malformed JSON gracefully + try: + data = self.handler.flow_to_json(markdown_content) + # JSON parsing should fail gracefully and use default empty state + self.assertIn("nodes", data) + if data["nodes"]: + # gui_state should be empty dict as fallback + self.assertEqual(data["nodes"][0].get("gui_state", {}), {}) + except Exception as e: + self.fail(f"Should handle malformed JSON gracefully: {e}") + + def test_file_operations_gui_refresh(self): + """Test that FileOperations properly refreshes GUI after loading.""" + markdown_content = '''# Test Graph + +## Node: Refresh Test Node (ID: refresh-test-1) + +Node to test GUI refresh. + +### Metadata + +```json +{ + "uuid": "refresh-test-1", + "title": "Refresh Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel + +widgets['label'] = QLabel('Refresh Test', parent) +layout.addWidget(widgets['label']) +``` + +## Connections + +```json +[] +``` +''' + + # Create temporary file + temp_file = self.create_test_markdown_file(markdown_content) + + try: + # Mock the settings and other dependencies + with patch.object(self.file_ops, 'settings') as mock_settings: + mock_settings.setValue = Mock() + + # Load file using FileOperations + self.file_ops.open_file(temp_file) + + # Verify node was loaded and GUI was created + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + self.assertIn('label', node.gui_widgets) + + finally: + # Clean up temp file + os.unlink(temp_file) + + +class TestGUILoadingIntegration(unittest.TestCase): + """Integration tests for complete GUI loading workflow.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def test_load_existing_markdown_example(self): + """Test loading an existing markdown example file with GUI components.""" + # Test with the data analysis dashboard example if it exists + example_path = os.path.join(os.path.dirname(__file__), 'examples', 'data_analysis_dashboard.md') + + if os.path.exists(example_path): + try: + data = load_flow_file(example_path) + + # Create a graph and load the data + graph = NodeGraph() + graph.deserialize(data) + + # Find nodes with GUI components + gui_nodes = [node for node in graph.nodes if node.gui_code.strip()] + + # Verify GUI nodes load properly + for node in gui_nodes: + node.rebuild_gui() + # Should have widgets if gui_code is present + if node.gui_code.strip(): + self.assertGreater(len(node.gui_widgets), 0, + f"Node {node.title} should have GUI widgets") + + # GUI state is handled during deserialization, not stored as attribute + + except Exception as e: + self.fail(f"Failed to load existing markdown example: {e}") + + +def run_gui_tests(): + """Run all GUI loading tests.""" + # Create test suite + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add basic GUI loading tests + suite.addTest(loader.loadTestsFromTestCase(TestGUILoading)) + + # Add integration tests + suite.addTest(loader.loadTestsFromTestCase(TestGUILoadingIntegration)) + + # Run tests + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + print("=== GUI Loading Tests ===") + success = run_gui_tests() + + if success: + print("\n=== All GUI Loading Tests Passed ===") + sys.exit(0) + else: + print("\n=== Some GUI Loading Tests Failed ===") + sys.exit(1) \ No newline at end of file diff --git a/test_gui_loading_bugs.py b/test_gui_loading_bugs.py new file mode 100644 index 0000000..924baac --- /dev/null +++ b/test_gui_loading_bugs.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 + +""" +Focused unit tests for detecting GUI loading bugs in markdown graphs. +This test suite specifically targets the types of issues mentioned: +"any node that has a GUI doesn't load correctly" +""" + +import sys +import os +import tempfile +import unittest +import json + +# Add project root to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication, QGraphicsView, QTextEdit +from PySide6.QtCore import QTimer, Qt + +from node import Node +from node_graph import NodeGraph +from flow_format import FlowFormatHandler, load_flow_file + + +class TestGUILoadingBugs(unittest.TestCase): + """Test suite focused on detecting GUI loading bugs in markdown graphs.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def setUp(self): + """Set up test fixtures.""" + self.graph = NodeGraph() + self.handler = FlowFormatHandler() + + def tearDown(self): + """Clean up after each test.""" + # Clear the graph + for node in list(self.graph.nodes): + self.graph.remove_node(node) + + def test_gui_node_basic_loading(self): + """Test that GUI nodes load and have basic GUI components.""" + markdown_content = '''# Test Graph + +## Node: GUI Test Node (ID: gui-test-1) + +Basic GUI node test. + +### Metadata + +```json +{ + "uuid": "gui-test-1", + "title": "GUI Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit + +widgets['label'] = QLabel('Test:', parent) +layout.addWidget(widgets['label']) + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) +``` + +## Connections + +```json +[] +``` +''' + + # Load and verify + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + # Check that GUI node was created and has GUI components + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + # Verify GUI code was loaded + self.assertTrue(hasattr(node, 'gui_code'), "Node should have gui_code attribute") + self.assertNotEqual(node.gui_code.strip(), "", "GUI code should not be empty") + + # Force GUI rebuild (this is where bugs typically occur) + try: + node.rebuild_gui() + gui_built_successfully = True + except Exception as e: + gui_built_successfully = False + print(f"GUI rebuild failed: {e}") + + self.assertTrue(gui_built_successfully, "GUI should rebuild without errors") + + # Verify widgets were created + self.assertGreater(len(node.gui_widgets), 0, "GUI widgets should be created") + self.assertIn('label', node.gui_widgets, "Label widget should exist") + self.assertIn('input', node.gui_widgets, "Input widget should exist") + + def test_gui_node_zero_height_bug(self): + """Test for the zero height bug mentioned in git commits.""" + markdown_content = '''# Test Graph + +## Node: Height Test Node (ID: height-test-1) + +Test node height after GUI loading. + +### Metadata + +```json +{ + "uuid": "height-test-1", + "title": "Height Test Node", + "pos": [100, 100], + "size": [250, 200], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel + +widgets['label'] = QLabel('Test Label', parent) +layout.addWidget(widgets['label']) +``` + +## Connections + +```json +[] +``` +''' + + # Load and check height + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + + # Check height before GUI rebuild + initial_height = node.height + self.assertEqual(initial_height, 200, "Initial height should match metadata") + + # Rebuild GUI and check height + node.rebuild_gui() + + # Height should not be zero (this was the bug) + self.assertGreater(node.height, 0, "Node height should not be zero after GUI rebuild") + + # Height should be reasonable (not negative or extremely small) + self.assertGreaterEqual(node.height, 50, "Node height should be at least 50 pixels") + + def test_gui_code_execution_errors(self): + """Test that GUI code execution errors are handled gracefully.""" + markdown_content = '''# Test Graph + +## Node: Error Test Node (ID: error-test-1) + +Node with GUI code that causes errors. + +### Metadata + +```json +{ + "uuid": "error-test-1", + "title": "Error Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel +# This will cause an error - undefined variable +widgets['label'] = QLabel(undefined_variable, parent) +layout.addWidget(widgets['label']) +``` + +## Connections + +```json +[] +``` +''' + + # Load and test error handling + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + + # GUI rebuild should not crash, but handle error gracefully + try: + node.rebuild_gui() + rebuild_succeeded = True + except Exception as e: + rebuild_succeeded = False + print(f"GUI rebuild crashed: {e}") + + self.assertTrue(rebuild_succeeded, "GUI rebuild should handle errors gracefully") + + # Node should still be functional even with GUI errors + self.assertIsNotNone(node.title) + self.assertGreater(node.height, 0) + + def test_gui_proxy_widget_creation(self): + """Test that the QGraphicsProxyWidget is properly created for GUI nodes.""" + markdown_content = '''# Test Graph + +## Node: Proxy Widget Test (ID: proxy-test-1) + +Test proxy widget creation. + +### Metadata + +```json +{ + "uuid": "proxy-test-1", + "title": "Proxy Widget Test", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": {} +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QPushButton + +widgets['label'] = QLabel('Proxy Test', parent) +layout.addWidget(widgets['label']) + +widgets['button'] = QPushButton('Test Button', parent) +layout.addWidget(widgets['button']) +``` + +## Connections + +```json +[] +``` +''' + + # Load and test proxy widget + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + node.rebuild_gui() + + # Verify proxy widget was created + self.assertTrue(hasattr(node, 'proxy_widget'), "Node should have proxy_widget attribute") + + if hasattr(node, 'proxy_widget') and node.proxy_widget: + self.assertIsNotNone(node.proxy_widget, "Proxy widget should not be None") + + # Verify the proxy widget has a widget + if node.proxy_widget: + self.assertIsNotNone(node.proxy_widget.widget(), + "Proxy widget should contain a widget") + + def test_gui_state_handling(self): + """Test that GUI state is properly handled during loading.""" + markdown_content = '''# Test Graph + +## Node: State Test Node (ID: state-test-1) + +Test GUI state handling. + +### Metadata + +```json +{ + "uuid": "state-test-1", + "title": "State Test Node", + "pos": [100, 100], + "size": [250, 150], + "colors": {}, + "gui_state": { + "test_value": "initial_state" + } +} +``` + +### Logic + +```python +@node_entry +def test_func() -> str: + return "test" +``` + +### GUI Definition + +```python +from PySide6.QtWidgets import QLabel, QLineEdit + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) +``` + +### GUI State Handler + +```python +def get_values(widgets): + return {'test_value': widgets['input'].text()} + +def set_initial_state(widgets, state): + if 'test_value' in state: + widgets['input'].setText(state['test_value']) +``` + +## Connections + +```json +[] +``` +''' + + # Load and test state handling + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + node = self.graph.nodes[0] + + # Verify the GUI state was loaded + gui_state = data['nodes'][0].get('gui_state', {}) + self.assertIn('test_value', gui_state) + self.assertEqual(gui_state['test_value'], 'initial_state') + + # Rebuild GUI and apply state (this simulates the loading process) + node.rebuild_gui() + + # Verify widgets exist before applying state + self.assertIn('input', node.gui_widgets) + + # Apply the GUI state (this happens during deserialization) + node.apply_gui_state(gui_state) + + # Verify state was applied + self.assertEqual(node.gui_widgets['input'].text(), 'initial_state') + + def test_reroute_node_loading(self): + """Test that reroute nodes load correctly without GUI issues.""" + markdown_content = '''# Test Graph + +## Node: Reroute (ID: reroute-1) + +Reroute node test. + +### Metadata + +```json +{ + "uuid": "reroute-1", + "title": "Reroute", + "pos": [200, 200], + "size": [50, 50], + "colors": {}, + "gui_state": {}, + "is_reroute": true +} +``` + +### Logic + +```python +# Reroute nodes don't have logic +``` + +## Connections + +```json +[] +``` +''' + + # Load and verify reroute node + data = self.handler.flow_to_json(markdown_content) + self.graph.deserialize(data) + + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + # Reroute nodes should be a different class + self.assertEqual(type(node).__name__, 'RerouteNode') + + # Reroute nodes should not cause GUI-related errors + # RerouteNode uses radius instead of width/height + self.assertGreater(node.radius, 0) + + def test_existing_markdown_file_loading(self): + """Test loading an actual markdown example file.""" + example_path = os.path.join(os.path.dirname(__file__), 'examples', 'data_analysis_dashboard.md') + + if os.path.exists(example_path): + try: + # Load the file + data = load_flow_file(example_path) + + # Create graph and load data + graph = NodeGraph() + graph.deserialize(data) + + # Check that nodes were loaded + self.assertGreater(len(graph.nodes), 0, "Should load at least one node") + + # Find nodes with GUI code + gui_nodes = [] + for node in graph.nodes: + if hasattr(node, 'gui_code') and node.gui_code.strip(): + gui_nodes.append(node) + + # Test GUI nodes + for node in gui_nodes: + # GUI should rebuild without errors + try: + node.rebuild_gui() + gui_success = True + except Exception as e: + gui_success = False + print(f"GUI rebuild failed for {node.title}: {e}") + + self.assertTrue(gui_success, + f"Node {node.title} GUI should rebuild successfully") + + # Node should have reasonable dimensions + self.assertGreater(node.height, 0, + f"Node {node.title} should have positive height") + self.assertGreater(node.width, 0, + f"Node {node.title} should have positive width") + + except Exception as e: + # If file doesn't exist or has issues, that's OK for this test + print(f"Note: Could not test existing file: {e}") + + +def run_gui_bug_tests(): + """Run the GUI bug detection tests.""" + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(TestGUILoadingBugs) + + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + print("=== GUI Loading Bug Detection Tests ===") + print("Testing for common GUI loading issues in markdown graphs...") + print() + + success = run_gui_bug_tests() + + if success: + print("\n=== All GUI Loading Tests Passed ===") + print("No GUI loading bugs detected!") + sys.exit(0) + else: + print("\n=== GUI Loading Issues Detected ===") + print("Some tests failed - GUI loading bugs may be present.") + sys.exit(1) \ No newline at end of file diff --git a/test_gui_loading_fix.py b/test_gui_loading_fix.py new file mode 100644 index 0000000..44fe561 --- /dev/null +++ b/test_gui_loading_fix.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +Test to verify that GUI widgets load correctly from .md files without requiring manual resize. + +This test checks for the zero height bug and GUI loading issues that were present +when loading .md files compared to JSON files. +""" + +import sys +import os +import tempfile +import json +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QTimer, QEventLoop +from PySide6.QtTest import QTest + +# Add the project root to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from node_graph import NodeGraph +from flow_format import FlowFormatHandler + + +class TestGUILoadingFix: + """Test class to verify GUI loading works correctly from .md files.""" + + def __init__(self): + self.app = QApplication.instance() or QApplication(sys.argv) + self.graph = NodeGraph() + + def create_test_node_data(self): + """Create test data for a node with GUI components.""" + return { + "nodes": [ + { + "uuid": "test-node-with-gui", + "title": "Test GUI Node", + "pos": [100, 100], + "size": [300, 200], + "code": """from typing import Tuple + +@node_entry +def test_function(input_value: str) -> Tuple[str, int]: + return f"Processed: {input_value}", len(input_value)""", + "gui_code": """from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton + +layout.addWidget(QLabel('Test Input:', parent)) +widgets['input_field'] = QLineEdit(parent) +widgets['input_field'].setText('Default text') +layout.addWidget(widgets['input_field']) + +widgets['button'] = QPushButton('Test Button', parent) +layout.addWidget(widgets['button'])""", + "gui_get_values_code": """def get_values(widgets): + return {'input_value': widgets['input_field'].text()} + +def set_initial_state(widgets, state): + if 'input_value' in state: + widgets['input_field'].setText(state['input_value'])""", + "gui_state": {"input_value": "Test value"}, + "colors": {"title": "#007bff", "body": "#004494"}, + "is_reroute": False + }, + { + "uuid": "test-node-no-gui", + "title": "Test No GUI Node", + "pos": [400, 100], + "size": [200, 150], + "code": """@node_entry +def simple_function(x: int) -> int: + return x * 2""", + "gui_code": "", + "gui_get_values_code": "", + "gui_state": {}, + "colors": {"title": "#28a745", "body": "#1e7e34"}, + "is_reroute": False + } + ], + "connections": [], + "requirements": [] + } + + def create_test_md_file(self, data): + """Create a temporary .md file with test data.""" + handler = FlowFormatHandler() + md_content = handler.json_to_flow(data, "Test Graph", "Test graph for GUI loading verification") + + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') + temp_file.write(md_content) + temp_file.close() + return temp_file.name + + def wait_for_deferred_updates(self): + """Wait for Qt's deferred updates to complete.""" + # Process all pending events + self.app.processEvents() + + # Wait a bit more for the QTimer.singleShot(0, ...) to execute + loop = QEventLoop() + QTimer.singleShot(50, loop.quit) # 50ms should be enough for deferred updates + loop.exec() + + # Process events again after the timer + self.app.processEvents() + + def test_md_file_loading(self): + """Test that .md file loading works correctly.""" + print("Testing .md file loading...") + + # Create test data and temporary .md file + test_data = self.create_test_node_data() + md_file_path = self.create_test_md_file(test_data) + + try: + # Load the .md file + handler = FlowFormatHandler() + with open(md_file_path, 'r', encoding='utf-8') as f: + md_content = f.read() + + loaded_data = handler.flow_to_json(md_content) + + # Clear the graph and load the data + self.graph.clear_graph() + self.graph.deserialize(loaded_data) + + # Wait for deferred updates to complete + self.wait_for_deferred_updates() + + # Verify nodes were created + assert len(self.graph.nodes) == 2, f"Expected 2 nodes, got {len(self.graph.nodes)}" + + # Find the GUI node and no-GUI node + gui_node = None + no_gui_node = None + + for node in self.graph.nodes: + if node.title == "Test GUI Node": + gui_node = node + elif node.title == "Test No GUI Node": + no_gui_node = node + + assert gui_node is not None, "GUI node not found" + assert no_gui_node is not None, "No-GUI node not found" + + # Test 1: Check that nodes have proper non-zero dimensions + print(f"GUI node dimensions: {gui_node.width}x{gui_node.height}") + print(f"No-GUI node dimensions: {no_gui_node.width}x{no_gui_node.height}") + + assert gui_node.height > 0, f"GUI node has zero height: {gui_node.height}" + assert gui_node.width > 0, f"GUI node has zero width: {gui_node.width}" + assert no_gui_node.height > 0, f"No-GUI node has zero height: {no_gui_node.height}" + assert no_gui_node.width > 0, f"No-GUI node has zero width: {no_gui_node.width}" + + # Test 2: Check that GUI widgets were created + assert len(gui_node.gui_widgets) > 0, "GUI widgets were not created" + assert 'input_field' in gui_node.gui_widgets, "Input field widget not found" + assert 'button' in gui_node.gui_widgets, "Button widget not found" + + # Test 3: Check that GUI widgets are properly sized and visible + content_size_hint = gui_node.content_container.sizeHint() + print(f"Content container size hint: {content_size_hint.width()}x{content_size_hint.height()}") + assert content_size_hint.height() > 0, "Content container has zero height size hint" + + # Test 4: Check that pins were created correctly + assert len(gui_node.pins) > 0, "GUI node has no pins" + assert len(no_gui_node.pins) > 0, "No-GUI node has no pins" + + # Test 5: Check that GUI state was applied + input_field = gui_node.gui_widgets.get('input_field') + if input_field and hasattr(input_field, 'text'): + current_text = input_field.text() + print(f"Input field text: '{current_text}'") + assert current_text == "Test value", f"GUI state not applied correctly. Expected 'Test value', got '{current_text}'" + + print("✓ All tests passed! GUI loading works correctly.") + return True + + except Exception as e: + print(f"✗ Test failed: {str(e)}") + import traceback + traceback.print_exc() + return False + + finally: + # Clean up temporary file + try: + os.unlink(md_file_path) + except: + pass + + def test_json_loading_comparison(self): + """Test that JSON loading still works for comparison.""" + print("Testing JSON file loading for comparison...") + + test_data = self.create_test_node_data() + + # Create temporary JSON file + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') + json.dump(test_data, temp_file, indent=2) + temp_file.close() + + try: + # Load JSON data directly + with open(temp_file.name, 'r', encoding='utf-8') as f: + loaded_data = json.load(f) + + # Clear the graph and load the data + self.graph.clear_graph() + self.graph.deserialize(loaded_data) + + # Wait for deferred updates + self.wait_for_deferred_updates() + + # Verify it works the same way + assert len(self.graph.nodes) == 2, f"JSON loading: Expected 2 nodes, got {len(self.graph.nodes)}" + + for node in self.graph.nodes: + assert node.height > 0, f"JSON loading: Node {node.title} has zero height" + assert node.width > 0, f"JSON loading: Node {node.title} has zero width" + + print("✓ JSON loading comparison test passed!") + return True + + except Exception as e: + print(f"✗ JSON comparison test failed: {str(e)}") + return False + + finally: + try: + os.unlink(temp_file.name) + except: + pass + + +def main(): + """Run the GUI loading tests.""" + print("Running GUI Loading Fix Tests") + print("=" * 40) + + tester = TestGUILoadingFix() + + # Run tests + md_test_passed = tester.test_md_file_loading() + json_test_passed = tester.test_json_loading_comparison() + + print("\n" + "=" * 40) + if md_test_passed and json_test_passed: + print("🎉 All tests PASSED! The GUI loading fix is working correctly.") + return 0 + else: + print("❌ Some tests FAILED. There may still be issues with GUI loading.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test_gui_rendering.py b/test_gui_rendering.py new file mode 100644 index 0000000..e0b4183 --- /dev/null +++ b/test_gui_rendering.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 + +""" +GUI Rendering Tests for PyFlowGraph + +This test suite specifically tests that GUI components are actually rendered and visible +after loading markdown files, not just that widgets exist in memory. + +Addresses the issue: "nodes that have a GUI don't actually have the GUI shown" +""" + +import sys +import os +import unittest +import time + +# Add project root to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication, QGraphicsView, QWidget, QMainWindow +from PySide6.QtCore import QTimer, Qt, QRectF +from PySide6.QtTest import QTest + +from node import Node +from node_graph import NodeGraph +from flow_format import load_flow_file +from node_editor_view import NodeEditorView +from node_editor_window import NodeEditorWindow + + +class TestGUIRendering(unittest.TestCase): + """Test suite for verifying actual GUI rendering after markdown loading.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def setUp(self): + """Set up test fixtures.""" + self.graph = NodeGraph() + self.view = NodeEditorView(self.graph) + self.view.show() # Important: GUI must be shown to render properly + + # Process events to ensure GUI is initialized + QApplication.processEvents() + + def tearDown(self): + """Clean up after each test.""" + # Clean up the view + self.view.hide() + self.view.close() + + # Clear the graph + for node in list(self.graph.nodes): + self.graph.remove_node(node) + + # Process events to clean up + QApplication.processEvents() + + def wait_for_rendering(self, timeout_ms=1000): + """Wait for GUI rendering to complete.""" + start_time = time.time() * 1000 + while (time.time() * 1000 - start_time) < timeout_ms: + QApplication.processEvents() + QTest.qWait(10) + + def test_text_processing_pipeline_gui_rendering(self): + """Test that the text_processing_pipeline.md file loads with visible GUIs.""" + pipeline_path = os.path.join(os.path.dirname(__file__), 'examples', 'text_processing_pipeline.md') + + if not os.path.exists(pipeline_path): + self.skipTest(f"Test file not found: {pipeline_path}") + + # Load the markdown file + try: + data = load_flow_file(pipeline_path) + self.graph.deserialize(data) + except Exception as e: + self.fail(f"Failed to load text processing pipeline: {e}") + + # Verify nodes were loaded + self.assertGreater(len(self.graph.nodes), 0, "Should load nodes from the file") + + # Find nodes with GUI components + gui_nodes = [] + for node in self.graph.nodes: + if hasattr(node, 'gui_code') and node.gui_code.strip(): + gui_nodes.append(node) + + self.assertGreater(len(gui_nodes), 0, "Should have nodes with GUI code") + + # Force GUI rebuild and rendering + for node in gui_nodes: + node.rebuild_gui() + + # Wait for rendering to complete + self.wait_for_rendering(2000) + + # Test each GUI node + for node in gui_nodes: + with self.subTest(node=node.title): + self.verify_node_gui_rendering(node) + + def verify_node_gui_rendering(self, node): + """Verify that a node's GUI is actually rendered and visible.""" + + # 1. Check that GUI widgets were created + self.assertGreater(len(node.gui_widgets), 0, + f"Node {node.title} should have GUI widgets") + + # 2. Check that proxy widget exists and is visible + self.assertTrue(hasattr(node, 'proxy_widget'), + f"Node {node.title} should have proxy_widget attribute") + + if node.proxy_widget: + self.assertIsNotNone(node.proxy_widget.widget(), + f"Node {node.title} proxy widget should contain a widget") + + # Check if proxy widget is visible + self.assertTrue(node.proxy_widget.isVisible(), + f"Node {node.title} proxy widget should be visible") + + # 3. Check node dimensions (should not be zero height) + self.assertGreater(node.height, 0, + f"Node {node.title} height should be > 0, got {node.height}") + self.assertGreater(node.width, 0, + f"Node {node.title} width should be > 0, got {node.width}") + + # 4. Check that node height is reasonable (not tiny) + self.assertGreaterEqual(node.height, 100, + f"Node {node.title} should have reasonable height (≥100px), got {node.height}") + + # 5. Check that the node has proper bounding rect + bounding_rect = node.boundingRect() + self.assertGreater(bounding_rect.width(), 0, + f"Node {node.title} bounding rect width should be > 0") + self.assertGreater(bounding_rect.height(), 0, + f"Node {node.title} bounding rect height should be > 0") + + # 6. For nodes with custom widgets, verify the container is properly sized + if hasattr(node, 'content_container') and node.content_container: + container_size = node.content_container.size() + self.assertGreater(container_size.width(), 0, + f"Node {node.title} content container width should be > 0") + self.assertGreater(container_size.height(), 0, + f"Node {node.title} content container height should be > 0") + + def test_gui_node_zero_height_regression(self): + """Test for the specific zero height bug mentioned.""" + # Create a test node with GUI + node = self.graph.create_node("Zero Height Test", pos=(100, 100)) + + # Set GUI code that should create visible components + gui_code = ''' +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton, QCheckBox + +widgets['label1'] = QLabel('Test Label 1', parent) +layout.addWidget(widgets['label1']) + +widgets['input1'] = QLineEdit(parent) +widgets['input1'].setMinimumHeight(30) +layout.addWidget(widgets['input1']) + +widgets['checkbox1'] = QCheckBox('Test Checkbox', parent) +layout.addWidget(widgets['checkbox1']) + +widgets['button1'] = QPushButton('Test Button', parent) +layout.addWidget(widgets['button1']) +''' + node.set_gui_code(gui_code) + + # Simulate the loading process + node.rebuild_gui() + self.wait_for_rendering() + + # Verify the node doesn't have zero height + self.assertGreater(node.height, 0, "Node height should not be zero after GUI rebuild") + self.assertGreaterEqual(node.height, 150, "Node should have reasonable height for its content") + + # Verify widgets exist and are properly sized + self.assertEqual(len(node.gui_widgets), 4, "Should have 4 GUI widgets") + + # Check that proxy widget is properly configured + if node.proxy_widget: + proxy_size = node.proxy_widget.size() + self.assertGreater(proxy_size.width(), 0, "Proxy widget should have positive width") + self.assertGreater(proxy_size.height(), 0, "Proxy widget should have positive height") + + def test_pin_positions_after_gui_loading(self): + """Test that pins are not stuck in top-left corner after GUI loading.""" + # Create a node with GUI that should affect layout + node = self.graph.create_node("Pin Position Test", pos=(200, 200)) + + # Set code that will create input and output pins + logic_code = ''' +@node_entry +def test_function(input1: str, input2: int, flag: bool) -> str: + return f"result: {input1}_{input2}_{flag}" +''' + node.set_code(logic_code) + + # Set GUI code that adds substantial height + gui_code = ''' +from PySide6.QtWidgets import QLabel, QTextEdit, QSpinBox, QCheckBox + +for i in range(3): + widgets[f'label_{i}'] = QLabel(f'Label {i}', parent) + layout.addWidget(widgets[f'label_{i}']) + +widgets['text_area'] = QTextEdit(parent) +widgets['text_area'].setMinimumHeight(100) +layout.addWidget(widgets['text_area']) + +widgets['spinner'] = QSpinBox(parent) +layout.addWidget(widgets['spinner']) + +widgets['check'] = QCheckBox('Enable feature', parent) +layout.addWidget(widgets['check']) +''' + node.set_gui_code(gui_code) + + # Rebuild everything + node.update_pins_from_code() # This creates pins + node.rebuild_gui() # This should update layout + self.wait_for_rendering() + + # Check that node has reasonable height + self.assertGreaterEqual(node.height, 200, "Node with substantial GUI should be tall enough") + + # Check that pins are positioned correctly (not all at 0,0) + input_pins = [pin for pin in node.pins if pin.pin_direction == "input"] + output_pins = [pin for pin in node.pins if pin.pin_direction == "output"] + + self.assertGreater(len(input_pins), 0, "Should have input pins") + self.assertGreater(len(output_pins), 0, "Should have output pins") + + # Verify input pins are positioned down the left side + for i, pin in enumerate(input_pins): + pin_pos = pin.pos() + expected_y = 30 + i * 25 # Approximate expected position + + # Pin should not be at (0, 0) or stuck at the top + self.assertNotEqual(pin_pos.x(), 0, f"Input pin {pin.name} should not be at x=0") + self.assertGreaterEqual(pin_pos.y(), 20, f"Input pin {pin.name} should be positioned below title bar") + + # Pin should be positioned progressively down + if i > 0: + prev_pin_y = input_pins[i-1].pos().y() + self.assertGreater(pin_pos.y(), prev_pin_y, + f"Input pin {pin.name} should be below previous pin") + + # Verify output pins are positioned down the right side + for i, pin in enumerate(output_pins): + pin_pos = pin.pos() + + # Pin should be on the right side of the node + self.assertGreater(pin_pos.x(), node.width - 50, + f"Output pin {pin.name} should be on right side") + self.assertGreaterEqual(pin_pos.y(), 20, + f"Output pin {pin.name} should be positioned below title bar") + + def test_gui_refresh_after_markdown_load(self): + """Test that GUI properly refreshes after markdown loading (simulating the bug).""" + # This test simulates the sequence that happens when loading a .md file + + # Create a node manually first (simulating JSON loading) + node = self.graph.create_node("Refresh Test", pos=(300, 300)) + + # Set the properties as they would be loaded from markdown + node.width = 250 + node.height = 180 # Set a specific height + + node.set_code(''' +@node_entry +def test_func(text: str) -> str: + return text.upper() +''') + + node.set_gui_code(''' +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton + +widgets['label'] = QLabel('Enter text:', parent) +layout.addWidget(widgets['label']) + +widgets['input'] = QLineEdit(parent) +widgets['input'].setPlaceholderText('Type here...') +layout.addWidget(widgets['input']) + +widgets['button'] = QPushButton('Process', parent) +layout.addWidget(widgets['button']) +''') + + # Update pins from code (this happens during deserialization) + node.update_pins_from_code() + + # Now simulate the refresh that should happen after markdown loading + # This is what might be failing + node.rebuild_gui() + + # Force scene update (this is what the file_operations.py does) + self.graph.update() + + # Wait for rendering + self.wait_for_rendering(1000) + + # Verify the refresh worked properly + self.verify_node_gui_rendering(node) + + # Additional check: verify the GUI state can be applied + if hasattr(node, 'apply_gui_state'): + test_state = {'text': 'test input'} + try: + node.apply_gui_state(test_state) + gui_state_works = True + except Exception as e: + gui_state_works = False + print(f"GUI state application failed: {e}") + + self.assertTrue(gui_state_works, "GUI state application should work after refresh") + + +class TestGUIRenderingIntegration(unittest.TestCase): + """Integration test with full NodeEditorWindow.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def test_full_application_gui_loading(self): + """Test GUI loading in the context of the full application.""" + # This test is more realistic but also more complex + try: + # Create a minimal NodeEditorWindow + window = NodeEditorWindow() + window.show() + + # Process events to initialize + QApplication.processEvents() + QTest.qWait(100) + + # Try to load the text processing pipeline + pipeline_path = os.path.join(os.path.dirname(__file__), 'examples', 'text_processing_pipeline.md') + + if os.path.exists(pipeline_path): + # Use the window's file operations to load + try: + window.file_ops.load(pipeline_path) + + # Wait for loading to complete + QTest.qWait(500) + QApplication.processEvents() + + # Check that nodes with GUI were loaded properly + gui_nodes = [] + for node in window.graph.nodes: + if hasattr(node, 'gui_code') and node.gui_code.strip(): + gui_nodes.append(node) + + self.assertGreater(len(gui_nodes), 0, "Should load GUI nodes") + + # Verify each GUI node is properly rendered + for node in gui_nodes: + self.assertGreater(len(node.gui_widgets), 0, + f"Node {node.title} should have widgets") + self.assertGreater(node.height, 50, + f"Node {node.title} should have reasonable height") + + except Exception as e: + self.fail(f"Failed to load file through NodeEditorWindow: {e}") + + # Clean up + window.close() + + except Exception as e: + # If we can't create the full window, skip this test + self.skipTest(f"Could not create NodeEditorWindow: {e}") + + +def run_gui_rendering_tests(): + """Run all GUI rendering tests.""" + # Run with GUI event processing + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add the main rendering tests + suite.addTest(loader.loadTestsFromTestCase(TestGUIRendering)) + + # Add integration tests + suite.addTest(loader.loadTestsFromTestCase(TestGUIRenderingIntegration)) + + runner = unittest.TextTestRunner(verbosity=2, buffer=True) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + print("=== GUI Rendering Tests ===") + print("Testing actual GUI visibility and rendering after markdown loading...") + print("This test verifies that GUI components are visually rendered, not just created in memory.") + print() + + success = run_gui_rendering_tests() + + if success: + print("\n=== All GUI Rendering Tests Passed ===") + print("GUI components are rendering correctly after markdown loading!") + sys.exit(0) + else: + print("\n=== GUI Rendering Issues Detected ===") + print("Some GUI components are not rendering properly after markdown loading.") + sys.exit(1) \ No newline at end of file diff --git a/test_pin_creation_bug.py b/test_pin_creation_bug.py new file mode 100644 index 0000000..b5becd2 --- /dev/null +++ b/test_pin_creation_bug.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 + +""" +Pin Creation Bug Tests + +This test specifically targets the issue where nodes loaded from markdown +don't get their pins created properly, which causes: +1. Pins to be missing or stuck at (0,0) +2. Nodes to appear broken/unusable +3. Connection issues + +This is the REAL bug being experienced. +""" + +import sys +import os +import unittest + +# Add project root to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication + +from node import Node +from node_graph import NodeGraph +from flow_format import load_flow_file + + +class TestPinCreationBug(unittest.TestCase): + """Test suite for pin creation issues during markdown loading.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def setUp(self): + """Set up test fixtures.""" + self.graph = NodeGraph() + + def tearDown(self): + """Clean up after each test.""" + for node in list(self.graph.nodes): + self.graph.remove_node(node) + + def test_text_analyzer_pin_creation(self): + """Test the specific Text Statistics Analyzer node that has no pins.""" + pipeline_path = os.path.join(os.path.dirname(__file__), 'examples', 'text_processing_pipeline.md') + + if not os.path.exists(pipeline_path): + self.skipTest(f"Test file not found: {pipeline_path}") + + # Load the markdown file + data = load_flow_file(pipeline_path) + self.graph.deserialize(data) + + # Find the Text Statistics Analyzer node + analyzer_node = None + for node in self.graph.nodes: + if hasattr(node, 'title') and 'Text Statistics Analyzer' in node.title: + analyzer_node = node + break + + self.assertIsNotNone(analyzer_node, "Should find Text Statistics Analyzer node") + + print(f"\\nAnalyzing Text Statistics Analyzer node:") + print(f" Title: {analyzer_node.title}") + print(f" Code length: {len(analyzer_node.code)} characters") + print(f" Code preview: {analyzer_node.code[:100]}...") + print(f" Total pins: {len(getattr(analyzer_node, 'pins', []))}") + + # Check the pins in detail + pins = getattr(analyzer_node, 'pins', []) + input_pins = [p for p in pins if hasattr(p, 'pin_direction') and p.pin_direction == 'input'] + output_pins = [p for p in pins if hasattr(p, 'pin_direction') and p.pin_direction == 'output'] + + print(f" Input pins: {len(input_pins)}") + for pin in input_pins: + print(f" - {pin.name} ({getattr(pin, 'pin_type', 'unknown')})") + + print(f" Output pins: {len(output_pins)}") + for pin in output_pins: + print(f" - {pin.name} ({getattr(pin, 'pin_type', 'unknown')})") + + # This is the bug: the node should have pins based on its function signature + # Function: def analyze_text(text: str) -> Tuple[int, int, int, int, float, str] + # Should have: 1 input pin (text), 6 output pins, plus exec pins + + # Check for expected pins + expected_input_pins = ['text'] # plus exec_in + expected_output_pins = ['output_1', 'output_2', 'output_3', 'output_4', 'output_5', 'output_6'] # plus exec_out + + input_pin_names = [pin.name for pin in input_pins] + output_pin_names = [pin.name for pin in output_pins] + + print(f" Expected input pins: {expected_input_pins}") + print(f" Actual input pin names: {input_pin_names}") + print(f" Expected output pins: {expected_output_pins}") + print(f" Actual output pin names: {output_pin_names}") + + # The bug test: pins should exist + self.assertGreater(len(pins), 0, "Node should have pins") + self.assertGreater(len(input_pins), 0, "Node should have input pins") + self.assertGreater(len(output_pins), 0, "Node should have output pins") + + # Check for the text input pin specifically + has_text_input = any(pin.name == 'text' for pin in input_pins) + self.assertTrue(has_text_input, "Node should have 'text' input pin") + + # Check for multiple output pins (Tuple return creates multiple outputs) + self.assertGreaterEqual(len(output_pins), 6, "Node should have 6+ output pins for Tuple return") + + def test_manual_pin_creation_vs_markdown_loading(self): + """Compare manually created node vs markdown loaded node.""" + + # 1. Create a node manually and set the same code + manual_node = self.graph.create_node("Manual Test Node", pos=(100, 100)) + manual_node.set_code(''' +import re +from typing import Tuple +from collections import Counter + +@node_entry +def analyze_text(text: str) -> Tuple[int, int, int, int, float, str]: + # Basic counts + char_count = len(text) + word_count = len(text.split()) + sentence_count = len(re.findall(r'[.!?]+', text)) + paragraph_count = len([p for p in text.split('\\n\\n') if p.strip()]) + + # Average word length + words = text.split() + avg_word_length = sum(len(word.strip('.,!?;:')) for word in words) / len(words) if words else 0 + + # Most common words (top 5) + word_freq = Counter(word.lower().strip('.,!?;:') for word in words if len(word) > 2) + top_words = ', '.join([f"{word}({count})" for word, count in word_freq.most_common(5)]) + + return char_count, word_count, sentence_count, paragraph_count, round(avg_word_length, 1), top_words +''') + + # 2. Load the same node from markdown + pipeline_path = os.path.join(os.path.dirname(__file__), 'examples', 'text_processing_pipeline.md') + if os.path.exists(pipeline_path): + data = load_flow_file(pipeline_path) + + # Create a second graph for the markdown node + markdown_graph = NodeGraph() + markdown_graph.deserialize(data) + + # Find the analyzer node + markdown_node = None + for node in markdown_graph.nodes: + if hasattr(node, 'title') and 'Text Statistics Analyzer' in node.title: + markdown_node = node + break + + if markdown_node: + # Compare the two nodes + manual_pins = len(getattr(manual_node, 'pins', [])) + markdown_pins = len(getattr(markdown_node, 'pins', [])) + + print(f"\\nPin comparison:") + print(f" Manual node pins: {manual_pins}") + print(f" Markdown node pins: {markdown_pins}") + + manual_inputs = [p for p in getattr(manual_node, 'pins', []) if hasattr(p, 'pin_direction') and p.pin_direction == 'input'] + markdown_inputs = [p for p in getattr(markdown_node, 'pins', []) if hasattr(p, 'pin_direction') and p.pin_direction == 'input'] + + print(f" Manual input pins: {[p.name for p in manual_inputs]}") + print(f" Markdown input pins: {[p.name for p in markdown_inputs]}") + + # This is the bug: markdown loaded node should have same pins as manual node + self.assertEqual(manual_pins, markdown_pins, + "Markdown loaded node should have same number of pins as manually created node") + + self.assertEqual(len(manual_inputs), len(markdown_inputs), + "Markdown loaded node should have same number of input pins") + + def test_pin_creation_during_deserialization(self): + """Test the pin creation process during deserialization.""" + + # Simulate the data that would come from markdown + test_data = { + "nodes": [{ + "uuid": "test-analyzer", + "title": "Test Analyzer", + "pos": [100, 100], + "size": [250, 150], + "code": ''' +import re +from typing import Tuple + +@node_entry +def analyze_text(text: str) -> Tuple[int, int, int]: + char_count = len(text) + word_count = len(text.split()) + sentence_count = len(re.findall(r'[.!?]+', text)) + return char_count, word_count, sentence_count +''', + "gui_code": "", + "gui_get_values_code": "", + "gui_state": {}, + "colors": {} + }], + "connections": [] + } + + # Load using deserialize (same as markdown loading) + self.graph.deserialize(test_data) + + # Check the node + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + print(f"\\nDeserialization test:") + print(f" Node title: {node.title}") + print(f" Total pins: {len(getattr(node, 'pins', []))}") + + pins = getattr(node, 'pins', []) + input_pins = [p for p in pins if hasattr(p, 'pin_direction') and p.pin_direction == 'input'] + output_pins = [p for p in pins if hasattr(p, 'pin_direction') and p.pin_direction == 'output'] + + print(f" Input pins: {[p.name for p in input_pins]}") + print(f" Output pins: {[p.name for p in output_pins]}") + + # Should have text input and 3 outputs for Tuple[int, int, int] + self.assertGreater(len(pins), 0, "Deserialized node should have pins") + + # Check for specific expected pins + input_names = [p.name for p in input_pins] + self.assertIn('text', input_names, "Should have 'text' input pin") + + # Should have 3 output pins for the Tuple return + output_names = [p.name for p in output_pins] + expected_outputs = ['output_1', 'output_2', 'output_3'] + for expected in expected_outputs: + self.assertIn(expected, output_names, f"Should have '{expected}' output pin") + + +def run_pin_creation_tests(): + """Run the pin creation bug tests.""" + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(TestPinCreationBug) + + runner = unittest.TextTestRunner(verbosity=2, buffer=False) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + print("=== Pin Creation Bug Tests ===") + print("Testing for the pin creation issues in markdown-loaded nodes...") + print("This is likely the root cause of the GUI rendering problems.") + print() + + success = run_pin_creation_tests() + + if success: + print("\\n=== No Pin Creation Issues Found ===") + else: + print("\\n=== Pin Creation Bug Detected ===") + print("Found the root cause of the GUI rendering issues!") \ No newline at end of file diff --git a/test_specific_gui_bugs.py b/test_specific_gui_bugs.py new file mode 100644 index 0000000..afdf4cd --- /dev/null +++ b/test_specific_gui_bugs.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 + +""" +Specific GUI Bug Tests for PyFlowGraph + +This test file specifically targets the reported issues: +1. "nodes that have a GUI don't actually have the GUI shown" +2. "nodes that don't have gui's will load with zero height which messes up the pin locations" +3. Issues with text_processing_pipeline.md specifically + +Designed to reproduce and detect the exact bugs mentioned. +""" + +import sys +import os +import unittest +import time + +# Add project root to path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from PySide6.QtWidgets import QApplication, QGraphicsView +from PySide6.QtCore import QTimer, Qt +from PySide6.QtTest import QTest + +from node import Node +from node_graph import NodeGraph +from flow_format import load_flow_file + + +class TestSpecificGUIBugs(unittest.TestCase): + """Test suite for the specific GUI bugs reported.""" + + @classmethod + def setUpClass(cls): + """Set up QApplication for the entire test suite.""" + if QApplication.instance() is None: + cls.app = QApplication(sys.argv) + else: + cls.app = QApplication.instance() + + def setUp(self): + """Set up test fixtures.""" + self.graph = NodeGraph() + self.view = QGraphicsView(self.graph) + self.view.show() + QApplication.processEvents() + + def tearDown(self): + """Clean up after each test.""" + self.view.hide() + self.view.close() + for node in list(self.graph.nodes): + self.graph.remove_node(node) + QApplication.processEvents() + + def wait_for_gui_update(self, timeout_ms=1000): + """Wait for GUI updates to complete.""" + start_time = time.time() * 1000 + while (time.time() * 1000 - start_time) < timeout_ms: + QApplication.processEvents() + QTest.qWait(10) + + def test_text_processing_pipeline_specific_bugs(self): + """Test the specific text_processing_pipeline.md for GUI rendering issues.""" + pipeline_path = os.path.join(os.path.dirname(__file__), 'examples', 'text_processing_pipeline.md') + + if not os.path.exists(pipeline_path): + self.skipTest(f"Test file not found: {pipeline_path}") + + print(f"\\nTesting {pipeline_path}...") + + # Load the file + data = load_flow_file(pipeline_path) + self.graph.deserialize(data) + + self.wait_for_gui_update(2000) + + # Analyze each node individually + nodes_with_gui = [] + nodes_without_gui = [] + + for node in self.graph.nodes: + print(f"\\nAnalyzing node: {node.title}") + print(f" UUID: {getattr(node, 'uuid', 'N/A')}") + print(f" Type: {type(node).__name__}") + print(f" Position: ({node.pos().x():.1f}, {node.pos().y():.1f})") + if hasattr(node, 'width') and hasattr(node, 'height'): + print(f" Size: {node.width}x{node.height}") + elif hasattr(node, 'radius'): + print(f" Size: radius {node.radius}") + + # Check if it's a reroute node + if type(node).__name__ == 'RerouteNode': + print(f" -> Reroute node (expected to be small)") + continue + + # Check for GUI code + has_gui = hasattr(node, 'gui_code') and node.gui_code.strip() + print(f" Has GUI code: {has_gui}") + + if has_gui: + nodes_with_gui.append(node) + print(f" GUI widgets count: {len(getattr(node, 'gui_widgets', {}))}") + + # Check if proxy widget exists and is visible + if hasattr(node, 'proxy_widget') and node.proxy_widget: + print(f" Proxy widget exists: True") + print(f" Proxy widget visible: {node.proxy_widget.isVisible()}") + proxy_size = node.proxy_widget.size() + print(f" Proxy widget size: {proxy_size.width()}x{proxy_size.height()}") + else: + print(f" Proxy widget exists: False") + + # This is the key bug check: GUI nodes should show their GUI + self.assertGreater(len(node.gui_widgets), 0, + f"Node '{node.title}' has GUI code but no widgets were created") + + if hasattr(node, 'proxy_widget') and node.proxy_widget: + self.assertTrue(node.proxy_widget.isVisible(), + f"Node '{node.title}' proxy widget should be visible") + + else: + nodes_without_gui.append(node) + + # Check for zero height bug in non-GUI nodes + print(f" Height check: {node.height} (should be > 0)") + self.assertGreater(node.height, 0, + f"Node '{node.title}' without GUI has zero height") + + # Check pin positions for non-GUI nodes + input_pins = [p for p in getattr(node, 'pins', []) if hasattr(p, 'pin_direction') and p.pin_direction == 'input'] + output_pins = [p for p in getattr(node, 'pins', []) if hasattr(p, 'pin_direction') and p.pin_direction == 'output'] + + print(f" Input pins: {len(input_pins)}, Output pins: {len(output_pins)}") + + # Check that pins are not stuck at (0,0) or top-left corner + for pin in input_pins + output_pins: + pin_pos = pin.pos() + print(f" Pin '{pin.name}' at ({pin_pos.x():.1f}, {pin_pos.y():.1f})") + + # Pins should not all be at the same position (stuck) + self.assertNotEqual((pin_pos.x(), pin_pos.y()), (0, 0), + f"Pin '{pin.name}' in node '{node.title}' is stuck at origin") + + print(f"\\nSummary:") + print(f" Nodes with GUI: {len(nodes_with_gui)}") + print(f" Nodes without GUI: {len(nodes_without_gui)}") + + # Verify we found the expected nodes + self.assertGreater(len(nodes_with_gui), 0, "Should have found nodes with GUI") + + # All GUI nodes should have visible widgets + for node in nodes_with_gui: + with self.subTest(node=node.title): + self.verify_gui_node_rendering(node) + + def verify_gui_node_rendering(self, node): + """Detailed verification of a GUI node's rendering.""" + print(f"\\nDetailed GUI check for: {node.title}") + + # 1. Widgets should exist + widgets = getattr(node, 'gui_widgets', {}) + print(f" GUI widgets: {list(widgets.keys())}") + self.assertGreater(len(widgets), 0, f"Node should have GUI widgets") + + # 2. Proxy widget should exist and be configured + if hasattr(node, 'proxy_widget'): + proxy = node.proxy_widget + if proxy: + print(f" Proxy widget size: {proxy.size().width()}x{proxy.size().height()}") + print(f" Proxy widget visible: {proxy.isVisible()}") + print(f" Proxy widget pos: ({proxy.pos().x():.1f}, {proxy.pos().y():.1f})") + + self.assertTrue(proxy.isVisible(), "Proxy widget should be visible") + self.assertGreater(proxy.size().width(), 0, "Proxy widget should have width") + self.assertGreater(proxy.size().height(), 0, "Proxy widget should have height") + else: + self.fail("Node has gui_code but no proxy widget") + + # 3. Node should have reasonable dimensions + print(f" Node dimensions: {node.width}x{node.height}") + self.assertGreater(node.height, 50, f"GUI node should have reasonable height") + self.assertGreater(node.width, 100, f"GUI node should have reasonable width") + + # 4. Content container should exist and be sized + if hasattr(node, 'content_container'): + container = node.content_container + if container: + container_size = container.size() + print(f" Content container: {container_size.width()}x{container_size.height()}") + self.assertGreater(container_size.height(), 0, "Content container should have height") + + def test_gui_vs_non_gui_node_comparison(self): + """Compare GUI and non-GUI nodes to verify different behaviors.""" + + # Create a non-GUI node + non_gui_node = self.graph.create_node("Non-GUI Node", pos=(100, 100)) + non_gui_node.set_code(''' +@node_entry +def simple_function(input_text: str) -> str: + return input_text.upper() +''') + non_gui_node.update_pins_from_code() + + # Create a GUI node + gui_node = self.graph.create_node("GUI Node", pos=(300, 100)) + gui_node.set_code(''' +@node_entry +def gui_function(input_text: str, flag: bool) -> str: + return input_text if flag else "" +''') + gui_node.set_gui_code(''' +from PySide6.QtWidgets import QLabel, QLineEdit, QCheckBox + +widgets['label'] = QLabel('Enter text:', parent) +layout.addWidget(widgets['label']) + +widgets['input'] = QLineEdit(parent) +layout.addWidget(widgets['input']) + +widgets['flag'] = QCheckBox('Enable processing', parent) +layout.addWidget(widgets['flag']) +''') + gui_node.update_pins_from_code() + + self.wait_for_gui_update(500) + + print(f"\\nNode comparison:") + print(f" Non-GUI node: {non_gui_node.width}x{non_gui_node.height}") + print(f" GUI node: {gui_node.width}x{gui_node.height}") + + # Non-GUI node checks + self.assertGreater(non_gui_node.height, 0, "Non-GUI node should not have zero height") + self.assertEqual(len(getattr(non_gui_node, 'gui_widgets', {})), 0, + "Non-GUI node should not have GUI widgets") + + # GUI node checks + self.assertGreater(len(gui_node.gui_widgets), 0, "GUI node should have widgets") + self.assertGreater(gui_node.height, non_gui_node.height, + "GUI node should be taller than non-GUI node") + + # Pin position checks for both + for node, name in [(non_gui_node, "Non-GUI"), (gui_node, "GUI")]: + pins = getattr(node, 'pins', []) + print(f" {name} node pins:") + for pin in pins: + pos = pin.pos() + print(f" {pin.name}: ({pos.x():.1f}, {pos.y():.1f})") + self.assertNotEqual((pos.x(), pos.y()), (0, 0), + f"{name} node pin should not be at origin") + + def test_markdown_vs_json_gui_rendering(self): + """Test if there's a difference between loading from markdown vs JSON.""" + + # Create test data that simulates what would be in a markdown file + test_data = { + "nodes": [{ + "uuid": "test-gui-node", + "title": "Test GUI Node", + "pos": [200, 200], + "size": [250, 180], + "code": ''' +@node_entry +def test_function(text: str) -> str: + return text.upper() +''', + "gui_code": ''' +from PySide6.QtWidgets import QLabel, QLineEdit, QPushButton + +widgets['label'] = QLabel('Input:', parent) +layout.addWidget(widgets['label']) + +widgets['input'] = QLineEdit(parent) +widgets['input'].setPlaceholderText('Enter text...') +layout.addWidget(widgets['input']) + +widgets['button'] = QPushButton('Convert', parent) +layout.addWidget(widgets['button']) +''', + "gui_get_values_code": ''' +def get_values(widgets): + return {'text': widgets['input'].text()} + +def set_initial_state(widgets, state): + widgets['input'].setText(state.get('text', '')) +''', + "gui_state": {"text": "test input"}, + "colors": {"title": "#007bff", "body": "#0056b3"} + }], + "connections": [] + } + + # Load using the same method as markdown loading + self.graph.deserialize(test_data) + self.wait_for_gui_update(1000) + + # Verify the node loaded correctly + self.assertEqual(len(self.graph.nodes), 1) + node = self.graph.nodes[0] + + print(f"\\nMarkdown-style loading test:") + print(f" Node: {node.title}") + print(f" Size: {node.width}x{node.height}") + print(f" GUI widgets: {len(node.gui_widgets)}") + + # Verify GUI is working + self.verify_gui_node_rendering(node) + + # Check that GUI state was applied + if 'input' in node.gui_widgets: + current_text = node.gui_widgets['input'].text() + print(f" Input text: '{current_text}'") + # Note: GUI state might not be applied automatically in this test + + +def run_specific_bug_tests(): + """Run the specific GUI bug tests.""" + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(TestSpecificGUIBugs) + + runner = unittest.TextTestRunner(verbosity=2, buffer=False) + result = runner.run(suite) + + return result.wasSuccessful() + + +if __name__ == "__main__": + print("=== Specific GUI Bug Detection Tests ===") + print("Testing for the reported issues:") + print("1. Nodes with GUI don't show their GUI components") + print("2. Nodes without GUI have zero height and broken pin positions") + print("3. Issues with text_processing_pipeline.md specifically") + print() + + success = run_specific_bug_tests() + + if success: + print("\\n=== All Specific Bug Tests Passed ===") + print("Could not reproduce the reported GUI bugs.") + sys.exit(0) + else: + print("\\n=== GUI Bugs Detected ===") + print("Found issues matching the reported problems!") + sys.exit(1) \ No newline at end of file diff --git a/ui_utils.py b/ui_utils.py new file mode 100644 index 0000000..f0df412 --- /dev/null +++ b/ui_utils.py @@ -0,0 +1,174 @@ +# ui_utils.py +# UI utilities for PyFlowGraph including icon creation and styling + +from PySide6.QtWidgets import QWidget, QHBoxLayout, QRadioButton, QPushButton, QButtonGroup, QLabel +from PySide6.QtGui import QAction, QFont, QIcon, QPainter, QColor, QPixmap +from PySide6.QtCore import Qt + + +def create_fa_icon(char_code, color="white", font_style="regular"): + """Creates a QIcon from a Font Awesome character code.""" + pixmap = QPixmap(32, 32) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + + if font_style == "solid": + font = QFont("Font Awesome 6 Free Solid") + else: + font = QFont("Font Awesome 7 Free Regular") + + font.setPixelSize(24) + painter.setFont(font) + painter.setPen(QColor(color)) + painter.drawText(pixmap.rect(), Qt.AlignCenter, char_code) + painter.end() + return QIcon(pixmap) + + +class ButtonStyleManager: + """Manages button styles for different execution modes and states.""" + + @staticmethod + def get_button_style(mode, state="ready"): + """Get stylesheet for the main button based on mode and state.""" + if mode == "batch": + if state == "ready": + return """ + QPushButton { + background-color: #4CAF50; + color: white; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background-color: #45a049; + } + QPushButton:pressed { + background-color: #3d8b40; + } + """ + else: # executing + return """ + QPushButton { + background-color: #607D8B; + color: white; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + """ + else: # live mode + if state == "ready": + return """ + QPushButton { + background-color: #FF9800; + color: white; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background-color: #F57C00; + } + QPushButton:pressed { + background-color: #E65100; + } + """ + elif state == "active": + return """ + QPushButton { + background-color: #4CAF50; + color: white; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background-color: #45a049; + } + """ + else: # paused + return """ + QPushButton { + background-color: #F44336; + color: white; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 12px; + } + QPushButton:hover { + background-color: #da190b; + } + """ + + +def create_execution_control_widget(mode_changed_callback, button_clicked_callback): + """Create the execution mode and control widget.""" + # Container widget + exec_widget = QWidget() + layout = QHBoxLayout(exec_widget) + layout.setContentsMargins(10, 5, 10, 5) + layout.setSpacing(15) + + # Mode selection label + mode_label = QLabel("Execution Mode:") + mode_label.setStyleSheet("font-weight: bold; color: #E0E0E0;") + layout.addWidget(mode_label) + + # Radio buttons for mode selection + mode_button_group = QButtonGroup() + + batch_radio = QRadioButton("Batch") + batch_radio.setToolTip("Traditional one-shot execution of entire graph") + batch_radio.setChecked(True) # Default mode + batch_radio.setStyleSheet(""" + QRadioButton { color: #E0E0E0; font-weight: bold; } + QRadioButton::indicator::checked { background-color: #4CAF50; } + """) + + live_radio = QRadioButton("Live") + live_radio.setToolTip("Interactive mode with event-driven execution") + live_radio.setStyleSheet(""" + QRadioButton { color: #E0E0E0; font-weight: bold; } + QRadioButton::indicator::checked { background-color: #FF9800; } + """) + + mode_button_group.addButton(batch_radio, 0) + mode_button_group.addButton(live_radio, 1) + mode_button_group.idClicked.connect(mode_changed_callback) + + layout.addWidget(batch_radio) + layout.addWidget(live_radio) + + # Separator + separator = QLabel("|") + separator.setStyleSheet("color: #666; font-size: 16px;") + layout.addWidget(separator) + + # Main execution button - changes based on mode + main_exec_button = QPushButton("▶️ Execute Graph") + main_exec_button.setMinimumSize(140, 35) + main_exec_button.setStyleSheet(ButtonStyleManager.get_button_style("batch")) + main_exec_button.clicked.connect(button_clicked_callback) + main_exec_button.setShortcut("F5") + layout.addWidget(main_exec_button) + + # Status indicator + status_label = QLabel("Ready") + status_label.setStyleSheet("color: #4CAF50; font-weight: bold; font-size: 12px;") + layout.addWidget(status_label) + + # Store references for external access + exec_widget.mode_button_group = mode_button_group + exec_widget.batch_radio = batch_radio + exec_widget.live_radio = live_radio + exec_widget.main_exec_button = main_exec_button + exec_widget.status_label = status_label + + return exec_widget \ No newline at end of file diff --git a/view_state_manager.py b/view_state_manager.py new file mode 100644 index 0000000..05ff4d0 --- /dev/null +++ b/view_state_manager.py @@ -0,0 +1,39 @@ +# view_state_manager.py +# View state management for saving and restoring view transforms and positions + +from PySide6.QtCore import QSettings, QPointF +from PySide6.QtGui import QTransform + + +class ViewStateManager: + """Manages saving and loading of view state (zoom, pan) for different files.""" + + def __init__(self, view, file_operations_manager): + self.view = view + self.file_ops = file_operations_manager + self.settings = QSettings("PyFlowGraph", "NodeEditor") + + def save_view_state(self): + """Saves the current view's transform (zoom) and center point (pan) to QSettings.""" + if self.file_ops.current_file_path: + self.settings.beginGroup(f"view_state/{self.file_ops.current_file_path}") + self.settings.setValue("transform", self.view.transform()) + # Save the scene coordinates of the center of the view + center_point = self.view.mapToScene(self.view.viewport().rect().center()) + self.settings.setValue("center_point", center_point) + self.settings.endGroup() + + def load_view_state(self): + """Loads and applies the view's transform and center point from QSettings.""" + if self.file_ops.current_file_path: + self.settings.beginGroup(f"view_state/{self.file_ops.current_file_path}") + transform_data = self.settings.value("transform") + center_point = self.settings.value("center_point") + self.settings.endGroup() + + if isinstance(transform_data, QTransform): + self.view.setTransform(transform_data) + + if isinstance(center_point, QPointF): + # Use centerOn to robustly set the pan position + self.view.centerOn(center_point) \ No newline at end of file