From 8d0bc13cc0d4a28e527df5d55c2b0acfbf5f5ff9 Mon Sep 17 00:00:00 2001 From: edraft Date: Mon, 13 Oct 2025 06:21:03 +0200 Subject: [PATCH] Added live dev server --- src/cli/cpl/cli/command/project/run.py | 2 +- src/cli/cpl/cli/command/project/start.py | 25 +++++ src/cli/cpl/cli/main.py | 4 +- src/cli/cpl/cli/model/project.py | 10 +- src/cli/cpl/cli/utils/live_server/__init__.py | 0 .../utils/live_server/file_event_handler.py | 51 +++++++++ .../cpl/cli/utils/live_server/live_server.py | 103 ++++++++++++++++++ src/cli/cpl/cli/utils/structure.py | 8 +- src/cli/cpl/cli/utils/venv.py | 2 +- src/cli/requirements.txt | 3 +- 10 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 src/cli/cpl/cli/command/project/start.py create mode 100644 src/cli/cpl/cli/utils/live_server/__init__.py create mode 100644 src/cli/cpl/cli/utils/live_server/file_event_handler.py create mode 100644 src/cli/cpl/cli/utils/live_server/live_server.py diff --git a/src/cli/cpl/cli/command/project/run.py b/src/cli/cpl/cli/command/project/run.py index 5bbd5aba..8b724a81 100644 --- a/src/cli/cpl/cli/command/project/run.py +++ b/src/cli/cpl/cli/command/project/run.py @@ -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) diff --git a/src/cli/cpl/cli/command/project/start.py b/src/cli/cpl/cli/command/project/start.py new file mode 100644 index 00000000..a380dcba --- /dev/null +++ b/src/cli/cpl/cli/command/project/start.py @@ -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() diff --git a/src/cli/cpl/cli/main.py b/src/cli/cpl/cli/main.py index be81881f..959c7bd0 100644 --- a/src/cli/cpl/cli/main.py +++ b/src/cli/cpl/cli/main.py @@ -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(): diff --git a/src/cli/cpl/cli/model/project.py b/src/cli/cpl/cli/model/project.py index cb78ba12..326eee68 100644 --- a/src/cli/cpl/cli/model/project.py +++ b/src/cli/cpl/cli/model/project.py @@ -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()) diff --git a/src/cli/cpl/cli/utils/live_server/__init__.py b/src/cli/cpl/cli/utils/live_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cli/cpl/cli/utils/live_server/file_event_handler.py b/src/cli/cpl/cli/utils/live_server/file_event_handler.py new file mode 100644 index 00000000..009a0b7b --- /dev/null +++ b/src/cli/cpl/cli/utils/live_server/file_event_handler.py @@ -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) diff --git a/src/cli/cpl/cli/utils/live_server/live_server.py b/src/cli/cpl/cli/utils/live_server/live_server.py new file mode 100644 index 00000000..acb98172 --- /dev/null +++ b/src/cli/cpl/cli/utils/live_server/live_server.py @@ -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() diff --git a/src/cli/cpl/cli/utils/structure.py b/src/cli/cpl/cli/utils/structure.py index a2c50dbe..c8952527 100644 --- a/src/cli/cpl/cli/utils/structure.py +++ b/src/cli/cpl/cli/utils/structure.py @@ -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) diff --git a/src/cli/cpl/cli/utils/venv.py b/src/cli/cpl/cli/utils/venv.py index c8806f33..580f0639 100644 --- a/src/cli/cpl/cli/utils/venv.py +++ b/src/cli/cpl/cli/utils/venv.py @@ -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 diff --git a/src/cli/requirements.txt b/src/cli/requirements.txt index 838c3e83..06b0dafe 100644 --- a/src/cli/requirements.txt +++ b/src/cli/requirements.txt @@ -1,2 +1,3 @@ cpl-core -click==8.3.0 \ No newline at end of file +click==8.3.0 +watchdog==6.0.0 \ No newline at end of file