From d3d33856f5d6637e113f2d589581b4fdd5f88559 Mon Sep 17 00:00:00 2001 From: belloibrahv Date: Wed, 22 Oct 2025 07:20:58 +0100 Subject: [PATCH 1/2] docs(cli): improve docstring formatting for --help and markdown - Add backticks around technical terms (APP_TARGET, APP_FLOW_SPECIFIER, paths, modules) - Fix docstring formatting in cli.py for better rendering in both terminal and markdown - Simplify extract_description() logic in generate_cli_docs.py (join with \n, let Markdown handle formatting) - Fix bullet list formatting: each bullet now on separate line - Eliminate redundant blank lines that break sentences mid-paragraph - Ensure consistent, professional rendering across all CLI commands Fixes #1108 --- dev/generate_cli_docs.py | 296 +++++++++++++++++++++++++++++++++ docs/docs/core/cli-commands.md | 203 ++++++++++++++++++++++ python/cocoindex/cli.py | 191 +++++++++++++-------- 3 files changed, 621 insertions(+), 69 deletions(-) create mode 100644 dev/generate_cli_docs.py create mode 100644 docs/docs/core/cli-commands.md diff --git a/dev/generate_cli_docs.py b/dev/generate_cli_docs.py new file mode 100644 index 00000000..b3b2ff5b --- /dev/null +++ b/dev/generate_cli_docs.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +""" +Script to generate CLI documentation from CocoIndex Click commands. + +This script uses md-click as the foundation but generates enhanced markdown +documentation that's suitable for inclusion in the CocoIndex documentation site. +""" + +import sys +from pathlib import Path +import re +import click +from cocoindex.cli import cli + +# Add the cocoindex python directory to the path +project_root = Path(__file__).parent.parent +python_path = project_root / "python" +sys.path.insert(0, str(python_path)) + + +def clean_usage_line(usage: str) -> str: + """Clean up the usage line to remove 'cli' and make it generic, and remove the 'Usage:' prefix.""" + # Replace 'cli' with 'cocoindex' in usage lines and remove 'Usage:' prefix + cleaned = usage.replace("Usage: cli ", "cocoindex ") + # Handle case where it might be "Usage: cocoindex" already + if cleaned.startswith("Usage: cocoindex "): + cleaned = cleaned.replace("Usage: cocoindex ", "cocoindex ") + return cleaned + + +def escape_html_tags(text: str) -> str: + """Escape HTML-like tags in text to prevent MDX parsing issues, but preserve them in code blocks.""" + import re + + # Handle special cases where URLs with placeholders should be wrapped in code blocks + text = re.sub(r"http://localhost:<([^>]+)>", r"`http://localhost:<\1>`", text) + text = re.sub(r"https://([^<\s]+)<([^>]+)>", r"`https://\1<\2>`", text) + + # Handle comma-separated URL examples specifically (e.g., "https://site1.com,http://localhost:3000") + text = re.sub(r"(?", ">")) + else: + # Odd indices are code blocks, preserve as-is + result.append(part) + + return "".join(result) + + +def format_options_section(help_text: str) -> str: + """Extract and format the options section.""" + lines = help_text.split("\n") + options_start = None + commands_start = None + + for i, line in enumerate(lines): + if line.strip() == "Options:": + options_start = i + elif line.strip() == "Commands:": + commands_start = i + break + + if options_start is None: + return "" + + # Extract options section + end_idx = commands_start if commands_start else len(lines) + options_lines = lines[options_start + 1 : end_idx] # Skip "Options:" header + + # Parse options - each option starts with exactly 2 spaces and a dash + formatted_options = [] + current_option = None + current_description = [] + + for line in options_lines: + if not line.strip(): # Empty line + continue + + # Check if this is a new option line (starts with exactly 2 spaces then -) + if line.startswith(" -") and not line.startswith(" "): + # Save previous option if exists + if current_option is not None: + desc = " ".join(current_description).strip() + desc = escape_html_tags(desc) # Escape HTML tags for MDX compatibility + formatted_options.append(f"| `{current_option}` | {desc} |") + + # Remove the leading 2 spaces + content = line[2:] + + # Find the position where we have multiple consecutive spaces (start of description) + match = re.search(r"\s{2,}", content) + if match: + # Split at the first occurrence of multiple spaces + option_part = content[: match.start()] + desc_part = content[match.end() :] + current_option = option_part.strip() + current_description = [desc_part.strip()] if desc_part.strip() else [] + else: + # No description on this line, just the option + current_option = content.strip() + current_description = [] + else: + # Continuation line (starts with more than 2 spaces) + if current_option is not None and line.strip(): + current_description.append(line.strip()) + + # Add last option + if current_option is not None: + desc = " ".join(current_description).strip() + desc = escape_html_tags(desc) # Escape HTML tags for MDX compatibility + formatted_options.append(f"| `{current_option}` | {desc} |") + + if formatted_options: + header = "| Option | Description |\n|--------|-------------|" + return f"{header}\n" + "\n".join(formatted_options) + "\n" + + return "" + + +def format_commands_section(help_text: str) -> str: + """Extract and format the commands section.""" + lines = help_text.split("\n") + commands_start = None + + for i, line in enumerate(lines): + if line.strip() == "Commands:": + commands_start = i + break + + if commands_start is None: + return "" + + # Extract commands section + commands_lines = lines[commands_start + 1 :] + + # Parse commands - each command starts with 2 spaces then the command name + formatted_commands = [] + + for line in commands_lines: + if not line.strip(): # Empty line + continue + + # Check if this is a command line (starts with 2 spaces + command name) + match = re.match(r"^ (\w+)\s{2,}(.+)$", line) + if match: + command = match.group(1) + description = match.group(2).strip() + # Truncate long descriptions + if len(description) > 80: + description = description[:77] + "..." + formatted_commands.append(f"| `{command}` | {description} |") + + if formatted_commands: + header = "| Command | Description |\n|---------|-------------|" + return f"{header}\n" + "\n".join(formatted_commands) + "\n" + + return "" + + +def extract_description(help_text: str) -> str: + """Extract the main description from help text.""" + lines = help_text.split("\n") + + # Find the description between usage and options/commands + description_lines = [] + in_description = False + last_was_empty = False + + for line in lines: + if line.startswith("Usage:"): + in_description = True + continue + elif line.strip() in ["Options:", "Commands:"]: + break + elif in_description: + stripped = line.strip() + if stripped: + description_lines.append(stripped) + last_was_empty = False + else: + # Blank line - preserve as paragraph separator, avoid duplicates + if description_lines and not last_was_empty: + description_lines.append("") + last_was_empty = True + + # Simply join with single newline - let Markdown handle paragraph formatting naturally + description = "\n".join(description_lines) if description_lines else "" + return escape_html_tags(description) # Escape HTML tags for MDX compatibility + + +def generate_command_docs(cmd: click.Group) -> str: + """Generate markdown documentation for all commands.""" + + markdown_content = [] + + # Disable lint warnings for about "first line in file should be a top level heading" + # We intentionally start with a level 2 heading below, as this file is imported into another file. + markdown_content.append("") + markdown_content.append("") + + # Add top-level heading to satisfy MD041 linting rule + markdown_content.append("## Subcommands Reference") + markdown_content.append("") + + ctx = click.core.Context(cmd, info_name=cmd.name) + subcommands = list(cmd.commands.values()) + # Generate only the command details section (remove redundant headers) + for sub_cmd in sorted(subcommands, key=lambda x: x.name or ""): + sub_ctx = click.core.Context(sub_cmd, info_name=sub_cmd.name, parent=ctx) + command_name = sub_cmd.name + help_text = sub_cmd.get_help(sub_ctx) + usage = clean_usage_line(sub_cmd.get_usage(sub_ctx)) + description = extract_description(help_text) + + markdown_content.append(f"### `{command_name}`") + markdown_content.append("") + + if description: + markdown_content.append(description) + markdown_content.append("") + + # Add usage + markdown_content.append("**Usage:**") + markdown_content.append("") + markdown_content.append(f"```bash") + markdown_content.append(usage) + markdown_content.append("```") + markdown_content.append("") + + # Add options if any + options_section = format_options_section(help_text) + if options_section: + markdown_content.append("**Options:**") + markdown_content.append("") + markdown_content.append(options_section) + + markdown_content.append("---") + markdown_content.append("") + + return "\n".join(markdown_content) + + +def main() -> None: + """Generate CLI documentation and save to file.""" + print("Generating CocoIndex CLI documentation...") + + try: + # Generate markdown content + markdown_content = generate_command_docs(cli) + + # Determine output path + docs_dir = project_root / "docs" / "docs" / "core" + output_file = docs_dir / "cli-commands.md" + + # Ensure directory exists + docs_dir.mkdir(parents=True, exist_ok=True) + + # Write the generated documentation + content_changed = True + if output_file.exists(): + with open(output_file, "r", encoding="utf-8") as f: + existing_content = f.read() + content_changed = existing_content != markdown_content + + if content_changed: + with open(output_file, "w", encoding="utf-8") as f: + f.write(markdown_content) + + print(f"CLI documentation generated successfully at: {output_file}") + print( + f"Generated {len(markdown_content.splitlines())} lines of documentation" + ) + else: + print(f"CLI documentation is up to date at: {output_file}") + + except Exception as e: + print(f"Error generating documentation: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/docs/docs/core/cli-commands.md b/docs/docs/core/cli-commands.md new file mode 100644 index 00000000..95011b46 --- /dev/null +++ b/docs/docs/core/cli-commands.md @@ -0,0 +1,203 @@ + + +## Subcommands Reference + +### `drop` + +Drop the backend setup for flows. + +Modes of operation: +1. Drop all flows defined in an app: `cocoindex drop ` +2. Drop specific named flows: `cocoindex drop [FLOW_NAME...]` + + +**Usage:** + +```bash +cocoindex drop [OPTIONS] [APP_TARGET] [FLOW_NAME]... +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-f, --force` | Force drop without confirmation prompts. | +| `--help` | Show this message and exit. | + +--- + +### `evaluate` + +Evaluate the flow and dump flow outputs to files. + +Instead of updating the index, it dumps what should be indexed to files. +Mainly used for evaluation purpose. + +`APP_FLOW_SPECIFIER`: Specifies the application and optionally the target flow. Can be one of the following formats: +- `path/to/your_app.py` +- `an_installed.module_name` +- `path/to/your_app.py:SpecificFlowName` +- `an_installed.module_name:SpecificFlowName` + +`:SpecificFlowName` can be omitted only if the application defines a single +flow. + + +**Usage:** + +```bash +cocoindex evaluate [OPTIONS] APP_FLOW_SPECIFIER +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-o, --output-dir TEXT` | The directory to dump the output to. | +| `--cache / --no-cache` | Use already-cached intermediate data if available. [default: cache] | +| `--help` | Show this message and exit. | + +--- + +### `ls` + +List all flows. + +If `APP_TARGET` (`path/to/app.py` or a module) is provided, lists flows +defined in the app and their backend setup status. + +If `APP_TARGET` is omitted, lists all flows that have a persisted setup in +the backend. + + +**Usage:** + +```bash +cocoindex ls [OPTIONS] [APP_TARGET] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--help` | Show this message and exit. | + +--- + +### `server` + +Start a HTTP server providing REST APIs. + +It will allow tools like CocoInsight to access the server. + +`APP_TARGET`: `path/to/app.py` or `installed_module`. + + +**Usage:** + +```bash +cocoindex server [OPTIONS] APP_TARGET +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-a, --address TEXT` | The address to bind the server to, in the format of IP:PORT. If unspecified, the address specified in COCOINDEX_SERVER_ADDRESS will be used. | +| `-c, --cors-origin TEXT` | The origins of the clients (e.g. CocoInsight UI) to allow CORS from. Multiple origins can be specified as a comma-separated list. e.g. `https://cocoindex.io,http://localhost:3000`. Origins specified in COCOINDEX_SERVER_CORS_ORIGINS will also be included. | +| `-ci, --cors-cocoindex` | Allow `https://cocoindex.io` to access the server. | +| `-cl, --cors-local INTEGER` | Allow `http://localhost:` to access the server. | +| `-L, --live-update` | Continuously watch changes from data sources and apply to the target index. | +| `--setup` | Automatically setup backends for the flow if it's not setup yet. | +| `--reset` | Drop existing setup before starting server (equivalent to running 'cocoindex drop' first). `--reset` implies `--setup`. | +| `--reexport` | Reexport to targets even if there's no change. | +| `-f, --force` | Force setup without confirmation prompts. | +| `-q, --quiet` | Avoid printing anything to the standard output, e.g. statistics. | +| `-r, --reload` | Enable auto-reload on code changes. | +| `--help` | Show this message and exit. | + +--- + +### `setup` + +Check and apply backend setup changes for flows, including the internal +storage and target (to export to). + +`APP_TARGET`: `path/to/app.py` or `installed_module`. + + +**Usage:** + +```bash +cocoindex setup [OPTIONS] APP_TARGET +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-f, --force` | Force setup without confirmation prompts. | +| `--reset` | Drop existing setup before running setup (equivalent to running 'cocoindex drop' first). | +| `--help` | Show this message and exit. | + +--- + +### `show` + +Show the flow spec and schema. + +`APP_FLOW_SPECIFIER`: Specifies the application and optionally the target +flow. Can be one of the following formats: + +- `path/to/your_app.py` +- `an_installed.module_name` +- `path/to/your_app.py:SpecificFlowName` +- `an_installed.module_name:SpecificFlowName` + +`:SpecificFlowName` can be omitted only if the application defines a single +flow. + + +**Usage:** + +```bash +cocoindex show [OPTIONS] APP_FLOW_SPECIFIER +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--color / --no-color` | Enable or disable colored output. | +| `--verbose` | Show verbose output with full details. | +| `--help` | Show this message and exit. | + +--- + +### `update` + +Update the index to reflect the latest data from data sources. + +`APP_FLOW_SPECIFIER`: `path/to/app.py`, module, `path/to/app.py:FlowName`, +or `module:FlowName`. If `:FlowName` is omitted, updates all flows. + + +**Usage:** + +```bash +cocoindex update [OPTIONS] APP_FLOW_SPECIFIER +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `-L, --live` | Continuously watch changes from data sources and apply to the target index. | +| `--reexport` | Reexport to targets even if there's no change. | +| `--setup` | Automatically setup backends for the flow if it's not setup yet. | +| `--reset` | Drop existing setup before updating (equivalent to running 'cocoindex drop' first). `--reset` implies `--setup`. | +| `-f, --force` | Force setup without confirmation prompts. | +| `-q, --quiet` | Avoid printing anything to the standard output, e.g. statistics. | +| `--help` | Show this message and exit. | + +--- diff --git a/python/cocoindex/cli.py b/python/cocoindex/cli.py index 3a981400..45a6b9ea 100644 --- a/python/cocoindex/cli.py +++ b/python/cocoindex/cli.py @@ -2,6 +2,7 @@ import asyncio import datetime import importlib.util +import json import os import signal import threading @@ -83,9 +84,7 @@ def _load_user_app(app_target: str) -> None: try: load_user_app(app_target) except UserAppLoaderError as e: - raise click.ClickException( - f"Failed to load APP_TARGET '{app_target}': {e}" - ) from e + raise ValueError(f"Failed to load APP_TARGET '{app_target}'") from e add_user_app(app_target) @@ -137,11 +136,9 @@ def ls(app_target: str | None) -> None: """ List all flows. - If APP_TARGET (path/to/app.py or a module) is provided, lists flows - defined in the app and their backend setup status. + If `APP_TARGET` (`path/to/app.py` or a module) is provided, lists flows defined in the app and their backend setup status. - If APP_TARGET is omitted, lists all flows that have a persisted - setup in the backend. + If `APP_TARGET` is omitted, lists all flows that have a persisted setup in the backend. """ persisted_flow_names = flow_names_with_setup() if app_target: @@ -189,16 +186,15 @@ def show(app_flow_specifier: str, color: bool, verbose: bool) -> None: """ Show the flow spec and schema. - APP_FLOW_SPECIFIER: Specifies the application and optionally the target flow. - Can be one of the following formats: + `APP_FLOW_SPECIFIER`: Specifies the application and optionally the target flow. Can be one of the following formats: \b - - path/to/your_app.py - - an_installed.module_name - - path/to/your_app.py:SpecificFlowName - - an_installed.module_name:SpecificFlowName + - `path/to/your_app.py` + - `an_installed.module_name` + - `path/to/your_app.py:SpecificFlowName` + - `an_installed.module_name:SpecificFlowName` - :SpecificFlowName can be omitted only if the application defines a single flow. + `:SpecificFlowName` can be omitted only if the application defines a single flow. """ app_ref, flow_ref = _parse_app_flow_specifier(app_flow_specifier) _load_user_app(app_ref) @@ -220,6 +216,41 @@ def show(app_flow_specifier: str, color: bool, verbose: bool) -> None: console.print(table) +def _drop_flows(flows: Iterable[flow.Flow], app_ref: str, force: bool = False) -> None: + """ + Helper function to drop flows without user interaction. + Used internally by --reset flag + + Args: + flows: Iterable of Flow objects to drop + force: If True, skip confirmation prompts + """ + flow_full_names = ", ".join(fl.full_name for fl in flows) + click.echo( + f"Preparing to drop specified flows: {flow_full_names} (in '{app_ref}').", + err=True, + ) + + if not flows: + click.echo("No flows identified for the drop operation.") + return + + setup_bundle = flow.make_drop_bundle(flows) + description, is_up_to_date = setup_bundle.describe() + click.echo(description) + if is_up_to_date: + click.echo("No flows need to be dropped.") + return + if not force and not click.confirm( + f"\nThis will apply changes to drop setup for: {flow_full_names}. Continue? [yes/N]", + default=False, + show_default=False, + ): + click.echo("Drop operation aborted by user.") + return + setup_bundle.apply(report_to_stdout=True) + + def _setup_flows( flow_iter: Iterable[flow.Flow], *, @@ -269,14 +300,26 @@ async def _update_all_flows_with_hint_async( default=False, help="Force setup without confirmation prompts.", ) -def setup(app_target: str, force: bool) -> None: +@click.option( + "--reset", + is_flag=True, + show_default=True, + default=False, + help="Drop existing setup before running setup (equivalent to running 'cocoindex drop' first).", +) +def setup(app_target: str, force: bool, reset: bool) -> None: """ Check and apply backend setup changes for flows, including the internal storage and target (to export to). - APP_TARGET: path/to/app.py or installed_module. + `APP_TARGET`: `path/to/app.py` or `installed_module`. """ app_ref = _get_app_ref_from_specifier(app_target) _load_user_app(app_ref) + + # If --reset is specified, drop existing setup first + if reset: + _drop_flows(flow.flows().values(), app_ref=app_ref, force=force) + _setup_flows(flow.flows().values(), force=force, always_show_setup=True) @@ -325,30 +368,7 @@ def drop(app_target: str | None, flow_name: tuple[str, ...], force: bool) -> Non else: flows = flow.flows().values() - flow_full_names = ", ".join(fl.full_name for fl in flows) - click.echo( - f"Preparing to drop specified flows: {flow_full_names} (in '{app_ref}').", - err=True, - ) - - if not flows: - click.echo("No flows identified for the drop operation.") - return - - setup_bundle = flow.make_drop_bundle(flows) - description, is_up_to_date = setup_bundle.describe() - click.echo(description) - if is_up_to_date: - click.echo("No flows need to be dropped.") - return - if not force and not click.confirm( - f"\nThis will apply changes to drop setup for: {flow_full_names}. Continue? [yes/N]", - default=False, - show_default=False, - ): - click.echo("Drop operation aborted by user.") - return - setup_bundle.apply(report_to_stdout=True) + _drop_flows(flows, app_ref=app_ref, force=force) @cli.command() @@ -375,6 +395,13 @@ def drop(app_target: str | None, flow_name: tuple[str, ...], force: bool) -> Non default=False, help="Automatically setup backends for the flow if it's not setup yet.", ) +@click.option( + "--reset", + is_flag=True, + show_default=True, + default=False, + help="Drop existing setup before updating (equivalent to running 'cocoindex drop' first). `--reset` implies `--setup`.", +) @click.option( "-f", "--force", @@ -396,17 +423,24 @@ def update( live: bool, reexport: bool, setup: bool, # pylint: disable=redefined-outer-name + reset: bool, force: bool, quiet: bool, ) -> None: """ Update the index to reflect the latest data from data sources. - APP_FLOW_SPECIFIER: path/to/app.py, module, path/to/app.py:FlowName, or module:FlowName. - If :FlowName is omitted, updates all flows. + `APP_FLOW_SPECIFIER`: `path/to/app.py`, module, `path/to/app.py:FlowName`, or `module:FlowName`. If `:FlowName` is omitted, updates all flows. """ app_ref, flow_name = _parse_app_flow_specifier(app_flow_specifier) _load_user_app(app_ref) + flow_list = ( + [flow.flow_by_name(flow_name)] if flow_name else list(flow.flows().values()) + ) + + # If --reset is specified, drop existing setup first + if reset: + _drop_flows(flow_list, app_ref=app_ref, force=force) if live: click.secho( @@ -419,19 +453,14 @@ def update( reexport_targets=reexport, print_stats=not quiet, ) + if reset or setup: + _setup_flows(flow_list, force=force, quiet=quiet) + if flow_name is None: - if setup: - _setup_flows( - flow.flows().values(), - force=force, - quiet=quiet, - ) execution_context.run(_update_all_flows_with_hint_async(options)) else: - fl = flow.flow_by_name(flow_name) - if setup: - _setup_flows((fl,), force=force, quiet=quiet) - with flow.FlowLiveUpdater(fl, options) as updater: + assert len(flow_list) == 1 + with flow.FlowLiveUpdater(flow_list[0], options) as updater: updater.wait() if options.live_mode: _show_no_live_update_hint() @@ -459,18 +488,16 @@ def evaluate( """ Evaluate the flow and dump flow outputs to files. - Instead of updating the index, it dumps what should be indexed to files. - Mainly used for evaluation purpose. + Instead of updating the index, it dumps what should be indexed to files. Mainly used for evaluation purpose. \b - APP_FLOW_SPECIFIER: Specifies the application and optionally the target flow. - Can be one of the following formats: - - path/to/your_app.py - - an_installed.module_name - - path/to/your_app.py:SpecificFlowName - - an_installed.module_name:SpecificFlowName - - :SpecificFlowName can be omitted only if the application defines a single flow. + `APP_FLOW_SPECIFIER`: Specifies the application and optionally the target flow. Can be one of the following formats: + - `path/to/your_app.py` + - `an_installed.module_name` + - `path/to/your_app.py:SpecificFlowName` + - `an_installed.module_name:SpecificFlowName` + + `:SpecificFlowName` can be omitted only if the application defines a single flow. """ app_ref, flow_ref = _parse_app_flow_specifier(app_flow_specifier) _load_user_app(app_ref) @@ -529,6 +556,13 @@ def evaluate( default=False, help="Automatically setup backends for the flow if it's not setup yet.", ) +@click.option( + "--reset", + is_flag=True, + show_default=True, + default=False, + help="Drop existing setup before starting server (equivalent to running 'cocoindex drop' first). `--reset` implies `--setup`.", +) @click.option( "--reexport", is_flag=True, @@ -565,6 +599,7 @@ def server( address: str | None, live_update: bool, setup: bool, # pylint: disable=redefined-outer-name + reset: bool, reexport: bool, force: bool, quiet: bool, @@ -578,7 +613,7 @@ def server( It will allow tools like CocoInsight to access the server. - APP_TARGET: path/to/app.py or installed_module. + `APP_TARGET`: `path/to/app.py` or `installed_module`. """ app_ref = _get_app_ref_from_specifier(app_target) args = ( @@ -588,11 +623,14 @@ def server( cors_cocoindex, cors_local, live_update, - setup, reexport, - force, quiet, ) + kwargs = { + "run_reset": reset, + "run_setup": setup, + "force": force, + } if reload: watch_paths = {os.getcwd()} @@ -610,6 +648,7 @@ def server( *watch_paths, target=_reloadable_server_target, args=args, + kwargs=kwargs, watch_filter=watchfiles.PythonFilter(), callback=lambda changes: click.secho( f"\nDetected changes in {len(changes)} file(s), reloading server...\n", @@ -621,12 +660,19 @@ def server( "NOTE: Flow code changes will NOT be reflected until you restart to load the new code. Use --reload to enable auto-reload.\n", fg="yellow", ) - _run_server(*args) + _run_server(*args, **kwargs) def _reloadable_server_target(*args: Any, **kwargs: Any) -> None: """Reloadable target for the watchfiles process.""" _initialize_cocoindex_in_process() + + kwargs["run_setup"] = kwargs["run_setup"] or kwargs["run_reset"] + changed_files = json.loads(os.environ.get("WATCHFILES_CHANGES", "[]")) + if changed_files: + kwargs["run_reset"] = False + kwargs["force"] = True + _run_server(*args, **kwargs) @@ -637,10 +683,13 @@ def _run_server( cors_cocoindex: bool = False, cors_local: int | None = None, live_update: bool = False, - run_setup: bool = False, reexport: bool = False, - force: bool = False, quiet: bool = False, + /, + *, + force: bool = False, + run_reset: bool = False, + run_setup: bool = False, ) -> None: """Helper function to run the server with specified settings.""" _load_user_app(app_ref) @@ -664,6 +713,10 @@ def _run_server( ) raise click.Abort() + # If --reset is specified, drop existing setup first + if run_reset: + _drop_flows(flow.flows().values(), app_ref=app_ref, force=force) + server_settings = setting.ServerSettings.from_env() cors_origins: set[str] = set(server_settings.cors_origins or []) if cors_origin is not None: @@ -677,7 +730,7 @@ def _run_server( if address is not None: server_settings.address = address - if run_setup: + if run_reset or run_setup: _setup_flows( flow.flows().values(), force=force, From efdc55377b5afda86e39d75e6e4801616c838e2b Mon Sep 17 00:00:00 2001 From: belloibrahv Date: Wed, 22 Oct 2025 07:29:04 +0100 Subject: [PATCH 2/2] refactor: simplify blank line handling in extract_description Remove unnecessary last_was_empty tracking logic. Click's help formatter already produces well-formatted output, so we only need to: - Append non-empty stripped lines - Preserve blank lines for paragraph separation (if we have content) This addresses reviewer feedback and makes the code cleaner and more maintainable. --- dev/generate_cli_docs.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/dev/generate_cli_docs.py b/dev/generate_cli_docs.py index b3b2ff5b..2647c7bf 100644 --- a/dev/generate_cli_docs.py +++ b/dev/generate_cli_docs.py @@ -175,7 +175,6 @@ def extract_description(help_text: str) -> str: # Find the description between usage and options/commands description_lines = [] in_description = False - last_was_empty = False for line in lines: if line.startswith("Usage:"): @@ -187,12 +186,8 @@ def extract_description(help_text: str) -> str: stripped = line.strip() if stripped: description_lines.append(stripped) - last_was_empty = False - else: - # Blank line - preserve as paragraph separator, avoid duplicates - if description_lines and not last_was_empty: - description_lines.append("") - last_was_empty = True + elif description_lines: # Preserve blank line only if we have content + description_lines.append("") # Simply join with single newline - let Markdown handle paragraph formatting naturally description = "\n".join(description_lines) if description_lines else ""