From a96b410112f5b6044600f3ec2c2a8601d63602e7 Mon Sep 17 00:00:00 2001 From: Mariangela Berardinelli Date: Mon, 27 Jul 2026 15:10:49 +0200 Subject: [PATCH 1/3] added new pre commit hook for automating the error tables generation --- .pre-commit-hooks.yaml | 6 ++ .../generate_error_string_tables.py | 72 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 pre_commit_hooks/generate_error_string_tables.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index eca241d..d42d50f 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -27,3 +27,9 @@ language: python files: '.*\.dc$' additional_dependencies: [] +- id: generate_error_string_tables + name: Check if the ErrorTable folder has been modified in local + entry: generate_error_string_tables + language: python + files: '^Dreamcatcher/Content/Errors/ErrorTables/*\.csv$' + additional_dependencies: [] diff --git a/pre_commit_hooks/generate_error_string_tables.py b/pre_commit_hooks/generate_error_string_tables.py new file mode 100644 index 0000000..adf0043 --- /dev/null +++ b/pre_commit_hooks/generate_error_string_tables.py @@ -0,0 +1,72 @@ +# Copyright (c) 2014-2026 Zuru Tech HK Limited, All rights reserved. + +"""................ Pre Commit Hook for String Table Generator .................... +This script is needed to automate the execution of the script for generating the string table csv files from the error csv files. +Since this script should be run after every change to the files in the Content/Errors folder, this pre commit hook allows to guarantee the execution of the csv script once the new file are pushed onto git. +""" + +import subprocess +import sys +from pathlib import Path + +""" Constants """ + +# The wrapper lives next to the generator inside Content/Errors, so every path +# is resolved from this file's location rather than the current working +# directory (pre-commit runs hooks from the repository root). +ERRORS_DIR = Path(__file__).resolve().parent +GENERATOR = ERRORS_DIR / "string_table_generator.py" +ERROR_TABLES_DIR = ERRORS_DIR / "ErrorTables" +STRING_TABLES_DIR = ERRORS_DIR / "StringTables" + +""" Functions """ + +def _fail(message): + print(f"[string-tables] {message}", file=sys.stderr) + return 1 + +def main(): + if not GENERATOR.is_file(): + return _fail(f"Generator not found: {GENERATOR}") + + if not ERROR_TABLES_DIR.is_dir(): + return _fail(f"ErrorTables folder not found: {ERROR_TABLES_DIR}") + + error_tables = sorted(ERROR_TABLES_DIR.glob("*.csv")) + if not error_tables: + print("[string-tables] No error tables found, nothing to generate.") + return 0 + + # The generator resolves its own paths from the current working directory, + # so it must run with Content/Errors as the working directory. Running it as + # a subprocess also isolates the sys.exit() calls it makes internally. + print(f"[string-tables] Regenerating from {len(error_tables)} error table(s)...") + result = subprocess.run([sys.executable, str(GENERATOR)], cwd=str(ERRORS_DIR)) + if result.returncode != 0: + return _fail("string_table_generator.py failed.") + + # The generator always exits 0 and never reports whether it changed anything, + # so git is the source of truth for detecting a diff (modified or new files). + status = subprocess.run( + ["git", "status", "--porcelain", "--", str(STRING_TABLES_DIR)], + cwd=str(ERRORS_DIR), + capture_output=True, + text=True, + ) + + if status.returncode != 0: + return _fail("Unable to check the git status of the String Tables.") + + if status.stdout.strip(): + changed = "\n".join(f" {line}" for line in status.stdout.strip().splitlines()) + return _fail( + "String Tables were out of date and have been regenerated:\n" + f"{changed}\n" + "Stage them (git add Content/Errors/StringTables) and commit again." + ) + + print("[string-tables] String Tables are up to date.") + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file From 12ce875cef0798baaf69a78b6ebf07ac3faa93f0 Mon Sep 17 00:00:00 2001 From: Mariangela Berardinelli Date: Mon, 27 Jul 2026 17:14:12 +0200 Subject: [PATCH 2/3] fixed script --- .../generate_error_string_tables.py | 163 +++++++++++++----- 1 file changed, 116 insertions(+), 47 deletions(-) diff --git a/pre_commit_hooks/generate_error_string_tables.py b/pre_commit_hooks/generate_error_string_tables.py index adf0043..3265615 100644 --- a/pre_commit_hooks/generate_error_string_tables.py +++ b/pre_commit_hooks/generate_error_string_tables.py @@ -1,71 +1,140 @@ # Copyright (c) 2014-2026 Zuru Tech HK Limited, All rights reserved. """................ Pre Commit Hook for String Table Generator .................... -This script is needed to automate the execution of the script for generating the string table csv files from the error csv files. +This script is needed to automate the execution of the script for generating the string table csv files from the error csv files. `Content/Errors/StringTables` is a generated artifact: ``string_table_generator.py`` turns every ``Content/Errors/ErrorTables/*.csv`` into the matching StringTable csv used by UE localization. +The two folders must therefore stay in sync. This hook inspects the *staged* changes and fails when: + 1. The ErrorTables changed but the StringTables were left untouched (the generated tables were not refreshed). + 2. Both folders changed, but running ``string_table_generator.py`` on the current ErrorTables produces StringTables that differ from the ones being committed (the staged StringTables are not what the generator would produce). +The working tree is never modified: the generator is run against an isolated copy. Since this script should be run after every change to the files in the Content/Errors folder, this pre commit hook allows to guarantee the execution of the csv script once the new file are pushed onto git. """ +from __future__ import annotations + +import os +import shutil import subprocess import sys +import tempfile from pathlib import Path -""" Constants """ +# Layout, relative to the git repository root. +PROJECT_SUBDIR = "Dreamcatcher" +ERRORS_RELPATH = Path("Content") / "Errors" +ERROR_TABLES_DIRNAME = "ErrorTables" +STRING_TABLES_DIRNAME = "StringTables" + +# The generator lives next to this hook. +GENERATOR = Path(__file__).resolve().parent / "string_table_generator.py" -# The wrapper lives next to the generator inside Content/Errors, so every path -# is resolved from this file's location rather than the current working -# directory (pre-commit runs hooks from the repository root). -ERRORS_DIR = Path(__file__).resolve().parent -GENERATOR = ERRORS_DIR / "string_table_generator.py" -ERROR_TABLES_DIR = ERRORS_DIR / "ErrorTables" -STRING_TABLES_DIR = ERRORS_DIR / "StringTables" +PREFIX = "error-string-tables: " -""" Functions """ +def _run(cmd, **kwargs): + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) def _fail(message): - print(f"[string-tables] {message}", file=sys.stderr) - return 1 + print(PREFIX + message, file=sys.stderr) + sys.exit(1) + +def _git_root(): + result = _run(["git", "rev-parse", "--show-toplevel"]) + if result.returncode != 0: + _fail("not inside a git repository.") + return Path(result.stdout.strip()) + +def _staged_paths(root): + result = _run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMRD"], cwd=root,) + if result.returncode != 0: + _fail("failed to read staged changes:\n" + result.stderr) + return [Path(line) for line in result.stdout.splitlines() if line.strip()] + +def _is_under(path, folder): + try: + path.relative_to(folder) + return True + except ValueError: + return False + +def _csv_names_under(paths, folder): + """Basenames of the staged .csv files located under ``folder``.""" + return {p.name for p in paths if p.suffix == ".csv" and _is_under(p, folder)} + + +def _normalize(text): + # Compare on content, ignoring line-ending differences: git may check the file out with CRLF, while the generator always writes LF. + return text.replace("\r\n", "\n").replace("\r", "\n") + +def _read(path): + try: + return _normalize(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + + +def _generate_expected(error_tables, tmp_root): + """Run the generator on a copy of the ErrorTables and return the generated folder. The real StringTables folder is left untouched: the generator writes into ``tmp_root``.""" + tmp_error_tables = tmp_root / ERRORS_RELPATH / ERROR_TABLES_DIRNAME + tmp_error_tables.mkdir(parents=True, exist_ok=True) + for csv_file in error_tables.glob("*.csv"): + shutil.copy2(csv_file, tmp_error_tables / csv_file.name) + + result = _run([sys.executable, str(GENERATOR)],env={**os.environ, "PROJECT_PATH": str(tmp_root)},) + if result.returncode != 0: + _fail("string_table_generator.py failed to run:\n"+ result.stdout+ result.stderr) + return tmp_root / ERRORS_RELPATH / STRING_TABLES_DIRNAME def main(): if not GENERATOR.is_file(): - return _fail(f"Generator not found: {GENERATOR}") + _fail(f"generator not found at {GENERATOR}.") - if not ERROR_TABLES_DIR.is_dir(): - return _fail(f"ErrorTables folder not found: {ERROR_TABLES_DIR}") + root = _git_root() + errors_dir = root / PROJECT_SUBDIR / ERRORS_RELPATH + error_tables = errors_dir / ERROR_TABLES_DIRNAME + string_tables = errors_dir / STRING_TABLES_DIRNAME - error_tables = sorted(ERROR_TABLES_DIR.glob("*.csv")) - if not error_tables: - print("[string-tables] No error tables found, nothing to generate.") + if not error_tables.is_dir(): + _fail(f"ErrorTables folder not found at {error_tables}.") + error_tables_rel = Path(PROJECT_SUBDIR) / ERRORS_RELPATH / ERROR_TABLES_DIRNAME + string_tables_rel = Path(PROJECT_SUBDIR) / ERRORS_RELPATH / STRING_TABLES_DIRNAME + + staged = _staged_paths(root) + changed_error_names = _csv_names_under(staged, error_tables_rel) + changed_string_names = _csv_names_under(staged, string_tables_rel) + + # Nothing to enforce unless the ErrorTables changed. + if not changed_error_names: return 0 - # The generator resolves its own paths from the current working directory, - # so it must run with Content/Errors as the working directory. Running it as - # a subprocess also isolates the sys.exit() calls it makes internally. - print(f"[string-tables] Regenerating from {len(error_tables)} error table(s)...") - result = subprocess.run([sys.executable, str(GENERATOR)], cwd=str(ERRORS_DIR)) - if result.returncode != 0: - return _fail("string_table_generator.py failed.") - - # The generator always exits 0 and never reports whether it changed anything, - # so git is the source of truth for detecting a diff (modified or new files). - status = subprocess.run( - ["git", "status", "--porcelain", "--", str(STRING_TABLES_DIR)], - cwd=str(ERRORS_DIR), - capture_output=True, - text=True, - ) - - if status.returncode != 0: - return _fail("Unable to check the git status of the String Tables.") - - if status.stdout.strip(): - changed = "\n".join(f" {line}" for line in status.stdout.strip().splitlines()) - return _fail( - "String Tables were out of date and have been regenerated:\n" - f"{changed}\n" - "Stage them (git add Content/Errors/StringTables) and commit again." - ) - - print("[string-tables] String Tables are up to date.") + regenerate_hint = ( + "\n\nRegenerate the StringTables and stage them:\n" + f' PROJECT_PATH="{root / PROJECT_SUBDIR}" "{sys.executable}" "{GENERATOR}"\n' + f" git add {string_tables_rel.as_posix()}/" + ) + + # Rule 1: the ErrorTables changed but the StringTables were not refreshed. + if not changed_string_names: + _fail("ErrorTables were modified but no StringTables change is staged."+ regenerate_hint) + + # Rule 2: both folders changed -> the staged StringTables must equal what the generator produces from the current ErrorTables. Only the files touched by this commit are checked, so unrelated pre-existing state cannot fail the commit. + relevant_names = sorted(changed_error_names | changed_string_names) + with tempfile.TemporaryDirectory(prefix="error-string-tables-") as tmp: + expected_dir = _generate_expected(error_tables, Path(tmp)) + expected_dir = _generate_expected(error_tables, Path(tmp)) + + mismatches = [] + for name in relevant_names: + expected = _read(expected_dir / name) + actual = _read(string_tables / name) + if expected is None: + mismatches.append(f" {name}: the generator does not produce this file ""(its ErrorTable is missing/deleted;delete the StringTable too)") + elif actual is None: + mismatches.append(f" {name}: missing from StringTables") + elif expected != actual: + mismatches.append(f" {name}: differs from the generator output") + + if mismatches: + _fail("the staged StringTables do not match string_table_generator.py output:\n"+ "\n".join(mismatches)+ regenerate_hint) + return 0 if __name__ == "__main__": From 2f912ad7764ea8d66fd3fa69354f881ad0764e91 Mon Sep 17 00:00:00 2001 From: Mariangela Berardinelli Date: Mon, 27 Jul 2026 17:57:13 +0200 Subject: [PATCH 3/3] try fixing --- .pre-commit-hooks.yaml | 3 +- .../generate_error_string_tables.py | 124 ++++++++++++------ setup.cfg | 1 + 3 files changed, 86 insertions(+), 42 deletions(-) diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index d42d50f..ebd43d2 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -31,5 +31,6 @@ name: Check if the ErrorTable folder has been modified in local entry: generate_error_string_tables language: python - files: '^Dreamcatcher/Content/Errors/ErrorTables/*\.csv$' + files: '^Dreamcatcher/Content/Errors/ErrorTables/.*\.csv$' + pass_filenames: false additional_dependencies: [] diff --git a/pre_commit_hooks/generate_error_string_tables.py b/pre_commit_hooks/generate_error_string_tables.py index 3265615..48a948a 100644 --- a/pre_commit_hooks/generate_error_string_tables.py +++ b/pre_commit_hooks/generate_error_string_tables.py @@ -1,12 +1,19 @@ # Copyright (c) 2014-2026 Zuru Tech HK Limited, All rights reserved. -"""................ Pre Commit Hook for String Table Generator .................... -This script is needed to automate the execution of the script for generating the string table csv files from the error csv files. `Content/Errors/StringTables` is a generated artifact: ``string_table_generator.py`` turns every ``Content/Errors/ErrorTables/*.csv`` into the matching StringTable csv used by UE localization. -The two folders must therefore stay in sync. This hook inspects the *staged* changes and fails when: - 1. The ErrorTables changed but the StringTables were left untouched (the generated tables were not refreshed). - 2. Both folders changed, but running ``string_table_generator.py`` on the current ErrorTables produces StringTables that differ from the ones being committed (the staged StringTables are not what the generator would produce). -The working tree is never modified: the generator is run against an isolated copy. -Since this script should be run after every change to the files in the Content/Errors folder, this pre commit hook allows to guarantee the execution of the csv script once the new file are pushed onto git. +"""Pre-commit hook for the Error String Tables. + +``Content/Errors/StringTables`` is a generated artifact: ``string_table_generator.py`` +turns every ``Content/Errors/ErrorTables/*.csv`` into the matching StringTable csv used +by UE localization, so the two folders must stay in sync. + +The hook inspects the *staged* changes and fails when: + + 1. The ErrorTables changed but no StringTables change is staged (the generated tables + were not refreshed). + 2. Both folders changed, but running ``string_table_generator.py`` on the staged + ErrorTables produces StringTables that differ from the staged ones. + +The working tree is never modified: the generator runs against an isolated copy. """ from __future__ import annotations @@ -17,83 +24,106 @@ import sys import tempfile from pathlib import Path +from typing import Any, NoReturn -# Layout, relative to the git repository root. +# Layout relative to the git repository root of the project being committed. PROJECT_SUBDIR = "Dreamcatcher" ERRORS_RELPATH = Path("Content") / "Errors" ERROR_TABLES_DIRNAME = "ErrorTables" STRING_TABLES_DIRNAME = "StringTables" - -# The generator lives next to this hook. -GENERATOR = Path(__file__).resolve().parent / "string_table_generator.py" +# The generator ships with the consuming project, not with this hooks repo. +GENERATOR_RELPATH = Path("dev-scripts") / "string_table_generator.py" PREFIX = "error-string-tables: " -def _run(cmd, **kwargs): + +def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, capture_output=True, text=True, **kwargs) -def _fail(message): + +def _fail(message: str) -> NoReturn: print(PREFIX + message, file=sys.stderr) sys.exit(1) -def _git_root(): + +def _git_root() -> Path: result = _run(["git", "rev-parse", "--show-toplevel"]) if result.returncode != 0: _fail("not inside a git repository.") return Path(result.stdout.strip()) -def _staged_paths(root): - result = _run(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMRD"], cwd=root,) + +def _staged_paths(root: Path) -> list[Path]: + """Return the staged (added/copied/modified/renamed/deleted) paths, repo-relative.""" + result = _run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMRD"], + cwd=root, + ) if result.returncode != 0: _fail("failed to read staged changes:\n" + result.stderr) return [Path(line) for line in result.stdout.splitlines() if line.strip()] -def _is_under(path, folder): + +def _is_under(path: Path, folder: Path) -> bool: try: path.relative_to(folder) return True except ValueError: return False -def _csv_names_under(paths, folder): + +def _csv_names_under(paths: list[Path], folder: Path) -> set[str]: """Basenames of the staged .csv files located under ``folder``.""" return {p.name for p in paths if p.suffix == ".csv" and _is_under(p, folder)} -def _normalize(text): - # Compare on content, ignoring line-ending differences: git may check the file out with CRLF, while the generator always writes LF. +def _normalize(text: str) -> str: + # Compare on content, ignoring line-ending differences: git may check the file out + # with CRLF, while the generator always writes LF. return text.replace("\r\n", "\n").replace("\r", "\n") -def _read(path): + +def _read(path: Path) -> str | None: try: return _normalize(path.read_text(encoding="utf-8")) except FileNotFoundError: return None -def _generate_expected(error_tables, tmp_root): - """Run the generator on a copy of the ErrorTables and return the generated folder. The real StringTables folder is left untouched: the generator writes into ``tmp_root``.""" +def _generate_expected(error_tables: Path, tmp_root: Path, generator: Path) -> Path: + """Run the generator on a copy of the ErrorTables and return the generated folder. + + The real StringTables folder is left untouched: the generator writes into ``tmp_root``. + """ tmp_error_tables = tmp_root / ERRORS_RELPATH / ERROR_TABLES_DIRNAME tmp_error_tables.mkdir(parents=True, exist_ok=True) for csv_file in error_tables.glob("*.csv"): shutil.copy2(csv_file, tmp_error_tables / csv_file.name) - result = _run([sys.executable, str(GENERATOR)],env={**os.environ, "PROJECT_PATH": str(tmp_root)},) + result = _run( + [sys.executable, str(generator)], + env={**os.environ, "PROJECT_PATH": str(tmp_root)}, + ) if result.returncode != 0: - _fail("string_table_generator.py failed to run:\n"+ result.stdout+ result.stderr) + _fail( + "string_table_generator.py failed to run:\n" + result.stdout + result.stderr + ) return tmp_root / ERRORS_RELPATH / STRING_TABLES_DIRNAME -def main(): - if not GENERATOR.is_file(): - _fail(f"generator not found at {GENERATOR}.") +def main() -> int: root = _git_root() + + generator = root / GENERATOR_RELPATH + if not generator.is_file(): + _fail(f"generator not found at {generator}.") + errors_dir = root / PROJECT_SUBDIR / ERRORS_RELPATH error_tables = errors_dir / ERROR_TABLES_DIRNAME string_tables = errors_dir / STRING_TABLES_DIRNAME - if not error_tables.is_dir(): _fail(f"ErrorTables folder not found at {error_tables}.") + error_tables_rel = Path(PROJECT_SUBDIR) / ERRORS_RELPATH / ERROR_TABLES_DIRNAME string_tables_rel = Path(PROJECT_SUBDIR) / ERRORS_RELPATH / STRING_TABLES_DIRNAME @@ -106,36 +136,48 @@ def main(): return 0 regenerate_hint = ( - "\n\nRegenerate the StringTables and stage them:\n" - f' PROJECT_PATH="{root / PROJECT_SUBDIR}" "{sys.executable}" "{GENERATOR}"\n' - f" git add {string_tables_rel.as_posix()}/" - ) + "\n\nRegenerate the StringTables and stage them:\n" + f' PROJECT_PATH="{root / PROJECT_SUBDIR}" "{sys.executable}" "{generator}"\n' + f" git add {string_tables_rel.as_posix()}/" + ) # Rule 1: the ErrorTables changed but the StringTables were not refreshed. if not changed_string_names: - _fail("ErrorTables were modified but no StringTables change is staged."+ regenerate_hint) - - # Rule 2: both folders changed -> the staged StringTables must equal what the generator produces from the current ErrorTables. Only the files touched by this commit are checked, so unrelated pre-existing state cannot fail the commit. + _fail( + "ErrorTables were modified but no StringTables change is staged." + + regenerate_hint + ) + + # Rule 2: both folders changed -> the staged StringTables must equal what the + # generator produces from the staged ErrorTables. Only the files touched by this + # commit are checked, so unrelated pre-existing state cannot fail the commit. relevant_names = sorted(changed_error_names | changed_string_names) with tempfile.TemporaryDirectory(prefix="error-string-tables-") as tmp: - expected_dir = _generate_expected(error_tables, Path(tmp)) - expected_dir = _generate_expected(error_tables, Path(tmp)) + expected_dir = _generate_expected(error_tables, Path(tmp), generator) mismatches = [] for name in relevant_names: expected = _read(expected_dir / name) actual = _read(string_tables / name) if expected is None: - mismatches.append(f" {name}: the generator does not produce this file ""(its ErrorTable is missing/deleted;delete the StringTable too)") + mismatches.append( + f" {name}: the generator does not produce this file " + "(its ErrorTable is missing/deleted; delete the StringTable too)" + ) elif actual is None: mismatches.append(f" {name}: missing from StringTables") elif expected != actual: mismatches.append(f" {name}: differs from the generator output") if mismatches: - _fail("the staged StringTables do not match string_table_generator.py output:\n"+ "\n".join(mismatches)+ regenerate_hint) + _fail( + "the staged StringTables do not match string_table_generator.py output:\n" + + "\n".join(mismatches) + + regenerate_hint + ) return 0 + if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/setup.cfg b/setup.cfg index b213bc8..4ce5c3e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -41,6 +41,7 @@ console_scripts = generated_header_separated = pre_commit_hooks.generated_header_separated:main check_locks = soft_lock.pre_commit:main dcfiles_updated = pre_commit_hooks.dcfiles_updated:main + generate_error_string_tables = pre_commit_hooks.generate_error_string_tables:main [bdist_wheel] universal = True