@@ -15,15 +15,18 @@ import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
from textwrap import indent
import tomli_w
import tomlkit
# Template directory
TEMPLATE_DIR = Path ( __file__ ) . parent / " template "
# Marker comment indicating file can be auto-upgraded
# Users should remove this line to prevent automatic updates
UPGRADE_MARKER = " auto-upgrade@fastapi-vue-setup "
# pyproject.toml additions for patched projects
PYPROJECT_ADDITIONS = {
" tool " : {
@@ -48,9 +51,7 @@ PYPROJECT_ADDITIONS = {
# Frontend instantiation block for patching existing apps
FRONTEND_BLOCK = """
# Vue Frontend static files
frontend = Frontend(
Path(__file__).with_name( " frontend-build " ), spa=True, cached=[ " /assets/ " ]
)
frontend = Frontend(Path(__file__).with_name( " frontend-build " ), cached=[ " /assets/ " ])
"""
# TypeScript health check script for Vue components
@@ -126,8 +127,7 @@ def find_module_name(project_dir: Path) -> str | None:
if not pyproject . exists ( ) :
return None
with open ( pyproject , " rb " ) as f :
data = tomllib . load ( f )
data = tomlkit . parse ( pyproject . read_text ( ) )
if " project " in data and " name " in data [ " project " ] :
name = data [ " project " ] [ " name " ]
@@ -136,10 +136,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 " ]
@@ -152,13 +160,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
@@ -200,19 +348,15 @@ def patch_app_file(
print ( f " ❌ Cannot patch { path } - file not found " )
return False
content = path . read_text ( )
original_content = path . read_text ( )
marker = " from fastapi_vue import Frontend "
if marker in content :
print ( f " ⚠ ️ Skipping { path } (already patched) " )
if marker in original_content :
print ( f " ✔ ️ { path } (already patched) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { path } " )
return True
# Find where to insert the import (after other imports)
lines = content . split ( " \n " )
lines = original_content . split ( " \n " )
import_line = " from fastapi_vue import Frontend "
route_line = f ' frontend.route( { app_var } , " / " ) '
@@ -230,6 +374,11 @@ def patch_app_file(
# Stop at first non-import, non-comment, non-empty line after imports
break
# Check if we found imports to insert after
if last_import_idx == 0 and not lines [ 0 ] . strip ( ) . startswith ( ( " import " , " from " ) ) :
print ( f " ⚠️ Skipping { path } (no imports found to patch) " )
return False
# Insert imports after last import, then frontend instantiation
if not has_pathlib :
lines . insert ( last_import_idx + 1 , " from pathlib import Path " )
@@ -239,35 +388,33 @@ def patch_app_file(
# Append route at end
lines . append ( " " )
lines . append ( " # Serve the Vue frontend (needs to be last if SPA catch-all is used) " )
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
# Check if content actually changed
if content == original_content :
print ( f " ⚠️ Skipping { path } (no changes needed) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { path } " )
return True
path . write_text ( content )
print ( f " ✅ Patched { path } " )
@@ -303,21 +450,17 @@ def patch_vite_config(
print ( f " ❌ Cannot patch { path } - file not found " )
return False
content = path . read_text ( )
original_content = path . read_text ( )
marker = " vite-plugin-fastapi "
if marker in content :
print ( f " ⚠ ️ Skipping { path } (already patched) " )
if marker in original_content :
print ( f " ✔ ️ { path } (already patched) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { path } " )
return True
# Add import for the plugin at the top (after other imports)
import_line = f " import fastapiVue from ' ./ { marker } .js ' "
lines = content . split ( " \n " )
lines = original_content . split ( " \n " )
new_lines = [ ]
import_inserted = False
@@ -349,6 +492,18 @@ def patch_vite_config(
if match :
insert_pos = match . end ( )
content = content [ : insert_pos ] + " \n fastapiVue(), " + content [ insert_pos : ]
else :
print ( f " ⚠️ Skipping { path } (no plugins array found) " )
return False
# Check if content actually changed
if content == original_content :
print ( f " ⚠️ Skipping { path } (no changes needed) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { path } " )
return True
path . write_text ( content )
print ( f " ✅ Patched { path } " )
@@ -384,16 +539,14 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
print ( " ⚠️ No Vue file found to patch, skipping frontend health check " )
return False
content = target_file . read_text ( )
original_content = target_file . read_text ( )
# Check if already patched
if " /api/health " in content :
print ( f " ⚠ ️ Skipping { target_file } (already patched) " )
if " /api/health " in original_content :
print ( f " ✔ ️ { target_file } (already patched) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { target_file } " )
return True
content = original_content
# Detect if TypeScript (has lang="ts" in script tag)
is_typescript = ' lang= " ts " ' in content
@@ -405,47 +558,95 @@ def patch_frontend_health_check(frontend_dir: Path, dry_run: bool = False) -> bo
# Insert script addition before </script>
script_end_match = re . search ( r " </script> " , content )
if script_end_match :
if not script_end_match :
print ( f " ⚠️ Skipping { target_file } (no </script> tag found) " )
return False
insert_pos = script_end_match . start ( )
content = content [ : insert_pos ] + script_addition + content [ insert_pos : ]
# Insert status inline - find the best place based on file type
# For HelloWorld.vue: insert before </h3>
# For App.vue (minimal): insert before the last </p> in template
# For App.vue (minimal): only patch if it's the default "You did it!" template
if " HelloWorld " in str ( target_file ) :
# Insert before closing </h3>
h3_close = content . find ( " </h3> " )
if h3_close == - 1 :
print (
f " ⚠️ Skipping { target_file } (no </h3> tag found for status insertion) "
)
return False
before , after = content [ : h3_close ] , content [ h3_close : ]
content = f " { before } { indent ( STATUS_SPAN_TEMPLATE , ' ' ) } { after } "
else :
# Minimal App.vue - insert before the last </p> before </template>
# Minimal App.vue - only patch if it contains the default welcome message
if " <h1>You did it!</h1> " not in content :
print ( f " ⚠️ Skipping { target_file } (not a default Vue template) " )
return False
# Insert before the </p> tag
template_end = content . find ( " </template> " )
if template_end ! = - 1 :
# Find last </p> before </template>
if template_end = = - 1 :
print ( f " ⚠️ Skipping { target_file } (no </template> tag found) " )
return False
# Find </p> before </template>
last_p = content . rfind ( " </p> " , 0 , template_end )
if last_p == - 1 :
print ( f " ⚠️ Skipping { target_file } (no </p> tag found for status insertion) " )
return False
before , after = content [ : last_p ] , content [ last_p : ]
content = f " { before } { STATUS_SPAN_TEMPLATE } { after } "
# Check if content actually changed
if content == original_content :
print ( f " ⚠️ Skipping { target_file } (no changes needed) " )
return False
if dry_run :
print ( f " [DRY RUN] Would patch { target_file } " )
return True
target_file . write_text ( content )
print ( f " ✅ Patched { target_file } " )
return True
# Track .new.py files written during setup (for merge notification)
_new_files_written : list [ tuple [ Path , Path ] ] = [ ]
def write_file (
path : Path ,
content : str ,
overwrite : bool = True ,
dry_run : bool = False ,
executable : bool = False ,
fallback_path : Path | None = None ,
) - > bool :
""" Write content to a file, handling existing files and dry-run. """
""" 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.
"""
exists = path . exists ( )
if exists and not overwrite :
print ( f " ⚠ ️ Skipping { path } (exists) " )
print ( f " ℹ ️ Skipping { path } (exists) " )
return False
# Check if content is the same
if exists :
existing_content = path . read_text ( )
if existing_content == content :
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 fallback_path is not None :
# Write to fallback path instead
return _write_fallback_file (
path , fallback_path , content , dry_run , executable
)
print ( f " ℹ ️ Skipping { path } (customized by user) " )
return False
if dry_run :
@@ -462,47 +663,113 @@ def write_file(
return True
def merge_pyproject ( data : dict , additions : dict , module_name : str ) - > dict :
""" Merge additions into pyproject.toml data. """
result = data . copy ( )
def _write_fallback_file (
original_path : Path ,
fallback_path : Path ,
content : str ,
dry_run : bool ,
executable : bool ,
) - > bool :
""" Write content to a fallback .new.py file when original can ' t be overwritten. """
# Check if fallback already has same content
if fallback_path . exists ( ) :
if fallback_path . read_text ( ) == content :
print ( f " ✔️ { fallback_path } (already up to date) " )
return False
if dry_run :
print ( f " [DRY RUN] Would create { fallback_path } (original customized by user) " )
_new_files_written . append ( ( fallback_path , original_path ) )
return True
fallback_path . parent . mkdir ( parents = True , exist_ok = True )
fallback_path . write_text ( content )
if executable and sys . platform != " win32 " :
fallback_path . chmod ( fallback_path . stat ( ) . st_mode | 0o111 )
print ( f " ✅ Created { fallback_path } (original customized by user) " )
_new_files_written . append ( ( fallback_path , original_path ) )
return True
def merge_pyproject (
data : tomlkit . TOMLDocument , additions : dict , module_name : str
) - > tomlkit . TOMLDocument :
""" Merge additions into pyproject.toml data, preserving comments and existing values. """
# Ensure hatchling build system is configured
if " build-system " not in result :
result [ " build-system " ] = { }
result [ " build-system " ] [ " requires " ] = [ " hatchling " ]
result [ " build-system " ] [ " build-backend " ] = " hatchling.build "
if " build-system " not in data :
data [ " build-system " ] = tomlkit . table ( )
# Add dependenci es
if " project " not in result :
result [ " project " ] = { }
if " dependencies " not in result [ " project " ] :
result [ " project " ] [ " dependencies " ] = [ ]
# Add hatchling to requires if not present, preserving existing requir es
if " requires " not in data [ " build-system " ] :
data [ " build-system " ] [ " requires " ] = [ " hatchling " ]
else :
requires = list ( data [ " build-system " ] [ " requires " ] )
if not any ( r . startswith ( " hatchling " ) for r in requires ) :
requires . insert ( 0 , " hatchling " )
data [ " build-system " ] [ " requires " ] = requires
if " build-backend " not in data [ " build-system " ] :
data [ " build-system " ] [ " build-backend " ] = " hatchling.build "
# Ensure project table exists
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 result :
result [ " tool " ] = { }
if " hatch " not in result [ " tool " ] :
result [ " tool " ] [ " hatch " ] = { }
if " build " not in result [ " tool " ] [ " hatch " ] :
result [ " tool " ] [ " hatch " ] [ " build " ] = { }
if " tool " not in data :
data [ " tool " ] = tomlkit . table ( )
if " hatch " not in data [ " tool " ] :
data [ " tool " ] [ " hatch " ] = tomlkit . table ( )
if " build " not in data [ " tool " ] [ " hatch " ] :
data [ " tool " ] [ " hatch " ] [ " build " ] = tomlkit . table ( )
hatch_build = additions [ " tool " ] [ " hatch " ] [ " build " ]
result [ " tool " ] [ " hatch " ] [ " build " ] [ " packages " ] = [
p . replace ( " MODULE_NAME " , module_name ) for p in hatch_build [ " packages " ]
]
result [ " tool " ] [ " hatch " ] [ " build " ] [ " artifacts " ] = [
a . replace ( " MODULE_NAME " , module_name ) for a in hatch_build [ " artifacts " ]
]
result [ " tool " ] [ " hatch " ] [ " build " ] [ " only-packages " ] = hatch_build [ " only-packages " ]
hatch_build = data [ " tool " ] [ " hatch " ] [ " build " ]
hatch_additions = additions [ " tool " ] [ " hatch " ] [ " build " ]
if " targets " not in result [ " tool " ] [ " hatch " ] [ " build " ] :
result [ " tool " ] [ " hatch " ] [ " build " ] [ " targets " ] = { }
result [ " tool " ] [ " hatch " ] [ " build " ] [ " targets " ] [ " sdist " ] = hatch_build [ " targets " ] [
" sdist "
# 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 " ]
]
return result
# 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 " ]
]
# 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
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 " ]
return data
# =============================================================================
@@ -679,7 +946,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
@@ -700,25 +967,38 @@ def cmd_setup(args: argparse.Namespace) -> int:
module_dir . mkdir ( parents = True )
# === Install scripts (always update our own scripts) ===
script_files = [
# util.py and build-frontend.py 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 " ,
) ,
( scripts_dir / " devserver.py " , " scripts/devserver.py " ) ,
]
for dest_path , template_path in script_files :
for dest_path , template_path in internal_script_files :
template = load_template ( template_path )
content = render_template ( template , * * tpl_vars )
is_executable = dest_path . name == " devserver.py "
write_file (
dest_path ,
content ,
overwrite = True ,
dry_run = dry_run ,
executable = is_executable ,
)
# devserver.py - use fallback if customized by user
devserver_path = scripts_dir / " devserver.py "
devserver_fallback = scripts_dir / " devserver.new.py "
template = load_template ( " scripts/devserver.py " )
content = render_template ( template , * * tpl_vars )
write_file (
devserver_path ,
content ,
overwrite = True ,
dry_run = dry_run ,
executable = True ,
fallback_path = devserver_fallback ,
)
# === Handle app module ===
@@ -741,11 +1021,27 @@ 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 "
if not main_file . exists ( ) :
main_fallback = module_dir / " __main__.new.py "
template = load_template ( " backend/__main__.py " )
content = render_template ( template , * * tpl_vars )
write_file ( main_file , content , overwrite = False , dry_run = dry_run )
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
write_file (
main_file ,
main_content ,
overwrite = True ,
dry_run = dry_run ,
fallback_path = main_fallback ,
)
# === Update vite.config.js/ts ===
frontend_dir = project_dir / " frontend "
@@ -774,39 +1070,43 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Update pyproject.toml ===
pyproject_path = project_dir / " pyproject.toml "
if pyproject_path . exists ( ) :
with open ( pyproject_path , " rb " ) as f :
data = tomllib . load ( f )
old_content = pyproject_path . read_text ( )
data = tomlkit . parse ( old_content )
updated = merge_pyproject ( data , PYPROJECT_ADDITIONS , module_name )
# A dd script entry pointing to the app we found/created
if " scripts " not in updated [ " project " ] :
updated [ " project " ] [ " scripts " ] = { }
# Only a dd script entry if project doesn't already have scripts
if " scripts " not in updated [ " project " ] or not updated [ " project " ] [ " scripts " ] :
updated [ " project " ] [ " scripts " ] = tomlkit . table ( )
script_name = module_name . replace ( " _ " , " - " )
updated [ " project " ] [ " scripts " ] [ script_name ] = f " { module_name } .__main__:main "
if dry_run :
# Check if content actually changed
new_content = tomlkit . dumps ( updated )
if new_content == old_content :
print ( f " ✔️ { pyproject_path } (already up to date) " )
elif dry_run :
print ( f " [DRY RUN] Would update { pyproject_path } " )
else :
with open ( pyproject_path , " wb " ) as f :
tomli_w . dump ( updated , f )
pyproject_path . write_text ( new_content )
print ( f " ✅ Updated { pyproject_path } " )
# === Add dependencies using uv ===
if dry_run :
print ( " [DRY RUN] Would run: uv add ' fastapi[standard] ' fastapi-vue " )
print ( " [DRY RUN] Would run: uv add --group dev httpx " )
print ( " [DRY RUN] Would run: uv add -U ' fastapi[standard] ' fastapi-vue " )
print ( " [DRY RUN] Would run: uv add -U --group dev httpx " )
else :
print ( " 📦 Adding dependencies... " )
result = subprocess . run (
[ " uv " , " add " , " fastapi[standard] " , " fastapi-vue " ] ,
[ " uv " , " add " , " -U " , " fastapi[standard] " , " fastapi-vue " ] ,
cwd = project_dir ,
check = False ,
)
if result . returncode != 0 :
print ( " ⚠️ Failed to add main dependencies " )
result = subprocess . run (
[ " uv " , " add " , " --group " , " dev " , " httpx " ] ,
[ " uv " , " add " , " -U " , " --group " , " dev " , " httpx " ] ,
cwd = project_dir ,
check = False ,
)
@@ -815,17 +1115,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 } " )
@@ -847,6 +1147,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
print ( message )
# Show merge note if any .new.py files were written
if _new_files_written :
print ( )
print (
" ⚠️ Note: Some files could not be auto-upgraded because you customized them. "
)
print ( " Please manually merge the following files: " )
for new_file , original_file in _new_files_written :
print ( f " • { new_file . name } → { original_file . name } " )
print ( )
# Clear the list for potential subsequent runs
_new_files_written . clear ( )
return 0