Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9ddf7dc5 | ||
|
|
b189ae66e7 |
+185
-31
@@ -138,10 +138,18 @@ def find_module_name(project_dir: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def find_fastapi_app(module_dir: Path) -> 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.
|
||||
|
||||
Returns (file_path, app_variable_name) or None if not found.
|
||||
|
||||
Search order:
|
||||
1. Common app files in module_dir (app.py, main.py, etc.)
|
||||
2. All .py files in module_dir
|
||||
3. Subpackage indicated by CLI entrypoint in pyproject.toml
|
||||
4. Follow re-exports in __init__.py files
|
||||
"""
|
||||
# Common app file names to check first
|
||||
candidates = ["app.py", "main.py", "server.py", "api.py", "__init__.py"]
|
||||
@@ -154,13 +162,153 @@ def find_fastapi_app(module_dir: Path) -> tuple[Path, str] | None:
|
||||
if result:
|
||||
return path, result
|
||||
|
||||
# Then check all .py files
|
||||
# Then check all .py files in module_dir
|
||||
for path in module_dir.glob("*.py"):
|
||||
if path.name not in candidates:
|
||||
result = _find_app_in_file(path)
|
||||
if result:
|
||||
return path, result
|
||||
|
||||
# Try to find app via CLI entrypoint in pyproject.toml
|
||||
if project_dir:
|
||||
result = _find_app_via_entrypoint(module_dir, project_dir)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return 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"`,
|
||||
look in myapp/subpkg/ for the app (checking __init__.py exports and common files).
|
||||
"""
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if not pyproject.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = tomlkit.parse(pyproject.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
scripts = data.get("project", {}).get("scripts", {})
|
||||
if not scripts:
|
||||
return None
|
||||
|
||||
module_name = module_dir.name
|
||||
|
||||
# Find script entries that reference this module
|
||||
for script_name, entry in scripts.items():
|
||||
if not isinstance(entry, str):
|
||||
continue
|
||||
# Parse entry like "module.subpkg.__main__:main"
|
||||
if ":" not in entry:
|
||||
continue
|
||||
module_path, _ = entry.rsplit(":", 1)
|
||||
parts = module_path.split(".")
|
||||
|
||||
# Check if this entry starts with our module
|
||||
if not parts or parts[0] != module_name:
|
||||
continue
|
||||
|
||||
# If there's a subpackage (e.g., module.fastapi.__main__), check there
|
||||
if len(parts) >= 2:
|
||||
# Build path to subpackage (exclude __main__ or similar)
|
||||
subpkg_parts = [p for p in parts[1:] if not p.startswith("_")]
|
||||
if subpkg_parts:
|
||||
subpkg_dir = module_dir / "/".join(subpkg_parts)
|
||||
if subpkg_dir.is_dir():
|
||||
result = _find_app_in_subpackage(subpkg_dir)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
|
||||
"""Find FastAPI app in a subpackage, following __init__.py exports."""
|
||||
# First check __init__.py for re-exports like `from .mainapp import app`
|
||||
init_file = subpkg_dir / "__init__.py"
|
||||
if init_file.exists():
|
||||
result = _follow_init_reexport(init_file, subpkg_dir)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Check common app file names in subpackage
|
||||
for name in ["app.py", "main.py", "mainapp.py", "server.py", "api.py"]:
|
||||
path = subpkg_dir / name
|
||||
if path.exists():
|
||||
result = _find_app_in_file(path)
|
||||
if result:
|
||||
return path, result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str | None:
|
||||
"""Check if pyproject.toml already has a CLI entrypoint for this module.
|
||||
|
||||
Returns the entrypoint string if found, None otherwise.
|
||||
"""
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if not pyproject.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = tomlkit.parse(pyproject.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
scripts = data.get("project", {}).get("scripts", {})
|
||||
if not scripts:
|
||||
return None
|
||||
|
||||
# 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 None
|
||||
|
||||
|
||||
def _follow_init_reexport(init_file: Path, subpkg_dir: Path) -> tuple[Path, str] | None:
|
||||
"""Follow a re-export in __init__.py to find the actual app file.
|
||||
|
||||
Looks for patterns like:
|
||||
- from .mainapp import app
|
||||
- from module.subpkg.mainapp import app
|
||||
"""
|
||||
try:
|
||||
content = init_file.read_text()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Look for: from .module import app (or similar variable names)
|
||||
# Pattern matches: from .mainapp import app, application, etc.
|
||||
pattern = r"from\s+\.(\w+)\s+import\s+(\w+)"
|
||||
for match in re.finditer(pattern, content):
|
||||
module_name, var_name = match.groups()
|
||||
if var_name.lower() in ("app", "application", "api"):
|
||||
target_file = subpkg_dir / f"{module_name}.py"
|
||||
if target_file.exists():
|
||||
# Verify the app is actually there
|
||||
app_var = _find_app_in_file(target_file)
|
||||
if app_var:
|
||||
return target_file, app_var
|
||||
|
||||
# Also check for absolute imports: from pkg.subpkg.module import app
|
||||
abs_pattern = r"from\s+[\w.]+\.(\w+)\s+import\s+(\w+)"
|
||||
for match in re.finditer(abs_pattern, content):
|
||||
module_name, var_name = match.groups()
|
||||
if var_name.lower() in ("app", "application", "api"):
|
||||
target_file = subpkg_dir / f"{module_name}.py"
|
||||
if target_file.exists():
|
||||
app_var = _find_app_in_file(target_file)
|
||||
if app_var:
|
||||
return target_file, app_var
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -245,28 +393,16 @@ def patch_app_file(
|
||||
lines.append(route_line)
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Try to patch lifespan function
|
||||
# Try to patch lifespan function - insert await frontend.load() before yield
|
||||
lifespan_patched = False
|
||||
|
||||
# Look for async def lifespan pattern and insert after the opening (and docstring if present)
|
||||
lifespan_pattern = r"(async\s+def\s+lifespan\s*\([^)]*\)\s*(?:->.*?)?:\s*\n)"
|
||||
match = re.search(lifespan_pattern, content)
|
||||
if match:
|
||||
insert_pos = match.end()
|
||||
rest = content[insert_pos:]
|
||||
|
||||
# Detect indentation from the next line
|
||||
indent_match = re.match(r"([ \t]*)", rest)
|
||||
indent = (
|
||||
indent_match.group(1) if indent_match and indent_match.group(1) else " "
|
||||
)
|
||||
|
||||
# Check if there's a docstring and skip past it
|
||||
docstring_pattern = r'^([ \t]*)("""[\s\S]*?"""|\'\'\'\'[\s\S]*?\'\'\')\s*\n'
|
||||
docstring_match = re.match(docstring_pattern, rest)
|
||||
if docstring_match:
|
||||
insert_pos += docstring_match.end()
|
||||
|
||||
# Look for yield inside an async def lifespan function
|
||||
# Find the yield statement and insert before it
|
||||
yield_pattern = r"^([ \t]+)(yield\b)"
|
||||
yield_match = re.search(yield_pattern, content, re.MULTILINE)
|
||||
if yield_match:
|
||||
indent = yield_match.group(1)
|
||||
insert_pos = yield_match.start()
|
||||
load_code = f"{indent}await frontend.load()\n"
|
||||
content = content[:insert_pos] + load_code + content[insert_pos:]
|
||||
lifespan_patched = True
|
||||
@@ -580,6 +716,19 @@ def merge_pyproject(
|
||||
if "project" not in data:
|
||||
data["project"] = tomlkit.table()
|
||||
|
||||
# Ensure Python version is at least 3.11 (required by fastapi-vue)
|
||||
if "requires-python" in data["project"]:
|
||||
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))
|
||||
if major < 3 or (major == 3 and minor < 11):
|
||||
data["project"]["requires-python"] = ">=3.11"
|
||||
else:
|
||||
data["project"]["requires-python"] = ">=3.11"
|
||||
|
||||
# Add hatch build config
|
||||
if "tool" not in data:
|
||||
data["tool"] = tomlkit.table()
|
||||
@@ -798,7 +947,7 @@ 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) 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
|
||||
@@ -873,6 +1022,11 @@ 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")
|
||||
@@ -962,17 +1116,17 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
# === Update .gitignore ===
|
||||
gitignore_path = project_dir / ".gitignore"
|
||||
gitignore_entry = f"{module_name}/frontend-build/"
|
||||
gitignore_entry = f"/{module_name}/frontend-build"
|
||||
if gitignore_path.exists():
|
||||
gitignore_content = gitignore_path.read_text()
|
||||
if gitignore_entry not in gitignore_content:
|
||||
if dry_run:
|
||||
gitignore_content = gitignore_path.read_bytes()
|
||||
if b"frontend-build" in gitignore_content:
|
||||
print("✔️ .gitignore (frontend-build already ignored)")
|
||||
elif dry_run:
|
||||
print(f"[DRY RUN] Would add {gitignore_entry} to .gitignore")
|
||||
else:
|
||||
with open(gitignore_path, "a") as f:
|
||||
if not gitignore_content.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(f"{gitignore_entry}\n")
|
||||
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)
|
||||
print(f"✅ Added {gitignore_entry} to .gitignore")
|
||||
elif dry_run:
|
||||
print(f"[DRY RUN] Would create .gitignore with {gitignore_entry}")
|
||||
|
||||
Reference in New Issue
Block a user