From faedf328cb7c055d4c80e7e293d43964901597a6 Mon Sep 17 00:00:00 2001 From: edraft Date: Sat, 11 Oct 2025 15:50:38 +0200 Subject: [PATCH] Added build command --- src/cli/cpl.project.json | 16 ++- src/cli/cpl/cli/__init__.py | 1 - src/cli/cpl/cli/command/package/add.py | 9 +- src/cli/cpl/cli/command/package/install.py | 2 +- src/cli/cpl/cli/command/package/remove.py | 7 +- src/cli/cpl/cli/command/package/uninstall.py | 2 +- src/cli/cpl/cli/command/package/update.py | 2 +- .../command/{execute => project}/__init__.py | 0 src/cli/cpl/cli/command/project/build.py | 31 ++++++ .../cli/command/{execute => project}/run.py | 0 src/cli/cpl/cli/main.py | 9 +- src/cli/cpl/cli/model/build.py | 18 +++ src/cli/cpl/cli/model/cpl_structure_model.py | 11 ++ .../cpl/cli/model/cpl_sub_structure_model.py | 104 ++++++++++++++++++ src/cli/cpl/cli/model/project.py | 91 +++++++++++++++ src/cli/cpl/cli/utils/structure.py | 4 + src/cli/run | 1 - src/core/cpl.project.json | 10 +- 18 files changed, 302 insertions(+), 16 deletions(-) rename src/cli/cpl/cli/command/{execute => project}/__init__.py (100%) create mode 100644 src/cli/cpl/cli/command/project/build.py rename src/cli/cpl/cli/command/{execute => project}/run.py (100%) create mode 100644 src/cli/cpl/cli/model/build.py create mode 100644 src/cli/cpl/cli/model/cpl_sub_structure_model.py diff --git a/src/cli/cpl.project.json b/src/cli/cpl.project.json index f097547a..886fbd05 100644 --- a/src/cli/cpl.project.json +++ b/src/cli/cpl.project.json @@ -13,7 +13,19 @@ "devDependencies": { "black": "~25.9" }, - "references": [], + "references": [ + "../core/cpl.project.json" + ], "main": "cpl/cli/main.py", - "directory": "cpl/cli" + "directory": "cpl/cli", + "build": { + "include": [ + "_templates/" + ], + "exclude": [ + "**/__pycache__", + "**/logs", + "**/tests" + ] + } } \ No newline at end of file diff --git a/src/cli/cpl/cli/__init__.py b/src/cli/cpl/cli/__init__.py index a6d8caf7..5becc17c 100644 --- a/src/cli/cpl/cli/__init__.py +++ b/src/cli/cpl/cli/__init__.py @@ -1,2 +1 @@ __version__ = "1.0.0" -__version__ = "1.0.0" diff --git a/src/cli/cpl/cli/command/package/add.py b/src/cli/cpl/cli/command/package/add.py index b06bd675..ee4afc5d 100644 --- a/src/cli/cpl/cli/command/package/add.py +++ b/src/cli/cpl/cli/command/package/add.py @@ -1,3 +1,5 @@ +from pathlib import Path + import click from cpl.cli.cli import cli @@ -8,7 +10,8 @@ from cpl.core.console import Console @cli.command("add", aliases=["a"]) @click.argument("reference", type=click.STRING, required=True) @click.argument("target", type=click.STRING, required=True) -def add(reference: str, target: str): +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") +def add(reference: str, target: str, verbose: bool): reference_project = get_project_by_name_or_path(reference) target_project = get_project_by_name_or_path(target) @@ -17,6 +20,8 @@ def add(reference: str, target: str): if reference_project.path in target_project.references: raise ValueError(f"Project '{reference_project.name}' is already a reference of '{target_project.name}'") - target_project.references.append(reference_project.path) + + rel_path = Path(reference_project.path).relative_to(Path(target_project.path).parent, walk_up=True) + target_project.references.append(str(rel_path)) target_project.save() Console.write_line(f"Added '{reference_project.name}' to '{target_project.name}' project") diff --git a/src/cli/cpl/cli/command/package/install.py b/src/cli/cpl/cli/command/package/install.py index f20e76fe..d2bb4aa2 100644 --- a/src/cli/cpl/cli/command/package/install.py +++ b/src/cli/cpl/cli/command/package/install.py @@ -12,7 +12,7 @@ from cpl.core.console import Console @click.argument("package", type=click.STRING, required=False) @click.argument("project", type=click.STRING, required=False) @click.option("--dev", is_flag=True, help="Include dev dependencies") -@click.option("--verbose", is_flag=True, help="Enable verbose output") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") def install(package: str, project: str, dev: bool, verbose: bool): project = get_project_by_name_or_path(project or "./") diff --git a/src/cli/cpl/cli/command/package/remove.py b/src/cli/cpl/cli/command/package/remove.py index be337efb..230580fd 100644 --- a/src/cli/cpl/cli/command/package/remove.py +++ b/src/cli/cpl/cli/command/package/remove.py @@ -1,3 +1,5 @@ +from pathlib import Path + import click from cpl.cli.cli import cli @@ -15,9 +17,10 @@ def remove(reference: str, target: str): if reference_project.name == target_project.name: raise ValueError("Cannot add a project as a dependency to itself!") - if reference_project.path not in target_project.references: + rel_path = str(Path(reference_project.path).relative_to(Path(target_project.path).parent, walk_up=True)) + if rel_path not in target_project.references: raise ValueError(f"Project '{reference_project.name}' isn't a reference of '{target_project.name}'") - target_project.references.remove(reference_project.path) + target_project.references.remove(rel_path) target_project.save() Console.write_line(f"Removed '{reference_project.name}' from '{target_project.name}' project") diff --git a/src/cli/cpl/cli/command/package/uninstall.py b/src/cli/cpl/cli/command/package/uninstall.py index ba70495a..ef1b5b7e 100644 --- a/src/cli/cpl/cli/command/package/uninstall.py +++ b/src/cli/cpl/cli/command/package/uninstall.py @@ -12,7 +12,7 @@ from cpl.core.console import Console @click.argument("package", required=False) @click.argument("project", type=click.STRING, required=False) @click.option("--dev", is_flag=True, help="Include dev dependencies") -@click.option("--verbose", is_flag=True, help="Enable verbose output") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") def uninstall(package: str, project: str, dev: bool, verbose: bool): if package is None: package = Console.read("Package name to uninstall: ").strip() diff --git a/src/cli/cpl/cli/command/package/update.py b/src/cli/cpl/cli/command/package/update.py index ea116b84..fed28888 100644 --- a/src/cli/cpl/cli/command/package/update.py +++ b/src/cli/cpl/cli/command/package/update.py @@ -12,7 +12,7 @@ from cpl.core.console import Console @click.argument("package", type=click.STRING, required=False) @click.argument("project", type=click.STRING, required=False) @click.option("--dev", is_flag=True, help="Include dev dependencies") -@click.option("--verbose", is_flag=True, help="Enable verbose output") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") def update(package: str, project: str, dev: bool, verbose: bool): project = get_project_by_name_or_path(project or "./") diff --git a/src/cli/cpl/cli/command/execute/__init__.py b/src/cli/cpl/cli/command/project/__init__.py similarity index 100% rename from src/cli/cpl/cli/command/execute/__init__.py rename to src/cli/cpl/cli/command/project/__init__.py diff --git a/src/cli/cpl/cli/command/project/build.py b/src/cli/cpl/cli/command/project/build.py new file mode 100644 index 00000000..16d7718c --- /dev/null +++ b/src/cli/cpl/cli/command/project/build.py @@ -0,0 +1,31 @@ +import os.path +from pathlib import Path + +import click + +from cpl.cli.cli import cli +from cpl.cli.utils.structure import get_project_by_name_or_path +from cpl.core.configuration import Configuration +from cpl.core.console import Console + + +@cli.command("build", aliases=["b"]) +@click.argument("project", type=click.STRING, required=False) +@click.option("--dist", "-d", type=str) +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") +def build(project: str, dist: str | None, verbose: bool): + project = get_project_by_name_or_path(project or "./") + dist_path = dist or Path(project.path).parent / "dist" + + if dist is None and Configuration.get("workspace") is not None: + dist_path = Path(Configuration.get("workspace").path).parent / "dist" + + dist_path = Path(dist_path).resolve().absolute() + + if verbose: + Console.write_line(f"Creating dist folder at {dist_path}...") + + os.makedirs(dist_path, exist_ok=True) + + project.do_build(dist_path, verbose) + Console.write_line("\nDone!") diff --git a/src/cli/cpl/cli/command/execute/run.py b/src/cli/cpl/cli/command/project/run.py similarity index 100% rename from src/cli/cpl/cli/command/execute/run.py rename to src/cli/cpl/cli/command/project/run.py diff --git a/src/cli/cpl/cli/main.py b/src/cli/cpl/cli/main.py index 8cb00444..be81881f 100644 --- a/src/cli/cpl/cli/main.py +++ b/src/cli/cpl/cli/main.py @@ -1,14 +1,14 @@ -import os from pathlib import Path from cpl.cli.cli import cli -from cpl.cli.command.execute.run import run -from cpl.cli.command.package.remove import remove from cpl.cli.command.package.add import add -from cpl.cli.command.structure.init import init from cpl.cli.command.package.install import install +from cpl.cli.command.package.remove import remove 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.structure.init import init from cpl.cli.command.version import version from cpl.cli.model.workspace import Workspace from cpl.cli.utils.custom_command import script_command @@ -61,6 +61,7 @@ def configure(): cli.add_command(remove) # run + cli.add_command(build) cli.add_command(run) # cli.add_command(start) diff --git a/src/cli/cpl/cli/model/build.py b/src/cli/cpl/cli/model/build.py new file mode 100644 index 00000000..bfd87003 --- /dev/null +++ b/src/cli/cpl/cli/model/build.py @@ -0,0 +1,18 @@ +from cpl.cli.model.cpl_sub_structure_model import CPLSubStructureModel + + +class Build(CPLSubStructureModel): + + def __init__(self, include: list[str], exclude: list[str]): + CPLSubStructureModel.__init__(self) + + self._include = include + self._exclude = exclude + + @property + def include(self) -> list[str]: + return self._include + + @property + def exclude(self) -> list[str]: + return self._exclude diff --git a/src/cli/cpl/cli/model/cpl_structure_model.py b/src/cli/cpl/cli/model/cpl_structure_model.py index 867f30b6..ba32133a 100644 --- a/src/cli/cpl/cli/model/cpl_structure_model.py +++ b/src/cli/cpl/cli/model/cpl_structure_model.py @@ -1,8 +1,11 @@ import inspect import json +from inspect import isclass from pathlib import Path from typing import Any, Dict, List, Optional, Type, TypeVar +from cpl.cli.model.cpl_sub_structure_model import CPLSubStructureModel + T = TypeVar("T", bound="CPLStructureModel") @@ -35,6 +38,10 @@ class CPLStructureModel: kwargs[name] = str(path) continue + if isclass(param.annotation) and issubclass(param.annotation, CPLSubStructureModel): + kwargs[name] = param.annotation.from_json(data[name]) + continue + if name in data: kwargs[name] = data[name] continue @@ -63,6 +70,10 @@ class CPLStructureModel: if not key.startswith("_") or key == "_path": continue out_key = _self_or_cls_snake_to_camel(key[1:]) + + if isinstance(value, CPLSubStructureModel): + value = value.to_json() + result[out_key] = value return result diff --git a/src/cli/cpl/cli/model/cpl_sub_structure_model.py b/src/cli/cpl/cli/model/cpl_sub_structure_model.py new file mode 100644 index 00000000..0f6cf4f5 --- /dev/null +++ b/src/cli/cpl/cli/model/cpl_sub_structure_model.py @@ -0,0 +1,104 @@ +import inspect +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Type, TypeVar + +T = TypeVar("T", bound="CPLSubStructureModel") + + +class CPLSubStructureModel: + def __init__(self, path: Optional[str] = None): + self._path = path + + @classmethod + def from_json(cls: Type[T], data: Dict[str, Any]) -> T: + sig = inspect.signature(cls.__init__) + kwargs: Dict[str, Any] = {} + for name, param in list(sig.parameters.items())[1:]: + if name in data: + kwargs[name] = data[name] + continue + + priv = "_" + name + if priv in data: + kwargs[name] = data[priv] + continue + + camel = _self_or_cls_snake_to_camel(name) + if camel in data: + kwargs[name] = data[camel] + continue + + if param.default is not inspect._empty: + kwargs[name] = param.default + continue + + raise KeyError(f"Missing required field '{name}' for {cls.__name__}.") + + return cls(**kwargs) + + def to_json(self) -> Dict[str, Any]: + result: Dict[str, Any] = {} + for key, value in self.__dict__.items(): + if not key.startswith("_") or key == "_path": + continue + out_key = _self_or_cls_snake_to_camel(key[1:]) + result[out_key] = value + return result + + @staticmethod + def _require_str(value, field: str, allow_empty: bool = True) -> str: + if not isinstance(value, str): + raise TypeError(f"{field} must be of type str") + if not allow_empty and not value.strip(): + raise ValueError(f"{field} must not be empty") + return value + + @staticmethod + def _require_optional_non_empty_str(value, field: str) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + raise TypeError(f"{field} must be str or None") + s = value.strip() + if not s: + raise ValueError(f"{field} must not be empty when set") + return s + + @staticmethod + def _require_list_of_str(value, field: str) -> List[str]: + if not isinstance(value, list): + raise TypeError(f"{field} must be a list") + out: List[str] = [] + for i, v in enumerate(value): + if not isinstance(v, str): + raise TypeError(f"{field}[{i}] must be of type str") + s = v.strip() + if s: + out.append(s) + + seen = set() + uniq: List[str] = [] + for s in out: + if s not in seen: + seen.add(s) + uniq.append(s) + return uniq + + @staticmethod + def _require_dict_str_str(value, field: str) -> Dict[str, str]: + if not isinstance(value, dict): + raise TypeError(f"{field} must be a dict") + out: Dict[str, str] = {} + for k, v in value.items(): + if not isinstance(k, str) or not k.strip(): + raise TypeError(f"Keys in {field} must be non-empty strings") + if not isinstance(v, str) or not v.strip(): + raise TypeError(f"Values in {field} must be non-empty strings") + out[k.strip()] = v.strip() + return out + + +def _self_or_cls_snake_to_camel(s: str) -> str: + parts = s.split("_") + return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) diff --git a/src/cli/cpl/cli/model/project.py b/src/cli/cpl/cli/model/project.py index 1944a48f..37fa4e5d 100644 --- a/src/cli/cpl/cli/model/project.py +++ b/src/cli/cpl/cli/model/project.py @@ -1,8 +1,14 @@ +import fnmatch +import os import re +import shutil +from pathlib import Path from typing import Optional, List, Dict from urllib.parse import urlparse +from cpl.cli.model.build import Build from cpl.cli.model.cpl_structure_model import CPLStructureModel +from cpl.core.console import Console class Project(CPLStructureModel): @@ -44,6 +50,7 @@ class Project(CPLStructureModel): references: List[str], main: Optional[str], directory: str, + build: Build, ): CPLStructureModel.__init__(self, path) @@ -60,6 +67,7 @@ class Project(CPLStructureModel): self._references = references self._main = main self._directory = directory + self._build = build @property def name(self) -> str: @@ -176,3 +184,86 @@ class Project(CPLStructureModel): @directory.setter def directory(self, value: str): self._directory = self._require_str(value, "directory", allow_empty=False).strip() + + @property + def build(self) -> Build: + return self._build + + def _collect_files(self, rel_dir: Path) -> List[Path]: + files: List[Path] = [] + exclude_patterns = [p.strip() for p in self._build.exclude or []] + + for root, dirnames, filenames in os.walk(rel_dir, topdown=True): + root_path = Path(root) + rel_root = root_path.relative_to(rel_dir).as_posix() + + dirnames[:] = [ + d + for d in dirnames + if not any( + fnmatch.fnmatch(f"{rel_root}/{d}" if rel_root else d, pattern) or fnmatch.fnmatch(d, pattern) + for pattern in exclude_patterns + ) + ] + + for filename in filenames: + rel_path = f"{rel_root}/{filename}" if rel_root else filename + if any( + fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(filename, pattern) + for pattern in exclude_patterns + ): + continue + + files.append(root_path / filename) + + return files + + def build_references(self, dist: Path, verbose: bool = False): + references = [] + old_dir = os.getcwd() + os.chdir(Path(self.path).parent) + for ref in self.references: + os.chdir(Path(ref).parent) + references.append(Project.from_file(ref)) + + for p in references: + os.chdir(Path(p.path).parent) + p.do_build(dist, verbose) + + os.chdir(old_dir) + + def do_build(self, dist: Path, verbose: bool = False): + self.build_references(dist, verbose) + + Console.write_line(f"Building project {self.name}...") + if isinstance(dist, str): + dist = Path(dist) + + dist_path = dist / self.name + rel_dir = Path(self.path).parent / Path(self.directory) + + if verbose: + Console.write_line(f" Collecting project '{self.name}' files...") + + files = self._collect_files(rel_dir) + + if len(files) == 0: + return + + if verbose: + Console.write_line(f" Cleaning dist folder at {dist_path.absolute()}...") + + shutil.rmtree(str(dist_path), ignore_errors=True) + + for file in files: + rel_path = file.relative_to(rel_dir) + dest_file_path = dist_path / rel_path + + if not dest_file_path.parent.exists(): + os.makedirs(dest_file_path.parent, exist_ok=True) + + shutil.copy(file, dest_file_path) + + if verbose: + Console.write_line(f" Copied {len(files)} files from {rel_dir.absolute()} to {dist_path.absolute()}") + Console.write_line(" Done!") diff --git a/src/cli/cpl/cli/utils/structure.py b/src/cli/cpl/cli/utils/structure.py index a5039850..0044bd24 100644 --- a/src/cli/cpl/cli/utils/structure.py +++ b/src/cli/cpl/cli/utils/structure.py @@ -25,6 +25,10 @@ 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}") + if workspace.default_project is not None: for p in workspace.actual_projects: if p.name == workspace.default_project: diff --git a/src/cli/run b/src/cli/run index 2acff0b4..580c69e9 100755 --- a/src/cli/run +++ b/src/cli/run @@ -8,6 +8,5 @@ export PYTHONPATH="$ROOT_DIR/core:$ROOT_DIR/cli:$PYTHONPATH" old_dir="$(pwd)" cd ../ -echo "$@" python -m cpl.cli.main "$@" cd "$old_dir" \ No newline at end of file diff --git a/src/core/cpl.project.json b/src/core/cpl.project.json index 7a3bd3b2..5cebf59d 100644 --- a/src/core/cpl.project.json +++ b/src/core/cpl.project.json @@ -20,5 +20,13 @@ }, "references": [], "main": null, - "directory": "cpl/core" + "directory": "cpl/core", + "build": { + "include": [], + "exclude": [ + "**/__pycache__", + "**/logs", + "**/tests" + ] + } } \ No newline at end of file