Added live dev server
All checks were successful
Test before pr merge / test-lint (pull_request) Successful in 6s
All checks were successful
Test before pr merge / test-lint (pull_request) Successful in 6s
This commit is contained in:
@@ -46,6 +46,6 @@ def run(project: str, args: list[str], dev: bool, verbose: bool):
|
|||||||
if verbose:
|
if verbose:
|
||||||
Console.write_line(f" with args {args}...")
|
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)
|
subprocess.run([python, executable, *args], cwd=path)
|
||||||
|
|||||||
25
src/cli/cpl/cli/command/project/start.py
Normal file
25
src/cli/cpl/cli/command/project/start.py
Normal 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()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from cpl.cli.cli import cli
|
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.package.update import update
|
||||||
from cpl.cli.command.project.build import build
|
from cpl.cli.command.project.build import build
|
||||||
from cpl.cli.command.project.run import run
|
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.structure.init import init
|
||||||
from cpl.cli.command.version import version
|
from cpl.cli.command.version import version
|
||||||
from cpl.cli.model.workspace import Workspace
|
from cpl.cli.model.workspace import Workspace
|
||||||
@@ -63,7 +65,7 @@ def configure():
|
|||||||
# run
|
# run
|
||||||
cli.add_command(build)
|
cli.add_command(build)
|
||||||
cli.add_command(run)
|
cli.add_command(run)
|
||||||
# cli.add_command(start)
|
cli.add_command(start)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class Project(CPLStructureModel):
|
|||||||
|
|
||||||
os.chdir(old_dir)
|
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):
|
if isinstance(dist, str):
|
||||||
dist = Path(dist)
|
dist = Path(dist)
|
||||||
|
|
||||||
@@ -249,7 +249,6 @@ class Project(CPLStructureModel):
|
|||||||
|
|
||||||
self.build_references(dist, verbose)
|
self.build_references(dist, verbose)
|
||||||
|
|
||||||
# Console.write_line(f"Building project {self.name}...")
|
|
||||||
def _build():
|
def _build():
|
||||||
if verbose:
|
if verbose:
|
||||||
Console.write_line(f" Collecting project '{self.name}' files...")
|
Console.write_line(f" Collecting project '{self.name}' files...")
|
||||||
@@ -272,6 +271,11 @@ class Project(CPLStructureModel):
|
|||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
Console.write_line(f" Copied {len(files)} files from {rel_dir} to {dist_path}")
|
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())
|
Console.spinner(f"Building project {self.name}...", lambda: _build())
|
||||||
|
|||||||
0
src/cli/cpl/cli/utils/live_server/__init__.py
Normal file
0
src/cli/cpl/cli/utils/live_server/__init__.py
Normal file
51
src/cli/cpl/cli/utils/live_server/file_event_handler.py
Normal file
51
src/cli/cpl/cli/utils/live_server/file_event_handler.py
Normal 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)
|
||||||
103
src/cli/cpl/cli/utils/live_server/live_server.py
Normal file
103
src/cli/cpl/cli/utils/live_server/live_server.py
Normal 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()
|
||||||
@@ -26,7 +26,6 @@ def get_project_by_name_or_path(project: str) -> Project:
|
|||||||
if p.name == project:
|
if p.name == project:
|
||||||
return Project.from_file(Path(p.path))
|
return Project.from_file(Path(p.path))
|
||||||
|
|
||||||
|
|
||||||
if not path.is_dir() and not path.is_file():
|
if not path.is_dir() and not path.is_file():
|
||||||
raise ValueError(f"Unknown project {project}")
|
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.")
|
raise ValueError(f"Project '{project}' not found.")
|
||||||
|
|
||||||
|
|
||||||
def create_pyproject_toml(project: Project, path: Path):
|
def create_pyproject_toml(project: Project, path: Path):
|
||||||
pyproject_path = path / "pyproject.toml"
|
pyproject_path = path / "pyproject.toml"
|
||||||
if pyproject_path.exists():
|
if pyproject_path.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
content = textwrap.dedent(f"""
|
content = textwrap.dedent(
|
||||||
|
f"""
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=70.1.0", "wheel", "build"]
|
requires = ["setuptools>=70.1.0", "wheel", "build"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
@@ -53,6 +54,7 @@ def create_pyproject_toml(project: Project, path: Path):
|
|||||||
authors = [{{name="{project.author or ''}"}}]
|
authors = [{{name="{project.author or ''}"}}]
|
||||||
license = "{project.license or ''}"
|
license = "{project.license or ''}"
|
||||||
dependencies = [{', '.join([f'"{dep}"' for dep in project.dependencies])}]
|
dependencies = [{', '.join([f'"{dep}"' for dep in project.dependencies])}]
|
||||||
""").lstrip()
|
"""
|
||||||
|
).lstrip()
|
||||||
|
|
||||||
pyproject_path.write_text(content)
|
pyproject_path.write_text(content)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def ensure_venv(start_path: Path | None = None) -> Path:
|
|||||||
else:
|
else:
|
||||||
venv_path = start_path / ".venv"
|
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)
|
venv.EnvBuilder(with_pip=True).create(venv_path)
|
||||||
return venv_path
|
return venv_path
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
cpl-core
|
cpl-core
|
||||||
click==8.3.0
|
click==8.3.0
|
||||||
|
watchdog==6.0.0
|
||||||
Reference in New Issue
Block a user