diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d155bc94..12c831f4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,4 +1,4 @@ -name: Cargo Build & Test +name: Build & Test on: pull_request: @@ -7,8 +7,8 @@ env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: Rust project - latest + rust_tests: + name: Rust Tests runs-on: ubuntu-latest strategy: matrix: @@ -37,3 +37,76 @@ jobs: - name: Run all tests in workspace run: cargo test --workspace --verbose + + cli_tests: + name: CLI Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + exclude: + # Reduce matrix size by testing fewer Python versions on non-Linux + - os: windows-latest + python-version: '3.8' + - os: windows-latest + python-version: '3.9' + - os: macos-latest + python-version: '3.8' + - os: macos-latest + python-version: '3.9' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + ./target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache Python dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }} + + - name: Install system dependencies (Ubuntu) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Install maturin + run: pip install maturin + + - name: Build and install Python package + working-directory: ./crates/python_api + run: | + maturin develop --release + + - name: Install test dependencies + working-directory: ./crates/python_api + run: pip install -e ".[test]" + + - name: Run CLI tests + working-directory: ./crates/python_api + run: pytest tests/ -v --tb=short + + - name: Run CLI tests in parallel (Linux only) + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + working-directory: ./crates/python_api + run: pytest tests/ -n auto -v diff --git a/crates/python_api/README_TESTING.md b/crates/python_api/README_TESTING.md new file mode 100644 index 00000000..520ed55c --- /dev/null +++ b/crates/python_api/README_TESTING.md @@ -0,0 +1,172 @@ +# CLI Testing Guide + +This document explains how to run and develop tests for the OpenFire CLI tool. + +## Test Structure + +``` +crates/python_api/ +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Test fixtures and configuration +│ └── test_cli.py # CLI test cases +└── pyproject.toml # Test dependencies and pytest config +``` + +## Running Tests Locally + +### Prerequisites + +1. **Install the package in development mode:** + ```bash + cd crates/python_api + maturin develop --release + ``` + +2. **Install test dependencies:** + ```bash + pip install -e ".[test]" + ``` + +### Running Tests + +**Run all tests:** +```bash +cd crates/python_api +pytest tests/ -v +``` + +**Run specific test classes:** +```bash +pytest tests/test_cli.py::TestVersionCommand -v +pytest tests/test_cli.py::TestNewCommand -v +``` + +**Run tests in parallel:** +```bash +pytest tests/ -n auto -v +``` + +**Run tests with coverage:** +```bash +pytest tests/ --cov=ofire --cov-report=html +``` + +## Test Categories + +### 1. CLI Availability Tests +- Verify `ofire` command is accessible +- Test module importability +- Basic command execution + +### 2. Command-Specific Tests +- **Version Command:** Output format and content +- **Help Command:** Subcommand listing and help text +- **New Command:** Project creation, file structure, various names +- **Run Command:** Basic functionality and error handling +- **Docs Command:** Execution without errors + +### 3. Cross-Platform Tests +- Path handling (Windows vs Unix) +- File permissions (Unix-specific) +- Platform-specific behaviors + +### 4. Error Handling Tests +- Invalid commands +- Missing arguments +- Graceful failure scenarios + +## CI/CD Testing + +The GitHub Actions workflow tests across: +- **Operating Systems:** Ubuntu, Windows, macOS +- **Python Versions:** 3.8, 3.9, 3.10, 3.11, 3.12 +- **Test Modes:** Sequential and parallel execution + +### Matrix Strategy +- Full Python version matrix on Ubuntu +- Reduced matrix on Windows/macOS for efficiency +- Separate Rust and Python test jobs + +## Writing New Tests + +### Test Fixtures Available + +**`cli_runner`** - Execute CLI commands: +```python +def test_my_command(cli_runner): + result = cli_runner("new", "my_project", "-d", "/tmp") + assert result.returncode == 0 +``` + +**`temp_dir`** - Temporary directory: +```python +def test_file_creation(temp_dir): + test_file = temp_dir / "test.txt" + test_file.write_text("content") + assert test_file.exists() +``` + +**`ofire_available`** - Skip if CLI not installed: +```python +def test_cli_function(ofire_available, cli_runner): + # Test only runs if ofire is properly installed + result = cli_runner("version") + assert result.returncode == 0 +``` + +### Test Naming Conventions +- Test files: `test_*.py` +- Test classes: `Test*` +- Test functions: `test_*` + +### Platform-Specific Tests +```python +@pytest.mark.skipif(os.name == 'nt', reason="Unix-specific test") +def test_unix_feature(): + # Unix-only test code + +@pytest.mark.skipif(os.name != 'nt', reason="Windows-specific test") +def test_windows_feature(): + # Windows-only test code +``` + +## Test Configuration + +The `pyproject.toml` includes pytest configuration: +- Test discovery patterns +- Verbose output by default +- Custom markers for slow/integration tests +- Short traceback format + +## Debugging Tests + +**Run with detailed output:** +```bash +pytest tests/ -v --tb=long +``` + +**Run specific test with debugging:** +```bash +pytest tests/test_cli.py::test_version_command -v -s +``` + +**Stop on first failure:** +```bash +pytest tests/ -x +``` + +## Common Issues + +1. **"ofire CLI not available"** - Run `maturin develop` first +2. **Import errors** - Ensure you're in the `python_api` directory +3. **Permission errors** - Check temp directory permissions +4. **Path issues** - Use `pathlib.Path` for cross-platform compatibility + +## Contributing + +When adding new CLI functionality: +1. Add corresponding tests in `test_cli.py` +2. Update this README if new test patterns are introduced +3. Ensure tests pass on all platforms via CI/CD +4. Consider edge cases and error conditions \ No newline at end of file diff --git a/crates/python_api/docs/cli.rst b/crates/python_api/docs/cli.rst new file mode 100644 index 00000000..5b39160c --- /dev/null +++ b/crates/python_api/docs/cli.rst @@ -0,0 +1,291 @@ +Command Line Interface +====================== + +The OpenFire CLI provides a convenient command-line interface for creating and managing fire engineering projects. It allows you to scaffold new projects, run applications, and access documentation directly from your terminal. + +Installation +------------ + +The CLI is installed automatically when you install the OpenFire package: + +.. code-block:: bash + + pip install ofire + +After installation, the ``ofire`` command will be available in your terminal. + +Basic Usage +----------- + +The general syntax for the OpenFire CLI is: + +.. code-block:: bash + + ofire [options] + +To see all available commands and options: + +.. code-block:: bash + + ofire --help + +Commands +-------- + +new +~~~ + +Create a new OpenFire fire engineering project with all necessary files and setup. + +**Syntax:** + +.. code-block:: bash + + ofire new [options] + +**Arguments:** + +* ``project_name`` - Name of the new project (required) + +**Options:** + +* ``-d, --directory `` - Directory to create the project in (default: current directory) + +**Example:** + +.. code-block:: bash + + # Create a new project in the current directory + ofire new my_fire_project + +**What gets created:** + +When you run ``ofire new``, the following structure is created: + +.. code-block:: text + + project_name/ + ├── main.py # Main Streamlit application + ├── requirements.txt # Python dependencies + ├── README.md # Project documentation + ├── AGENTS.md # AI agent guidance + ├── CLAUDE.md # Claude AI instructions + ├── .venv/ # Virtual environment (auto-created) + ├── activate.sh # Virtual environment activation (Unix) + └── activate.bat # Virtual environment activation (Windows) + +The project includes: + +* A complete virtual environment with all dependencies installed +* A web-based fire engineering application using Streamlit +* Pre-configured settings for AI coding assistants +* Example calculations and documentation + +run +~~~ + +Run a fire engineering application using Streamlit. + +**Syntax:** + +.. code-block:: bash + + ofire run [target] + +**Arguments:** + +* ``target`` - File path or URL to run (optional, default: main.py) + +**Examples:** + +.. code-block:: bash + + # Run the default main.py application + ofire run + + # Run a specific Python file + ofire run my_calculations.py + + # Run an application from a URL + ofire run https://example.com/fire_app.py + +**Note:** This command starts a Streamlit web server. The application will be accessible in your browser, typically at ``http://localhost:8501``. Press ``Ctrl+C`` to stop the server. + +docs +~~~~ + +Open the OpenFire documentation in your default web browser. + +**Syntax:** + +.. code-block:: bash + + ofire docs + +**Example:** + +.. code-block:: bash + + ofire docs + +This opens the comprehensive OpenFire documentation at `https://emberon-tech.github.io/openfire/ `_ in your default browser. + +version +~~~~~~~ + +Display the current version of the OpenFire CLI. + +**Syntax:** + +.. code-block:: bash + + ofire version + +**Example:** + +.. code-block:: bash + + ofire version + +Workflows +--------- + +Creating and Running a New Project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Here's a typical workflow for creating and running a new fire engineering project: + +1. **Create the project:** + + .. code-block:: bash + + ofire new building_analysis + +2. **Navigate to the project:** + + .. code-block:: bash + + cd building_analysis + +3. **Activate the virtual environment:** + + .. code-block:: bash + + # On Unix/Linux/macOS + source activate.sh + + # On Windows + activate.bat + +4. **Run the application:** + + .. code-block:: bash + + ofire run + +5. **Open your browser** to the URL shown in the terminal (usually http://localhost:8501) + +Development Workflow +~~~~~~~~~~~~~~~~~~~~ + +For ongoing development work: + +1. **Activate the virtual environment** (if not already active): + + .. code-block:: bash + + source .venv/bin/activate # Unix/Linux/macOS + # or + .venv\Scripts\activate.bat # Windows + +2. **Edit your calculations** in ``main.py`` or create new files + +3. **Run your application** to test changes: + + .. code-block:: bash + + ofire run + +Virtual Environment Management +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The OpenFire CLI automatically creates and manages virtual environments for new projects: + +* **Automatic creation**: Virtual environment is created during ``ofire new`` +* **Dependency installation**: All required packages are installed automatically +* **Activation scripts**: Platform-specific activation scripts are generated +* **Isolation**: Each project has its own isolated environment + +Integration with AI Assistants +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Projects created with ``ofire new`` include configuration files for AI coding assistants: + +* **CLAUDE.md**: Instructions for Claude AI to always read the agents guide +* **AGENTS.md**: Comprehensive guidance for AI assistants working on fire engineering projects + +These files help AI assistants understand the project context and provide more accurate assistance with fire engineering calculations. + +Troubleshooting +--------------- + +Common Issues +~~~~~~~~~~~~~ + +**Command not found: ofire** + +* Ensure OpenFire is installed: ``pip install ofire`` +* Check that your Python scripts directory is in your PATH +* Try reinstalling: ``pip uninstall ofire && pip install ofire`` + +**Virtual environment issues** + +* Ensure Python venv module is available: ``python -m venv --help`` +* On some systems, you may need ``python3`` instead of ``python`` +* Check permissions in the target directory + +**Streamlit not starting** + +* Ensure all dependencies are installed: ``pip install -r requirements.txt`` +* Check if port 8501 is already in use +* Try specifying a different port: ``streamlit run main.py --server.port 8502`` + +**Permission errors on Unix systems** + +* The activation script should be executable: ``chmod +x activate.sh`` +* Check directory permissions for the target location + +Getting Help +~~~~~~~~~~~~ + +* Use ``ofire --help`` for general help +* Use ``ofire --help`` for command-specific help +* Visit the documentation: ``ofire docs`` +* Check the project README.md for project-specific information + +Examples +-------- + +Quick Calculation Script +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Create a simple project for quick calculations + ofire new quick_calc + + # cd into the project directory, activate the virtual environment, and run the app + cd quick_calc + source activate.sh + ofire run + +Running External Applications +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Run an app from GitHub + ofire run https://raw.githubusercontent.com/example/fire-calc/main/app.py + + # Run a local file from anywhere on your system + ofire run /path/to/my/fire_analysis.py \ No newline at end of file diff --git a/crates/python_api/docs/index.rst b/crates/python_api/docs/index.rst index 9f320cc2..5c9a1049 100644 --- a/crates/python_api/docs/index.rst +++ b/crates/python_api/docs/index.rst @@ -42,6 +42,7 @@ Project Structure :caption: Contents: installation + cli guide/index api/index diff --git a/crates/python_api/ofire/__init__.py b/crates/python_api/ofire/__init__.py new file mode 100644 index 00000000..0363f4e9 --- /dev/null +++ b/crates/python_api/ofire/__init__.py @@ -0,0 +1,4 @@ +"""OpenFire CLI and Python API package.""" + +# Import the compiled Rust modules +from .ofire import * \ No newline at end of file diff --git a/crates/python_api/ofire/__main__.py b/crates/python_api/ofire/__main__.py new file mode 100644 index 00000000..7c954ac1 --- /dev/null +++ b/crates/python_api/ofire/__main__.py @@ -0,0 +1,8 @@ +""" +Entry point for running ofire as a module with python -m ofire +""" + +from .cli import main + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/crates/python_api/ofire/cli.py b/crates/python_api/ofire/cli.py new file mode 100644 index 00000000..b6123553 --- /dev/null +++ b/crates/python_api/ofire/cli.py @@ -0,0 +1,87 @@ +import argparse +from importlib.metadata import version, PackageNotFoundError +from .project import scaffold_new_project, open_documentation, run_fire_app + + +def open_docs(args): + open_documentation() + + +def scaffold_project(args): + scaffold_new_project( + project_name=args.name, + target_dir=args.directory + ) + + +def run_app(args): + run_fire_app(target=args.target) + + +def show_version(args): + """Show the OpenFire CLI version.""" + try: + pkg_version = version('ofire') + print(f"OpenFire CLI v{pkg_version}") + except PackageNotFoundError: + print("OpenFire CLI version unknown (package not found)") + + +def main(): + parser = argparse.ArgumentParser( + description="OpenFire CLI - Tools for fire engineering projects", + prog="ofire" + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + new_parser = subparsers.add_parser( + 'new', + help='Create a new OpenFire project' + ) + new_parser.add_argument( + 'name', + help='Name of the new project' + ) + new_parser.add_argument( + '-d', '--directory', + default='.', + help='Directory to create the project in (default: current directory)' + ) + new_parser.set_defaults(func=scaffold_project) + + run_parser = subparsers.add_parser( + 'run', + help='Run a fire engineering application' + ) + run_parser.add_argument( + 'target', + nargs='?', + default=None, + help='File path or URL to run (default: main.py)' + ) + run_parser.set_defaults(func=run_app) + + docs_parser = subparsers.add_parser( + 'docs', + help='Open OpenFire documentation in browser' + ) + docs_parser.set_defaults(func=open_docs) + + version_parser = subparsers.add_parser( + 'version', + help='Show OpenFire version' + ) + version_parser.set_defaults(func=show_version) + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return + + args.func(args) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/crates/python_api/ofire/ofire.abi3.so b/crates/python_api/ofire/ofire.abi3.so new file mode 100755 index 00000000..ce1f8c2c Binary files /dev/null and b/crates/python_api/ofire/ofire.abi3.so differ diff --git a/crates/python_api/ofire/project.py b/crates/python_api/ofire/project.py new file mode 100644 index 00000000..8be09918 --- /dev/null +++ b/crates/python_api/ofire/project.py @@ -0,0 +1,439 @@ +"""OpenFire project creation and management logic.""" + +import json +import os +import re +import subprocess +import sys +import webbrowser +from pathlib import Path +from textwrap import dedent + + +def validate_project_name(project_name: str) -> str: + """Validate and sanitize the project name to prevent path traversal attacks. + + Args: + project_name: The project name to validate + + Returns: + str: The validated project name + + Raises: + ValueError: If the project name is invalid + """ + if not project_name or not project_name.strip(): + raise ValueError("Project name cannot be empty") + + project_name = project_name.strip() + + # Check for path traversal attempts + if '..' in project_name or '/' in project_name or '\\' in project_name: + raise ValueError("Project name cannot contain path separators or parent directory references") + + # Check for reserved names on Windows + reserved_names = { + 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', + 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', + 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + } + if project_name.upper() in reserved_names: + raise ValueError(f"Project name '{project_name}' is reserved on Windows systems") + + # Validate characters: allow alphanumeric, hyphens, underscores, and spaces + if not re.match(r'^[a-zA-Z0-9_\-\s]+$', project_name): + raise ValueError("Project name can only contain letters, numbers, hyphens, underscores, and spaces") + + # Check length + if len(project_name) > 100: + raise ValueError("Project name is too long (maximum 100 characters)") + + return project_name + + +def create_project_structure(project_name: str, target_dir: str) -> None: + """Create the basic project structure.""" + project_path = Path(target_dir).resolve() / project_name + + # Create main project directory + project_path.mkdir(parents=True, exist_ok=True) + + print(f"Created project structure in: {project_path}") + + +def create_main_script(project_name: str, target_dir: str) -> None: + """Create the main fire engineering application script.""" + project_path = Path(target_dir).resolve() / project_name + main_script = project_path / "main.py" + + # Read template file + template_path = Path(__file__).parent / "templates" / "main.py" + try: + with open(template_path, 'r', encoding='utf-8') as f: + template_content = f.read() + except FileNotFoundError: + # Fallback content if template file is missing + template_content = '"""Template file not found"""' + print(f"Warning: Template file not found at {template_path}, using fallback content") + + # Format template with project name + content = template_content.format(project_name=project_name) + + with open(main_script, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"Created fire engineering application: {main_script}") + + +def get_latest_ofire_version() -> str: + """Get the latest version of ofire package.""" + try: + result = subprocess.run( + [sys.executable, '-m', 'pip', 'index', 'versions', 'ofire'], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0 and 'Available versions:' in result.stdout: + # Parse the latest version from pip index output + lines = result.stdout.split('\n') + for line in lines: + if 'Available versions:' in line: + versions = line.split('Available versions:')[1].strip() + if versions: + latest = versions.split(',')[0].strip() + return latest + except (subprocess.TimeoutExpired, subprocess.SubprocessError): + pass + + return "0.1.0" + + +def create_requirements_file(project_name: str, target_dir: str) -> None: + """Create a requirements.txt file.""" + project_path = Path(target_dir).resolve() / project_name + requirements_file = project_path / "requirements.txt" + + ofire_version = get_latest_ofire_version() + + content = dedent(f''' + # Fire engineering calculations + ofire>={ofire_version} + streamlit>=1.28.0 + + # Data analysis and visualization (uncomment if needed) + # numpy>=1.20.0 + # pandas>=1.3.0 + # matplotlib>=3.4.0 + # seaborn>=0.11.0 + ''').strip() + + with open(requirements_file, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"Created requirements file: {requirements_file}") + + +def create_claude_guide(project_name: str, target_dir: str) -> None: + """Create a CLAUDE.md file that instructs Claude to always read AGENTS.md.""" + project_path = Path(target_dir).resolve() / project_name + claude_file = project_path / "CLAUDE.md" + + content = "Always read @AGENTS.md" + + with open(claude_file, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"Created Claude guide: {claude_file}") + + +def create_agents_guide(project_name: str, target_dir: str) -> None: + """Create an AGENTS.md file with guidance for AI coding agents.""" + project_path = Path(target_dir).resolve() / project_name + agents_file = project_path / "AGENTS.md" + + # Read the template file + template_path = Path(__file__).parent / "templates" / "agents_template.md" + try: + with open(template_path, 'r', encoding='utf-8') as f: + content = f.read() + except FileNotFoundError: + # Fallback content if template file is missing + content = "# AI Agent Guide\n\nAlways use ofire library for fire engineering calculations." + print(f"Warning: Template file not found at {template_path}, using fallback content") + + with open(agents_file, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"Created AI agent guide: {agents_file}") + + +def create_readme(project_name: str, target_dir: str) -> None: + """Create a README.md file.""" + project_path = Path(target_dir).resolve() / project_name + readme_file = project_path / "README.md" + + content = dedent(f''' + # {project_name} + + A web-based fire engineering application using the OpenFire library. + + ## Setup + + 1. Install Python dependencies: + ```bash + pip install -r requirements.txt + ``` + + 2. Run the fire engineering application: + ```bash + ofire run + ``` + + 3. Open your browser to the URL shown in the terminal (usually `http://localhost:8501`) + + ## Project Structure + + - `main.py`: Main fire engineering application with calculation tools + - `requirements.txt`: Python package dependencies + + ## Features + + This fire engineering tool includes: + + - **Interactive Web Interface**: User-friendly calculation interface + - **Heat Release Rate Calculator**: CIBSE Guide E calculations + - **Smoke Filling Analysis**: Room smoke filling estimations + - **Extensible Framework**: Easy to add new calculations + + ## Available OpenFire Modules + + This project uses the OpenFire library which provides implementations for: + + - **BR 187**: External fire spread calculations + - **BS 9999**: Fire safety calculations + - **CIBSE Guide E**: Fire safety engineering calculations + - **Fire Dynamics Tools**: General fire dynamics calculations + - **PD 7974**: Fire safety engineering calculations + - **SFPE Handbook**: Fire protection engineering calculations + - **TR 17**: Fire calculations + - **Introduction to Fire Dynamics**: Basic fire dynamics + + ## Adding New Calculations + + To add a new calculation page: + + 1. Create a new function in `main.py` following the pattern of existing pages + 2. Add the page to the sidebar navigation selectbox + 3. Implement your calculations using OpenFire library functions + 4. Use the web interface components for inputs and results display + + Example: + ```python + def your_calculation_page(): + st.header("Your Calculation") + # Add input widgets and calculation logic here + ``` + + ## Documentation + + For detailed documentation and examples, visit: + [OpenFire Documentation](https://emberon-tech.github.io/openfire/) + + ## Running Specific Files + + To run a specific file or URL: + ```bash + ofire run your_app.py + ofire run https://example.com/fire_app.py + ``` + ''').strip() + + with open(readme_file, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"Created README: {readme_file}") + + +def create_virtual_environment(project_name: str, target_dir: str) -> Path: + """Create a virtual environment for the project.""" + project_path = Path(target_dir).resolve() / project_name + venv_path = project_path / ".venv" + + print("Creating virtual environment...") + + try: + subprocess.run( + [sys.executable, '-m', 'venv', str(venv_path)], + check=True, + capture_output=True, + text=True + ) + print(f"Virtual environment created: {venv_path}") + return venv_path + except subprocess.CalledProcessError as e: + print(f"Error creating virtual environment: {e}") + print(f"stderr: {e.stderr}") + raise + + +def install_requirements(project_name: str, target_dir: str, venv_path: Path) -> None: + """Install requirements in the virtual environment.""" + project_path = Path(target_dir).resolve() / project_name + requirements_file = project_path / "requirements.txt" + + # Determine the pip executable path in the virtual environment + if sys.platform == "win32": + pip_exe = venv_path / "Scripts" / "pip.exe" + else: + pip_exe = venv_path / "bin" / "pip" + + print("Installing requirements...") + + try: + subprocess.run( + [str(pip_exe), 'install', '-r', str(requirements_file)], + check=True, + capture_output=True, + text=True, + cwd=str(project_path) + ) + print("Requirements installed successfully!") + except subprocess.CalledProcessError as e: + print(f"Error installing requirements: {e}") + print(f"stderr: {e.stderr}") + raise + + +def create_activation_script(project_name: str, target_dir: str, venv_path: Path) -> None: + """Create platform-specific activation scripts.""" + project_path = Path(target_dir).resolve() / project_name + + if sys.platform == "win32": + # Windows batch script + activate_script = project_path / "activate.bat" + content = f"@echo off\ncall \"{venv_path}\\Scripts\\activate.bat\"\necho Virtual environment activated!\n" + with open(activate_script, 'w', encoding='utf-8') as f: + f.write(content) + print(f"Created activation script: {activate_script}") + else: + # Unix shell script + activate_script = project_path / "activate.sh" + content = f"#!/bin/bash\nsource \"{venv_path}/bin/activate\"\necho \"Virtual environment activated!\"\n" + with open(activate_script, 'w', encoding='utf-8') as f: + f.write(content) + # Make executable + activate_script.chmod(0o755) + print(f"Created activation script: {activate_script}") + + + +def open_documentation() -> None: + """Open the OpenFire documentation in the default browser.""" + docs_url = "https://emberon-tech.github.io/openfire/" + + print(f"Opening documentation: {docs_url}") + + try: + webbrowser.open(docs_url) + print("Documentation opened in your default browser!") + except Exception as e: + print(f"Error opening browser: {e}") + print(f"You can manually visit: {docs_url}") + + +def run_fire_app(target: str = None) -> None: + """Run a fire engineering application.""" + if target is None: + # Default to main.py + target = "main.py" + print("Running fire engineering app (main.py)") + elif target.startswith(('http://', 'https://')): + # URL provided + print(f"Running fire engineering app from URL: {target}") + elif os.path.exists(target): + # File path provided + print(f"Running fire engineering app: {target}") + else: + # Assume it's a file that might not exist yet + print(f"Running fire engineering app: {target}") + + try: + # Build the command to run the web app + cmd = [sys.executable, '-m', 'streamlit', 'run', target] + + print("Starting fire engineering web application...") + print("Press Ctrl+C to stop the application") + + # Run the app - this will block until the user stops it + subprocess.run(cmd, check=True) + + except subprocess.CalledProcessError as e: + print(f"Error running fire engineering app: {e}") + if e.returncode == 2: + print("Required dependencies not installed. Please install with: pip install -r requirements.txt") + sys.exit(1) + except KeyboardInterrupt: + print("\nFire engineering application stopped") + except FileNotFoundError: + print("Error: Python not found in PATH") + sys.exit(1) + + +def scaffold_new_project(project_name: str, target_dir: str) -> None: + """Scaffold a new OpenFire project with all required files and setup.""" + try: + # Validate project name for security + validated_project_name = validate_project_name(project_name) + + print(f"Creating OpenFire project: {validated_project_name}") + print(f"Target directory: {target_dir}") + + # Use validated name for all operations + project_name = validated_project_name + # Create project structure + create_project_structure(project_name, target_dir) + + # Create files + create_main_script(project_name, target_dir) + create_requirements_file(project_name, target_dir) + create_readme(project_name, target_dir) + create_agents_guide(project_name, target_dir) + create_claude_guide(project_name, target_dir) + + # Create virtual environment + venv_path = create_virtual_environment(project_name, target_dir) + + # Install requirements in the virtual environment + install_requirements(project_name, target_dir, venv_path) + + # Create activation script + create_activation_script(project_name, target_dir, venv_path) + + project_path = Path(target_dir).resolve() / project_name + + print("\n" + "="*50) + print("Project created successfully!") + print("="*50) + print(f"\nProject location: {project_path}") + print("\nNext steps:") + print(f"1. cd {project_path}") + + if sys.platform == "win32": + print("2. activate.bat") + print("3. ofire run") + else: + print("2. source activate.sh # or: source .venv/bin/activate") + print("3. ofire run") + + print("4. Open your browser to http://localhost:8501") + + print("\nVirtual environment created with all dependencies installed!") + print("🔥 Your fire engineering web application is ready to run!") + print("For documentation, visit: https://emberon-tech.github.io/openfire/") + + except ValueError as e: + # Handle validation errors specifically + print(f"\nInvalid project name: {e}") + sys.exit(1) + except Exception as e: + print(f"\nError creating project: {e}") + sys.exit(1) \ No newline at end of file diff --git a/crates/python_api/ofire/templates/agents_template.md b/crates/python_api/ofire/templates/agents_template.md new file mode 100644 index 00000000..4766b694 --- /dev/null +++ b/crates/python_api/ofire/templates/agents_template.md @@ -0,0 +1,36 @@ +# AI Agent Guide + +## Technology Stack +- **Language**: Python 3.8+ +- **UI Framework**: Streamlit +- **Fire Engineering Library**: OpenFire (ofire) +- **Environment**: Virtual environment (.venv) + +## Key Rules +1. **Always use ofire library** for fire engineering calculations +2. **Use Streamlit** for user interfaces +3. **Follow patterns** in main.py + +## Run Application +```bash +ofire run +``` + +## UI Pattern +```python +import streamlit as st +import ofire + +# Two column layout: inputs left, results right +col1, col2 = st.columns([1, 1]) + +with col1: + param = st.number_input("Parameter", value=10.0) + if st.button("Calculate"): + result = ofire.module.function(param) + st.session_state.result = result + +with col2: + if hasattr(st.session_state, 'result'): + st.metric("Result", f"{st.session_state.result:.2f}") +``` \ No newline at end of file diff --git a/crates/python_api/ofire/templates/main.py b/crates/python_api/ofire/templates/main.py new file mode 100644 index 00000000..a283ef0d --- /dev/null +++ b/crates/python_api/ofire/templates/main.py @@ -0,0 +1,199 @@ +""" +{project_name} - Fire Engineering Tool + +A web application for fire engineering calculations +using the OpenFire library. +""" + +import streamlit as st +import ofire + + +def main(): + """Main Streamlit application.""" + st.set_page_config( + page_title="{project_name}", + page_icon="🔥", + layout="wide", + initial_sidebar_state="expanded" + ) + + st.title("🔥 {project_name}") + st.markdown("Fire Engineering Calculations built using OpenFire") + + # Sidebar for navigation + st.sidebar.title("Navigation") + + # Initialize session state for page selection + if 'current_page' not in st.session_state: + st.session_state.current_page = "Welcome" + + # Create navigation buttons + if st.sidebar.button("🏠 Welcome", use_container_width=True): + st.session_state.current_page = "Welcome" + + if st.sidebar.button("🔥 Smoke Layer Analysis", use_container_width=True): + st.session_state.current_page = "Smoke Layer Analysis" + + page = st.session_state.current_page + + if page == "Welcome": + welcome_page() + elif page == "Smoke Layer Analysis": + smoke_filling_page() + + +def welcome_page(): + st.header("Welcome to {project_name}") + + st.markdown(""" + This fire engineering tool provides calculations for: + + - **Smoke Layer Analysis**: Calculate smoke layer interface height and properties using Fire Dynamics Tools + - **Custom Calculations**: Add your own fire engineering calculations + + Select a calculation from the sidebar to get started. + """) + + st.info("💡 This tool is built using the OpenFire library for fire engineering calculations.") + + +def smoke_filling_page(): + """Smoke layer analysis calculation page.""" + st.header("Smoke Layer Analysis") + st.markdown("Calculate smoke layer interface height and properties using OpenFire fire dynamics tools.") + + col1, col2 = st.columns([1, 1]) + + with col1: + st.subheader("Room Parameters") + room_length = st.number_input( + "Room Length (m)", + min_value=1.0, + max_value=100.0, + value=10.0, + step=0.1, + help="Length of the room in meters" + ) + + room_width = st.number_input( + "Room Width (m)", + min_value=1.0, + max_value=100.0, + value=8.0, + step=0.1, + help="Width of the room in meters" + ) + + room_height = st.number_input( + "Room Height (m)", + min_value=1.0, + max_value=50.0, + value=3.0, + step=0.1, + help="Height of the room in meters" + ) + + st.subheader("Fire Parameters") + heat_release_rate = st.number_input( + "Heat Release Rate (kW)", + min_value=10.0, + max_value=50000.0, + value=1000.0, + step=10.0, + help="Heat release rate of the fire in kilowatts" + ) + + time_after_ignition = st.number_input( + "Time After Ignition (s)", + min_value=1.0, + max_value=3600.0, + value=90.0, + step=1.0, + help="Time elapsed since ignition in seconds" + ) + + hot_gas_temp = st.number_input( + "Hot Gas Temperature (K)", + min_value=300.0, + max_value=1500.0, + value=500.0, + step=10.0, + help="Temperature of the hot gas layer in Kelvin" + ) + + if st.button("Calculate Smoke Layer Properties", type="primary"): + try: + floor_area = room_length * room_width + + hot_gas_density = ofire.fire_dynamics_tools.chapter_2.equation_2_13.density_hot_gas_layer( + hot_gas_temp + ) + + k_coefficient = ofire.fire_dynamics_tools.chapter_2.equation_2_12.k_constant_smoke_layer_height_post_substitution( + hot_gas_density + ) + + interface_height = ofire.fire_dynamics_tools.chapter_2.equation_2_10.height_smoke_layer_interface_natural_ventilation( + k_coefficient, heat_release_rate, time_after_ignition, floor_area, room_height + ) + + smoke_layer_depth = room_height - interface_height + smoke_volume = floor_area * max(0, smoke_layer_depth) + percent_filled = (max(0, smoke_layer_depth) / room_height) * 100 + + st.session_state.smoke_results = { + 'hot_gas_density': hot_gas_density, + 'k_coefficient': k_coefficient, + 'interface_height': interface_height, + 'smoke_layer_depth': smoke_layer_depth, + 'smoke_volume': smoke_volume, + 'percent_filled': percent_filled, + 'floor_area': floor_area + } + + st.success("Smoke layer analysis completed!") + + except Exception as e: + st.error(f"Calculation error: {e}") + st.info("Note: Ensure all parameters are within reasonable ranges for fire engineering calculations.") + + with col2: + st.subheader("Results") + if hasattr(st.session_state, 'smoke_results'): + results = st.session_state.smoke_results + + # Primary results + st.metric( + "Smoke Layer Interface Height", + f"{results['interface_height']:.2f} m", + help="Height of the interface between clear air below and smoke layer above" + ) + + st.metric( + "Smoke Layer Depth", + f"{results['smoke_layer_depth']:.2f} m", + help="Thickness of the smoke layer from interface to ceiling" + ) + + st.metric( + "Room Smoke-Filled", + f"{results['percent_filled']:.1f}%", + help="Percentage of room height filled with smoke" + ) + + + # Methodology information + st.info(""" + **Calculation Method**: + - Yamana-Tanaka correlation for smoke layer interface height + - Natural ventilation conditions assumed + - Based on Fire Dynamics Tools, Chapter 2 + """) + + else: + st.info("Enter room and fire parameters, then click 'Calculate Smoke Layer Properties' to see results.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/crates/python_api/pyproject.toml b/crates/python_api/pyproject.toml index 94b8cef6..fb433496 100644 --- a/crates/python_api/pyproject.toml +++ b/crates/python_api/pyproject.toml @@ -27,7 +27,32 @@ keywords = [ "Introduction to Fire Dynamics", ] +[project.scripts] +ofire = "ofire.cli:main" + +[project.optional-dependencies] +test = [ + "pytest>=7.0", + "pytest-subprocess>=1.5.0", + "pytest-xdist>=3.0.0", +] + [project.urls] Homepage = "https://www.openfiresoftware.com/" Documentation = "https://emberon-tech.github.io/openfire/" -Repository = "https://github.com/emberon-tech/openfire" +Repository = "https://github.com/emberon-tech/openfire/" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", +] diff --git a/crates/python_api/tests/__init__.py b/crates/python_api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crates/python_api/tests/conftest.py b/crates/python_api/tests/conftest.py new file mode 100644 index 00000000..f1c2623f --- /dev/null +++ b/crates/python_api/tests/conftest.py @@ -0,0 +1,64 @@ +import pytest +import tempfile +import shutil +import subprocess +import sys +import os +from pathlib import Path + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + temp_path = tempfile.mkdtemp() + yield Path(temp_path) + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def cli_runner(): + """Helper to run CLI commands with proper error handling.""" + def run_cli(*args, check=True, cwd=None, input=None): + """Run ofire CLI command and return CompletedProcess result.""" + cmd = [sys.executable, "-m", "ofire"] + list(args) + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=check, + cwd=cwd, + input=input, + timeout=30 + ) + return result + except subprocess.TimeoutExpired: + pytest.fail(f"Command timed out: {' '.join(cmd)}") + except subprocess.CalledProcessError as e: + if check: + pytest.fail(f"Command failed: {' '.join(cmd)}\nSTDOUT: {e.stdout}\nSTDERR: {e.stderr}") + return e + + return run_cli + + +@pytest.fixture +def sample_project_name(): + """Provide a consistent test project name.""" + return "test_fire_project" + + +@pytest.fixture(scope="session") +def ofire_available(): + """Check if ofire CLI is available and properly installed.""" + try: + result = subprocess.run( + [sys.executable, "-m", "ofire", "--help"], + capture_output=True, + text=True, + check=True, + timeout=10 + ) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + pytest.skip("ofire CLI not available - package not installed") \ No newline at end of file diff --git a/crates/python_api/tests/test_cli.py b/crates/python_api/tests/test_cli.py new file mode 100644 index 00000000..9b9c2c24 --- /dev/null +++ b/crates/python_api/tests/test_cli.py @@ -0,0 +1,212 @@ +import pytest +import subprocess +import sys +import os +from pathlib import Path + + +class TestCLIAvailability: + """Test that the CLI is properly installed and accessible.""" + + def test_cli_importable(self, ofire_available): + """Test that ofire module can be imported.""" + import ofire.cli + assert hasattr(ofire.cli, 'main') + + def test_cli_executable(self, cli_runner): + """Test that ofire command is executable.""" + result = cli_runner("--help") + assert result.returncode == 0 + assert "OpenFire CLI" in result.stdout + + +class TestVersionCommand: + """Test the version command functionality.""" + + def test_version_command(self, cli_runner, ofire_available): + """Test version command shows version information.""" + result = cli_runner("version") + assert result.returncode == 0 + assert "OpenFire CLI" in result.stdout + # Should show either version number or "unknown" message + assert ("v" in result.stdout) or ("unknown" in result.stdout) + + def test_version_command_output_format(self, cli_runner, ofire_available): + """Test version command output format.""" + result = cli_runner("version") + lines = result.stdout.strip().split('\n') + assert len(lines) == 1 # Should be single line output + assert result.stdout.startswith("OpenFire CLI") + + +class TestHelpCommand: + """Test help command and general CLI structure.""" + + def test_help_command(self, cli_runner, ofire_available): + """Test that help command works.""" + result = cli_runner("--help") + assert result.returncode == 0 + assert "OpenFire CLI" in result.stdout + assert "Available commands" in result.stdout + + def test_subcommands_listed(self, cli_runner, ofire_available): + """Test that all expected subcommands are listed in help.""" + result = cli_runner("--help") + output = result.stdout + + expected_commands = ["new", "run", "docs", "version"] + for cmd in expected_commands: + assert cmd in output + + def test_subcommand_help(self, cli_runner, ofire_available): + """Test that subcommands have their own help.""" + result = cli_runner("new", "--help") + assert result.returncode == 0 + assert "Create a new OpenFire project" in result.stdout + + result = cli_runner("run", "--help") + assert result.returncode == 0 + assert "Run a fire engineering application" in result.stdout + + +class TestNewCommand: + """Test the new project creation command.""" + + def test_new_command_creates_project(self, cli_runner, temp_dir, sample_project_name, ofire_available): + """Test that new command creates a project structure.""" + result = cli_runner("new", sample_project_name, "-d", str(temp_dir)) + assert result.returncode == 0 + + project_dir = temp_dir / sample_project_name + assert project_dir.exists() + assert project_dir.is_dir() + + def test_new_command_creates_main_file(self, cli_runner, temp_dir, sample_project_name, ofire_available): + """Test that new command creates main.py file.""" + result = cli_runner("new", sample_project_name, "-d", str(temp_dir)) + assert result.returncode == 0 + + main_file = temp_dir / sample_project_name / "main.py" + assert main_file.exists() + assert main_file.is_file() + + def test_new_command_without_directory_flag(self, cli_runner, temp_dir, ofire_available): + """Test new command works in current directory.""" + project_name = "current_dir_test" + result = cli_runner("new", project_name, cwd=str(temp_dir)) + assert result.returncode == 0 + + project_dir = temp_dir / project_name + assert project_dir.exists() + + def test_new_command_missing_name(self, cli_runner, ofire_available): + """Test new command fails without project name.""" + result = cli_runner("new", check=False) + assert result.returncode != 0 + assert "required" in result.stderr.lower() or "required" in result.stdout.lower() + + @pytest.mark.parametrize("project_name", [ + "simple_project", + "project-with-hyphens", + "project_123", + "CamelCaseProject" + ]) + def test_new_command_various_names(self, cli_runner, temp_dir, project_name, ofire_available): + """Test new command with various valid project names.""" + result = cli_runner("new", project_name, "-d", str(temp_dir)) + assert result.returncode == 0 + + project_dir = temp_dir / project_name + assert project_dir.exists() + + +class TestRunCommand: + """Test the run command functionality.""" + + def test_run_command_help(self, cli_runner, ofire_available): + """Test run command shows help properly.""" + result = cli_runner("run", "--help") + assert result.returncode == 0 + assert "Run a fire engineering application" in result.stdout + + def test_run_command_with_nonexistent_file(self, cli_runner, temp_dir, ofire_available): + """Test run command with non-existent file.""" + nonexistent_file = temp_dir / "nonexistent.py" + result = cli_runner("run", str(nonexistent_file), check=False, cwd=str(temp_dir)) + # Should fail gracefully (exact behavior depends on implementation) + assert result.returncode != 0 or "not found" in result.stderr.lower() + + +class TestDocsCommand: + """Test the docs command functionality.""" + + def test_docs_command_help(self, cli_runner, ofire_available): + """Test docs command shows help properly.""" + result = cli_runner("docs", "--help") + assert result.returncode == 0 + assert "Open OpenFire documentation" in result.stdout + + def test_docs_command_execution(self, cli_runner, ofire_available): + """Test docs command executes without error.""" + # Note: This test might need adjustment based on how docs command works + # If it opens a browser, it might succeed silently or need mocking + result = cli_runner("docs", check=False) + # Should either succeed or fail gracefully + assert result.returncode in [0, 1] # Allow either success or controlled failure + + +class TestErrorHandling: + """Test CLI error handling and edge cases.""" + + def test_invalid_command(self, cli_runner, ofire_available): + """Test that invalid commands are handled properly.""" + result = cli_runner("invalid_command", check=False) + assert result.returncode != 0 + + def test_no_command(self, cli_runner, ofire_available): + """Test that running CLI with no command shows help.""" + result = cli_runner() + # Should show help when no command is provided + assert "OpenFire CLI" in result.stdout + assert "Available commands" in result.stdout + + +class TestCrossPlatformCompatibility: + """Test cross-platform specific functionality.""" + + def test_path_handling(self, cli_runner, temp_dir, ofire_available): + """Test that paths are handled correctly across platforms.""" + project_name = "path_test_project" + + # Test with different path separators + target_dir = temp_dir / "nested" / "directory" + target_dir.mkdir(parents=True, exist_ok=True) + + result = cli_runner("new", project_name, "-d", str(target_dir)) + assert result.returncode == 0 + + project_dir = target_dir / project_name + assert project_dir.exists() + + @pytest.mark.skipif(os.name == 'nt', reason="Unix-specific test") + def test_unix_permissions(self, cli_runner, temp_dir, sample_project_name, ofire_available): + """Test file permissions on Unix systems.""" + result = cli_runner("new", sample_project_name, "-d", str(temp_dir)) + assert result.returncode == 0 + + main_file = temp_dir / sample_project_name / "main.py" + assert main_file.exists() + # Check that file is readable + assert os.access(str(main_file), os.R_OK) + + @pytest.mark.skipif(os.name != 'nt', reason="Windows-specific test") + def test_windows_path_handling(self, cli_runner, temp_dir, sample_project_name, ofire_available): + """Test Windows-specific path handling.""" + # Test with Windows-style paths if on Windows + result = cli_runner("new", sample_project_name, "-d", str(temp_dir)) + assert result.returncode == 0 + + project_dir = temp_dir / sample_project_name + assert project_dir.exists() + # Verify Windows path normalization works + assert str(project_dir).replace('/', os.sep) == str(project_dir) \ No newline at end of file