Files
vtt_work/help_scripts/sync_foundry_module.py
T
2025-10-29 15:51:25 +01:00

846 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sync_foundry_module.py
Rozszerzony:
- Tryb AUTO: wykrywanie katalogów ze zmianami (git), obsługa modułów i niemodułów.
- Każdy katalog -> osobny commit i push.
- Root version.nfo: opcjonalny bump globalnej wersji (0.x) jeśli są zmiany i HEAD ma tag z obecną wersją.
- Zipowanie po commit/push, przed podmianą plików (moduły) lub tuż po commicie (niemoduły):
ZIP_MODE = "ALL" | "MODULE_ONLY" | "OFF"; CLI: --zip-mode; --zip-output-dir.
Dwa tryby:
- AUTO (domyślny): wykryj wszystkie ZMIENIONE podkatalogi w SOURCE_BASE_DIR:
* jeśli folder zawiera module.json -> procedura "moduł"
* jeśli nie -> procedura "niemoduł" (version.nfo 0.x, inkrementacja)
- SINGLE: przetwórz wyłącznie katalog wskazany w MODULE_SUBDIR_NAME (jak wcześniej)
Dry-run:
- nic NIE modyfikuje (ani plików, ani repo), nie czyści i nie kopiuje tylko plan.
Wymagania:
- Python 3.8+
- git w PATH (dla commit/push i wykrywania zmian)
- uprawnienia do zapisu w repo i w katalogu docelowym modułów
"""
from __future__ import annotations
import os
import sys
import shutil
import argparse
import json
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple, Set
# ==========================
# KONFIGURACJA (DOMYŚLNA)
# ==========================
# Tryb: "auto" (wykryj zmienione katalogi) lub "single" (tylko MODULE_SUBDIR_NAME)
MODE: str = "auto" # "auto" | "single"
TARGET_USER: str = "mtusz" # nazwa folderu w C:\Users\...
APPDATA_KIND: Optional[str] = None # None | "Local" | "LocalLow" | "Roaming"
TARGET_MODULES_DIR_OVERRIDE: Optional[str] = None # pełna ścieżka do ...\FoundryVTT\Data\modules
MODULE_SUBDIR_NAME: str = "wg-voidships" # używane w trybie SINGLE
CONFIRM_DELETE: bool = True
SOURCE_BASE_DIR: str = r"C:\vtt_work"
RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules"
DRY_RUN: bool = False # globalny tryb symulacji (nic nie zmienia)
# --- ZIP settings ---
# ALL -> zipuj WSZYSTKIE katalogi (moduły i niemoduły)
# MODULE_ONLY -> zipuj TYLKO katalogi z module.json
# OFF -> nie twórz zipów
ZIP_MODE: str = "ALL" # "ALL" | "MODULE_ONLY" | "OFF"
# Katalog wyjściowy na archiwa .zip; jeśli None, użyje SOURCE_BASE_DIR/_zips
ZIP_OUTPUT_DIR: Optional[str] = r"C:\Release packs"
CHECK_MODE: bool = False # tylko walidacja brak zmian, wyjście !=0 jeśli są akcje
ENABLE_GH_RELEASE: bool = False # publikowanie release przez gh CLI
RELEASE_TAG_PREFIX: Optional[str] = None # prefiks taga release (np. "module/")
RELEASE_NOTES_TEMPLATE: str = "Automated release for {name} v{version}"
GIT_REMOTE_NAME: str = "origin"
# ==========================
# KONIEC KONFIGURACJI
# ==========================
APPDATA_VARIANTS = ("Local", "LocalLow", "Roaming")
ARGS = None # wypełniane w main()
class AmbiguityError(RuntimeError):
pass
def norm(p: Path) -> Path:
return Path(os.path.normpath(str(p)))
def sh(cmd: List[str], cwd: Path) -> Tuple[int, str]:
"""Uruchom polecenie i zwróć (rc, out)."""
r = subprocess.run(cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return r.returncode, r.stdout
def git_head_tag(repo_dir: Path) -> Optional[str]:
"""Zwraca tag dokładnie na HEAD (jeśli istnieje), inaczej None."""
rc, out = sh(["git", "describe", "--tags", "--exact-match", "HEAD"], repo_dir)
if rc != 0:
return None
return out.strip() if out.strip() else None
def ensure_git_repo(path: Path) -> None:
rc, out = sh(["git", "rev-parse", "--is-inside-work-tree"], path)
if rc != 0 or "true" not in out.strip().lower():
raise RuntimeError(f"Katalog {norm(path)} nie wygląda na repozytorium git.\n{out}")
def git_changed_top_level_dirs(repo_dir: Path) -> List[str]:
"""
Zwraca posortowaną listę unikalnych NAZW PODKATALOGÓW (1. poziom) w repo,
które mają zmiany wg `git status --porcelain` (staged/unstaged/untracked).
Zmiany w plikach w root repo są ignorowane (brak katalogu).
"""
rc, out = sh(["git", "status", "--porcelain"], repo_dir)
if rc != 0:
raise RuntimeError(f"Błąd podczas `git status --porcelain` w {norm(repo_dir)}:\n{out}")
changed: Set[str] = set()
for line in out.splitlines():
if not line.strip():
continue
# format: XY <path> lub "?? <path>"
try:
path = line[3:].strip()
except Exception:
continue
# ignoruj rename "R a -> b": wówczas path ma "a -> b"
if "->" in path:
path = path.split("->", 1)[1].strip()
parts = Path(path).parts
if len(parts) >= 1:
top = parts[0]
# bierz tylko katalogi (jeśli top to plik w root, pomiń)
if (repo_dir / top).is_dir():
changed.add(top)
return sorted(changed)
def git_current_head(repo_dir: Path) -> str:
rc, out = sh(["git", "rev-parse", "HEAD"], repo_dir)
if rc != 0:
raise RuntimeError(f"Nie mogę ustalić HEAD w {norm(repo_dir)}:\n{out}")
return out.strip()
def git_tag_exists(repo_dir: Path, tag: str) -> bool:
rc, _ = sh(["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], repo_dir)
return rc == 0
def create_git_tag(repo_dir: Path, tag: str, message: str, commit: Optional[str]) -> None:
if git_tag_exists(repo_dir, tag):
print(f"[INFO] Tag {tag} już istnieje pomijam tworzenie.")
return
cmd = ["git", "tag", "-a", tag]
if commit:
cmd.append(commit)
cmd += ["-m", message]
rc, out = sh(cmd, repo_dir)
if rc != 0:
raise RuntimeError(f"Nie udało się utworzyć taga {tag}:\n{out}")
if out.strip():
print(out)
def push_git_tag(repo_dir: Path, tag: str) -> None:
remote = getattr(ARGS, "git_remote", GIT_REMOTE_NAME)
rc, out = sh(["git", "push", remote, tag], repo_dir)
if rc != 0:
raise RuntimeError(f"Nie udało się wypchnąć taga {tag}:\n{out}")
if out.strip():
print(out)
def ensure_gh_available(repo_dir: Path) -> None:
rc, out = sh(["gh", "--version"], repo_dir)
if rc != 0:
raise RuntimeError("Polecenie 'gh' nie jest dostępne w PATH:\n" + out)
def github_release_exists(repo_dir: Path, tag: str) -> bool:
rc, _ = sh(["gh", "release", "view", tag], repo_dir)
return rc == 0
def publish_github_release(repo_dir: Path, tag: str, title: str, notes: str, assets: List[Path]) -> None:
ensure_gh_available(repo_dir)
assets_str = [str(p) for p in assets]
if github_release_exists(repo_dir, tag):
if not assets_str:
print(f"[INFO] Release {tag} już istnieje brak nowych assetów do przesłania.")
return
cmd = ["gh", "release", "upload", tag, *assets_str, "--clobber"]
rc, out = sh(cmd, repo_dir)
if rc != 0:
raise RuntimeError(f"Nie udało się zaktualizować release {tag}:\n{out}")
if out.strip():
print(out)
return
cmd = ["gh", "release", "create", tag, *assets_str, "--title", title, "--notes", notes]
rc, out = sh(cmd, repo_dir)
if rc != 0:
raise RuntimeError(f"Nie udało się utworzyć release {tag}:\n{out}")
if out.strip():
print(out)
def build_release_tag(name: str, version: str) -> str:
safe_name = name.replace(" ", "-").replace("/", "-")
base = f"{safe_name}-v{version}"
prefix = getattr(ARGS, "release_tag_prefix", RELEASE_TAG_PREFIX)
if prefix:
return f"{prefix}{base}"
return base
def find_modules_dir_for_user(user: str,
relative_pattern: str,
appdata_kind: Optional[str]) -> Path:
base = Path(f"C:/Users/{user}/AppData")
if not base.exists():
raise FileNotFoundError(f"Nie znaleziono katalogu użytkownika: {norm(base)}")
candidates: List[Path] = []
if appdata_kind:
if appdata_kind not in APPDATA_VARIANTS:
raise ValueError(f"APPDATA_KIND musi być jednym z: {APPDATA_VARIANTS}, a nie '{appdata_kind}'")
p = base / appdata_kind / Path(relative_pattern)
if p.is_dir():
return norm(p)
else:
raise FileNotFoundError(
f"Nie znaleziono katalogu: {norm(p)} (dla APPDATA_KIND={appdata_kind})."
)
for kind in APPDATA_VARIANTS:
p = base / kind / Path(relative_pattern)
if p.is_dir():
candidates.append(p)
if not candidates:
raise FileNotFoundError(
"Nie znaleziono żadnego katalogu zgodnego z wzorcem.\n"
f"Sprawdź, czy istnieje któryś z poniższych:\n"
+ "\n".join(f" - {norm(base / kind / Path(relative_pattern))}" for kind in APPDATA_VARIANTS)
)
if len(candidates) == 1:
return norm(candidates[0])
suggestions = []
for p in candidates:
try:
kind = p.parts[p.parts.index("AppData") + 1]
except Exception:
kind = "??"
suggestions.append(f'* Ustaw APPDATA_KIND="{kind}" # trafi w: {norm(p)}')
suggestions.append(f'* Albo ustaw TARGET_MODULES_DIR_OVERRIDE="{norm(candidates[0])}" # pełny override')
raise AmbiguityError(
"Znaleziono wiele pasujących lokalizacji dla wzorca 'FoundryVTT\\Data\\modules':\n"
+ "\n".join(f" - {norm(c)}" for c in candidates)
+ "\n\nPropozycje ujednolicenia:\n" + "\n".join(suggestions)
)
def ensure_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def iter_dir_contents(dir_path: Path) -> List[Path]:
if not dir_path.exists():
return []
return list(dir_path.iterdir())
def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
ensure_dir(dir_path)
files = 0
dirs = 0
to_delete = iter_dir_contents(dir_path)
if dry_run:
print("[DRY-RUN] Zawartość do skasowania:")
if not to_delete:
print(" (pusto)")
for child in to_delete:
tag = "[plik]" if child.is_file() else "[katalog]" if child.is_dir() else "[inne]"
print(f" {tag} {norm(child)}")
for child in to_delete:
if child.is_file() or child.is_symlink():
files += 1
elif child.is_dir():
dirs += 1
return files, dirs
for child in to_delete:
if child.is_file() or child.is_symlink():
files += 1
child.unlink(missing_ok=True)
elif child.is_dir():
dirs += 1
shutil.rmtree(child, ignore_errors=True)
return files, dirs
def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
plan: List[Tuple[Path, Path, str]] = []
if not src.is_dir():
raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(src)}")
for root, dirs, files in os.walk(src):
root_p = Path(root)
rel_root = root_p.relative_to(src)
for d in dirs:
s = root_p / d
t = dst / rel_root / d
status = "overwrite" if t.exists() else "create"
plan.append((s, t, status))
for f in files:
s = root_p / f
t = dst / rel_root / f
status = "overwrite" if t.exists() else "create"
plan.append((s, t, status))
return plan
def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
ensure_dir(dst)
plan = plan_copy(src, dst)
if dry_run:
print("[DRY-RUN] Plan kopiowania:")
if not plan:
print(" (brak plików do skopiowania)")
for s, t, status in plan:
kind = "[katalog]" if s.is_dir() else "[plik]"
print(f" {kind} {norm(s)} -> {norm(t)} ({status})")
files = sum(1 for s, _, _ in plan if s.is_file())
dirs = sum(1 for s, _, _ in plan if s.is_dir())
return files, dirs
files = 0
dirs = 0
for s, t, _ in plan:
if s.is_dir():
shutil.copytree(s, t, dirs_exist_ok=True)
dirs += 1
elif s.is_file():
ensure_dir(t.parent)
shutil.copy2(s, t)
files += 1
elif s.is_symlink():
target = s.resolve()
ensure_dir(t.parent)
if target.is_dir():
shutil.copytree(target, t, dirs_exist_ok=True)
dirs += 1
else:
shutil.copy2(target, t)
files += 1
else:
print(f"[OSTRZEŻENIE] Pominięto nieobsługiwany typ pliku: {norm(s)}")
return files, dirs
# ============ PRE-FLIGHT: module.json + git ============
def read_manifest(manifest_path: Path) -> dict:
if not manifest_path.is_file():
raise FileNotFoundError(f"Brak pliku manifestu: {norm(manifest_path)}")
try:
with manifest_path.open("r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Niepoprawny JSON w {norm(manifest_path)}: {e}") from e
def bump_version(last_version: str) -> str:
parts = last_version.strip().split(".")
if not parts:
raise ValueError(f"Niepoprawna wersja: '{last_version}'")
try:
n = int(parts[-1])
except ValueError:
raise ValueError(f"Ostatni segment wersji nie jest liczbą: '{last_version}'")
parts[-1] = str(n + 1)
return ".".join(parts)
def write_json_atomic(path: Path, data: dict) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
tmp.replace(path)
def run_git_commands(repo_dir: Path, commit_msg: str, dry_run: bool) -> Optional[str]:
if dry_run:
print("[DRY-RUN] git add -A")
print(f"[DRY-RUN] git commit -m {commit_msg!r}")
print("[DRY-RUN] git push")
return None
def run(cmd: List[str]) -> None:
rc, out = sh(cmd, repo_dir)
if rc != 0:
raise RuntimeError(f"Blad podczas uruchamiania: {' '.join(cmd)}\nWyjscie:\n{out}")
if out.strip():
print(out)
run(["git", "add", "-A"])
rc, out = sh(["git", "commit", "-m", commit_msg], repo_dir)
if rc != 0:
raise RuntimeError(f"Blad podczas git commit:\n{out}")
if out.strip():
print(out)
run(["git", "push"])
head = git_current_head(repo_dir)
return head
def preflight_module(src_dir: Path, dry_run: bool) -> Tuple[str, str, Optional[str]]:
"""
Moduł: odczyt module.json, bump, zapis (jeśli nie dry_run), commit i push.
Zwraca (module_id, new_version, commit_sha lub None przy dry-run).
"""
manifest_path = src_dir / "module.json"
manifest = read_manifest(manifest_path)
if "id" not in manifest or "version" not in manifest:
raise KeyError(f"W {norm(manifest_path)} brakuje pola 'id' lub 'version'.")
module_id = str(manifest["id"])
old_version = str(manifest["version"])
new_version = bump_version(old_version)
print(f"[PRE][MODULE] {src_dir.name}: id={module_id}, version={old_version} -> {new_version}")
commit_sha: Optional[str] = None
if dry_run:
print("[DRY-RUN] Nie zapisuję module.json i nie wykonuję git.")
else:
manifest["version"] = new_version
write_json_atomic(manifest_path, manifest)
commit_msg = f"sync: {module_id} v{new_version}"
commit_sha = run_git_commands(src_dir, commit_msg, dry_run=False)
return module_id, new_version, commit_sha
def preflight_non_module(src_dir: Path, dry_run: bool) -> Tuple[str, Optional[str]]:
"""
Niemoduł: version.nfo w formacie '0.x' (x int).
- jeśli brak -> '0.1'
- jeśli jest -> inkrementuj x
Zapis (nie dry-run) oraz osobny commit/push.
Zwraca (new_version, commit_sha lub None przy dry-run).
"""
nfo = src_dir / "version.nfo"
commit_sha: Optional[str] = None
if not nfo.exists():
new_version = "0.1"
print(f"[PRE][FOLDER] {src_dir.name}: version.nfo brak -> {new_version}")
if not dry_run:
with nfo.open("w", encoding="utf-8") as f:
f.write(new_version + "\n")
commit_msg = f"sync: {src_dir.name} v{new_version}"
commit_sha = run_git_commands(src_dir, commit_msg, dry_run=False)
return new_version, commit_sha
# istnieje -> wczytaj i bumpnij
content = nfo.read_text(encoding="utf-8").strip()
if not (content.startswith("0.") or content.startswith("1.")):
raise ValueError(f"{norm(nfo)}: oczekiwano formatu '0/1.x', otrzymano '{content}'")
tail = content.split(".", 1)[1]
head = content.split(".",1)[0]
try:
_ = int(head)
x = int(tail)
except ValueError:
raise ValueError(f"{norm(nfo)}: część po '0/1.' nie jest liczbą: '{tail}'")
new_version = f"{head}.{x+1}"
print(f"[PRE][FOLDER] {src_dir.name}: version {content} -> {new_version}")
if not dry_run:
with nfo.open("w", encoding="utf-8") as f:
f.write(new_version + "\n")
commit_msg = f"sync: {src_dir.name} v{new_version}"
commit_sha = run_git_commands(src_dir, commit_msg, dry_run=False)
return new_version, commit_sha
# ====== ROOT version.nfo (globalna wersja repo) ======
def maybe_bump_root_version(
repo_dir: Path, changed_any: bool, dry_run: bool
) -> Optional[Tuple[str, Optional[str]]]:
"""
Jeśli:
- istnieją zmiany do przetworzenia (changed_any == True)
- HEAD ma tag zawierający aktualną wersję globalną z version.nfo (0.x)
to bumpnij version.nfo w roocie (0.x -> 0.(x+1)), zrób osobny commit i push.
Zwraca nową wersję (str) lub None, jeśli pominięto.
"""
if not changed_any:
print("[ROOT] Pomijam bump wersji globalnej: brak zmian do przetworzenia.")
return None
nfo = repo_dir / "version.nfo"
if not nfo.exists():
print("[ROOT] Brak version.nfo w roocie; pomijam bump (wymagany istniejący plik).")
return None
current = nfo.read_text(encoding="utf-8").strip()
if not current.startswith("0."):
raise ValueError(f"{nfo}: oczekiwano formatu '0.x', otrzymano '{current}'")
# HEAD musi mieć tag zawierający 'current'
tag = git_head_tag(repo_dir)
if not tag or current not in tag:
print(f"[ROOT] Pomijam bump: {'brak taga na HEAD' if not tag else f'tag \"{tag}\" nie zawiera wersji {current}'}")
return None
# Bump
try:
tail = int(current.split(".", 1)[1])
except ValueError:
raise ValueError(f"{nfo}: część po '0.' nie jest liczbą: '{current}'")
new_version = f"0.{tail+1}"
print(f"[ROOT] Globalna wersja: {current} -> {new_version}")
if dry_run:
print("[DRY-RUN][ROOT] Zapis version.nfo i commit/push zostaną pominięte.")
return new_version, None
nfo.write_text(new_version + "\n", encoding="utf-8")
commit_sha = run_git_commands(repo_dir, f"sync: root v{new_version}", dry_run=False)
return new_version, commit_sha
def maybe_publish_root_release(repo_dir: Path, version: str, commit_sha: Optional[str], dry_run: bool) -> None:
if not ARGS.gh_release or dry_run:
return
name = repo_dir.name or "root"
tag = build_release_tag(name, version)
title = f"{name} v{version}"
template = getattr(ARGS, "release_notes_template", RELEASE_NOTES_TEMPLATE)
notes = template.format(name=name, version=version, tag=tag)
if commit_sha is None:
commit_sha = git_current_head(repo_dir)
create_git_tag(repo_dir, tag, title, commit_sha)
push_git_tag(repo_dir, tag)
publish_github_release(repo_dir, tag, title, notes, [])
# ====== ZIP utilities ======
def should_zip(is_module: bool, zip_mode: str) -> bool:
if zip_mode == "OFF":
return False
if zip_mode == "ALL":
return True
# MODULE_ONLY
return is_module
def ensure_zip_output_dir(source_base: Path, override: Optional[str]) -> Path:
out = Path(override) if override else (source_base / "_zips")
out.mkdir(parents=True, exist_ok=True)
return out
def make_zip_for_folder(src_dir: Path, version: str, out_dir: Path, dry_run: bool) -> Path:
"""
Tworzy .zip z zawartością src_dir (bez nadkatalogów) o nazwie NAME-vVERSION.zip
Zwraca ścieżkę do archiwum.
"""
base_name = f"{src_dir.name}-v{version}"
target = out_dir / (base_name + ".zip")
if dry_run:
print(f"[DRY-RUN][ZIP] {src_dir} -> {target}")
return target
# shutil.make_archive potrzebuje base_name BEZ .zip i root_dir+base_dir
base_name_path = out_dir / base_name
shutil.make_archive(str(base_name_path), "zip", root_dir=str(src_dir), base_dir=".")
print(f"[ZIP] Utworzono {target}")
return target
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Synchronizacja FoundryVTT: AUTO wykrywanie zmian + obsługa modułów/niemodułów.")
p.add_argument("--mode", choices=["auto", "single"], default=MODE,
help=f"Tryb działania (domyślnie: {MODE}).")
p.add_argument("--user", dest="user", default=TARGET_USER,
help=f"Nazwa użytkownika Windows (domyślnie: {TARGET_USER})")
p.add_argument("--appdata-kind", dest="appdata_kind", choices=APPDATA_VARIANTS + (None,),
default=APPDATA_KIND,
help=f"Wymuś AppData wariant: {APPDATA_VARIANTS} lub brak (None). Domyślnie: {APPDATA_KIND}")
p.add_argument("--modules-dir-override", dest="modules_dir_override",
default=TARGET_MODULES_DIR_OVERRIDE,
help="Pełna ścieżka do ...\\FoundryVTT\\Data\\modules (pomija wyszukiwanie).")
p.add_argument("--module-subdir-name", dest="module_subdir_name",
default=MODULE_SUBDIR_NAME,
help=f"Nazwa podkatalogu modułu (tylko w --mode single). Domyślnie: {MODULE_SUBDIR_NAME}")
cd_group = p.add_mutually_exclusive_group()
cd_group.add_argument("--confirm-delete", dest="confirm_delete", action="store_true",
help="Włącz potwierdzenie kasowania.")
cd_group.add_argument("--no-confirm-delete", dest="confirm_delete", action="store_false",
help="Wyłącz potwierdzenie kasowania.")
p.set_defaults(confirm_delete=CONFIRM_DELETE)
p.add_argument("--source-base-dir", dest="source_base_dir",
default=SOURCE_BASE_DIR,
help=f"Katalog źródłowy repo (domyślnie: {SOURCE_BASE_DIR})")
p.add_argument("--relative-pattern", dest="relative_pattern",
default=RELATIVE_PATTERN,
help=f"Wzorzec relatywny od AppData dla katalogu modules (domyślnie: {RELATIVE_PATTERN})")
dr_group = p.add_mutually_exclusive_group()
dr_group.add_argument("--dry-run", dest="dry_run", action="store_true",
help="Tryb symulacji: tylko plan (bez modyfikacji i bez gita).")
dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false",
help="Tryb wykonawczy: modyfikacje + git.")
p.set_defaults(dry_run=DRY_RUN)
# ZIP options
p.add_argument("--zip-mode", dest="zip_mode",
choices=["ALL", "MODULE_ONLY", "OFF"],
default=ZIP_MODE,
help=f"Zipowanie: ALL | MODULE_ONLY | OFF (domyślnie: {ZIP_MODE})")
p.add_argument("--zip-output-dir", dest="zip_output_dir",
default=ZIP_OUTPUT_DIR,
help="Katalog wyjściowy na zipy (domyślnie: SOURCE_BASE_DIR/_zips)")
# Tryb check (tylko walidacja)
p.add_argument("--check", dest="check", action="store_true",
help="Tylko sprawdzenie zmian (wymusza dry-run, kod wyjścia 1 przy wymaganych akcjach).")
p.set_defaults(check=CHECK_MODE)
# GitHub Release options
release_group = p.add_mutually_exclusive_group()
release_group.add_argument("--gh-release", dest="gh_release", action="store_true",
help="Po zakończeniu commitów utwórz release na GitHubie (wymaga gh CLI).")
release_group.add_argument("--no-gh-release", dest="gh_release", action="store_false",
help="Wyłącz publikowanie release (domyślne).")
p.set_defaults(gh_release=ENABLE_GH_RELEASE)
p.add_argument("--release-tag-prefix", dest="release_tag_prefix",
default=RELEASE_TAG_PREFIX,
help="Opcjonalny prefiks dodawany przed tagiem release (np. 'module/').")
p.add_argument("--release-notes-template", dest="release_notes_template",
default=RELEASE_NOTES_TEMPLATE,
help="Szablon notatek release (format: name, version, tag).")
p.add_argument("--git-remote", dest="git_remote",
default=GIT_REMOTE_NAME,
help="Nazwa zdalnego repozytorium do wypychania tagów (domyślnie 'origin').")
return p.parse_args()
def resolve_modules_base(user: str, relative_pattern: str, appdata_kind: Optional[str],
override: Optional[str]) -> Path:
if override:
modules_dir = Path(override)
if not modules_dir.is_dir():
raise FileNotFoundError(f"TARGET_MODULES_DIR_OVERRIDE wskazuje na nieistniejący katalog: {norm(modules_dir)}")
return modules_dir
return find_modules_dir_for_user(user, relative_pattern, appdata_kind)
def process_module(src_dir: Path, modules_base: Path, confirm_delete: bool, dry_run: bool) -> None:
module_id, new_ver, commit_sha = preflight_module(src_dir, dry_run=dry_run)
target_dir = modules_base / src_dir.name
print(f"[INFO][MODULE] Cel modułu: {norm(target_dir)}")
if confirm_delete and not dry_run:
ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_dir)}'? [y/N]: ").strip().lower()
if ans not in ("y", "yes", "t", "tak"):
raise RuntimeError("Przerwano na życzenie użytkownika.")
if confirm_delete and dry_run:
print("[INFO] (dry-run) Pytanie o potwierdzenie pominięte.")
# ZIP po commicie, przed podmianą
zip_path: Optional[Path] = None
if should_zip(is_module=True, zip_mode=ARGS.zip_mode):
out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir)
try:
zip_path = make_zip_for_folder(src_dir, new_ver, out_dir, dry_run)
except Exception as e:
raise RuntimeError(f"Błąd zipowania modułu {src_dir.name}: {e}") from e
print("[AKCJA] Czyszczenie..." if not dry_run else "[SYMULACJA] Plan czyszczenia...")
del_files, del_dirs = clear_dir_contents(target_dir, dry_run=dry_run)
print(f"[INFO] {'Do usunięcia' if dry_run else 'Usunięto'}: plików={del_files}, katalogów={del_dirs}")
print("[AKCJA] Kopiowanie..." if not dry_run else "[SYMULACJA] Plan kopiowania...")
cp_files, cp_dirs = copy_dir_contents(src_dir, target_dir, dry_run=dry_run)
print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}")
print(f"[DONE][MODULE] {src_dir.name} -> commit 'sync: {module_id} v{new_ver}' wykonany." if not dry_run
else f"[DRY-RUN][MODULE] {src_dir.name} -> commit planowany.")
if ARGS.gh_release and not dry_run:
tag = build_release_tag(module_id, new_ver)
title = f"{module_id} v{new_ver}"
template = getattr(ARGS, "release_notes_template", RELEASE_NOTES_TEMPLATE)
notes = template.format(name=module_id, version=new_ver, tag=tag)
assets = [zip_path] if zip_path else []
if commit_sha is None:
commit_sha = git_current_head(src_dir)
create_git_tag(src_dir, tag, title, commit_sha)
push_git_tag(src_dir, tag)
publish_github_release(src_dir, tag, title, notes, assets)
def process_non_module(src_dir: Path, dry_run: bool) -> None:
new_ver, commit_sha = preflight_non_module(src_dir, dry_run=dry_run)
# ZIP po commicie (jeśli ustawienie pozwala)
zip_path: Optional[Path] = None
if should_zip(is_module=False, zip_mode=ARGS.zip_mode):
out_dir = ensure_zip_output_dir(Path(ARGS.source_base_dir), ARGS.zip_output_dir)
try:
zip_path = make_zip_for_folder(src_dir, new_ver, out_dir, dry_run)
except Exception as e:
raise RuntimeError(f"Błąd zipowania folderu {src_dir.name}: {e}") from e
print(f"[DONE][FOLDER] {src_dir.name} -> version.nfo = {new_ver}; commit wykonany." if not dry_run
else f"[DRY-RUN][FOLDER] {src_dir.name} -> version.nfo będzie {new_ver}; commit planowany.")
if ARGS.gh_release and not dry_run:
name = src_dir.name
tag = build_release_tag(name, new_ver)
title = f"{name} v{new_ver}"
template = getattr(ARGS, "release_notes_template", RELEASE_NOTES_TEMPLATE)
notes = template.format(name=name, version=new_ver, tag=tag)
assets = [zip_path] if zip_path else []
if commit_sha is None:
commit_sha = git_current_head(src_dir)
create_git_tag(src_dir, tag, title, commit_sha)
push_git_tag(src_dir, tag)
publish_github_release(src_dir, tag, title, notes, assets)
def main() -> int:
global ARGS
ARGS = parse_args()
check_mode = ARGS.check
if check_mode:
ARGS.dry_run = True
ARGS.release_tag_prefix = (ARGS.release_tag_prefix or "").strip() or None
ARGS.release_notes_template = ARGS.release_notes_template or RELEASE_NOTES_TEMPLATE
ARGS.git_remote = ARGS.git_remote or GIT_REMOTE_NAME
mode = ARGS.mode
user = ARGS.user
appdata_kind = ARGS.appdata_kind if ARGS.appdata_kind != "None" else None
modules_override = ARGS.modules_dir_override
module_subdir_single = ARGS.module_subdir_name
confirm_delete = ARGS.confirm_delete
source_base = Path(ARGS.source_base_dir)
relative_pattern = ARGS.relative_pattern
dry_run = ARGS.dry_run
if not source_base.is_dir():
raise FileNotFoundError(f"Katalog zrodlowy nie istnieje: {norm(source_base)}")
ensure_git_repo(source_base)
modules_base = resolve_modules_base(user, relative_pattern, appdata_kind, modules_override)
print(f"[INFO] Katalog modules base: {norm(modules_base)}")
pending_actions = False
if mode == "single":
src_dir = source_base / module_subdir_single
if not src_dir.is_dir():
raise FileNotFoundError(f"[SINGLE] Brak katalogu: {norm(src_dir)}")
root_info = maybe_bump_root_version(source_base, changed_any=True, dry_run=dry_run)
if root_info:
pending_actions = True
root_version, root_commit = root_info
maybe_publish_root_release(source_base, root_version, root_commit, dry_run)
pending_actions = True
if (src_dir / "module.json").is_file():
print(f"[MODE=SINGLE][MODULE] {src_dir.name}")
process_module(src_dir, modules_base, confirm_delete, dry_run)
else:
print(f"[MODE=SINGLE][FOLDER] {src_dir.name}")
process_non_module(src_dir, dry_run)
print("[SUKCES] Tryb SINGLE zakonczony.")
if check_mode:
print("[CHECK] Wykryto oczekujace akcje (tryb single).")
return 1
return 0
changed = git_changed_top_level_dirs(source_base)
print(f"[AUTO] Zmienione katalogi w {norm(source_base)}: {changed if changed else '(brak)'}")
if changed:
pending_actions = True
root_info = maybe_bump_root_version(source_base, changed_any=bool(changed), dry_run=dry_run)
if root_info:
pending_actions = True
root_version, root_commit = root_info
maybe_publish_root_release(source_base, root_version, root_commit, dry_run)
if not changed:
print("[INFO] Brak zmian do przetworzenia.")
if check_mode:
if pending_actions:
print("[CHECK] Wykryto oczekujace akcje.")
return 1
print("[CHECK] OK - brak akcji wymaganych.")
return 0
# Przetwarzaj w stalej kolejnosci
for name in changed:
src_dir = source_base / name
if (src_dir / "module.json").is_file():
print(f"\n[AUTO][MODULE] {name}")
process_module(src_dir, modules_base, confirm_delete, dry_run)
else:
print(f"\n[AUTO][FOLDER] {name}")
process_non_module(src_dir, dry_run)
print("\n[SUKCES] Tryb AUTO zakonczony.")
if check_mode and pending_actions:
print("[CHECK] Wykryto oczekujace akcje.")
return 1
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AmbiguityError as e:
print("\n[NIERÓWNOZNACZNOŚĆ]\n" + str(e), file=sys.stderr)
sys.exit(2)
except Exception as e:
print("\n[BŁĄD]\n" + str(e), file=sys.stderr)
sys.exit(1)