Compare commits

...

2 Commits

6 changed files with 191 additions and 112 deletions

View File

@ -5,3 +5,5 @@
### a nut ### a nut
#### a nut #### a nut
test

View File

@ -0,0 +1 @@
__version__ = "0.0.1"

View File

@ -1,9 +1,5 @@
from .cli import CLI from .cli import CLI
from .commands import setup
from .config import ConfigError, load_config
from .commands.setup import is_chipnut_workspace, find_template_conflicts, copy_template_files
from .commands.compiler import compile as compile_project
def help_text(): def help_text():
return [ return [
@ -12,96 +8,8 @@ def help_text():
def main() -> int: def main() -> int:
cli = CLI(help_callback=help_text) cli = CLI(help_callback=help_text)
setup(cli)
cli.command(
"init",
help="Create an empty chipnut workspace or reinitialize with --force",
description=(
"Initialize a chipnut development workspace by creating a Nutfile and default project files. "
"If a Nutfile already exists, initialization should fail unless --force is used."
),
).flag(
"force",
"f",
help="Reinitialize even when a Nutfile already exists",
).arg(
"path",
nargs="?",
default=".",
help="Directory to initialize (default: current directory)",
).handle(cmd_init)
cli.command(
"build",
help="Compile the project",
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() return cli.run()
def cmd_build(args, flags) -> int:
config_path = flags["config"]
build_folder_override = flags["build_folder"]
file_name_override = flags["name"]
if config_path == "Nutfile" and not is_chipnut_workspace():
print("Error: No Nutfile found. Please run 'chipnut init' to create a workspace.")
return 1
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
def cmd_init(args, flags) -> int:
dest_dir = args[0]
force = flags["force"]
conflicts = find_template_conflicts(dest_dir)
if conflicts and not force:
print("Some files already exist that would be overwritten:")
for file in conflicts:
print(f" {file}")
print("Use --force to overwrite existing files.")
return 1
copy_template_files(dest_dir, force=force, checked=True)
return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@ -0,0 +1,8 @@
from ..cli import CLI
from .setup import setup as cmd_setup
from .compiler import setup as cmd_build
def setup(cli: CLI):
cmd_setup(cli)
cmd_build(cli)

View File

@ -2,12 +2,14 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import os
import shutil import shutil
from ..config import Config from ..cli import CLI
from ..config import Config, ConfigError, load_config
from ..paths import get_templates_path from ..paths import get_templates_path
from ..utils import zip_dir from ..utils import zip_dir
from ..logger import logger
@dataclass(frozen=True) @dataclass(frozen=True)
class CompileContext: class CompileContext:
@ -17,29 +19,98 @@ class CompileContext:
entry_path: Path entry_path: Path
output_name: str output_name: str
class BuildError(Exception):
pass
class AstParser:
def __init__(self, file: Path):
self.file = self.read_file(file)
self.labels = []
def read_file(self, file: Path) -> str:
with file.open("r") as f:
return f.read()
def parse_labels(self):
self.labels = []
current_label = None
current_sub_label = None
for line in self.file.splitlines():
stripped_line = line.strip()
if stripped_line.endswith(":"):
label_name = stripped_line[:-1].strip()
if not label_name:
continue
if label_name.startswith("."):
if current_label is None:
raise BuildError(f"Sub-label '{label_name}:' found before any parent label.")
sub_label_name = label_name[1:].strip()
if not sub_label_name:
continue
current_sub_label = {
"name": sub_label_name,
"lines": [],
}
current_label["sub_labels"].append(current_sub_label)
continue
if current_label is not None:
self.labels.append(current_label)
current_label = {
"name": label_name,
"lines": [],
"sub_labels": [],
}
current_sub_label = None
continue
if current_label is None:
continue
if stripped_line == "":
continue
if current_sub_label is not None:
current_sub_label["lines"].append(stripped_line)
else:
current_label["lines"].append(stripped_line)
if current_label is not None:
self.labels.append(current_label)
return self.labels
def parse(self):
return self.parse_labels()
_ACTIVE_CONTEXT: Optional[CompileContext] = None
class Compiler: class Compiler:
def __init__(self, context: CompileContext):
self.context = context
def run(self) -> None: def run(self) -> None:
return 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: def _resolve_project_path(project_root: Path, value: Path) -> Path:
if value.is_absolute(): if value.is_absolute():
return value return value
return (project_root / value).resolve() return (project_root / value).resolve()
def _format_to_filename(name: str) -> str: def _format_to_filename(name: str) -> str:
formatted = name.strip().lower().replace(" ", "_") formatted = name.strip().lower().replace(" ", "_")
if not formatted.endswith(".nutsi"): if not formatted.endswith(".nutsi"):
formatted += ".nutsi" formatted += ".nutsi"
return formatted return formatted
def _clear_directory(directory: Path) -> None: def _clear_directory(directory: Path) -> None:
for child in directory.iterdir(): for child in directory.iterdir():
if child.is_dir(): if child.is_dir():
@ -47,6 +118,11 @@ def _clear_directory(directory: Path) -> None:
else: else:
child.unlink() child.unlink()
def _is_chipnut_workspace(path: str = ".") -> bool:
return os.path.isfile(os.path.join(path, "Nutfile"))
def prepare_build_directory(build_dir: Path) -> Path: def prepare_build_directory(build_dir: Path) -> Path:
if build_dir.exists() and not build_dir.is_dir(): if build_dir.exists() and not build_dir.is_dir():
raise RuntimeError(f"Build path exists but is not a directory: {build_dir}") raise RuntimeError(f"Build path exists but is not a directory: {build_dir}")
@ -70,6 +146,7 @@ def prepare_build_directory(build_dir: Path) -> Path:
return build_dir return build_dir
def stage_project_sources(project_root: Path, entry_path: Path, build_dir: Path) -> None: def stage_project_sources(project_root: Path, entry_path: Path, build_dir: Path) -> None:
if entry_path.is_absolute(): if entry_path.is_absolute():
raise RuntimeError( raise RuntimeError(
@ -99,9 +176,9 @@ def stage_project_sources(project_root: Path, entry_path: Path, build_dir: Path)
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.nutsi"), ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.nutsi"),
) )
def generate_nutsi_file(build_dir: Path, file_name: str) -> Path: def generate_nutsi_file(build_dir: Path, file_name: str) -> Path:
output_path = build_dir / file_name output_path = build_dir / file_name
zip_dir( zip_dir(
str(build_dir), str(build_dir),
str(output_path), str(output_path),
@ -113,6 +190,7 @@ def generate_nutsi_file(build_dir: Path, file_name: str) -> Path:
) )
return output_path return output_path
def _create_context(config: Config, build_folder: Optional[str], file_name: Optional[str]) -> CompileContext: def _create_context(config: Config, build_folder: Optional[str], file_name: Optional[str]) -> CompileContext:
project_root = config.path.parent.resolve() project_root = config.path.parent.resolve()
entry_path = Path(config.build.entry) entry_path = Path(config.build.entry)
@ -134,18 +212,66 @@ def _create_context(config: Config, build_folder: Optional[str], file_name: Opti
output_name=output_name, output_name=output_name,
) )
def compile(config: Config, build_folder: Optional[str] = None, file_name: Optional[str] = None) -> Path:
global _ACTIVE_CONTEXT
def compile(config: Config, build_folder: Optional[str] = None, file_name: Optional[str] = None) -> Path:
context = _create_context(config, build_folder=build_folder, file_name=file_name) context = _create_context(config, build_folder=build_folder, file_name=file_name)
prepared_dir = prepare_build_directory(context.build_dir) prepared_dir = prepare_build_directory(context.build_dir)
stage_project_sources(context.project_root, context.entry_path, prepared_dir) stage_project_sources(context.project_root, context.entry_path, prepared_dir)
Compiler(context).run()
return generate_nutsi_file(prepared_dir, context.output_name)
def main(args, flags) -> int:
config_path = flags["config"]
build_folder_override = flags["build_folder"]
file_name_override = flags["name"]
if config_path == "Nutfile" and not _is_chipnut_workspace():
print("Error: No Nutfile found. Please run 'chipnut init' to create a workspace.")
return 1
_ACTIVE_CONTEXT = context
try: try:
Compiler().run() config = load_config(config_path)
finally: except FileNotFoundError:
_ACTIVE_CONTEXT = None print(f"Error: Config file not found: {config_path}")
return 1
except ConfigError as error:
print(f"Error: Invalid Nutfile: {error}")
return 1
archive_path = generate_nutsi_file(prepared_dir, context.output_name) try:
return archive_path compile(
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
def setup(cli: CLI):
cli.command(
"build",
help="Compile the project",
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(main)

View File

@ -1,4 +1,5 @@
from ..paths import get_templates_path from ..paths import get_templates_path
from ..cli import CLI
import shutil import shutil
import os import os
@ -43,5 +44,38 @@ def copy_template_files(dest_dir: str, force: bool = False, checked: bool = Fals
dirs_exist_ok=True dirs_exist_ok=True
) )
def is_chipnut_workspace(path: str = ".") -> bool: def main(args, flags) -> int:
return os.path.isfile(os.path.join(path, "Nutfile")) dest_dir = args[0]
force = flags["force"]
conflicts = find_template_conflicts(dest_dir)
if conflicts and not force:
print("Some files already exist that would be overwritten:")
for file in conflicts:
print(f" {file}")
print("Use --force to overwrite existing files.")
return 1
copy_template_files(dest_dir, force=force, checked=True)
return 0
def setup(cli: CLI):
cli.command(
"init",
help="Create an empty chipnut workspace or reinitialize with --force",
description=(
"Initialize a chipnut development workspace by creating a Nutfile and default project files. "
"If a Nutfile already exists, initialization should fail unless --force is used."
),
).flag(
"force",
"f",
help="Reinitialize even when a Nutfile already exists",
).arg(
"path",
nargs="?",
default=".",
help="Directory to initialize (default: current directory)",
).handle(main)