diff --git a/nut/__main__.py b/nut/__main__.py index 64984ac..5a7bad3 100644 --- a/nut/__main__.py +++ b/nut/__main__.py @@ -1,9 +1,9 @@ from .cli import CLI -from .config import load_config +from .config import ConfigError, load_config from .commands.setup import is_nut_workspace, find_template_conflicts, copy_template_files -from .commands.compiler import compile +from .commands.compiler import compile as compile_project def help_text(): return [ @@ -37,17 +37,48 @@ def main() -> int: description=( "Compile the project according to the configuration in the Nutfile. " ), + ).option( + "config", + "c", + default="Nutfile", + help="Path to Nutfile (default: ./Nutfile)", + ).option( + "build-folder", + "o", + default=None, + help="Override output build folder from config", + ).option( + "name", + "n", + default=None, + help="Override output file name from config", ).handle(cmd_build) return cli.run() def cmd_build(args, flags) -> int: - if not is_nut_workspace(): + config_path = flags["config"] + build_folder_override = flags["build_folder"] + file_name_override = flags["name"] + + if config_path == "Nutfile" and not is_nut_workspace(): print("Error: No Nutfile found. Please run 'nut init' to create a workspace.") return 1 - config = load_config() - compile(config) + try: + config = load_config(config_path) + except FileNotFoundError: + print(f"Error: Config file not found: {config_path}") + return 1 + except ConfigError as error: + print(f"Error: Invalid Nutfile: {error}") + return 1 + + try: + 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 return 0 diff --git a/nut/commands/compiler.py b/nut/commands/compiler.py index 321eff1..6a80dc8 100644 --- a/nut/commands/compiler.py +++ b/nut/commands/compiler.py @@ -1,35 +1,67 @@ -from nut.config import Config -from nut.paths import get_templates_path -from os import remove, makedirs, rmdir, listdir +from pathlib import Path +from typing import Optional + import shutil -def prepare_build_directory(path: str) -> None: - if path.exists(path): - if not path.isdir(path): - raise RuntimeError(f"Build path exists but is not a directory: {path}") - - for item in listdir(path): - item_path = path.join(path, item) - if path.isdir(item_path): - rmdir(item_path) - else: - remove(item_path) - else: - makedirs(path) +from nut.config import Config +from nut.paths import get_templates_path +from nut.utils import zip_dir + +def _format_to_filename(name: str) -> str: + formatted = name.lower().replace(" ", "_") + if not formatted.endswith(".nutsi"): + formatted += ".nutsi" + return formatted + +def _clear_directory(directory: Path) -> None: + for child in directory.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + 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}") + + build_dir.mkdir(parents=True, exist_ok=True) + _clear_directory(build_dir) builtins_path = get_templates_path() / "builtins" - if not path.exists(builtins_path): - raise RuntimeError(f"Builtins path does not exist: {builtins_path}, this should not happen, installation may be corrupted") - + if not builtins_path.is_dir(): + raise RuntimeError( + "Builtins path does not exist: " + f"{builtins_path}. Installation may be corrupted." + ) + shutil.copytree( builtins_path, - path.join(path, "builtins"), - dirs_exist_ok=True + build_dir / "builtins", + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), ) -def compile(config: Config) -> None: - print("Compiling the project...") - - prepare_build_directory(config.buildFolder or "build") + return build_dir + +def generate_nutsi_file(build_dir: Path, file_name: str) -> None: + output_path = build_dir / file_name + + zip_dir(build_dir, output_path, ignore_patterns=[ + "__pycache__", + "*.pyc", + "*.nutsi", + ]) + + +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)) + + generate_nutsi_file(prepared_dir, selected_name) + + - print("Compilation completed successfully.") diff --git a/nut/config.py b/nut/config.py index 82497fd..37401f5 100644 --- a/nut/config.py +++ b/nut/config.py @@ -1,31 +1,64 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + from .utils import read_yaml -class Node: - def __init__(self, data): - for key, value in data.items(): - if isinstance(value, dict): - value = Node(value) - elif isinstance(value, list): - value = [ - Node(item) if isinstance(item, dict) else item - for item in value - ] - setattr(self, key, value) +class ConfigError(ValueError): + """Raised when Nutfile contents are invalid.""" - def __repr__(self): - return f"" - +@dataclass(frozen=True) +class BuildConfig: + name: str + build_folder: str + entry: str + + @property + def buildFolder(self) -> str: + # Compatibility alias for existing camelCase references. + return self.build_folder + +@dataclass(frozen=True) class Config: - def __init__(self, path: str): - self.path = path - self.raw = read_yaml(path) - self.root = Node(self.raw) + path: Path + raw: Dict[str, Any] + build: BuildConfig - def __getattr__(self, item): - if not hasattr(self.root, item): - return None - - return getattr(self.root, item) + @property + def buildFolder(self) -> str: + # Compatibility alias for older call-sites. + return self.build.build_folder -def load_config(): - return Config("Nutfile") +def _ensure_dict(value: Any, context: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise ConfigError(f"{context} must be a mapping") + return value + +def _read_string(mapping: Dict[str, Any], key: str, default: str) -> str: + value = mapping.get(key, default) + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"build.{key} must be a non-empty string") + return value + +def load_config(path: str = "Nutfile") -> Config: + config_path = Path(path) + raw_data: Optional[Dict[str, Any]] = read_yaml(str(config_path)) + + if raw_data is None: + raw_data = {} + raw_dict = _ensure_dict(raw_data, "Nutfile root") + + build_section = raw_dict.get("build", {}) + build_dict = _ensure_dict(build_section, "build section") + + build = BuildConfig( + name=_read_string(build_dict, "name", "Example Build"), + build_folder=_read_string(build_dict, "buildFolder", "build"), + entry=_read_string(build_dict, "entry", "src/main.nut"), + ) + + return Config( + path=config_path, + raw=raw_dict, + build=build, + ) diff --git a/nut/templates/builtins/__init__.py b/nut/templates/builtins/__init__.py new file mode 100644 index 0000000..e69de29