From 458d798022867c17d952808076a5bb44411823ce Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 30 Jan 2026 18:15:52 +0000 Subject: [PATCH] Refactored scripts to remove any extra code from build-frontend.py and devserver.py (for better user editability). Individual helper functions can still be imported from scripts/fastapi-vue/ for any customization needs. Removed devmode support from CLI entry point simplifying it. Instead running via FastAPI CLI in dev mode (but it is still easy to modify to use a custom CLI entry). --- fastapi_vue_setup.py | 101 ++++--- template/backend/__main__.py | 30 +- template/scripts/devserver.py | 282 +++--------------- .../scripts/fastapi-vue/build-frontend.py | 27 +- template/scripts/fastapi-vue/util.py | 87 ------ 5 files changed, 122 insertions(+), 405 deletions(-) delete mode 100644 template/scripts/fastapi-vue/util.py diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index d0bb1c8..12dcdfa 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -13,6 +13,7 @@ Options: import argparse import os import re +import shutil import subprocess import sys from pathlib import Path @@ -176,7 +177,9 @@ def find_fastapi_app( return None -def _find_app_via_entrypoint(module_dir: Path, project_dir: Path) -> tuple[Path, str] | None: +def _find_app_via_entrypoint( + module_dir: Path, project_dir: Path +) -> tuple[Path, str] | None: """Find FastAPI app by following the CLI entrypoint in pyproject.toml. If pyproject.toml has a script like `myapp = "myapp.subpkg.__main__:main"`, @@ -266,7 +269,7 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str | # Look for any script that references our module for script_name, entry in scripts.items(): if isinstance(entry, str) and entry.startswith(f"{module_name}."): - return f"{script_name} = \"{entry}\"" + return f'{script_name} = "{entry}"' return None @@ -621,11 +624,14 @@ def write_file( dry_run: bool = False, executable: bool = False, fallback_path: Path | None = None, + force: bool = False, ) -> bool: """Write content to a file, handling existing files and dry-run. If fallback_path is provided and the file exists without the upgrade marker, the content will be written to fallback_path instead of being skipped. + + If force=True, always overwrite without checking for upgrade marker. """ exists = path.exists() if exists and not overwrite: @@ -639,8 +645,8 @@ def write_file( print(f"āœ”ļø {path} (already up to date)") return False - # If overwrite requested but file doesn't have upgrade marker - if overwrite and UPGRADE_MARKER not in existing_content: + # If overwrite requested but file doesn't have upgrade marker (unless force) + if overwrite and not force and UPGRADE_MARKER not in existing_content: if fallback_path is not None: # Write to fallback path instead return _write_fallback_file( @@ -720,6 +726,7 @@ def merge_pyproject( req = data["project"]["requires-python"] # Parse minimum version from strings like ">=3.10" or ">=3.9,<4" import re + match = re.search(r">=\s*(\d+)\.(\d+)", req) if match: major, minor = int(match.group(1)), int(match.group(2)) @@ -783,8 +790,6 @@ def find_js_runtime() -> tuple[str, str] | None: Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". Returns None if no runtime is found. """ - import shutil - options = ["deno", "npm", "bun"] # Check for JS_RUNTIME environment variable @@ -946,7 +951,9 @@ def cmd_setup(args: argparse.Namespace) -> int: fastapi_vue_scripts = scripts_dir / "fastapi-vue" # Find existing FastAPI app - app_info = find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None + app_info = ( + find_fastapi_app(module_dir, project_dir) if module_dir.exists() else None + ) if app_info: app_file, app_var = app_info @@ -962,30 +969,37 @@ def cmd_setup(args: argparse.Namespace) -> int: # Create directories if not dry_run: - fastapi_vue_scripts.mkdir(parents=True, exist_ok=True) if not module_dir.exists(): module_dir.mkdir(parents=True) + fastapi_vue_scripts.mkdir(parents=True, exist_ok=True) # === Install scripts (always update our own scripts) === - # util.py and build-frontend.py are internal and always overwritten + # Scripts in fastapi-vue folder are internal and always overwritten # devserver.py can be customized, so use fallback if needed - internal_script_files = [ - (fastapi_vue_scripts / "util.py", "scripts/fastapi-vue/util.py"), - ( - fastapi_vue_scripts / "build-frontend.py", - "scripts/fastapi-vue/build-frontend.py", - ), - ] - for dest_path, template_path in internal_script_files: - template = load_template(template_path) - content = render_template(template, **tpl_vars) - write_file( - dest_path, - content, - overwrite=True, - dry_run=dry_run, - ) + # Remove obsolete util.py if present + obsolete_util = fastapi_vue_scripts / "util.py" + if obsolete_util.exists(): + if dry_run: + print(f"[DRY RUN] Would remove obsolete {obsolete_util}") + else: + obsolete_util.unlink() + print(f"šŸ—‘ļø Removed obsolete {obsolete_util}") + + # Copy all files from the template's fastapi-vue folder + template_fastapi_vue_dir = TEMPLATE_DIR / "scripts" / "fastapi-vue" + for template_file in template_fastapi_vue_dir.iterdir(): + if template_file.is_file(): + dest_path = fastapi_vue_scripts / template_file.name + template = template_file.read_text() + content = render_template(template, **tpl_vars) + write_file( + dest_path, + content, + overwrite=True, + dry_run=dry_run, + force=True, # Internal files, always overwrite + ) # devserver.py - use fallback if customized by user devserver_path = scripts_dir / "devserver.py" @@ -1021,27 +1035,32 @@ def cmd_setup(args: argparse.Namespace) -> int: write_file(app_file_path, content, overwrite=False, dry_run=dry_run) # === Handle __main__.py === - # Skip if project already has a CLI entrypoint in pyproject.toml - existing_cli = _find_existing_cli_entrypoint(project_dir, module_name) - if existing_cli: - print(f"ā„¹ļø Using existing CLI entrypoint: {existing_cli}") - else: - main_file = module_dir / "__main__.py" - main_fallback = module_dir / "__main__.new.py" - template = load_template("backend/__main__.py") - main_content = render_template(template, **tpl_vars) + main_file = module_dir / "__main__.py" + main_fallback = module_dir / "__main__.new.py" + template = load_template("backend/__main__.py") + main_content = render_template(template, **tpl_vars) - # Use write_file with fallback - it handles all cases: - # - File doesn't exist: create it - # - File exists with marker: update it - # - File exists without marker: write to fallback + # Check if project already has a CLI entrypoint in pyproject.toml + existing_cli = _find_existing_cli_entrypoint(project_dir, module_name) + + if main_file.exists(): + # File exists: update if it has the auto-upgrade marker, otherwise use fallback write_file( main_file, main_content, - overwrite=True, + overwrite=True, dry_run=dry_run, fallback_path=main_fallback, ) + elif not existing_cli: + # No file and no existing entrypoint: create new __main__.py + write_file( + main_file, + main_content, + overwrite=False, + dry_run=dry_run, + ) + # else: no file but has existing entrypoint - don't create (user has custom CLI setup) # === Update vite.config.js/ts === frontend_dir = project_dir / "frontend" @@ -1125,7 +1144,9 @@ def cmd_setup(args: argparse.Namespace) -> int: else: nl = b"\r\n" if b"\r\n" in gitignore_content else b"\n" suffix = b"" if gitignore_content.endswith(nl) else nl - gitignore_path.write_bytes(gitignore_content + suffix + gitignore_entry.encode() + nl) + gitignore_path.write_bytes( + gitignore_content + suffix + gitignore_entry.encode() + nl + ) print(f"āœ… Added {gitignore_entry} to .gitignore") elif dry_run: print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}") diff --git a/template/backend/__main__.py b/template/backend/__main__.py index f5493ad..7ed2aa5 100644 --- a/template/backend/__main__.py +++ b/template/backend/__main__.py @@ -3,35 +3,24 @@ import argparse import asyncio import os -import uvicorn from fastapi_vue.hostutil import parse_endpoint from uvicorn import Config, Server -from .APP_MODULE import APP_VAR - DEFAULT_PORT = 5080 -def run_server(endpoints: list[dict], *, proxy="", devmode=False): +def run_server(endpoints: list[dict], *, proxy=""): conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"} if proxy: conf["proxy_headers"] = True conf["forwarded_allow_ips"] = proxy - if devmode: - conf["reload"] = True - conf["reload_dirs"] = ["MODULE_NAME"] - APP_VAR.debug = True - if len(endpoints) > 1: - # Run separate servers for multiple endpoints - async def serve_all(): - async with asyncio.TaskGroup() as tg: - for ep in endpoints: - tg.create_task(Server(Config(**conf, **ep)).serve()) + async def serve_all(): + async with asyncio.TaskGroup() as tg: + for ep in endpoints: + tg.create_task(Server(Config(**conf, **ep)).serve()) - asyncio.run(serve_all()) - else: - uvicorn.run(**conf, **endpoints[0]) + asyncio.run(serve_all()) def main(): @@ -46,9 +35,10 @@ def main(): ) args = parser.parse_args() proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") - devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL")) - endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT) - run_server(endpoints, proxy=proxy, devmode=devmode) + try: + run_server(parse_endpoint(args.endpoint, DEFAULT_PORT), proxy=proxy) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/template/scripts/devserver.py b/template/scripts/devserver.py index 258e5d0..f36dbde 100755 --- a/template/scripts/devserver.py +++ b/template/scripts/devserver.py @@ -1,242 +1,64 @@ #!/usr/bin/env -S uv run # auto-upgrade@fastapi-vue-setup - remove this if you modify this file -"""Run Vite development server for frontend and FastAPI backend with auto-reload. - -Usage: - uv run scripts/devserver.py [host:port] [--backend host:port] - -The optional host:port argument sets where the Vite frontend listens. -Supported forms: host[:port], :port (all interfaces), or just port. -The --backend option sets where the FastAPI backend listens (default: localhost:5180). - -Environment: - JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun). - FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is. -""" +"""Run Vite development server for frontend and FastAPI backend with auto-reload.""" import argparse import asyncio import contextlib import os +import sys from pathlib import Path -from sys import stderr -import httpx -from fastapi_vue.hostutil import parse_endpoint - -exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 - -DEFAULT_VITE_PORT = 5173 -DEFAULT_BACKEND_PORT = 5180 -FRONTEND_PATH = Path(__file__).parent.parent / "frontend" - -EPILOG = """ - scripts/devserver.py # Default ports on localhost - scripts/devserver.py 3000 # Vite on localhost:3000 - scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000 -""" - -BUN_BUG = """\ -ā”ƒ āš ļø Bun cannot correctly proxy API requests to the backend. -ā”ƒ Bug report: https://github.com/oven-sh/bun/issues/9882 -ā”ƒ -ā”ƒ Consider using deno or npm instead for development. -""" +# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path) +sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) +from devutil import ( # type: ignore + ProcessGroup, + logger, + ready, + setup_fastapi, + setup_vite, +) -def resolve_frontend_tools( - vite_port: int, all_ifaces: bool -) -> tuple[list[str], list[str], str]: - """Resolve frontend install and dev commands. - - Returns (install_cmd, dev_cmd, tool_name). - Raises SystemExit if tools are not available. - """ - if not (FRONTEND_PATH / "package.json").exists(): - stderr.write(f"ā”ƒ āš ļø Frontend source not found at {FRONTEND_PATH}\n") +async def run_devserver(frontend: str, backend: str) -> None: + reporoot = Path(__file__).parent.parent + front = reporoot / "frontend" + if not (front / "package.json").exists(): + logger.warning("Frontend source not found at %s", front) raise SystemExit(1) - result = find_js_runtime() # noqa # type: ignore - if result is None: - if not os.environ.get("JS_RUNTIME"): - stderr.write("ā”ƒ āš ļø deno, npm or bun needed to run the frontend server.\n") - raise SystemExit(1) + frontend_url, npm_install, vite = setup_vite(frontend) + backend_url, fastapi = setup_fastapi(backend, "MODULE_NAME.APP_MODULE:APP_VAR") - tool, name = result + # Tell the everyone where the frontend and backend are (vite proxy, etc) + os.environ["FASTAPI_VUE_FRONTEND_URL"] = frontend_url + os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url - install_args = { - "deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"), - "npm": ("install", "--silent"), - "bun": ("install", "--silent"), - } - dev_args = { - "deno": ("run", "dev", "--"), - "npm": ("--silent", "run", "dev", "--"), - "bun": ("run", "dev", "--"), - } - - install_cmd = [tool, *install_args[name]] - dev_cmd = [ - tool, - *dev_args[name], - "--clearScreen=false", - f"--port={vite_port}", - ] - - if all_ifaces: - dev_cmd.append("--host") - - if name == "bun": - stderr.write(BUN_BUG) - - return install_cmd, dev_cmd, name - - -async def wait_for_backend(host: str, port: int): - """Wait for the backend to be ready by polling the health endpoint.""" - max_attempts = 50 - url = f"http://{host}:{port}" - - async with httpx.AsyncClient() as client: - for attempt in range(max_attempts): - try: - await client.get(url, timeout=1.0) - stderr.write("āœ“ Backend ready!\n") - return True - except httpx.RequestError: - if attempt == max_attempts - 1: - stderr.write("ā”ƒ āš ļø Backend didn't start in time\n") - return False - await asyncio.sleep(0.1) - return False - - -async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None: - """Gracefully terminate a subprocess.""" - if proc.returncode is not None: - return - try: - proc.terminate() - except ProcessLookupError: - return - try: - await asyncio.wait_for(proc.wait(), timeout=2) - except TimeoutError: - try: - proc.kill() - except ProcessLookupError: - return - await proc.wait() - - -async def run_devserver( - vite_port: int, - all_ifaces: bool, - backend_host: str, - backend_port: int, -) -> None: - """Run the development server with install, backend, and frontend.""" - install_cmd, dev_cmd, tool_name = resolve_frontend_tools(vite_port, all_ifaces) - - # Tell the backend where the Vite dev server is - os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://localhost:{vite_port}" - # Tell Vite where the backend is (for proxying /api requests) - os.environ["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}" - - backend_cmd = [ - "uvicorn", - "MODULE_NAME.app:app", - "--host", - backend_host, - "--port", - str(backend_port), - "--reload", - ] - - cwd = str(Path(__file__).parent.parent) - frontend_cwd = str(FRONTEND_PATH) - - backend_proc: asyncio.subprocess.Process | None = None - install_proc: asyncio.subprocess.Process | None = None - frontend_proc: asyncio.subprocess.Process | None = None - - try: - # Start install (concurrent with backend) - stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n") - install_proc = await asyncio.create_subprocess_exec( - *install_cmd, cwd=frontend_cwd + async with ProcessGroup() as pg: + install_proc = await pg.spawn(*npm_install, cwd=front) + await asyncio.sleep(0.2) # reduce message overlap + await pg.spawn( + *fastapi, + "--reload", + "--reload-dir=MODULE_NAME", # Don't reload on frontend changes + "--forwarded-allow-ips=*", + cwd=reporoot, ) - await asyncio.sleep(0.1) + # Wait for both install and backend to be ready + async with asyncio.TaskGroup() as tg: + tg.create_task(pg.wait(install_proc)) + tg.create_task(ready(backend_url, path="/api/health?from=devserver.py")) - # Start backend (concurrent with install) - stderr.write(f">>> {' '.join(backend_cmd)}\n") - backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd) - - # Wait for install to complete and backend to be ready - install_task = asyncio.create_task(install_proc.wait(), name="install") - backend_ready_task = asyncio.create_task( - wait_for_backend(backend_host, backend_port), name="backend_ready" - ) - - done, pending = await asyncio.wait( - {install_task, backend_ready_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - - for task in done: - if task.get_name() == "install": - if task.result() != 0: - stderr.write("ā”ƒ āš ļø Install failed\n") - raise SystemExit(1) - elif task.get_name() == "backend_ready" and not task.result(): - raise SystemExit(1) - - if pending: - done2, _ = await asyncio.wait(pending) - for task in done2: - if task.get_name() == "install": - if task.result() != 0: - stderr.write("ā”ƒ āš ļø Install failed\n") - raise SystemExit(1) - elif task.get_name() == "backend_ready" and not task.result(): - raise SystemExit(1) - - install_proc = None - - # Start Vite dev server - stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n") - frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd) - - # Wait for either process to exit - done, pending = await asyncio.wait( - { - asyncio.create_task(backend_proc.wait(), name="backend"), - asyncio.create_task(frontend_proc.wait(), name="frontend"), - }, - return_when=asyncio.FIRST_COMPLETED, - ) - for t in done: - t.result() - for t in pending: - t.cancel() - - except asyncio.CancelledError: - stderr.write("\nāœ“ Shutting down...\n") - finally: - if frontend_proc is not None: - await _terminate_process(frontend_proc, "frontend") - if install_proc is not None: - await _terminate_process(install_proc, "install") - if backend_proc is not None: - await _terminate_process(backend_proc, "backend") + # Start Vite dev server (ProcessGroup waits for any exit, then terminates others) + await pg.spawn(*vite, cwd=front) def main(): parser = argparse.ArgumentParser( description="Run Vite and FastAPI development servers", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=EPILOG, + epilog=HELP_EPILOG, ) parser.add_argument( "frontend", @@ -250,27 +72,17 @@ def main(): help="FastAPI backend endpoint (default: localhost:5180)", ) args = parser.parse_args() - - # parse_endpoint returns list of dicts with host/port or uds keys - # Multiple entries means bind all interfaces (IPv4 + IPv6) - vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT) - backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT) - - # Vite doesn't support unix sockets - if "uds" in vite_endpoints[0]: - stderr.write("ā”ƒ āš ļø Unix sockets not supported for frontend\n") - raise SystemExit(1) - if "uds" in backend_endpoints[0]: - stderr.write("ā”ƒ āš ļø Unix sockets not supported for backend\n") - raise SystemExit(1) - - vite_port = vite_endpoints[0]["port"] - all_ifaces = len(vite_endpoints) > 1 - backend_host = backend_endpoints[0]["host"] - backend_port = backend_endpoints[0]["port"] - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port)) + asyncio.run(run_devserver(args.frontend, args.backend)) + + +HELP_EPILOG = """ + scripts/devserver.py # Default ports on localhost + scripts/devserver.py 3000 # Vite on localhost:3000 + scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000 + + JS_RUNTIME environment variable can be used to select the JS runtime +""" if __name__ == "__main__": diff --git a/template/scripts/fastapi-vue/build-frontend.py b/template/scripts/fastapi-vue/build-frontend.py index 7fccf65..152c48c 100644 --- a/template/scripts/fastapi-vue/build-frontend.py +++ b/template/scripts/fastapi-vue/build-frontend.py @@ -1,34 +1,15 @@ """Hatch build hook for building Vue frontend during package build.""" -import subprocess +import sys from pathlib import Path -from sys import stderr from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore -exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102 - - -def run(cmd, **kwargs): - """Run a command and display it.""" - display_cmd = [Path(cmd[0]).name, *cmd[1:]] - stderr.write(f"### {' '.join(display_cmd)}\n") - subprocess.run(cmd, check=True, **kwargs) +sys.path.insert(0, str(Path(__file__).parent)) +from buildutil import build class CustomBuildHook(BuildHookInterface): - """Build hook that compiles Vue frontend before packaging.""" - def initialize(self, version, build_data): super().initialize(version, build_data) - stderr.write(">>> Building the frontend\n") - - install_cmd, build_cmd = find_build_tool() # noqa # type: ignore - - try: - run(install_cmd, cwd="frontend") - stderr.write("\n") - run(build_cmd, cwd="frontend") - except Exception as e: - stderr.write(f"Error occurred while building frontend: {e}\n") - raise + build("frontend") diff --git a/template/scripts/fastapi-vue/util.py b/template/scripts/fastapi-vue/util.py deleted file mode 100644 index 236b2c5..0000000 --- a/template/scripts/fastapi-vue/util.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Shared utilities for build and dev scripts.""" - -import os -import shutil -from pathlib import Path -from sys import stderr - - -def find_js_runtime() -> tuple[str, str] | None: - """Find a JavaScript runtime from JS_RUNTIME env or auto-detect. - - Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun". - Returns None if no runtime is found. - """ - options = ["deno", "npm", "bun"] - - # Check for JS_RUNTIME environment variable - if js_runtime_env := os.environ.get("JS_RUNTIME"): - js_runtime = js_runtime_env - js_path = Path(js_runtime) - runtime_name = js_path.name - # Map node to npm - if runtime_name == "node": - runtime_name = "npm" - js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm" - for option in options: - if option == runtime_name or runtime_name.startswith(option): - tool = shutil.which(js_runtime) - if tool is None: - stderr.write(f"ā”ƒ āš ļø JS_RUNTIME={js_runtime_env} not found\n") - return None - return tool, option - stderr.write(f"ā”ƒ āš ļø JS_RUNTIME={js_runtime_env} not recognized\n") - return None - - # Auto-detect - for option in options: - if tool := shutil.which(option): - return tool, option - return None - - -def find_build_tool(): - """Find JavaScript runtime and construct install/build commands. - - Returns (install_cmd, build_cmd) tuples of command lists. - Raises RuntimeError if no runtime is found. - """ - install = { - "deno": ("install", "--allow-scripts=npm:vue-demi"), - "npm": ("install",), - "bun": ("--bun", "install"), - } - # Run vite directly for deno to avoid npm-run-all2/run-p issues - build = { - "deno": ("run", "-A", "npm:vite", "build"), - "npm": ("run", "build"), - "bun": ("--bun", "run", "build"), - } - - result = find_js_runtime() - if result is None: - raise RuntimeError( - "Deno, npm or Bun is required for building but none was found" - ) - - tool, name = result - return [tool, *install[name]], [tool, *build[name]] - - -def find_dev_tool(): - """Find JavaScript runtime and construct dev command. - - Returns (dev_cmd, tool_name) or (None, None) if not found. - """ - dev_args = { - "deno": ("run", "dev", "--"), - "npm": ("--silent", "run", "dev", "--"), - "bun": ("run", "dev", "--"), - } - - result = find_js_runtime() - if result is None: - return None, None - - tool, name = result - return [tool, *dev_args[name]], name