build(python): vendor rpycdec

This commit is contained in:
2026-09-06 15:06:03 -04:00
parent 64773e0948
commit c85993c196
61 changed files with 6644 additions and 464 deletions

View File

@@ -0,0 +1,8 @@
*.swp
*.pyc
*.rpyc
__pycache__
saves
cache
*_decompiled
log.txt

View File

@@ -0,0 +1,109 @@
"""Tests for renpy.ast node code generation"""
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import renpy.ast as ast
def make_pycode(code_str):
"""Create a PyCode object with the given source string."""
pycode = ast.PyCode()
pycode.state = (None, code_str, None, None, None)
return pycode
class TestPythonGetCode(unittest.TestCase):
"""Tests for Python.get_code() keyword order and correctness."""
def _make_node(self, code_str, store=None, hide=None):
node = ast.Python(loc=("script.rpyc", 1))
node.code = make_pycode(code_str)
if store is not None:
node.store = store
if hide is not None:
node.hide = hide
return node
def test_single_line_shorthand(self):
"""Single-line Python without store/hide uses $ shorthand."""
node = self._make_node("flag = True")
self.assertEqual(node.get_code(), "$ flag = True")
def test_hide_before_store(self):
"""'hide' must appear before 'in <store>' per Ren'Py grammar."""
node = self._make_node("flag = True", hide=True, store="store.mystore")
result = node.get_code()
self.assertIn("python hide in mystore:", result)
def test_hide_only(self):
"""python hide: block without store."""
node = self._make_node("flag = True", hide=True)
result = node.get_code()
self.assertTrue(result.startswith("python hide:"))
def test_store_only(self):
"""python in <store>: block without hide."""
node = self._make_node("flag = True", store="store.mystore")
result = node.get_code()
self.assertTrue(result.startswith("python in mystore:"))
def test_multiline(self):
"""Multiline code produces a python: block."""
node = self._make_node("flag = True\nother = False")
result = node.get_code()
self.assertIn("python:", result)
class TestEarlyPythonGetCode(unittest.TestCase):
"""Tests for EarlyPython.get_code() — keyword order and missing-attribute safety."""
def _make_node(self, code_str, store=None, hide=None):
node = ast.EarlyPython(loc=("script.rpyc", 1))
node.code = make_pycode(code_str)
if store is not None:
node.store = store
if hide is not None:
node.hide = hide
return node
def test_single_line_no_hide_attr(self):
"""EarlyPython without 'hide' attribute should not raise AttributeError."""
node = self._make_node("flag = True")
self.assertEqual(node.get_code(), "$ flag = True")
def test_single_line_hide_false(self):
"""EarlyPython with hide=False should use $ shorthand."""
node = self._make_node("flag = True", hide=False)
self.assertEqual(node.get_code(), "$ flag = True")
def test_hide_before_store(self):
"""'hide' must appear before 'in <store>' per Ren'Py grammar."""
node = self._make_node("flag = True", hide=True, store="store.mystore")
result = node.get_code()
self.assertIn("python early hide in mystore:", result)
def test_hide_only(self):
"""python early hide: block without store."""
node = self._make_node("flag = True", hide=True)
result = node.get_code()
self.assertTrue(result.startswith("python early hide:"))
def test_store_only(self):
"""python early in <store>: block without hide."""
node = self._make_node("flag = True", store="store.mystore")
result = node.get_code()
self.assertTrue(result.startswith("python early in mystore:"))
def test_multiline(self):
"""Multiline code should emit 'python early:' block."""
node = self._make_node("flag = True\nother = False")
result = node.get_code()
self.assertIn("python early:", result)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,187 @@
import os
import sys
import tempfile
import unittest
from pathlib import Path
from subprocess import run
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from rpycdec.rpa import create_rpa, extract_rpa
SRC_DIR = str(Path(__file__).resolve().parents[1] / "src")
class RPATests(unittest.TestCase):
def create_filter_fixture(self, root: Path) -> Path:
source = root / "game"
(source / "images").mkdir(parents=True)
(source / "scripts").mkdir()
(source / "script.rpy").write_text('label start:\n "Hello"\n')
(source / "scripts" / "chapter.RPYC").write_bytes(b"compiled")
(source / "images" / "bg.png").write_bytes(b"image")
(source / "notes.txt").write_text("notes")
archive = root / "archive.rpa"
create_rpa(str(archive), [str(source)])
return archive
def test_create_rpa_round_trips_directory(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
source = root / "game"
(source / "images").mkdir(parents=True)
(source / "script.rpy").write_text('label start:\n "Hello"\n')
(source / "images" / "bg.png").write_bytes(b"\x89PNG\r\n\x1a\n")
archive = root / "archive.rpa"
create_rpa(str(archive), [str(source)])
output = root / "out"
with archive.open("rb") as f:
extract_rpa(f, str(output))
self.assertEqual(
(output / "script.rpy").read_text(), 'label start:\n "Hello"\n'
)
self.assertEqual(
(output / "images" / "bg.png").read_bytes(), b"\x89PNG\r\n\x1a\n"
)
def test_rpa_cli_creates_archive_from_file(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
source = root / "00unlock.rpy"
source.write_text("define unlock = True\n")
archive = root / "unlock.rpa"
env = os.environ.copy()
env["PYTHONPATH"] = SRC_DIR + os.pathsep + env.get("PYTHONPATH", "")
result = run(
[
sys.executable,
"-m",
"rpycdec",
"rpa",
"-o",
str(archive),
str(source),
],
text=True,
capture_output=True,
env=env,
)
self.assertEqual(result.returncode, 0, result.stderr)
output = root / "out"
with archive.open("rb") as f:
extract_rpa(f, str(output))
self.assertEqual(
(output / "00unlock.rpy").read_text(), "define unlock = True\n"
)
def test_extract_rpa_filters_by_multiple_suffixes_case_insensitively(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
archive = self.create_filter_fixture(root)
output = root / "out"
with archive.open("rb") as f:
extract_rpa(f, str(output), suffixes=[".rpy", ".rpyc"])
self.assertTrue((output / "script.rpy").is_file())
self.assertTrue((output / "scripts" / "chapter.RPYC").is_file())
self.assertFalse((output / "images" / "bg.png").exists())
self.assertFalse((output / "notes.txt").exists())
def test_extract_rpa_accepts_a_single_filter_string(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
archive = self.create_filter_fixture(root)
output = root / "out"
with archive.open("rb") as f:
extract_rpa(f, str(output), suffixes=".rpy")
self.assertTrue((output / "script.rpy").is_file())
self.assertFalse((output / "scripts" / "chapter.RPYC").exists())
def test_extract_rpa_combines_expressions_and_suffixes_with_or(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
archive = self.create_filter_fixture(root)
output = root / "out"
with archive.open("rb") as f:
extract_rpa(
f,
str(output),
expressions=[r"^images/.*\.png$"],
suffixes=[".rpy"],
)
self.assertTrue((output / "script.rpy").is_file())
self.assertTrue((output / "images" / "bg.png").is_file())
self.assertFalse((output / "scripts" / "chapter.RPYC").exists())
self.assertFalse((output / "notes.txt").exists())
def test_unrpa_cli_supports_expression_and_multiple_suffixes(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
archive = self.create_filter_fixture(root)
output = root / "out"
env = os.environ.copy()
env["PYTHONPATH"] = SRC_DIR + os.pathsep + env.get("PYTHONPATH", "")
result = run(
[
sys.executable,
"-m",
"rpycdec",
"unrpa",
str(archive),
"-o",
str(output),
"-e",
r"^images/",
"-s",
".rpy",
".rpyc",
],
text=True,
capture_output=True,
env=env,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((output / "script.rpy").is_file())
self.assertTrue((output / "scripts" / "chapter.RPYC").is_file())
self.assertTrue((output / "images" / "bg.png").is_file())
self.assertFalse((output / "notes.txt").exists())
def test_unrpa_cli_rejects_invalid_expression(self):
env = os.environ.copy()
env["PYTHONPATH"] = SRC_DIR + os.pathsep + env.get("PYTHONPATH", "")
result = run(
[
sys.executable,
"-m",
"rpycdec",
"unrpa",
"archive.rpa",
"-e",
"[",
],
text=True,
capture_output=True,
env=env,
)
self.assertEqual(result.returncode, 2)
self.assertIn("invalid regular expression", result.stderr)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,296 @@
"""Tests for rpycdec.translate module"""
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
import renpy.ast
from rpycdec.translate import (
DialogueTranslation,
StringTranslation,
TranslationExtractor,
elide_filename,
get_translation_filename,
quote_unicode,
unique_identifier,
write_dialogue_translations,
write_string_translations,
)
class TestGetTranslationFilename(unittest.TestCase):
"""Tests for get_translation_filename()"""
def test_rpyc_to_rpy(self):
self.assertEqual(get_translation_filename("script.rpyc"), "script.rpy")
def test_rpymc_to_rpym(self):
self.assertEqual(get_translation_filename("script.rpymc"), "script.rpym")
def test_subdirectory_preserved(self):
"""Directory structure should be preserved (was a bug: basename stripped dirs)"""
self.assertEqual(get_translation_filename("sub/intro.rpyc"), "sub/intro.rpy")
def test_nested_subdirectory_preserved(self):
self.assertEqual(
get_translation_filename("a/b/c/script.rpyc"), "a/b/c/script.rpy"
)
def test_rpym_unchanged(self):
self.assertEqual(get_translation_filename("script.rpym"), "script.rpym")
def test_rpy_unchanged(self):
self.assertEqual(get_translation_filename("script.rpy"), "script.rpy")
class TestElideFilename(unittest.TestCase):
def test_strips_game_dir(self):
result = elide_filename("/game/script.rpyc", "/game")
self.assertEqual(result, "script.rpy")
def test_rpymc_to_rpym(self):
result = elide_filename("/game/script.rpymc", "/game")
self.assertEqual(result, "script.rpym")
def test_no_game_dir(self):
result = elide_filename("script.rpyc")
self.assertEqual(result, "script.rpy")
class TestQuoteUnicode(unittest.TestCase):
def test_backslash(self):
self.assertEqual(quote_unicode("a\\b"), "a\\\\b")
def test_double_quote(self):
self.assertEqual(quote_unicode('say "hi"'), 'say \\"hi\\"')
def test_newline(self):
self.assertEqual(quote_unicode("line1\nline2"), "line1\\nline2")
def test_plain_string(self):
self.assertEqual(quote_unicode("hello"), "hello")
class TestUniqueIdentifier(unittest.TestCase):
def test_no_label(self):
result = unique_identifier(None, "abcd1234")
self.assertEqual(result, "abcd1234")
def test_with_label(self):
result = unique_identifier("start", "abcd1234")
self.assertEqual(result, "start_abcd1234")
def test_label_dot_replaced(self):
result = unique_identifier("chapter.one", "abcd1234")
self.assertEqual(result, "chapter_one_abcd1234")
def test_collision_appends_index(self):
existing = {"start_abcd1234"}
result = unique_identifier("start", "abcd1234", existing)
self.assertEqual(result, "start_abcd1234_1")
def test_multiple_collisions(self):
existing = {"start_abcd1234", "start_abcd1234_1"}
result = unique_identifier("start", "abcd1234", existing)
self.assertEqual(result, "start_abcd1234_2")
class TestTranslationExtractor(unittest.TestCase):
def _make_say_node(self, who, what, filename="script.rpyc", linenumber=1):
node = renpy.ast.Say(loc=(filename, linenumber))
node.who = who
node.what = what
node.interact = True
node.identifier = None
node.explicit_identifier = False
node.attributes = None
node.temporary_attributes = None
node.arguments = None
node.with_ = None
return node
def test_extract_say_dialogue(self):
extractor = TranslationExtractor()
node = self._make_say_node("e", "Hello world.")
extractor.extract_file("script.rpyc", [node])
self.assertEqual(len(extractor.dialogues), 1)
d = extractor.dialogues[0]
self.assertEqual(d.who, "e")
self.assertEqual(d.what, "Hello world.")
self.assertEqual(d.filename, "script.rpyc")
def test_extract_say_no_speaker(self):
extractor = TranslationExtractor()
node = self._make_say_node(None, "A narrator line.")
extractor.extract_file("script.rpyc", [node])
self.assertEqual(len(extractor.dialogues), 1)
self.assertIsNone(extractor.dialogues[0].who)
def test_no_say_no_dialogue(self):
extractor = TranslationExtractor()
extractor.extract_file("script.rpyc", [])
self.assertEqual(len(extractor.dialogues), 0)
self.assertEqual(len(extractor.strings), 0)
def test_identifier_generated(self):
extractor = TranslationExtractor()
node = self._make_say_node("e", "Hello.")
extractor.extract_file("script.rpyc", [node])
self.assertIsNotNone(extractor.dialogues[0].identifier)
self.assertIsInstance(extractor.dialogues[0].identifier, str)
class TestWriteDialogueTranslations(unittest.TestCase):
def test_writes_dialogue_file(self):
dialogues = [
DialogueTranslation(
identifier="start_abcd1234",
filename="script.rpyc",
linenumber=5,
who="e",
what="Hello.",
code='e "Hello."',
)
]
with tempfile.TemporaryDirectory() as tmpdir:
write_dialogue_translations(
dialogues, tmpdir, "chinese", empty_translation=False
)
tl_file = os.path.join(tmpdir, "script.rpy")
self.assertTrue(os.path.exists(tl_file))
with open(tl_file) as f:
content = f.read()
self.assertIn("translate chinese start_abcd1234:", content)
self.assertIn('e "Hello."', content)
def test_preserves_subdirectory_structure(self):
"""Translation files should mirror the source directory structure"""
dialogues = [
DialogueTranslation(
identifier="intro_abcd1234",
filename="sub/intro.rpyc",
linenumber=3,
who=None,
what="Welcome.",
code='"Welcome."',
)
]
with tempfile.TemporaryDirectory() as tmpdir:
write_dialogue_translations(
dialogues, tmpdir, "chinese", empty_translation=False
)
tl_file = os.path.join(tmpdir, "sub", "intro.rpy")
self.assertTrue(
os.path.exists(tl_file),
f"Expected {tl_file} to exist (subdirectory structure not preserved)",
)
def test_empty_translation(self):
dialogues = [
DialogueTranslation(
identifier="start_abcd1234",
filename="script.rpyc",
linenumber=1,
who="e",
what="Hello.",
code='e "Hello."',
)
]
with tempfile.TemporaryDirectory() as tmpdir:
write_dialogue_translations(
dialogues, tmpdir, "chinese", empty_translation=True
)
with open(os.path.join(tmpdir, "script.rpy")) as f:
content = f.read()
self.assertIn('e ""', content)
class TestWriteStringTranslations(unittest.TestCase):
def test_writes_strings_file(self):
strings = [StringTranslation(filename="script.rpyc", linenumber=2, text="Play")]
with tempfile.TemporaryDirectory() as tmpdir:
count = write_string_translations(
strings, tmpdir, "chinese", empty_translation=False
)
self.assertEqual(count, 1)
tl_file = os.path.join(tmpdir, "strings.rpy")
self.assertTrue(os.path.exists(tl_file))
with open(tl_file) as f:
content = f.read()
self.assertIn("translate chinese strings:", content)
self.assertIn('old "Play"', content)
self.assertIn('new "Play"', content)
def test_empty_strings_not_written(self):
with tempfile.TemporaryDirectory() as tmpdir:
count = write_string_translations([], tmpdir, "chinese")
self.assertEqual(count, 0)
self.assertFalse(os.path.exists(os.path.join(tmpdir, "strings.rpy")))
class TestCLIExtractTranslate(unittest.TestCase):
"""Integration tests for the extract-translate CLI command"""
def test_language_argument_recognized(self):
"""The --language argument must be recognized by the CLI parser"""
from rpycdec.cli import main
with tempfile.TemporaryDirectory() as tmpdir:
sys.argv = [
"rpycdec",
"extract-translate",
tmpdir,
"--language",
"chinese",
]
# Should not raise SystemExit with non-zero code (unrecognized arguments)
try:
main()
except SystemExit as e:
if e.code != 0:
self.fail(
f"CLI raised SystemExit({e.code}) - --language may not be recognized"
)
def test_language_short_flag(self):
"""The -l short flag must also work"""
from rpycdec.cli import main
with tempfile.TemporaryDirectory() as tmpdir:
sys.argv = ["rpycdec", "extract-translate", tmpdir, "-l", "japanese"]
try:
main()
except SystemExit as e:
if e.code != 0:
self.fail(
f"CLI raised SystemExit({e.code}) - -l flag may not be recognized"
)
def test_creates_tl_directory(self):
"""extract-translate must create tl/<language>/ directory in output"""
from rpycdec.cli import main
with tempfile.TemporaryDirectory() as game_dir:
with tempfile.TemporaryDirectory() as out_dir:
sys.argv = [
"rpycdec",
"extract-translate",
game_dir,
"--language",
"chinese",
"--output",
out_dir,
]
main()
tl_dir = os.path.join(out_dir, "tl", "chinese")
self.assertTrue(
os.path.isdir(tl_dir),
f"Expected tl/chinese/ directory at {tl_dir}",
)
if __name__ == "__main__":
unittest.main()