- 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>
431 lines
12 KiB
Python
Executable File
431 lines
12 KiB
Python
Executable File
#!/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()
|