from __future__ import annotations
import json
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .config import ProjectConfig, load_project_config
from .settings import AngularCommandError, AngularSettings, load_angular_settings
[docs]
@dataclass(frozen=True)
class AngularInvocation:
"""A single Angular CLI invocation and the directory it should run from."""
command_name: str
argv: tuple[str, ...]
cwd: Path
[docs]
def to_dict(self) -> dict[str, object]:
return {"argv": list(self.argv), "cwd": str(self.cwd)}
AngularInvocationBuilder = Callable[..., list[AngularInvocation]]
[docs]
def resolve_angular_command(
command_name: str, config_path: str | Path | None = None, **options: Any
) -> list[AngularInvocation]:
"""Resolve one logical djng command into an ordered list of subprocess calls.
This function does not execute Angular or Node tooling. It loads the
project configuration and Angular settings, chooses the matching command
builder, and returns the concrete ``AngularInvocation`` list that a dry run
can print or ``execute_invocations`` can later run.
"""
settings = load_angular_settings()
config = load_project_config(config_path or settings.config_path)
builder = _COMMAND_BUILDERS.get(command_name)
if builder is None:
raise AngularCommandError(f"Unknown Angular command '{command_name}'.")
return builder(config, settings, **options)
[docs]
def execute_invocations(
invocations: list[AngularInvocation], settings: AngularSettings | None = None
) -> None:
"""Run a previously resolved ordered list of subprocess calls."""
active_settings = settings or load_angular_settings()
for invocation in invocations:
_ensure_command_is_allowed(invocation.command_name, active_settings)
try:
subprocess.run(invocation.argv, cwd=invocation.cwd, check=True)
except FileNotFoundError as exc:
raise AngularCommandError(f"Command not found: {invocation.argv[0]}") from exc
except subprocess.CalledProcessError as exc:
raise AngularCommandError(
f"Command '{' '.join(invocation.argv)}' failed with exit code {exc.returncode}."
) from exc
[docs]
def build_ng_new_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
# Ensure the parent directory exists before calling subprocess.run
config.angular_output.parent.mkdir(parents=True, exist_ok=True)
return [
AngularInvocation(
command_name="ng_new",
argv=(
settings.ng_executable,
"new",
config.project_name,
"--defaults",
"--skip-git",
"--skip-install",
"--no-create-application",
f"--package-manager={settings.package_manager}",
f"--directory={config.angular_output.name}",
),
cwd=config.angular_output.parent,
)
]
[docs]
def build_ng_workspace_schematic_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
return [
AngularInvocation(
command_name="ng_workspace",
argv=(
settings.ng_executable,
"generate",
"angular-django2:ng-workspace",
config.project_name,
),
cwd=config.angular_output,
)
]
[docs]
def build_ng_config_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
return [
AngularInvocation(
command_name="ng_config",
argv=(
settings.ng_executable,
"config",
"cli.packageManager",
settings.package_manager,
),
cwd=config.angular_output,
),
AngularInvocation(
command_name="ng_config",
argv=(
settings.ng_executable,
"config",
"schematics.@schematics/angular:application.style",
settings.style,
),
cwd=config.angular_output,
),
AngularInvocation(
command_name="ng_config",
argv=(
settings.ng_executable,
"config",
"schematics.@schematics/angular:application.routing",
_stringify_bool(settings.routing),
),
cwd=config.angular_output,
),
]
[docs]
def build_ng_build_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
return [
AngularInvocation(
command_name="ng_build",
argv=(
settings.ng_executable,
"build",
config.project_name,
f"--configuration={settings.build_configuration}",
),
cwd=config.angular_output,
)
]
[docs]
def build_ng_gen_app_invocations(
config: ProjectConfig, settings: AngularSettings, *, app_name: str | None = None, **_: Any
) -> list[AngularInvocation]:
target_app = app_name or config.project_name
return [
AngularInvocation(
command_name="ng_gen_app",
argv=(
settings.ng_executable,
"generate",
"angular-django2:ng-app",
target_app,
f"--style={settings.style}",
"--routing" if settings.routing else "--no-routing",
),
cwd=config.angular_output,
)
]
[docs]
def build_ng_openapi_gen_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
source = config.ng_openapi_gen_config or config.openapi_source
source_flag = "-c" if config.ng_openapi_gen_config else "-i"
return [
AngularInvocation(
command_name="ng_openapi_gen",
argv=(
settings.pnpm_executable,
"exec",
"ng-openapi-gen",
source_flag,
str(source),
),
cwd=config.angular_output,
)
]
[docs]
def build_ng_add_invocations(
config: ProjectConfig, settings: AngularSettings, *, package: str | None = None, **_: Any
) -> list[AngularInvocation]:
target_package = package or settings.ng_add_package
return [
AngularInvocation(
command_name="ng_add",
argv=(
settings.ng_executable,
"add",
target_package,
"--skip-confirmation",
),
cwd=config.angular_output,
)
]
[docs]
def build_ng_workspace_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
"""Create and bootstrap an Angular workspace with angular-django2 defaults."""
invocations = _relabel_invocations(build_ng_new_invocations(config, settings), "ng_workspace")
invocations.extend(_relabel_invocations(build_ng_config_invocations(config, settings), "ng_workspace"))
invocations.extend(_relabel_invocations(build_ng_add_invocations(config, settings), "ng_workspace"))
invocations.extend(build_ng_workspace_schematic_invocations(config, settings))
return invocations
[docs]
def build_ng_workspace_modify_invocations(
config: ProjectConfig, settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
"""Reapply workspace defaults, collection registration, and workspace scaffolding."""
invocations = _relabel_invocations(
build_ng_config_invocations(config, settings), "ng_workspace_modify"
)
invocations.extend(
_relabel_invocations(build_ng_add_invocations(config, settings), "ng_workspace_modify")
)
invocations.extend(
_relabel_invocations(
build_ng_workspace_schematic_invocations(config, settings), "ng_workspace_modify"
)
)
return invocations
[docs]
def build_ng_workspace_delete_invocations(
config: ProjectConfig, _settings: AngularSettings, **_: Any
) -> list[AngularInvocation]:
"""Deletes the entire workspace folder using Python's native cross-platform shutil."""
import sys
py_code = f"import shutil; shutil.rmtree(r'{config.angular_output}', ignore_errors=True)"
return [
AngularInvocation(
command_name="ng_workspace_delete",
argv=(sys.executable, "-c", py_code),
cwd=config.angular_output.parent,
)
]
_COMMAND_BUILDERS: dict[str, AngularInvocationBuilder] = {
"ng_new": build_ng_new_invocations,
"ng_workspace": build_ng_workspace_invocations,
"ng_config": build_ng_config_invocations,
"ng_build": build_ng_build_invocations,
"ng_gen_app": build_ng_gen_app_invocations,
"ng_openapi_gen": build_ng_openapi_gen_invocations,
"ng_add": build_ng_add_invocations,
"ng_workspace_modify": build_ng_workspace_modify_invocations,
"ng_workspace_delete": build_ng_workspace_delete_invocations,
}
def _stringify_bool(value: bool) -> str:
return "true" if value else "false"
def _relabel_invocations(
invocations: list[AngularInvocation], command_name: str
) -> list[AngularInvocation]:
return [
AngularInvocation(command_name=command_name, argv=invocation.argv, cwd=invocation.cwd)
for invocation in invocations
]
def _ensure_command_is_allowed(command_name: str, settings: AngularSettings) -> None:
normalized_command_name = command_name.strip().lower()
if normalized_command_name in settings.command_allowlist:
return
allowed_commands = ", ".join(settings.command_allowlist) or "<none>"
raise AngularCommandError(
f"Command '{command_name}' is not allowed. Allowed commands: {allowed_commands}."
)