diff --git a/nut/__main__.py b/nut/__main__.py index 5a7bad3..6b4a329 100644 --- a/nut/__main__.py +++ b/nut/__main__.py @@ -75,7 +75,11 @@ def cmd_build(args, flags) -> int: return 1 try: - compile_project(config, build_folder=build_folder_override, file_name=file_name_override) + compile_project( + config, + build_folder=build_folder_override, + file_name=file_name_override, + ) except (RuntimeError, OSError) as error: print(f"Error: Build failed: {error}") return 1 diff --git a/nut/commands/compiler.py b/nut/commands/compiler.py index 6a80dc8..414c922 100644 --- a/nut/commands/compiler.py +++ b/nut/commands/compiler.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -6,9 +7,35 @@ import shutil from nut.config import Config from nut.paths import get_templates_path from nut.utils import zip_dir +from nut.logger import logger + +@dataclass(frozen=True) +class CompileContext: + config: Config + project_root: Path + build_dir: Path + entry_path: Path + output_name: str + + +_ACTIVE_CONTEXT: Optional[CompileContext] = None + +class Compiler: + def run(self) -> None: + return None + +def get_compile_context() -> CompileContext: + if _ACTIVE_CONTEXT is None: + raise RuntimeError("No active compile context. Call only during compile().") + return _ACTIVE_CONTEXT + +def _resolve_project_path(project_root: Path, value: Path) -> Path: + if value.is_absolute(): + return value + return (project_root / value).resolve() def _format_to_filename(name: str) -> str: - formatted = name.lower().replace(" ", "_") + formatted = name.strip().lower().replace(" ", "_") if not formatted.endswith(".nutsi"): formatted += ".nutsi" return formatted @@ -20,7 +47,6 @@ def _clear_directory(directory: Path) -> None: else: child.unlink() - def prepare_build_directory(build_dir: Path) -> Path: if build_dir.exists() and not build_dir.is_dir(): raise RuntimeError(f"Build path exists but is not a directory: {build_dir}") @@ -44,24 +70,82 @@ def prepare_build_directory(build_dir: Path) -> Path: return build_dir -def generate_nutsi_file(build_dir: Path, file_name: str) -> None: +def stage_project_sources(project_root: Path, entry_path: Path, build_dir: Path) -> None: + if entry_path.is_absolute(): + raise RuntimeError( + f"build.entry must be a relative path, got absolute path: {entry_path}" + ) + + source_entry = (project_root / entry_path).resolve() + if not source_entry.is_file(): + raise RuntimeError( + f"build.entry does not point to a file: {entry_path} (resolved: {source_entry})" + ) + + entry_parent = entry_path.parent + if str(entry_parent) == ".": + destination_file = build_dir / source_entry.name + destination_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_entry, destination_file) + return + + source_dir = (project_root / entry_parent).resolve() + destination_dir = build_dir / entry_parent + + shutil.copytree( + source_dir, + destination_dir, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.nutsi"), + ) + +def generate_nutsi_file(build_dir: Path, file_name: str) -> Path: output_path = build_dir / file_name - zip_dir(build_dir, output_path, ignore_patterns=[ - "__pycache__", - "*.pyc", - "*.nutsi", - ]) + zip_dir( + str(build_dir), + str(output_path), + ignore_patterns=[ + "__pycache__", + "*.pyc", + "*.nutsi", + ], + ) + return output_path +def _create_context(config: Config, build_folder: Optional[str], file_name: Optional[str]) -> CompileContext: + project_root = config.path.parent.resolve() + entry_path = Path(config.build.entry) -def compile(config: Config, build_folder: Optional[str] = None, file_name: Optional[str] = None) -> None: - print("Compiling the project...") - - selected_name = _format_to_filename(file_name or config.build.name) selected_folder = build_folder or config.build.build_folder - prepared_dir = prepare_build_directory(Path(selected_folder)) + build_dir = _resolve_project_path(project_root, Path(selected_folder)) - generate_nutsi_file(prepared_dir, selected_name) + selected_name = file_name or config.build.name + output_name = _format_to_filename(selected_name) + if build_dir == project_root: + raise RuntimeError("buildFolder cannot point to project root") + return CompileContext( + config=config, + project_root=project_root, + build_dir=build_dir, + entry_path=entry_path, + output_name=output_name, + ) +def compile(config: Config, build_folder: Optional[str] = None, file_name: Optional[str] = None) -> Path: + global _ACTIVE_CONTEXT + + context = _create_context(config, build_folder=build_folder, file_name=file_name) + prepared_dir = prepare_build_directory(context.build_dir) + stage_project_sources(context.project_root, context.entry_path, prepared_dir) + + _ACTIVE_CONTEXT = context + try: + Compiler().run() + finally: + _ACTIVE_CONTEXT = None + + archive_path = generate_nutsi_file(prepared_dir, context.output_name) + return archive_path diff --git a/nut/logger.py b/nut/logger.py new file mode 100644 index 0000000..843bab9 --- /dev/null +++ b/nut/logger.py @@ -0,0 +1,105 @@ +# logger.py + +import time +from typing import Optional +from dataclasses import dataclass + +try: + from colorama import Fore, Style, init as _colorama_init + _colorama_init(autoreset=True) + COLOR_ENABLED = True +except Exception: + COLOR_ENABLED = False + + class Fore: + RED = "" + YELLOW = "" + GREEN = "" + CYAN = "" + WHITE = "" + + class Style: + RESET_ALL = "" + +START_TIME = time.perf_counter() + +LEVEL_COLORS = { + "LOG": Fore.WHITE, + "INFO": Fore.CYAN, + "SUCCESS": Fore.GREEN, + "WARNING": Fore.YELLOW, + "ERROR": Fore.RED, +} + +@dataclass +class Logger: + name: str + to_file: bool = False + file_path: Optional[str] = None + stdio: bool = True + enabled: bool = True + + def _time_since_start(self) -> float: + return time.perf_counter() - START_TIME + + def _format(self, level: str, message: str) -> str: + t = self._time_since_start() + prefix = f"[{t:0.3f}][{level}]" + if COLOR_ENABLED and level in LEVEL_COLORS: + prefix = f"{LEVEL_COLORS[level]}{prefix}{Style.RESET_ALL}" + return f"{prefix} {message}" + + def _write_file(self, text: str): + if not self.file_path: + return + with open(self.file_path, "a", encoding="utf-8") as f: + f.write(text + "\n") + + def _log(self, level: str, *args): + if not self.enabled: + return + message = " ".join(str(a) for a in args) + formatted = self._format(level, message) + if self.stdio: + print(formatted) + if self.to_file: + clean = f"[{self._time_since_start():0.3f}][{level}] {message}" + self._write_file(clean) + + def log(self, *args): + self._log("LOG", *args) + + def info(self, *args): + self._log("INFO", *args) + + def success(self, *args): + self._log("SUCCESS", *args) + + def warning(self, *args): + self._log("WARNING", *args) + + def error(self, *args): + self._log("ERROR", *args) + + def enable(self): + self.enabled = True + + def disable(self): + self.enabled = False + + +def create_logger(name: str = "default", toFile: bool = False, path: Optional[str] = None, stdio: bool = True) -> Logger: + return Logger( + name=name, + to_file=toFile, + file_path=path, + stdio=stdio, + enabled=True + ) + + +logger = create_logger( + name="main", + toFile=False, + stdio=True +) diff --git a/nut/utils.py b/nut/utils.py index 15ad053..cfa85e5 100644 --- a/nut/utils.py +++ b/nut/utils.py @@ -8,19 +8,21 @@ def _should_ignore(rel_path: str, patterns: list) -> bool: """ Check if a file should be ignored based on patterns. """ - rel_path = rel_path.replace("\\", "/") + normalized = rel_path.replace("\\", "/").lstrip("./") + basename = os.path.basename(normalized) for pattern in patterns: - pattern = pattern.strip() + pattern = pattern.strip().replace("\\", "/") if not pattern: continue if pattern.endswith("/"): - if rel_path.startswith(pattern): + prefix = pattern.rstrip("/") + if normalized == prefix or normalized.startswith(prefix + "/"): return True - if fnmatch.fnmatch(rel_path, pattern): + if fnmatch.fnmatch(normalized, pattern) or fnmatch.fnmatch(basename, pattern): return True return False @@ -38,14 +40,13 @@ def zip_dir(source_dir: str, output_zip_path: str, metadata: dict = None, ignore with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for root, dirs, files in os.walk(source_dir): - rel_root = os.path.relpath(root, source_dir).replace("\\", "/") # filter dirs in-place (important for performance) dirs[:] = [ d for d in dirs if not _should_ignore( - os.path.join(rel_root, d).replace("\\", "/"), + os.path.join(rel_root, d).replace("\\", "/").lstrip("./"), ignore_patterns ) ]