feat: add Python library infrastructure and extension scaffolding
- 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>
This commit is contained in:
452
scripts/lib/common.py
Executable file
452
scripts/lib/common.py
Executable file
@@ -0,0 +1,452 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user