Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e645a263 | ||
|
|
8b8a24a6b2 | ||
|
|
cb6017cedf | ||
|
|
fab4108e92 | ||
|
|
637f737d4c | ||
|
|
0b986717eb |
@@ -12,6 +12,7 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||
|
||||
Returns:
|
||||
List of dicts with uvicorn bind kwargs (host/port or uds).
|
||||
Two entries may be returned for IPv4 and IPv6 (all interaces).
|
||||
|
||||
Supported forms:
|
||||
- None or empty -> [{host: "localhost", port: default_port}]
|
||||
@@ -65,3 +66,19 @@ def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||
ipaddress.ip_address(host)
|
||||
|
||||
return [{"host": host, "port": port}]
|
||||
|
||||
|
||||
def parse_endpoints(
|
||||
listen: str | list[str] | None = None, default_port: int = 8000
|
||||
) -> list[dict]:
|
||||
"""Parse listen strings into a list of endpoint dicts.
|
||||
|
||||
Args:
|
||||
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||
default_port: Port to use when not specified in listen args.
|
||||
"""
|
||||
if listen is None:
|
||||
listen = [f"localhost:{default_port}"]
|
||||
elif isinstance(listen, str):
|
||||
listen = [listen]
|
||||
return [ep for s in listen for ep in parse_endpoint(s, default_port)]
|
||||
|
||||
@@ -6,7 +6,7 @@ from contextlib import suppress
|
||||
import uvicorn
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from .hostutil import parse_endpoint
|
||||
from .hostutil import parse_endpoints
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,13 +30,9 @@ def run(
|
||||
workers: Number of worker processes (requires uvicorn.run, single endpoint only).
|
||||
**uvicorn_config: Additional uvicorn config options (overrides all other settings).
|
||||
"""
|
||||
if listen is None:
|
||||
listen = [f"localhost:{default_port}"]
|
||||
elif isinstance(listen, str):
|
||||
listen = [listen]
|
||||
endpoints: list[dict] = []
|
||||
for ep in listen:
|
||||
endpoints.extend(parse_endpoint(ep, default_port))
|
||||
endpoints = parse_endpoints(listen, default_port)
|
||||
if not endpoints:
|
||||
raise ValueError("No endpoints to serve; check listen configuration")
|
||||
|
||||
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||
|
||||
+49
-42
@@ -21,6 +21,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import indent
|
||||
from typing import Literal
|
||||
|
||||
import tomlkit
|
||||
|
||||
@@ -38,12 +39,16 @@ def print_boxed(text: str) -> None:
|
||||
print(f"╰{'─' * width}╯")
|
||||
|
||||
|
||||
def ruff_format_content(content: str, target_path: Path) -> str:
|
||||
def ruff_format_content(
|
||||
content: str, target_path: Path, *, mode: Literal["isort", "full"] = "full"
|
||||
) -> str:
|
||||
"""Format Python content using ruff with project settings.
|
||||
|
||||
Writes to a temp file (.new.py) next to target, runs ruff check (import sorting)
|
||||
and ruff format on it, reads back the result, and cleans up.
|
||||
Writes to a temp file next to target, runs ruff check (import sorting)
|
||||
and optionally ruff format on it, reads back the result, and cleans up.
|
||||
Returns the formatted content, or original if ruff fails.
|
||||
|
||||
mode='isort' only sorts imports; mode='full' also formats.
|
||||
"""
|
||||
temp_file = target_path.with_suffix(".new.py")
|
||||
try:
|
||||
@@ -65,6 +70,8 @@ def ruff_format_content(content: str, target_path: Path) -> str:
|
||||
cwd=target_path.parent,
|
||||
capture_output=True,
|
||||
)
|
||||
if mode == "isort":
|
||||
return temp_file.read_text("UTF-8")
|
||||
# Then format
|
||||
result = subprocess.run(
|
||||
["uv", "run", "--with", "ruff", "ruff", "format", str(temp_file)],
|
||||
@@ -253,23 +260,20 @@ def find_import_insertion_line(source: str) -> int:
|
||||
return 2 if source.startswith("#!") else 1
|
||||
|
||||
|
||||
def extract_existing_ports(project_dir: Path) -> tuple[int, int, int] | None:
|
||||
def extract_existing_ports(
|
||||
project_dir: Path, main: Path
|
||||
) -> tuple[int, int, int] | None:
|
||||
"""Extract existing port configuration from project files.
|
||||
|
||||
Returns (default, vite, dev) or None if not found.
|
||||
"""
|
||||
module_name = find_module_name(project_dir)
|
||||
if not module_name:
|
||||
return None
|
||||
|
||||
default_port = None
|
||||
vite_port = None
|
||||
dev_port = None
|
||||
|
||||
# Try to extract DEFAULT_PORT from __main__.py
|
||||
main_file = project_dir / module_name / "__main__.py"
|
||||
if main_file.exists():
|
||||
content = main_file.read_text("UTF-8")
|
||||
# Try to extract DEFAULT_PORT from the CLI main module
|
||||
if main.exists():
|
||||
content = main.read_text("UTF-8")
|
||||
match = re.search(r"DEFAULT_PORT\s*=\s*(\d+)", content)
|
||||
if match:
|
||||
default_port = int(match.group(1))
|
||||
@@ -715,6 +719,8 @@ def patch_app_file(
|
||||
print(f"✅ Would patch {path}")
|
||||
return True
|
||||
|
||||
# Sort imports only (avoid full formatting of user code)
|
||||
content = ruff_format_content(content, path, mode="isort")
|
||||
path.write_text(content, "UTF-8", newline="\n")
|
||||
print(f"✅ Patched {path}")
|
||||
|
||||
@@ -836,7 +842,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry: bool = False) -> bool:
|
||||
target_file = app_vue
|
||||
|
||||
if target_file is None:
|
||||
print("ℹ️ No Vue file found to patch, skipping frontend health check")
|
||||
print("ℹ️ No default App.vue found to patch, not adding /api/health check")
|
||||
return False
|
||||
|
||||
original_content = target_file.read_text("UTF-8")
|
||||
@@ -881,7 +887,7 @@ def patch_frontend_health_check(frontend_dir: Path, dry: bool = False) -> bool:
|
||||
else:
|
||||
# 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)")
|
||||
print(f"ℹ️ Skipping {target_file} (not a Vue demo app)")
|
||||
return False
|
||||
# Insert before the </p> tag
|
||||
template_end = content.find("</template>")
|
||||
@@ -1284,13 +1290,28 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
else:
|
||||
print(f"📦 Module: {module_name}")
|
||||
|
||||
module_dir = project_dir / module_name
|
||||
scripts_dir = project_dir / "scripts"
|
||||
fastapi_vue_scripts = scripts_dir / "fastapi-vue"
|
||||
|
||||
# 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__"
|
||||
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")
|
||||
|
||||
# Determine port configuration
|
||||
# Priority: --ports argument > existing project values > defaults
|
||||
if args.ports:
|
||||
default_port, vite_port, dev_port = parse_ports(args.ports)
|
||||
ports_note = "(--ports)"
|
||||
else:
|
||||
existing_ports = extract_existing_ports(project_dir)
|
||||
existing_ports = extract_existing_ports(project_dir, main)
|
||||
if existing_ports:
|
||||
default_port, vite_port, dev_port = existing_ports
|
||||
ports_note = "(kept for upgrade)"
|
||||
@@ -1305,21 +1326,11 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
# Title for templates
|
||||
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
|
||||
tpl_vars = {
|
||||
"MODULE_NAME": module_name,
|
||||
@@ -1336,13 +1347,15 @@ 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
|
||||
tpl_vars["APP_MODULE"] = app_file.stem
|
||||
# Dotted module path relative to project dir (e.g. "paskia.fastapi.mainapp")
|
||||
app_module = ".".join(app_file.relative_to(project_dir).with_suffix("").parts)
|
||||
tpl_vars["APP_MODULE"] = app_module
|
||||
else:
|
||||
print("📍 No existing FastAPI app found, will create new one")
|
||||
app_file = None
|
||||
app_var = "app"
|
||||
tpl_vars["APP_VAR"] = app_var
|
||||
tpl_vars["APP_MODULE"] = "app"
|
||||
tpl_vars["APP_MODULE"] = f"{module_name}.app"
|
||||
|
||||
# Create directories
|
||||
if not dry:
|
||||
@@ -1414,11 +1427,8 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
# === Handle __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")
|
||||
main_fallback = main.with_suffix(".new.py")
|
||||
else:
|
||||
main_module_file = None
|
||||
main_fallback = module_dir / "__main__.new.py"
|
||||
|
||||
main_file = module_dir / "__main__.py"
|
||||
@@ -1445,28 +1455,27 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
else:
|
||||
# Existing CLI entrypoint: write our template as .new.py beside the existing module
|
||||
# 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,
|
||||
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 main.exists():
|
||||
content = main.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")
|
||||
new_file = main.with_suffix(".new.py")
|
||||
_write_fallback_file(
|
||||
main_module_file,
|
||||
main,
|
||||
new_file,
|
||||
new_content,
|
||||
dry=dry,
|
||||
executable=False,
|
||||
)
|
||||
elif main_module_file:
|
||||
print(f"⚠️ Existing CLI main module {main_module_file} not found")
|
||||
else:
|
||||
print(f"⚠️ Existing CLI main module {main} not found")
|
||||
|
||||
# === Update vite.config.js/ts ===
|
||||
frontend_dir = project_dir / "frontend"
|
||||
@@ -1564,9 +1573,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
||||
# 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("⚠️ 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:
|
||||
try:
|
||||
|
||||
@@ -17,12 +17,12 @@ def main():
|
||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
dev = {"reload": True, "reload_dirs": ["MODULE_NAME"]}
|
||||
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||
server.run(
|
||||
"MODULE_NAME.APP_MODULE:APP_VAR",
|
||||
"APP_MODULE:APP_VAR",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
**(dev if DEVMODE else {}),
|
||||
**dev,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ def main():
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
metavar="addr",
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
@@ -72,7 +72,7 @@ def main():
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
scripts/devserver.py [args to PROJECT_CLI]
|
||||
Other options are forwarded to PROJECT_CLI [args]
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||
|
||||
Reference in New Issue
Block a user