Added live dev server
All checks were successful
Test before pr merge / test-lint (pull_request) Successful in 6s

This commit is contained in:
2025-10-13 06:21:03 +02:00
parent 33728cdec3
commit 8d0bc13cc0
10 changed files with 198 additions and 10 deletions

View File

@@ -46,6 +46,6 @@ def run(project: str, args: list[str], dev: bool, verbose: bool):
if verbose:
Console.write_line(f" with args {args}...")
Console.write_line("\n\n") # add some space before output
Console.write_line("\n\n")
subprocess.run([python, executable, *args], cwd=path)

View File

@@ -0,0 +1,25 @@
import click
from cpl.cli.cli import cli
from cpl.cli.utils.live_server.live_server import LiveServer
from cpl.cli.utils.structure import get_project_by_name_or_path
from cpl.core.console import Console
@cli.command("start", aliases=["s"])
@click.argument("args", nargs=-1)
@click.option("--project", "-p", type=str)
@click.option("--dev", "-d", is_flag=True, help="Use sources instead of build output")
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
def start(project: str, args: list[str], dev: bool, verbose: bool):
project = get_project_by_name_or_path(project or "./")
if project.main is None:
Console.error(f"Project {project.name} has no executable")
return
LiveServer(
project,
args,
dev,
verbose,
).start()

View File

@@ -1,3 +1,4 @@
import sys
from pathlib import Path
from cpl.cli.cli import cli
@@ -8,6 +9,7 @@ from cpl.cli.command.package.uninstall import uninstall
from cpl.cli.command.package.update import update
from cpl.cli.command.project.build import build
from cpl.cli.command.project.run import run
from cpl.cli.command.project.start import start
from cpl.cli.command.structure.init import init
from cpl.cli.command.version import version
from cpl.cli.model.workspace import Workspace
@@ -63,7 +65,7 @@ def configure():
# run
cli.add_command(build)
cli.add_command(run)
# cli.add_command(start)
cli.add_command(start)
def main():

View File

@@ -232,7 +232,7 @@ class Project(CPLStructureModel):
os.chdir(old_dir)
def do_build(self, dist: Path, verbose: bool = False, parent: Self = None):
def do_build(self, dist: Path, verbose: bool = False, parent: Self = None, silent: bool = False):
if isinstance(dist, str):
dist = Path(dist)
@@ -249,7 +249,6 @@ class Project(CPLStructureModel):
self.build_references(dist, verbose)
# Console.write_line(f"Building project {self.name}...")
def _build():
if verbose:
Console.write_line(f" Collecting project '{self.name}' files...")
@@ -272,6 +271,11 @@ class Project(CPLStructureModel):
if verbose:
Console.write_line(f" Copied {len(files)} files from {rel_dir} to {dist_path}")
Console.write_line(" Done!")
if not silent:
Console.write_line(" Done!")
if silent:
_build()
return
Console.spinner(f"Building project {self.name}...", lambda: _build())

View File

@@ -0,0 +1,51 @@
import re
import time
from watchdog.events import FileSystemEventHandler, FileModifiedEvent, FileClosedEvent
class FileEventHandler(FileSystemEventHandler):
_ignore_patterns = [
r".*~$",
r".*\.swp$",
r".*\.swx$",
r".*\.tmp$",
r".*__pycache__.*",
r".*\.pytest_cache.*",
r".*/\.idea/.*",
r".*/\.vscode/.*",
r".*\.DS_Store$",
r"#.*#$",
]
_watch_extensions = (".py", ".json", ".yaml", ".yml", ".toml")
def __init__(self, on_save, debounce_seconds: float = 0.5):
super().__init__()
self._on_save = on_save
self._debounce = debounce_seconds
self._last_triggered = 0
self._last_file = None
def _should_ignore(self, path: str) -> bool:
for pattern in self._ignore_patterns:
if re.match(pattern, path):
return True
return not path.endswith(self._watch_extensions)
def _debounced_trigger(self, path: str):
now = time.time()
if path != self._last_file or now - self._last_triggered > self._debounce:
self._last_triggered = now
self._last_file = path
self._on_save(path)
def on_modified(self, event):
if not event.is_directory and isinstance(event, FileModifiedEvent):
if not self._should_ignore(event.src_path):
self._debounced_trigger(event.src_path)
def on_closed(self, event):
if not event.is_directory and isinstance(event, FileClosedEvent):
if not self._should_ignore(event.src_path):
self._debounced_trigger(event.src_path)

View File

