www_archline_hu/lib/jmap.py
LÁZÁR Imre AI Agent a9eeddbc3b build(baseline): add lib/jmap.py Joomla manifest-mapper + tests
Generic Joomla 3.x manifest -> deployed-path mapper (component/module/plugin/
library/template/package), used to author the pristine (upstream) commit of a
packages/<slug> module before it is reconciled to the live bytes. 18/18 tests
green; ruff/mypy-strict clean.

Signed-off-by: LÁZÁR Imre <imre@illusion.hu>
Assisted-by: claude-code@claude-opus-4-8
2026-07-16 11:27:45 +02:00

558 lines
23 KiB
Python

#!/usr/bin/env python3
"""Joomla 3.x manifest -> deployed-path mapper.
Generikus baseline-generátor komponens: egy kicsomagolt Joomla-bővítmény-
csomagot (manifest-XML + a hozzá tartozó forrásfák) a Joomla-installer
mapping-szabályai szerint másolja a megfelelő deployed docroot-útvonalakra.
Támogatott ``<extension type="...">`` gyökér-típusok: component, module,
plugin, library, template, language, file, package (pkg_ csomag, rekurzívan
feldolgozott al-bővítményekkel).
A ``folder`` attribútum (``<files folder="...">``, ``<media folder="...">``,
``<languages folder="...">``) a manifest-fájl könyvtárához relatív forrás-
alkönyvtárat jelöli; ha nincs megadva, a manifest melletti könyvtár a forrás.
Csak a Python standard library-t használja (nincs külső dependency).
"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
import xml.etree.ElementTree as ET
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
class JoomlaManifestError(Exception):
"""A manifest-feldolgozás vagy a mapping-szabályok megsértése esetén dobott hiba.
Minden hibás/hiányzó manifest-tartalom, ismeretlen extension-típus vagy
hiányzó forrás-fájl ezt dobja — a hívó CLI ezt explicit elkapja és
érthető hibaüzenettel, nemnulla exit-kóddal fejezi be a futást.
"""
@dataclass(frozen=True)
class CopyOp:
"""Egyetlen másolási művelet: forrás -> cél. A src lehet fájl vagy könyvtár."""
src: Path
dest: Path
# ---------------------------------------------------------------------------
# XML-segédfüggvények
# ---------------------------------------------------------------------------
def _text(elem: ET.Element | None) -> str:
"""Egy elem trimmelt szöveg-tartalma, vagy üres string, ha nincs elem/szöveg."""
if elem is None or elem.text is None:
return ""
return elem.text.strip()
def _slug(text: str) -> str:
"""Emberi-olvasható nevet gépi slug-gá normalizál (kisbetű, szóköz -> '_')."""
normalized = text.strip().lower()
return "_".join(normalized.split())
def _extension_name(root: ET.Element) -> str:
"""A bővítmény gépi-nevét adja vissza a gyökér ``element`` attribútumból,
vagy ennek hiányában a ``<name>`` elem szövegéből (slugolva).
"""
element_attr = root.get("element")
if element_attr:
return element_attr.strip()
name_text = _text(root.find("name"))
if not name_text:
raise JoomlaManifestError(
"nem határozható meg a bővítmény neve: hiányzik a <name> elem és az 'element' attribútum"
)
return _slug(name_text)
def _plugin_element(root: ET.Element) -> str:
"""A plugin element-slugját adja vissza.
Elsődleges forrás: ``<files>`` blokk ``<filename plugin="...">`` vagy
``<file plugin="...">`` gyereke (mindkét tag-alak előfordul a valós
csomagokban -- ez a Joomla-konvenció szerinti autoritatív érték). Ha ez
hiányzik, a ``<name>PLG_GROUP_ELEMENT</name>`` konvenció utolsó
'_'-tagját használja fallback-ként.
"""
files_elem = root.find("files")
if files_elem is not None:
for child in files_elem:
if child.tag not in ("filename", "file"):
continue
plugin_attr = child.get("plugin")
if plugin_attr:
return plugin_attr.strip()
name_text = _text(root.find("name"))
if name_text:
return _slug(name_text.rsplit("_", 1)[-1])
raise JoomlaManifestError(
'nem határozható meg a plugin element-je: nincs <filename plugin="..."> / '
'<file plugin="..."> és nincs <name>'
)
_COMPONENT_LANG_SYS_INI = re.compile(r"(com_[a-z0-9_]+)\.sys\.ini$", re.IGNORECASE)
_COMPONENT_SCRIPTFILE = re.compile(r"script\.([a-z0-9_]+)\.php$", re.IGNORECASE)
def _component_slug(text: str) -> str:
"""Komponens-display-nevet ('OneAll Social Login') Joomla-konvenció szerint
normalizál a végső fallback-ágon: szóköz-ELTÁVOLÍTÁS (nem aláhúzás-csere)
+ kisbetűsítés -> 'oneallsociallogin'.
"""
return "".join(text.strip().lower().split())
def _component_name(root: ET.Element) -> str:
"""A komponens deployed-elemnevét ('com_X') vezeti le a Joomla 3.x
installer-heurisztika prioritási sorrendjében:
1. explicit gyökér ``element`` attribútum
2. a fő ``<files folder="com_...">`` attribútuma, ha 'com_'-prefixű --
ez az autoritatív forrás, mert a ``<name>`` gyakran csak emberi
megjelenítő-cím (pl. "OneAll Social Login", nem "com_oneallsociallogin")
3. az adminisztratív, majd a site ``<languages><language>`` fájlnév-
mintázata ``en-GB.com_<elem>.sys.ini`` -> ``com_<elem>``
4. ``<scriptfile>install/script.<elem>.php</scriptfile>`` -> ``com_<elem>``
5. végső fallback: ``<name>`` Joomla-konvenció szerinti normalizálása
(szóköz-eltávolítás, kisbetűsítés); ha az eredmény már 'com_'-prefixű
(gépi-nevet tartalmazó manifest), változatlanul megy tovább, egyébként
'com_' prefixet kap.
"""
element_attr = root.get("element")
if element_attr:
return element_attr.strip()
files_elem = root.find("files")
if files_elem is not None:
folder_attr = files_elem.get("folder", "")
if folder_attr.lower().startswith("com_"):
return folder_attr
for languages_elem in (root.find("administration/languages"), root.find("languages")):
if languages_elem is None:
continue
for language_elem in languages_elem.findall("language"):
match = _COMPONENT_LANG_SYS_INI.search(_text(language_elem))
if match:
return match.group(1).lower()
scriptfile_text = _text(root.find("scriptfile"))
if scriptfile_text:
match = _COMPONENT_SCRIPTFILE.search(scriptfile_text)
if match:
return f"com_{match.group(1).lower()}"
name_text = _text(root.find("name"))
if name_text:
slug = _component_slug(name_text)
return slug if slug.startswith("com_") else f"com_{slug}"
raise JoomlaManifestError(
"nem határozható meg a komponens deployed-neve: nincs 'element' attribútum, "
'nincs <files folder="com_...">, nincs com_-mintázatú nyelvi fájl, '
"nincs <scriptfile>, és nincs <name>"
)
# ---------------------------------------------------------------------------
# <files> / <media> / <languages> blokk -> CopyOp lista
# ---------------------------------------------------------------------------
def _apply_files(files_elem: ET.Element | None, manifest_dir: Path, dest_dir: Path) -> list[CopyOp]:
"""Egy ``<files>``-szerű blokk (files/media) gyerekeit CopyOp-listává alakítja.
Az egyedi-fájl gyerek tag-neve a valós csomagokban ``<filename>`` VAGY
``<file>`` lehet (mindkettőt elfogadja a Joomla-installer, csak a szöveg-
tartalom és a ``target``/egyéb attribútumok számítanak); a ``<folder>``
gyerek egy teljes alfát másol. A ``folder`` attribútum a forrás-
alkönyvtárat jelöli a manifest könyvtárához képest; a ``target``
attribútum (ha van) felülírja a cél-relatív-útvonalat.
"""
if files_elem is None:
return []
folder_attr = files_elem.get("folder", "")
src_base = manifest_dir / folder_attr if folder_attr else manifest_dir
ops: list[CopyOp] = []
for child in files_elem:
if child.tag not in ("filename", "file", "folder"):
continue
rel_text = _text(child)
if not rel_text:
raise JoomlaManifestError(
f"<{child.tag}> elem üres útvonal-szöveggel a(z) {manifest_dir} manifest-ben"
)
rel_path = Path(rel_text)
target_attr = child.get("target")
dest_rel = Path(target_attr) if target_attr else rel_path
ops.append(CopyOp(src=src_base / rel_path, dest=dest_dir / dest_rel))
return ops
def _apply_media(media_elem: ET.Element | None, manifest_dir: Path, media_root: Path) -> list[CopyOp]:
"""``<media destination="..." folder="...">`` blokk -> ``media/<destination>/`` alá."""
if media_elem is None:
return []
destination = media_elem.get("destination")
if not destination:
raise JoomlaManifestError(
f"<media> elem hiányzó 'destination' attribútummal a(z) {manifest_dir} manifest-ben"
)
return _apply_files(media_elem, manifest_dir, media_root / destination)
def _apply_languages(languages_elem: ET.Element | None, manifest_dir: Path, lang_root: Path) -> list[CopyOp]:
"""``<languages folder="...">`` blokk -> ``<lang_root>/<tag>/<fájlnév>``.
A gyerek ``<language tag="...">relatív/út.ini</language>`` elemek szövege
a forrás-relatív útvonal; a cél a tag-alkönyvtárban a fájl alap-neve
(a Joomla-konvenció szerint a forrás már tag-alkönyvtárban van).
"""
if languages_elem is None:
return []
folder_attr = languages_elem.get("folder", "")
src_base = manifest_dir / folder_attr if folder_attr else manifest_dir
ops: list[CopyOp] = []
for language_elem in languages_elem.findall("language"):
tag = language_elem.get("tag")
if not tag:
raise JoomlaManifestError(
f"<language> elem hiányzó 'tag' attribútummal a(z) {manifest_dir} manifest-ben"
)
rel_text = _text(language_elem)
if not rel_text:
raise JoomlaManifestError(
f'<language tag="{tag}"> elem üres fájl-útvonallal a(z) {manifest_dir} manifest-ben'
)
rel_path = Path(rel_text)
ops.append(CopyOp(src=src_base / rel_path, dest=lang_root / tag / rel_path.name))
return ops
# ---------------------------------------------------------------------------
# Extension-típus -> mapping-szabály
# ---------------------------------------------------------------------------
def map_component(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""com_X: files->components/com_X, administration/files->administrator/components/com_X,
media->media/com_X, languages(site+admin)->language/ ill. administrator/language/.
A manifestbe ágyazott ``<modules>``/``<plugins>`` szekciók (ha vannak --
pl. egy komponens saját manifestje telepít egy modult/plugint is, mint
az OneAll Social Login csomagnál) is feldolgozásra kerülnek; ez NEM a
``pkg_`` package-típus (annak külön al-manifestjei vannak), hanem a
komponens EGYETLEN manifestjébe ágyazott mapping-szekció.
"""
name = _component_name(root)
ops = _apply_files(root.find("files"), manifest_dir, out_root / "components" / name)
admin_elem = root.find("administration")
if admin_elem is not None:
ops += _apply_files(
admin_elem.find("files"), manifest_dir, out_root / "administrator" / "components" / name
)
ops += _apply_languages(
admin_elem.find("languages"), manifest_dir, out_root / "administrator" / "language"
)
ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language")
ops += _apply_embedded_modules(root.find("modules"), manifest_dir, out_root)
ops += _apply_embedded_plugins(root.find("plugins"), manifest_dir, out_root)
return ops
def _apply_embedded_modules(
modules_elem: ET.Element | None, manifest_dir: Path, out_root: Path
) -> list[CopyOp]:
"""Egy komponens-manifestbe ágyazott ``<modules><module module="mod_X" client="...">``
szekció feldolgozása. A deployed-modulnév közvetlenül a ``module`` attribútumból jön
(nem a gyakran emberi-olvasható ``<name>``-ből).
"""
if modules_elem is None:
return []
ops: list[CopyOp] = []
for module_elem in modules_elem.findall("module"):
module_name = module_elem.get("module")
if not module_name:
raise JoomlaManifestError(f"beágyazott <module> hiányzó 'module' attribútummal: {manifest_dir}")
is_admin = module_elem.get("client") == "administrator"
base = out_root / ("administrator/modules" if is_admin else "modules") / module_name
ops += _apply_files(module_elem.find("files"), manifest_dir, base)
ops += _apply_media(module_elem.find("media"), manifest_dir, out_root / "media")
return ops
def _apply_embedded_plugins(
plugins_elem: ET.Element | None, manifest_dir: Path, out_root: Path
) -> list[CopyOp]:
"""Egy komponens-manifestbe ágyazott ``<plugins><plugin plugin="X" group="Y">``
szekció feldolgozása. Az elem-slug és a group közvetlenül a ``plugin``/``group``
attribútumokból jön (nem a gyakran emberi-olvasható ``<name>``-ből).
"""
if plugins_elem is None:
return []
ops: list[CopyOp] = []
for plugin_elem in plugins_elem.findall("plugin"):
element = plugin_elem.get("plugin")
group = plugin_elem.get("group")
if not element or not group:
raise JoomlaManifestError(
f"beágyazott <plugin> hiányzó 'plugin' vagy 'group' attribútummal: {manifest_dir}"
)
base = out_root / "plugins" / group / element
ops += _apply_files(plugin_elem.find("files"), manifest_dir, base)
ops += _apply_media(plugin_elem.find("media"), manifest_dir, out_root / "media")
return ops
def map_module(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""mod_X: files->modules/mod_X (vagy administrator/modules/mod_X ha client=administrator)."""
name = _extension_name(root)
is_admin = root.get("client") == "administrator"
base = out_root / ("administrator/modules" if is_admin else "modules") / name
ops = _apply_files(root.find("files"), manifest_dir, base)
ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
lang_root = out_root / ("administrator/language" if is_admin else "language")
ops += _apply_languages(root.find("languages"), manifest_dir, lang_root)
return ops
def map_plugin(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""plugin: files->plugins/<group>/<element>."""
group = root.get("group")
if not group:
raise JoomlaManifestError(f"plugin manifest hiányzó 'group' attribútummal: {manifest_dir}")
element = _plugin_element(root)
base = out_root / "plugins" / group / element
ops = _apply_files(root.find("files"), manifest_dir, base)
ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
ops += _apply_languages(root.find("languages"), manifest_dir, out_root / "language")
return ops
def map_library(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""library: files->libraries/<name>."""
name = _extension_name(root)
ops = _apply_files(root.find("files"), manifest_dir, out_root / "libraries" / name)
ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
return ops
def map_template(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""template: files->templates/<name> (vagy administrator/templates/<name>)."""
name = _extension_name(root)
is_admin = root.get("client") == "administrator"
base = out_root / ("administrator/templates" if is_admin else "templates") / name
ops = _apply_files(root.find("files"), manifest_dir, base)
ops += _apply_media(root.find("media"), manifest_dir, out_root / "media")
return ops
def map_language(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""language (nyelvi csomag): files -> language/<tag> (vagy administrator/language/<tag>)."""
tag = _text(root.find("tag")) or _slug(_text(root.find("name")))
if not tag:
raise JoomlaManifestError(f"language manifest hiányzó <tag> és <name> elemmel: {manifest_dir}")
is_admin = root.get("client") == "administrator"
lang_root = out_root / ("administrator/language" if is_admin else "language") / tag
return _apply_files(root.find("files"), manifest_dir, lang_root)
def map_file(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""type=file: a <files> gyerekek target-attribútuma (vagy a relatív útvonaluk)
közvetlenül a docroot gyökeréhez képest értelmezendő.
"""
return _apply_files(root.find("files"), manifest_dir, out_root)
def map_package(root: ET.Element, manifest_dir: Path, out_root: Path) -> list[CopyOp]:
"""pkg_ csomag: a <files><file type="..." id="..."> bejegyzések al-csomag
alkönyvtárakra mutatnak; mindegyiket a saját manifestjével rekurzívan dolgozza fel.
"""
files_elem = root.find("files")
if files_elem is None:
raise JoomlaManifestError(f"package manifest hiányzó <files> elemmel: {manifest_dir}")
folder_attr = files_elem.get("folder", "")
src_base = manifest_dir / folder_attr if folder_attr else manifest_dir
ops: list[CopyOp] = []
for file_elem in sorted(files_elem.findall("file"), key=lambda e: (_text(e), e.get("id", ""))):
rel_text = _text(file_elem)
if not rel_text:
raise JoomlaManifestError(f"<file> al-csomag bejegyzés üres útvonal-szöveggel: {manifest_dir}")
sub_dir = src_base / rel_text
if not sub_dir.is_dir():
raise JoomlaManifestError(
f"az al-csomag könyvtára nem található: {sub_dir} (package manifest: {manifest_dir})"
)
sub_manifest = find_manifest(sub_dir)
ops += build_operations(sub_manifest, out_root)
return ops
TypeHandler = Callable[[ET.Element, Path, Path], list[CopyOp]]
TYPE_HANDLERS: dict[str, TypeHandler] = {
"component": map_component,
"module": map_module,
"plugin": map_plugin,
"library": map_library,
"template": map_template,
"language": map_language,
"file": map_file,
"package": map_package,
}
# ---------------------------------------------------------------------------
# Manifest-keresés + dispatch + végrehajtás
# ---------------------------------------------------------------------------
def _is_extension_manifest(path: Path) -> bool:
"""True, ha a path egy jólformált XML, aminek gyökér-eleme <extension>."""
try:
# bandit B314: stdlib ElementTree XXE-re elméletileg sebezhető; a bemenet
# egy lokálisan kicsomagolt, adminisztrátor-szolgáltatta bővítmény-csomag
# (nem távoli/attacker-controlled forrás), és a feladat-kontraktus
# kifejezetten csak stdlib-et enged (defusedxml külső dependency lenne
# -> PO-jóváhagyás szükséges, jelen scope-ban nincs) — lásd záró-riport.
root = ET.parse(path).getroot() # nosec B314
except ET.ParseError:
return False
except OSError:
return False
return root.tag == "extension"
def find_manifest(directory: Path) -> Path:
"""A directory közvetlen szintjén (nem rekurzívan) megkeresi az egyetlen
Joomla extension-manifestet (<extension> gyökér-elemű XML-fájl).
Raises:
JoomlaManifestError: ha egy sincs, vagy ha egynél több található
(ez utóbbi esetben a mapping nem egyértelmű, a hívónak
szét kell választania a csomagot).
"""
candidates = sorted(
(p for p in directory.glob("*.xml") if _is_extension_manifest(p)),
key=lambda p: p.name,
)
if not candidates:
raise JoomlaManifestError(f"nem található Joomla extension-manifest itt: {directory}")
if len(candidates) > 1:
names = ", ".join(str(c) for c in candidates)
raise JoomlaManifestError(
f"több lehetséges manifest is található itt: {directory} ({names}); "
"egy csomag-könyvtárban pontosan egy gyökér-manifest várt"
)
return candidates[0]
def build_operations(manifest_path: Path, out_root: Path) -> list[CopyOp]:
"""A manifest-path-hoz tartozó extension teljes CopyOp-listáját építi fel
(a saját mapping-szabálya szerint; package-típus esetén rekurzívan az
al-bővítményekével együtt).
"""
try:
tree = ET.parse(manifest_path) # nosec B314 -- lásd _is_extension_manifest WHY-kommentje
except ET.ParseError as exc:
raise JoomlaManifestError(f"hibás XML a manifest-ben: {manifest_path} ({exc})") from exc
root = tree.getroot()
if root.tag != "extension":
raise JoomlaManifestError(f"{manifest_path}: a gyökér-elem <{root.tag}>, elvárt: <extension>")
ext_type = root.get("type")
if not ext_type:
raise JoomlaManifestError(f"{manifest_path}: <extension> hiányzó 'type' attribútummal")
handler = TYPE_HANDLERS.get(ext_type)
if handler is None:
supported = ", ".join(sorted(TYPE_HANDLERS))
raise JoomlaManifestError(
f"{manifest_path}: nem támogatott extension type '{ext_type}' (támogatott: {supported})"
)
return handler(root, manifest_path.parent, out_root)
def _copytree_merge(src: Path, dest: Path) -> None:
"""src könyvtárfát dest alá másolja rendezett bejárással, meglévő dest-et megtartva/bővítve."""
dest.mkdir(parents=True, exist_ok=True)
for entry in sorted(src.iterdir(), key=lambda p: p.name):
target = dest / entry.name
if entry.is_dir():
_copytree_merge(entry, target)
else:
shutil.copy2(entry, target)
def execute(ops: list[CopyOp]) -> None:
"""A CopyOp-listát ténylegesen végrehajtja: fájlokat/könyvtárfákat másol.
Raises:
JoomlaManifestError: ha egy forrás-útvonal nem létezik.
"""
for op in ops:
if not op.src.exists():
raise JoomlaManifestError(f"a forrás-útvonal nem létezik: {op.src}")
if op.src.is_dir():
_copytree_merge(op.src, op.dest)
else:
op.dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(op.src, op.dest)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
"""CLI belépőpont: ``python3 jmap.py --package <pkg_dir> --out <docroot_dir>``."""
parser = argparse.ArgumentParser(
description=(
"Joomla 3.x manifest-alapú bővítmény-deployer: kicsomagolt bővítmény-csomagot "
"másol a megfelelő deployed docroot-útvonalakra a Joomla-installer mapping-szabályai szerint."
)
)
parser.add_argument(
"--package", required=True, type=Path, help="kicsomagolt bővítmény-csomag gyökér-könyvtára"
)
parser.add_argument("--out", required=True, type=Path, help="cél deployed docroot könyvtár")
args = parser.parse_args(argv)
package_dir: Path = args.package
out_dir: Path = args.out
if not package_dir.is_dir():
parser.error(f"--package nem létező könyvtár: {package_dir}")
try:
manifest_path = find_manifest(package_dir)
ops = build_operations(manifest_path, out_dir)
execute(ops)
except JoomlaManifestError as exc:
print(f"hiba: {exc}", file=sys.stderr)
return 1
print(f"kész: {len(ops)} másolási művelet végrehajtva -> {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())