@@ -26,9 +26,6 @@ import tomlkit
version = importlib . metadata . version ( " fastapi-vue-setup " )
version = importlib . metadata . version ( " fastapi-vue-setup " )
# Track Python files written/patched for ruff formatting
_python_files_to_format : list [ Path ] = [ ]
# Template directory
# Template directory
TEMPLATE_DIR = Path ( __file__ ) . parent / " template "
TEMPLATE_DIR = Path ( __file__ ) . parent / " template "
@@ -41,21 +38,49 @@ def print_boxed(text: str) -> None:
print ( f " ╰ { ' ─ ' * width } ╯ " )
print ( f " ╰ { ' ─ ' * width } ╯ " )
def ruff_sort_imports ( files : list [ Path ] , dry : bool = False ) - > None :
def ruff_format_content ( content : str , target_path : Path ) - > str :
""" Run ruff to sort imports in the given Python files. """
""" Format Python content using ruff with project settings.
if not files :
return
Writes to a temp file (.new.py) next to target, runs ruff check (import sorting)
py_files = [ str ( f ) for f in files if f . suffix == " .py " and f . exists ( ) ]
and ruff format on it, reads back the result, and cleans up.
if not py_files :
Returns the formatted content, or original if ruff fails.
return
"""
if dry :
temp_file = target_path . with_suffix ( " .new.py " )
print ( f " 🔧 Would run ruff import sorting on { len ( py_files ) } files " )
try :
return
temp_file . write_text ( content , " UTF-8 " , newline = " \n " )
print ( " 🔧 Ruff isort on modified files " )
# Sort imports first
subprocess . run (
subprocess . run (
[ sys . executable , " -m " , " ruff " , " check " , " --select " , " I " , " --fix " , * py_files ] ,
[
stdout = subprocess . DEVNULL ,
" uv " ,
" run " ,
" --with " ,
" ruff " ,
" ruff " ,
" check " ,
" --select " ,
" I " ,
" --fix " ,
str ( temp_file ) ,
] ,
cwd = target_path . parent ,
capture_output = True ,
)
)
# Then format
result = subprocess . run (
[ " uv " , " run " , " --with " , " ruff " , " ruff " , " format " , str ( temp_file ) ] ,
cwd = target_path . parent ,
capture_output = True ,
)
if result . returncode == 0 :
return temp_file . read_text ( " UTF-8 " )
except Exception :
pass
finally :
try :
temp_file . unlink ( missing_ok = True )
except Exception :
pass
return content
def uv_add_packages (
def uv_add_packages (
@@ -402,10 +427,43 @@ def _find_app_in_subpackage(subpkg_dir: Path) -> tuple[Path, str] | None:
return None
return None
def _find_existing_cli_entrypoint ( project_dir : Path , module_name : str ) - > str | None :
def _add_devmode_to_main ( content : str ) - > str :
""" Add DEVMODE variable to an existing main module. """
lines = content . splitlines ( )
# Check if os is imported
has_os_import = any ( " import os " in line for line in lines )
# Find the first import line
insert_idx = 0
for i , line in enumerate ( lines ) :
stripped = line . strip ( )
if stripped . startswith ( " import " ) or stripped . startswith ( " from " ) :
insert_idx = i + 1
elif stripped and not stripped . startswith ( " # " ) :
break
# Insert imports and DEVMODE after existing imports
new_lines = [ ]
if not has_os_import :
new_lines . append ( " import os " )
new_lines . extend (
[
" " ,
" # Added by fastapi-vue-setup " ,
' DEVMODE = os.getenv( " ENVPREFIX_DEV " ) == " 1 " ' ,
" " ,
]
)
lines [ insert_idx : insert_idx ] = new_lines
return " \n " . join ( lines )
def _find_existing_cli_module_path ( project_dir : Path , module_name : str ) - > str | None :
""" Check if pyproject.toml already has a CLI entrypoint for this module.
""" Check if pyproject.toml already has a CLI entrypoint for this module.
Returns the entrypoint string if found, None otherwise.
Returns the module path (e.g., ' module.subpkg.__main__ ' ) if found, None otherwise.
"""
"""
pyproject = project_dir / " pyproject.toml "
pyproject = project_dir / " pyproject.toml "
if not pyproject . exists ( ) :
if not pyproject . exists ( ) :
@@ -423,7 +481,10 @@ def _find_existing_cli_entrypoint(project_dir: Path, module_name: str) -> str |
# Look for any script that references our module
# Look for any script that references our module
for script_name , entry in scripts . items ( ) :
for script_name , entry in scripts . items ( ) :
if isinstance ( entry , str ) and entry . startswith ( f " { module_name } . " ) :
if isinstance ( entry , str ) and entry . startswith ( f " { module_name } . " ) :
return f ' { script_name } = " { entry } " '
# Extract module path from "module.subpkg.__main__:main"
if " : " in entry :
module_path , _ = entry . rsplit ( " : " , 1 )
return module_path
return None
return None
@@ -492,7 +553,7 @@ def render_template(template: str, **kwargs) -> str:
def patch_app_file (
def patch_app_file (
path : Path , module_name : str , app_var : str , dry : bool = False
path : Path , main_module_path : str , app_var : str , dry : bool = False
) - > bool :
) - > bool :
""" Patch an existing app.py with frontend integration.
""" Patch an existing app.py with frontend integration.
@@ -510,7 +571,7 @@ def patch_app_file(
# Check what's already patched
# Check what's already patched
has_frontend = " from fastapi_vue import Frontend " in content
has_frontend = " from fastapi_vue import Frontend " in content
has_devmode = f " from { module_name } .__main__ import DEVMODE" in content
has_devmode = f " from { main_module_path } import DEVMODE " in content
has_debug_arg = re . search ( r " FastAPI \ s* \ ([^)]*debug \ s*= " , content ) is not None
has_debug_arg = re . search ( r " FastAPI \ s* \ ([^)]*debug \ s*= " , content ) is not None
has_lifespan = " await frontend.load() " in content
has_lifespan = " await frontend.load() " in content
@@ -525,7 +586,7 @@ def patch_app_file(
if not has_frontend :
if not has_frontend :
imports . extend ( [ " from pathlib import Path " , " from fastapi_vue import Frontend " ] )
imports . extend ( [ " from pathlib import Path " , " from fastapi_vue import Frontend " ] )
if not has_devmode :
if not has_devmode :
imports . append ( f " from { module_name } .__main__ import DEVMODE" )
imports . append ( f " from { main_module_path } import DEVMODE " )
if imports :
if imports :
insert_line = find_import_insertion_line ( content )
insert_line = find_import_insertion_line ( content )
lines = content . splitlines ( keepends = True )
lines = content . splitlines ( keepends = True )
@@ -655,7 +716,6 @@ def patch_app_file(
return True
return True
path . write_text ( content , " UTF-8 " , newline = " \n " )
path . write_text ( content , " UTF-8 " , newline = " \n " )
_python_files_to_format . append ( path )
print ( f " ✅ Patched { path } " )
print ( f " ✅ Patched { path } " )
if not lifespan_patched :
if not lifespan_patched :
@@ -903,13 +963,20 @@ def write_file(
the content will be written to fallback_path instead of being skipped.
the content will be written to fallback_path instead of being skipped.
If force=True, always overwrite without checking for upgrade marker.
If force=True, always overwrite without checking for upgrade marker.
Python files (.py) are automatically formatted using ruff with the project ' s
settings before writing.
"""
"""
# Format Python content using project settings before any comparison/writing
if path . suffix == " .py " :
content = ruff_format_content ( content , path )
exists = path . exists ( )
exists = path . exists ( )
if exists and not overwrite :
if exists and not overwrite :
print ( f " ⚠️ Skipping { path } (exists) " )
print ( f " ⚠️ Skipping { path } (exists) " )
return False
return False
# Check if content is the same
# Check if content is the same (new content already formatted)
if exists :
if exists :
existing_content = path . read_text ( " UTF-8 " )
existing_content = path . read_text ( " UTF-8 " )
if existing_content == content :
if existing_content == content :
@@ -935,8 +1002,6 @@ def write_file(
path . write_text ( content , " UTF-8 " , newline = " \n " )
path . write_text ( content , " UTF-8 " , newline = " \n " )
if executable and sys . platform != " win32 " :
if executable and sys . platform != " win32 " :
path . chmod ( path . stat ( ) . st_mode | 0o111 )
path . chmod ( path . stat ( ) . st_mode | 0o111 )
if path . suffix == " .py " :
_python_files_to_format . append ( path )
action = " Updated " if exists else " Created "
action = " Updated " if exists else " Created "
print ( f " ✅ { action } { path } " )
print ( f " ✅ { action } { path } " )
return True
return True
@@ -949,11 +1014,16 @@ def _write_fallback_file(
dry : bool ,
dry : bool ,
executable : bool ,
executable : bool ,
) - > bool :
) - > bool :
""" Write content to a fallback .new.py file when original can ' t be overwritten. """
""" Write content to a fallback .new.py file when original can ' t be overwritten.
# Check if fallback already has same content
Note: content should already be formatted before calling this function.
"""
# Check if fallback already has same content (content already formatted)
if fallback_path . exists ( ) :
if fallback_path . exists ( ) :
if fallback_path . read_text ( " UTF-8 " ) == content :
existing_content = fallback_path . read_text ( " UTF-8 " )
if existing_content == content :
print ( f " ✔️ { fallback_path } (already up to date) " )
print ( f " ✔️ { fallback_path } (already up to date) " )
_new_files_written . append ( ( fallback_path , original_path ) )
return False
return False
if dry :
if dry :
@@ -965,8 +1035,6 @@ def _write_fallback_file(
fallback_path . write_text ( content , " UTF-8 " , newline = " \n " )
fallback_path . write_text ( content , " UTF-8 " , newline = " \n " )
if executable and sys . platform != " win32 " :
if executable and sys . platform != " win32 " :
fallback_path . chmod ( fallback_path . stat ( ) . st_mode | 0o111 )
fallback_path . chmod ( fallback_path . stat ( ) . st_mode | 0o111 )
if fallback_path . suffix == " .py " :
_python_files_to_format . append ( fallback_path )
print ( f " ✅ Created { fallback_path } (original customized by user) " )
print ( f " ✅ Created { fallback_path } (original customized by user) " )
_new_files_written . append ( ( fallback_path , original_path ) )
_new_files_written . append ( ( fallback_path , original_path ) )
return True
return True
@@ -1237,6 +1305,21 @@ def cmd_setup(args: argparse.Namespace) -> int:
# Title for templates
# Title for templates
project_title = module_name . replace ( " _ " , " " ) . title ( )
project_title = module_name . replace ( " _ " , " " ) . title ( )
module_dir = project_dir / module_name
scripts_dir = project_dir / " scripts "
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
)
# Check if project already has a CLI entrypoint in pyproject.toml
existing_cli_module = _find_existing_cli_module_path ( project_dir , module_name )
# Determine main module path for DEVMODE import
main_module_path = existing_cli_module or f " { module_name } .__main__ "
# Template variables
# Template variables
tpl_vars = {
tpl_vars = {
" MODULE_NAME " : module_name ,
" MODULE_NAME " : module_name ,
@@ -1246,17 +1329,9 @@ def cmd_setup(args: argparse.Namespace) -> int:
" TEMPLATE_DEV_PORT " : str ( dev_port ) ,
" TEMPLATE_DEV_PORT " : str ( dev_port ) ,
" ENVPREFIX " : module_name . upper ( ) ,
" ENVPREFIX " : module_name . upper ( ) ,
" PROJECT_CLI " : module_name ,
" PROJECT_CLI " : module_name ,
" MAIN_MODULE " : main_module_path ,
}
}
module_dir = project_dir / module_name
scripts_dir = project_dir / " scripts "
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
)
if app_info :
if app_info :
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 } " )
@@ -1320,7 +1395,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
# === Handle app module ===
# === Handle app module ===
if app_file :
if app_file :
# Existing app: patch with import, route, and try to patch lifespan
# Existing app: patch with import, route, and try to patch lifespan
patch_app_file ( app_file , module_name , app_var , dry = dry )
patch_app_file ( app_file , main_module_path , app_var , dry = dry )
else :
else :
# No app: create full app.py
# No app: create full app.py
# Create __init__.py if missing
# Create __init__.py if missing
@@ -1337,14 +1412,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
write_file ( app_file_path , content , overwrite = False , dry = dry )
write_file ( app_file_path , content , overwrite = False , dry = dry )
# === Handle __main__.py ===
# === Handle __main__.py ===
main_file = module_dir / " __main__.py "
if existing_cli_module :
# Existing CLI entrypoint - write .new.py beside the existing main module
main_module_parts = existing_cli_module . split ( " . " )
main_module_file = project_dir / ( " / " . join ( main_module_parts ) + " .py " )
main_fallback = main_module_file . with_suffix ( " .new.py " )
else :
main_module_file = None
main_fallback = module_dir / " __main__.new.py "
main_fallback = module_dir / " __main__.new.py "
main_file = module_dir / " __main__.py "
template = load_template ( " backend/__main__.py " )
template = load_template ( " backend/__main__.py " )
main_content = render_template ( template , * * tpl_vars )
main_content = render_template ( template , * * tpl_vars )
# 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 ( ) :
if main_file . exists ( ) :
# File exists: update if it has the auto-upgrade marker, otherwise use fallback
# File exists: update if it has the auto-upgrade marker, otherwise use fallback
write_file (
write_file (
@@ -1354,7 +1434,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
dry = dry ,
dry = dry ,
fallback_path = main_fallback ,
fallback_path = main_fallback ,
)
)
elif not existing_cli :
elif not existing_cli_module :
# No file and no existing entrypoint: create new __main__.py
# No file and no existing entrypoint: create new __main__.py
write_file (
write_file (
main_file ,
main_file ,
@@ -1363,8 +1443,30 @@ def cmd_setup(args: argparse.Namespace) -> int:
dry = dry ,
dry = dry ,
)
)
else :
else :
# No file but has e xisting entrypoint - don't create (user has custom CLI setup)
# E xisting CLI entrypoint: write our template as .new.py beside the existing module
print ( f " ℹ ️ Skipping __main__.py (package already has CLI: { existing_cli } ) " )
# and also patch the existing module with DEVMODE if needed
print ( f " ℹ ️ Using existing CLI: { existing_cli_module } " )
_write_fallback_file (
main_module_file or main_file ,
main_fallback ,
main_content ,
dry = dry ,
executable = False ,
)
if main_module_file and main_module_file . exists ( ) :
content = main_module_file . read_text ( " UTF-8 " )
if " DEVMODE " not in content :
new_content = _add_devmode_to_main ( content )
new_file = main_module_file . with_suffix ( " .new.py " )
_write_fallback_file (
main_module_file ,
new_file ,
new_content ,
dry = dry ,
executable = False ,
)
elif main_module_file :
print ( f " ⚠️ Existing CLI main module { main_module_file } not found " )
# === Update vite.config.js/ts ===
# === Update vite.config.js/ts ===
frontend_dir = project_dir / " frontend "
frontend_dir = project_dir / " frontend "
@@ -1440,7 +1542,6 @@ def cmd_setup(args: argparse.Namespace) -> int:
print ( " ✅ Created .gitignore " )
print ( " ✅ Created .gitignore " )
# === Add dependencies using uv ===
# === Add dependencies using uv ===
ruff_sort_imports ( _python_files_to_format , dry = dry )
if dry :
if dry :
print ( " 📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only) " )
print ( " 📦 Would add: fastapi[standard], fastapi-vue, httpx (dev only) " )
else :
else :
@@ -1468,7 +1569,13 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
)
print ( " Please manually merge the following files: " )
print ( " Please manually merge the following files: " )
for new_file , original_file in _new_files_written :
for new_file , original_file in _new_files_written :
print ( f " • { new_file . name } → { original_file . name } " )
try :
new_rel = new_file . relative_to ( project_dir )
orig_rel = original_file . relative_to ( project_dir )
except ValueError :
new_rel = new_file . name
orig_rel = original_file . name
print ( f " • { new_rel } → { orig_rel } " )
print ( )
print ( )
# Clear the list for potential subsequent runs
# Clear the list for potential subsequent runs
_new_files_written . clear ( )
_new_files_written . clear ( )