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:
BIN
scripts/lib/__pycache__/common.cpython-312.pyc
Normal file
BIN
scripts/lib/__pycache__/common.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc
Normal file
BIN
scripts/lib/__pycache__/joomla_manifest.cpython-312.pyc
Normal file
Binary file not shown.
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()
|
||||
430
scripts/lib/joomla_manifest.py
Executable file
430
scripts/lib/joomla_manifest.py
Executable file
@@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Joomla manifest parsing and validation utilities.
|
||||
|
||||
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: Joomla.Manifest
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/lib/joomla_manifest.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Joomla manifest parsing and validation utilities
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from . import common
|
||||
except ImportError:
|
||||
import common
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Joomla Extension Types
|
||||
# ============================================================================
|
||||
|
||||
class ExtensionType:
|
||||
"""Joomla extension types."""
|
||||
COMPONENT = "component"
|
||||
MODULE = "module"
|
||||
PLUGIN = "plugin"
|
||||
TEMPLATE = "template"
|
||||
LIBRARY = "library"
|
||||
PACKAGE = "package"
|
||||
FILE = "file"
|
||||
LANGUAGE = "language"
|
||||
|
||||
ALL_TYPES = [
|
||||
COMPONENT, MODULE, PLUGIN, TEMPLATE,
|
||||
LIBRARY, PACKAGE, FILE, LANGUAGE
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Data Class
|
||||
# ============================================================================
|
||||
|
||||
@dataclass
|
||||
class ManifestInfo:
|
||||
"""Information extracted from a Joomla manifest."""
|
||||
path: Path
|
||||
extension_type: str
|
||||
name: str
|
||||
version: str
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
author_email: Optional[str] = None
|
||||
author_url: Optional[str] = None
|
||||
copyright: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
creation_date: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"ext_type": self.extension_type,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"description": self.description or "",
|
||||
"author": self.author or "",
|
||||
"author_email": self.author_email or "",
|
||||
"author_url": self.author_url or "",
|
||||
"copyright": self.copyright or "",
|
||||
"license": self.license or "",
|
||||
"creation_date": self.creation_date or ""
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Discovery
|
||||
# ============================================================================
|
||||
|
||||
def find_manifest(src_dir: str = "src") -> Path:
|
||||
"""
|
||||
Find the primary Joomla manifest in the given directory.
|
||||
|
||||
Args:
|
||||
src_dir: Source directory to search
|
||||
|
||||
Returns:
|
||||
Path to manifest file
|
||||
|
||||
Raises:
|
||||
SystemExit: If no manifest found
|
||||
"""
|
||||
src_path = Path(src_dir)
|
||||
|
||||
if not src_path.is_dir():
|
||||
common.die(f"Source directory missing: {src_dir}")
|
||||
|
||||
# Template manifest (templateDetails.xml)
|
||||
template_manifest = src_path / "templateDetails.xml"
|
||||
if template_manifest.is_file():
|
||||
return template_manifest
|
||||
|
||||
# Also check in templates subdirectory
|
||||
templates_dir = src_path / "templates"
|
||||
if templates_dir.is_dir():
|
||||
for template_file in templates_dir.glob("templateDetails.xml"):
|
||||
return template_file
|
||||
|
||||
# Package manifest (pkg_*.xml)
|
||||
pkg_manifests = list(src_path.rglob("pkg_*.xml"))
|
||||
if pkg_manifests:
|
||||
return pkg_manifests[0]
|
||||
|
||||
# Component manifest (com_*.xml)
|
||||
com_manifests = list(src_path.rglob("com_*.xml"))
|
||||
if com_manifests:
|
||||
return com_manifests[0]
|
||||
|
||||
# Module manifest (mod_*.xml)
|
||||
mod_manifests = list(src_path.rglob("mod_*.xml"))
|
||||
if mod_manifests:
|
||||
return mod_manifests[0]
|
||||
|
||||
# Plugin manifest (plg_*.xml)
|
||||
plg_manifests = list(src_path.rglob("plg_*.xml"))
|
||||
if plg_manifests:
|
||||
return plg_manifests[0]
|
||||
|
||||
# Fallback: any XML with <extension
|
||||
for xml_file in src_path.rglob("*.xml"):
|
||||
try:
|
||||
content = xml_file.read_text(encoding="utf-8")
|
||||
if "<extension" in content:
|
||||
return xml_file
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
common.die(f"No Joomla manifest XML found under {src_dir}")
|
||||
|
||||
|
||||
def find_all_manifests(src_dir: str = "src") -> List[Path]:
|
||||
"""
|
||||
Find all Joomla manifests in the given directory.
|
||||
|
||||
Args:
|
||||
src_dir: Source directory to search
|
||||
|
||||
Returns:
|
||||
List of manifest paths
|
||||
"""
|
||||
src_path = Path(src_dir)
|
||||
|
||||
if not src_path.is_dir():
|
||||
return []
|
||||
|
||||
manifests = []
|
||||
|
||||
# Template manifests
|
||||
manifests.extend(src_path.rglob("templateDetails.xml"))
|
||||
|
||||
# Package manifests
|
||||
manifests.extend(src_path.rglob("pkg_*.xml"))
|
||||
|
||||
# Component manifests
|
||||
manifests.extend(src_path.rglob("com_*.xml"))
|
||||
|
||||
# Module manifests
|
||||
manifests.extend(src_path.rglob("mod_*.xml"))
|
||||
|
||||
# Plugin manifests
|
||||
manifests.extend(src_path.rglob("plg_*.xml"))
|
||||
|
||||
return manifests
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Parsing
|
||||
# ============================================================================
|
||||
|
||||
def parse_manifest(manifest_path: Path) -> ManifestInfo:
|
||||
"""
|
||||
Parse a Joomla manifest file.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
ManifestInfo object
|
||||
|
||||
Raises:
|
||||
SystemExit: If parsing fails
|
||||
"""
|
||||
if not manifest_path.is_file():
|
||||
common.die(f"Manifest not found: {manifest_path}")
|
||||
|
||||
try:
|
||||
tree = ET.parse(manifest_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Extract extension type
|
||||
ext_type = root.attrib.get("type", "").strip().lower()
|
||||
if not ext_type:
|
||||
common.die(f"Manifest missing type attribute: {manifest_path}")
|
||||
|
||||
# Extract name
|
||||
name_elem = root.find("name")
|
||||
if name_elem is None or not name_elem.text:
|
||||
common.die(f"Manifest missing name element: {manifest_path}")
|
||||
name = name_elem.text.strip()
|
||||
|
||||
# Extract version
|
||||
version_elem = root.find("version")
|
||||
if version_elem is None or not version_elem.text:
|
||||
common.die(f"Manifest missing version element: {manifest_path}")
|
||||
version = version_elem.text.strip()
|
||||
|
||||
# Extract optional fields
|
||||
description = None
|
||||
desc_elem = root.find("description")
|
||||
if desc_elem is not None and desc_elem.text:
|
||||
description = desc_elem.text.strip()
|
||||
|
||||
author = None
|
||||
author_elem = root.find("author")
|
||||
if author_elem is not None and author_elem.text:
|
||||
author = author_elem.text.strip()
|
||||
|
||||
author_email = None
|
||||
email_elem = root.find("authorEmail")
|
||||
if email_elem is not None and email_elem.text:
|
||||
author_email = email_elem.text.strip()
|
||||
|
||||
author_url = None
|
||||
url_elem = root.find("authorUrl")
|
||||
if url_elem is not None and url_elem.text:
|
||||
author_url = url_elem.text.strip()
|
||||
|
||||
copyright_text = None
|
||||
copyright_elem = root.find("copyright")
|
||||
if copyright_elem is not None and copyright_elem.text:
|
||||
copyright_text = copyright_elem.text.strip()
|
||||
|
||||
license_text = None
|
||||
license_elem = root.find("license")
|
||||
if license_elem is not None and license_elem.text:
|
||||
license_text = license_elem.text.strip()
|
||||
|
||||
creation_date = None
|
||||
date_elem = root.find("creationDate")
|
||||
if date_elem is not None and date_elem.text:
|
||||
creation_date = date_elem.text.strip()
|
||||
|
||||
return ManifestInfo(
|
||||
path=manifest_path,
|
||||
extension_type=ext_type,
|
||||
name=name,
|
||||
version=version,
|
||||
description=description,
|
||||
author=author,
|
||||
author_email=author_email,
|
||||
author_url=author_url,
|
||||
copyright=copyright_text,
|
||||
license=license_text,
|
||||
creation_date=creation_date
|
||||
)
|
||||
|
||||
except ET.ParseError as e:
|
||||
common.die(f"Failed to parse manifest {manifest_path}: {e}")
|
||||
except Exception as e:
|
||||
common.die(f"Error reading manifest {manifest_path}: {e}")
|
||||
|
||||
|
||||
def get_manifest_version(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract version from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Version string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.version
|
||||
|
||||
|
||||
def get_manifest_name(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract name from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Name string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.name
|
||||
|
||||
|
||||
def get_manifest_type(manifest_path: Path) -> str:
|
||||
"""
|
||||
Extract extension type from manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Extension type string
|
||||
"""
|
||||
info = parse_manifest(manifest_path)
|
||||
return info.extension_type
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manifest Validation
|
||||
# ============================================================================
|
||||
|
||||
def validate_manifest(manifest_path: Path) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a Joomla manifest.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to manifest file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_warnings)
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
info = parse_manifest(manifest_path)
|
||||
|
||||
# Check for recommended fields
|
||||
if not info.description:
|
||||
warnings.append("Missing description element")
|
||||
|
||||
if not info.author:
|
||||
warnings.append("Missing author element")
|
||||
|
||||
if not info.copyright:
|
||||
warnings.append("Missing copyright element")
|
||||
|
||||
if not info.license:
|
||||
warnings.append("Missing license element")
|
||||
|
||||
if not info.creation_date:
|
||||
warnings.append("Missing creationDate element")
|
||||
|
||||
# Validate extension type
|
||||
if info.extension_type not in ExtensionType.ALL_TYPES:
|
||||
warnings.append(f"Unknown extension type: {info.extension_type}")
|
||||
|
||||
return (True, warnings)
|
||||
|
||||
except SystemExit:
|
||||
return (False, ["Failed to parse manifest"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point (for testing)
|
||||
# ============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""Test the manifest utilities."""
|
||||
import sys
|
||||
|
||||
common.log_section("Testing Joomla Manifest Utilities")
|
||||
|
||||
src_dir = sys.argv[1] if len(sys.argv) > 1 else "src"
|
||||
|
||||
try:
|
||||
manifest = find_manifest(src_dir)
|
||||
common.log_success(f"Found manifest: {manifest}")
|
||||
|
||||
info = parse_manifest(manifest)
|
||||
|
||||
common.log_section("Manifest Information")
|
||||
common.log_kv("Type", info.extension_type)
|
||||
common.log_kv("Name", info.name)
|
||||
common.log_kv("Version", info.version)
|
||||
|
||||
if info.description:
|
||||
common.log_kv("Description", info.description[:60] + "..." if len(info.description) > 60 else info.description)
|
||||
|
||||
if info.author:
|
||||
common.log_kv("Author", info.author)
|
||||
|
||||
is_valid, warnings = validate_manifest(manifest)
|
||||
|
||||
if is_valid:
|
||||
common.log_success("Manifest is valid")
|
||||
if warnings:
|
||||
common.log_warn(f"Warnings: {len(warnings)}")
|
||||
for warning in warnings:
|
||||
print(f" - {warning}")
|
||||
else:
|
||||
common.log_error("Manifest validation failed")
|
||||
|
||||
except SystemExit as e:
|
||||
sys.exit(e.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
448
scripts/run/scaffold_extension.py
Executable file
448
scripts/run/scaffold_extension.py
Executable file
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create Joomla extension scaffolding.
|
||||
|
||||
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: Moko-Cassiopeia.Scripts
|
||||
INGROUP: Scripts.Run
|
||||
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||
PATH: /scripts/run/scaffold_extension.py
|
||||
VERSION: 01.00.00
|
||||
BRIEF: Create scaffolding for different Joomla extension types
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
# Add lib directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "lib"))
|
||||
|
||||
try:
|
||||
import common
|
||||
import joomla_manifest
|
||||
except ImportError:
|
||||
print("ERROR: Cannot import required libraries", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Templates for Extension Scaffolding
|
||||
# ============================================================================
|
||||
|
||||
def get_component_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a component."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
com_name = f"com_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="component" version="4.0" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files folder="site">
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
|
||||
<administration>
|
||||
<menu>{name}</menu>
|
||||
<files folder="admin">
|
||||
<folder>services</folder>
|
||||
<folder>sql</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
</administration>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{com_name}.xml": manifest,
|
||||
"site/src/.gitkeep": "",
|
||||
"admin/services/provider.php": f"<?php\n// Service provider for {name}\n",
|
||||
"admin/sql/install.mysql.utf8.sql": "-- Installation SQL\n",
|
||||
"admin/sql/uninstall.mysql.utf8.sql": "-- Uninstallation SQL\n",
|
||||
"admin/src/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
def get_module_structure(name: str, description: str, author: str, client: str = "site") -> Dict[str, str]:
|
||||
"""Get directory structure and files for a module."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
mod_name = f"mod_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="module" version="4.0" client="{client}" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename module="{mod_name}">{mod_name}.php</filename>
|
||||
<filename>{mod_name}.xml</filename>
|
||||
<folder>tmpl</folder>
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
module_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
// Module logic here
|
||||
require JModuleHelper::getLayoutPath('mod_{safe_name}', $params->get('layout', 'default'));
|
||||
"""
|
||||
|
||||
default_tmpl = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
?>
|
||||
<div class="{mod_name}">
|
||||
<p><?php echo JText::_('MOD_{safe_name.upper()}_DESCRIPTION'); ?></p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{mod_name}.xml": manifest,
|
||||
f"{mod_name}.php": module_php,
|
||||
"tmpl/default.php": default_tmpl,
|
||||
}
|
||||
|
||||
|
||||
def get_plugin_structure(name: str, description: str, author: str, group: str = "system") -> Dict[str, str]:
|
||||
"""Get directory structure and files for a plugin."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
plg_name = f"{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="plugin" version="4.0" group="{group}" method="upgrade">
|
||||
<name>plg_{group}_{safe_name}</name>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename plugin="{plg_name}">{plg_name}.php</filename>
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
plugin_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\\CMS\\Plugin\\CMSPlugin;
|
||||
|
||||
class Plg{group.capitalize()}{plg_name.capitalize()} extends CMSPlugin
|
||||
{{
|
||||
protected $autoloadLanguage = true;
|
||||
|
||||
public function onContentPrepare($context, &$article, &$params, $limitstart = 0)
|
||||
{{
|
||||
// Plugin logic here
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
return {
|
||||
f"plg_{group}_{safe_name}.xml": manifest,
|
||||
f"{plg_name}.php": plugin_php,
|
||||
}
|
||||
|
||||
|
||||
def get_template_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a template."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="template" version="4.0" client="site" method="upgrade">
|
||||
<name>{safe_name}</name>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<author>{author}</author>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files>
|
||||
<filename>index.php</filename>
|
||||
<filename>templateDetails.xml</filename>
|
||||
<folder>css</folder>
|
||||
<folder>js</folder>
|
||||
<folder>images</folder>
|
||||
</files>
|
||||
|
||||
<positions>
|
||||
<position>header</position>
|
||||
<position>main</position>
|
||||
<position>footer</position>
|
||||
</positions>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
index_php = f"""<?php
|
||||
/**
|
||||
* @package {name}
|
||||
* @copyright Copyright (C) {datetime.now().year} {author}
|
||||
* @license GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\\CMS\\Factory;
|
||||
use Joomla\\CMS\\HTML\\HTMLHelper;
|
||||
use Joomla\\CMS\\Uri\\Uri;
|
||||
|
||||
$app = Factory::getApplication();
|
||||
$wa = $app->getDocument()->getWebAssetManager();
|
||||
|
||||
// Load template assets
|
||||
$wa->useStyle('template.{safe_name}')->useScript('template.{safe_name}');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
|
||||
<head>
|
||||
<jdoc:include type="metas" />
|
||||
<jdoc:include type="styles" />
|
||||
<jdoc:include type="scripts" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<jdoc:include type="modules" name="header" style="html5" />
|
||||
</header>
|
||||
<main>
|
||||
<jdoc:include type="component" />
|
||||
</main>
|
||||
<footer>
|
||||
<jdoc:include type="modules" name="footer" style="html5" />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return {
|
||||
"templateDetails.xml": manifest,
|
||||
"index.php": index_php,
|
||||
"css/template.css": "/* Template styles */\n",
|
||||
"js/template.js": "// Template JavaScript\n",
|
||||
"images/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
def get_package_structure(name: str, description: str, author: str) -> Dict[str, str]:
|
||||
"""Get directory structure and files for a package."""
|
||||
safe_name = name.lower().replace(" ", "_")
|
||||
pkg_name = f"pkg_{safe_name}"
|
||||
|
||||
manifest = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<extension type="package" version="4.0" method="upgrade">
|
||||
<name>{name}</name>
|
||||
<packagename>{safe_name}</packagename>
|
||||
<author>{author}</author>
|
||||
<creationDate>{datetime.now().strftime("%Y-%m-%d")}</creationDate>
|
||||
<copyright>Copyright (C) {datetime.now().year} {author}</copyright>
|
||||
<license>GPL-3.0-or-later</license>
|
||||
<authorEmail>hello@example.com</authorEmail>
|
||||
<authorUrl>https://example.com</authorUrl>
|
||||
<version>1.0.0</version>
|
||||
<description>{description}</description>
|
||||
|
||||
<files folder="packages">
|
||||
<!-- Add extension packages here -->
|
||||
</files>
|
||||
</extension>
|
||||
"""
|
||||
|
||||
return {
|
||||
f"{pkg_name}.xml": manifest,
|
||||
"packages/.gitkeep": "",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scaffolding Functions
|
||||
# ============================================================================
|
||||
|
||||
def create_extension(
|
||||
ext_type: str,
|
||||
name: str,
|
||||
description: str,
|
||||
author: str,
|
||||
output_dir: str = "src",
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Create extension scaffolding.
|
||||
|
||||
Args:
|
||||
ext_type: Extension type (component, module, plugin, template, package)
|
||||
name: Extension name
|
||||
description: Extension description
|
||||
author: Author name
|
||||
output_dir: Output directory
|
||||
**kwargs: Additional type-specific options
|
||||
"""
|
||||
output_path = Path(output_dir)
|
||||
|
||||
# Get structure based on type
|
||||
if ext_type == "component":
|
||||
structure = get_component_structure(name, description, author)
|
||||
elif ext_type == "module":
|
||||
client = kwargs.get("client", "site")
|
||||
structure = get_module_structure(name, description, author, client)
|
||||
elif ext_type == "plugin":
|
||||
group = kwargs.get("group", "system")
|
||||
structure = get_plugin_structure(name, description, author, group)
|
||||
elif ext_type == "template":
|
||||
structure = get_template_structure(name, description, author)
|
||||
elif ext_type == "package":
|
||||
structure = get_package_structure(name, description, author)
|
||||
else:
|
||||
common.die(f"Unknown extension type: {ext_type}")
|
||||
|
||||
# Create files
|
||||
common.log_section(f"Creating {ext_type}: {name}")
|
||||
|
||||
for file_path, content in structure.items():
|
||||
full_path = output_path / file_path
|
||||
|
||||
# Create parent directories
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
full_path.write_text(content, encoding="utf-8")
|
||||
common.log_success(f"Created: {file_path}")
|
||||
|
||||
common.log_section("Scaffolding Complete")
|
||||
common.log_info(f"Extension files created in: {output_path}")
|
||||
common.log_info(f"Extension type: {ext_type}")
|
||||
common.log_info(f"Extension name: {name}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Command Line Interface
|
||||
# ============================================================================
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create Joomla extension scaffolding",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Create a component
|
||||
%(prog)s component MyComponent "My Component Description" "John Doe"
|
||||
|
||||
# Create a module
|
||||
%(prog)s module MyModule "My Module Description" "John Doe" --client site
|
||||
|
||||
# Create a plugin
|
||||
%(prog)s plugin MyPlugin "My Plugin Description" "John Doe" --group system
|
||||
|
||||
# Create a template
|
||||
%(prog)s template mytheme "My Theme Description" "John Doe"
|
||||
|
||||
# Create a package
|
||||
%(prog)s package mypackage "My Package Description" "John Doe"
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"type",
|
||||
choices=["component", "module", "plugin", "template", "package"],
|
||||
help="Extension type to create"
|
||||
)
|
||||
parser.add_argument("name", help="Extension name")
|
||||
parser.add_argument("description", help="Extension description")
|
||||
parser.add_argument("author", help="Author name")
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
default="src",
|
||||
help="Output directory (default: src)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--client",
|
||||
choices=["site", "administrator"],
|
||||
default="site",
|
||||
help="Module client (site or administrator)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--group",
|
||||
default="system",
|
||||
help="Plugin group (system, content, user, etc.)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
create_extension(
|
||||
ext_type=args.type,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
author=args.author,
|
||||
output_dir=args.output,
|
||||
client=args.client,
|
||||
group=args.group
|
||||
)
|
||||
except Exception as e:
|
||||
common.log_error(f"Failed to create extension: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user