├── .python-version ├── src └── fasthtml_cli │ ├── __init__.py │ ├── toml.py │ ├── utils.py │ ├── cli.py │ ├── template_builder.py │ └── gallery_fetcher.py ├── .gitignore ├── cli.png ├── TODO.md ├── CLAUDE.md ├── pyproject.toml ├── LICENSE ├── CHANGELOG.md ├── README.md └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /src/fasthtml_cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .DS_Store 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ExploringML/fasthtml-cli/HEAD/cli.png -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | - Add an importer similar to --gallery flag feature but for any URL? Maybe show a warning when using this flag about untrusted sources. Do some simple checks to make sure it is a Fasthtml app and no malicious code. 2 | - Delete the legacy fh-init CLI. 3 | - Be able to paste in full URL of gallery url too? -------------------------------------------------------------------------------- /src/fasthtml_cli/toml.py: -------------------------------------------------------------------------------- 1 | def config(name: str = "", dependencies: list = None): 2 | """Generate pyproject.toml content with the given dependencies.""" 3 | if dependencies is None: 4 | dependencies = ["python-fasthtml"] 5 | 6 | # Format dependencies for TOML 7 | deps_str = ', '.join([f'"{dep}"' for dep in dependencies]) 8 | 9 | return f"""[project] 10 | name = "{name}" 11 | version = "0.1.0" 12 | description = "Add your description here" 13 | readme = "README.md" 14 | requires-python = ">=3.12" 15 | dependencies = [{deps_str}]""" -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # FastHTML CLI 2 | 3 | See the 'Development' section of README.md for details on setting up the development version of the CLI for local testing. 4 | 5 | # Update MD Files 6 | 7 | As you make changes ALWAYS keep the README.md and CHANGELOG.md files up to date. This is very important to keep users informed of updates. Make sure you only update the changelog section relating to the current version specified in pyproject.toml. 8 | 9 | # Publish to PyPi 10 | 11 | Here are the instructions to push new CLI versions to PyPi. 12 | 13 | Delete the dist folder and run: 14 | 15 | ``` 16 | uv build 17 | ``` 18 | 19 | Then make sure the PyPi token has been set with: 20 | 21 | ``` 22 | export UV_PUBLISH_TOKEN=pypi-AgEIc... 23 | ``` 24 | 25 | Publish to PyPi with: 26 | 27 | ``` 28 | uv publish 29 | ``` 30 | 31 | # Resources 32 | 33 | - https://youtu.be/qh98qOND6MI -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fh-cli" 3 | version = "0.1.6" 4 | description = "Quickly scaffold FastHTML apps!" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "David Gwyer", email = "d.v.gwyer@gmail.com" } 8 | ] 9 | requires-python = ">=3.12" 10 | dependencies = [ 11 | "typer>=0.17.0,<1.0.0", 12 | "requests>=2.32.0,<3.0.0", 13 | ] 14 | 15 | [project.urls] 16 | Homepage = "https://github.com/ExploringML/fasthtml-cli" 17 | Repository = "https://github.com/ExploringML/fasthtml-cli" 18 | "Bug Tracker" = "https://github.com/ExploringML/fasthtml-cli/issues" 19 | Documentation = "https://github.com/ExploringML/fasthtml-cli#readme" 20 | 21 | [project.scripts] 22 | fh-cli = "fasthtml_cli.cli:app" 23 | fh-init = "fasthtml_cli.cli:app" 24 | fasthtml-init = "fasthtml_cli.cli:app" 25 | 26 | [tool.hatch.build.targets.wheel] 27 | packages = ["src/fasthtml_cli"] 28 | 29 | [build-system] 30 | requires = ["hatchling"] 31 | build-backend = "hatchling.build" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ExploringML 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/fasthtml_cli/utils.py: -------------------------------------------------------------------------------- 1 | from fasthtml_cli.template_builder import TemplateBuilder 2 | from fasthtml_cli.gallery_fetcher import GalleryFetcher 3 | from fasthtml_cli import toml 4 | 5 | def create_main_py(name: str, template: str, tailwind: bool, reload: bool, pico: bool, deps: str = "", gallery: str = ""): 6 | """Create the main.py file using the flexible template builder or gallery example.""" 7 | 8 | # Gallery mode: ignore all other template options 9 | if gallery: 10 | fetcher = GalleryFetcher() 11 | try: 12 | example = fetcher.fetch_example(gallery) 13 | return example['code'] 14 | except Exception as e: 15 | print(f"Warning: Failed to fetch gallery example '{gallery}': {e}") 16 | print("Falling back to basic template...") 17 | # Fall through to regular template mode 18 | 19 | # Regular template mode 20 | builder = TemplateBuilder(name) 21 | 22 | # Add features based on options 23 | if tailwind: 24 | builder.add_tailwind() 25 | elif pico: # pico is default unless tailwind is used 26 | builder.add_pico() 27 | 28 | if reload: 29 | builder.add_live_reload() 30 | 31 | # Add custom dependencies 32 | if deps: 33 | custom_deps = [dep.strip() for dep in deps.split() if dep.strip()] 34 | builder.add_dependencies(custom_deps) 35 | 36 | return builder.build_main_py() 37 | 38 | def create_pyproject_toml(name: str, deps: str = "", gallery: str = ""): 39 | """Create the pyproject.toml file with selected config options.""" 40 | 41 | # Gallery mode: use gallery dependencies 42 | if gallery: 43 | fetcher = GalleryFetcher() 44 | try: 45 | example = fetcher.fetch_example(gallery) 46 | dependencies = example['dependencies'] 47 | return toml.config(name, dependencies) 48 | except Exception as e: 49 | print(f"Warning: Failed to fetch gallery dependencies for '{gallery}': {e}") 50 | print("Using basic FastHTML dependencies...") 51 | # Fall through to regular mode 52 | 53 | # Regular mode: use template builder 54 | builder = TemplateBuilder(name) 55 | if deps: 56 | custom_deps = [dep.strip() for dep in deps.split() if dep.strip()] 57 | builder.add_dependencies(custom_deps) 58 | 59 | dependencies = builder.build_pyproject_toml() 60 | return toml.config(name, dependencies) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.6] - 2025-09-13 9 | 10 | ### Changed 11 | - **BREAKING**: Package name changed from `fh-init` to `fh-cli` for consistency 12 | - Primary command is now `fh-cli` instead of `fh-init` 13 | - Backward compatibility: `fh-init` command still available during transition period 14 | 15 | ### Migration Guide 16 | - **Old:** `uvx fh-init my-app` 17 | - **New:** `uvx fh-cli my-app` 18 | - Both commands work during transition period 19 | - Update your scripts and documentation to use `fh-cli` going forward 20 | 21 | ## [0.1.5] - 2025-09-13 22 | 23 | ### Added 24 | - Added `--version` flag to display current version 25 | - Version is now automatically read from package metadata to stay in sync with `pyproject.toml` 26 | - Added `--deps` option to specify additional Python dependencies (space-separated) 27 | - Dependencies are now automatically imported in generated main.py files with proper aliases (e.g., `pandas as pd`, `numpy as np`) 28 | - **NEW**: Added `--gallery` option to bootstrap projects from the official [FastHTML Gallery](https://gallery.fastht.ml/) 29 | - Pull complete working examples with `fh-init my-app --gallery category/example` 30 | - Automatic dependency detection from gallery code 31 | - When `--gallery` is used, all other template options are ignored (by design) 32 | 33 | ### Fixed 34 | - Fixed bug where `Path` was not imported, causing import errors 35 | - Fixed duplicate parameter warning for `-p` flag by changing `--template` shorthand to `-tp` 36 | - Fixed bug where `--deps` dependencies were only added to pyproject.toml but not imported in main.py 37 | - Fixed single-item tuple syntax for headers (now properly includes trailing comma) 38 | 39 | ### Security 40 | - Added input sanitization to prevent path traversal attacks in project names 41 | - Enhanced error message sanitization to prevent information disclosure 42 | - Added version constraints to dependencies to improve supply chain security 43 | - Implemented comprehensive security audit with 0 vulnerabilities found 44 | 45 | ### Changed 46 | - **BREAKING**: Replaced rigid template system with flexible component-based TemplateBuilder 47 | - Improved version detection system to work reliably with different installation methods 48 | - Updated Tailwind CSS CDN link to new `@tailwindcss/browser@4` URL 49 | - Restructured codebase: moved toml.py and removed fasthtml_templates folder -------------------------------------------------------------------------------- /src/fasthtml_cli/cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import re 3 | import typer 4 | from typing_extensions import Annotated 5 | from fasthtml_cli.utils import create_main_py, create_pyproject_toml 6 | 7 | def sanitize_project_name(name: str) -> str: 8 | """Sanitize project name to prevent path traversal and ensure valid directory names.""" 9 | # Allow only alphanumeric, hyphens, underscores, and dots 10 | # Remove any path separators or parent directory references 11 | sanitized = re.sub(r'[^a-zA-Z0-9._-]', '', name) 12 | 13 | # Remove leading dots to prevent hidden files 14 | sanitized = sanitized.lstrip('.') 15 | 16 | # Ensure name is not empty after sanitization 17 | if not sanitized: 18 | return "fasthtml-app" 19 | 20 | return sanitized 21 | 22 | def get_version(): 23 | """Get version from package metadata""" 24 | try: 25 | from importlib.metadata import version 26 | return version("fh-cli") 27 | except Exception: 28 | return "Error: Version not found" 29 | 30 | app = typer.Typer() 31 | 32 | def version_callback(value: bool): 33 | if value: 34 | print(f"fh-cli version {get_version()}") 35 | raise typer.Exit() 36 | 37 | @app.command() 38 | def main( 39 | name: Annotated[str, typer.Argument(help="FastHTML app name.")], 40 | template: Annotated[str, typer.Option("--template", "-tp", help="The name of the FastHTML template to use.")] = "base", 41 | reload: Annotated[bool, typer.Option("--reload", "-r", help="Enable live reload.")] = False, 42 | pico: Annotated[bool, typer.Option("--pico", "-p", help="Enable Pico CSS.")] = True, 43 | uv: Annotated[bool, typer.Option(help="Use uv to manage project dependencies.")] = True, 44 | tailwind: Annotated[bool, typer.Option("--tailwind", "-t", help="Enable Tailwind CSS.")] = False, 45 | deps: Annotated[str, typer.Option("--deps", "-d", help="Space-separated list of Python dependencies to add (e.g., 'pandas numpy requests').")] = "", 46 | gallery: Annotated[str, typer.Option("--gallery", "-g", help="Use a FastHTML Gallery example (e.g., 'todo_series/beginner'). When used, other template options are ignored.")] = "", 47 | version: Annotated[bool, typer.Option("--version", callback=version_callback, help="Show version and exit.")] = False, 48 | ): 49 | """ 50 | Scaffold a new FastHTML application. 51 | """ 52 | 53 | # Sanitize project name to prevent path traversal 54 | sanitized_name = sanitize_project_name(name) 55 | if sanitized_name != name: 56 | print(f"Error: Invalid project name. Only alphanumeric characters, hyphens, and underscores are allowed.") 57 | return 58 | 59 | # Create the project path. 60 | path = Path(sanitized_name) 61 | 62 | # Check if directory exists. 63 | if path.exists(): 64 | print(f"Error: Directory '{sanitized_name}' already exists") 65 | return 66 | 67 | try: 68 | # Create directory 69 | path.mkdir(parents=True) 70 | 71 | # Create main.py 72 | main_file = path/'main.py' 73 | if main_file.exists(): 74 | print(f"Error: {main_file} already exists, skipping") 75 | else: 76 | main_file.write_text(create_main_py(name, template, tailwind, reload, pico, deps, gallery)) 77 | 78 | # Create pyproject.toml if uv is enabled. 79 | if uv: 80 | pyproject_file = path/'pyproject.toml' 81 | if pyproject_file.exists(): 82 | print(f"Error: {pyproject_file} already exists, skipping") 83 | else: 84 | pyproject_file.write_text(create_pyproject_toml(name, deps, gallery)) 85 | except PermissionError: 86 | print(f"Error: Permission denied creating {name}") 87 | except OSError as e: 88 | print(f"Error creating project: {e}") 89 | else: 90 | print(f"\n✨ New FastHTML app created successfully!") 91 | 92 | print("\nTo get started, enter:\n") 93 | print(f" $ cd {name}") 94 | 95 | if uv: 96 | print(" $ uv run main.py\n") 97 | else: 98 | print(" $ python main.py\n") 99 | 100 | 101 | if __name__ == "__main__": 102 | app() -------------------------------------------------------------------------------- /src/fasthtml_cli/template_builder.py: -------------------------------------------------------------------------------- 1 | class TemplateBuilder: 2 | """A flexible component-based template builder for FastHTML apps.""" 3 | 4 | def __init__(self, name: str): 5 | self.name = name 6 | self.imports = ["from fasthtml.common import *"] 7 | self.headers = [] 8 | self.app_options = [] 9 | self.dependencies = ["python-fasthtml"] 10 | 11 | def add_tailwind(self): 12 | """Add Tailwind CSS support.""" 13 | self.headers.append('Script(src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4")') 14 | # Tailwind disables Pico CSS 15 | self.app_options = [opt for opt in self.app_options if not opt.startswith("pico=")] 16 | self.app_options.append("pico=False") 17 | return self 18 | 19 | def add_live_reload(self): 20 | """Add live reload support.""" 21 | if "live=True" not in self.app_options: 22 | self.app_options.append("live=True") 23 | return self 24 | 25 | def add_pico(self): 26 | """Add Pico CSS support (default unless Tailwind is used).""" 27 | # Only add if no pico option exists and no Tailwind 28 | has_pico = any(opt.startswith("pico=") for opt in self.app_options) 29 | has_tailwind = any("tailwindcss" in header for header in self.headers) 30 | 31 | if not has_pico and not has_tailwind: 32 | self.app_options.append("pico=True") 33 | return self 34 | 35 | def add_dependencies(self, deps: list): 36 | """Add custom Python dependencies.""" 37 | for dep in deps: 38 | if dep not in self.dependencies: 39 | self.dependencies.append(dep) 40 | # Add import statement for common packages 41 | import_name = self._get_import_name(dep) 42 | if import_name: 43 | import_statement = f"import {import_name}" 44 | if import_statement not in self.imports: 45 | self.imports.append(import_statement) 46 | return self 47 | 48 | def _get_import_name(self, dep: str): 49 | """Get the import name for common packages.""" 50 | # Handle common package name mappings 51 | common_mappings = { 52 | 'pandas': 'pandas as pd', 53 | 'numpy': 'numpy as np', 54 | 'requests': 'requests', 55 | 'matplotlib': 'matplotlib.pyplot as plt', 56 | 'seaborn': 'seaborn as sns', 57 | 'scikit-learn': 'sklearn', 58 | 'pillow': 'PIL', 59 | 'beautifulsoup4': 'bs4', 60 | } 61 | 62 | # Extract base package name (remove version specifiers) 63 | base_dep = dep.split('>=')[0].split('==')[0].split('<')[0].strip() 64 | 65 | return common_mappings.get(base_dep, base_dep) 66 | 67 | def build_main_py(self): 68 | """Generate the main.py file content.""" 69 | # Build imports 70 | imports = "\n".join(self.imports) 71 | 72 | # Build headers 73 | headers_section = "" 74 | if self.headers: 75 | headers_list = ", ".join(self.headers) 76 | # Ensure proper tuple syntax with trailing comma for single items 77 | if len(self.headers) == 1: 78 | headers_section = f"hdrs = ({headers_list},)\n" 79 | else: 80 | headers_section = f"hdrs = ({headers_list})\n" 81 | 82 | # Build app configuration 83 | app_config_parts = [] 84 | if self.headers: 85 | app_config_parts.append("hdrs=hdrs") 86 | app_config_parts.extend(self.app_options) 87 | app_config = ", ".join(app_config_parts) 88 | 89 | # Default route 90 | default_route = '''@rt('/') 91 | def get(): 92 | return Div( 93 | H1("Hello, FastHTML!"), 94 | P("Your app is running!") 95 | )''' 96 | 97 | # Assemble the complete template 98 | template = f"""{imports} 99 | 100 | {headers_section}app, rt = fast_app({app_config}) 101 | 102 | {default_route} 103 | 104 | serve()""" 105 | 106 | return template 107 | 108 | def build_pyproject_toml(self): 109 | """Generate the pyproject.toml dependencies list.""" 110 | return self.dependencies -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastHTML CLI 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/fh-cli)](https://pypi.org/project/fh-cli/) 4 | [![License](https://img.shields.io/github/license/ExploringML/fasthtml-cli)](https://github.com/your-username/your-repo/blob/main/LICENSE) 5 | [![Sponsor](https://img.shields.io/badge/Sponsor-FastHTML%20CLI-blue?logo=github)](https://github.com/sponsors/ExploringML) 6 | 7 | Fastest way to scaffold FastHTML apps! 8 | 9 | ![CLI Demo](cli.png) 10 | 11 | > **📦 Package Name Change:** This package was previously named `fh-init` but has been renamed to `fh-cli` for consistency. Both commands (`fh-cli` and `fh-init`) work during the transition period. We recommend using `fh-cli` going forward. 12 | 13 | ## Usage 14 | 15 | To create a new FastHTML application, use the `fh-cli` command. Make sure [`uv`](https://docs.astral.sh/uv/getting-started/installation/) is installed before running `uvx`: 16 | 17 | ```bash 18 | uvx fh-cli [OPTIONS] NAME 19 | ``` 20 | 21 | ### Arguments 22 | 23 | * `NAME`: The name of your FastHTML application (required). 24 | 25 | ### Options 26 | 27 | * `--template, -tp TEXT`: The name of the FastHTML template to use (default: `base`). 28 | * `--reload, -r`: Enable live reload. 29 | * `--pico, -p`: Enable Pico CSS (default: `True`). 30 | * `--uv / --no-uv`: Use uv to manage project dependencies (default: `uv`). 31 | * `--tailwind, -t`: Enable Tailwind CSS. 32 | * `--deps, -d TEXT`: Space-separated list of Python dependencies to add (e.g., `pandas numpy requests`). 33 | * `--gallery, -g TEXT`: Use a FastHTML Gallery example (e.g., `todo_series/beginner`). When used, other template options are ignored. 34 | * `--version`: Show version and exit. 35 | * `--install-completion`: Install tab completion for the current shell (run once to enable auto-completion). 36 | * `--show-completion`: Show completion script to copy or customize the installation. 37 | * `--help`: Show the help message and exit. 38 | 39 | ### Examples 40 | 41 | ```bash 42 | # Create a basic app 43 | uvx fh-cli my_awesome_app 44 | 45 | # Create an app with live reload and Tailwind CSS 46 | uvx fh-cli my_awesome_app --reload --tailwind 47 | 48 | # Create an app with additional Python dependencies 49 | uvx fh-cli data_app --deps "pandas numpy matplotlib" 50 | 51 | # Create an app from the FastHTML Gallery 52 | uvx fh-cli my-todo --gallery todo_series/beginner 53 | uvx fh-cli my-viz --gallery visualizations/observable_plot 54 | ``` 55 | 56 | Then to run the FastHTML app: 57 | 58 | ```bash 59 | cd my_awesome_app 60 | uv run main.py 61 | ``` 62 | 63 | ### Shell Completion 64 | 65 | For a better CLI experience, you can enable tab completion: 66 | 67 | ```bash 68 | # Install completion for your current shell (run once) 69 | fh-cli --install-completion 70 | 71 | # After installation, you can use tab completion 72 | fh-cli # Shows available commands and options 73 | fh-cli -- # Shows all available flags 74 | ``` 75 | 76 | ### FastHTML Gallery Integration 77 | 78 | Bootstrap your projects with real-world examples from the official [FastHTML Gallery](https://gallery.fastht.ml/): 79 | 80 | ```bash 81 | # Browse examples at https://gallery.fastht.ml/ 82 | # Use the format: category/example_name 83 | 84 | # Todo applications 85 | uvx fh-cli my-todo --gallery todo_series/beginner 86 | 87 | # Data visualizations 88 | uvx fh-cli my-charts --gallery visualizations/observable_plot 89 | 90 | # Interactive applications 91 | uvx fh-cli my-app --gallery applications/csv_editor 92 | ``` 93 | 94 | **Gallery Features:** 95 | - **Complete Examples**: Get fully working FastHTML applications instantly 96 | - **Auto Dependencies**: Required packages automatically added to `pyproject.toml` 97 | - **No Modifications**: Gallery code copied exactly as-is for authentic examples 98 | - **Exclusive Mode**: When using `--gallery`, other template options (`--tailwind`, `--deps`, etc.) are ignored 99 | 100 | **Available Categories:** 101 | - `applications/` - Full-featured apps (csv_editor, tic_tac_toe, etc.) 102 | - `todo_series/` - Todo app examples of varying complexity 103 | - `visualizations/` - Data visualization examples 104 | - `widgets/` - Reusable UI components 105 | - `svg/` - SVG and graphics examples 106 | - `dynamic_user_interface_(htmx)/` - Advanced HTMX interactions 107 | 108 | ## Development 109 | 110 | ### Initial Setup 111 | 112 | For first-time setup: 113 | 114 | 1. **Activate the virtual environment:** 115 | ```bash 116 | source .venv/bin/activate 117 | ``` 118 | 119 | 2. **Install the CLI locally in editable mode:** 120 | ```bash 121 | uv pip install -e . 122 | ``` 123 | 124 | ### Quick Development Workflow 125 | 126 | After making any changes to code or `pyproject.toml`: 127 | 128 | 1. **In the CLI root folder, run:** 129 | ```bash 130 | source .venv/bin/activate && uv pip install -e . --force-reinstall && uv cache clean 131 | ``` 132 | 133 | 2. **Use the local development CLI from any other folder:** 134 | ```bash 135 | # Check version 136 | uvx --from fh-cli --version 137 | 138 | # Create a test project 139 | uvx --from fh-cli test-app 140 | ``` 141 | 142 | ### Troubleshooting 143 | 144 | - If uvx shows old version after changes, the single update command above should resolve it 145 | - The `--version` flag reads from package metadata to ensure version consistency with `pyproject.toml` 146 | 147 | ### Switching Back to PyPI Version 148 | 149 | After local development, you may want to return to using the official PyPI version: 150 | 151 | 1. **Deactivate the development environment:** 152 | ```bash 153 | deactivate 154 | ``` 155 | 156 | 2. **Use the official PyPI version:** 157 | ```bash 158 | # This will automatically use the latest PyPI version 159 | uvx fh-cli --version 160 | uvx fh-cli my-app 161 | ``` 162 | 163 | **Notes:** 164 | - `uvx` automatically isolates package environments, so your local development doesn't affect the global PyPI version 165 | - The `--from ` flag in development only affects that specific command 166 | - Once you deactivate the virtual environment, `uvx fh-cli` will use the official PyPI package 167 | - No need to uninstall anything - `uvx` handles package isolation automatically 168 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 1 3 | requires-python = ">=3.12" 4 | 5 | [[package]] 6 | name = "click" 7 | version = "8.1.8" 8 | source = { registry = "https://pypi.org/simple" } 9 | dependencies = [ 10 | { name = "colorama", marker = "sys_platform == 'win32'" }, 11 | ] 12 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 13 | wheels = [ 14 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 15 | ] 16 | 17 | [[package]] 18 | name = "colorama" 19 | version = "0.4.6" 20 | source = { registry = "https://pypi.org/simple" } 21 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 22 | wheels = [ 23 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 24 | ] 25 | 26 | [[package]] 27 | name = "fh-init" 28 | version = "0.1.1" 29 | source = { editable = "." } 30 | dependencies = [ 31 | { name = "typer" }, 32 | ] 33 | 34 | [package.metadata] 35 | requires-dist = [{ name = "typer" }] 36 | 37 | [[package]] 38 | name = "markdown-it-py" 39 | version = "3.0.0" 40 | source = { registry = "https://pypi.org/simple" } 41 | dependencies = [ 42 | { name = "mdurl" }, 43 | ] 44 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 45 | wheels = [ 46 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 47 | ] 48 | 49 | [[package]] 50 | name = "mdurl" 51 | version = "0.1.2" 52 | source = { registry = "https://pypi.org/simple" } 53 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 54 | wheels = [ 55 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 56 | ] 57 | 58 | [[package]] 59 | name = "pygments" 60 | version = "2.19.1" 61 | source = { registry = "https://pypi.org/simple" } 62 | sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } 63 | wheels = [ 64 | { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, 65 | ] 66 | 67 | [[package]] 68 | name = "rich" 69 | version = "14.0.0" 70 | source = { registry = "https://pypi.org/simple" } 71 | dependencies = [ 72 | { name = "markdown-it-py" }, 73 | { name = "pygments" }, 74 | ] 75 | sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } 76 | wheels = [ 77 | { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, 78 | ] 79 | 80 | [[package]] 81 | name = "shellingham" 82 | version = "1.5.4" 83 | source = { registry = "https://pypi.org/simple" } 84 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 85 | wheels = [ 86 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 87 | ] 88 | 89 | [[package]] 90 | name = "typer" 91 | version = "0.15.2" 92 | source = { registry = "https://pypi.org/simple" } 93 | dependencies = [ 94 | { name = "click" }, 95 | { name = "rich" }, 96 | { name = "shellingham" }, 97 | { name = "typing-extensions" }, 98 | ] 99 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 } 100 | wheels = [ 101 | { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 }, 102 | ] 103 | 104 | [[package]] 105 | name = "typing-extensions" 106 | version = "4.13.2" 107 | source = { registry = "https://pypi.org/simple" } 108 | sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } 109 | wheels = [ 110 | { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, 111 | ] 112 | -------------------------------------------------------------------------------- /src/fasthtml_cli/gallery_fetcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastHTML Gallery integration for fetching example code from the official repository. 3 | """ 4 | import re 5 | import ast 6 | from typing import Dict, List, Optional, Tuple 7 | from pathlib import Path 8 | 9 | 10 | class GalleryFetcher: 11 | """Fetches and analyzes FastHTML examples from the official gallery repository.""" 12 | 13 | BASE_URL = "https://raw.githubusercontent.com/AnswerDotAI/FastHTML-Gallery/main/examples" 14 | GITHUB_API_URL = "https://api.github.com/repos/AnswerDotAI/FastHTML-Gallery/contents/examples" 15 | 16 | def __init__(self): 17 | self.session = None # Will be set up when needed 18 | 19 | def validate_slug(self, slug: str) -> bool: 20 | """Validate that a gallery slug has the correct format.""" 21 | if not slug or "/" not in slug: 22 | return False 23 | 24 | parts = slug.split("/") 25 | if len(parts) != 2: 26 | return False 27 | 28 | category, example = parts 29 | return bool(category.strip() and example.strip()) 30 | 31 | def fetch_example_code(self, slug: str) -> str: 32 | """Fetch the app.py code from a gallery example.""" 33 | if not self.validate_slug(slug): 34 | raise ValueError(f"Invalid gallery slug format: '{slug}'. Expected format: 'category/example'") 35 | 36 | url = f"{self.BASE_URL}/{slug}/app.py" 37 | 38 | try: 39 | import requests 40 | response = requests.get(url, timeout=10) 41 | response.raise_for_status() 42 | return response.text 43 | except ImportError: 44 | raise ImportError("requests library is required for gallery functionality") 45 | except requests.RequestException as e: 46 | # Sanitize error message to avoid information disclosure 47 | if "404" in str(e): 48 | raise ConnectionError(f"Gallery example '{slug}' not found") 49 | else: 50 | raise ConnectionError(f"Failed to fetch gallery example '{slug}': Network error") 51 | 52 | def extract_dependencies(self, code: str) -> List[str]: 53 | """Extract import statements and infer dependencies from FastHTML code.""" 54 | dependencies = ["python-fasthtml"] # Always include FastHTML 55 | 56 | # Parse imports using AST 57 | try: 58 | tree = ast.parse(code) 59 | for node in ast.walk(tree): 60 | if isinstance(node, ast.Import): 61 | for alias in node.names: 62 | dep = self._map_import_to_dependency(alias.name) 63 | if dep and dep not in dependencies: 64 | dependencies.append(dep) 65 | elif isinstance(node, ast.ImportFrom): 66 | if node.module: 67 | dep = self._map_import_to_dependency(node.module) 68 | if dep and dep not in dependencies: 69 | dependencies.append(dep) 70 | except SyntaxError: 71 | # If code parsing fails, fall back to regex 72 | dependencies.extend(self._extract_dependencies_regex(code)) 73 | 74 | return dependencies 75 | 76 | def _extract_dependencies_regex(self, code: str) -> List[str]: 77 | """Fallback dependency extraction using regex patterns.""" 78 | dependencies = [] 79 | 80 | # Common patterns to look for 81 | patterns = { 82 | r'import\s+(numpy|np)': 'numpy', 83 | r'from\s+numpy': 'numpy', 84 | r'import\s+pandas': 'pandas', 85 | r'from\s+pandas': 'pandas', 86 | r'import\s+requests': 'requests', 87 | r'from\s+requests': 'requests', 88 | r'import\s+httpx': 'httpx', 89 | r'from\s+httpx': 'httpx', 90 | r'fastsql': 'fastsql', 91 | r'apswutils': 'apswutils', 92 | r'matplotlib': 'matplotlib', 93 | r'seaborn': 'seaborn', 94 | } 95 | 96 | for pattern, dep in patterns.items(): 97 | if re.search(pattern, code, re.IGNORECASE): 98 | if dep not in dependencies: 99 | dependencies.append(dep) 100 | 101 | return dependencies 102 | 103 | def _map_import_to_dependency(self, import_name: str) -> Optional[str]: 104 | """Map Python import names to pip package names.""" 105 | # Common mappings where import name != pip package name 106 | mappings = { 107 | 'cv2': 'opencv-python', 108 | 'PIL': 'pillow', 109 | 'sklearn': 'scikit-learn', 110 | 'yaml': 'pyyaml', 111 | 'bs4': 'beautifulsoup4', 112 | 'flask': 'flask', 113 | 'django': 'django', 114 | 'fastapi': 'fastapi', 115 | 'starlette': 'starlette', 116 | # FastHTML ecosystem 117 | 'fasthtml': 'python-fasthtml', 118 | 'fastsql': 'fastsql', 119 | 'apswutils': 'apswutils', 120 | # Common data science 121 | 'numpy': 'numpy', 122 | 'pandas': 'pandas', 123 | 'matplotlib': 'matplotlib', 124 | 'seaborn': 'seaborn', 125 | 'requests': 'requests', 126 | 'httpx': 'httpx', 127 | } 128 | 129 | # Get base module name (before any dots) 130 | base_import = import_name.split('.')[0] 131 | 132 | # Skip standard library modules 133 | stdlib_modules = { 134 | 'os', 'sys', 'json', 'csv', 'sqlite3', 'uuid', 'datetime', 135 | 'pathlib', 'typing', 'collections', 'itertools', 'functools', 136 | 'asyncio', 're', 'random', 'math', 'time', 'io', 'base64' 137 | } 138 | 139 | if base_import in stdlib_modules: 140 | return None 141 | 142 | return mappings.get(base_import, base_import) 143 | 144 | def fetch_example(self, slug: str) -> Dict[str, any]: 145 | """ 146 | Fetch a complete gallery example with code and metadata. 147 | 148 | Returns: 149 | { 150 | 'code': str, # The app.py code 151 | 'dependencies': list, # Detected dependencies 152 | 'slug': str # The original slug 153 | } 154 | """ 155 | code = self.fetch_example_code(slug) 156 | dependencies = self.extract_dependencies(code) 157 | 158 | return { 159 | 'code': code, 160 | 'dependencies': dependencies, 161 | 'slug': slug 162 | } 163 | 164 | def list_categories(self) -> List[str]: 165 | """ 166 | List available gallery categories (future feature). 167 | For now, returns known categories. 168 | """ 169 | return [ 170 | "applications", 171 | "dynamic_user_interface_(htmx)", 172 | "svg", 173 | "todo_series", 174 | "visualizations", 175 | "widgets" 176 | ] --------------------------------------------------------------------------------