@@ -0,0 +1,103 @@
import os
import subprocess
import time
from pathlib import Path
from watchdog.observers import Observer
from cpl.cli.model.project import Project
from cpl.cli.utils.live_server.file_event_handler import FileEventHandler
from cpl.cli.utils.venv import get_venv_python, ensure_venv
from cpl.core.configuration import Configuration
from cpl.core.console import Console
class LiveServer:
def __init__(self, project: Project, args: list[str], dev: bool = False, verbose: bool = False):
path = str(Path(project.path).parent.resolve().absolute())
executable = (Path(project.path).parent / Path(project.directory) / project.main).resolve().absolute()
self._dist_path = None
if not dev:
self._dist_path = Path(project.path).parent / "dist"
if Configuration.get("workspace") is not None:
self._dist_path = Path(Configuration.get("workspace").path).parent / "dist"
self._dist_path = Path(self._dist_path).resolve().absolute()
if verbose:
Console.write_line(f"Creating dist folder at {self._dist_path}...")
os.makedirs(self._dist_path, exist_ok=True)
project.do_build(self._dist_path, verbose)
path = self._dist_path / project.name / project.directory
main = project.main.replace(project.directory, "").lstrip("/\\")
executable = (path / main).resolve().absolute()
if not os.path.isfile(executable):
Console.error(f"Executable {executable} not found.")
return
self._project = project
self._sources = (Path(project.path).parent / Path(project.directory)).resolve().absolute()
self._executable = executable
self._working_directory = Path(path)
self._args = args
self._is_dev = dev
self._verbose = verbose
self._process = None
self._observer = None
self._python = str(get_venv_python(ensure_venv(Path("./"))).absolute())
def _run_executable(self):
if self._process:
self._process.terminate()
self._process.wait()
self._process = subprocess.Popen(
[self._python, str(self._executable), *self._args],
cwd=self._working_directory,
)
def _on_change(self, changed_file: str):
if self._verbose:
Console.write_line(f"Change detected: {changed_file}")
if self._is_dev and self._process:
self._process.terminate()
self._process.wait()
Console.write_line("Restart\n\n")
time.sleep(0.5) # debounce to avoid copy temp files
if not self._is_dev:
self._project.do_build(self._dist_path, verbose=self._verbose, silent=True)
self._run_executable()
def start(self):
handler = FileEventHandler(self._on_change)
observer = Observer()
observer.schedule(handler, str(self._sources), recursive=True)
observer.start()
self._observer = observer
Console.write_line("** CPL live development server is running **\n")
Console.write_line(f"Watching {self._sources} ... (Ctrl+C to stop)")
Console.write_line(f"Starting {self._executable} ...\n\n")
self._run_executable()
try:
while True:
time.sleep(1)
except KeyboardInterrupt as e:
time.sleep(1)
Console.write_line("Stopping...")
finally:
if self._process:
self._process.terminate()
self._process.wait()
observer.stop()
observer.join()
Console.close()

View File

@@ -26,7 +26,6 @@ def get_project_by_name_or_path(project: str) -> Project:
if p.name == project:
return Project.from_file(Path(p.path))
if not path.is_dir() and not path.is_file():
raise ValueError(f"Unknown project {project}")
@@ -37,12 +36,14 @@ def get_project_by_name_or_path(project: str) -> Project:
raise ValueError(f"Project '{project}' not found.")
def create_pyproject_toml(project: Project, path: Path):
pyproject_path = path / "pyproject.toml"
if pyproject_path.exists():
return
content = textwrap.dedent(f"""
content = textwrap.dedent(
f"""
[build-system]
requires = ["setuptools>=70.1.0", "wheel", "build"]
build-backend = "setuptools.build_meta"
@@ -53,6 +54,7 @@ def create_pyproject_toml(project: Project, path: Path):
authors = [{{name="{project.author or ''}"}}]
license = "{project.license or ''}"
dependencies = [{', '.join([f'"{dep}"' for dep in project.dependencies])}]
""").lstrip()
"""
).lstrip()
pyproject_path.write_text(content)

View File

@@ -28,7 +28,7 @@ def ensure_venv(start_path: Path | None = None) -> Path:
else:
venv_path = start_path / ".venv"
Console.write_line(f"Creating virtual environment at {venv_path}...")
Console.write_line(f"Creating virtual environment at {venv_path.absolute()}...")
venv.EnvBuilder(with_pip=True).create(venv_path)
return venv_path

View File

@@ -1,2 +1,3 @@
cpl-core
click==8.3.0
watchdog==6.0.0