From 372d249a858e3c85e5164455abac189ce19f789e Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 27 Oct 2026 22:19:22 +0000 Subject: [PATCH] Fix setup for new uv init layouts; use backend/ package dir for new projects --- fastapi_vue_setup.py | 177 +++++++++++++++------ template/frontend/vite-plugin-fastapi.d.ts | 6 + template/frontend/vite-plugin-fastapi.js | 2 +- 3 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 template/frontend/vite-plugin-fastapi.d.ts diff --git a/fastapi_vue_setup.py b/fastapi_vue_setup.py index 32d7c2b..2039819 100644 --- a/fastapi_vue_setup.py +++ b/fastapi_vue_setup.py @@ -138,18 +138,23 @@ DEFAULT_HEALTH = "/api/health?from=devserver.py" UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup" # pyproject.toml additions for patched projects +# packages/only-include use the package path relative to the project root +# (MODULE_NAME placeholder), so both flat and nested layouts work; the wheel +# target strips the leading directories, mapping e.g. backend/my_app -> my_app PYPROJECT_ADDITIONS = { "tool": { "hatch": { "build": { - "packages": ["MODULE_NAME"], "artifacts": ["MODULE_NAME/frontend-build"], "targets": { + "wheel": { + "packages": ["MODULE_NAME"], + }, "sdist": { + "only-include": ["MODULE_NAME", "scripts/fastapi-vue"], "hooks": {"custom": {"path": "scripts/fastapi-vue/buildhook.py"}}, - } + }, }, - "only-packages": True, } } }, @@ -368,6 +373,19 @@ def find_module_name(project_dir: Path) -> str | None: return None +def find_module_dir(project_dir: Path, module_name: str) -> Path: + """Locate the Python package directory. + + Supports flat (), src/, and backend/ layouts. + For new projects (no package yet), defaults to the backend/ layout. + """ + for sub in (module_name, f"src/{module_name}", f"backend/{module_name}"): + candidate = project_dir / sub + if candidate.is_dir(): + return candidate + return project_dir / "backend" / module_name + + def find_fastapi_app(module_dir: Path, project_dir: Path | None = None) -> tuple[Path, str] | None: """Find the FastAPI app in a module directory. @@ -1180,13 +1198,26 @@ def _write_fallback_file( def merge_pyproject( - data: tomlkit.TOMLDocument, additions: dict, module_name: str + data: tomlkit.TOMLDocument, additions: dict, module_rel: str ) -> tomlkit.TOMLDocument: - """Merge additions into pyproject.toml data, preserving comments and existing values.""" + """Merge additions into pyproject.toml data, preserving comments and existing values. + + module_rel is the package path relative to the project root (e.g. "backend/my_app"). + """ # Ensure hatchling build system is configured if "build-system" not in data: data["build-system"] = tomlkit.table() + # Replace uv_build with hatchling: the frontend build hook and artifacts + # handling require hatch, which uv_build does not support + if data["build-system"].get("build-backend") == "uv_build": + data["build-system"]["requires"] = ["hatchling"] + data["build-system"]["build-backend"] = "hatchling.build" + uv_tool = data.get("tool", {}).get("uv", {}) if "tool" in data else {} + if "build-backend" in uv_tool: + del data["tool"]["uv"]["build-backend"] + print("â„šī¸ Replaced uv_build with hatchling (required for the frontend build hook)") + # Add hatchling to requires if not present, preserving existing requires if "requires" not in data["build-system"]: data["build-system"]["requires"] = ["hatchling"] @@ -1226,38 +1257,45 @@ def merge_pyproject( hatch_build = data["tool"]["hatch"]["build"] hatch_additions = additions["tool"]["hatch"]["build"] - # Set packages if not already set - if "packages" not in hatch_build: - hatch_build["packages"] = [ - p.replace("MODULE_NAME", module_name) for p in hatch_additions["packages"] - ] - # Set artifacts if not already set if "artifacts" not in hatch_build: hatch_build["artifacts"] = [ - a.replace("MODULE_NAME", module_name) for a in hatch_additions["artifacts"] + a.replace("MODULE_NAME", module_rel) for a in hatch_additions["artifacts"] ] - # Set only-packages if not already set - if "only-packages" not in hatch_build: - hatch_build["only-packages"] = hatch_additions["only-packages"] - - # Add sdist target with custom hook + # Add wheel/sdist targets if "targets" not in hatch_build: hatch_build["targets"] = tomlkit.table() - if "sdist" not in hatch_build["targets"]: - hatch_build["targets"]["sdist"] = tomlkit.table() - if "hooks" not in hatch_build["targets"]["sdist"]: - hatch_build["targets"]["sdist"]["hooks"] = tomlkit.table() - if "custom" not in hatch_build["targets"]["sdist"]["hooks"]: - hatch_build["targets"]["sdist"]["hooks"]["custom"] = tomlkit.table() - if "path" not in hatch_build["targets"]["sdist"]["hooks"]["custom"]: - hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = hatch_additions["targets"][ - "sdist" - ]["hooks"]["custom"]["path"] - elif hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] == OLD_BUILD_HOOK_PATH: + targets = hatch_build["targets"] + target_additions = hatch_additions["targets"] + + # Wheel: set packages if neither the wheel target nor the top-level + # hatch build config (older setups) defines them + if "wheel" not in targets: + targets["wheel"] = tomlkit.table() + if "packages" not in targets["wheel"] and "packages" not in hatch_build: + targets["wheel"]["packages"] = [ + p.replace("MODULE_NAME", module_rel) for p in target_additions["wheel"]["packages"] + ] + + # Sdist: only-include (keeps the package directory prefix so wheels built + # from the sdist find the same layout) plus the frontend build hook + if "sdist" not in targets: + targets["sdist"] = tomlkit.table() + sdist = targets["sdist"] + if "only-include" not in sdist: + sdist["only-include"] = [ + p.replace("MODULE_NAME", module_rel) for p in target_additions["sdist"]["only-include"] + ] + if "hooks" not in sdist: + sdist["hooks"] = tomlkit.table() + if "custom" not in sdist["hooks"]: + sdist["hooks"]["custom"] = tomlkit.table() + if "path" not in sdist["hooks"]["custom"]: + sdist["hooks"]["custom"]["path"] = target_additions["sdist"]["hooks"]["custom"]["path"] + elif sdist["hooks"]["custom"]["path"] == OLD_BUILD_HOOK_PATH: # Migrate old build hook path to new name - hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = NEW_BUILD_HOOK_PATH + sdist["hooks"]["custom"]["path"] = NEW_BUILD_HOOK_PATH return data @@ -1308,17 +1346,20 @@ def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool: return True if dry: - print(f"đŸ“Ļ Would run: uv init {project_dir}") + print(f"đŸ“Ļ Would run: uv init --bare --build-backend hatchling {project_dir}") return True print("đŸ“Ļ No pyproject.toml found, initializing Python project...") - print(">>> uv init") - result = subprocess.run(["uv", "init", str(project_dir)], check=False) # noqa: S603, S607 + print(">>> uv init --bare --build-backend hatchling") + result = subprocess.run( # noqa: S603 + ["uv", "init", "--bare", "--vcs", "git", "--build-backend", "hatchling", str(project_dir)], # noqa: S607 + check=False, + ) if result.returncode != 0: print("❌ uv init failed") return False - # Remove files created by uv init that we don't need + # Remove boilerplate files created by older uv init versions for filename in ["hello.py", "main.py", ".python-version"]: filepath = project_dir / filename if filepath.exists(): @@ -1412,6 +1453,7 @@ def cmd_setup(args: argparse.Namespace) -> int: return 1 # Step 2: Ensure Python project exists + fresh_project = not (project_dir / "pyproject.toml").exists() if not ensure_python_project(project_dir, dry=dry): return 1 @@ -1424,7 +1466,10 @@ def cmd_setup(args: argparse.Namespace) -> int: else: print(f"đŸ“Ļ Module: {module_name}") - module_dir = project_dir / module_name + module_dir = find_module_dir(project_dir, module_name) + module_rel = module_dir.relative_to(project_dir).as_posix() + if module_rel != module_name: + print(f"📂 Package layout: {module_rel}/") scripts_dir = project_dir / "scripts" fastapi_vue_scripts = scripts_dir / "fastapi-vue" @@ -1436,8 +1481,9 @@ def cmd_setup(args: argparse.Namespace) -> int: if existing_cli_module: print(f"â„šī¸ Using existing CLI: {existing_cli_module}") - # Resolve the CLI main module to a file path - main = project_dir / Path(*main_module_path.split(".")).with_suffix(".py") + # Resolve the CLI main module to a file path (relative to the import root, + # i.e. the parent of the package directory) + main = module_dir.parent / Path(*main_module_path.split(".")).with_suffix(".py") # Determine port configuration # Priority: --ports argument > existing project values > defaults @@ -1485,6 +1531,7 @@ def cmd_setup(args: argparse.Namespace) -> int: # Template variables tpl_vars = { "MODULE_NAME": module_name, + "MODULE_DIR": module_rel, "PROJECT_TITLE": project_title, "TEMPLATE_DEFAULT_PORT": str(default_port), "TEMPLATE_VITE_PORT": str(vite_port), @@ -1499,8 +1546,8 @@ def cmd_setup(args: argparse.Namespace) -> int: app_file, app_var = app_info print(f"📍 Found FastAPI app: {app_var} in {app_file.name}") tpl_vars["APP_VAR"] = app_var - # Dotted module path relative to project dir (e.g. "{module_name}.api.main") - app_module = ".".join(app_file.relative_to(project_dir).with_suffix("").parts) + # Dotted module path (e.g. "{module_name}.api.main") + app_module = ".".join(app_file.relative_to(module_dir.parent).with_suffix("").parts) tpl_vars["APP_MODULE"] = app_module else: print("📍 No existing FastAPI app found, will create new one") @@ -1583,12 +1630,15 @@ def cmd_setup(args: argparse.Namespace) -> int: ) else: # No app: create full app.py - # Create __init__.py if missing + # Create __init__.py if missing (overwrite uv init hello-world boilerplate) init_file = module_dir / "__init__.py" - if not init_file.exists(): + boilerplate = init_file.exists() and re.search( + r'^def main\(\) -> None:\s*\n\s*print\("Hello from', init_file.read_text("UTF-8") + ) + if not init_file.exists() or boilerplate: template = load_template("backend/__init__.py") content = render_template(template, **tpl_vars) - write_file(init_file, content, overwrite=False, dry=dry) + write_file(init_file, content, overwrite=False, dry=dry, force=boilerplate) # Create app.py app_file_path = module_dir / "app.py" @@ -1669,9 +1719,10 @@ def cmd_setup(args: argparse.Namespace) -> int: plugin_file = frontend_dir / "vite-plugin-fastapi.js" # Upgrade old plugin versions that lack the auto-upgrade marker _upgrade_old_vite_plugin(plugin_file, module_name, dry=dry) - template = load_template("frontend/vite-plugin-fastapi.js") - content = render_template(template, **tpl_vars) - write_file(plugin_file, content, overwrite=True, dry=dry) + for plugin_template in ("vite-plugin-fastapi.js", "vite-plugin-fastapi.d.ts"): + template = load_template(f"frontend/{plugin_template}") + content = render_template(template, **tpl_vars) + write_file(frontend_dir / plugin_template, content, overwrite=True, dry=dry) # Find existing vite config (prefer .ts, fall back to .js) vite_config_ts = frontend_dir / "vite.config.ts" @@ -1694,13 +1745,29 @@ def cmd_setup(args: argparse.Namespace) -> int: old_content = pyproject_path.read_text("UTF-8") data = tomlkit.parse(old_content) - updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_name) + updated = merge_pyproject(data, PYPROJECT_ADDITIONS, module_rel) - # Only add script entry if project doesn't already have scripts - if "scripts" not in updated["project"] or not updated["project"]["scripts"]: + # Ensure a script entry pointing at our __main__:main + scripts = updated["project"].get("scripts") + main_entry = f"{module_name}.__main__:main" + if not scripts: updated["project"]["scripts"] = tomlkit.table() - script_name = module_name.replace("_", "-") - updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main" + updated["project"]["scripts"][module_name.replace("_", "-")] = main_entry + else: + has_our_entry = any( + isinstance(entry, str) and entry.startswith(f"{module_name}.") + for entry in scripts.values() + ) + if not has_our_entry: + # Rewrite uv init boilerplate ("my-app = \"my_app:main\"") if present + boilerplate = next( + (name for name, entry in scripts.items() if entry == f"{module_name}:main"), + None, + ) + if boilerplate: + scripts[boilerplate] = main_entry + else: + scripts[module_name.replace("_", "-")] = main_entry # Check if content actually changed new_content = tomlkit.dumps(updated) @@ -1715,8 +1782,16 @@ def cmd_setup(args: argparse.Namespace) -> int: # === Update .gitignore === gitignore_path = project_dir / ".gitignore" - gitignore_entry = f"/{module_name}/frontend-build" - if gitignore_path.exists(): + gitignore_entry = f"/{module_rel}/frontend-build" + if fresh_project: + # New project: opinionated .gitignore (ignore dotfiles and lockfiles) + content = f"{gitignore_entry}\n.*\n!.gitignore\n*.lock\n" + if dry: + print(f"✅ Would create opinionated .gitignore in {project_dir}") + else: + gitignore_path.write_text(content, "UTF-8", newline="\n") + print("✅ Created .gitignore") + elif gitignore_path.exists(): gitignore_content = gitignore_path.read_bytes() if b"frontend-build" in gitignore_content: print("âœ”ī¸ .gitignore (frontend-build already ignored)") diff --git a/template/frontend/vite-plugin-fastapi.d.ts b/template/frontend/vite-plugin-fastapi.d.ts new file mode 100644 index 0000000..e7f3832 --- /dev/null +++ b/template/frontend/vite-plugin-fastapi.d.ts @@ -0,0 +1,6 @@ +// auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin +import type { Plugin } from 'vite' + +declare function fastapiVue(options?: { paths?: string[] }): Plugin + +export default fastapiVue diff --git a/template/frontend/vite-plugin-fastapi.js b/template/frontend/vite-plugin-fastapi.js index 9e18944..a571372 100644 --- a/template/frontend/vite-plugin-fastapi.js +++ b/template/frontend/vite-plugin-fastapi.js @@ -30,7 +30,7 @@ export default function fastapiVue({ paths = ['/api'] } = {}) { clearScreen: false, server: { proxy }, build: { - outDir: '../MODULE_NAME/frontend-build', + outDir: '../MODULE_DIR/frontend-build', emptyOutDir: true, }, }),