Initialer Import des AssetManagers
This commit is contained in:
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a source tree contains files unsuitable for public release."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TEXT_SUFFIXES = {
|
||||
".css", ".env", ".html", ".ini", ".js", ".json", ".md", ".py",
|
||||
".sh", ".toml", ".txt", ".yaml", ".yml", ".ps1", ".cmd", ".bat",
|
||||
}
|
||||
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "dist", "THIRD_PARTY_LICENSES"}
|
||||
ALLOWED_RUNTIME_MARKER = ".gitkeep"
|
||||
FORBIDDEN_FILES = {
|
||||
".env",
|
||||
"data/config/config.json",
|
||||
"data/config/APPINFO.json",
|
||||
"app/main_old.py",
|
||||
"PRUEFEN-0.5.5.5.sh",
|
||||
}
|
||||
FORBIDDEN_RUNTIME_DIRS = {
|
||||
"data/postgres", "data/uploads", "data/logs", "data/backups",
|
||||
"data/backup", "data/scripts", "app/static/uploads",
|
||||
}
|
||||
# Comparison language is blocked independently of a specific competitor name.
|
||||
# Real integrations should be described factually instead of "similar to ...".
|
||||
COMPARISON_PATTERNS = {
|
||||
"similar-to comparison": re.compile(r"\b(?:ähnlich(?:e|er|es|en|em)?|similar to)\b", re.IGNORECASE),
|
||||
"modeled-after comparison": re.compile(r"\b(?:nach (?:dem )?Vorbild|modelled after|modeled after)\b", re.IGNORECASE),
|
||||
"inspired-by comparison": re.compile(r"\b(?:inspiriert von|inspired by)\b", re.IGNORECASE),
|
||||
"like-in comparison": re.compile(r"\b(?:wie bei|as in)\b", re.IGNORECASE),
|
||||
"based-on comparison": re.compile(r"\b(?:angelehnt an|based on the UI of)\b", re.IGNORECASE),
|
||||
}
|
||||
PRIVATE_DOMAIN_PATTERN = re.compile(r"(?:https?://|wss?://|ldaps?://|@)[^\s/\"'<>]+\.(?:local|lan|internal)\b", re.IGNORECASE)
|
||||
IPV4_PATTERN = re.compile(r"(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)")
|
||||
PRIVATE_KEY_PATTERN = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
|
||||
IGNORE_PRODUCT_SCAN = {
|
||||
"tools/check_public_release.py",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"LICENSE.txt",
|
||||
}
|
||||
|
||||
|
||||
def rel(path: Path, root: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def public_files(root: Path):
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if any(part in SKIP_DIRS for part in relative.parts):
|
||||
continue
|
||||
yield path
|
||||
|
||||
|
||||
def read_text(path: Path) -> str | None:
|
||||
if path.suffix.lower() not in TEXT_SUFFIXES and path.name not in {"VERSION", ".gitignore", ".dockerignore", ".gitattributes"}:
|
||||
return None
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def version_from_python(path: Path) -> str:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "APP_VERSION" and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
||||
return node.value.value
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--denylist", type=Path, help="Optional local file with one private literal per line")
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
errors: list[str] = []
|
||||
local_literals: list[str] = []
|
||||
if args.denylist and args.denylist.is_file():
|
||||
local_literals = [
|
||||
line.strip().casefold()
|
||||
for line in args.denylist.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
]
|
||||
|
||||
for name in FORBIDDEN_FILES:
|
||||
if (root / name).is_file():
|
||||
errors.append(f"forbidden public file: {name}")
|
||||
|
||||
for directory in FORBIDDEN_RUNTIME_DIRS:
|
||||
path = root / directory
|
||||
if not path.exists():
|
||||
continue
|
||||
for item in path.rglob("*"):
|
||||
if item.is_file() and item.name != ALLOWED_RUNTIME_MARKER:
|
||||
errors.append(f"runtime/user data must not be public: {rel(item, root)}")
|
||||
|
||||
for path in public_files(root):
|
||||
relative = rel(path, root)
|
||||
lower = path.name.lower()
|
||||
if lower.endswith((".db", ".sqlite", ".sqlite3")):
|
||||
errors.append(f"database file must not be public: {relative}")
|
||||
text = read_text(path)
|
||||
if text is None:
|
||||
continue
|
||||
if PRIVATE_KEY_PATTERN.search(text):
|
||||
errors.append(f"private key material found: {relative}")
|
||||
if PRIVATE_DOMAIN_PATTERN.search(text):
|
||||
errors.append(f"private DNS suffix found in {relative}")
|
||||
for candidate in IPV4_PATTERN.findall(text):
|
||||
try:
|
||||
octets = [int(part) for part in candidate.split(".")]
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part > 255 for part in octets):
|
||||
continue
|
||||
is_rfc1918 = (
|
||||
octets[0] == 10
|
||||
or (octets[0] == 172 and 16 <= octets[1] <= 31)
|
||||
or (octets[0] == 192 and octets[1] == 168)
|
||||
)
|
||||
if is_rfc1918:
|
||||
errors.append(f"private IPv4 address found in {relative}")
|
||||
lowered = text.casefold()
|
||||
for literal in local_literals:
|
||||
if literal in lowered:
|
||||
errors.append(f"local denylist value found in {relative}")
|
||||
if relative not in IGNORE_PRODUCT_SCAN:
|
||||
for label, pattern in COMPARISON_PATTERNS.items():
|
||||
if pattern.search(text):
|
||||
errors.append(f"{label} found in {relative}")
|
||||
|
||||
license_path = root / "LICENSE.txt"
|
||||
if not license_path.is_file() or "Apache License" not in license_path.read_text(encoding="utf-8", errors="replace") or "Version 2.0, January 2004" not in license_path.read_text(encoding="utf-8", errors="replace"):
|
||||
errors.append("LICENSE.txt is not the complete Apache License 2.0 text")
|
||||
|
||||
for appinfo_name in ("APPINFO.json", "APPINFO.example.json"):
|
||||
path = root / appinfo_name
|
||||
try:
|
||||
info = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
errors.append(f"cannot read {appinfo_name}: {exc}")
|
||||
continue
|
||||
if info.get("license") != "Apache-2.0":
|
||||
errors.append(f"{appinfo_name} must use SPDX identifier Apache-2.0")
|
||||
|
||||
try:
|
||||
version = (root / "VERSION").read_text(encoding="utf-8").strip()
|
||||
py_version = version_from_python(root / "app/version.py")
|
||||
readme = (root / "README.md").read_text(encoding="utf-8")
|
||||
if not version or version != py_version:
|
||||
errors.append(f"version mismatch: VERSION={version!r}, app/version.py={py_version!r}")
|
||||
if version and version not in readme:
|
||||
errors.append(f"README.md does not contain current version {version}")
|
||||
except OSError as exc:
|
||||
errors.append(f"version check failed: {exc}")
|
||||
|
||||
if errors:
|
||||
print("Public release check failed:")
|
||||
for item in sorted(set(errors)):
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
print(f"Public release check passed for {root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check template/Python translation keys against BASE_TRANSLATIONS."""
|
||||
from pathlib import Path
|
||||
import ast, re, sys
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
source=(root/'app'/'i18n.py').read_text(encoding='utf-8')
|
||||
tree=ast.parse(source)
|
||||
keys=set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Dict):
|
||||
for key in node.keys:
|
||||
if isinstance(key, ast.Constant) and isinstance(key.value,str): keys.add(key.value)
|
||||
used=set()
|
||||
patterns=[re.compile(r"\bt\(\s*['\"]([^'\"]+)"),re.compile(r"_translate_request\([^,]+,\s*['\"]([^'\"]+)")]
|
||||
for path in list((root/'app').rglob('*.py'))+list((root/'app'/'templates').rglob('*.html')):
|
||||
text=path.read_text(encoding='utf-8',errors='replace')
|
||||
for pattern in patterns: used.update(pattern.findall(text))
|
||||
dynamic_prefixes=('field.','jobdefs.interpreter.','jobdefs.platform.','jobdefs.source.','jobs.action.','jobs.event.','jobs.type.','presence.','software.platform.','software.status.','backup.reason_')
|
||||
missing=sorted(k for k in used if k not in keys and '~' not in k and not k.startswith(dynamic_prefixes))
|
||||
jobdef_used=sorted(k for k in used if k.startswith('jobdefs.') and not k.endswith('.'))
|
||||
jobdef_missing=sorted(k for k in jobdef_used if k not in keys and not k.startswith(('jobdefs.interpreter.','jobdefs.platform.','jobdefs.source.')))
|
||||
if jobdef_missing:
|
||||
print('Missing jobdefs keys:')
|
||||
for key in jobdef_missing: print(' -',key)
|
||||
sys.exit(1)
|
||||
print(f'Defined translation keys: {len(keys)}')
|
||||
print(f'Statically referenced keys: {len(used)}')
|
||||
if missing:
|
||||
print('Legacy/static keys without BASE_TRANSLATIONS entry (warning):')
|
||||
for key in missing: print(' -',key)
|
||||
print('Job definition translation check passed.')
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a sanitized GitHub source ZIP with one assetmanager/ root folder."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EXCLUDED_NAMES = {
|
||||
".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache",
|
||||
".ruff_cache", "dist", "THIRD_PARTY_LICENSES",
|
||||
}
|
||||
EXCLUDED_FILES = {
|
||||
".env",
|
||||
"data/config/config.json",
|
||||
"data/config/APPINFO.json",
|
||||
"app/main_old.py",
|
||||
"PRUEFEN-0.5.5.5.sh",
|
||||
"assetmanager.db",
|
||||
".public-release-local-patterns",
|
||||
}
|
||||
RUNTIME_DIRS = {
|
||||
"data/postgres", "data/uploads", "data/logs", "data/backups",
|
||||
"data/backup", "data/scripts", "app/static/uploads",
|
||||
}
|
||||
|
||||
|
||||
def should_exclude(relative: Path) -> bool:
|
||||
posix = relative.as_posix()
|
||||
if any(part in EXCLUDED_NAMES for part in relative.parts):
|
||||
return True
|
||||
if posix in EXCLUDED_FILES:
|
||||
return True
|
||||
if relative.name.startswith(".env") and relative.name != ".env.example":
|
||||
return True
|
||||
if relative.suffix.lower() in {".db", ".sqlite", ".sqlite3", ".pyc", ".zip"}:
|
||||
return True
|
||||
return any(posix == directory or posix.startswith(directory + "/") for directory in RUNTIME_DIRS)
|
||||
|
||||
|
||||
def copy_source(destination: Path) -> None:
|
||||
for source in ROOT.rglob("*"):
|
||||
relative = source.relative_to(ROOT)
|
||||
if should_exclude(relative):
|
||||
continue
|
||||
target = destination / relative
|
||||
if source.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
elif source.is_file():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
for directory in sorted(RUNTIME_DIRS):
|
||||
marker = destination / directory / ".gitkeep"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
marker.touch()
|
||||
|
||||
|
||||
def make_zip(source_root: Path, output: Path) -> None:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for path in sorted(source_root.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, Path("assetmanager") / path.relative_to(source_root))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
output = (args.output or ROOT / "dist" / f"assetmanager-github-{version}.zip").resolve()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="assetmanager-public-") as temp_name:
|
||||
staged = Path(temp_name) / "assetmanager"
|
||||
staged.mkdir()
|
||||
copy_source(staged)
|
||||
checker = staged / "tools" / "check_public_release.py"
|
||||
command = [sys.executable, str(checker), "--root", str(staged)]
|
||||
denylist = ROOT / ".public-release-local-patterns"
|
||||
if denylist.is_file():
|
||||
command.extend(["--denylist", str(denylist)])
|
||||
result = subprocess.run(command)
|
||||
if result.returncode:
|
||||
print("GitHub ZIP was not created because the staged source failed validation.", file=sys.stderr)
|
||||
return result.returncode
|
||||
make_zip(staged, output)
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect license files from the packages installed in the build environment.
|
||||
|
||||
The result is evidence for the concrete image build. It does not replace a
|
||||
manual compatibility review of new or changed dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.metadata as metadata
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
LICENSE_PREFIXES = ("license", "licence", "copying", "notice", "copyright", "authors")
|
||||
|
||||
|
||||
def safe_name(value: str) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9._+-]+", "-", value.strip())
|
||||
return value.strip("-.") or "unknown"
|
||||
|
||||
|
||||
def is_license_name(name: str) -> bool:
|
||||
lower = name.lower()
|
||||
return any(lower.startswith(prefix) for prefix in LICENSE_PREFIXES)
|
||||
|
||||
|
||||
def unique_target(directory: Path, relative: Path) -> Path:
|
||||
candidate = directory / relative
|
||||
candidate.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
stem, suffix = candidate.stem, candidate.suffix
|
||||
index = 2
|
||||
while True:
|
||||
numbered = candidate.with_name(f"{stem}-{index}{suffix}")
|
||||
if not numbered.exists():
|
||||
return numbered
|
||||
index += 1
|
||||
|
||||
|
||||
def one_line(value: object) -> str:
|
||||
return " ".join(str(value or "").split())
|
||||
|
||||
|
||||
def collect_python(output: Path) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for dist in sorted(metadata.distributions(), key=lambda item: (item.metadata.get("Name") or "").lower()):
|
||||
name = dist.metadata.get("Name") or "unknown"
|
||||
version = dist.version or "unknown"
|
||||
destination = output / "python" / f"{safe_name(name)}-{safe_name(version)}"
|
||||
copied: list[str] = []
|
||||
for item in dist.files or ():
|
||||
if not is_license_name(Path(str(item)).name):
|
||||
continue
|
||||
source = Path(dist.locate_file(item))
|
||||
if not source.is_file():
|
||||
continue
|
||||
relative = Path(*Path(str(item)).parts[-2:]) if len(Path(str(item)).parts) > 1 else Path(source.name)
|
||||
target = unique_target(destination, relative)
|
||||
shutil.copy2(source, target)
|
||||
copied.append(target.relative_to(output).as_posix())
|
||||
classifiers = [value.removeprefix("License :: ") for value in dist.metadata.get_all("Classifier", []) if value.startswith("License :: ")]
|
||||
rows.append({
|
||||
"ecosystem": "Python",
|
||||
"name": name,
|
||||
"version": version,
|
||||
"license": one_line(dist.metadata.get("License") or "; ".join(classifiers)),
|
||||
"homepage": one_line(dist.metadata.get("Home-page") or dist.metadata.get("Project-URL")),
|
||||
"files": ", ".join(copied),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def node_package_dirs(node_modules: Path) -> Iterable[Path]:
|
||||
if not node_modules.is_dir():
|
||||
return
|
||||
for entry in sorted(node_modules.iterdir(), key=lambda path: path.name.lower()):
|
||||
if entry.name.startswith(".") or entry.name == ".bin" or not entry.is_dir():
|
||||
continue
|
||||
if entry.name.startswith("@"):
|
||||
for scoped in sorted(entry.iterdir(), key=lambda path: path.name.lower()):
|
||||
if scoped.is_dir() and (scoped / "package.json").is_file():
|
||||
yield scoped
|
||||
elif (entry / "package.json").is_file():
|
||||
yield entry
|
||||
|
||||
|
||||
def license_value(value: object) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return one_line(value.get("type") or value.get("name"))
|
||||
if isinstance(value, list):
|
||||
return "; ".join(filter(None, (license_value(item) for item in value)))
|
||||
return ""
|
||||
|
||||
|
||||
def collect_node(output: Path, node_modules: Path) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
for package_dir in node_package_dirs(node_modules):
|
||||
try:
|
||||
package = json.loads((package_dir / "package.json").read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"WARNING: cannot read {package_dir / 'package.json'}: {exc}")
|
||||
continue
|
||||
name = str(package.get("name") or package_dir.name)
|
||||
version = str(package.get("version") or "unknown")
|
||||
destination = output / "node" / f"{safe_name(name)}-{safe_name(version)}"
|
||||
copied: list[str] = []
|
||||
for source in sorted(package_dir.iterdir(), key=lambda path: path.name.lower()):
|
||||
if source.is_file() and is_license_name(source.name):
|
||||
target = unique_target(destination, Path(source.name))
|
||||
shutil.copy2(source, target)
|
||||
copied.append(target.relative_to(output).as_posix())
|
||||
repository = package.get("repository")
|
||||
if isinstance(repository, dict):
|
||||
repository = repository.get("url")
|
||||
rows.append({
|
||||
"ecosystem": "Node.js",
|
||||
"name": name,
|
||||
"version": version,
|
||||
"license": license_value(package.get("license") or package.get("licenses")),
|
||||
"homepage": one_line(package.get("homepage") or repository),
|
||||
"files": ", ".join(copied),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def write_index(output: Path, rows: list[dict[str, str]]) -> None:
|
||||
lines = [
|
||||
"# Installed dependency licenses",
|
||||
"",
|
||||
"Generated from the packages present during this build. Empty metadata fields must be checked against the copied original license files and upstream source.",
|
||||
"",
|
||||
"| Ecosystem | Package | Version | Declared license | Copied files |",
|
||||
"|---|---|---:|---|---|",
|
||||
]
|
||||
for row in sorted(rows, key=lambda item: (item["ecosystem"], item["name"].lower(), item["version"])):
|
||||
escaped = {key: value.replace("|", "\\|") for key, value in row.items()}
|
||||
lines.append(f"| {escaped['ecosystem']} | {escaped['name']} | {escaped['version']} | {escaped['license']} | {escaped['files']} |")
|
||||
(output / "INDEX.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
(output / "INDEX.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--node-modules", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
output = args.output.resolve()
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
output.mkdir(parents=True)
|
||||
|
||||
rows = collect_python(output)
|
||||
if args.node_modules:
|
||||
rows.extend(collect_node(output, args.node_modules.resolve()))
|
||||
write_index(output, rows)
|
||||
|
||||
missing = [row for row in rows if not row["license"] or not row["files"]]
|
||||
print(f"Collected metadata for {len(rows)} installed packages in {output}")
|
||||
if missing:
|
||||
print(f"WARNING: {len(missing)} package(s) have missing license metadata or no copied license file; review INDEX.md")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lightweight release checks for AssetManager.
|
||||
|
||||
The checks are intentionally read-only and do not require a running database.
|
||||
A failure exits with status 1 so the script can be used in CI or a release job.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import compileall
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_DIR = ROOT / "app"
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"[FAIL] {message}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def ok(message: str) -> None:
|
||||
print(f"[ OK ] {message}")
|
||||
|
||||
|
||||
def check_version() -> None:
|
||||
path = APP_DIR / "version.py"
|
||||
spec = importlib.util.spec_from_file_location("assetmanager_release_version", path)
|
||||
if spec is None or spec.loader is None:
|
||||
fail("app/version.py could not be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
app_version = getattr(module, "APP_VERSION", None)
|
||||
conventional = getattr(module, "__version__", None)
|
||||
if not isinstance(app_version, str) or not app_version.strip():
|
||||
fail("APP_VERSION is missing or empty")
|
||||
if conventional != app_version:
|
||||
fail("__version__ must be an alias of APP_VERSION")
|
||||
version_file = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||
if version_file != app_version:
|
||||
fail(f"VERSION ({version_file}) and app/version.py ({app_version}) differ")
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
if app_version not in readme:
|
||||
fail(f"README.md does not contain current version {app_version}")
|
||||
ok(f"Version exports are valid ({app_version})")
|
||||
|
||||
|
||||
def check_compile() -> None:
|
||||
if not compileall.compile_dir(str(APP_DIR), quiet=1, force=True):
|
||||
fail("Python compilation failed")
|
||||
ok("Python sources compile")
|
||||
|
||||
|
||||
def check_translations() -> None:
|
||||
script = ROOT / "tools" / "check_translations.py"
|
||||
result = subprocess.run([sys.executable, str(script)], cwd=ROOT, text=True)
|
||||
if result.returncode:
|
||||
fail("Translation check failed")
|
||||
ok("Translation check passed")
|
||||
|
||||
|
||||
def check_templates() -> None:
|
||||
try:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
except ImportError as exc:
|
||||
fail(f"Jinja2 is unavailable: {exc}")
|
||||
template_dir = APP_DIR / "templates"
|
||||
env = Environment(loader=FileSystemLoader(str(template_dir)))
|
||||
failures: list[str] = []
|
||||
for path in sorted(template_dir.rglob("*.html")):
|
||||
name = path.relative_to(template_dir).as_posix()
|
||||
try:
|
||||
env.get_template(name)
|
||||
except Exception as exc: # syntax/load validation
|
||||
failures.append(f"{name}: {exc}")
|
||||
if failures:
|
||||
fail("Template validation failed:\n " + "\n ".join(failures))
|
||||
ok("Templates load without syntax errors")
|
||||
|
||||
|
||||
|
||||
def check_job_definition_frontend() -> None:
|
||||
form_path = APP_DIR / "templates" / "job_definition_form.html"
|
||||
content = form_path.read_text(encoding="utf-8")
|
||||
required_frontend = [
|
||||
'id="insert-job-test-script"',
|
||||
'id="job-parameters-json"',
|
||||
'id="add-job-parameter"',
|
||||
'job-enabled-top',
|
||||
'jobdefs.own_script',
|
||||
]
|
||||
missing = [item for item in required_frontend if item not in content]
|
||||
if missing:
|
||||
fail("Job-definition frontend validation failed; missing: " + ", ".join(missing))
|
||||
|
||||
template_dir = APP_DIR / "templates" / "jobs"
|
||||
required_templates = {
|
||||
"powershell_wrapper.ps1": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"],
|
||||
"python_wrapper.py.txt": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"],
|
||||
"bash_wrapper.sh": ["{{JobId}}", "{{CallbackUrl}}", "${status}", "${code}"],
|
||||
"cmd_wrapper.cmd": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"],
|
||||
"powershell_test.txt": ["{{AssetName}}", "{{Hostname}}"],
|
||||
"python_test.txt": ["{{AssetName}}", "{{Hostname}}"],
|
||||
"bash_test.txt": ["{{AssetName}}", "{{Hostname}}"],
|
||||
"cmd_test.txt": ["{{AssetName}}", "{{Hostname}}"],
|
||||
}
|
||||
failures: list[str] = []
|
||||
for filename, markers in required_templates.items():
|
||||
path = template_dir / filename
|
||||
if not path.is_file():
|
||||
failures.append(f"missing file: {filename}")
|
||||
continue
|
||||
template_content = path.read_text(encoding="utf-8")
|
||||
for marker in markers:
|
||||
if marker not in template_content:
|
||||
failures.append(f"{filename}: missing {marker}")
|
||||
if failures:
|
||||
fail("Job script template validation failed:\n " + "\n ".join(failures))
|
||||
ok("Job-definition backend generator safeguards are present")
|
||||
|
||||
|
||||
def check_fastapi_import() -> None:
|
||||
# Import only. Do not start Uvicorn and do not connect to external services.
|
||||
sys.path.insert(0, str(ROOT))
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:")
|
||||
try:
|
||||
from app.main import app # noqa: F401
|
||||
except Exception as exc:
|
||||
fail(f"FastAPI application import failed: {type(exc).__name__}: {exc}")
|
||||
ok("FastAPI application imports")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("AssetManager release validation")
|
||||
check_version()
|
||||
check_compile()
|
||||
check_translations()
|
||||
check_templates()
|
||||
check_job_definition_frontend()
|
||||
check_fastapi_import()
|
||||
print("All release checks passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user