- Add scripts/lib/common.py with core utilities (logging, validation, JSON, file ops, git) - Add scripts/lib/joomla_manifest.py for manifest parsing and validation - Add scripts/run/scaffold_extension.py to create extension scaffolding - Support all Joomla extension types (component, module, plugin, template, package) - Maintain CLI compatibility with existing bash scripts - Foundation for converting remaining scripts to Python Co-authored-by: jmiller-moko <230051081+jmiller-moko@users.noreply.github.com>
453 lines
11 KiB
Python
Executable File
453 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Common utilities for Moko-Cassiopeia scripts.
|
|
|
|
Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
|
|
|
This file is part of a Moko Consulting project.
|
|
|
|
SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
This program is free software; you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation; either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program (./LICENSE.md).
|
|
|
|
FILE INFORMATION
|
|
DEFGROUP: Script.Library
|
|
INGROUP: Common
|
|
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
|
PATH: /scripts/lib/common.py
|
|
VERSION: 01.00.00
|
|
BRIEF: Unified shared Python utilities for all CI and local scripts
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
import subprocess
|
|
import traceback
|
|
|
|
|
|
# ============================================================================
|
|
# Environment and Detection
|
|
# ============================================================================
|
|
|
|
def is_ci() -> bool:
|
|
"""Check if running in CI environment."""
|
|
return os.environ.get("CI", "").lower() == "true"
|
|
|
|
|
|
def require_cmd(command: str) -> None:
|
|
"""
|
|
Ensure a required command is available.
|
|
|
|
Args:
|
|
command: Command name to check
|
|
|
|
Raises:
|
|
SystemExit: If command is not found
|
|
"""
|
|
if not shutil.which(command):
|
|
log_error(f"Required command not found: {command}")
|
|
sys.exit(1)
|
|
|
|
|
|
# ============================================================================
|
|
# Logging
|
|
# ============================================================================
|
|
|
|
class Colors:
|
|
"""ANSI color codes for terminal output."""
|
|
RED = '\033[0;31m'
|
|
GREEN = '\033[0;32m'
|
|
YELLOW = '\033[1;33m'
|
|
BLUE = '\033[0;34m'
|
|
CYAN = '\033[0;36m'
|
|
BOLD = '\033[1m'
|
|
NC = '\033[0m' # No Color
|
|
|
|
@classmethod
|
|
def enabled(cls) -> bool:
|
|
"""Check if colors should be enabled."""
|
|
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
|
|
|
|
def log_info(message: str) -> None:
|
|
"""Log informational message."""
|
|
print(f"INFO: {message}")
|
|
|
|
|
|
def log_warn(message: str) -> None:
|
|
"""Log warning message."""
|
|
color = Colors.YELLOW if Colors.enabled() else ""
|
|
nc = Colors.NC if Colors.enabled() else ""
|
|
print(f"{color}WARN: {message}{nc}", file=sys.stderr)
|
|
|
|
|
|
def log_error(message: str) -> None:
|
|
"""Log error message."""
|
|
color = Colors.RED if Colors.enabled() else ""
|
|
nc = Colors.NC if Colors.enabled() else ""
|
|
print(f"{color}ERROR: {message}{nc}", file=sys.stderr)
|
|
|
|
|
|
def log_success(message: str) -> None:
|
|
"""Log success message."""
|
|
color = Colors.GREEN if Colors.enabled() else ""
|
|
nc = Colors.NC if Colors.enabled() else ""
|
|
print(f"{color}✓ {message}{nc}")
|
|
|
|
|
|
def log_step(message: str) -> None:
|
|
"""Log a step in a process."""
|
|
color = Colors.CYAN if Colors.enabled() else ""
|
|
nc = Colors.NC if Colors.enabled() else ""
|
|
print(f"{color}➜ {message}{nc}")
|
|
|
|
|
|
def log_section(title: str) -> None:
|
|
"""Log a section header."""
|
|
print()
|
|
print("=" * 60)
|
|
print(title)
|
|
print("=" * 60)
|
|
|
|
|
|
def log_kv(key: str, value: str) -> None:
|
|
"""Log a key-value pair."""
|
|
print(f" {key}: {value}")
|
|
|
|
|
|
def die(message: str, exit_code: int = 1) -> None:
|
|
"""
|
|
Log error and exit.
|
|
|
|
Args:
|
|
message: Error message
|
|
exit_code: Exit code (default: 1)
|
|
"""
|
|
log_error(message)
|
|
|
|
if os.environ.get("VERBOSE_ERRORS", "true").lower() == "true":
|
|
print("", file=sys.stderr)
|
|
print("Stack trace:", file=sys.stderr)
|
|
traceback.print_stack(file=sys.stderr)
|
|
print("", file=sys.stderr)
|
|
print("Environment:", file=sys.stderr)
|
|
print(f" PWD: {os.getcwd()}", file=sys.stderr)
|
|
print(f" USER: {os.environ.get('USER', 'unknown')}", file=sys.stderr)
|
|
print(f" PYTHON: {sys.version}", file=sys.stderr)
|
|
print(f" CI: {is_ci()}", file=sys.stderr)
|
|
print("", file=sys.stderr)
|
|
|
|
sys.exit(exit_code)
|
|
|
|
|
|
# ============================================================================
|
|
# Validation Helpers
|
|
# ============================================================================
|
|
|
|
def assert_file_exists(path: Union[str, Path]) -> None:
|
|
"""
|
|
Assert that a file exists.
|
|
|
|
Args:
|
|
path: Path to file
|
|
|
|
Raises:
|
|
SystemExit: If file doesn't exist
|
|
"""
|
|
if not Path(path).is_file():
|
|
die(f"Required file missing: {path}")
|
|
|
|
|
|
def assert_dir_exists(path: Union[str, Path]) -> None:
|
|
"""
|
|
Assert that a directory exists.
|
|
|
|
Args:
|
|
path: Path to directory
|
|
|
|
Raises:
|
|
SystemExit: If directory doesn't exist
|
|
"""
|
|
if not Path(path).is_dir():
|
|
die(f"Required directory missing: {path}")
|
|
|
|
|
|
def assert_not_empty(value: Any, name: str) -> None:
|
|
"""
|
|
Assert that a value is not empty.
|
|
|
|
Args:
|
|
value: Value to check
|
|
name: Name of the value for error message
|
|
|
|
Raises:
|
|
SystemExit: If value is empty
|
|
"""
|
|
if not value:
|
|
die(f"Required value is empty: {name}")
|
|
|
|
|
|
# ============================================================================
|
|
# JSON Utilities
|
|
# ============================================================================
|
|
|
|
def json_escape(text: str) -> str:
|
|
"""
|
|
Escape text for JSON.
|
|
|
|
Args:
|
|
text: Text to escape
|
|
|
|
Returns:
|
|
Escaped text
|
|
"""
|
|
return json.dumps(text)[1:-1] # Remove surrounding quotes
|
|
|
|
|
|
def json_output(data: Dict[str, Any], pretty: bool = False) -> None:
|
|
"""
|
|
Output data as JSON.
|
|
|
|
Args:
|
|
data: Dictionary to output
|
|
pretty: Whether to pretty-print
|
|
"""
|
|
if pretty:
|
|
print(json.dumps(data, indent=2, sort_keys=True))
|
|
else:
|
|
print(json.dumps(data, separators=(',', ':')))
|
|
|
|
|
|
# ============================================================================
|
|
# Path Utilities
|
|
# ============================================================================
|
|
|
|
def script_root() -> Path:
|
|
"""
|
|
Get the root scripts directory.
|
|
|
|
Returns:
|
|
Path to scripts directory
|
|
"""
|
|
return Path(__file__).parent.parent
|
|
|
|
|
|
def repo_root() -> Path:
|
|
"""
|
|
Get the repository root directory.
|
|
|
|
Returns:
|
|
Path to repository root
|
|
"""
|
|
return script_root().parent
|
|
|
|
|
|
def normalize_path(path: Union[str, Path]) -> str:
|
|
"""
|
|
Normalize a path (resolve, absolute, forward slashes).
|
|
|
|
Args:
|
|
path: Path to normalize
|
|
|
|
Returns:
|
|
Normalized path string
|
|
"""
|
|
return str(Path(path).resolve()).replace("\\", "/")
|
|
|
|
|
|
# ============================================================================
|
|
# File Operations
|
|
# ============================================================================
|
|
|
|
def read_file(path: Union[str, Path], encoding: str = "utf-8") -> str:
|
|
"""
|
|
Read a file.
|
|
|
|
Args:
|
|
path: Path to file
|
|
encoding: File encoding
|
|
|
|
Returns:
|
|
File contents
|
|
"""
|
|
assert_file_exists(path)
|
|
return Path(path).read_text(encoding=encoding)
|
|
|
|
|
|
def write_file(path: Union[str, Path], content: str, encoding: str = "utf-8") -> None:
|
|
"""
|
|
Write a file.
|
|
|
|
Args:
|
|
path: Path to file
|
|
content: Content to write
|
|
encoding: File encoding
|
|
"""
|
|
Path(path).write_text(content, encoding=encoding)
|
|
|
|
|
|
def ensure_dir(path: Union[str, Path]) -> None:
|
|
"""
|
|
Ensure a directory exists.
|
|
|
|
Args:
|
|
path: Path to directory
|
|
"""
|
|
Path(path).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ============================================================================
|
|
# Command Execution
|
|
# ============================================================================
|
|
|
|
def run_command(
|
|
cmd: List[str],
|
|
capture_output: bool = True,
|
|
check: bool = True,
|
|
cwd: Optional[Union[str, Path]] = None,
|
|
env: Optional[Dict[str, str]] = None
|
|
) -> subprocess.CompletedProcess:
|
|
"""
|
|
Run a command.
|
|
|
|
Args:
|
|
cmd: Command and arguments
|
|
capture_output: Whether to capture stdout/stderr
|
|
check: Whether to raise on non-zero exit
|
|
cwd: Working directory
|
|
env: Environment variables
|
|
|
|
Returns:
|
|
CompletedProcess instance
|
|
"""
|
|
return subprocess.run(
|
|
cmd,
|
|
capture_output=capture_output,
|
|
text=True,
|
|
check=check,
|
|
cwd=cwd,
|
|
env=env
|
|
)
|
|
|
|
|
|
def run_shell(
|
|
script: str,
|
|
capture_output: bool = True,
|
|
check: bool = True,
|
|
cwd: Optional[Union[str, Path]] = None
|
|
) -> subprocess.CompletedProcess:
|
|
"""
|
|
Run a shell script.
|
|
|
|
Args:
|
|
script: Shell script
|
|
capture_output: Whether to capture stdout/stderr
|
|
check: Whether to raise on non-zero exit
|
|
cwd: Working directory
|
|
|
|
Returns:
|
|
CompletedProcess instance
|
|
"""
|
|
return subprocess.run(
|
|
script,
|
|
shell=True,
|
|
capture_output=capture_output,
|
|
text=True,
|
|
check=check,
|
|
cwd=cwd
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Git Utilities
|
|
# ============================================================================
|
|
|
|
def git_root() -> Path:
|
|
"""
|
|
Get git repository root.
|
|
|
|
Returns:
|
|
Path to git root
|
|
"""
|
|
result = run_command(
|
|
["git", "rev-parse", "--show-toplevel"],
|
|
capture_output=True,
|
|
check=True
|
|
)
|
|
return Path(result.stdout.strip())
|
|
|
|
|
|
def git_status(porcelain: bool = True) -> str:
|
|
"""
|
|
Get git status.
|
|
|
|
Args:
|
|
porcelain: Use porcelain format
|
|
|
|
Returns:
|
|
Git status output
|
|
"""
|
|
cmd = ["git", "status"]
|
|
if porcelain:
|
|
cmd.append("--porcelain")
|
|
|
|
result = run_command(cmd, capture_output=True, check=True)
|
|
return result.stdout
|
|
|
|
|
|
def git_branch() -> str:
|
|
"""
|
|
Get current git branch.
|
|
|
|
Returns:
|
|
Branch name
|
|
"""
|
|
result = run_command(
|
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
capture_output=True,
|
|
check=True
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
# ============================================================================
|
|
# Main Entry Point (for testing)
|
|
# ============================================================================
|
|
|
|
def main() -> None:
|
|
"""Test the common utilities."""
|
|
log_section("Testing Common Utilities")
|
|
|
|
log_info("This is an info message")
|
|
log_warn("This is a warning message")
|
|
log_success("This is a success message")
|
|
log_step("This is a step message")
|
|
|
|
log_section("Environment")
|
|
log_kv("CI", str(is_ci()))
|
|
log_kv("Script Root", str(script_root()))
|
|
log_kv("Repo Root", str(repo_root()))
|
|
log_kv("Git Root", str(git_root()))
|
|
log_kv("Git Branch", git_branch())
|
|
|
|
log_section("Tests Passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|