Improve Joomla development workflows and convert scripts to Python #31
312
scripts/release/package_extension.py
Executable file
312
scripts/release/package_extension.py
Executable file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Package Joomla extension as distributable ZIP.
|
||||||
|
|
||||||
|
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.Release
|
||||||
|
INGROUP: Extension.Packaging
|
||||||
|
REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
|
||||||
|
PATH: /scripts/release/package_extension.py
|
||||||
|
VERSION: 01.00.00
|
||||||
|
BRIEF: Package Joomla extension as distributable ZIP
|
||||||
|
USAGE: ./scripts/release/package_extension.py [output_dir] [version]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
|
|||||||
|
import shutil
|
||||||
|
Import of 'shutil' is not used. Import of 'shutil' is not used.
```suggestion
```
|
|||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Set
|
||||||
|
Import of 'List' is not used. Import of 'List' is not used.
```suggestion
from typing import Set
```
|
|||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
# Exclusion patterns for packaging
|
||||||
|
EXCLUDE_PATTERNS = {
|
||||||
|
# Version control
|
||||||
|
".git", ".gitignore", ".gitattributes",
|
||||||
|
# IDE
|
||||||
|
".vscode", ".idea", "*.sublime-*",
|
||||||
|
# Development
|
||||||
|
"node_modules", "vendor", ".env", ".env.*",
|
||||||
|
# Documentation (optional, can be included)
|
||||||
|
# Build artifacts
|
||||||
|
"dist", "build", ".phpunit.cache",
|
||||||
|
# OS files
|
||||||
|
".DS_Store", "Thumbs.db",
|
||||||
|
# Logs
|
||||||
|
"*.log",
|
||||||
|
# Tests
|
||||||
|
"tests", "test", "Tests",
|
||||||
|
# CI/CD
|
||||||
|
".github",
|
||||||
|
# Scripts
|
||||||
|
"scripts",
|
||||||
|
# Docs (can be included if needed)
|
||||||
|
"docs",
|
||||||
|
# Config files
|
||||||
|
"composer.json", "composer.lock",
|
||||||
|
"package.json", "package-lock.json",
|
||||||
|
"phpunit.xml", "phpstan.neon", "phpcs.xml",
|
||||||
|
"codeception.yml",
|
||||||
|
# Others
|
||||||
|
"README.md", "CHANGELOG.md", "CONTRIBUTING.md",
|
||||||
|
"CODE_OF_CONDUCT.md", "SECURITY.md", "GOVERNANCE.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def should_exclude(path: Path, base_path: Path, exclude_patterns: Set[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a path should be excluded from packaging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to check
|
||||||
|
base_path: Base directory path
|
||||||
|
exclude_patterns: Set of exclusion patterns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if should be excluded
|
||||||
|
"""
|
||||||
|
relative_path = path.relative_to(base_path)
|
||||||
|
|
||||||
|
# Check each part of the path
|
||||||
|
for part in relative_path.parts:
|
||||||
|
if part in exclude_patterns:
|
||||||
|
return True
|
||||||
|
# Check wildcard patterns
|
||||||
|
for pattern in exclude_patterns:
|
||||||
|
if "*" in pattern:
|
||||||
|
import fnmatch
|
||||||
|
The import statement uses The import statement uses `fnmatch` inside the function instead of at the module level. This violates PEP 8 import conventions and can impact performance when the function is called repeatedly. Import statements should be placed at the top of the file with other imports.
|
|||||||
|
if fnmatch.fnmatch(part, pattern):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_package(
|
||||||
|
src_dir: str,
|
||||||
|
output_dir: str,
|
||||||
|
version: str = None,
|
||||||
|
repo_name: str = None,
|
||||||
|
exclude_patterns: Set[str] = None
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Create a distributable ZIP package for a Joomla extension.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_dir: Source directory containing extension files
|
||||||
|
output_dir: Output directory for ZIP file
|
||||||
|
version: Version string (auto-detected if not provided)
|
||||||
|
repo_name: Repository name for ZIP file naming
|
||||||
|
exclude_patterns: Patterns to exclude from packaging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created ZIP file
|
||||||
|
"""
|
||||||
|
src_path = Path(src_dir)
|
||||||
|
if not src_path.is_dir():
|
||||||
|
common.die(f"Source directory not found: {src_dir}")
|
||||||
|
|
||||||
|
# Find and parse manifest
|
||||||
|
manifest_path = joomla_manifest.find_manifest(src_dir)
|
||||||
|
manifest_info = joomla_manifest.parse_manifest(manifest_path)
|
||||||
|
|
||||||
|
# Determine version
|
||||||
|
if not version:
|
||||||
|
version = manifest_info.version
|
||||||
|
|
||||||
|
# Determine repo name
|
||||||
|
if not repo_name:
|
||||||
|
try:
|
||||||
|
repo_root = common.git_root()
|
||||||
|
repo_name = repo_root.name
|
||||||
|
except Exception:
|
||||||
|
repo_name = "extension"
|
||||||
|
|
||||||
|
# Determine exclusion patterns
|
||||||
|
if exclude_patterns is None:
|
||||||
|
exclude_patterns = EXCLUDE_PATTERNS
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
output_path = Path(output_dir)
|
||||||
|
common.ensure_dir(output_path)
|
||||||
|
|
||||||
|
# Generate ZIP filename
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired. The variable 'timestamp' is assigned on line 165 but never used in the code. This appears to be unused code that should either be removed or incorporated into the ZIP filename if timestamping is desired.
```suggestion
```
|
|||||||
|
zip_filename = f"{repo_name}-{version}-{manifest_info.extension_type}.zip"
|
||||||
|
zip_path = output_path / zip_filename
|
||||||
|
|
||||||
|
# Remove existing ZIP if present
|
||||||
|
if zip_path.exists():
|
||||||
|
zip_path.unlink()
|
||||||
|
|
||||||
|
common.log_section("Creating Extension Package")
|
||||||
|
common.log_kv("Extension", manifest_info.name)
|
||||||
|
common.log_kv("Type", manifest_info.extension_type)
|
||||||
|
common.log_kv("Version", version)
|
||||||
|
common.log_kv("Source", src_dir)
|
||||||
|
common.log_kv("Output", str(zip_path))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create ZIP file
|
||||||
|
file_count = 0
|
||||||
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||||
|
for item in src_path.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
# Check if should be excluded
|
||||||
|
if should_exclude(item, src_path, exclude_patterns):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add to ZIP with relative path
|
||||||
|
arcname = item.relative_to(src_path)
|
||||||
|
zipf.write(item, arcname)
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
if file_count % 10 == 0:
|
||||||
|
common.log_step(f"Added {file_count} files...")
|
||||||
|
|
||||||
|
# Get ZIP file size
|
||||||
|
zip_size = zip_path.stat().st_size
|
||||||
|
zip_size_mb = zip_size / (1024 * 1024)
|
||||||
|
|
||||||
|
print()
|
||||||
|
common.log_success(f"Package created: {zip_path.name}")
|
||||||
|
common.log_kv("Files", str(file_count))
|
||||||
|
common.log_kv("Size", f"{zip_size_mb:.2f} MB")
|
||||||
|
|
||||||
|
# Output JSON for machine consumption
|
||||||
|
result = {
|
||||||
|
"status": "ok",
|
||||||
|
"extension": manifest_info.name,
|
||||||
|
"ext_type": manifest_info.extension_type,
|
||||||
|
"version": version,
|
||||||
|
"package": str(zip_path),
|
||||||
|
"files": file_count,
|
||||||
|
"size_bytes": zip_size
|
||||||
|
}
|
||||||
|
|
||||||
|
print()
|
||||||
|
common.json_output(result)
|
||||||
|
|
||||||
|
return zip_path
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Package Joomla extension as distributable ZIP",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Package with auto-detected version
|
||||||
|
%(prog)s
|
||||||
|
|
||||||
|
# Package to specific directory
|
||||||
|
%(prog)s dist
|
||||||
|
|
||||||
|
# Package with specific version
|
||||||
|
%(prog)s dist 1.2.3
|
||||||
|
|
||||||
|
# Package with custom source
|
||||||
|
%(prog)s --src-dir my-extension dist 1.0.0
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"output_dir",
|
||||||
|
nargs="?",
|
||||||
|
default="dist",
|
||||||
|
help="Output directory for ZIP file (default: dist)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"version",
|
||||||
|
nargs="?",
|
||||||
|
help="Version string (default: auto-detected from manifest)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--src-dir",
|
||||||
|
default="src",
|
||||||
|
help="Source directory (default: src)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-name",
|
||||||
|
help="Repository name for ZIP filename (default: auto-detected)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-docs",
|
||||||
|
action="store_true",
|
||||||
|
help="Include documentation files in package"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-tests",
|
||||||
|
action="store_true",
|
||||||
|
help="Include test files in package"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Adjust exclusion patterns based on arguments
|
||||||
|
exclude_patterns = EXCLUDE_PATTERNS.copy()
|
||||||
|
if args.include_docs:
|
||||||
|
exclude_patterns.discard("docs")
|
||||||
|
exclude_patterns.discard("README.md")
|
||||||
|
exclude_patterns.discard("CHANGELOG.md")
|
||||||
|
if args.include_tests:
|
||||||
|
exclude_patterns.discard("tests")
|
||||||
|
exclude_patterns.discard("test")
|
||||||
|
exclude_patterns.discard("Tests")
|
||||||
|
|
||||||
|
# Create package
|
||||||
|
zip_path = create_package(
|
||||||
|
Variable zip_path is not used. Variable zip_path is not used.
```suggestion
create_package(
```
|
|||||||
|
src_dir=args.src_dir,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
version=args.version,
|
||||||
|
repo_name=args.repo_name,
|
||||||
|
exclude_patterns=exclude_patterns
|
||||||
|
)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
common.log_error(f"Packaging failed: {e}")
|
||||||
|
result = {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
common.json_output(result)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user
Import of 'os' is not used.