Skip to content

Instantly share code, notes, and snippets.

@me-suzy
Created July 1, 2026 19:55
Show Gist options
  • Select an option

  • Save me-suzy/4199664193a574b533fe4165a66f479a to your computer and use it in GitHub Desktop.

Select an option

Save me-suzy/4199664193a574b533fe4165a66f479a to your computer and use it in GitHub Desktop.
replace-flags.py
#!/usr/bin/env python3
r"""Replace the Categories -> BOOKS START block in Romanian 2022 ART pages.
Defaults are set for this folder:
source: ..\ro\profetia-ghicitoarei-din-desert.html
target: .\2022 - ro
Usage:
python replace-flags.py --dry-run
python replace-flags.py
python replace-flags.py --self-test
python replace-flags.py --source "E:\...\source.html" --target "E:\...\2022 - ro"
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
SECTION_RE = re.compile(
rb"(?m)^[^\S\r\n]*<!-- Categories -->\r?\n.*?^[^\S\r\n]*<!-- BOOKS START -->\r?\n?",
re.S,
)
@dataclass
class ReplaceStats:
scanned_files: int = 0
changed_files: int = 0
missing_section: int = 0
already_identical: int = 0
@dataclass
class PlannedChange:
path: Path
original: bytes
updated: bytes
def default_source() -> Path:
script_dir = Path(__file__).resolve().parent
return script_dir.parent / "ro" / "profetia-ghicitoarei-din-desert.html"
def default_target() -> Path:
return Path(__file__).resolve().parent / "2022 - ro"
def read_source_block(source: Path) -> bytes:
data = source.read_bytes()
matches = list(SECTION_RE.finditer(data))
if len(matches) != 1:
raise ValueError(f"Expected exactly one source section in {source}, found {len(matches)}.")
return matches[0].group(0)
def replace_one_file(path: Path, source_block: bytes) -> tuple[bytes | None, str]:
data = path.read_bytes()
matches = list(SECTION_RE.finditer(data))
if len(matches) == 0:
return None, "missing"
if len(matches) > 1:
raise ValueError(f"Expected one target section in {path}, found {len(matches)}.")
match = matches[0]
updated = data[: match.start()] + source_block + data[match.end() :]
if updated == data:
return None, "identical"
return updated, "changed"
def plan_changes(target_root: Path, source_block: bytes) -> tuple[ReplaceStats, list[PlannedChange]]:
stats = ReplaceStats()
changes: list[PlannedChange] = []
for path in sorted(target_root.rglob("*.html")):
stats.scanned_files += 1
original = path.read_bytes()
updated, status = replace_one_file(path, source_block)
if status == "missing":
stats.missing_section += 1
elif status == "identical":
stats.already_identical += 1
elif status == "changed" and updated is not None:
stats.changed_files += 1
changes.append(PlannedChange(path=path, original=original, updated=updated))
return stats, changes
def create_backup(target_root: Path, changes: list[PlannedChange], backup_dir: Path) -> Path:
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_dir / f"leadership-art-categories-backup-{stamp}.zip"
with zipfile.ZipFile(backup_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for change in changes:
archive.writestr(change.path.relative_to(target_root).as_posix(), change.original)
return backup_path
def apply_changes(
target_root: Path,
source_block: bytes,
*,
dry_run: bool,
backup: bool,
backup_dir: Path,
) -> tuple[ReplaceStats, Path | None]:
stats, changes = plan_changes(target_root, source_block)
if dry_run or not changes:
return stats, None
backup_path = create_backup(target_root, changes, backup_dir) if backup else None
for change in changes:
change.path.write_bytes(change.updated)
return stats, backup_path
def validate_target(target_root: Path, source_block: bytes, *, after_rewrite: bool = False) -> list[str]:
problems: list[str] = []
for path in sorted(target_root.rglob("*.html")):
data = path.read_bytes()
matches = list(SECTION_RE.finditer(data))
if len(matches) == 0:
continue
if len(matches) > 1:
problems.append(f"{path}: found {len(matches)} sections")
continue
if after_rewrite:
match = matches[0]
data = data[: match.start()] + source_block + data[match.end() :]
matches = list(SECTION_RE.finditer(data))
if matches[0].group(0) != source_block:
problems.append(f"{path}: section differs from source")
return problems
def self_test() -> None:
source_html = b"""<html>
<!-- Categories -->
<div>NEW CATEGORIES</div>
<!-- BOOKS START -->
<section>books</section>
</html>
"""
old_target_html = b"""<html>
<main>article</main>
<!-- Categories -->
<div>OLD CATEGORIES</div>
<!-- BOOKS START -->
<section>books</section>
</html>
"""
missing_html = b"<html><p>No target section here.</p></html>\n"
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / "ro" / "profetia-ghicitoarei-din-desert.html"
target = root / "Leadership ART" / "2022 - ro"
source.parent.mkdir(parents=True)
(target / "nested").mkdir(parents=True)
source.write_bytes(source_html)
(target / "a.html").write_bytes(old_target_html)
(target / "nested" / "b.html").write_bytes(old_target_html)
(target / "nested" / "poza-sus-SLIDER.html").write_bytes(missing_html)
source_block = read_source_block(source)
stats, backup_path = apply_changes(
target,
source_block,
dry_run=False,
backup=False,
backup_dir=root,
)
problems = validate_target(target, source_block)
if backup_path is not None:
raise AssertionError("self-test should not create a backup")
if stats.scanned_files != 3:
raise AssertionError(f"expected 3 scanned files, got {stats.scanned_files}")
if stats.changed_files != 2:
raise AssertionError(f"expected 2 changed files, got {stats.changed_files}")
if stats.missing_section != 1:
raise AssertionError(f"expected 1 missing section, got {stats.missing_section}")
if problems:
raise AssertionError("; ".join(problems))
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Replace Categories -> BOOKS START blocks in HTML files."
)
parser.add_argument("--source", type=Path, default=default_source(), help="HTML file to copy the section from.")
parser.add_argument("--target", type=Path, default=default_target(), help="Folder with target HTML files.")
parser.add_argument("--dry-run", action="store_true", help="Show what would change without writing files.")
parser.add_argument("--no-backup", action="store_true", help="Do not create a ZIP backup before editing.")
parser.add_argument(
"--backup-dir",
type=Path,
default=Path(tempfile.gettempdir()),
help="Folder for backup ZIP files. Defaults to the system temp folder.",
)
parser.add_argument("--self-test", action="store_true", help="Run built-in tests on temporary files.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
if args.self_test:
self_test()
print("Self-test OK.")
return 0
source = args.source.resolve()
target = args.target.resolve()
backup_dir = args.backup_dir.resolve()
if not source.exists() or not source.is_file():
print(f"Source file does not exist: {source}", file=sys.stderr)
return 2
if not target.exists() or not target.is_dir():
print(f"Target folder does not exist: {target}", file=sys.stderr)
return 2
try:
source_block = read_source_block(source)
stats, backup_path = apply_changes(
target,
source_block,
dry_run=args.dry_run,
backup=not args.no_backup,
backup_dir=backup_dir,
)
problems = validate_target(target, source_block, after_rewrite=args.dry_run)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1
print("Dry run." if args.dry_run else "Done.")
print(f"Source: {source}")
print(f"Target: {target}")
print(f"HTML files scanned: {stats.scanned_files}")
print(f"HTML files changed: {stats.changed_files}")
print(f"Already identical: {stats.already_identical}")
print(f"Missing section: {stats.missing_section}")
if backup_path:
print(f"Backup ZIP: {backup_path}")
if problems:
print("Validation problems:", file=sys.stderr)
for problem in problems:
print(f"- {problem}", file=sys.stderr)
return 1
print("Validation OK.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment