Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbce326798 | ||
|
|
218c9e203b | ||
|
|
c9557d4308 | ||
|
|
372d249a85 | ||
|
|
a19c9028c5 | ||
|
|
465206067b | ||
|
|
c869783b0b | ||
|
|
c7577e9ff7 |
No files matched your search
@@ -9,7 +9,7 @@ import time
|
|||||||
from ipaddress import IPv6Address
|
from ipaddress import IPv6Address
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from .termwidth import pad_display
|
from .tty import pad_display, use_color
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from uvicorn._types import (
|
from uvicorn._types import (
|
||||||
@@ -388,6 +388,7 @@ class AccessLogMiddleware:
|
|||||||
if message["type"] == "websocket.accept" and not accepted:
|
if message["type"] == "websocket.accept" and not accepted:
|
||||||
accepted = True
|
accepted = True
|
||||||
ws_id = _next_ws_id()
|
ws_id = _next_ws_id()
|
||||||
|
www_scope["state"]["logid"] = f"{_WS_OPEN}{ws_id}{_RESET}" if use_color() else ws_id
|
||||||
fields = _ws_open_extra(www_scope, ws_id, origin, _extra())
|
fields = _ws_open_extra(www_scope, ws_id, origin, _extra())
|
||||||
logger.info(_assemble_access_log(fields), extra=fields)
|
logger.info(_assemble_access_log(fields), extra=fields)
|
||||||
elif message["type"] == "websocket.http.response.start" and not closed:
|
elif message["type"] == "websocket.http.response.start" and not closed:
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
@@ -32,34 +29,15 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .accesslog import AccessLogMiddleware
|
from .accesslog import AccessLogMiddleware
|
||||||
from .environ import env
|
from .environ import env
|
||||||
|
from .tty import strip_ansi, use_color
|
||||||
|
|
||||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
RESET = "\033[0m"
|
||||||
|
|
||||||
ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s"
|
ACCESS_LOG_FMT = "%(client)s %(status)s %(method)s %(host)s%(path)s %(extra)s%(timing)s"
|
||||||
|
|
||||||
ACCESS_LOGGER = "fastapi_vue.access"
|
ACCESS_LOGGER = "fastapi_vue.access"
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(text: str) -> str:
|
|
||||||
"""Remove ANSI escape codes from text."""
|
|
||||||
return ANSI_ESCAPE_RE.sub("", text)
|
|
||||||
|
|
||||||
|
|
||||||
def use_color(stream: io.TextIOBase = sys.stderr) -> bool:
|
|
||||||
"""Test if the stream supports color codes."""
|
|
||||||
if os.environ.get("NO_COLOR"): # Non empty means no (no-color.org)
|
|
||||||
return False
|
|
||||||
if os.environ.get("FORCE_COLOR", "") not in {"", "0"}: # force-color.org, node
|
|
||||||
return True
|
|
||||||
if hasattr(stream, "isatty") and stream.isatty():
|
|
||||||
return True
|
|
||||||
with suppress(KeyError, ValueError, OSError): # Journald does color (-ocat)
|
|
||||||
dev, ino = map(int, os.environ["JOURNAL_STREAM"].split(":", 1))
|
|
||||||
st = os.fstat(stream.fileno())
|
|
||||||
return st.st_dev == dev and st.st_ino == ino
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
_LEVEL_EMOJI = {
|
_LEVEL_EMOJI = {
|
||||||
logging.DEBUG: "🐛",
|
logging.DEBUG: "🐛",
|
||||||
logging.INFO: "🔷",
|
logging.INFO: "🔷",
|
||||||
@@ -113,7 +91,7 @@ class Formatter(logging.Formatter):
|
|||||||
if use_colors in (True, False):
|
if use_colors in (True, False):
|
||||||
self.use_colors = use_colors
|
self.use_colors = use_colors
|
||||||
else:
|
else:
|
||||||
self.use_colors = use_color(sys.stdout)
|
self.use_colors = use_color()
|
||||||
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
|
super().__init__(fmt=fmt, datefmt=datefmt, style=style)
|
||||||
|
|
||||||
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
|
def formatMessage(self, record: logging.LogRecord) -> str: # noqa: N802
|
||||||
@@ -122,7 +100,14 @@ class Formatter(logging.Formatter):
|
|||||||
return _level_prefix(record) + record.getMessage()
|
return _level_prefix(record) + record.getMessage()
|
||||||
formatted = super().formatMessage(record)
|
formatted = super().formatMessage(record)
|
||||||
if not self.use_colors:
|
if not self.use_colors:
|
||||||
formatted = strip_ansi(formatted)
|
return strip_ansi(formatted)
|
||||||
|
# Guard against app-supplied fields (``extra``) carrying raw color
|
||||||
|
# codes without a reset: ensure the line begins and ends with a
|
||||||
|
# reset, but only add one where it's missing to avoid duplicates.
|
||||||
|
if not formatted.startswith(RESET):
|
||||||
|
formatted = RESET + formatted
|
||||||
|
if not formatted.endswith(RESET):
|
||||||
|
formatted += RESET
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ from .logging import (
|
|||||||
patch_lifespan_logging,
|
patch_lifespan_logging,
|
||||||
patch_log_config,
|
patch_log_config,
|
||||||
patch_server_error_middleware,
|
patch_server_error_middleware,
|
||||||
use_color,
|
|
||||||
)
|
)
|
||||||
from .startupbox import print_box
|
from .startupbox import print_box
|
||||||
|
from .tty import use_color
|
||||||
|
|
||||||
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
|
tracerite.load() # Early load on CLI load (import server); uvicorn workers reload via log config
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .termwidth import display_width, pad_display
|
from .tty import display_width, pad_display
|
||||||
|
|
||||||
|
|
||||||
def print_box(text: str) -> None:
|
def print_box(text: str) -> None:
|
||||||
"""Print text in a rounded unicode box sized to its longest line.
|
"""Print text in a rounded unicode box sized to its longest line.
|
||||||
|
|
||||||
The text is split on newlines as-is (not trimmed). Padding accounts
|
The text is split on newlines as-is (not trimmed). Padding accounts
|
||||||
for ANSI colors and unicode display width (see termwidth.display_width).
|
for ANSI colors and unicode display width (see tty.display_width).
|
||||||
"""
|
"""
|
||||||
lines = text.split("\n")
|
lines = text.split("\n")
|
||||||
width = max(map(display_width, lines))
|
width = max(map(display_width, lines))
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
"""Terminal display width calculation for unicode and ANSI-colored text."""
|
"""Terminal color detection, ANSI helpers, and display width calculation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
"""Remove ANSI escape codes from text."""
|
||||||
|
return ANSI_ESCAPE_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def use_color(stream: io.TextIOBase = sys.stderr) -> bool:
|
||||||
|
"""Test if the stream supports color codes."""
|
||||||
|
if os.environ.get("NO_COLOR"): # Non empty means no (no-color.org)
|
||||||
|
return False
|
||||||
|
if os.environ.get("FORCE_COLOR", "") not in {"", "0"}: # force-color.org, node
|
||||||
|
return True
|
||||||
|
if hasattr(stream, "isatty") and stream.isatty():
|
||||||
|
return True
|
||||||
|
with suppress(KeyError, ValueError, OSError): # Journald does color (-ocat)
|
||||||
|
dev, ino = map(int, os.environ["JOURNAL_STREAM"].split(":", 1))
|
||||||
|
st = os.fstat(stream.fileno())
|
||||||
|
return st.st_dev == dev and st.st_ino == ino
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_wide(char: str) -> bool:
|
def _is_wide(char: str) -> bool:
|
||||||
"""Return True for characters rendered as two terminal columns."""
|
"""Return True for characters rendered as two terminal columns."""
|
||||||
if unicodedata.east_asian_width(char) in {"F", "W"}:
|
if unicodedata.east_asian_width(char) in {"F", "W"}:
|
||||||
+272
-77
@@ -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
|
||||||
|
|
||||||
@@ -1267,38 +1305,130 @@ def merge_pyproject(
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _check_node_version(node_path: str) -> None:
|
||||||
|
"""Check if Node.js version is >= 20.
|
||||||
|
|
||||||
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # noqa: S603
|
||||||
|
[node_path, "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
version_str = result.stdout.strip()
|
||||||
|
# Parse version like "v20.10.0" or "v18.17.1"
|
||||||
|
match = re.match(r"v(\d+)", version_str)
|
||||||
|
if match:
|
||||||
|
major_version = int(match.group(1))
|
||||||
|
if major_version >= MIN_NODE_VERSION:
|
||||||
|
return
|
||||||
|
msg = f"Node.js {version_str} found, but v{MIN_NODE_VERSION}+ required"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||||
|
pass
|
||||||
|
msg = "Could not determine Node.js version"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npm_runtime(tool: str) -> bool:
|
||||||
|
"""Validate npm runtime by checking Node.js version. Returns True if valid."""
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _find_runtime_from_env(options: list[str]) -> tuple[str, str] | None:
|
||||||
|
"""Find runtime specified by JS_RUNTIME environment variable."""
|
||||||
|
js_runtime_env = os.environ.get("JS_RUNTIME")
|
||||||
|
if not js_runtime_env:
|
||||||
|
return None
|
||||||
|
|
||||||
|
js_runtime = js_runtime_env
|
||||||
|
js_path = Path(js_runtime)
|
||||||
|
runtime_name = js_path.name
|
||||||
|
|
||||||
|
# Map node to npm
|
||||||
|
if runtime_name == "node":
|
||||||
|
runtime_name = "npm"
|
||||||
|
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
if option != runtime_name and not runtime_name.startswith(option):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool = shutil.which(js_runtime)
|
||||||
|
if tool is None:
|
||||||
|
print(f"⚠️ JS_RUNTIME={js_runtime_env}: {option} not found")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if option == "npm":
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path is None:
|
||||||
|
print(f"⚠️ JS_RUNTIME={js_runtime_env}: node not found")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"⚠️ JS_RUNTIME={js_runtime_env}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
print(f"⚠️ JS_RUNTIME={js_runtime_env} not recognized")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_detect_runtime(options: list[str]) -> tuple[str, str] | None:
|
||||||
|
"""Auto-detect JavaScript runtime from available options."""
|
||||||
|
node_version_error: RuntimeError | None = None
|
||||||
|
|
||||||
|
for option in options:
|
||||||
|
tool = shutil.which(option)
|
||||||
|
if not tool:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if option == "npm" and not _validate_npm_runtime(tool):
|
||||||
|
try:
|
||||||
|
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||||
|
if node_path:
|
||||||
|
_check_node_version(node_path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
node_version_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return tool, option
|
||||||
|
|
||||||
|
if node_version_error:
|
||||||
|
print(f"⚠️ {node_version_error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def find_js_runtime() -> tuple[str, str] | None:
|
def find_js_runtime() -> tuple[str, str] | None:
|
||||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||||
|
|
||||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
Returns (tool_path, tool_name) where tool_name is "npm", "deno", or "bun".
|
||||||
Returns None if no runtime is found.
|
Returns None if no runtime is found.
|
||||||
"""
|
"""
|
||||||
options = ["deno", "npm", "bun"]
|
options = ["npm", "deno", "bun"]
|
||||||
|
|
||||||
# Check for JS_RUNTIME environment variable
|
# Check for JS_RUNTIME environment variable
|
||||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
if result := _find_runtime_from_env(options):
|
||||||
js_runtime = js_runtime_env
|
return result
|
||||||
js_path = Path(js_runtime)
|
if os.environ.get("JS_RUNTIME"):
|
||||||
runtime_name = js_path.name
|
|
||||||
# Map node to npm
|
|
||||||
if runtime_name == "node":
|
|
||||||
runtime_name = "npm"
|
|
||||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
|
||||||
for option in options:
|
|
||||||
if option == runtime_name or runtime_name.startswith(option):
|
|
||||||
tool = shutil.which(js_runtime)
|
|
||||||
if tool is None:
|
|
||||||
print(f"⚠️ JS_RUNTIME={js_runtime_env} not found")
|
|
||||||
return None
|
|
||||||
return tool, option
|
|
||||||
print(f"⚠️ JS_RUNTIME={js_runtime_env} not recognized")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Auto-detect
|
# Auto-detect
|
||||||
for option in options:
|
return _auto_detect_runtime(options)
|
||||||
if tool := shutil.which(option):
|
|
||||||
return tool, option
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool:
|
def ensure_python_project(project_dir: Path, *, dry: bool = False) -> bool:
|
||||||
@@ -1308,17 +1438,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():
|
||||||
@@ -1341,7 +1474,7 @@ def ensure_frontend(
|
|||||||
# Find JS runtime
|
# Find JS runtime
|
||||||
runtime = find_js_runtime()
|
runtime = find_js_runtime()
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
print("❌ No JavaScript runtime found (need deno, npm, or bun)")
|
print("❌ No JavaScript runtime found (need Node.js v20+/npm, deno, or bun)")
|
||||||
return False
|
return False
|
||||||
js_tool, js_name = runtime
|
js_tool, js_name = runtime
|
||||||
|
|
||||||
@@ -1361,14 +1494,37 @@ def ensure_frontend(
|
|||||||
print(f"🎨 Would run: {' '.join(create_cmd)}")
|
print(f"🎨 Would run: {' '.join(create_cmd)}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Remember pre-existing content so a failed create-vue can be cleaned up
|
||||||
|
pre_existing = set(frontend_dir.iterdir()) if frontend_dir.exists() else set()
|
||||||
|
|
||||||
print("🎨 No frontend/ found, creating Vue project...")
|
print("🎨 No frontend/ found, creating Vue project...")
|
||||||
print(f">>> {' '.join(create_cmd)}")
|
print(f">>> {' '.join(create_cmd)}")
|
||||||
if not vue_args:
|
if not vue_args:
|
||||||
print("(Follow the prompts to configure your Vue app)")
|
print("(Follow the prompts to configure your Vue app)")
|
||||||
print()
|
print()
|
||||||
result = subprocess.run(create_cmd, cwd=project_dir, check=False) # noqa: S603
|
try:
|
||||||
if result.returncode != 0:
|
result = subprocess.run(create_cmd, cwd=project_dir, check=False) # noqa: S603
|
||||||
print("❌ create-vue failed")
|
except KeyboardInterrupt:
|
||||||
|
result = None
|
||||||
|
# create-vue exits 0 when its prompts are cancelled, so a zero return
|
||||||
|
# code alone doesn't mean a project was created.
|
||||||
|
if result is None or result.returncode != 0 or not package_json.exists():
|
||||||
|
reason = "interrupted" if result is None else "failed or was cancelled"
|
||||||
|
print(f"❌ create-vue {reason}")
|
||||||
|
leftovers = (
|
||||||
|
set(frontend_dir.iterdir()) - pre_existing if frontend_dir.exists() else set()
|
||||||
|
)
|
||||||
|
if leftovers:
|
||||||
|
if pre_existing:
|
||||||
|
for path in leftovers:
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
print("🧹 Removed partial files created by create-vue")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(frontend_dir, ignore_errors=True)
|
||||||
|
print("🧹 Removed partial frontend/")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1398,20 +1554,26 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|||||||
print("🏃 DRY RUN MODE - no changes will be made\n")
|
print("🏃 DRY RUN MODE - no changes will be made\n")
|
||||||
|
|
||||||
# Create project directory if it doesn't exist
|
# Create project directory if it doesn't exist
|
||||||
|
created_project_dir = False
|
||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
if dry:
|
if dry:
|
||||||
print(f"✅ Would create directory: {project_dir}")
|
print(f"✅ Would create directory: {project_dir}")
|
||||||
else:
|
else:
|
||||||
project_dir.mkdir(parents=True)
|
project_dir.mkdir(parents=True)
|
||||||
|
created_project_dir = True
|
||||||
print(f"✅ Created {project_dir}")
|
print(f"✅ Created {project_dir}")
|
||||||
|
|
||||||
print(f"🔧 Setting up project: {project_dir}")
|
print(f"🔧 Setting up project: {project_dir}")
|
||||||
|
|
||||||
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
# Step 1: Ensure frontend exists (do this first so cancellation doesn't leave partial setup)
|
||||||
if not ensure_frontend(project_dir, vue_args=args.vue_args, dry=dry):
|
if not ensure_frontend(project_dir, vue_args=args.vue_args, dry=dry):
|
||||||
|
if created_project_dir:
|
||||||
|
shutil.rmtree(project_dir, ignore_errors=True)
|
||||||
|
print(f"🧹 Removed {project_dir}")
|
||||||
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 +1586,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 +1601,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 +1651,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 +1666,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 +1750,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 +1839,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 +1865,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 +1902,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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,23 +10,19 @@ from pathlib import Path
|
|||||||
|
|
||||||
MIN_NODE_VERSION = 20
|
MIN_NODE_VERSION = 20
|
||||||
|
|
||||||
# Duplicated from fastapi_vue.logging because build environment is isolated
|
|
||||||
_LEVEL_EMOJI = {
|
|
||||||
logging.DEBUG: "🐛",
|
|
||||||
logging.INFO: "🔷",
|
|
||||||
logging.WARNING: "❗",
|
|
||||||
logging.ERROR: "🛑",
|
|
||||||
logging.CRITICAL: "🚨",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class _Formatter(logging.Formatter):
|
class _Formatter(logging.Formatter):
|
||||||
"""Emoji level prefix formatter, mirroring fastapi_vue.logging.Formatter."""
|
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||||
|
|
||||||
|
INFO and below pass through unprefixed so messages can use their own
|
||||||
|
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
emoji = _LEVEL_EMOJI.get(record.levelno)
|
if record.levelno >= logging.ERROR:
|
||||||
prefix = f"{emoji} " if emoji else f"{record.levelname}: "
|
return f"🛑 {record.getMessage()}"
|
||||||
return prefix + record.getMessage()
|
if record.levelno >= logging.WARNING:
|
||||||
|
return f"💣 {record.getMessage()}"
|
||||||
|
return record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
_handler = logging.StreamHandler()
|
_handler = logging.StreamHandler()
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
|||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||||
logger.info("✓ Backend ready!")
|
logger.info("🟢 Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.error("Backend at %s didn't start in time", url)
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
|
|||||||
Reference in new issue
Block a user