Fix setup for new uv init layouts; use backend/ package dir for new projects
This commit is contained in:
1 parent
a19c9028c5
commit
372d249a85
3 files changed
+133
-52
No files matched your search
+126
-51
@@ -138,18 +138,23 @@ DEFAULT_HEALTH = "/api/health?from=devserver.py"
|
|||||||
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
UPGRADE_MARKER = "auto-upgrade@fastapi-vue-setup"
|
||||||
|
|
||||||
# pyproject.toml additions for patched projects
|
# 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 = {
|
PYPROJECT_ADDITIONS = {
|
||||||
"tool": {
|
"tool": {
|
||||||
"hatch": {
|
"hatch": {
|
||||||
"build": {
|
"build": {
|
||||||
"packages": ["MODULE_NAME"],
|
|
||||||
"artifacts": ["MODULE_NAME/frontend-build"],
|
"artifacts": ["MODULE_NAME/frontend-build"],
|
||||||
"targets": {
|
"targets": {
|
||||||
|
"wheel": {
|
||||||
|
"packages": ["MODULE_NAME"],
|
||||||
|
},
|
||||||
"sdist": {
|
"sdist": {
|
||||||
|
"only-include": ["MODULE_NAME", "scripts/fastapi-vue"],
|
||||||
"hooks": {"custom": {"path": "scripts/fastapi-vue/buildhook.py"}},
|
"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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_module_dir(project_dir: Path, module_name: str) -> Path:
|
||||||
|
"""Locate the Python package directory.
|
||||||
|
|
||||||
|
Supports flat (<module>), src/<module>, and backend/<module> 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:
|
def find_fastapi_app(module_dir: Path, project_dir: Path | None = None) -> tuple[Path, str] | None:
|
||||||
"""Find the FastAPI app in a module directory.
|
"""Find the FastAPI app in a module directory.
|
||||||
|
|
||||||
@@ -1180,13 +1198,26 @@ def _write_fallback_file(
|
|||||||
|
|
||||||
|
|
||||||
def merge_pyproject(
|
def merge_pyproject(
|
||||||
data: tomlkit.TOMLDocument, additions: dict, module_name: str
|
data: tomlkit.TOMLDocument, additions: dict, module_rel: str
|
||||||
) -> tomlkit.TOMLDocument:
|
) -> 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
|
# Ensure hatchling build system is configured
|
||||||
if "build-system" not in data:
|
if "build-system" not in data:
|
||||||
data["build-system"] = tomlkit.table()
|
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
|
# Add hatchling to requires if not present, preserving existing requires
|
||||||
if "requires" not in data["build-system"]:
|
if "requires" not in data["build-system"]:
|
||||||
data["build-system"]["requires"] = ["hatchling"]
|
data["build-system"]["requires"] = ["hatchling"]
|
||||||
@@ -1226,38 +1257,45 @@ def merge_pyproject(
|
|||||||
hatch_build = data["tool"]["hatch"]["build"]
|
hatch_build = data["tool"]["hatch"]["build"]
|
||||||
hatch_additions = additions["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
|
# Set artifacts if not already set
|
||||||
if "artifacts" not in hatch_build:
|
if "artifacts" not in hatch_build:
|
||||||
hatch_build["artifacts"] = [
|
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
|
# Add wheel/sdist targets
|
||||||
if "only-packages" not in hatch_build:
|
|
||||||
hatch_build["only-packages"] = hatch_additions["only-packages"]
|
|
||||||
|
|
||||||
# Add sdist target with custom hook
|
|
||||||
if "targets" not in hatch_build:
|
if "targets" not in hatch_build:
|
||||||
hatch_build["targets"] = tomlkit.table()
|
hatch_build["targets"] = tomlkit.table()
|
||||||
if "sdist" not in hatch_build["targets"]:
|
targets = hatch_build["targets"]
|
||||||
hatch_build["targets"]["sdist"] = tomlkit.table()
|
target_additions = hatch_additions["targets"]
|
||||||
if "hooks" not in hatch_build["targets"]["sdist"]:
|
|
||||||
hatch_build["targets"]["sdist"]["hooks"] = tomlkit.table()
|
# Wheel: set packages if neither the wheel target nor the top-level
|
||||||
if "custom" not in hatch_build["targets"]["sdist"]["hooks"]:
|
# hatch build config (older setups) defines them
|
||||||
hatch_build["targets"]["sdist"]["hooks"]["custom"] = tomlkit.table()
|
if "wheel" not in targets:
|
||||||
if "path" not in hatch_build["targets"]["sdist"]["hooks"]["custom"]:
|
targets["wheel"] = tomlkit.table()
|
||||||
hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] = hatch_additions["targets"][
|
if "packages" not in targets["wheel"] and "packages" not in hatch_build:
|
||||||
"sdist"
|
targets["wheel"]["packages"] = [
|
||||||
]["hooks"]["custom"]["path"]
|
p.replace("MODULE_NAME", module_rel) for p in target_additions["wheel"]["packages"]
|
||||||
elif hatch_build["targets"]["sdist"]["hooks"]["custom"]["path"] == OLD_BUILD_HOOK_PATH:
|
]
|
||||||
|
|
||||||
|
# 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
|
# 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
|
return data
|
||||||
|
|
||||||
@@ -1308,17 +1346,20 @@ def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
if dry:
|
if dry:
|
||||||
print(f"📦 Would run: uv init {project_dir}")
|
print(f"📦 Would run: uv init --bare --build-backend hatchling {project_dir}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
print("📦 No pyproject.toml found, initializing Python project...")
|
print("📦 No pyproject.toml found, initializing Python project...")
|
||||||
print(">>> uv init")
|
print(">>> uv init --bare --build-backend hatchling")
|
||||||
result = subprocess.run(["uv", "init", str(project_dir)], check=False) # noqa: S603, S607
|
result = subprocess.run( # noqa: S603
|
||||||
|
["uv", "init", "--bare", "--vcs", "git", "--build-backend", "hatchling", str(project_dir)], # noqa: S607
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("❌ uv init failed")
|
print("❌ uv init failed")
|
||||||
return False
|
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"]:
|
for filename in ["hello.py", "main.py", ".python-version"]:
|
||||||
filepath = project_dir / filename
|
filepath = project_dir / filename
|
||||||
if filepath.exists():
|
if filepath.exists():
|
||||||
@@ -1412,6 +1453,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Step 2: Ensure Python project exists
|
# Step 2: Ensure Python project exists
|
||||||
|
fresh_project = not (project_dir / "pyproject.toml").exists()
|
||||||
if not ensure_python_project(project_dir, dry=dry):
|
if not ensure_python_project(project_dir, dry=dry):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -1424,7 +1466,10 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
else:
|
else:
|
||||||
print(f"📦 Module: {module_name}")
|
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"
|
scripts_dir = project_dir / "scripts"
|
||||||
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
||||||
|
|
||||||
@@ -1436,8 +1481,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
if existing_cli_module:
|
if existing_cli_module:
|
||||||
print(f"ℹ️ Using existing CLI: {existing_cli_module}")
|
print(f"ℹ️ Using existing CLI: {existing_cli_module}")
|
||||||
|
|
||||||
# Resolve the CLI main module to a file path
|
# Resolve the CLI main module to a file path (relative to the import root,
|
||||||
main = project_dir / Path(*main_module_path.split(".")).with_suffix(".py")
|
# i.e. the parent of the package directory)
|
||||||
|
main = module_dir.parent / Path(*main_module_path.split(".")).with_suffix(".py")
|
||||||
|
|
||||||
# Determine port configuration
|
# Determine port configuration
|
||||||
# Priority: --ports argument > existing project values > defaults
|
# Priority: --ports argument > existing project values > defaults
|
||||||
@@ -1485,6 +1531,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
# Template variables
|
# Template variables
|
||||||
tpl_vars = {
|
tpl_vars = {
|
||||||
"MODULE_NAME": module_name,
|
"MODULE_NAME": module_name,
|
||||||
|
"MODULE_DIR": module_rel,
|
||||||
"PROJECT_TITLE": project_title,
|
"PROJECT_TITLE": project_title,
|
||||||
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
"TEMPLATE_DEFAULT_PORT": str(default_port),
|
||||||
"TEMPLATE_VITE_PORT": str(vite_port),
|
"TEMPLATE_VITE_PORT": str(vite_port),
|
||||||
@@ -1499,8 +1546,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
app_file, app_var = app_info
|
app_file, app_var = app_info
|
||||||
print(f"📍 Found FastAPI app: {app_var} in {app_file.name}")
|
print(f"📍 Found FastAPI app: {app_var} in {app_file.name}")
|
||||||
tpl_vars["APP_VAR"] = app_var
|
tpl_vars["APP_VAR"] = app_var
|
||||||
# Dotted module path relative to project dir (e.g. "{module_name}.api.main")
|
# Dotted module path (e.g. "{module_name}.api.main")
|
||||||
app_module = ".".join(app_file.relative_to(project_dir).with_suffix("").parts)
|
app_module = ".".join(app_file.relative_to(module_dir.parent).with_suffix("").parts)
|
||||||
tpl_vars["APP_MODULE"] = app_module
|
tpl_vars["APP_MODULE"] = app_module
|
||||||
else:
|
else:
|
||||||
print("📍 No existing FastAPI app found, will create new one")
|
print("📍 No existing FastAPI app found, will create new one")
|
||||||
@@ -1583,12 +1630,15 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No app: create full app.py
|
# 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"
|
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")
|
template = load_template("backend/__init__.py")
|
||||||
content = render_template(template, **tpl_vars)
|
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
|
# Create app.py
|
||||||
app_file_path = module_dir / "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"
|
plugin_file = frontend_dir / "vite-plugin-fastapi.js"
|
||||||
# Upgrade old plugin versions that lack the auto-upgrade marker
|
# Upgrade old plugin versions that lack the auto-upgrade marker
|
||||||
_upgrade_old_vite_plugin(plugin_file, module_name, dry=dry)
|
_upgrade_old_vite_plugin(plugin_file, module_name, dry=dry)
|
||||||
template = load_template("frontend/vite-plugin-fastapi.js")
|
for plugin_template in ("vite-plugin-fastapi.js", "vite-plugin-fastapi.d.ts"):
|
||||||
content = render_template(template, **tpl_vars)
|
template = load_template(f"frontend/{plugin_template}")
|
||||||
write_file(plugin_file, content, overwrite=True, dry=dry)
|
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)
|
# Find existing vite config (prefer .ts, fall back to .js)
|
||||||
vite_config_ts = frontend_dir / "vite.config.ts"
|
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")
|
old_content = pyproject_path.read_text("UTF-8")
|
||||||
data = tomlkit.parse(old_content)
|
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
|
# Ensure a script entry pointing at our __main__:main
|
||||||
if "scripts" not in updated["project"] or not updated["project"]["scripts"]:
|
scripts = updated["project"].get("scripts")
|
||||||
|
main_entry = f"{module_name}.__main__:main"
|
||||||
|
if not scripts:
|
||||||
updated["project"]["scripts"] = tomlkit.table()
|
updated["project"]["scripts"] = tomlkit.table()
|
||||||
script_name = module_name.replace("_", "-")
|
updated["project"]["scripts"][module_name.replace("_", "-")] = main_entry
|
||||||
updated["project"]["scripts"][script_name] = f"{module_name}.__main__:main"
|
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
|
# Check if content actually changed
|
||||||
new_content = tomlkit.dumps(updated)
|
new_content = tomlkit.dumps(updated)
|
||||||
@@ -1715,8 +1782,16 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# === Update .gitignore ===
|
# === Update .gitignore ===
|
||||||
gitignore_path = project_dir / ".gitignore"
|
gitignore_path = project_dir / ".gitignore"
|
||||||
gitignore_entry = f"/{module_name}/frontend-build"
|
gitignore_entry = f"/{module_rel}/frontend-build"
|
||||||
if gitignore_path.exists():
|
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()
|
gitignore_content = gitignore_path.read_bytes()
|
||||||
if b"frontend-build" in gitignore_content:
|
if b"frontend-build" in gitignore_content:
|
||||||
print("✔️ .gitignore (frontend-build already ignored)")
|
print("✔️ .gitignore (frontend-build already ignored)")
|
||||||
|
|||||||
+6
@@ -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
|
||||||
@@ -30,7 +30,7 @@ export default function fastapiVue({ paths = ['/api'] } = {}) {
|
|||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: '../MODULE_NAME/frontend-build',
|
outDir: '../MODULE_DIR/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in new issue
Block a user