From 19f18ba1968407e47489732a05163f9d84919326 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 15:37:02 -0300 Subject: [PATCH 01/44] StringName support --- godot_parser/objects.py | 25 +++++++++++++++++++++++++ godot_parser/values.py | 9 +++++++-- tests/test_parser.py | 17 ++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 42d5004..a2b30c8 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -13,6 +13,7 @@ "NodePath", "ExtResource", "SubResource", + "StringName", ] GD_OBJECT_REGISTRY = {} @@ -245,3 +246,27 @@ def id(self) -> int: def id(self, id: int) -> None: """Setter for id""" self.args[0] = id + +class StringName(): + def __init__(self, str) -> None: + self.str = str + + @classmethod + def from_parser(cls: Type[StringName], parse_result) -> StringName: + return StringName(parse_result[0]) + + def __str__(self) -> str: + return "&\"%s\"" % ( + self.str, + ) + + def __repr__(self) -> str: + return self.__str__() + + def __eq__(self, other) -> bool: + if not isinstance(other, StringName): + return False + return self.str == other.str + + def __ne__(self, other) -> bool: + return not self.__eq__(other) \ No newline at end of file diff --git a/godot_parser/values.py b/godot_parser/values.py index 1b43031..fe6fa77 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -14,7 +14,7 @@ common, ) -from .objects import GDObject +from .objects import GDObject, StringName boolean = ( (Keyword("true") | Keyword("false")) @@ -24,9 +24,14 @@ null = Keyword("null").set_parse_action(lambda _: [None]) +_string = QuotedString('"', escChar="\\", multiline=True).set_name("string") + +_string_name = ( + Suppress('&') + _string +).set_name("string_name").set_parse_action(StringName.from_parser) primitive = ( - null | QuotedString('"', escChar="\\", multiline=True) | boolean | common.number + null | _string | _string_name | boolean | common.number ) value = Forward() diff --git a/tests/test_parser.py b/tests/test_parser.py index 975166f..d526495 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,7 +3,7 @@ from pyparsing import ParseException -from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, parse +from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse HERE = os.path.dirname(__file__) @@ -110,6 +110,21 @@ ) ), ), + ( + """[sub_resource type="CustomType" id=1] + string_value = "String" + string_name_value = &"StringName" + """ , +GDFile( + GDSection( + GDSectionHeader("sub_resource", type="CustomType", id=1), + **{ + "string_value": "String", + "string_name_value": StringName("StringName"), + } + ) + ), + ), ] From f38867e0fd1d0d9eb931f1c7a35069012297bb8a Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 15:38:31 -0300 Subject: [PATCH 02/44] Update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ea21f34..5616a14 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,6 @@ dmypy.json .pyre/ .direnv/ + +#PyCharm +.idea \ No newline at end of file From 8c9011cc3dd384ad65d6d7f62e4bff34119e6067 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 15:47:18 -0300 Subject: [PATCH 03/44] Updated parseAll to parse_all --- godot_parser/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index adf5e73..87483f6 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -304,7 +304,7 @@ def get_node(self, path: str = ".") -> Optional[GDNodeSection]: @classmethod def parse(cls: Type[GDFileType], contents: str) -> GDFileType: """Parse the contents of a Godot file""" - return cls.from_parser(scene_file.parse_string(contents, parseAll=True)) + return cls.from_parser(scene_file.parse_string(contents, parse_all=True)) @classmethod def load(cls: Type[GDFileType], filepath: str) -> GDFileType: From 66118bb777a151cef54962cf94a9d04f7c3e5ed8 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 16:35:50 -0300 Subject: [PATCH 04/44] Ignoring whitespaces on test_parse_files and outputting StringName with inner quotes --- godot_parser/objects.py | 4 +--- test_parse_files.py | 7 +++++-- tests/test_parser.py | 4 ++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index a2b30c8..d6bfd36 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -256,9 +256,7 @@ def from_parser(cls: Type[StringName], parse_result) -> StringName: return StringName(parse_result[0]) def __str__(self) -> str: - return "&\"%s\"" % ( - self.str, - ) + return "&" + stringify_object(self.str) def __repr__(self) -> str: return self.__str__() diff --git a/test_parse_files.py b/test_parse_files.py index bccb7b0..55e0988 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -2,10 +2,13 @@ import argparse import os import sys +import re from itertools import zip_longest from godot_parser import load, parse +# Regex to detect all whitespaces not between quotes +line_normalizer_re = re.compile('\s+(?=((\\[\\"]|[^\\"])*"(\\[\\"]|[^\\"])*")*(\\[\\"]|[^\\"])*$)') def _parse_and_test_file(filename: str) -> bool: print("Parsing %s" % filename) @@ -24,8 +27,8 @@ def _parse_and_test_file(filename: str) -> bool: with f.use_tree() as tree: pass - data_lines = [l for l in str(data).split("\n") if l] - content_lines = [l for l in contents.split("\n") if l] + data_lines = [line_normalizer_re.sub("",l) for l in str(data).split("\n") if l] + content_lines = [line_normalizer_re.sub("",l) for l in contents.split("\n") if l] if data_lines != content_lines: print(" Error!") max_len = max([len(l) for l in content_lines]) diff --git a/tests/test_parser.py b/tests/test_parser.py index d526495..caaa312 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -114,6 +114,8 @@ """[sub_resource type="CustomType" id=1] string_value = "String" string_name_value = &"StringName" + string_quote = "\\"String\\"" + string_name_quote = &"\\"StringName\\"" """ , GDFile( GDSection( @@ -121,6 +123,8 @@ **{ "string_value": "String", "string_name_value": StringName("StringName"), + "string_quote": "\"String\"", + "string_name_quote": StringName("\"StringName\""), } ) ), From 8f01daaa5f461c1426c1697a1d7f1392ac9b2612 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 16:57:10 -0300 Subject: [PATCH 05/44] Removed whitespace from GDObject string representation and added StringName tests to test_objects --- godot_parser/objects.py | 2 +- tests/test_gdfile.py | 2 +- tests/test_objects.py | 26 +++++++++++++++++++------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index d6bfd36..6b9b4d5 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -56,7 +56,7 @@ def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: return factory(*parse_result[1:]) def __str__(self) -> str: - return "%s( %s )" % ( + return "%s(%s)" % ( self.name, ", ".join([stringify_object(v) for v in self.args]), ) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 7706e0f..f2e35dd 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -29,7 +29,7 @@ def test_all_data_types(self): [resource] list = [ 1, 2.0, "string" ] map = { -"key": [ "nested", Vector2( 1, 1 ) ] +"key": [ "nested", Vector2(1, 1) ] } empty = null escaped = "foo(\\"bar\\")" diff --git a/tests/test_objects.py b/tests/test_objects.py index 89598dc..caf840b 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -1,6 +1,6 @@ import unittest -from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3 +from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName class TestGDObjects(unittest.TestCase): @@ -13,7 +13,7 @@ def test_vector2(self): self.assertEqual(v[1], 2) self.assertEqual(v.x, 1) self.assertEqual(v.y, 2) - self.assertEqual(str(v), "Vector2( 1, 2 )") + self.assertEqual(str(v), "Vector2(1, 2)") v.x = 2 v.y = 3 self.assertEqual(v.x, 2) @@ -32,7 +32,7 @@ def test_vector3(self): self.assertEqual(v.x, 1) self.assertEqual(v.y, 2) self.assertEqual(v.z, 3) - self.assertEqual(str(v), "Vector3( 1, 2, 3 )") + self.assertEqual(str(v), "Vector3(1, 2, 3)") v.x = 2 v.y = 3 v.z = 4 @@ -57,7 +57,7 @@ def test_color(self): self.assertEqual(c.g, 0.2) self.assertEqual(c.b, 0.3) self.assertEqual(c.a, 0.4) - self.assertEqual(str(c), "Color( 0.1, 0.2, 0.3, 0.4 )") + self.assertEqual(str(c), "Color(0.1, 0.2, 0.3, 0.4)") c.r = 0.2 c.g = 0.3 c.b = 0.4 @@ -89,7 +89,7 @@ def test_ext_resource(self): self.assertEqual(r.id, 1) r.id = 2 self.assertEqual(r.id, 2) - self.assertEqual(str(r), "ExtResource( 2 )") + self.assertEqual(str(r), "ExtResource(2)") def test_sub_resource(self): """Test for SubResource""" @@ -97,14 +97,26 @@ def test_sub_resource(self): self.assertEqual(r.id, 1) r.id = 2 self.assertEqual(r.id, 2) - self.assertEqual(str(r), "SubResource( 2 )") + self.assertEqual(str(r), "SubResource(2)") def test_dunder(self): """Test the __magic__ methods on GDObject""" v = Vector2(1, 2) - self.assertEqual(repr(v), "Vector2( 1, 2 )") + self.assertEqual(repr(v), "Vector2(1, 2)") v2 = Vector2(1, 2) self.assertEqual(v, v2) v2.x = 10 self.assertNotEqual(v, v2) self.assertNotEqual(v, (1, 2)) + + def test_string_name(self): + """Test for StringName""" + s = StringName("test") + self.assertEqual(repr(s), "&\"test\"") + s2 = StringName("test") + self.assertEqual(s, s2) + s2.str = "bad" + self.assertNotEqual(s, s2) + + s = StringName("A \"Quoted test\"") + self.assertEqual(repr(s), "&\"A \\\"Quoted test\\\"\"") From 2b7410fa0d5f42f9cfb0d85d53fdf60415399833 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 17:05:39 -0300 Subject: [PATCH 06/44] Removed regex warning --- test_parse_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_parse_files.py b/test_parse_files.py index 55e0988..0c0d0d5 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -8,7 +8,7 @@ from godot_parser import load, parse # Regex to detect all whitespaces not between quotes -line_normalizer_re = re.compile('\s+(?=((\\[\\"]|[^\\"])*"(\\[\\"]|[^\\"])*")*(\\[\\"]|[^\\"])*$)') +line_normalizer_re = re.compile('\\s+(?=((\\[\\"]|[^\\"])*"(\\[\\"]|[^\\"])*")*(\\[\\"]|[^\\"])*$)') def _parse_and_test_file(filename: str) -> bool: print("Parsing %s" % filename) From 792b3103480ae2d358dc479510b7ddf25cd1424f Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 18:42:38 -0300 Subject: [PATCH 07/44] Typed Dictionary support --- godot_parser/objects.py | 52 ++++++++++++++++++++++++++++++++++++++++- godot_parser/util.py | 2 +- godot_parser/values.py | 23 ++++++++++++++---- tests/test_objects.py | 11 ++++++++- tests/test_parser.py | 27 ++++++++++++++++++++- 5 files changed, 106 insertions(+), 9 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 6b9b4d5..d05a6bb 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -14,6 +14,7 @@ "ExtResource", "SubResource", "StringName", + "TypedDictionary", ] GD_OBJECT_REGISTRY = {} @@ -72,6 +73,9 @@ def __eq__(self, other) -> bool: def __ne__(self, other) -> bool: return not self.__eq__(other) + def __hash__(self): + return hash(frozenset((self.name,*self.args))) + class Vector2(GDObject): def __init__(self, x: float, y: float) -> None: @@ -247,6 +251,49 @@ def id(self, id: int) -> None: """Setter for id""" self.args[0] = id + +class TypedDictionary(): + def __init__(self, key_type, value_type, dict_) -> None: + self.name = "Dictionary" + self.key_type = key_type + self.value_type = value_type + self.dict_ = dict_ + + @classmethod + def WithCustomName(cls: Type[TypedDictionary], name, key_type, value_type, dict_) -> TypedDictionary: + custom_dict_ = TypedDictionary(key_type, value_type, dict_) + custom_dict_.name = name + return custom_dict_ + + @classmethod + def from_parser(cls: Type[TypedDictionary], parse_result) -> TypedDictionary: + return TypedDictionary.WithCustomName(*parse_result) + + def __str__(self) -> str: + return "%s[%s, %s](%s)" % ( + self.name, + self.key_type, + self.value_type, + stringify_object(self.dict_) + ) + + def __repr__(self) -> str: + return self.__str__() + + def __eq__(self, other) -> bool: + if not isinstance(other, TypedDictionary): + return False + return self.name == other.name and \ + self.key_type == other.key_type and \ + self.value_type == other.value_type and \ + self.dict_ == other.dict_ + + def __ne__(self, other) -> bool: + return not self.__eq__(other) + + def __hash__(self): + return hash(frozenset((self.name,self.key_type,self.value_type,self.dict_))) + class StringName(): def __init__(self, str) -> None: self.str = str @@ -267,4 +314,7 @@ def __eq__(self, other) -> bool: return self.str == other.str def __ne__(self, other) -> bool: - return not self.__eq__(other) \ No newline at end of file + return not self.__eq__(other) + + def __hash__(self): + return hash(self.str) \ No newline at end of file diff --git a/godot_parser/util.py b/godot_parser/util.py index 42b1433..76e4b56 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -17,7 +17,7 @@ def stringify_object(value): return ( "{\n" + ",\n".join( - ['"%s": %s' % (k, stringify_object(v)) for k, v in value.items()] + ['%s: %s' % (stringify_object(k), stringify_object(v)) for k, v in value.items()] ) + "\n}" ) diff --git a/godot_parser/values.py b/godot_parser/values.py index fe6fa77..f8b4b72 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -14,7 +14,7 @@ common, ) -from .objects import GDObject, StringName +from .objects import GDObject, StringName, TypedDictionary boolean = ( (Keyword("true") | Keyword("false")) @@ -36,13 +36,15 @@ value = Forward() # Vector2( 1, 2 ) -obj_type = ( +obj_ = ( Word(alphas, alphanums).set_results_name("object_name") + Suppress("(") - + DelimitedList(value) + + Opt(DelimitedList(value)) + Suppress(")") ).set_parse_action(GDObject.from_parser) +obj_type = obj_ | Word(alphas, alphanums) + # [ 1, 2 ] or [ 1, 2, ] list_ = ( Group( @@ -51,7 +53,7 @@ .set_name("list") .set_parse_action(lambda p: p.as_list()) ) -key_val = Group(QuotedString('"', escChar="\\") + Suppress(":") + value) +key_val = Group(value + Suppress(":") + value) # { # "_edit_use_anchors_": false @@ -62,6 +64,17 @@ .set_parse_action(lambda d: {k: v for k, v in d}) ) +typed_dict = ( + Word(alphas, alphanums).set_results_name("object_name") + + ( + Suppress("[") + + obj_type.set_results_name("key_type") + + Suppress(",") + + obj_type.set_results_name("value_type") + + Suppress("]") + ) + Suppress("(") + dict_ + Suppress(")") +).set_parse_action(TypedDictionary.from_parser) + # Exports -value <<= primitive | list_ | dict_ | obj_type +value <<= list_ | dict_ | typed_dict | obj_ | primitive diff --git a/tests/test_objects.py b/tests/test_objects.py index caf840b..aa3d72c 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -1,6 +1,6 @@ import unittest -from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName +from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName, GDObject, TypedDictionary class TestGDObjects(unittest.TestCase): @@ -120,3 +120,12 @@ def test_string_name(self): s = StringName("A \"Quoted test\"") self.assertEqual(repr(s), "&\"A \\\"Quoted test\\\"\"") + + def test_typed_dictionary(self): + dict1 = { + StringName("asd"): GDObject("ExtResource", "2_qwert") + } + td = TypedDictionary("StringName", GDObject("ExtResource", "1_qwert"), dict1) + self.assertEqual(repr(td), """Dictionary[StringName, ExtResource("1_qwert")]({ +&"asd": ExtResource("2_qwert") +})""") diff --git a/tests/test_parser.py b/tests/test_parser.py index caaa312..ae87aa9 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,7 +3,7 @@ from pyparsing import ParseException -from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse +from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse, TypedDictionary HERE = os.path.dirname(__file__) @@ -129,6 +129,31 @@ ) ), ), + ( + """[sub_resource type="CustomType" id=1] + typed_dict_1 = Dictionary[StringName, ExtResource("1_testt")]({ + &"key": ExtResource("2_testt") + }) + typed_dict_2 = Dictionary[ExtResource("1_testt"), StringName]({ + ExtResource("2_testt"): &"key" + }) + """ , +GDFile( + GDSection( + GDSectionHeader("sub_resource", type="CustomType", id=1), + **{ + "typed_dict_1": TypedDictionary("StringName", GDObject("ExtResource", "1_testt"), + { + StringName("key"): GDObject("ExtResource", "2_testt") + }), + "typed_dict_2": TypedDictionary(GDObject("ExtResource", "1_testt"), "StringName", + { + GDObject("ExtResource", "2_testt"): StringName("key") + }) + } + ) + ), + ), ] From b9bbce2e88f84153b48bc4fa165bc389f3d842a6 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 20:25:51 -0300 Subject: [PATCH 08/44] Handling and testing special characters --- godot_parser/files.py | 2 +- godot_parser/util.py | 3 ++- tests/test_gdfile.py | 22 ++++++++++++++++++++++ tests/test_objects.py | 1 + tests/test_parser.py | 21 +++++++++++++++++++-- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 87483f6..f903198 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -304,7 +304,7 @@ def get_node(self, path: str = ".") -> Optional[GDNodeSection]: @classmethod def parse(cls: Type[GDFileType], contents: str) -> GDFileType: """Parse the contents of a Godot file""" - return cls.from_parser(scene_file.parse_string(contents, parse_all=True)) + return cls.from_parser(scene_file.parse_with_tabs().parse_string(contents, parse_all=True)) @classmethod def load(cls: Type[GDFileType], filepath: str) -> GDFileType: diff --git a/godot_parser/util.py b/godot_parser/util.py index 76e4b56..622f410 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -10,7 +10,8 @@ def stringify_object(value): if value is None: return "null" elif isinstance(value, str): - return json.dumps(value, ensure_ascii=False) + #return json.dumps(value, ensure_ascii=False) + return "\"%s\"" % value.replace("\"", "\\\"") elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index f2e35dd..dd35655 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -242,3 +242,25 @@ def test_file_equality(self): resource = s1.find_section("resource") resource["key"] = "value" self.assertNotEqual(s1, s2) + + + + def test_string_special_characters(self): + """ + Testing strings with multiple special characters. Currently matching Godot 4.6 behavior + + Tab handling is done by calling parse_with_tabs before parse_string + For this reason, this test is being done at a GDFile level, where this method is called upon parsing + """ + res = GDResource() + res.add_section( + GDResourceSection( + str_value="\ta\"q\'é'd\"\n", + ) + ) + self.assertEqual(str(res), """[gd_resource load_steps=1 format=3] + +[resource] +str_value = " a\\"q'é'd\\" +" +""") \ No newline at end of file diff --git a/tests/test_objects.py b/tests/test_objects.py index aa3d72c..9b56b9d 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -122,6 +122,7 @@ def test_string_name(self): self.assertEqual(repr(s), "&\"A \\\"Quoted test\\\"\"") def test_typed_dictionary(self): + """Test for TypedDictionary""" dict1 = { StringName("asd"): GDObject("ExtResource", "2_qwert") } diff --git a/tests/test_parser.py b/tests/test_parser.py index ae87aa9..be7099a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -116,8 +116,10 @@ string_name_value = &"StringName" string_quote = "\\"String\\"" string_name_quote = &"\\"StringName\\"" + string_single_quote = "\'String\'" + string_name_single_quote = &"\'StringName\'" """ , -GDFile( + GDFile( GDSection( GDSectionHeader("sub_resource", type="CustomType", id=1), **{ @@ -125,6 +127,8 @@ "string_name_value": StringName("StringName"), "string_quote": "\"String\"", "string_name_quote": StringName("\"StringName\""), + "string_single_quote": "'String'", + "string_name_single_quote": StringName("'StringName'"), } ) ), @@ -138,7 +142,7 @@ ExtResource("2_testt"): &"key" }) """ , -GDFile( + GDFile( GDSection( GDSectionHeader("sub_resource", type="CustomType", id=1), **{ @@ -154,6 +158,19 @@ ) ), ), + ( + """[node name="Label" type="Label" parent="." unique_id=1387035530] + text = "\ta\\\"q\\'é'd\\\"\n" + """, + GDFile( + GDSection( + GDSectionHeader("node", name="Label", type="Label", parent=".", unique_id=1387035530), + **{ + "text": "\ta\"q'é'd\"\n" + } + ) + ), + ), ] From 5dc96f70da4e6aa182118a6620afd3cf08f28cb1 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 20:29:51 -0300 Subject: [PATCH 09/44] Allow specifying format version on GDCommonFile creation --- godot_parser/files.py | 12 ++++++------ tests/test_gdfile.py | 6 +++--- tests/test_parser.py | 4 ++++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index f903198..659c55e 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -356,9 +356,9 @@ def __ne__(self, other) -> bool: class GDCommonFile(GDFile): """Base class with common application logic for all Godot file types""" - def __init__(self, name: str, *sections: GDSection) -> None: + def __init__(self, name: str, *sections: GDSection, _format:int=2) -> None: super().__init__( - GDSection(GDSectionHeader(name, load_steps=1, format=2)), *sections + GDSection(GDSectionHeader(name, load_steps=1, format=_format)), *sections ) self.load_steps = ( 1 + len(self.get_ext_resources()) + len(self.get_sub_resources()) @@ -449,10 +449,10 @@ def _renumber_resource_ids( class GDScene(GDCommonFile): - def __init__(self, *sections: GDSection) -> None: - super().__init__("gd_scene", *sections) + def __init__(self, *sections: GDSection, _format: int = 2) -> None: + super().__init__("gd_scene", *sections, _format = _format) class GDResource(GDCommonFile): - def __init__(self, *sections: GDSection) -> None: - super().__init__("gd_resource", *sections) + def __init__(self, *sections: GDSection, _format: int = 2) -> None: + super().__init__("gd_resource", *sections, _format = _format) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index dd35655..09cb091 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -13,7 +13,7 @@ def test_basic_scene(self): def test_all_data_types(self): """Run the parsing test cases""" - res = GDResource() + res = GDResource(_format = 3) res.add_section( GDResourceSection( list=[1, 2.0, "string"], @@ -24,7 +24,7 @@ def test_all_data_types(self): ) self.assertEqual( str(res), - """[gd_resource load_steps=1 format=2] + """[gd_resource load_steps=1 format=3] [resource] list = [ 1, 2.0, "string" ] @@ -258,7 +258,7 @@ def test_string_special_characters(self): str_value="\ta\"q\'é'd\"\n", ) ) - self.assertEqual(str(res), """[gd_resource load_steps=1 format=3] + self.assertEqual(str(res), """[gd_resource load_steps=1 format=2] [resource] str_value = " a\\"q'é'd\\" diff --git a/tests/test_parser.py b/tests/test_parser.py index be7099a..d4c107f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -16,6 +16,10 @@ "[gd_resource load_steps=5 format=2]", GDFile(GDSection(GDSectionHeader("gd_resource", load_steps=5, format=2))), ), + ( + "[gd_resource format=3]", + GDFile(GDSection(GDSectionHeader("gd_resource", format=3))), + ), ( '[ext_resource path="res://Sample.tscn" type="PackedScene" id=1]', GDFile( From 7c2af0c9b7c4be7f2f67657b7dd8dd7ba83d878d Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 17 Feb 2026 22:30:29 -0300 Subject: [PATCH 10/44] Fixed escaping back slashes --- godot_parser/util.py | 2 +- tests/test_gdfile.py | 5 +++-- tests/test_parser.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/godot_parser/util.py b/godot_parser/util.py index 622f410..468d1b7 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -11,7 +11,7 @@ def stringify_object(value): return "null" elif isinstance(value, str): #return json.dumps(value, ensure_ascii=False) - return "\"%s\"" % value.replace("\"", "\\\"") + return "\"%s\"" % value.replace("\\","\\\\").replace("\"", "\\\"") elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 09cb091..f22d1a1 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -255,12 +255,13 @@ def test_string_special_characters(self): res = GDResource() res.add_section( GDResourceSection( - str_value="\ta\"q\'é'd\"\n", + str_value="\ta\"q\'é'd\"\n\n\\", ) ) self.assertEqual(str(res), """[gd_resource load_steps=1 format=2] [resource] str_value = " a\\"q'é'd\\" -" + +\\\\" """) \ No newline at end of file diff --git a/tests/test_parser.py b/tests/test_parser.py index d4c107f..0eee5b8 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -164,13 +164,13 @@ ), ( """[node name="Label" type="Label" parent="." unique_id=1387035530] - text = "\ta\\\"q\\'é'd\\\"\n" + text = "\ta\\\"q\\'é'd\\\"\n\n\\\\" """, GDFile( GDSection( GDSectionHeader("node", name="Label", type="Label", parent=".", unique_id=1387035530), **{ - "text": "\ta\"q'é'd\"\n" + "text": "\ta\"q'é'd\"\n\n\\" } ) ), From 9cd8e61ff6c0aaa5ae50197f0596667fe4da60aa Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 00:50:39 -0300 Subject: [PATCH 11/44] test_parse_files.py now using difflib and dealing with whitespace differences between parsed and original files --- test_parse_files.py | 118 ++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/test_parse_files.py b/test_parse_files.py index 0c0d0d5..39fd644 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -1,58 +1,102 @@ #!/usr/bin/env python import argparse import os -import sys import re -from itertools import zip_longest +import io +import sys +import traceback +import difflib from godot_parser import load, parse -# Regex to detect all whitespaces not between quotes -line_normalizer_re = re.compile('\\s+(?=((\\[\\"]|[^\\"])*"(\\[\\"]|[^\\"])*")*(\\[\\"]|[^\\"])*$)') +# Regex to detect space sequences +space_re = re.compile(r" +") +# Regex to detect all spaces not surrounded by alphanumeric characters +line_normalizer_re = re.compile(r"(?<=\W) +| +(?=\W)") +# Regex to detect quotes and possible escape sequences +find_quote_re = re.compile(r"(\\*\")") + +def join_lines_within_quotes(input: list[str]): + buffer_list = [] + lines = [] + buffer = "" + + for part in input: + # Find all quotes that are not escaped + # " is not escaped. \" is escaped. \\" is not escaped as \\ becomes \, leaving the quote unescaped + read_pos = 0 + for match in find_quote_re.finditer(part): + span = match.span() + match_text = part[span[0]:span[1]] + buffer += part[read_pos:span[1]] + read_pos = span[1] + if len(match_text)%2 == 1: + buffer_list.append(buffer) + buffer = "" + buffer += part[read_pos:] + + if (len(buffer_list) % 2 == 0) and buffer: + buffer_list.append(buffer) + buffer = "" + + if buffer: + buffer += "\n" + else: + for i in range(len(buffer_list)): + if i%2 == 0: + buffer_list[i] = space_re.sub(" ", buffer_list[i]) + buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) + lines.append("".join(buffer_list) + "\n") + buffer_list = [] + buffer = "" + + if buffer: + buffer_list.append(buffer) + if buffer_list: + for i in range(len(buffer_list)): + if i % 2 == 0: + buffer_list[i] = space_re.sub(" ", buffer_list[i]) + buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) + lines.append("".join(buffer_list) + "\n") -def _parse_and_test_file(filename: str) -> bool: - print("Parsing %s" % filename) + return lines + + +def _parse_and_test_file(filename: str, verbose: bool) -> bool: + if verbose: + print("Parsing %s" % filename) with open(filename, "r") as ifile: - contents = ifile.read() + original_file = ifile.read() try: - data = parse(contents) + parsed_file = str(parse(original_file)) except Exception: - print(" Parsing error!") - import traceback - - traceback.print_exc() + print("! Parsing error on %s" % filename, file=sys.stderr) + traceback.print_exc(file=sys.stderr) return False - f = load(filename) - with f.use_tree() as tree: - pass - - data_lines = [line_normalizer_re.sub("",l) for l in str(data).split("\n") if l] - content_lines = [line_normalizer_re.sub("",l) for l in contents.split("\n") if l] - if data_lines != content_lines: - print(" Error!") - max_len = max([len(l) for l in content_lines]) - if max_len < 100: - for orig, parsed in zip_longest(content_lines, data_lines, fillvalue=""): - c = " " if orig == parsed else "x" - print("%s <%s> %s" % (orig.ljust(max_len), c, parsed)) - else: - for orig, parsed in zip_longest( - content_lines, data_lines, fillvalue="----EMPTY----" - ): - c = " " if orig == parsed else "XXX)" - print("%s\n%s%s" % (orig, c, parsed)) - return False - return True + original_file = join_lines_within_quotes([l.strip() for l in io.StringIO(original_file).readlines() if l.strip()]) + parsed_file = join_lines_within_quotes([l.strip() for l in io.StringIO(str(parsed_file)).readlines() if l.strip()]) + + diff = difflib.context_diff(original_file, parsed_file, fromfile=filename, tofile="PARSED FILE") + diff = [" "+"\n ".join(l.strip().split("\n"))+"\n" for l in diff] + + if(len(diff) == 0): + return True + + print("! Difference detected on %s" % filename) + sys.stdout.writelines(diff) + return False def main(): """Test the parsing of one tscn file or all files in directory""" parser = argparse.ArgumentParser(description=main.__doc__) parser.add_argument("file_or_dir", help="Parse file or files under this directory") + parser.add_argument("--all", action='store_true', help="Tests all files even if one fails") + parser.add_argument("--verbose", "-v", action='store_true', help="Prints all file paths as they're parsed") args = parser.parse_args() if os.path.isfile(args.file_or_dir): - _parse_and_test_file(args.file_or_dir) + _parse_and_test_file(args.file_or_dir, args.verbose) else: for root, _dirs, files in os.walk(args.file_or_dir, topdown=False): for file in files: @@ -60,9 +104,9 @@ def main(): if ext not in [".tscn", ".tres"]: continue filepath = os.path.join(root, file) - if not _parse_and_test_file(filepath): - sys.exit(1) + if not _parse_and_test_file(filepath, args.verbose) and not args.all: + return 1 if __name__ == "__main__": - main() + sys.exit(main()) From eb0b7b45fac0709c97bc085373837d34c769c0dc Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 01:38:33 -0300 Subject: [PATCH 12/44] TypedArray --- godot_parser/objects.py | 47 ++++++++++++++++++++++++++++++++++++++--- godot_parser/util.py | 2 +- godot_parser/values.py | 18 ++++++++++++++-- tests/test_gdfile.py | 4 ++-- tests/test_objects.py | 18 +++++++++++++++- tests/test_parser.py | 26 ++++++++++++++++++++++- 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index d05a6bb..243ac5f 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -14,6 +14,7 @@ "ExtResource", "SubResource", "StringName", + "TypedArray", "TypedDictionary", ] @@ -252,6 +253,46 @@ def id(self, id: int) -> None: self.args[0] = id +class TypedArray(): + def __init__(self, type, list_) -> None: + self.name = "Array" + self.type = type + self.list_ = list_ + + @classmethod + def WithCustomName(cls: Type[TypedArray], name, type, list_) -> TypedArray: + custom_array = TypedArray(type, list_) + custom_array.name = name + return custom_array + + @classmethod + def from_parser(cls: Type[TypedArray], parse_result) -> TypedArray: + return TypedArray.WithCustomName(*parse_result) + + def __str__(self) -> str: + return "%s[%s](%s)" % ( + self.name, + self.type, + stringify_object(self.list_) + ) + + def __repr__(self) -> str: + return self.__str__() + + def __eq__(self, other) -> bool: + if not isinstance(other, TypedArray): + return False + return self.name == other.name and \ + self.type == other.type and \ + self.list_ == other.list_ + + def __ne__(self, other) -> bool: + return not self.__eq__(other) + + def __hash__(self): + return hash(frozenset((self.name,self.type,self.list_))) + + class TypedDictionary(): def __init__(self, key_type, value_type, dict_) -> None: self.name = "Dictionary" @@ -261,9 +302,9 @@ def __init__(self, key_type, value_type, dict_) -> None: @classmethod def WithCustomName(cls: Type[TypedDictionary], name, key_type, value_type, dict_) -> TypedDictionary: - custom_dict_ = TypedDictionary(key_type, value_type, dict_) - custom_dict_.name = name - return custom_dict_ + custom_dict = TypedDictionary(key_type, value_type, dict_) + custom_dict.name = name + return custom_dict @classmethod def from_parser(cls: Type[TypedDictionary], parse_result) -> TypedDictionary: diff --git a/godot_parser/util.py b/godot_parser/util.py index 468d1b7..ee4b2ae 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -23,7 +23,7 @@ def stringify_object(value): + "\n}" ) elif isinstance(value, list): - return "[ " + ", ".join([stringify_object(v) for v in value]) + " ]" + return "[" + ", ".join([stringify_object(v) for v in value]) + "]" else: return str(value) diff --git a/godot_parser/values.py b/godot_parser/values.py index f8b4b72..f6d8dbc 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -14,7 +14,7 @@ common, ) -from .objects import GDObject, StringName, TypedDictionary +from .objects import GDObject, StringName, TypedDictionary, TypedArray boolean = ( (Keyword("true") | Keyword("false")) @@ -53,6 +53,17 @@ .set_name("list") .set_parse_action(lambda p: p.as_list()) ) + +# Array[StringName]([&"a", &"b", &"c"]) +typed_list = ( + Word(alphas, alphanums).set_results_name("object_name") + + ( + Suppress("[") + + obj_type.set_results_name("type") + + Suppress("]") + ) + Suppress("(") + list_ + Suppress(")") +).set_parse_action(TypedArray.from_parser) + key_val = Group(value + Suppress(":") + value) # { @@ -64,6 +75,9 @@ .set_parse_action(lambda d: {k: v for k, v in d}) ) +# Dictionary[StringName,ExtResource("1_qwert")]({ +# &"_edit_use_anchors_": ExtResource("2_testt") +# }) typed_dict = ( Word(alphas, alphanums).set_results_name("object_name") + ( @@ -77,4 +91,4 @@ # Exports -value <<= list_ | dict_ | typed_dict | obj_ | primitive +value <<= list_ | typed_list | dict_ | typed_dict | obj_ | primitive diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index f22d1a1..bfd86c4 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -27,9 +27,9 @@ def test_all_data_types(self): """[gd_resource load_steps=1 format=3] [resource] -list = [ 1, 2.0, "string" ] +list = [1, 2.0, "string"] map = { -"key": [ "nested", Vector2(1, 1) ] +"key": ["nested", Vector2(1, 1)] } empty = null escaped = "foo(\\"bar\\")" diff --git a/tests/test_objects.py b/tests/test_objects.py index 9b56b9d..b3690b4 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -1,6 +1,6 @@ import unittest -from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName, GDObject, TypedDictionary +from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName, GDObject, TypedDictionary, TypedArray class TestGDObjects(unittest.TestCase): @@ -130,3 +130,19 @@ def test_typed_dictionary(self): self.assertEqual(repr(td), """Dictionary[StringName, ExtResource("1_qwert")]({ &"asd": ExtResource("2_qwert") })""") + + def test_typed_array(self): + """Test for TypedArray""" + list1 = [ + StringName("asd"), + StringName("dsa"), + ] + ta = TypedArray("StringName", list1) + self.assertEqual(repr(ta), """Array[StringName]([&"asd", &"dsa"])""") + + list2 = [ + GDObject("ExtResource", "1_qwert"), + GDObject("ExtResource", "2_testt") + ] + ta = TypedArray("StringName", list2) + self.assertEqual(repr(ta), """Array[StringName]([ExtResource("1_qwert"), ExtResource("2_testt")])""") diff --git a/tests/test_parser.py b/tests/test_parser.py index 0eee5b8..b55d76f 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,7 +3,7 @@ from pyparsing import ParseException -from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse, TypedDictionary +from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse, TypedDictionary, TypedArray HERE = os.path.dirname(__file__) @@ -162,6 +162,30 @@ ) ), ), + ( + """[sub_resource type="CustomType" id=1] + typed_array_1 = Array[StringName]([&"a", &"b", &"c"]) + typed_array_2 = Array[ExtResource("1_typee")]([ExtResource("1_qwert"), ExtResource("2_testt")]) + """ , + GDFile( + GDSection( + GDSectionHeader("sub_resource", type="CustomType", id=1), + **{ + "typed_array_1": TypedArray("StringName", + [ + StringName("a"), + StringName("b"), + StringName("c"), + ]), + "typed_array_2": TypedArray(GDObject("ExtResource", "1_typee"), + [ + GDObject("ExtResource", "1_qwert"), + GDObject("ExtResource", "2_testt"), + ]) + } + ) + ), + ), ( """[node name="Label" type="Label" parent="." unique_id=1387035530] text = "\ta\\\"q\\'é'd\\\"\n\n\\\\" From 31f177a9e8eb6faa29987c32f8fef4f7ddab1df3 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 01:56:11 -0300 Subject: [PATCH 13/44] string output fixes --- godot_parser/sections.py | 2 +- godot_parser/util.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/godot_parser/sections.py b/godot_parser/sections.py index 7f22f5a..0f51e9d 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -137,7 +137,7 @@ def __str__(self) -> str: if self.properties: ret += "\n" + "\n".join( [ - "%s = %s" % (k, stringify_object(v)) + "%s = %s" % ("\""+k+"\"" if ' ' in k else k, stringify_object(v)) for k, v in self.properties.items() ] ) diff --git a/godot_parser/util.py b/godot_parser/util.py index ee4b2ae..4604d16 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -15,6 +15,8 @@ def stringify_object(value): elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): + if len(value) == 0: + return "{}" return ( "{\n" + ",\n".join( From 2fe6e00d20c1779050806ccbeef9c83f5b2c5791 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 02:00:40 -0300 Subject: [PATCH 14/44] Godot4 tests --- tests/test_gdfile.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index bfd86c4..612ac0d 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -243,8 +243,7 @@ def test_file_equality(self): resource["key"] = "value" self.assertNotEqual(s1, s2) - - +class Godot4Test(unittest.TestCase): def test_string_special_characters(self): """ Testing strings with multiple special characters. Currently matching Godot 4.6 behavior From 939a3c2a1fc5e468446d275d560e5d3349fe6305 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 02:13:30 -0300 Subject: [PATCH 15/44] Unescaping test_parse_files output --- test_parse_files.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/test_parse_files.py b/test_parse_files.py index 39fd644..0abf7e0 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -16,7 +16,7 @@ # Regex to detect quotes and possible escape sequences find_quote_re = re.compile(r"(\\*\")") -def join_lines_within_quotes(input: list[str]): +def join_lines_within_quotes(input: list[str], unescape: bool): buffer_list = [] lines = [] buffer = "" @@ -46,6 +46,8 @@ def join_lines_within_quotes(input: list[str]): if i%2 == 0: buffer_list[i] = space_re.sub(" ", buffer_list[i]) buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) + elif unescape: + buffer_list[i] = buffer_list[i].encode('latin-1', 'backslashreplace').decode('unicode-escape') lines.append("".join(buffer_list) + "\n") buffer_list = [] buffer = "" @@ -57,12 +59,14 @@ def join_lines_within_quotes(input: list[str]): if i % 2 == 0: buffer_list[i] = space_re.sub(" ", buffer_list[i]) buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) + elif unescape: + buffer_list[i] = buffer_list[i].encode('latin-1', 'backslashreplace').decode('unicode-escape') lines.append("".join(buffer_list) + "\n") return lines -def _parse_and_test_file(filename: str, verbose: bool) -> bool: +def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: if verbose: print("Parsing %s" % filename) with open(filename, "r") as ifile: @@ -74,8 +78,14 @@ def _parse_and_test_file(filename: str, verbose: bool) -> bool: traceback.print_exc(file=sys.stderr) return False - original_file = join_lines_within_quotes([l.strip() for l in io.StringIO(original_file).readlines() if l.strip()]) - parsed_file = join_lines_within_quotes([l.strip() for l in io.StringIO(str(parsed_file)).readlines() if l.strip()]) + original_file = join_lines_within_quotes( + [l.strip() for l in io.StringIO(original_file).readlines() if l.strip()], + unescape + ) + parsed_file = join_lines_within_quotes( + [l.strip() for l in io.StringIO(str(parsed_file)).readlines() if l.strip()], + unescape + ) diff = difflib.context_diff(original_file, parsed_file, fromfile=filename, tofile="PARSED FILE") diff = [" "+"\n ".join(l.strip().split("\n"))+"\n" for l in diff] @@ -94,18 +104,25 @@ def main(): parser.add_argument("file_or_dir", help="Parse file or files under this directory") parser.add_argument("--all", action='store_true', help="Tests all files even if one fails") parser.add_argument("--verbose", "-v", action='store_true', help="Prints all file paths as they're parsed") + parser.add_argument("--unescape", action='store_true', help="Attempts to unescape strings before comparison (Godot 4.5+ standard)") args = parser.parse_args() if os.path.isfile(args.file_or_dir): - _parse_and_test_file(args.file_or_dir, args.verbose) + _parse_and_test_file(args.file_or_dir, args.verbose, args.unescape) else: + all_passed = True for root, _dirs, files in os.walk(args.file_or_dir, topdown=False): for file in files: ext = os.path.splitext(file)[1] if ext not in [".tscn", ".tres"]: continue filepath = os.path.join(root, file) - if not _parse_and_test_file(filepath, args.verbose) and not args.all: - return 1 + if not _parse_and_test_file(filepath, args.verbose, args.unescape): + all_passed = False + if not args.all: + return 1 + + if all_passed: + print("All tests passed!") if __name__ == "__main__": From d63e13194aba7c3c3a04932602007d395a7f5151 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Wed, 18 Feb 2026 02:16:39 -0300 Subject: [PATCH 16/44] Removed format on GDCommonFile as this is just a small part of a bigger change --- godot_parser/files.py | 12 ++++++------ tests/test_gdfile.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 659c55e..f903198 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -356,9 +356,9 @@ def __ne__(self, other) -> bool: class GDCommonFile(GDFile): """Base class with common application logic for all Godot file types""" - def __init__(self, name: str, *sections: GDSection, _format:int=2) -> None: + def __init__(self, name: str, *sections: GDSection) -> None: super().__init__( - GDSection(GDSectionHeader(name, load_steps=1, format=_format)), *sections + GDSection(GDSectionHeader(name, load_steps=1, format=2)), *sections ) self.load_steps = ( 1 + len(self.get_ext_resources()) + len(self.get_sub_resources()) @@ -449,10 +449,10 @@ def _renumber_resource_ids( class GDScene(GDCommonFile): - def __init__(self, *sections: GDSection, _format: int = 2) -> None: - super().__init__("gd_scene", *sections, _format = _format) + def __init__(self, *sections: GDSection) -> None: + super().__init__("gd_scene", *sections) class GDResource(GDCommonFile): - def __init__(self, *sections: GDSection, _format: int = 2) -> None: - super().__init__("gd_resource", *sections, _format = _format) + def __init__(self, *sections: GDSection) -> None: + super().__init__("gd_resource", *sections) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 612ac0d..0461d30 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -13,7 +13,7 @@ def test_basic_scene(self): def test_all_data_types(self): """Run the parsing test cases""" - res = GDResource(_format = 3) + res = GDResource() res.add_section( GDResourceSection( list=[1, 2.0, "string"], @@ -24,7 +24,7 @@ def test_all_data_types(self): ) self.assertEqual( str(res), - """[gd_resource load_steps=1 format=3] + """[gd_resource load_steps=1 format=2] [resource] list = [1, 2.0, "string"] From 8ea0da7075def5268e3d81a9ee5df957302cf229 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sat, 21 Feb 2026 16:19:39 -0300 Subject: [PATCH 17/44] Renamed GDScene to GDPackedScene, moved node specific functions from GDFile to GDPackedScene, fixed remove_unused_resources to include nested and improved GDResource initialization to allways include a main GDResourceSection --- README.md | 28 ++-- godot_parser/files.py | 323 +++++++++++++++++++++++------------------- godot_parser/util.py | 6 +- tests/test_gdfile.py | 81 ++++++----- tests/test_tree.py | 42 +++--- 5 files changed, 264 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index d36eb4b..586edae 100644 --- a/README.md +++ b/README.md @@ -17,20 +17,20 @@ functionality and make it easier to perform certain tasks. Let's look at an example by creating a new scene file for a Player: ```python - from godot_parser import GDScene, Node - - scene = GDScene() - res = scene.add_ext_resource("res://PlayerSprite.png", "PackedScene") - with scene.use_tree() as tree: - tree.root = Node("Player", type="KinematicBody2D") - tree.root.add_child( - Node( - "Sprite", - type="Sprite", - properties={"texture": res.reference}, - ) - ) - scene.write("Player.tscn") + from godot_parser import GDPackedScene, Node + +scene = GDPackedScene() +res = scene.add_ext_resource("res://PlayerSprite.png", "PackedScene") +with scene.use_tree() as tree: + tree.root = Node("Player", type="KinematicBody2D") + tree.root.add_child( + Node( + "Sprite", + type="Sprite", + properties={"texture": res.reference}, + ) + ) +scene.write("Player.tscn") ``` It's much easier to use the high-level API when it's available, but it doesn't diff --git a/godot_parser/files.py b/godot_parser/files.py index f903198..7843292 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -10,6 +10,7 @@ TypeVar, Union, cast, + Any, ) from .objects import ExtResource, GDObject, SubResource @@ -19,11 +20,12 @@ GDSection, GDSectionHeader, GDSubResourceSection, + GDResourceSection, ) from .structure import scene_file from .util import find_project_root, gdpath_to_filepath -__all__ = ["GDFile", "GDScene", "GDResource"] +__all__ = ["GDFile", "GDPackedScene", "GDResource"] # Scene and resource files seem to group the section types together and sort them. # This is the order I've observed @@ -87,10 +89,6 @@ def get_sections(self, name: Optional[str] = None) -> List[GDSection]: return self._sections return [s for s in self._sections if s.header.name == name] - def get_nodes(self) -> List[GDNodeSection]: - """Get all [node] sections""" - return cast(List[GDNodeSection], self.get_sections("node")) - def get_ext_resources(self) -> List[GDExtResourceSection]: """Get all [ext_resource] sections""" return cast(List[GDExtResourceSection], self.get_sections("ext_resource")) @@ -99,15 +97,6 @@ def get_sub_resources(self) -> List[GDSubResourceSection]: """Get all [sub_resource] sections""" return cast(List[GDSubResourceSection], self.get_sections("sub_resource")) - def find_node( - self, property_constraints: Optional[dict] = None, **constraints - ) -> Optional[GDNodeSection]: - """Find first [node] section that matches (see find_section)""" - return cast( - GDNodeSection, - self.find_section("node", property_constraints, **constraints), - ) - def find_ext_resource( self, property_constraints: Optional[dict] = None, **constraints ) -> Optional[GDExtResourceSection]: @@ -189,118 +178,6 @@ def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: self.add_section(section) return section - def add_node( - self, - name: str, - type: Optional[str] = None, - parent: Optional[str] = None, - index: Optional[int] = None, - instance: Optional[int] = None, - groups: Optional[List[str]] = None, - ) -> GDNodeSection: - """ - Simple API for adding a node - - For a friendlier, tree-oriented API use use_tree() - """ - node = GDNodeSection( - name, - type=type, - parent=parent, - index=index, - instance=instance, - groups=groups, - ) - self.add_section(node) - return node - - def add_ext_node( - self, - name: str, - instance: int, - parent: Optional[str] = None, - index: Optional[int] = None, - ) -> GDNodeSection: - """ - Simple API for adding a node that instances an ext_resource - - For a friendlier, tree-oriented API use use_tree() - """ - node = GDNodeSection.ext_node(name, instance, parent=parent, index=index) - self.add_section(node) - return node - - @property - def is_inherited(self) -> bool: - root = self.find_node(parent=None) - if root is None: - return False - return root.instance is not None - - def get_parent_scene(self) -> Optional[str]: - root = self.find_node(parent=None) - if root is None or root.instance is None: - return None - parent_res = self.find_ext_resource(id=root.instance) - if parent_res is None: - return None - return parent_res.path - - def load_parent_scene(self) -> "GDScene": - if self.project_root is None: - raise RuntimeError( - "load_parent_scene() requires a project_root on the GDFile" - ) - root = self.find_node(parent=None) - if root is None or root.instance is None: - raise RuntimeError("Cannot load parent scene; scene is not inherited") - parent_res = self.find_ext_resource(id=root.instance) - if parent_res is None: - raise RuntimeError( - "Could not find parent scene resource id(%d)" % root.instance - ) - return GDScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) - - @contextmanager - def use_tree(self): - """ - Helper API for working with the nodes in a tree structure - - This temporarily builds the nodes into a tree, and flattens them back into the - GD file format when done. - - Example:: - - with scene.use_tree() as tree: - tree.root = Node('MyScene') - tree.root.add_child(Node('Sensor', type='Area2D')) - tree.root.add_child(Node('HealthBar', instance=1)) - scene.write("MyScene.tscn") - """ - from .tree import Tree - - tree = Tree.build(self) - yield tree - for i in range(len(self._sections) - 1, -1, -1): - section = self._sections[i] - if section.header.name == "node": - self._sections.pop(i) - nodes = tree.flatten() - if not nodes: - return - # Let's find out where the root node belongs and then bulk add the rest at that - # index - i = self.add_section(nodes[0]) - self._sections[i + 1 : i + 1] = nodes[1:] - - def get_node(self, path: str = ".") -> Optional[GDNodeSection]: - """Mimics the Godot get_node API""" - with self.use_tree() as tree: - if tree.root is None: - return None - node = tree.root.get_node(path) - return node.section if node is not None else None - @classmethod def parse(cls: Type[GDFileType], contents: str) -> GDFileType: """Parse the contents of a Godot file""" @@ -323,7 +200,7 @@ def load(cls: Type[GDFileType], filepath: str) -> GDFileType: def from_parser(cls: Type[GDFileType], parse_result): first_section = parse_result[0] if first_section.header.name == "gd_scene": - scene = GDScene.__new__(GDScene) + scene = GDPackedScene.__new__(GDPackedScene) scene._sections = list(parse_result) return scene elif first_section.header.name == "gd_resource": @@ -392,21 +269,25 @@ def _remove_unused_resources( sections: Sequence[Union[GDExtResourceSection, GDSubResourceSection]], reference_type: Type[Union[ExtResource, SubResource]], ) -> None: - seen = set() - for ref in self._iter_node_resource_references(): - if isinstance(ref, reference_type): - seen.add(ref.id) - if len(seen) < len(sections): - to_remove = [s for s in sections if s.id not in seen] - for s in to_remove: - self.remove_section(s) + done_removing = False + while not done_removing: + done_removing = True + seen = set() + for ref in self._iter_resource_references(): + if isinstance(ref, reference_type): + seen.add(ref.id) + if len(seen) < len(sections): + to_remove = [s for s in sections if s.id not in seen] + for s in to_remove: + if self.remove_section(s): + done_removing = False def renumber_resource_ids(self): """Refactor all resource IDs to be sequential with no gaps""" self._renumber_resource_ids(self.get_ext_resources(), ExtResource) self._renumber_resource_ids(self.get_sub_resources(), SubResource) - def _iter_node_resource_references( + def _iter_resource_references( self, ) -> Iterator[Union[ExtResource, SubResource]]: def iter_resources(value): @@ -416,17 +297,20 @@ def iter_resources(value): for v in value: yield from iter_resources(v) elif isinstance(value, dict): + for k in value.keys(): + yield from iter_resources(k) for v in value.values(): yield from iter_resources(v) elif isinstance(value, GDObject): for v in value.args: yield from iter_resources(v) - for node in self.get_nodes(): - yield from iter_resources(node.header.attributes) - yield from iter_resources(node.properties) + for reference in self._iter_references(): + yield from iter_resources(reference) + + def _iter_references(self) -> Iterator[Any]: for resource in self.get_sections("resource"): - yield from iter_resources(resource.properties) + yield resource.properties def _renumber_resource_ids( self, @@ -440,7 +324,7 @@ def _renumber_resource_ids( section.id = i + 1 # Now we update all references to use the new number - for ref in self._iter_node_resource_references(): + for ref in self._iter_resource_references(): if isinstance(ref, reference_type): try: ref.id = id_map[ref.id] @@ -448,11 +332,160 @@ def _renumber_resource_ids( raise GodotFileException("Unknown resource ID %d" % ref.id) from e -class GDScene(GDCommonFile): +class GDResource(GDCommonFile): + def __init__(self, type: Optional[str] = None, *sections: GDSection, **attributes) -> None: + super().__init__("gd_resource", *sections) + + if type is not None: + self._sections[0].header["type"] = type + + self.resource_section = GDResourceSection(**attributes) + self.add_section(self.resource_section) + + def __getitem__(self, k: str) -> Any: + return self.resource_section[k] + + def __setitem__(self, k: str, v: Any) -> None: + self.resource_section[k] = v + + def __delitem__(self, k: str) -> None: + try: + del self.resource_section[k] + except KeyError: + pass + + +class GDPackedScene(GDCommonFile): def __init__(self, *sections: GDSection) -> None: super().__init__("gd_scene", *sections) + def get_nodes(self) -> List[GDNodeSection]: + """Get all [node] sections""" + return cast(List[GDNodeSection], self.get_sections("node")) -class GDResource(GDCommonFile): - def __init__(self, *sections: GDSection) -> None: - super().__init__("gd_resource", *sections) + def find_node( + self, property_constraints: Optional[dict] = None, **constraints + ) -> Optional[GDNodeSection]: + """Find first [node] section that matches (see find_section)""" + return cast( + GDNodeSection, + self.find_section("node", property_constraints, **constraints), + ) + + def add_node( + self, + name: str, + type: Optional[str] = None, + parent: Optional[str] = None, + index: Optional[int] = None, + instance: Optional[int] = None, + groups: Optional[List[str]] = None, + ) -> GDNodeSection: + """ + Simple API for adding a node + + For a friendlier, tree-oriented API use use_tree() + """ + node = GDNodeSection( + name, + type=type, + parent=parent, + index=index, + instance=instance, + groups=groups, + ) + self.add_section(node) + return node + + def add_ext_node( + self, + name: str, + instance: int, + parent: Optional[str] = None, + index: Optional[int] = None, + ) -> GDNodeSection: + """ + Simple API for adding a node that instances an ext_resource + + For a friendlier, tree-oriented API use use_tree() + """ + node = GDNodeSection.ext_node(name, instance, parent=parent, index=index) + self.add_section(node) + return node + + @property + def is_inherited(self) -> bool: + root = self.find_node(parent=None) + if root is None: + return False + return root.instance is not None + + def get_parent_scene(self) -> Optional[str]: + root = self.find_node(parent=None) + if root is None or root.instance is None: + return None + parent_res = self.find_ext_resource(id=root.instance) + if parent_res is None: + return None + return parent_res.path + + def load_parent_scene(self) -> "GDPackedScene": + if self.project_root is None: + raise RuntimeError( + "load_parent_scene() requires a project_root on the GDFile" + ) + root = self.find_node(parent=None) + if root is None or root.instance is None: + raise RuntimeError("Cannot load parent scene; scene is not inherited") + parent_res = self.find_ext_resource(id=root.instance) + if parent_res is None: + raise RuntimeError( + "Could not find parent scene resource id(%d)" % root.instance + ) + return GDPackedScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) + + @contextmanager + def use_tree(self): + """ + Helper API for working with the nodes in a tree structure + + This temporarily builds the nodes into a tree, and flattens them back into the + GD file format when done. + + Example:: + + with scene.use_tree() as tree: + tree.root = Node('MyScene') + tree.root.add_child(Node('Sensor', type='Area2D')) + tree.root.add_child(Node('HealthBar', instance=1)) + scene.write("MyScene.tscn") + """ + from .tree import Tree + + tree = Tree.build(self) + yield tree + for i in range(len(self._sections) - 1, -1, -1): + section = self._sections[i] + if section.header.name == "node": + self._sections.pop(i) + nodes = tree.flatten() + if not nodes: + return + # Let's find out where the root node belongs and then bulk add the rest at that + # index + i = self.add_section(nodes[0]) + self._sections[i + 1 : i + 1] = nodes[1:] + + def get_node(self, path: str = ".") -> Optional[GDNodeSection]: + """Mimics the Godot get_node API""" + with self.use_tree() as tree: + if tree.root is None: + return None + node = tree.root.get_node(path) + return node.section if node is not None else None + + def _iter_references(self) -> Iterator[Any]: + yield from super()._iter_references() + for node in self.get_nodes(): + yield node.header.attributes + yield node.properties diff --git a/godot_parser/util.py b/godot_parser/util.py index 4604d16..3b9e561 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -1,6 +1,5 @@ """Utils""" -import json import os from typing import Optional @@ -10,7 +9,6 @@ def stringify_object(value): if value is None: return "null" elif isinstance(value, str): - #return json.dumps(value, ensure_ascii=False) return "\"%s\"" % value.replace("\\","\\\\").replace("\"", "\\\"") elif isinstance(value, bool): return "true" if value else "false" @@ -37,14 +35,14 @@ def find_project_root(start: str) -> Optional[str]: while True: if os.path.isfile(os.path.join(curdir, "project.godot")): return curdir - next_dir = os.path.realpath(os.path.join(curdir, os.pardir)) + next_dir = os.path.dirname(curdir) if next_dir == curdir: return None curdir = next_dir def gdpath_to_filepath(root: str, path: str) -> str: - if not path.startswith("res://"): + if not is_gd_path(path): raise ValueError("'%s' is not a godot resource path" % path) pieces = path[6:].split("/") return os.path.join(root, *pieces) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 0461d30..cbfb9c5 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -1,7 +1,7 @@ import tempfile import unittest -from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDScene, Node +from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDPackedScene, Node, SubResource class TestGDFile(unittest.TestCase): @@ -9,18 +9,15 @@ class TestGDFile(unittest.TestCase): def test_basic_scene(self): """Run the parsing test cases""" - self.assertEqual(str(GDScene()), "[gd_scene load_steps=1 format=2]\n") + self.assertEqual(str(GDPackedScene()), "[gd_scene load_steps=1 format=2]\n") def test_all_data_types(self): """Run the parsing test cases""" - res = GDResource() - res.add_section( - GDResourceSection( - list=[1, 2.0, "string"], - map={"key": ["nested", GDObject("Vector2", 1, 1)]}, - empty=None, - escaped='foo("bar")', - ) + res = GDResource( + list=[1, 2.0, "string"], + map={"key": ["nested", GDObject("Vector2", 1, 1)]}, + empty=None, + escaped='foo("bar")', ) self.assertEqual( str(res), @@ -38,7 +35,7 @@ def test_all_data_types(self): def test_ext_resource(self): """Test serializing a scene with an ext_resource""" - scene = GDScene() + scene = GDPackedScene() scene.add_ext_resource("res://Other.tscn", "PackedScene") self.assertEqual( str(scene), @@ -50,7 +47,7 @@ def test_ext_resource(self): def test_sub_resource(self): """Test serializing a scene with an sub_resource""" - scene = GDScene() + scene = GDPackedScene() scene.add_sub_resource("Animation") self.assertEqual( str(scene), @@ -62,7 +59,7 @@ def test_sub_resource(self): def test_node(self): """Test serializing a scene with a node""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode", type="Node2D") scene.add_node("Child", type="Area2D", parent=".") self.assertEqual( @@ -77,7 +74,7 @@ def test_node(self): def test_tree_create(self): """Test creating a scene with the tree API""" - scene = GDScene() + scene = GDPackedScene() with scene.use_tree() as tree: tree.root = Node("RootNode", type="Node2D") tree.root.add_child( @@ -96,7 +93,7 @@ def test_tree_create(self): def test_tree_deep_create(self): """Test creating a scene with nested children using the tree API""" - scene = GDScene() + scene = GDPackedScene() with scene.use_tree() as tree: tree.root = Node("RootNode", type="Node2D") child = Node("Child", type="Node") @@ -130,7 +127,7 @@ def test_remove_section(self): def test_section_ordering(self): """Sections maintain an ordering""" - scene = GDScene() + scene = GDPackedScene() node = scene.add_node("RootNode") scene.add_ext_resource("res://Other.tscn", "PackedScene") res = scene.find_section("ext_resource") @@ -138,7 +135,7 @@ def test_section_ordering(self): def test_add_ext_node(self): """Test GDScene.add_ext_node""" - scene = GDScene() + scene = GDPackedScene() res = scene.add_ext_resource("res://Other.tscn", "PackedScene") node = scene.add_ext_node("Root", res.id) self.assertEqual(node.name, "Root") @@ -146,22 +143,22 @@ def test_add_ext_node(self): def test_write(self): """Test writing scene out to a file""" - scene = GDScene() + scene = GDPackedScene() outfile = tempfile.mkstemp()[1] scene.write(outfile) with open(outfile, "r", encoding="utf-8") as ifile: - gen_scene = GDScene.parse(ifile.read()) + gen_scene = GDPackedScene.parse(ifile.read()) self.assertEqual(scene, gen_scene) def test_get_node_none(self): """get_node() works with no nodes""" - scene = GDScene() + scene = GDPackedScene() n = scene.get_node() self.assertIsNone(n) def test_addremove_ext_res(self): """Test adding and removing an ext_resource""" - scene = GDScene() + scene = GDPackedScene() res = scene.add_ext_resource("res://Res.tscn", "PackedScene") self.assertEqual(res.id, 1) res2 = scene.add_ext_resource("res://Sprite.png", "Texture") @@ -185,7 +182,7 @@ def test_addremove_ext_res(self): def test_remove_unused_resource(self): """Can remove unused resources""" - scene = GDScene() + scene = GDPackedScene() res = scene.add_ext_resource("res://Res.tscn", "PackedScene") scene.remove_unused_resources() resources = scene.get_sections("ext_resource") @@ -209,9 +206,34 @@ def test_addremove_sub_res(self): self.assertEqual(s.id, 1) self.assertEqual(resource["shape"], s.reference) + def test_remove_unused_nested(self): + res = GDResource("CustomResource") + + res1 = res.add_sub_resource("CustomResource") + res["child_resource"] = SubResource(1) + + res2 = res.add_sub_resource("CustomResource") + res1["child_resource"] = SubResource(2) + + res3 = res.add_sub_resource("CustomResource") + res2["child_resource"] = SubResource(3) + + self.assertEqual(len(res._sections), 5) + self.assertIn(res1, res._sections) + self.assertIn(res2, res._sections) + self.assertIn(res3, res._sections) + + del res1["child_resource"] + res.remove_unused_resources() + + self.assertEqual(len(res._sections), 3) + self.assertIn(res1, res._sections) + self.assertNotIn(res2, res._sections) + self.assertNotIn(res3, res._sections) + def test_find_constraints(self): """Test for the find_section constraints""" - scene = GDScene() + scene = GDPackedScene() res1 = scene.add_sub_resource("CircleShape2D", radius=1) res2 = scene.add_sub_resource("CircleShape2D", radius=2) @@ -226,7 +248,7 @@ def test_find_constraints(self): def test_find_node(self): """Test GDScene.find_node""" - scene = GDScene() + scene = GDPackedScene() n1 = scene.add_node("Root", "Node") n2 = scene.add_node("Child", "Node", parent=".") node = scene.find_node(name="Root") @@ -236,8 +258,8 @@ def test_find_node(self): def test_file_equality(self): """Tests for GDFile == GDFile""" - s1 = GDScene(GDResourceSection()) - s2 = GDScene(GDResourceSection()) + s1 = GDPackedScene(GDResourceSection()) + s2 = GDPackedScene(GDResourceSection()) self.assertEqual(s1, s2) resource = s1.find_section("resource") resource["key"] = "value" @@ -251,12 +273,7 @@ def test_string_special_characters(self): Tab handling is done by calling parse_with_tabs before parse_string For this reason, this test is being done at a GDFile level, where this method is called upon parsing """ - res = GDResource() - res.add_section( - GDResourceSection( - str_value="\ta\"q\'é'd\"\n\n\\", - ) - ) + res = GDResource(str_value = "\ta\"q\'é'd\"\n\n\\") self.assertEqual(str(res), """[gd_resource load_steps=1 format=2] [resource] diff --git a/tests/test_tree.py b/tests/test_tree.py index b0cf47b..fbf22f0 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -3,7 +3,7 @@ import tempfile import unittest -from godot_parser import GDScene, Node, SubResource, TreeMutationException +from godot_parser import GDPackedScene, Node, SubResource, TreeMutationException from godot_parser.util import find_project_root, gdpath_to_filepath @@ -12,7 +12,7 @@ class TestTree(unittest.TestCase): def test_get_node(self): """Test for get_node()""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") scene.add_node("Child", parent=".") child = scene.add_node("Child2", parent="Child") @@ -21,7 +21,7 @@ def test_get_node(self): def test_remove_node(self): """Test for remove_node()""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") scene.add_node("Child", parent=".") node = scene.find_section("node", name="Child") @@ -58,7 +58,7 @@ def test_remove_node(self): def test_insert_child(self): """Test for insert_child()""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") scene.add_node("Child1", parent=".") with scene.use_tree() as tree: @@ -72,21 +72,21 @@ def test_insert_child(self): def test_empty_scene(self): """Empty scenes should not crash""" - scene = GDScene() + scene = GDPackedScene() with scene.use_tree() as tree: n = tree.get_node("Any") self.assertIsNone(n) def test_get_missing_node(self): """get_node on missing node should return None""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") node = scene.get_node("Foo/Bar/Baz") self.assertIsNone(node) def test_properties(self): """Test for changing properties on a node""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") with scene.use_tree() as tree: tree.root["vframes"] = 10 @@ -124,7 +124,7 @@ def setUpClass(cls): cls.root_scene = os.path.join(cls.project_dir, "Root.tscn") cls.mid_scene = os.path.join(cls.project_dir, "Mid.tscn") cls.leaf_scene = os.path.join(cls.project_dir, "Leaf.tscn") - scene = GDScene.parse( + scene = GDPackedScene.parse( """ [gd_scene load_steps=1 format=2] [node name="Root" type="KinematicBody2D"] @@ -139,7 +139,7 @@ def setUpClass(cls): ) scene.write(cls.root_scene) - scene = GDScene.parse( + scene = GDPackedScene.parse( """ [gd_scene load_steps=2 format=2] [ext_resource path="res://Root.tscn" type="PackedScene" id=1] @@ -151,7 +151,7 @@ def setUpClass(cls): ) scene.write(cls.mid_scene) - scene = GDScene.parse( + scene = GDPackedScene.parse( """ [gd_scene load_steps=2 format=2] [ext_resource path="res://Mid.tscn" type="PackedScene" id=1] @@ -172,7 +172,7 @@ def tearDownClass(cls): def test_load_inherited(self): """Can load an inherited scene and read the nodes""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: node = tree.get_node("Health/LifeBar") self.assertIsNotNone(node) @@ -180,7 +180,7 @@ def test_load_inherited(self): def test_add_new_nodes(self): """Can add new nodes to an inherited scene""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: tree.get_node("Health/LifeBar") node = Node("NewChild", type="Control") @@ -197,7 +197,7 @@ def test_add_new_nodes(self): def test_cannot_remove(self): """Cannot remove inherited nodes""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: node = tree.get_node("Health") self.assertRaises(TreeMutationException, node.remove_from_parent) @@ -208,7 +208,7 @@ def test_cannot_remove(self): def test_cannot_mutate(self): """Cannot change the name/type/instance of inherited nodes""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) def change_name(x): x.name = "foo" @@ -227,7 +227,7 @@ def change_instance(x): def test_inherit_properties(self): """Inherited nodes inherit properties""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: self.assertEqual(tree.root["shape"], SubResource(1)) self.assertEqual(tree.root["collision_layer"], 4) @@ -237,7 +237,7 @@ def test_inherit_properties(self): def test_unchanged_sections(self): """Inherited nodes do not appear in sections""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) num_nodes = len(scene.get_nodes()) self.assertEqual(num_nodes, 2) with scene.use_tree() as tree: @@ -249,7 +249,7 @@ def test_unchanged_sections(self): def test_overwrite_sections(self): """Inherited nodes appear in sections if we change their configuration""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: node = tree.get_node("Health/LifeBar") node["pause_mode"] = 2 @@ -260,7 +260,7 @@ def test_overwrite_sections(self): def test_disappear_sections(self): """Inherited nodes are removed from sections if we change their configuration to match parent""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: sprite = tree.get_node("Sprite") sprite["flip_h"] = False @@ -278,20 +278,20 @@ def test_find_project_root(self): def test_invalid_tree(self): """Raise exception when tree is invalid""" - scene = GDScene() + scene = GDPackedScene() scene.add_node("RootNode") scene.add_node("Child", parent="Missing") self.assertRaises(TreeMutationException, lambda: scene.get_node("Child")) def test_missing_root(self): """Raise exception when GDScene is inherited but missing project_root""" - scene = GDScene() + scene = GDPackedScene() scene.add_ext_node("Root", 1) self.assertRaises(RuntimeError, lambda: scene.get_node("Root")) def test_missing_ext_resource(self): """Raise exception when GDScene is inherited but ext_resource is missing""" - scene = GDScene.load(self.leaf_scene) + scene = GDPackedScene.load(self.leaf_scene) for section in scene.get_ext_resources(): scene.remove_section(section) self.assertRaises(RuntimeError, lambda: scene.get_node("Root")) From 64cdcff962bd84785e3032dac034a847c1089d7c Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sat, 21 Feb 2026 23:04:14 -0300 Subject: [PATCH 18/44] test_parser using subTest --- tests/test_parser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_parser.py b/tests/test_parser.py index b55d76f..f4f0ac5 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -231,4 +231,5 @@ def _run_test(self, string: str, expected): def test_cases(self): """Run the parsing test cases""" for string, expected in TEST_CASES: - self._run_test(string, expected) + with self.subTest(string): + self._run_test(string, expected) From 6a2cc43d151faa238e18df9db4f2231665d73910 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 16:30:30 -0300 Subject: [PATCH 19/44] ObjectFormat class and support for toggling puctuation spaces when outputing to string --- godot_parser/files.py | 59 ++++++++++++++++-------------- godot_parser/objects.py | 73 +++++++++++++++++-------------------- godot_parser/output.py | 70 +++++++++++++++++++++++++++++++++++ godot_parser/sections.py | 46 +++++++++++++---------- godot_parser/tree.py | 36 +++++++++--------- godot_parser/util.py | 12 +++--- requirements_dev.txt | 1 + tests/test_gdfile.py | 16 ++++---- tests/test_sections.py | 1 - tests/test_tree.py | 3 +- tests/test_versions.py | 79 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 274 insertions(+), 122 deletions(-) create mode 100644 godot_parser/output.py create mode 100644 tests/test_versions.py diff --git a/godot_parser/files.py b/godot_parser/files.py index 7843292..5d0ba4d 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -14,6 +14,7 @@ ) from .objects import ExtResource, GDObject, SubResource +from .output import OutputFormat, Outputable from .sections import ( GDExtResourceSection, GDNodeSection, @@ -48,7 +49,7 @@ class GodotFileException(Exception): """Thrown when there are errors in a Godot file""" -class GDFile(object): +class GDFile(Outputable): """Base class representing the contents of a Godot file""" project_root: Optional[str] = None @@ -179,12 +180,12 @@ def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: return section @classmethod - def parse(cls: Type[GDFileType], contents: str) -> GDFileType: + def parse(cls: Type[GDFile], contents: str) -> GDFile: """Parse the contents of a Godot file""" return cls.from_parser(scene_file.parse_with_tabs().parse_string(contents, parse_all=True)) @classmethod - def load(cls: Type[GDFileType], filepath: str) -> GDFileType: + def load(cls: Type[GDFile], filepath: str) -> GDFile: with open(filepath, "r", encoding="utf-8") as ifile: try: file = cls.parse(ifile.read()) @@ -197,7 +198,7 @@ def load(cls: Type[GDFileType], filepath: str) -> GDFileType: return file @classmethod - def from_parser(cls: Type[GDFileType], parse_result): + def from_parser(cls: Type[GDFile], parse_result) -> GDFile: first_section = parse_result[0] if first_section.header.name == "gd_scene": scene = GDPackedScene.__new__(GDPackedScene) @@ -215,8 +216,8 @@ def write(self, filename: str): with open(filename, "w", encoding="utf-8") as ofile: ofile.write(str(self)) - def __str__(self) -> str: - return "\n\n".join([str(s) for s in self._sections]) + "\n" + def _output_to_string(self, output_format : OutputFormat) -> str: + return "\n\n".join([s.output_to_string(output_format) for s in self._sections]) + "\n" def __repr__(self) -> str: return "%s(%s)" % (type(self).__name__, self.__str__()) @@ -234,31 +235,35 @@ class GDCommonFile(GDFile): """Base class with common application logic for all Godot file types""" def __init__(self, name: str, *sections: GDSection) -> None: - super().__init__( - GDSection(GDSectionHeader(name, load_steps=1, format=2)), *sections - ) - self.load_steps = ( - 1 + len(self.get_ext_resources()) + len(self.get_sub_resources()) - ) + super().__init__(GDSection(GDSectionHeader(name)), *sections) + + def _output_to_string(self, output_format : OutputFormat) -> str: + header = self._sections[0].header + if "load_steps" in header: + del header["load_steps"] + if "format" in header: + del header["format"] + + if output_format.load_steps: + header["load_steps"] = ( + 1 + len(self.get_ext_resources()) + len(self.get_sub_resources()) + ) - @property - def load_steps(self) -> int: - return self._sections[0].header["load_steps"] + if output_format.resource_ids_as_strings: + header["format"] = 3 + else: + header["format"] = 2 + + ret = super()._output_to_string(output_format) - @load_steps.setter - def load_steps(self, steps: int): - self._sections[0].header["load_steps"] = steps + return ret def add_section(self, new_section: GDSection) -> int: idx = super().add_section(new_section) - if new_section.header.name in ["ext_resource", "sub_resource"]: - self.load_steps += 1 return idx def remove_at(self, index: int): - section = self._sections.pop(index) - if section.header.name in ["ext_resource", "sub_resource"]: - self.load_steps -= 1 + self._sections.pop(index) def remove_unused_resources(self): self._remove_unused_resources(self.get_ext_resources(), ExtResource) @@ -342,6 +347,9 @@ def __init__(self, type: Optional[str] = None, *sections: GDSection, **attribute self.resource_section = GDResourceSection(**attributes) self.add_section(self.resource_section) + def __contains__(self, k: str) -> bool: + return k in self.resource_section + def __getitem__(self, k: str) -> Any: return self.resource_section[k] @@ -349,10 +357,7 @@ def __setitem__(self, k: str, v: Any) -> None: self.resource_section[k] = v def __delitem__(self, k: str) -> None: - try: - del self.resource_section[k] - except KeyError: - pass + del self.resource_section[k] class GDPackedScene(GDCommonFile): diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 243ac5f..ed9b995 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,8 +1,9 @@ """Wrappers for Godot's non-primitive object types""" from functools import partial -from typing import Type, TypeVar +from typing import Type, TypeVar, Optional +from .output import Outputable, OutputFormat from .util import stringify_object __all__ = [ @@ -38,7 +39,7 @@ def __new__(cls, name, bases, dct): GDObjectType = TypeVar("GDObjectType", bound="GDObject") -class GDObject(metaclass=GDObjectMeta): +class GDObject(Outputable, metaclass=GDObjectMeta): """ Base class for all GD Object types @@ -51,16 +52,27 @@ def __init__(self, name, *args) -> None: self.name = name self.args = list(args) + def __contains__(self, idx: int) -> bool: + return idx in self.args + + def __getitem__(self, idx: int) -> float: + return self.args[idx] + + def __setitem__(self, idx: int, value: float) -> None: + self.args[idx] = value + + def __delitem__(self, idx: int) -> None: + del self.args[idx] + @classmethod def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: name = parse_result[0] factory = GD_OBJECT_REGISTRY.get(name, partial(GDObject, name)) return factory(*parse_result[1:]) - def __str__(self) -> str: - return "%s(%s)" % ( - self.name, - ", ".join([stringify_object(v) for v in self.args]), + def _output_to_string(self, output_format : OutputFormat) -> str: + return self.name + output_format.surround_parentheses( + ", ".join([stringify_object(v, output_format) for v in self.args]) ) def __repr__(self) -> str: @@ -82,12 +94,6 @@ class Vector2(GDObject): def __init__(self, x: float, y: float) -> None: super().__init__("Vector2", x, y) - def __getitem__(self, idx) -> float: - return self.args[idx] - - def __setitem__(self, idx: int, value: float): - self.args[idx] = value - @property def x(self) -> float: """Getter for x""" @@ -113,12 +119,6 @@ class Vector3(GDObject): def __init__(self, x: float, y: float, z: float) -> None: super().__init__("Vector3", x, y, z) - def __getitem__(self, idx: int) -> float: - return self.args[idx] - - def __setitem__(self, idx: int, value: float) -> None: - self.args[idx] = value - @property def x(self) -> float: """Getter for x""" @@ -158,12 +158,6 @@ def __init__(self, r: float, g: float, b: float, a: float) -> None: assert 0 <= a <= 1 super().__init__("Color", r, g, b, a) - def __getitem__(self, idx: int) -> float: - return self.args[idx] - - def __setitem__(self, idx: int, value: float) -> None: - self.args[idx] = value - @property def r(self) -> float: """Getter for r""" @@ -253,7 +247,7 @@ def id(self, id: int) -> None: self.args[0] = id -class TypedArray(): +class TypedArray(Outputable): def __init__(self, type, list_) -> None: self.name = "Array" self.type = type @@ -269,11 +263,11 @@ def WithCustomName(cls: Type[TypedArray], name, type, list_) -> TypedArray: def from_parser(cls: Type[TypedArray], parse_result) -> TypedArray: return TypedArray.WithCustomName(*parse_result) - def __str__(self) -> str: - return "%s[%s](%s)" % ( - self.name, - self.type, - stringify_object(self.list_) + def _output_to_string(self, output_format : OutputFormat) -> str: + return ( + self.name + + output_format.surround_brackets(self.type) + + output_format.surround_parentheses(stringify_object(self.list_, output_format)) ) def __repr__(self) -> str: @@ -293,7 +287,7 @@ def __hash__(self): return hash(frozenset((self.name,self.type,self.list_))) -class TypedDictionary(): +class TypedDictionary(Outputable): def __init__(self, key_type, value_type, dict_) -> None: self.name = "Dictionary" self.key_type = key_type @@ -310,12 +304,11 @@ def WithCustomName(cls: Type[TypedDictionary], name, key_type, value_type, dict_ def from_parser(cls: Type[TypedDictionary], parse_result) -> TypedDictionary: return TypedDictionary.WithCustomName(*parse_result) - def __str__(self) -> str: - return "%s[%s, %s](%s)" % ( - self.name, - self.key_type, - self.value_type, - stringify_object(self.dict_) + def _output_to_string(self, output_format : OutputFormat) -> str: + return ( + self.name + + output_format.surround_brackets("%s, %s" % (self.key_type,self.value_type)) + + output_format.surround_parentheses(stringify_object(self.dict_, output_format)) ) def __repr__(self) -> str: @@ -335,7 +328,7 @@ def __ne__(self, other) -> bool: def __hash__(self): return hash(frozenset((self.name,self.key_type,self.value_type,self.dict_))) -class StringName(): +class StringName(Outputable): def __init__(self, str) -> None: self.str = str @@ -343,8 +336,8 @@ def __init__(self, str) -> None: def from_parser(cls: Type[StringName], parse_result) -> StringName: return StringName(parse_result[0]) - def __str__(self) -> str: - return "&" + stringify_object(self.str) + def _output_to_string(self, output_format : OutputFormat) -> str: + return "&" + stringify_object(self.str, output_format) def __repr__(self) -> str: return self.__str__() diff --git a/godot_parser/output.py b/godot_parser/output.py new file mode 100644 index 0000000..fd48106 --- /dev/null +++ b/godot_parser/output.py @@ -0,0 +1,70 @@ +from typing import Union, Tuple, Optional + +from packaging.version import Version + +class OutputFormat(object): + def __init__(self, + punctuation_spaces : bool = False, + resource_ids_as_strings: bool = True, + explicit_typed_array: bool = True, + packed_byte_array_base64_support : bool = True, + explicit_typed_dictionary: bool = True, + load_steps: bool = False): + self.punctuation_spaces = punctuation_spaces + self.resource_ids_as_strings = resource_ids_as_strings + self.explicit_typed_array = explicit_typed_array + self.packed_byte_array_base64_support = packed_byte_array_base64_support + self.explicit_typed_dictionary = explicit_typed_dictionary + self.load_steps = load_steps + + def surround_string(self, punctuation : Union[str, Tuple[str, str]], content : str) -> str: + if punctuation is str: + right = left = punctuation + else: + left = punctuation[0] + right = punctuation[1] + + if self.punctuation_spaces: + left += " " + right = " " + right + + return left + content + right + + def surround_parentheses(self, content: str) -> str: + return self.surround_string(("(",")"), content) + + def surround_brackets(self, content: str) -> str: + return self.surround_string(("[","]"), content) + +class VersionOutputFormat(OutputFormat): + __V40 = Version("4.0") + __V43 = Version("4.3") + __V44 = Version("4.4") + __V46 = Version("4.6") + + def __init__(self, version : Union[str, Version]): + if version is not Version: + version = Version(version) + + self.version = version + + super().__init__( + punctuation_spaces = version < self.__V40, + resource_ids_as_strings = version >= self.__V40, + explicit_typed_array = version >= self.__V40, + packed_byte_array_base64_support = version >= self.__V43, + explicit_typed_dictionary = version >= self.__V44, + load_steps = version < self.__V46, + ) + +class Outputable(object): + def _output_to_string(self, output_format : OutputFormat) -> str: + raise NotImplementedError("output_to_string method not defined in %s" % type(self)) + + def output_to_string(self, output_format : Optional[OutputFormat] = None) -> str: + if output_format is None: + output_format = OutputFormat() + return self._output_to_string(output_format) + + def __str__(self) -> str: + return self.output_to_string() \ No newline at end of file diff --git a/godot_parser/sections.py b/godot_parser/sections.py index 0f51e9d..ccc7435 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -3,6 +3,7 @@ from typing import Any, List, Optional, Type, TypeVar from .objects import ExtResource, SubResource +from .output import OutputFormat, Outputable from .util import stringify_object __all__ = [ @@ -18,7 +19,7 @@ GD_SECTION_REGISTRY = {} -class GDSectionHeader(object): +class GDSectionHeader(Outputable): """ Represents the header for a section @@ -33,6 +34,9 @@ def __init__(self, _name: str, **kwargs) -> None: for k, v in kwargs.items(): self.attributes[k] = v + def __contains__(self, k: str) -> bool: + return k in self.attributes + def __getitem__(self, k: str) -> Any: return self.attributes[k] @@ -40,10 +44,7 @@ def __setitem__(self, k: str, v: Any) -> None: self.attributes[k] = v def __delitem__(self, k: str): - try: - del self.attributes[k] - except KeyError: - pass + del self.attributes[k] def get(self, k: str, default: Any = None) -> Any: return self.attributes.get(k, default) @@ -55,11 +56,11 @@ def from_parser(cls: Type["GDSectionHeader"], parse_result) -> "GDSectionHeader" header.attributes[attribute[0]] = attribute[1] return header - def __str__(self) -> str: + def _output_to_string(self, output_format : OutputFormat) -> str: attribute_str = "" if self.attributes: attribute_str = " " + " ".join( - ["%s=%s" % (k, stringify_object(v)) for k, v in self.attributes.items()] + ["%s=%s" % (k, stringify_object(v, output_format)) for k, v in self.attributes.items()] ) return "[" + self.name + attribute_str + "]" @@ -89,7 +90,7 @@ def __new__(cls, name, bases, dct): GDSectionType = TypeVar("GDSectionType", bound="GDSection") -class GDSection(metaclass=GDSectionMeta): +class GDSection(Outputable, metaclass=GDSectionMeta): """ Represents a full section of a GD file @@ -106,6 +107,9 @@ def __init__(self, header: GDSectionHeader, **kwargs) -> None: for k, v in kwargs.items(): self.properties[k] = v + def __contains__(self, k: str) -> bool: + return k in self.properties + def __getitem__(self, k: str) -> Any: return self.properties[k] @@ -113,10 +117,7 @@ def __setitem__(self, k: str, v: Any) -> None: self.properties[k] = v def __delitem__(self, k: str) -> None: - try: - del self.properties[k] - except KeyError: - pass + del self.properties[k] def get(self, k: str, default: Any = None) -> Any: return self.properties.get(k, default) @@ -132,12 +133,12 @@ def from_parser(cls: Type[GDSectionType], parse_result) -> GDSectionType: section[k] = v return section - def __str__(self) -> str: - ret = str(self.header) + def _output_to_string(self, output_format : OutputFormat) -> str: + ret = self.header.output_to_string(output_format) if self.properties: ret += "\n" + "\n".join( [ - "%s = %s" % ("\""+k+"\"" if ' ' in k else k, stringify_object(v)) + "%s = %s" % ("\"" + k + "\"" if ' ' in k else k, stringify_object(v, output_format)) for k, v in self.properties.items() ] ) @@ -270,7 +271,8 @@ def type(self) -> Optional[str]: @type.setter def type(self, type: Optional[str]) -> None: if type is None: - del self.header["type"] + if "type" in self.header: + del self.header["type"] else: self.header["type"] = type self.instance = None @@ -282,7 +284,8 @@ def parent(self) -> Optional[str]: @parent.setter def parent(self, parent: Optional[str]) -> None: if parent is None: - del self.header["parent"] + if "parent" in self.header: + del self.header["parent"] else: self.header["parent"] = parent @@ -296,7 +299,8 @@ def instance(self) -> Optional[int]: @instance.setter def instance(self, instance: Optional[int]) -> None: if instance is None: - del self.header["instance"] + if "instance" in self.header: + del self.header["instance"] else: self.header["instance"] = ExtResource(instance) self.type = None @@ -311,7 +315,8 @@ def index(self) -> Optional[int]: @index.setter def index(self, index: Optional[int]) -> None: if index is None: - del self.header["index"] + if "index" in self.header: + del self.header["index"] else: self.header["index"] = str(index) @@ -322,7 +327,8 @@ def groups(self) -> Optional[List[str]]: @groups.setter def groups(self, groups: Optional[List[str]]) -> None: if groups is None: - del self.header["groups"] + if "groups" in self.header: + del self.header["groups"] else: self.header["groups"] = groups diff --git a/godot_parser/tree.py b/godot_parser/tree.py index 9ef48c8..1b853eb 100644 --- a/godot_parser/tree.py +++ b/godot_parser/tree.py @@ -7,7 +7,6 @@ from .sections import GDNodeSection __all__ = ["Node", "TreeMutationException"] -SENTINEL = object() class TreeMutationException(Exception): @@ -106,35 +105,34 @@ def instance(self, new_instance: Optional[int]) -> None: self._type = None self._instance = new_instance + def __contains__(self, k: str) -> bool: + if k in self.properties: + return True + if self._inherited_node != None: + return k in self._inherited_node + return False + def __getitem__(self, k: str) -> Any: - v = self.properties.get(k, SENTINEL) - if v is SENTINEL: - if self._inherited_node is not None: - return self._inherited_node[k] - raise KeyError("No property %s found on node %s" % (k, self.name)) - return v + if k in self.properties: + return self.properties[k] + if self._inherited_node is not None: + return self._inherited_node[k] + raise KeyError("No property %s found on node %s" % (k, self.name)) def __setitem__(self, k: str, v: Any) -> None: - if self._inherited_node is not None and v == self._inherited_node.get( - k, SENTINEL - ): + if self._inherited_node is not None and k in self._inherited_node and v == self._inherited_node[k]: del self[k] else: self.properties[k] = v def __delitem__(self, k: str) -> None: - try: - del self.properties[k] - except KeyError: - pass + del self.properties[k] def get(self, k: str, default: Any = None) -> Any: - v = self.properties.get(k, SENTINEL) - if v is SENTINEL: - if self._inherited_node is not None: - return self._inherited_node.get(k, default) + try: + return self[k] + except KeyError: return default - return v @classmethod def from_section(cls, section: GDNodeSection): diff --git a/godot_parser/util.py b/godot_parser/util.py index 3b9e561..6609141 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -3,8 +3,10 @@ import os from typing import Optional +from godot_parser.output import OutputFormat, Outputable -def stringify_object(value): + +def stringify_object(value, output_format : Optional[OutputFormat] = OutputFormat()): """Serialize a value to the godot file format""" if value is None: return "null" @@ -13,17 +15,17 @@ def stringify_object(value): elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): - if len(value) == 0: - return "{}" return ( "{\n" + ",\n".join( - ['%s: %s' % (stringify_object(k), stringify_object(v)) for k, v in value.items()] + ['%s: %s' % (stringify_object(k, output_format), stringify_object(v, output_format)) for k, v in value.items()] ) + "\n}" ) elif isinstance(value, list): - return "[" + ", ".join([stringify_object(v) for v in value]) + "]" + return output_format.surround_brackets(", ".join([stringify_object(v, output_format) for v in value])) + elif isinstance(value, Outputable): + return value.output_to_string(output_format) else: return str(value) diff --git a/requirements_dev.txt b/requirements_dev.txt index 64e77bb..adb303f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ tox twine bumpversion build +packaging \ No newline at end of file diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index cbfb9c5..d402948 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -9,7 +9,7 @@ class TestGDFile(unittest.TestCase): def test_basic_scene(self): """Run the parsing test cases""" - self.assertEqual(str(GDPackedScene()), "[gd_scene load_steps=1 format=2]\n") + self.assertEqual(str(GDPackedScene()), "[gd_scene format=3]\n") def test_all_data_types(self): """Run the parsing test cases""" @@ -21,7 +21,7 @@ def test_all_data_types(self): ) self.assertEqual( str(res), - """[gd_resource load_steps=1 format=2] + """[gd_resource format=3] [resource] list = [1, 2.0, "string"] @@ -39,7 +39,7 @@ def test_ext_resource(self): scene.add_ext_resource("res://Other.tscn", "PackedScene") self.assertEqual( str(scene), - """[gd_scene load_steps=2 format=2] + """[gd_scene format=3] [ext_resource path="res://Other.tscn" type="PackedScene" id=1] """, @@ -51,7 +51,7 @@ def test_sub_resource(self): scene.add_sub_resource("Animation") self.assertEqual( str(scene), - """[gd_scene load_steps=2 format=2] + """[gd_scene format=3] [sub_resource type="Animation" id=1] """, @@ -64,7 +64,7 @@ def test_node(self): scene.add_node("Child", type="Area2D", parent=".") self.assertEqual( str(scene), - """[gd_scene load_steps=1 format=2] + """[gd_scene format=3] [node name="RootNode" type="Node2D"] @@ -82,7 +82,7 @@ def test_tree_create(self): ) self.assertEqual( str(scene), - """[gd_scene load_steps=1 format=2] + """[gd_scene format=3] [node name="RootNode" type="Node2D"] @@ -102,7 +102,7 @@ def test_tree_deep_create(self): child.add_child(Node("ChildChild2", type="Node")) self.assertEqual( str(scene), - """[gd_scene load_steps=1 format=2] + """[gd_scene format=3] [node name="RootNode" type="Node2D"] @@ -274,7 +274,7 @@ def test_string_special_characters(self): For this reason, this test is being done at a GDFile level, where this method is called upon parsing """ res = GDResource(str_value = "\ta\"q\'é'd\"\n\n\\") - self.assertEqual(str(res), """[gd_resource load_steps=1 format=2] + self.assertEqual(str(res), """[gd_resource format=3] [resource] str_value = " a\\"q'é'd\\" diff --git a/tests/test_sections.py b/tests/test_sections.py index 542e454..67a29dd 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -39,7 +39,6 @@ def test_section_dunder(self): del s["vframes"] self.assertEqual(s.get("vframes"), None) - del s["vframes"] def test_ext_resource(self): """Test for GDExtResourceSection""" diff --git a/tests/test_tree.py b/tests/test_tree.py index fbf22f0..5f8f73c 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -93,8 +93,7 @@ def test_properties(self): self.assertEqual(tree.root["vframes"], 10) tree.root["hframes"] = 10 del tree.root["hframes"] - del tree.root["hframes"] - self.assertIsNone(tree.root.get("hframes")) + self.assertNotIn("hframes",tree.root) child = scene.find_section("node") self.assertEqual(child["vframes"], 10) diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..3d34b46 --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,79 @@ +import unittest + +from godot_parser import GDResource, Vector3 +from godot_parser.output import VersionOutputFormat, OutputFormat + + +class TestOutputFormat(unittest.TestCase): + def test_version_output_format(self): + version_output_format = VersionOutputFormat("3.6") + self.assertTrue(version_output_format.punctuation_spaces) + self.assertFalse(version_output_format.resource_ids_as_strings) + self.assertFalse(version_output_format.explicit_typed_array) + self.assertFalse(version_output_format.packed_byte_array_base64_support) + self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.0") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertFalse(version_output_format.packed_byte_array_base64_support) + self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.1") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertFalse(version_output_format.packed_byte_array_base64_support) + self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.3") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.packed_byte_array_base64_support) + self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.4") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.packed_byte_array_base64_support) + self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.5") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.packed_byte_array_base64_support) + self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.load_steps) + + version_output_format = VersionOutputFormat("4.6") + self.assertFalse(version_output_format.punctuation_spaces) + self.assertTrue(version_output_format.resource_ids_as_strings) + self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.packed_byte_array_base64_support) + self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertFalse(version_output_format.load_steps) + + def test_punctuation_spaces(self): + resource = GDResource() + resource["array"] = [Vector3(1,2,3)] + + self.assertEqual(resource.output_to_string(OutputFormat(punctuation_spaces=False)), + """[gd_resource format=3] + +[resource] +array = [Vector3(1, 2, 3)]\n""") + + self.assertEqual(resource.output_to_string(OutputFormat(punctuation_spaces=True)), + """[gd_resource format=3] + +[resource] +array = [ Vector3( 1, 2, 3 ) ]\n""") \ No newline at end of file From f4df1643204cf5bf810dae766cc26ef44e2864cb Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 16:39:53 -0300 Subject: [PATCH 20/44] load_steps test --- tests/test_versions.py | 57 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/test_versions.py b/tests/test_versions.py index 3d34b46..2cc4810 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -64,7 +64,7 @@ def test_version_output_format(self): def test_punctuation_spaces(self): resource = GDResource() - resource["array"] = [Vector3(1,2,3)] + resource["array"] = [Vector3(1, 2, 3)] self.assertEqual(resource.output_to_string(OutputFormat(punctuation_spaces=False)), """[gd_resource format=3] @@ -76,4 +76,57 @@ def test_punctuation_spaces(self): """[gd_resource format=3] [resource] -array = [ Vector3( 1, 2, 3 ) ]\n""") \ No newline at end of file +array = [ Vector3( 1, 2, 3 ) ]\n""") + + def test_load_steps(self): + resource = GDResource() + resource["toggle"] = True + + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + """[gd_resource format=3] + +[resource] +toggle = true\n""") + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), + """[gd_resource load_steps=1 format=3] + +[resource] +toggle = true\n""") + + resource.add_ext_resource("res://a.tres", "CustomResource") + + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + """[gd_resource format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id=1] + +[resource] +toggle = true\n""") + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), + """[gd_resource load_steps=2 format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id=1] + +[resource] +toggle = true\n""") + + resource.add_sub_resource("CustomResource") + + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + """[gd_resource format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id=1] + +[sub_resource type="CustomResource" id=1] + +[resource] +toggle = true\n""") + self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), + """[gd_resource load_steps=3 format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id=1] + +[sub_resource type="CustomResource" id=1] + +[resource] +toggle = true\n""") \ No newline at end of file From ce43c8b2509781272834ffdacadc63fbfbc08af5 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 19:53:06 -0300 Subject: [PATCH 21/44] resource_ids_as_strings --- godot_parser/files.py | 79 +++++++++++++++++++------ godot_parser/id_generator.py | 24 ++++++++ godot_parser/objects.py | 47 +++++++++------ godot_parser/output.py | 10 +++- godot_parser/sections.py | 65 +++++++++------------ godot_parser/util.py | 4 ++ tests/test_gdfile.py | 55 +++++++++++++----- tests/test_sections.py | 4 +- tests/test_tree.py | 2 +- tests/test_versions.py | 109 +++++++++++++++++++++++++++++++---- 10 files changed, 294 insertions(+), 105 deletions(-) create mode 100644 godot_parser/id_generator.py diff --git a/godot_parser/files.py b/godot_parser/files.py index 5d0ba4d..37251ad 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -1,4 +1,5 @@ import os +import re from contextlib import contextmanager from typing import ( Iterable, @@ -13,13 +14,14 @@ Any, ) -from .objects import ExtResource, GDObject, SubResource +from .objects import ExtResource, GDObject, SubResource, ResourceReference from .output import OutputFormat, Outputable from .sections import ( - GDExtResourceSection, GDNodeSection, GDSection, GDSectionHeader, + GDBaseResourceSection, + GDExtResourceSection, GDSubResourceSection, GDResourceSection, ) @@ -167,15 +169,13 @@ def find_all( def add_ext_resource(self, path: str, type: str) -> GDExtResourceSection: """Add an ext_resource""" - next_id = 1 + max([s.id for s in self.get_ext_resources()] + [0]) - section = GDExtResourceSection(path, type, next_id) + section = GDExtResourceSection(path, type) self.add_section(section) return section def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: """Add a sub_resource""" - next_id = 1 + max([s.id for s in self.get_sub_resources()] + [0]) - section = GDSubResourceSection(type, next_id, **kwargs) + section = GDSubResourceSection(type, **kwargs) self.add_section(section) return section @@ -210,11 +210,11 @@ def from_parser(cls: Type[GDFile], parse_result) -> GDFile: return resource return cls(*parse_result) - def write(self, filename: str): + def write(self, filename: str, output_format : Optional[OutputFormat] = None): """Writes this to a file""" os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "w", encoding="utf-8") as ofile: - ofile.write(str(self)) + ofile.write(self.output_to_string(output_format)) def _output_to_string(self, output_format : OutputFormat) -> str: return "\n\n".join([s.output_to_string(output_format) for s in self._sections]) + "\n" @@ -238,6 +238,8 @@ def __init__(self, name: str, *sections: GDSection) -> None: super().__init__(GDSection(GDSectionHeader(name)), *sections) def _output_to_string(self, output_format : OutputFormat) -> str: + self.generate_resource_ids(output_format) + header = self._sections[0].header if "load_steps" in header: del header["load_steps"] @@ -287,11 +289,6 @@ def _remove_unused_resources( if self.remove_section(s): done_removing = False - def renumber_resource_ids(self): - """Refactor all resource IDs to be sequential with no gaps""" - self._renumber_resource_ids(self.get_ext_resources(), ExtResource) - self._renumber_resource_ids(self.get_sub_resources(), SubResource) - def _iter_resource_references( self, ) -> Iterator[Union[ExtResource, SubResource]]: @@ -317,6 +314,54 @@ def _iter_references(self) -> Iterator[Any]: for resource in self.get_sections("resource"): yield resource.properties + def generate_resource_ids(self, output_format : Optional[OutputFormat] = OutputFormat()): + self._generate_resource_ids(self.get_ext_resources(), ExtResource, output_format) + self._generate_resource_ids(self.get_sub_resources(), SubResource, output_format) + + __extract_int_re = re.compile(r"^(\d+)") + def __extract_int_id(self, id_: Union[int, str, None]) -> Optional[int]: + if isinstance(id_, int) or id_ is None: + return id_ + match = self.__extract_int_re.match(id_) + if match: + return int(match.group(0)) + return None + + def _generate_resource_ids(self, + sections: Sequence[GDBaseResourceSection], + reference_type: Type[ResourceReference], + output_format : OutputFormat): + if output_format.resource_ids_as_strings: + ids = [self.__extract_int_id(s.id) for s in sections] + ids.append(1) + next_id = max([id for id in ids if id is not None]) + for section in sections: + if isinstance(section.id,int): + for ref in self._iter_resource_references(): + if isinstance(ref, reference_type) and ref.id == section.id: + ref.resource = section + section.id = str(section.id) + elif section.id is None: + section.id = output_format.generate_id(section, next_id) + next_id+=1 + else: + ids = [s.id for s in sections if isinstance(s.id,int)] + ids.append(1) + next_id = max(ids) + for section in sections: + if not isinstance(section.id,int): + if isinstance(section.id, str): + for ref in self._iter_resource_references(): + if isinstance(ref, reference_type) and ref.id == section.id: + ref.resource = section + section.id = next_id + next_id+=1 + + def renumber_resource_ids(self): + """Refactor all resource IDs to be sequential with no gaps""" + self._renumber_resource_ids(self.get_ext_resources(), ExtResource) + self._renumber_resource_ids(self.get_sub_resources(), SubResource) + def _renumber_resource_ids( self, sections: Sequence[Union[GDExtResourceSection, GDSubResourceSection]], @@ -324,17 +369,15 @@ def _renumber_resource_ids( ) -> None: id_map = {} # First we renumber all the resource IDs so there are no gaps - for i, section in enumerate(sections): + for i, section in enumerate([s for s in sections if isinstance(s.id,int)]): id_map[section.id] = i + 1 section.id = i + 1 # Now we update all references to use the new number for ref in self._iter_resource_references(): if isinstance(ref, reference_type): - try: + if ref.id in id_map: ref.id = id_map[ref.id] - except KeyError as e: - raise GodotFileException("Unknown resource ID %d" % ref.id) from e class GDResource(GDCommonFile): @@ -445,7 +488,7 @@ def load_parent_scene(self) -> "GDPackedScene": parent_res = self.find_ext_resource(id=root.instance) if parent_res is None: raise RuntimeError( - "Could not find parent scene resource id(%d)" % root.instance + "Could not find parent scene resource id(%s)" % root.instance ) return GDPackedScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) diff --git a/godot_parser/id_generator.py b/godot_parser/id_generator.py new file mode 100644 index 0000000..c5eb4b5 --- /dev/null +++ b/godot_parser/id_generator.py @@ -0,0 +1,24 @@ +from random import choice +from string import ascii_lowercase, digits +from typing import Any + + +class BaseGenerator(object): + def generate(self, section: Any, index: int): + return "" + +class RandomIdGenerator(BaseGenerator): + def __init__(self, length: int = 5, pool : str = ascii_lowercase+digits): + self.length = length + self.pool = pool + + def generate(self, section: Any, index: int): + return "%s_%s" % (index, "".join((choice(self.pool) for _ in range(self.length)))) + +class SequentialHexGenerator(BaseGenerator): + def __init__(self): + self.counter = 0 + + def generate(self, section: Any, index: int): + self.counter += 1 + return "%s_%x" % (index, self.counter) \ No newline at end of file diff --git a/godot_parser/objects.py b/godot_parser/objects.py index ed9b995..317ddf4 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,10 +1,10 @@ """Wrappers for Godot's non-primitive object types""" from functools import partial -from typing import Type, TypeVar, Optional +from typing import Type, TypeVar, Union from .output import Outputable, OutputFormat -from .util import stringify_object +from .util import stringify_object, Identifiable __all__ = [ "GDObject", @@ -12,6 +12,7 @@ "Vector3", "Color", "NodePath", + "ResourceReference", "ExtResource", "SubResource", "StringName", @@ -217,34 +218,42 @@ def __str__(self) -> str: return '%s("%s")' % (self.name, self.path) -class ExtResource(GDObject): - def __init__(self, id: int) -> None: - super().__init__("ExtResource", id) +class ResourceReference(GDObject): + def __init__(self, name:str, resource: Union[int, str, Identifiable]): + self.resource = resource + if isinstance(resource, Identifiable): + super().__init__(name) + else: + super().__init__(name, resource) @property def id(self) -> int: """Getter for id""" - return self.args[0] + if isinstance(self.resource, Identifiable): + return self.resource.get_id() + else: + return self.resource @id.setter - def id(self, id: int) -> None: + def id(self, id: Union[int, str]) -> None: """Setter for id""" - self.args[0] = id + self.resource = id + self.args = [id] + def _output_to_string(self, output_format : OutputFormat) -> str: + if isinstance(self.resource, Identifiable): + self.id = self.resource.get_id() + return super()._output_to_string(output_format) -class SubResource(GDObject): - def __init__(self, id: int) -> None: - super().__init__("SubResource", id) - @property - def id(self) -> int: - """Getter for id""" - return self.args[0] +class ExtResource(ResourceReference): + def __init__(self, resource: Union[int, str, Identifiable]) -> None: + super().__init__("ExtResource", resource) - @id.setter - def id(self, id: int) -> None: - """Setter for id""" - self.args[0] = id + +class SubResource(ResourceReference): + def __init__(self, resource: Union[int, str, Identifiable]) -> None: + super().__init__("SubResource", resource) class TypedArray(Outputable): diff --git a/godot_parser/output.py b/godot_parser/output.py index fd48106..4b1c83e 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -1,7 +1,10 @@ -from typing import Union, Tuple, Optional +from typing import Union, Tuple, Optional, Any from packaging.version import Version +from .id_generator import RandomIdGenerator + + class OutputFormat(object): def __init__(self, punctuation_spaces : bool = False, @@ -17,6 +20,8 @@ def __init__(self, self.explicit_typed_dictionary = explicit_typed_dictionary self.load_steps = load_steps + self._id_generator = RandomIdGenerator() + def surround_string(self, punctuation : Union[str, Tuple[str, str]], content : str) -> str: if punctuation is str: right = left = punctuation @@ -36,6 +41,9 @@ def surround_parentheses(self, content: str) -> str: def surround_brackets(self, content: str) -> str: return self.surround_string(("[","]"), content) + def generate_id(self, section : Any, index : int) -> str: + return self._id_generator.generate(section, index) + class VersionOutputFormat(OutputFormat): __V40 = Version("4.0") __V43 = Version("4.3") diff --git a/godot_parser/sections.py b/godot_parser/sections.py index ccc7435..3137353 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -1,15 +1,16 @@ import re from collections import OrderedDict -from typing import Any, List, Optional, Type, TypeVar +from typing import Any, List, Optional, Type, TypeVar, Union from .objects import ExtResource, SubResource from .output import OutputFormat, Outputable -from .util import stringify_object +from .util import stringify_object, Identifiable __all__ = [ "GDSectionHeader", "GDSection", "GDNodeSection", + "GDBaseResourceSection", "GDExtResourceSection", "GDSubResourceSection", "GDResourceSection", @@ -156,20 +157,7 @@ def __ne__(self, other: Any) -> bool: return not self.__eq__(other) -class GDExtResourceSection(GDSection): - """Section representing an [ext_resource]""" - - def __init__(self, path: str, type: str, id: int): - super().__init__(GDSectionHeader("ext_resource", path=path, type=type, id=id)) - - @property - def path(self) -> str: - return self.header["path"] - - @path.setter - def path(self, path: str) -> None: - self.header["path"] = path - +class GDBaseResourceSection(GDSection, Identifiable): @property def type(self) -> str: return self.header["type"] @@ -179,43 +167,44 @@ def type(self, type: str) -> None: self.header["type"] = type @property - def id(self) -> int: - return self.header["id"] + def id(self) -> Optional[Union[int,str]]: + if "id" in self.header: + return self.header["id"] + return None @id.setter - def id(self, id: int) -> None: + def id(self, id: Union[int,str]) -> None: self.header["id"] = id - @property - def reference(self) -> ExtResource: - return ExtResource(self.id) - -class GDSubResourceSection(GDSection): - """Section representing a [sub_resource]""" +class GDExtResourceSection(GDBaseResourceSection): + """Section representing an [ext_resource]""" - def __init__(self, type: str, id: int, **kwargs): - super().__init__(GDSectionHeader("sub_resource", type=type, id=id), **kwargs) + def __init__(self, path: str, type_: str, id_: Optional[Union[int,str]] = None): + super().__init__(GDSectionHeader("ext_resource", path=path, type=type_, id=id_)) @property - def type(self) -> str: - return self.header["type"] + def path(self) -> str: + return self.header["path"] - @type.setter - def type(self, type: str) -> None: - self.header["type"] = type + @path.setter + def path(self, path: str) -> None: + self.header["path"] = path @property - def id(self) -> int: - return self.header["id"] + def reference(self) -> ExtResource: + return ExtResource(self) - @id.setter - def id(self, id: int) -> None: - self.header["id"] = id + +class GDSubResourceSection(GDBaseResourceSection): + """Section representing a [sub_resource]""" + + def __init__(self, type_: str, id_: Optional[Union[int,str]] = None, **kwargs): + super().__init__(GDSectionHeader("sub_resource", type=type_, id=id_), **kwargs) @property def reference(self) -> SubResource: - return SubResource(self.id) + return SubResource(self) class GDNodeSection(GDSection): diff --git a/godot_parser/util.py b/godot_parser/util.py index 6609141..162bdae 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -56,3 +56,7 @@ def filepath_to_gdpath(root: str, path: str) -> str: def is_gd_path(path: str) -> bool: return path.startswith("res://") + +class Identifiable(object): + def get_id(self): + return self.id \ No newline at end of file diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index d402948..af454fb 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -2,10 +2,15 @@ import unittest from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDPackedScene, Node, SubResource +from godot_parser.id_generator import SequentialHexGenerator +from godot_parser.output import OutputFormat class TestGDFile(unittest.TestCase): """Tests for GDFile""" + def setUp(self): + self.test_output_format = OutputFormat() + self.test_output_format._id_generator = SequentialHexGenerator() def test_basic_scene(self): """Run the parsing test cases""" @@ -38,10 +43,10 @@ def test_ext_resource(self): scene = GDPackedScene() scene.add_ext_resource("res://Other.tscn", "PackedScene") self.assertEqual( - str(scene), + scene.output_to_string(self.test_output_format), """[gd_scene format=3] -[ext_resource path="res://Other.tscn" type="PackedScene" id=1] +[ext_resource path="res://Other.tscn" type="PackedScene" id="1_1"] """, ) @@ -50,10 +55,10 @@ def test_sub_resource(self): scene = GDPackedScene() scene.add_sub_resource("Animation") self.assertEqual( - str(scene), + scene.output_to_string(self.test_output_format), """[gd_scene format=3] -[sub_resource type="Animation" id=1] +[sub_resource type="Animation" id="1_1"] """, ) @@ -160,9 +165,10 @@ def test_addremove_ext_res(self): """Test adding and removing an ext_resource""" scene = GDPackedScene() res = scene.add_ext_resource("res://Res.tscn", "PackedScene") - self.assertEqual(res.id, 1) res2 = scene.add_ext_resource("res://Sprite.png", "Texture") - self.assertEqual(res2.id, 2) + scene.generate_resource_ids(self.test_output_format) + self.assertEqual(res.id, "1_1") + self.assertEqual(res2.id, "2_2") node = scene.add_node("Sprite", "Sprite") node["texture"] = res2.reference node["textures"] = [res2.reference] @@ -171,10 +177,9 @@ def test_addremove_ext_res(self): s = scene.find_section(path="res://Res.tscn") scene.remove_section(s) - scene.renumber_resource_ids() s = scene.find_section("ext_resource") - self.assertEqual(s.id, 1) + self.assertEqual(s.id, "2_2") self.assertEqual(node["texture"], s.reference) self.assertEqual(node["textures"][0], s.reference) self.assertEqual(node["texture_map"]["tex"], s.reference) @@ -192,31 +197,33 @@ def test_addremove_sub_res(self): """Test adding and removing a sub_resource""" scene = GDResource() res = scene.add_sub_resource("CircleShape2D") - self.assertEqual(res.id, 1) res2 = scene.add_sub_resource("AnimationNodeAnimation") - self.assertEqual(res2.id, 2) + scene.generate_resource_ids(self.test_output_format) + self.assertEqual(res.id, "1_1") + self.assertEqual(res2.id, "2_2") resource = GDResourceSection(shape=res2.reference) scene.add_section(resource) s = scene.find_sub_resource(type="CircleShape2D") scene.remove_section(s) - scene.renumber_resource_ids() s = scene.find_section("sub_resource") - self.assertEqual(s.id, 1) + self.assertEqual(s.id, "2_2") self.assertEqual(resource["shape"], s.reference) def test_remove_unused_nested(self): res = GDResource("CustomResource") res1 = res.add_sub_resource("CustomResource") - res["child_resource"] = SubResource(1) + res["child_resource"] = res1.reference res2 = res.add_sub_resource("CustomResource") - res1["child_resource"] = SubResource(2) + res1["child_resource"] = res2.reference res3 = res.add_sub_resource("CustomResource") - res2["child_resource"] = SubResource(3) + res2["child_resource"] = res3.reference + + res.generate_resource_ids(self.test_output_format) self.assertEqual(len(res._sections), 5) self.assertIn(res1, res._sections) @@ -236,6 +243,7 @@ def test_find_constraints(self): scene = GDPackedScene() res1 = scene.add_sub_resource("CircleShape2D", radius=1) res2 = scene.add_sub_resource("CircleShape2D", radius=2) + scene.generate_resource_ids() found = list(scene.find_all("sub_resource")) self.assertCountEqual(found, [res1, res2]) @@ -265,6 +273,23 @@ def test_file_equality(self): resource["key"] = "value" self.assertNotEqual(s1, s2) + def test_renumber_ids(self): + output_format = OutputFormat(resource_ids_as_strings=False) + + res = GDResource() + res1 = res.add_sub_resource("CircleShape2D") + res2 = res.add_sub_resource("AnimationNodeAnimation") + res.generate_resource_ids(output_format) + + self.assertEqual(res1.id, 1) + self.assertEqual(res2.id, 2) + + res.remove_section(res1) + res.renumber_resource_ids() + + self.assertEqual(res2.id, 1) + + class Godot4Test(unittest.TestCase): def test_string_special_characters(self): """ diff --git a/tests/test_sections.py b/tests/test_sections.py index 67a29dd..b8ae8cd 100644 --- a/tests/test_sections.py +++ b/tests/test_sections.py @@ -42,7 +42,7 @@ def test_section_dunder(self): def test_ext_resource(self): """Test for GDExtResourceSection""" - s = GDExtResourceSection("res://Other.tscn", type="PackedScene", id=1) + s = GDExtResourceSection("res://Other.tscn", type_="PackedScene", id_=1) self.assertEqual(s.path, "res://Other.tscn") self.assertEqual(s.type, "PackedScene") self.assertEqual(s.id, 1) @@ -55,7 +55,7 @@ def test_ext_resource(self): def test_sub_resource(self): """Test for GDSubResourceSection""" - s = GDSubResourceSection(type="CircleShape2D", id=1) + s = GDSubResourceSection(type_="CircleShape2D", id_=1) self.assertEqual(s.type, "CircleShape2D") self.assertEqual(s.id, 1) s.type = "Animation" diff --git a/tests/test_tree.py b/tests/test_tree.py index 5f8f73c..c01a8e0 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -228,7 +228,7 @@ def test_inherit_properties(self): """Inherited nodes inherit properties""" scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: - self.assertEqual(tree.root["shape"], SubResource(1)) + self.assertEqual(tree.root["shape"], SubResource("1")) self.assertEqual(tree.root["collision_layer"], 4) self.assertEqual(tree.root.get("collision_layer"), 4) self.assertEqual(tree.root.get("missing"), None) diff --git a/tests/test_versions.py b/tests/test_versions.py index 2cc4810..ced11cb 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,6 +1,7 @@ import unittest -from godot_parser import GDResource, Vector3 +from godot_parser import GDResource, Vector3, ExtResource, GDExtResourceSection, GDSubResourceSection +from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import VersionOutputFormat, OutputFormat @@ -82,12 +83,18 @@ def test_load_steps(self): resource = GDResource() resource["toggle"] = True - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + true_output_format = OutputFormat(load_steps=True) + true_output_format._id_generator = SequentialHexGenerator() + + false_output_format = OutputFormat(load_steps=False) + false_output_format._id_generator = SequentialHexGenerator() + + self.assertEqual(resource.output_to_string(false_output_format), """[gd_resource format=3] [resource] toggle = true\n""") - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), + self.assertEqual(resource.output_to_string(true_output_format), """[gd_resource load_steps=1 format=3] [resource] @@ -95,38 +102,118 @@ def test_load_steps(self): resource.add_ext_resource("res://a.tres", "CustomResource") - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + self.assertEqual(resource.output_to_string(false_output_format), """[gd_resource format=3] -[ext_resource path="res://a.tres" type="CustomResource" id=1] +[ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [resource] toggle = true\n""") - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), + self.assertEqual(resource.output_to_string(true_output_format), """[gd_resource load_steps=2 format=3] -[ext_resource path="res://a.tres" type="CustomResource" id=1] +[ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [resource] toggle = true\n""") resource.add_sub_resource("CustomResource") - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=False)), + self.assertEqual(resource.output_to_string(false_output_format), """[gd_resource format=3] +[ext_resource path="res://a.tres" type="CustomResource" id="1_1"] + +[sub_resource type="CustomResource" id="1_2"] + +[resource] +toggle = true\n""") + self.assertEqual(resource.output_to_string(true_output_format), + """[gd_resource load_steps=3 format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id="1_1"] + +[sub_resource type="CustomResource" id="1_2"] + +[resource] +toggle = true\n""") + + def test_resource_ids_as_string(self): + resource = GDResource() + resource["toggle"] = True + resource.add_ext_resource("res://a.tres", "CustomResource") + resource.add_sub_resource("CustomResource") + + false_output_format = OutputFormat(resource_ids_as_strings=False) + + self.assertEqual(resource.output_to_string(false_output_format), + """[gd_resource format=2] + [ext_resource path="res://a.tres" type="CustomResource" id=1] [sub_resource type="CustomResource" id=1] [resource] toggle = true\n""") - self.assertEqual(resource.output_to_string(OutputFormat(load_steps=True)), - """[gd_resource load_steps=3 format=3] + + resource = GDResource() + resource["toggle"] = True + resource.add_ext_resource("res://a.tres", "CustomResource") + resource.add_sub_resource("CustomResource") + + true_output_format = OutputFormat(resource_ids_as_strings=True) + true_output_format._id_generator = SequentialHexGenerator() + + self.assertEqual(resource.output_to_string(true_output_format), + """[gd_resource format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id="1_1"] + +[sub_resource type="CustomResource" id="1_2"] + +[resource] +toggle = true\n""") + + def test_resource_ids_as_string_migration(self): + resource = GDResource() + ext = GDExtResourceSection("res://a.tres", "CustomResource", 1) + sub = GDSubResourceSection("CustomResource", 1) + + resource.add_section(ext) + resource.add_section(sub) + + resource["ext"] = ext.reference + resource["sub"] = sub.reference + + self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), + """[gd_resource format=2] + +[ext_resource path="res://a.tres" type="CustomResource" id=1] + +[sub_resource type="CustomResource" id=1] + +[resource] +ext = ExtResource(1) +sub = SubResource(1)\n""") + + self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=True)), + """[gd_resource format=3] + +[ext_resource path="res://a.tres" type="CustomResource" id="1"] + +[sub_resource type="CustomResource" id="1"] + +[resource] +ext = ExtResource("1") +sub = SubResource("1")\n""") + + self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), + """[gd_resource format=2] [ext_resource path="res://a.tres" type="CustomResource" id=1] [sub_resource type="CustomResource" id=1] [resource] -toggle = true\n""") \ No newline at end of file +ext = ExtResource(1) +sub = SubResource(1)\n""") \ No newline at end of file From 07c9c89ccb27dcb535cffa332fb98766a2c97e07 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 21:04:24 -0300 Subject: [PATCH 22/44] Formatting --- godot_parser/files.py | 70 +++++++++++++-------- godot_parser/id_generator.py | 11 +++- godot_parser/objects.py | 63 +++++++++++-------- godot_parser/output.py | 54 ++++++++++------- godot_parser/sections.py | 27 ++++++--- godot_parser/tree.py | 6 +- godot_parser/util.py | 22 +++++-- godot_parser/values.py | 32 +++++----- test_parse_files.py | 54 ++++++++++++----- tests/test_gdfile.py | 20 ++++-- tests/test_objects.py | 40 +++++++----- tests/test_parser.py | 82 +++++++++++++++---------- tests/test_tree.py | 20 +++--- tests/test_versions.py | 114 ++++++++++++++++++++++------------- 14 files changed, 385 insertions(+), 230 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 37251ad..9210fa4 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -2,6 +2,7 @@ import re from contextlib import contextmanager from typing import ( + Any, Iterable, Iterator, List, @@ -11,19 +12,18 @@ TypeVar, Union, cast, - Any, ) -from .objects import ExtResource, GDObject, SubResource, ResourceReference -from .output import OutputFormat, Outputable +from .objects import ExtResource, GDObject, ResourceReference, SubResource +from .output import Outputable, OutputFormat from .sections import ( + GDBaseResourceSection, + GDExtResourceSection, GDNodeSection, + GDResourceSection, GDSection, GDSectionHeader, - GDBaseResourceSection, - GDExtResourceSection, GDSubResourceSection, - GDResourceSection, ) from .structure import scene_file from .util import find_project_root, gdpath_to_filepath @@ -182,7 +182,9 @@ def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: @classmethod def parse(cls: Type[GDFile], contents: str) -> GDFile: """Parse the contents of a Godot file""" - return cls.from_parser(scene_file.parse_with_tabs().parse_string(contents, parse_all=True)) + return cls.from_parser( + scene_file.parse_with_tabs().parse_string(contents, parse_all=True) + ) @classmethod def load(cls: Type[GDFile], filepath: str) -> GDFile: @@ -210,14 +212,17 @@ def from_parser(cls: Type[GDFile], parse_result) -> GDFile: return resource return cls(*parse_result) - def write(self, filename: str, output_format : Optional[OutputFormat] = None): + def write(self, filename: str, output_format: Optional[OutputFormat] = None): """Writes this to a file""" os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "w", encoding="utf-8") as ofile: ofile.write(self.output_to_string(output_format)) - def _output_to_string(self, output_format : OutputFormat) -> str: - return "\n\n".join([s.output_to_string(output_format) for s in self._sections]) + "\n" + def _output_to_string(self, output_format: OutputFormat) -> str: + return ( + "\n\n".join([s.output_to_string(output_format) for s in self._sections]) + + "\n" + ) def __repr__(self) -> str: return "%s(%s)" % (type(self).__name__, self.__str__()) @@ -237,7 +242,7 @@ class GDCommonFile(GDFile): def __init__(self, name: str, *sections: GDSection) -> None: super().__init__(GDSection(GDSectionHeader(name)), *sections) - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: self.generate_resource_ids(output_format) header = self._sections[0].header @@ -314,11 +319,18 @@ def _iter_references(self) -> Iterator[Any]: for resource in self.get_sections("resource"): yield resource.properties - def generate_resource_ids(self, output_format : Optional[OutputFormat] = OutputFormat()): - self._generate_resource_ids(self.get_ext_resources(), ExtResource, output_format) - self._generate_resource_ids(self.get_sub_resources(), SubResource, output_format) + def generate_resource_ids( + self, output_format: Optional[OutputFormat] = OutputFormat() + ): + self._generate_resource_ids( + self.get_ext_resources(), ExtResource, output_format + ) + self._generate_resource_ids( + self.get_sub_resources(), SubResource, output_format + ) __extract_int_re = re.compile(r"^(\d+)") + def __extract_int_id(self, id_: Union[int, str, None]) -> Optional[int]: if isinstance(id_, int) or id_ is None: return id_ @@ -327,35 +339,37 @@ def __extract_int_id(self, id_: Union[int, str, None]) -> Optional[int]: return int(match.group(0)) return None - def _generate_resource_ids(self, - sections: Sequence[GDBaseResourceSection], - reference_type: Type[ResourceReference], - output_format : OutputFormat): + def _generate_resource_ids( + self, + sections: Sequence[GDBaseResourceSection], + reference_type: Type[ResourceReference], + output_format: OutputFormat, + ): if output_format.resource_ids_as_strings: ids = [self.__extract_int_id(s.id) for s in sections] ids.append(1) next_id = max([id for id in ids if id is not None]) for section in sections: - if isinstance(section.id,int): + if isinstance(section.id, int): for ref in self._iter_resource_references(): if isinstance(ref, reference_type) and ref.id == section.id: ref.resource = section section.id = str(section.id) elif section.id is None: section.id = output_format.generate_id(section, next_id) - next_id+=1 + next_id += 1 else: - ids = [s.id for s in sections if isinstance(s.id,int)] + ids = [s.id for s in sections if isinstance(s.id, int)] ids.append(1) next_id = max(ids) for section in sections: - if not isinstance(section.id,int): + if not isinstance(section.id, int): if isinstance(section.id, str): for ref in self._iter_resource_references(): if isinstance(ref, reference_type) and ref.id == section.id: ref.resource = section section.id = next_id - next_id+=1 + next_id += 1 def renumber_resource_ids(self): """Refactor all resource IDs to be sequential with no gaps""" @@ -369,7 +383,7 @@ def _renumber_resource_ids( ) -> None: id_map = {} # First we renumber all the resource IDs so there are no gaps - for i, section in enumerate([s for s in sections if isinstance(s.id,int)]): + for i, section in enumerate([s for s in sections if isinstance(s.id, int)]): id_map[section.id] = i + 1 section.id = i + 1 @@ -381,7 +395,9 @@ def _renumber_resource_ids( class GDResource(GDCommonFile): - def __init__(self, type: Optional[str] = None, *sections: GDSection, **attributes) -> None: + def __init__( + self, type: Optional[str] = None, *sections: GDSection, **attributes + ) -> None: super().__init__("gd_resource", *sections) if type is not None: @@ -490,7 +506,9 @@ def load_parent_scene(self) -> "GDPackedScene": raise RuntimeError( "Could not find parent scene resource id(%s)" % root.instance ) - return GDPackedScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) + return GDPackedScene.load( + gdpath_to_filepath(self.project_root, parent_res.path) + ) @contextmanager def use_tree(self): diff --git a/godot_parser/id_generator.py b/godot_parser/id_generator.py index c5eb4b5..163ccaf 100644 --- a/godot_parser/id_generator.py +++ b/godot_parser/id_generator.py @@ -7,13 +7,18 @@ class BaseGenerator(object): def generate(self, section: Any, index: int): return "" + class RandomIdGenerator(BaseGenerator): - def __init__(self, length: int = 5, pool : str = ascii_lowercase+digits): + def __init__(self, length: int = 5, pool: str = ascii_lowercase + digits): self.length = length self.pool = pool def generate(self, section: Any, index: int): - return "%s_%s" % (index, "".join((choice(self.pool) for _ in range(self.length)))) + return "%s_%s" % ( + index, + "".join((choice(self.pool) for _ in range(self.length))), + ) + class SequentialHexGenerator(BaseGenerator): def __init__(self): @@ -21,4 +26,4 @@ def __init__(self): def generate(self, section: Any, index: int): self.counter += 1 - return "%s_%x" % (index, self.counter) \ No newline at end of file + return "%s_%x" % (index, self.counter) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 317ddf4..81eb564 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -4,7 +4,7 @@ from typing import Type, TypeVar, Union from .output import Outputable, OutputFormat -from .util import stringify_object, Identifiable +from .util import Identifiable, stringify_object __all__ = [ "GDObject", @@ -71,7 +71,7 @@ def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: factory = GD_OBJECT_REGISTRY.get(name, partial(GDObject, name)) return factory(*parse_result[1:]) - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: return self.name + output_format.surround_parentheses( ", ".join([stringify_object(v, output_format) for v in self.args]) ) @@ -88,7 +88,7 @@ def __ne__(self, other) -> bool: return not self.__eq__(other) def __hash__(self): - return hash(frozenset((self.name,*self.args))) + return hash(frozenset((self.name, *self.args))) class Vector2(GDObject): @@ -219,7 +219,7 @@ def __str__(self) -> str: class ResourceReference(GDObject): - def __init__(self, name:str, resource: Union[int, str, Identifiable]): + def __init__(self, name: str, resource: Union[int, str, Identifiable]): self.resource = resource if isinstance(resource, Identifiable): super().__init__(name) @@ -240,7 +240,7 @@ def id(self, id: Union[int, str]) -> None: self.resource = id self.args = [id] - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: if isinstance(self.resource, Identifiable): self.id = self.resource.get_id() return super()._output_to_string(output_format) @@ -272,11 +272,13 @@ def WithCustomName(cls: Type[TypedArray], name, type, list_) -> TypedArray: def from_parser(cls: Type[TypedArray], parse_result) -> TypedArray: return TypedArray.WithCustomName(*parse_result) - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: return ( - self.name + - output_format.surround_brackets(self.type) + - output_format.surround_parentheses(stringify_object(self.list_, output_format)) + self.name + + output_format.surround_brackets(self.type) + + output_format.surround_parentheses( + stringify_object(self.list_, output_format) + ) ) def __repr__(self) -> str: @@ -285,15 +287,17 @@ def __repr__(self) -> str: def __eq__(self, other) -> bool: if not isinstance(other, TypedArray): return False - return self.name == other.name and \ - self.type == other.type and \ - self.list_ == other.list_ + return ( + self.name == other.name + and self.type == other.type + and self.list_ == other.list_ + ) def __ne__(self, other) -> bool: return not self.__eq__(other) def __hash__(self): - return hash(frozenset((self.name,self.type,self.list_))) + return hash(frozenset((self.name, self.type, self.list_))) class TypedDictionary(Outputable): @@ -304,7 +308,9 @@ def __init__(self, key_type, value_type, dict_) -> None: self.dict_ = dict_ @classmethod - def WithCustomName(cls: Type[TypedDictionary], name, key_type, value_type, dict_) -> TypedDictionary: + def WithCustomName( + cls: Type[TypedDictionary], name, key_type, value_type, dict_ + ) -> TypedDictionary: custom_dict = TypedDictionary(key_type, value_type, dict_) custom_dict.name = name return custom_dict @@ -313,11 +319,15 @@ def WithCustomName(cls: Type[TypedDictionary], name, key_type, value_type, dict_ def from_parser(cls: Type[TypedDictionary], parse_result) -> TypedDictionary: return TypedDictionary.WithCustomName(*parse_result) - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: return ( - self.name + - output_format.surround_brackets("%s, %s" % (self.key_type,self.value_type)) + - output_format.surround_parentheses(stringify_object(self.dict_, output_format)) + self.name + + output_format.surround_brackets( + "%s, %s" % (self.key_type, self.value_type) + ) + + output_format.surround_parentheses( + stringify_object(self.dict_, output_format) + ) ) def __repr__(self) -> str: @@ -326,16 +336,19 @@ def __repr__(self) -> str: def __eq__(self, other) -> bool: if not isinstance(other, TypedDictionary): return False - return self.name == other.name and \ - self.key_type == other.key_type and \ - self.value_type == other.value_type and \ - self.dict_ == other.dict_ + return ( + self.name == other.name + and self.key_type == other.key_type + and self.value_type == other.value_type + and self.dict_ == other.dict_ + ) def __ne__(self, other) -> bool: return not self.__eq__(other) def __hash__(self): - return hash(frozenset((self.name,self.key_type,self.value_type,self.dict_))) + return hash(frozenset((self.name, self.key_type, self.value_type, self.dict_))) + class StringName(Outputable): def __init__(self, str) -> None: @@ -345,7 +358,7 @@ def __init__(self, str) -> None: def from_parser(cls: Type[StringName], parse_result) -> StringName: return StringName(parse_result[0]) - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: return "&" + stringify_object(self.str, output_format) def __repr__(self) -> str: @@ -360,4 +373,4 @@ def __ne__(self, other) -> bool: return not self.__eq__(other) def __hash__(self): - return hash(self.str) \ No newline at end of file + return hash(self.str) diff --git a/godot_parser/output.py b/godot_parser/output.py index 4b1c83e..7bdc6ae 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -1,4 +1,4 @@ -from typing import Union, Tuple, Optional, Any +from typing import Any, Optional, Tuple, Union from packaging.version import Version @@ -6,13 +6,15 @@ class OutputFormat(object): - def __init__(self, - punctuation_spaces : bool = False, - resource_ids_as_strings: bool = True, - explicit_typed_array: bool = True, - packed_byte_array_base64_support : bool = True, - explicit_typed_dictionary: bool = True, - load_steps: bool = False): + def __init__( + self, + punctuation_spaces: bool = False, + resource_ids_as_strings: bool = True, + explicit_typed_array: bool = True, + packed_byte_array_base64_support: bool = True, + explicit_typed_dictionary: bool = True, + load_steps: bool = False, + ): self.punctuation_spaces = punctuation_spaces self.resource_ids_as_strings = resource_ids_as_strings self.explicit_typed_array = explicit_typed_array @@ -22,7 +24,9 @@ def __init__(self, self._id_generator = RandomIdGenerator() - def surround_string(self, punctuation : Union[str, Tuple[str, str]], content : str) -> str: + def surround_string( + self, punctuation: Union[str, Tuple[str, str]], content: str + ) -> str: if punctuation is str: right = left = punctuation else: @@ -36,43 +40,47 @@ def surround_string(self, punctuation : Union[str, Tuple[str, str]], content : s return left + content + right def surround_parentheses(self, content: str) -> str: - return self.surround_string(("(",")"), content) + return self.surround_string(("(", ")"), content) def surround_brackets(self, content: str) -> str: - return self.surround_string(("[","]"), content) + return self.surround_string(("[", "]"), content) - def generate_id(self, section : Any, index : int) -> str: + def generate_id(self, section: Any, index: int) -> str: return self._id_generator.generate(section, index) + class VersionOutputFormat(OutputFormat): __V40 = Version("4.0") __V43 = Version("4.3") __V44 = Version("4.4") __V46 = Version("4.6") - def __init__(self, version : Union[str, Version]): + def __init__(self, version: Union[str, Version]): if version is not Version: version = Version(version) self.version = version super().__init__( - punctuation_spaces = version < self.__V40, - resource_ids_as_strings = version >= self.__V40, - explicit_typed_array = version >= self.__V40, - packed_byte_array_base64_support = version >= self.__V43, - explicit_typed_dictionary = version >= self.__V44, - load_steps = version < self.__V46, + punctuation_spaces=version < self.__V40, + resource_ids_as_strings=version >= self.__V40, + explicit_typed_array=version >= self.__V40, + packed_byte_array_base64_support=version >= self.__V43, + explicit_typed_dictionary=version >= self.__V44, + load_steps=version < self.__V46, ) + class Outputable(object): - def _output_to_string(self, output_format : OutputFormat) -> str: - raise NotImplementedError("output_to_string method not defined in %s" % type(self)) + def _output_to_string(self, output_format: OutputFormat) -> str: + raise NotImplementedError( + "output_to_string method not defined in %s" % type(self) + ) - def output_to_string(self, output_format : Optional[OutputFormat] = None) -> str: + def output_to_string(self, output_format: Optional[OutputFormat] = None) -> str: if output_format is None: output_format = OutputFormat() return self._output_to_string(output_format) def __str__(self) -> str: - return self.output_to_string() \ No newline at end of file + return self.output_to_string() diff --git a/godot_parser/sections.py b/godot_parser/sections.py index 3137353..cda9fc1 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -3,8 +3,8 @@ from typing import Any, List, Optional, Type, TypeVar, Union from .objects import ExtResource, SubResource -from .output import OutputFormat, Outputable -from .util import stringify_object, Identifiable +from .output import Outputable, OutputFormat +from .util import Identifiable, stringify_object __all__ = [ "GDSectionHeader", @@ -57,11 +57,14 @@ def from_parser(cls: Type["GDSectionHeader"], parse_result) -> "GDSectionHeader" header.attributes[attribute[0]] = attribute[1] return header - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: attribute_str = "" if self.attributes: attribute_str = " " + " ".join( - ["%s=%s" % (k, stringify_object(v, output_format)) for k, v in self.attributes.items()] + [ + "%s=%s" % (k, stringify_object(v, output_format)) + for k, v in self.attributes.items() + ] ) return "[" + self.name + attribute_str + "]" @@ -134,12 +137,16 @@ def from_parser(cls: Type[GDSectionType], parse_result) -> GDSectionType: section[k] = v return section - def _output_to_string(self, output_format : OutputFormat) -> str: + def _output_to_string(self, output_format: OutputFormat) -> str: ret = self.header.output_to_string(output_format) if self.properties: ret += "\n" + "\n".join( [ - "%s = %s" % ("\"" + k + "\"" if ' ' in k else k, stringify_object(v, output_format)) + "%s = %s" + % ( + '"' + k + '"' if " " in k else k, + stringify_object(v, output_format), + ) for k, v in self.properties.items() ] ) @@ -167,20 +174,20 @@ def type(self, type: str) -> None: self.header["type"] = type @property - def id(self) -> Optional[Union[int,str]]: + def id(self) -> Optional[Union[int, str]]: if "id" in self.header: return self.header["id"] return None @id.setter - def id(self, id: Union[int,str]) -> None: + def id(self, id: Union[int, str]) -> None: self.header["id"] = id class GDExtResourceSection(GDBaseResourceSection): """Section representing an [ext_resource]""" - def __init__(self, path: str, type_: str, id_: Optional[Union[int,str]] = None): + def __init__(self, path: str, type_: str, id_: Optional[Union[int, str]] = None): super().__init__(GDSectionHeader("ext_resource", path=path, type=type_, id=id_)) @property @@ -199,7 +206,7 @@ def reference(self) -> ExtResource: class GDSubResourceSection(GDBaseResourceSection): """Section representing a [sub_resource]""" - def __init__(self, type_: str, id_: Optional[Union[int,str]] = None, **kwargs): + def __init__(self, type_: str, id_: Optional[Union[int, str]] = None, **kwargs): super().__init__(GDSectionHeader("sub_resource", type=type_, id=id_), **kwargs) @property diff --git a/godot_parser/tree.py b/godot_parser/tree.py index 1b853eb..0d2868c 100644 --- a/godot_parser/tree.py +++ b/godot_parser/tree.py @@ -120,7 +120,11 @@ def __getitem__(self, k: str) -> Any: raise KeyError("No property %s found on node %s" % (k, self.name)) def __setitem__(self, k: str, v: Any) -> None: - if self._inherited_node is not None and k in self._inherited_node and v == self._inherited_node[k]: + if ( + self._inherited_node is not None + and k in self._inherited_node + and v == self._inherited_node[k] + ): del self[k] else: self.properties[k] = v diff --git a/godot_parser/util.py b/godot_parser/util.py index 162bdae..64a8219 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -3,27 +3,36 @@ import os from typing import Optional -from godot_parser.output import OutputFormat, Outputable +from godot_parser.output import Outputable, OutputFormat -def stringify_object(value, output_format : Optional[OutputFormat] = OutputFormat()): +def stringify_object(value, output_format: Optional[OutputFormat] = OutputFormat()): """Serialize a value to the godot file format""" if value is None: return "null" elif isinstance(value, str): - return "\"%s\"" % value.replace("\\","\\\\").replace("\"", "\\\"") + return '"%s"' % value.replace("\\", "\\\\").replace('"', '\\"') elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): return ( "{\n" + ",\n".join( - ['%s: %s' % (stringify_object(k, output_format), stringify_object(v, output_format)) for k, v in value.items()] + [ + "%s: %s" + % ( + stringify_object(k, output_format), + stringify_object(v, output_format), + ) + for k, v in value.items() + ] ) + "\n}" ) elif isinstance(value, list): - return output_format.surround_brackets(", ".join([stringify_object(v, output_format) for v in value])) + return output_format.surround_brackets( + ", ".join([stringify_object(v, output_format) for v in value]) + ) elif isinstance(value, Outputable): return value.output_to_string(output_format) else: @@ -57,6 +66,7 @@ def filepath_to_gdpath(root: str, path: str) -> str: def is_gd_path(path: str) -> bool: return path.startswith("res://") + class Identifiable(object): def get_id(self): - return self.id \ No newline at end of file + return self.id diff --git a/godot_parser/values.py b/godot_parser/values.py index f6d8dbc..ab81265 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -14,7 +14,7 @@ common, ) -from .objects import GDObject, StringName, TypedDictionary, TypedArray +from .objects import GDObject, StringName, TypedArray, TypedDictionary boolean = ( (Keyword("true") | Keyword("false")) @@ -27,12 +27,12 @@ _string = QuotedString('"', escChar="\\", multiline=True).set_name("string") _string_name = ( - Suppress('&') + _string -).set_name("string_name").set_parse_action(StringName.from_parser) - -primitive = ( - null | _string | _string_name | boolean | common.number + (Suppress("&") + _string) + .set_name("string_name") + .set_parse_action(StringName.from_parser) ) + +primitive = null | _string | _string_name | boolean | common.number value = Forward() # Vector2( 1, 2 ) @@ -56,12 +56,11 @@ # Array[StringName]([&"a", &"b", &"c"]) typed_list = ( - Word(alphas, alphanums).set_results_name("object_name") + - ( - Suppress("[") - + obj_type.set_results_name("type") - + Suppress("]") - ) + Suppress("(") + list_ + Suppress(")") + Word(alphas, alphanums).set_results_name("object_name") + + (Suppress("[") + obj_type.set_results_name("type") + Suppress("]")) + + Suppress("(") + + list_ + + Suppress(")") ).set_parse_action(TypedArray.from_parser) key_val = Group(value + Suppress(":") + value) @@ -79,14 +78,17 @@ # &"_edit_use_anchors_": ExtResource("2_testt") # }) typed_dict = ( - Word(alphas, alphanums).set_results_name("object_name") + - ( + Word(alphas, alphanums).set_results_name("object_name") + + ( Suppress("[") + obj_type.set_results_name("key_type") + Suppress(",") + obj_type.set_results_name("value_type") + Suppress("]") - ) + Suppress("(") + dict_ + Suppress(")") + ) + + Suppress("(") + + dict_ + + Suppress(")") ).set_parse_action(TypedDictionary.from_parser) # Exports diff --git a/test_parse_files.py b/test_parse_files.py index 0abf7e0..634f8da 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -1,11 +1,11 @@ #!/usr/bin/env python import argparse +import difflib +import io import os import re -import io import sys import traceback -import difflib from godot_parser import load, parse @@ -16,6 +16,7 @@ # Regex to detect quotes and possible escape sequences find_quote_re = re.compile(r"(\\*\")") + def join_lines_within_quotes(input: list[str], unescape: bool): buffer_list = [] lines = [] @@ -27,10 +28,10 @@ def join_lines_within_quotes(input: list[str], unescape: bool): read_pos = 0 for match in find_quote_re.finditer(part): span = match.span() - match_text = part[span[0]:span[1]] - buffer += part[read_pos:span[1]] + match_text = part[span[0] : span[1]] + buffer += part[read_pos : span[1]] read_pos = span[1] - if len(match_text)%2 == 1: + if len(match_text) % 2 == 1: buffer_list.append(buffer) buffer = "" buffer += part[read_pos:] @@ -43,11 +44,15 @@ def join_lines_within_quotes(input: list[str], unescape: bool): buffer += "\n" else: for i in range(len(buffer_list)): - if i%2 == 0: + if i % 2 == 0: buffer_list[i] = space_re.sub(" ", buffer_list[i]) buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) elif unescape: - buffer_list[i] = buffer_list[i].encode('latin-1', 'backslashreplace').decode('unicode-escape') + buffer_list[i] = ( + buffer_list[i] + .encode("latin-1", "backslashreplace") + .decode("unicode-escape") + ) lines.append("".join(buffer_list) + "\n") buffer_list = [] buffer = "" @@ -60,7 +65,11 @@ def join_lines_within_quotes(input: list[str], unescape: bool): buffer_list[i] = space_re.sub(" ", buffer_list[i]) buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) elif unescape: - buffer_list[i] = buffer_list[i].encode('latin-1', 'backslashreplace').decode('unicode-escape') + buffer_list[i] = ( + buffer_list[i] + .encode("latin-1", "backslashreplace") + .decode("unicode-escape") + ) lines.append("".join(buffer_list) + "\n") return lines @@ -80,17 +89,19 @@ def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: original_file = join_lines_within_quotes( [l.strip() for l in io.StringIO(original_file).readlines() if l.strip()], - unescape + unescape, ) parsed_file = join_lines_within_quotes( [l.strip() for l in io.StringIO(str(parsed_file)).readlines() if l.strip()], - unescape + unescape, ) - diff = difflib.context_diff(original_file, parsed_file, fromfile=filename, tofile="PARSED FILE") - diff = [" "+"\n ".join(l.strip().split("\n"))+"\n" for l in diff] + diff = difflib.context_diff( + original_file, parsed_file, fromfile=filename, tofile="PARSED FILE" + ) + diff = [" " + "\n ".join(l.strip().split("\n")) + "\n" for l in diff] - if(len(diff) == 0): + if len(diff) == 0: return True print("! Difference detected on %s" % filename) @@ -102,9 +113,20 @@ def main(): """Test the parsing of one tscn file or all files in directory""" parser = argparse.ArgumentParser(description=main.__doc__) parser.add_argument("file_or_dir", help="Parse file or files under this directory") - parser.add_argument("--all", action='store_true', help="Tests all files even if one fails") - parser.add_argument("--verbose", "-v", action='store_true', help="Prints all file paths as they're parsed") - parser.add_argument("--unescape", action='store_true', help="Attempts to unescape strings before comparison (Godot 4.5+ standard)") + parser.add_argument( + "--all", action="store_true", help="Tests all files even if one fails" + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Prints all file paths as they're parsed", + ) + parser.add_argument( + "--unescape", + action="store_true", + help="Attempts to unescape strings before comparison (Godot 4.5+ standard)", + ) args = parser.parse_args() if os.path.isfile(args.file_or_dir): _parse_and_test_file(args.file_or_dir, args.verbose, args.unescape) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index af454fb..5848daf 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -1,13 +1,22 @@ import tempfile import unittest -from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDPackedScene, Node, SubResource +from godot_parser import ( + GDFile, + GDObject, + GDPackedScene, + GDResource, + GDResourceSection, + Node, + SubResource, +) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat class TestGDFile(unittest.TestCase): """Tests for GDFile""" + def setUp(self): self.test_output_format = OutputFormat() self.test_output_format._id_generator = SequentialHexGenerator() @@ -298,11 +307,14 @@ def test_string_special_characters(self): Tab handling is done by calling parse_with_tabs before parse_string For this reason, this test is being done at a GDFile level, where this method is called upon parsing """ - res = GDResource(str_value = "\ta\"q\'é'd\"\n\n\\") - self.assertEqual(str(res), """[gd_resource format=3] + res = GDResource(str_value="\ta\"q'é'd\"\n\n\\") + self.assertEqual( + str(res), + """[gd_resource format=3] [resource] str_value = " a\\"q'é'd\\" \\\\" -""") \ No newline at end of file +""", + ) diff --git a/tests/test_objects.py b/tests/test_objects.py index b3690b4..969f38e 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -1,6 +1,17 @@ import unittest -from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3, StringName, GDObject, TypedDictionary, TypedArray +from godot_parser import ( + Color, + ExtResource, + GDObject, + NodePath, + StringName, + SubResource, + TypedArray, + TypedDictionary, + Vector2, + Vector3, +) class TestGDObjects(unittest.TestCase): @@ -112,24 +123,25 @@ def test_dunder(self): def test_string_name(self): """Test for StringName""" s = StringName("test") - self.assertEqual(repr(s), "&\"test\"") + self.assertEqual(repr(s), '&"test"') s2 = StringName("test") self.assertEqual(s, s2) s2.str = "bad" self.assertNotEqual(s, s2) - s = StringName("A \"Quoted test\"") - self.assertEqual(repr(s), "&\"A \\\"Quoted test\\\"\"") + s = StringName('A "Quoted test"') + self.assertEqual(repr(s), '&"A \\"Quoted test\\""') def test_typed_dictionary(self): """Test for TypedDictionary""" - dict1 = { - StringName("asd"): GDObject("ExtResource", "2_qwert") - } + dict1 = {StringName("asd"): GDObject("ExtResource", "2_qwert")} td = TypedDictionary("StringName", GDObject("ExtResource", "1_qwert"), dict1) - self.assertEqual(repr(td), """Dictionary[StringName, ExtResource("1_qwert")]({ + self.assertEqual( + repr(td), + """Dictionary[StringName, ExtResource("1_qwert")]({ &"asd": ExtResource("2_qwert") -})""") +})""", + ) def test_typed_array(self): """Test for TypedArray""" @@ -140,9 +152,9 @@ def test_typed_array(self): ta = TypedArray("StringName", list1) self.assertEqual(repr(ta), """Array[StringName]([&"asd", &"dsa"])""") - list2 = [ - GDObject("ExtResource", "1_qwert"), - GDObject("ExtResource", "2_testt") - ] + list2 = [GDObject("ExtResource", "1_qwert"), GDObject("ExtResource", "2_testt")] ta = TypedArray("StringName", list2) - self.assertEqual(repr(ta), """Array[StringName]([ExtResource("1_qwert"), ExtResource("2_testt")])""") + self.assertEqual( + repr(ta), + """Array[StringName]([ExtResource("1_qwert"), ExtResource("2_testt")])""", + ) diff --git a/tests/test_parser.py b/tests/test_parser.py index f4f0ac5..457f33a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -3,7 +3,17 @@ from pyparsing import ParseException -from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, StringName, parse, TypedDictionary, TypedArray +from godot_parser import ( + GDFile, + GDObject, + GDSection, + GDSectionHeader, + StringName, + TypedArray, + TypedDictionary, + Vector2, + parse, +) HERE = os.path.dirname(__file__) @@ -115,22 +125,22 @@ ), ), ( - """[sub_resource type="CustomType" id=1] + """[sub_resource type="CustomType" id=1] string_value = "String" string_name_value = &"StringName" string_quote = "\\"String\\"" string_name_quote = &"\\"StringName\\"" string_single_quote = "\'String\'" string_name_single_quote = &"\'StringName\'" - """ , + """, GDFile( GDSection( GDSectionHeader("sub_resource", type="CustomType", id=1), **{ "string_value": "String", "string_name_value": StringName("StringName"), - "string_quote": "\"String\"", - "string_name_quote": StringName("\"StringName\""), + "string_quote": '"String"', + "string_name_quote": StringName('"StringName"'), "string_single_quote": "'String'", "string_name_single_quote": StringName("'StringName'"), } @@ -138,64 +148,70 @@ ), ), ( - """[sub_resource type="CustomType" id=1] + """[sub_resource type="CustomType" id=1] typed_dict_1 = Dictionary[StringName, ExtResource("1_testt")]({ &"key": ExtResource("2_testt") }) typed_dict_2 = Dictionary[ExtResource("1_testt"), StringName]({ ExtResource("2_testt"): &"key" }) - """ , + """, GDFile( GDSection( GDSectionHeader("sub_resource", type="CustomType", id=1), **{ - "typed_dict_1": TypedDictionary("StringName", GDObject("ExtResource", "1_testt"), - { - StringName("key"): GDObject("ExtResource", "2_testt") - }), - "typed_dict_2": TypedDictionary(GDObject("ExtResource", "1_testt"), "StringName", - { - GDObject("ExtResource", "2_testt"): StringName("key") - }) + "typed_dict_1": TypedDictionary( + "StringName", + GDObject("ExtResource", "1_testt"), + {StringName("key"): GDObject("ExtResource", "2_testt")}, + ), + "typed_dict_2": TypedDictionary( + GDObject("ExtResource", "1_testt"), + "StringName", + {GDObject("ExtResource", "2_testt"): StringName("key")}, + ), } ) ), ), ( - """[sub_resource type="CustomType" id=1] + """[sub_resource type="CustomType" id=1] typed_array_1 = Array[StringName]([&"a", &"b", &"c"]) typed_array_2 = Array[ExtResource("1_typee")]([ExtResource("1_qwert"), ExtResource("2_testt")]) - """ , + """, GDFile( GDSection( GDSectionHeader("sub_resource", type="CustomType", id=1), **{ - "typed_array_1": TypedArray("StringName", - [ - StringName("a"), - StringName("b"), - StringName("c"), - ]), - "typed_array_2": TypedArray(GDObject("ExtResource", "1_typee"), - [ - GDObject("ExtResource", "1_qwert"), - GDObject("ExtResource", "2_testt"), - ]) + "typed_array_1": TypedArray( + "StringName", + [ + StringName("a"), + StringName("b"), + StringName("c"), + ], + ), + "typed_array_2": TypedArray( + GDObject("ExtResource", "1_typee"), + [ + GDObject("ExtResource", "1_qwert"), + GDObject("ExtResource", "2_testt"), + ], + ), } ) ), ), ( - """[node name="Label" type="Label" parent="." unique_id=1387035530] + """[node name="Label" type="Label" parent="." unique_id=1387035530] text = "\ta\\\"q\\'é'd\\\"\n\n\\\\" """, GDFile( GDSection( - GDSectionHeader("node", name="Label", type="Label", parent=".", unique_id=1387035530), - **{ - "text": "\ta\"q'é'd\"\n\n\\" - } + GDSectionHeader( + "node", name="Label", type="Label", parent=".", unique_id=1387035530 + ), + **{"text": "\ta\"q'é'd\"\n\n\\"} ) ), ), diff --git a/tests/test_tree.py b/tests/test_tree.py index c01a8e0..a13b84e 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -93,7 +93,7 @@ def test_properties(self): self.assertEqual(tree.root["vframes"], 10) tree.root["hframes"] = 10 del tree.root["hframes"] - self.assertNotIn("hframes",tree.root) + self.assertNotIn("hframes", tree.root) child = scene.find_section("node") self.assertEqual(child["vframes"], 10) @@ -123,8 +123,7 @@ def setUpClass(cls): cls.root_scene = os.path.join(cls.project_dir, "Root.tscn") cls.mid_scene = os.path.join(cls.project_dir, "Mid.tscn") cls.leaf_scene = os.path.join(cls.project_dir, "Leaf.tscn") - scene = GDPackedScene.parse( - """ + scene = GDPackedScene.parse(""" [gd_scene load_steps=1 format=2] [node name="Root" type="KinematicBody2D"] collision_layer = 3 @@ -134,24 +133,20 @@ def setUpClass(cls): flip_h = false [node name="Health" type="Control" parent="."] [node name="LifeBar" type="TextureProgress" parent="Health"] -""" - ) +""") scene.write(cls.root_scene) - scene = GDPackedScene.parse( - """ + scene = GDPackedScene.parse(""" [gd_scene load_steps=2 format=2] [ext_resource path="res://Root.tscn" type="PackedScene" id=1] [node name="Mid" instance=ExtResource( 1 )] collision_layer = 4 [node name="Health" parent="." index="2"] pause_mode = 2 -""" - ) +""") scene.write(cls.mid_scene) - scene = GDPackedScene.parse( - """ + scene = GDPackedScene.parse(""" [gd_scene load_steps=2 format=2] [ext_resource path="res://Mid.tscn" type="PackedScene" id=1] [sub_resource type="CircleShape2D" id=1] @@ -159,8 +154,7 @@ def setUpClass(cls): shape = SubResource( 1 ) [node name="Sprite" type="Sprite" parent="." index="1"] flip_h = true -""" - ) +""") scene.write(cls.leaf_scene) @classmethod diff --git a/tests/test_versions.py b/tests/test_versions.py index ced11cb..d4ba3b0 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,8 +1,14 @@ import unittest -from godot_parser import GDResource, Vector3, ExtResource, GDExtResourceSection, GDSubResourceSection +from godot_parser import ( + ExtResource, + GDExtResourceSection, + GDResource, + GDSubResourceSection, + Vector3, +) from godot_parser.id_generator import SequentialHexGenerator -from godot_parser.output import VersionOutputFormat, OutputFormat +from godot_parser.output import OutputFormat, VersionOutputFormat class TestOutputFormat(unittest.TestCase): @@ -67,17 +73,21 @@ def test_punctuation_spaces(self): resource = GDResource() resource["array"] = [Vector3(1, 2, 3)] - self.assertEqual(resource.output_to_string(OutputFormat(punctuation_spaces=False)), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(OutputFormat(punctuation_spaces=False)), + """[gd_resource format=3] [resource] -array = [Vector3(1, 2, 3)]\n""") +array = [Vector3(1, 2, 3)]\n""", + ) - self.assertEqual(resource.output_to_string(OutputFormat(punctuation_spaces=True)), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(OutputFormat(punctuation_spaces=True)), + """[gd_resource format=3] [resource] -array = [ Vector3( 1, 2, 3 ) ]\n""") +array = [ Vector3( 1, 2, 3 ) ]\n""", + ) def test_load_steps(self): resource = GDResource() @@ -89,54 +99,66 @@ def test_load_steps(self): false_output_format = OutputFormat(load_steps=False) false_output_format._id_generator = SequentialHexGenerator() - self.assertEqual(resource.output_to_string(false_output_format), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(false_output_format), + """[gd_resource format=3] [resource] -toggle = true\n""") - self.assertEqual(resource.output_to_string(true_output_format), - """[gd_resource load_steps=1 format=3] +toggle = true\n""", + ) + self.assertEqual( + resource.output_to_string(true_output_format), + """[gd_resource load_steps=1 format=3] [resource] -toggle = true\n""") +toggle = true\n""", + ) resource.add_ext_resource("res://a.tres", "CustomResource") - self.assertEqual(resource.output_to_string(false_output_format), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(false_output_format), + """[gd_resource format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [resource] -toggle = true\n""") - self.assertEqual(resource.output_to_string(true_output_format), - """[gd_resource load_steps=2 format=3] +toggle = true\n""", + ) + self.assertEqual( + resource.output_to_string(true_output_format), + """[gd_resource load_steps=2 format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [resource] -toggle = true\n""") +toggle = true\n""", + ) resource.add_sub_resource("CustomResource") - self.assertEqual(resource.output_to_string(false_output_format), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(false_output_format), + """[gd_resource format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [sub_resource type="CustomResource" id="1_2"] [resource] -toggle = true\n""") - self.assertEqual(resource.output_to_string(true_output_format), - """[gd_resource load_steps=3 format=3] +toggle = true\n""", + ) + self.assertEqual( + resource.output_to_string(true_output_format), + """[gd_resource load_steps=3 format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [sub_resource type="CustomResource" id="1_2"] [resource] -toggle = true\n""") +toggle = true\n""", + ) def test_resource_ids_as_string(self): resource = GDResource() @@ -146,15 +168,17 @@ def test_resource_ids_as_string(self): false_output_format = OutputFormat(resource_ids_as_strings=False) - self.assertEqual(resource.output_to_string(false_output_format), - """[gd_resource format=2] + self.assertEqual( + resource.output_to_string(false_output_format), + """[gd_resource format=2] [ext_resource path="res://a.tres" type="CustomResource" id=1] [sub_resource type="CustomResource" id=1] [resource] -toggle = true\n""") +toggle = true\n""", + ) resource = GDResource() resource["toggle"] = True @@ -164,15 +188,17 @@ def test_resource_ids_as_string(self): true_output_format = OutputFormat(resource_ids_as_strings=True) true_output_format._id_generator = SequentialHexGenerator() - self.assertEqual(resource.output_to_string(true_output_format), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(true_output_format), + """[gd_resource format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] [sub_resource type="CustomResource" id="1_2"] [resource] -toggle = true\n""") +toggle = true\n""", + ) def test_resource_ids_as_string_migration(self): resource = GDResource() @@ -185,8 +211,9 @@ def test_resource_ids_as_string_migration(self): resource["ext"] = ext.reference resource["sub"] = sub.reference - self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), - """[gd_resource format=2] + self.assertEqual( + resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), + """[gd_resource format=2] [ext_resource path="res://a.tres" type="CustomResource" id=1] @@ -194,10 +221,12 @@ def test_resource_ids_as_string_migration(self): [resource] ext = ExtResource(1) -sub = SubResource(1)\n""") +sub = SubResource(1)\n""", + ) - self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=True)), - """[gd_resource format=3] + self.assertEqual( + resource.output_to_string(OutputFormat(resource_ids_as_strings=True)), + """[gd_resource format=3] [ext_resource path="res://a.tres" type="CustomResource" id="1"] @@ -205,10 +234,12 @@ def test_resource_ids_as_string_migration(self): [resource] ext = ExtResource("1") -sub = SubResource("1")\n""") +sub = SubResource("1")\n""", + ) - self.assertEqual(resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), - """[gd_resource format=2] + self.assertEqual( + resource.output_to_string(OutputFormat(resource_ids_as_strings=False)), + """[gd_resource format=2] [ext_resource path="res://a.tres" type="CustomResource" id=1] @@ -216,4 +247,5 @@ def test_resource_ids_as_string_migration(self): [resource] ext = ExtResource(1) -sub = SubResource(1)\n""") \ No newline at end of file +sub = SubResource(1)\n""", + ) From 5810bfe0a7285f046f44ffc1fe4b422f7da8106e Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 21:06:49 -0300 Subject: [PATCH 23/44] Typing --- .pylintrc | 2 +- godot_parser/files.py | 33 +++++++++------------------------ godot_parser/objects.py | 20 +++++++++++--------- godot_parser/output.py | 4 ++-- godot_parser/sections.py | 3 +++ godot_parser/tree.py | 8 ++++---- godot_parser/util.py | 8 ++++---- test_parse_files.py | 10 ++++++---- tests/test_gdfile.py | 1 - tests/test_versions.py | 8 +------- 10 files changed, 41 insertions(+), 56 deletions(-) diff --git a/.pylintrc b/.pylintrc index dbd657d..28911a8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,5 +1,5 @@ [MESSAGES CONTROL] -disable=E1101,W0511,W0612,W0613,W0212,W0221,W0703,W0622,C,R +disable=E1101,W0511,W0612,W0613,W0212,W0221,W0703,W0622,W1113,C,R [BASIC] argument-rgx=[a-z_][a-z0-9_]{0,30}$ diff --git a/godot_parser/files.py b/godot_parser/files.py index 9210fa4..21f13ad 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -1,18 +1,7 @@ import os import re from contextlib import contextmanager -from typing import ( - Any, - Iterable, - Iterator, - List, - Optional, - Sequence, - Type, - TypeVar, - Union, - cast, -) +from typing import Any, Iterable, Iterator, List, Optional, Sequence, Type, Union, cast from .objects import ExtResource, GDObject, ResourceReference, SubResource from .output import Outputable, OutputFormat @@ -44,9 +33,6 @@ ] -GDFileType = TypeVar("GDFileType", bound="GDFile") - - class GodotFileException(Exception): """Thrown when there are errors in a Godot file""" @@ -180,14 +166,14 @@ def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: return section @classmethod - def parse(cls: Type[GDFile], contents: str) -> GDFile: + def parse(cls: Type["GDFile"], contents: str) -> "GDFile": """Parse the contents of a Godot file""" return cls.from_parser( scene_file.parse_with_tabs().parse_string(contents, parse_all=True) ) @classmethod - def load(cls: Type[GDFile], filepath: str) -> GDFile: + def load(cls: Type["GDFile"], filepath: str) -> "GDFile": with open(filepath, "r", encoding="utf-8") as ifile: try: file = cls.parse(ifile.read()) @@ -200,7 +186,7 @@ def load(cls: Type[GDFile], filepath: str) -> GDFile: return file @classmethod - def from_parser(cls: Type[GDFile], parse_result) -> GDFile: + def from_parser(cls: Type["GDFile"], parse_result) -> "GDFile": first_section = parse_result[0] if first_section.header.name == "gd_scene": scene = GDPackedScene.__new__(GDPackedScene) @@ -319,9 +305,7 @@ def _iter_references(self) -> Iterator[Any]: for resource in self.get_sections("resource"): yield resource.properties - def generate_resource_ids( - self, output_format: Optional[OutputFormat] = OutputFormat() - ): + def generate_resource_ids(self, output_format: OutputFormat = OutputFormat()): self._generate_resource_ids( self.get_ext_resources(), ExtResource, output_format ) @@ -361,7 +345,7 @@ def _generate_resource_ids( else: ids = [s.id for s in sections if isinstance(s.id, int)] ids.append(1) - next_id = max(ids) + next_id = max([id for id in ids if id is not None]) for section in sections: if not isinstance(section.id, int): if isinstance(section.id, str): @@ -506,8 +490,9 @@ def load_parent_scene(self) -> "GDPackedScene": raise RuntimeError( "Could not find parent scene resource id(%s)" % root.instance ) - return GDPackedScene.load( - gdpath_to_filepath(self.project_root, parent_res.path) + return cast( + GDPackedScene, + GDPackedScene.load(gdpath_to_filepath(self.project_root, parent_res.path)), ) @contextmanager diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 81eb564..47628ab 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,7 +1,7 @@ """Wrappers for Godot's non-primitive object types""" from functools import partial -from typing import Type, TypeVar, Union +from typing import Optional, Type, TypeVar, Union from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object @@ -227,7 +227,7 @@ def __init__(self, name: str, resource: Union[int, str, Identifiable]): super().__init__(name, resource) @property - def id(self) -> int: + def id(self) -> Optional[Union[int, str]]: """Getter for id""" if isinstance(self.resource, Identifiable): return self.resource.get_id() @@ -242,7 +242,9 @@ def id(self, id: Union[int, str]) -> None: def _output_to_string(self, output_format: OutputFormat) -> str: if isinstance(self.resource, Identifiable): - self.id = self.resource.get_id() + id = self.resource.get_id() + if id is not None: + self.id = id return super()._output_to_string(output_format) @@ -263,13 +265,13 @@ def __init__(self, type, list_) -> None: self.list_ = list_ @classmethod - def WithCustomName(cls: Type[TypedArray], name, type, list_) -> TypedArray: + def WithCustomName(cls: Type["TypedArray"], name, type, list_) -> "TypedArray": custom_array = TypedArray(type, list_) custom_array.name = name return custom_array @classmethod - def from_parser(cls: Type[TypedArray], parse_result) -> TypedArray: + def from_parser(cls: Type["TypedArray"], parse_result) -> "TypedArray": return TypedArray.WithCustomName(*parse_result) def _output_to_string(self, output_format: OutputFormat) -> str: @@ -309,14 +311,14 @@ def __init__(self, key_type, value_type, dict_) -> None: @classmethod def WithCustomName( - cls: Type[TypedDictionary], name, key_type, value_type, dict_ - ) -> TypedDictionary: + cls: Type["TypedDictionary"], name, key_type, value_type, dict_ + ) -> "TypedDictionary": custom_dict = TypedDictionary(key_type, value_type, dict_) custom_dict.name = name return custom_dict @classmethod - def from_parser(cls: Type[TypedDictionary], parse_result) -> TypedDictionary: + def from_parser(cls: Type["TypedDictionary"], parse_result) -> "TypedDictionary": return TypedDictionary.WithCustomName(*parse_result) def _output_to_string(self, output_format: OutputFormat) -> str: @@ -355,7 +357,7 @@ def __init__(self, str) -> None: self.str = str @classmethod - def from_parser(cls: Type[StringName], parse_result) -> StringName: + def from_parser(cls: Type["StringName"], parse_result) -> "StringName": return StringName(parse_result[0]) def _output_to_string(self, output_format: OutputFormat) -> str: diff --git a/godot_parser/output.py b/godot_parser/output.py index 7bdc6ae..10dcc08 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -27,7 +27,7 @@ def __init__( def surround_string( self, punctuation: Union[str, Tuple[str, str]], content: str ) -> str: - if punctuation is str: + if isinstance(punctuation, str): right = left = punctuation else: left = punctuation[0] @@ -56,7 +56,7 @@ class VersionOutputFormat(OutputFormat): __V46 = Version("4.6") def __init__(self, version: Union[str, Version]): - if version is not Version: + if not isinstance(version, Version): version = Version(version) self.version = version diff --git a/godot_parser/sections.py b/godot_parser/sections.py index cda9fc1..c7d2f60 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -183,6 +183,9 @@ def id(self) -> Optional[Union[int, str]]: def id(self, id: Union[int, str]) -> None: self.header["id"] = id + def get_id(self) -> Optional[Union[int, str]]: + return self.id + class GDExtResourceSection(GDBaseResourceSection): """Section representing an [ext_resource]""" diff --git a/godot_parser/tree.py b/godot_parser/tree.py index 0d2868c..e624632 100644 --- a/godot_parser/tree.py +++ b/godot_parser/tree.py @@ -3,7 +3,7 @@ from collections import OrderedDict from typing import Any, List, Optional, Union -from .files import GDFile +from .files import GDPackedScene from .sections import GDNodeSection __all__ = ["Node", "TreeMutationException"] @@ -296,7 +296,7 @@ def get_node(self, path: str) -> Optional[Node]: return self.root.get_node(path) @classmethod - def build(cls, file: GDFile): + def build(cls, file: GDPackedScene): """Build the Tree from a flat list of [node]'s""" tree = cls() # Makes assumptions that the nodes are well-ordered @@ -328,8 +328,8 @@ def flatten(self) -> List[GDNodeSection]: return ret -def _load_parent_scene(root: Node, file: GDFile): - parent_file: GDFile = file.load_parent_scene() +def _load_parent_scene(root: Node, file: GDPackedScene): + parent_file = file.load_parent_scene() parent_tree = Tree.build(parent_file) # Transfer parent scene's children to this scene for child in parent_tree.root.get_children(): diff --git a/godot_parser/util.py b/godot_parser/util.py index 64a8219..47f41bb 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -1,12 +1,12 @@ """Utils""" import os -from typing import Optional +from typing import Optional, Union from godot_parser.output import Outputable, OutputFormat -def stringify_object(value, output_format: Optional[OutputFormat] = OutputFormat()): +def stringify_object(value, output_format: OutputFormat = OutputFormat()): """Serialize a value to the godot file format""" if value is None: return "null" @@ -68,5 +68,5 @@ def is_gd_path(path: str) -> bool: class Identifiable(object): - def get_id(self): - return self.id + def get_id(self) -> Optional[Union[int, str]]: + return None diff --git a/test_parse_files.py b/test_parse_files.py index 634f8da..c94189e 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -7,7 +7,7 @@ import sys import traceback -from godot_parser import load, parse +from godot_parser import parse # Regex to detect space sequences space_re = re.compile(r" +") @@ -78,7 +78,7 @@ def join_lines_within_quotes(input: list[str], unescape: bool): def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: if verbose: print("Parsing %s" % filename) - with open(filename, "r") as ifile: + with open(filename, "r", encoding="utf-8") as ifile: original_file = ifile.read() try: parsed_file = str(parse(original_file)) @@ -96,8 +96,10 @@ def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: unescape, ) - diff = difflib.context_diff( - original_file, parsed_file, fromfile=filename, tofile="PARSED FILE" + diff = list( + difflib.context_diff( + original_file, parsed_file, fromfile=filename, tofile="PARSED FILE" + ) ) diff = [" " + "\n ".join(l.strip().split("\n")) + "\n" for l in diff] diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 5848daf..241a514 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -8,7 +8,6 @@ GDResource, GDResourceSection, Node, - SubResource, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat diff --git a/tests/test_versions.py b/tests/test_versions.py index d4ba3b0..99000ea 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,12 +1,6 @@ import unittest -from godot_parser import ( - ExtResource, - GDExtResourceSection, - GDResource, - GDSubResourceSection, - Vector3, -) +from godot_parser import GDExtResourceSection, GDResource, GDSubResourceSection, Vector3 from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat, VersionOutputFormat From 65c2a3a1cd209ec2ecbabaa9fb1573e79ff5c5f2 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Sun, 22 Feb 2026 23:52:14 -0300 Subject: [PATCH 24/44] Object type support --- godot_parser/files.py | 25 ++++++- godot_parser/objects.py | 149 +++++++++++++++++++++++++++++++++++----- godot_parser/output.py | 15 ++-- tests/test_objects.py | 19 +++++ tests/test_versions.py | 144 ++++++++++++++++++++++++++++++++++---- 5 files changed, 314 insertions(+), 38 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 21f13ad..6eefcf3 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -3,7 +3,14 @@ from contextlib import contextmanager from typing import Any, Iterable, Iterator, List, Optional, Sequence, Type, Union, cast -from .objects import ExtResource, GDObject, ResourceReference, SubResource +from .objects import ( + ExtResource, + GDObject, + ResourceReference, + SubResource, + PackedVector4Array, + PackedByteArray, +) from .output import Outputable, OutputFormat from .sections import ( GDBaseResourceSection, @@ -244,6 +251,17 @@ def _output_to_string(self, output_format: OutputFormat) -> str: if output_format.resource_ids_as_strings: header["format"] = 3 + for obj in self._iter_resource_references(): + if ( + isinstance(obj, PackedVector4Array) + and output_format.packed_vector4_array_support + ): + header["format"] = 4 + if ( + isinstance(obj, PackedByteArray) + and output_format.packed_byte_array_base64_support + ): + header["format"] = 4 else: header["format"] = 2 @@ -295,6 +313,7 @@ def iter_resources(value): for v in value.values(): yield from iter_resources(v) elif isinstance(value, GDObject): + yield value for v in value.args: yield from iter_resources(v) @@ -402,6 +421,10 @@ def __setitem__(self, k: str, v: Any) -> None: def __delitem__(self, k: str) -> None: del self.resource_section[k] + def _iter_references(self) -> Iterator[Any]: + yield from super()._iter_references() + yield self.resource_section.properties + class GDPackedScene(GDCommonFile): def __init__(self, *sections: GDSection) -> None: diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 47628ab..1ef0f79 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,7 +1,9 @@ """Wrappers for Godot's non-primitive object types""" +import base64 from functools import partial -from typing import Optional, Type, TypeVar, Union +from math import floor +from typing import Optional, Type, TypeVar, Union, List, Any from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object @@ -51,7 +53,7 @@ class GDObject(Outputable, metaclass=GDObjectMeta): def __init__(self, name, *args) -> None: self.name = name - self.args = list(args) + self.args: List[Any] = list(args) def __contains__(self, idx: int) -> bool: return idx in self.args @@ -151,6 +153,51 @@ def z(self, z: float) -> None: self.args[2] = z +class Vector4(GDObject): + def __init__(self, x: float, y: float, z: float, w: float) -> None: + super().__init__("Vector4", x, y, z, w) + + @property + def x(self) -> float: + """Getter for x""" + return self.args[0] + + @x.setter + def x(self, x: float) -> None: + """Setter for x""" + self.args[0] = x + + @property + def y(self) -> float: + """Getter for y""" + return self.args[1] + + @y.setter + def y(self, y: float) -> None: + """Setter for y""" + self.args[1] = y + + @property + def z(self) -> float: + """Getter for z""" + return self.args[2] + + @z.setter + def z(self, z: float) -> None: + """Setter for z""" + self.args[2] = z + + @property + def w(self) -> float: + """Getter for w""" + return self.args[3] + + @w.setter + def w(self, w: float) -> None: + """Setter for w""" + self.args[3] = w + + class Color(GDObject): def __init__(self, r: float, g: float, b: float, a: float) -> None: assert 0 <= r <= 1 @@ -200,6 +247,70 @@ def a(self, a: float) -> None: self.args[3] = a +class PackedVector4Array(GDObject): + def __init__(self, list_: List[Vector4]) -> None: + super().__init__( + "PackedVector4Array", *sum([[v.x, v.y, v.z, v.w] for v in list_], []) + ) + + def __contains__(self, idx: int) -> bool: + return len(self.args) > (idx * 4) + + def __getitem__(self, idx: int) -> Vector4: + return Vector4( + self.args[idx * 4 + 0], + self.args[idx * 4 + 1], + self.args[idx * 4 + 2], + self.args[idx * 4 + 3], + ) + + def __setitem__(self, idx: int, value: Vector4) -> None: + self.args[idx * 4 + 0] = value.x + self.args[idx * 4 + 1] = value.y + self.args[idx * 4 + 2] = value.z + self.args[idx * 4 + 3] = value.w + + def __delitem__(self, idx: int) -> None: + del self.args[idx * 4] + del self.args[idx * 4] + del self.args[idx * 4] + del self.args[idx * 4] + + def _output_to_string(self, output_format: OutputFormat) -> str: + if output_format.packed_vector4_array_support: + return super()._output_to_string(output_format) + else: + return TypedArray( + "Vector4", [self[i] for i in range(floor(len(self.args) / 4))] + ).output_to_string(output_format) + + +class PackedByteArray(GDObject): + def __init__(self, bytes_: bytes) -> None: + super().__init__("PackedByteArray", *list(bytes_)) + + def __stored_as_base64(self) -> bool: + return len(self.args) == 1 and isinstance(self.args[0], str) + + @property + def bytes_(self) -> bytes: + if self.__stored_as_base64(): + return base64.b64decode(self.args[0]) + return bytes(self.args) + + @bytes_.setter + def bytes_(self, bytes_: bytes) -> None: + self.args = list(bytes_) + + def _output_to_string(self, output_format: OutputFormat) -> str: + if output_format.packed_byte_array_base64_support: + if not self.__stored_as_base64(): + self.args = [base64.b64encode(self.bytes_).decode("utf-8")] + elif self.__stored_as_base64(): + self.bytes_ = self.bytes_ + return super()._output_to_string(output_format) + + class NodePath(GDObject): def __init__(self, path: str) -> None: super().__init__("NodePath", path) @@ -275,13 +386,16 @@ def from_parser(cls: Type["TypedArray"], parse_result) -> "TypedArray": return TypedArray.WithCustomName(*parse_result) def _output_to_string(self, output_format: OutputFormat) -> str: - return ( - self.name - + output_format.surround_brackets(self.type) - + output_format.surround_parentheses( - stringify_object(self.list_, output_format) + if output_format.typed_array_support: + return ( + self.name + + output_format.surround_brackets(self.type) + + output_format.surround_parentheses( + stringify_object(self.list_, output_format) + ) ) - ) + else: + return stringify_object(self.list_, output_format) def __repr__(self) -> str: return self.__str__() @@ -322,15 +436,18 @@ def from_parser(cls: Type["TypedDictionary"], parse_result) -> "TypedDictionary" return TypedDictionary.WithCustomName(*parse_result) def _output_to_string(self, output_format: OutputFormat) -> str: - return ( - self.name - + output_format.surround_brackets( - "%s, %s" % (self.key_type, self.value_type) - ) - + output_format.surround_parentheses( - stringify_object(self.dict_, output_format) + if output_format.typed_dictionary_support: + return ( + self.name + + output_format.surround_brackets( + "%s, %s" % (self.key_type, self.value_type) + ) + + output_format.surround_parentheses( + stringify_object(self.dict_, output_format) + ) ) - ) + else: + return stringify_object(self.dict_, output_format) def __repr__(self) -> str: return self.__str__() diff --git a/godot_parser/output.py b/godot_parser/output.py index 10dcc08..7375b7a 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -10,16 +10,18 @@ def __init__( self, punctuation_spaces: bool = False, resource_ids_as_strings: bool = True, - explicit_typed_array: bool = True, + typed_array_support: bool = True, packed_byte_array_base64_support: bool = True, - explicit_typed_dictionary: bool = True, + packed_vector4_array_support: bool = True, + typed_dictionary_support: bool = True, load_steps: bool = False, ): self.punctuation_spaces = punctuation_spaces self.resource_ids_as_strings = resource_ids_as_strings - self.explicit_typed_array = explicit_typed_array + self.typed_array_support = typed_array_support self.packed_byte_array_base64_support = packed_byte_array_base64_support - self.explicit_typed_dictionary = explicit_typed_dictionary + self.packed_vector4_array_support = packed_vector4_array_support + self.typed_dictionary_support = typed_dictionary_support self.load_steps = load_steps self._id_generator = RandomIdGenerator() @@ -64,9 +66,10 @@ def __init__(self, version: Union[str, Version]): super().__init__( punctuation_spaces=version < self.__V40, resource_ids_as_strings=version >= self.__V40, - explicit_typed_array=version >= self.__V40, + typed_array_support=version >= self.__V40, packed_byte_array_base64_support=version >= self.__V43, - explicit_typed_dictionary=version >= self.__V44, + packed_vector4_array_support=version >= self.__V43, + typed_dictionary_support=version >= self.__V44, load_steps=version < self.__V46, ) diff --git a/tests/test_objects.py b/tests/test_objects.py index 969f38e..4f11e89 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -12,6 +12,7 @@ Vector2, Vector3, ) +from godot_parser.objects import PackedVector4Array, Vector4, PackedByteArray class TestGDObjects(unittest.TestCase): @@ -57,6 +58,24 @@ def test_vector3(self): self.assertEqual(v[1], 4) self.assertEqual(v[2], 5) + def test_packed_vector4_array(self): + """Test for PackedVector4Array""" + array = PackedVector4Array([Vector4(i, i * 2, i * 3, i * 4) for i in range(3)]) + self.assertEqual(array[0], Vector4(0, 0, 0, 0)) + self.assertEqual(array[1], Vector4(1, 2, 3, 4)) + self.assertEqual(array[2], Vector4(2, 4, 6, 8)) + + del array[1] + + self.assertEqual(array[1], Vector4(2, 4, 6, 8)) + + def test_packed_byte_array(self): + """Test for PackedVector4Array""" + array = PackedByteArray(bytes(range(3))) + self.assertEqual(array.bytes_[0], 0) + self.assertEqual(array.bytes_[1], 1) + self.assertEqual(array.bytes_[2], 2) + def test_color(self): """Test for Color""" c = Color(0.1, 0.2, 0.3, 0.4) diff --git a/tests/test_versions.py b/tests/test_versions.py index 99000ea..5955ddf 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,7 +1,20 @@ +import base64 import unittest -from godot_parser import GDExtResourceSection, GDResource, GDSubResourceSection, Vector3 +from godot_parser import ( + GDExtResourceSection, + GDResource, + GDSubResourceSection, + Vector3, + TypedArray, +) from godot_parser.id_generator import SequentialHexGenerator +from godot_parser.objects import ( + PackedVector4Array, + Vector4, + TypedDictionary, + PackedByteArray, +) from godot_parser.output import OutputFormat, VersionOutputFormat @@ -10,57 +23,57 @@ def test_version_output_format(self): version_output_format = VersionOutputFormat("3.6") self.assertTrue(version_output_format.punctuation_spaces) self.assertFalse(version_output_format.resource_ids_as_strings) - self.assertFalse(version_output_format.explicit_typed_array) + self.assertFalse(version_output_format.typed_array_support) self.assertFalse(version_output_format.packed_byte_array_base64_support) - self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertFalse(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.0") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertFalse(version_output_format.packed_byte_array_base64_support) - self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertFalse(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.1") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertFalse(version_output_format.packed_byte_array_base64_support) - self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertFalse(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.3") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertTrue(version_output_format.packed_byte_array_base64_support) - self.assertFalse(version_output_format.explicit_typed_dictionary) + self.assertFalse(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.4") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertTrue(version_output_format.packed_byte_array_base64_support) - self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.5") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertTrue(version_output_format.packed_byte_array_base64_support) - self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.typed_dictionary_support) self.assertTrue(version_output_format.load_steps) version_output_format = VersionOutputFormat("4.6") self.assertFalse(version_output_format.punctuation_spaces) self.assertTrue(version_output_format.resource_ids_as_strings) - self.assertTrue(version_output_format.explicit_typed_array) + self.assertTrue(version_output_format.typed_array_support) self.assertTrue(version_output_format.packed_byte_array_base64_support) - self.assertTrue(version_output_format.explicit_typed_dictionary) + self.assertTrue(version_output_format.typed_dictionary_support) self.assertFalse(version_output_format.load_steps) def test_punctuation_spaces(self): @@ -243,3 +256,104 @@ def test_resource_ids_as_string_migration(self): ext = ExtResource(1) sub = SubResource(1)\n""", ) + + def test_typed_array_support(self): + resource = GDResource() + resource["test"] = TypedArray("int", [3, 1, 2]) + + self.assertEqual( + resource.output_to_string(OutputFormat(typed_array_support=True)), + """[gd_resource format=3] + +[resource] +test = Array[int]([3, 1, 2])\n""", + ) + + self.assertEqual( + resource.output_to_string(OutputFormat(typed_array_support=False)), + """[gd_resource format=3] + +[resource] +test = [3, 1, 2]\n""", + ) + + def test_typed_dictionary_support(self): + resource = GDResource() + resource["test"] = TypedDictionary( + "int", + "String", + { + 1: "One", + 2: "Two", + }, + ) + + self.assertEqual( + resource.output_to_string(OutputFormat(typed_dictionary_support=True)), + """[gd_resource format=3] + +[resource] +test = Dictionary[int, String]({ +1: "One", +2: "Two" +})\n""", + ) + + self.assertEqual( + resource.output_to_string(OutputFormat(typed_dictionary_support=False)), + """[gd_resource format=3] + +[resource] +test = { +1: "One", +2: "Two" +}\n""", + ) + + def test_packed_vector4_array_support(self): + resource = GDResource() + resource["test"] = PackedVector4Array([Vector4(1, 2, 3, 4)]) + + self.assertEqual( + resource.output_to_string(OutputFormat(packed_vector4_array_support=True)), + """[gd_resource format=4] + +[resource] +test = PackedVector4Array(1, 2, 3, 4)\n""", + ) + + self.assertEqual( + resource.output_to_string(OutputFormat(packed_vector4_array_support=False)), + """[gd_resource format=3] + +[resource] +test = Array[Vector4]([Vector4(1, 2, 3, 4)])\n""", + ) + + self.assertEqual( + resource.output_to_string( + OutputFormat( + packed_vector4_array_support=False, typed_array_support=False + ) + ), + """[gd_resource format=3] + +[resource] +test = [Vector4(1, 2, 3, 4)]\n""", + ) + + def test_packed_byte_array_base64_support(self): + bytes_ = bytes([5, 88, 10]) + + resource = GDResource() + resource["test"] = PackedByteArray(bytes_) + + self.assertEqual( + resource.output_to_string( + OutputFormat(packed_byte_array_base64_support=True) + ), + """[gd_resource format=4] + +[resource] +test = PackedByteArray("%s")\n""" % base64.b64encode(bytes_).decode("utf-8"), + ) From acf8eaa692c33f17a751707ec8e6aaf9b619b732 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 00:06:22 -0300 Subject: [PATCH 25/44] Multiversion Packed Array support --- godot_parser/objects.py | 11 +++++- godot_parser/output.py | 5 +++ tests/{test_versions.py => test_output.py} | 41 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) rename tests/{test_versions.py => test_output.py} (90%) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 1ef0f79..dc05e02 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,6 +1,7 @@ """Wrappers for Godot's non-primitive object types""" import base64 +import re from functools import partial from math import floor from typing import Optional, Type, TypeVar, Union, List, Any @@ -73,8 +74,16 @@ def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: factory = GD_OBJECT_REGISTRY.get(name, partial(GDObject, name)) return factory(*parse_result[1:]) + __packed_array_re = re.compile(r"^Packed(?P[A-Z]\w+)Array$") + def _output_to_string(self, output_format: OutputFormat) -> str: - return self.name + output_format.surround_parentheses( + name = self.name + + match = self.__packed_array_re.match(name) + if match: + name = output_format.packed_array_format % match.group("InnerType") + + return name + output_format.surround_parentheses( ", ".join([stringify_object(v, output_format) for v in self.args]) ) diff --git a/godot_parser/output.py b/godot_parser/output.py index 7375b7a..43367b9 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -15,6 +15,7 @@ def __init__( packed_vector4_array_support: bool = True, typed_dictionary_support: bool = True, load_steps: bool = False, + packed_array_format="Packed%sArray", ): self.punctuation_spaces = punctuation_spaces self.resource_ids_as_strings = resource_ids_as_strings @@ -23,6 +24,7 @@ def __init__( self.packed_vector4_array_support = packed_vector4_array_support self.typed_dictionary_support = typed_dictionary_support self.load_steps = load_steps + self.packed_array_format = packed_array_format self._id_generator = RandomIdGenerator() @@ -71,6 +73,9 @@ def __init__(self, version: Union[str, Version]): packed_vector4_array_support=version >= self.__V43, typed_dictionary_support=version >= self.__V44, load_steps=version < self.__V46, + packed_array_format=( + "Pool%sArray" if version < self.__V40 else "Packed%sArray" + ), ) diff --git a/tests/test_versions.py b/tests/test_output.py similarity index 90% rename from tests/test_versions.py rename to tests/test_output.py index 5955ddf..86a6295 100644 --- a/tests/test_versions.py +++ b/tests/test_output.py @@ -7,6 +7,7 @@ GDSubResourceSection, Vector3, TypedArray, + GDObject, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.objects import ( @@ -357,3 +358,43 @@ def test_packed_byte_array_base64_support(self): [resource] test = PackedByteArray("%s")\n""" % base64.b64encode(bytes_).decode("utf-8"), ) + + self.assertEqual( + resource.output_to_string( + OutputFormat(packed_byte_array_base64_support=False) + ), + """[gd_resource format=3] + +[resource] +test = PackedByteArray(5, 88, 10)\n""", + ) + + def test_packed_array_format(self): + resource = GDResource() + resource["test1"] = PackedByteArray(bytes(range(4))) + resource["test2"] = GDObject("PackedVector2Array", *range(4)) + + self.assertEqual( + resource.output_to_string( + OutputFormat(packed_byte_array_base64_support=False) + ), + """[gd_resource format=3] + +[resource] +test1 = PackedByteArray(0, 1, 2, 3) +test2 = PackedVector2Array(0, 1, 2, 3)\n""", + ) + + self.assertEqual( + resource.output_to_string( + OutputFormat( + packed_array_format="Pool%sArray", + packed_byte_array_base64_support=False, + ) + ), + """[gd_resource format=3] + +[resource] +test1 = PoolByteArray(0, 1, 2, 3) +test2 = PoolVector2Array(0, 1, 2, 3)\n""", + ) From 422b6fa2bcfb1115c24471a6fb0e2cbe088e5183 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 00:18:35 -0300 Subject: [PATCH 26/44] StringName support flag --- godot_parser/objects.py | 5 ++++- godot_parser/output.py | 3 +++ tests/test_output.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index dc05e02..3107b51 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -487,7 +487,10 @@ def from_parser(cls: Type["StringName"], parse_result) -> "StringName": return StringName(parse_result[0]) def _output_to_string(self, output_format: OutputFormat) -> str: - return "&" + stringify_object(self.str, output_format) + marker = "" + if output_format.string_name_support: + marker = "&" + return marker + stringify_object(self.str, output_format) def __repr__(self) -> str: return self.__str__() diff --git a/godot_parser/output.py b/godot_parser/output.py index 43367b9..76dcb2e 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -14,6 +14,7 @@ def __init__( packed_byte_array_base64_support: bool = True, packed_vector4_array_support: bool = True, typed_dictionary_support: bool = True, + string_name_support=True, load_steps: bool = False, packed_array_format="Packed%sArray", ): @@ -23,6 +24,7 @@ def __init__( self.packed_byte_array_base64_support = packed_byte_array_base64_support self.packed_vector4_array_support = packed_vector4_array_support self.typed_dictionary_support = typed_dictionary_support + self.string_name_support = string_name_support self.load_steps = load_steps self.packed_array_format = packed_array_format @@ -72,6 +74,7 @@ def __init__(self, version: Union[str, Version]): packed_byte_array_base64_support=version >= self.__V43, packed_vector4_array_support=version >= self.__V43, typed_dictionary_support=version >= self.__V44, + string_name_support=version >= self.__V40, load_steps=version < self.__V46, packed_array_format=( "Pool%sArray" if version < self.__V40 else "Packed%sArray" diff --git a/tests/test_output.py b/tests/test_output.py index 86a6295..bfa3dc8 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -8,6 +8,7 @@ Vector3, TypedArray, GDObject, + StringName, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.objects import ( @@ -398,3 +399,23 @@ def test_packed_array_format(self): test1 = PoolByteArray(0, 1, 2, 3) test2 = PoolVector2Array(0, 1, 2, 3)\n""", ) + + def test_string_name(self): + resource = GDResource() + resource["strName"] = StringName("StringName") + + self.assertEqual( + resource.output_to_string(OutputFormat(string_name_support=True)), + """[gd_resource format=3] + +[resource] +strName = &"StringName"\n""", + ) + + self.assertEqual( + resource.output_to_string(OutputFormat(string_name_support=False)), + """[gd_resource format=3] + +[resource] +strName = "StringName"\n""", + ) From edf8ad99aa3438b4160dd9dbdfd6d1ace8561b8b Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 01:04:55 -0300 Subject: [PATCH 27/44] Fixes --- godot_parser/files.py | 9 +++++- godot_parser/id_generator.py | 13 ++++----- godot_parser/objects.py | 53 +++++++++++++++++++++++++++++++----- godot_parser/output.py | 4 +-- tests/test_gdfile.py | 45 ++++++++++++++++++++++-------- tests/test_output.py | 6 ++-- 6 files changed, 97 insertions(+), 33 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 6eefcf3..d5e951d 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -10,6 +10,7 @@ SubResource, PackedVector4Array, PackedByteArray, + GDIterable, ) from .output import Outputable, OutputFormat from .sections import ( @@ -312,6 +313,9 @@ def iter_resources(value): yield from iter_resources(k) for v in value.values(): yield from iter_resources(v) + elif isinstance(value, GDIterable): + yield value + yield from value._iter_objects() elif isinstance(value, GDObject): yield value for v in value.args: @@ -359,7 +363,10 @@ def _generate_resource_ids( ref.resource = section section.id = str(section.id) elif section.id is None: - section.id = output_format.generate_id(section, next_id) + section.id = "%s_%s" % ( + reference_type.get_id_key(next_id), + output_format.generate_id(section), + ) next_id += 1 else: ids = [s.id for s in sections if isinstance(s.id, int)] diff --git a/godot_parser/id_generator.py b/godot_parser/id_generator.py index 163ccaf..7fed62c 100644 --- a/godot_parser/id_generator.py +++ b/godot_parser/id_generator.py @@ -4,7 +4,7 @@ class BaseGenerator(object): - def generate(self, section: Any, index: int): + def generate(self, section: Any): return "" @@ -13,17 +13,14 @@ def __init__(self, length: int = 5, pool: str = ascii_lowercase + digits): self.length = length self.pool = pool - def generate(self, section: Any, index: int): - return "%s_%s" % ( - index, - "".join((choice(self.pool) for _ in range(self.length))), - ) + def generate(self, section: Any): + return "".join((choice(self.pool) for _ in range(self.length))) class SequentialHexGenerator(BaseGenerator): def __init__(self): self.counter = 0 - def generate(self, section: Any, index: int): + def generate(self, section: Any): self.counter += 1 - return "%s_%x" % (index, self.counter) + return "%x" % (self.counter) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 3107b51..dea9f1c 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -4,7 +4,7 @@ import re from functools import partial from math import floor -from typing import Optional, Type, TypeVar, Union, List, Any +from typing import Optional, Type, TypeVar, Union, List, Any, Iterable from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object @@ -360,6 +360,10 @@ def id(self, id: Union[int, str]) -> None: self.resource = id self.args = [id] + @classmethod + def get_id_key(cls, index: Optional[int] = None) -> str: + return "Resource" + def _output_to_string(self, output_format: OutputFormat) -> str: if isinstance(self.resource, Identifiable): id = self.resource.get_id() @@ -372,17 +376,26 @@ class ExtResource(ResourceReference): def __init__(self, resource: Union[int, str, Identifiable]) -> None: super().__init__("ExtResource", resource) + @classmethod + def get_id_key(cls, index: Optional[int] = None) -> str: + return str(index) + class SubResource(ResourceReference): def __init__(self, resource: Union[int, str, Identifiable]) -> None: super().__init__("SubResource", resource) -class TypedArray(Outputable): +class GDIterable: + def _iter_objects(self) -> Iterable[Any]: + return iter([]) + + +class TypedArray(GDIterable, Outputable): def __init__(self, type, list_) -> None: self.name = "Array" self.type = type - self.list_ = list_ + self.list_: list = list_ @classmethod def WithCustomName(cls: Type["TypedArray"], name, type, list_) -> "TypedArray": @@ -398,7 +411,11 @@ def _output_to_string(self, output_format: OutputFormat) -> str: if output_format.typed_array_support: return ( self.name - + output_format.surround_brackets(self.type) + + output_format.surround_brackets( + self.type.output_to_string(output_format) + if isinstance(self.type, Outputable) + else self.type + ) + output_format.surround_parentheses( stringify_object(self.list_, output_format) ) @@ -424,13 +441,17 @@ def __ne__(self, other) -> bool: def __hash__(self): return hash(frozenset((self.name, self.type, self.list_))) + def _iter_objects(self) -> Iterable[Any]: + yield self.type + yield from self.list_ -class TypedDictionary(Outputable): + +class TypedDictionary(GDIterable, Outputable): def __init__(self, key_type, value_type, dict_) -> None: self.name = "Dictionary" self.key_type = key_type self.value_type = value_type - self.dict_ = dict_ + self.dict_: dict = dict_ @classmethod def WithCustomName( @@ -449,7 +470,19 @@ def _output_to_string(self, output_format: OutputFormat) -> str: return ( self.name + output_format.surround_brackets( - "%s, %s" % (self.key_type, self.value_type) + "%s, %s" + % ( + ( + self.key_type.output_to_string(output_format) + if isinstance(self.key_type, Outputable) + else self.key_type + ), + ( + self.value_type.output_to_string(output_format) + if isinstance(self.value_type, Outputable) + else self.value_type + ), + ) ) + output_format.surround_parentheses( stringify_object(self.dict_, output_format) @@ -477,6 +510,12 @@ def __ne__(self, other) -> bool: def __hash__(self): return hash(frozenset((self.name, self.key_type, self.value_type, self.dict_))) + def _iter_objects(self) -> Iterable[Any]: + yield self.key_type + yield self.value_type + yield from self.dict_.keys() + yield from self.dict_.values() + class StringName(Outputable): def __init__(self, str) -> None: diff --git a/godot_parser/output.py b/godot_parser/output.py index 76dcb2e..b1fad55 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -51,8 +51,8 @@ def surround_parentheses(self, content: str) -> str: def surround_brackets(self, content: str) -> str: return self.surround_string(("[", "]"), content) - def generate_id(self, section: Any, index: int) -> str: - return self._id_generator.generate(section, index) + def generate_id(self, section: Any) -> str: + return self._id_generator.generate(section) class VersionOutputFormat(OutputFormat): diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 241a514..fc3bc94 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -8,6 +8,8 @@ GDResource, GDResourceSection, Node, + TypedArray, + TypedDictionary, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat @@ -66,7 +68,7 @@ def test_sub_resource(self): scene.output_to_string(self.test_output_format), """[gd_scene format=3] -[sub_resource type="Animation" id="1_1"] +[sub_resource type="Animation" id="Resource_1"] """, ) @@ -207,8 +209,8 @@ def test_addremove_sub_res(self): res = scene.add_sub_resource("CircleShape2D") res2 = scene.add_sub_resource("AnimationNodeAnimation") scene.generate_resource_ids(self.test_output_format) - self.assertEqual(res.id, "1_1") - self.assertEqual(res2.id, "2_2") + self.assertEqual(res.id, "Resource_1") + self.assertEqual(res2.id, "Resource_2") resource = GDResourceSection(shape=res2.reference) scene.add_section(resource) @@ -216,7 +218,7 @@ def test_addremove_sub_res(self): scene.remove_section(s) s = scene.find_section("sub_resource") - self.assertEqual(s.id, "2_2") + self.assertEqual(s.id, "Resource_2") self.assertEqual(resource["shape"], s.reference) def test_remove_unused_nested(self): @@ -246,6 +248,33 @@ def test_remove_unused_nested(self): self.assertNotIn(res2, res._sections) self.assertNotIn(res3, res._sections) + def test_remove_unused_typed(self): + """Won't remove used types""" + resource = GDResource() + + script1 = resource.add_ext_resource("res://custom_resource.gd", "Script") + sub_res1 = resource.add_sub_resource("Resource") + + script2 = resource.add_ext_resource("res://custom_resource_2.gd", "Script") + sub_res2 = resource.add_sub_resource("Resource") + + resource.add_ext_resource("res://custom_resource_3.gd", "Script") + + resource["typedArray"] = TypedArray(script1.reference, [sub_res1.reference]) + resource["typedDict"] = TypedDictionary( + script2.reference, "String", {sub_res2.reference: "Cool"} + ) + + resource.generate_resource_ids() + + self.assertEqual(len(resource.get_sub_resources()), 2) + self.assertEqual(len(resource.get_ext_resources()), 3) + + resource.remove_unused_resources() + + self.assertEqual(len(resource.get_sub_resources()), 2) + self.assertEqual(len(resource.get_ext_resources()), 2) + def test_find_constraints(self): """Test for the find_section constraints""" scene = GDPackedScene() @@ -297,15 +326,7 @@ def test_renumber_ids(self): self.assertEqual(res2.id, 1) - -class Godot4Test(unittest.TestCase): def test_string_special_characters(self): - """ - Testing strings with multiple special characters. Currently matching Godot 4.6 behavior - - Tab handling is done by calling parse_with_tabs before parse_string - For this reason, this test is being done at a GDFile level, where this method is called upon parsing - """ res = GDResource(str_value="\ta\"q'é'd\"\n\n\\") self.assertEqual( str(res), diff --git a/tests/test_output.py b/tests/test_output.py index bfa3dc8..623b0eb 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -152,7 +152,7 @@ def test_load_steps(self): [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] -[sub_resource type="CustomResource" id="1_2"] +[sub_resource type="CustomResource" id="Resource_2"] [resource] toggle = true\n""", @@ -163,7 +163,7 @@ def test_load_steps(self): [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] -[sub_resource type="CustomResource" id="1_2"] +[sub_resource type="CustomResource" id="Resource_2"] [resource] toggle = true\n""", @@ -203,7 +203,7 @@ def test_resource_ids_as_string(self): [ext_resource path="res://a.tres" type="CustomResource" id="1_1"] -[sub_resource type="CustomResource" id="1_2"] +[sub_resource type="CustomResource" id="Resource_2"] [resource] toggle = true\n""", From 9ddab93a9d20c2a33220855b790a7a77a30d01c1 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 01:30:52 -0300 Subject: [PATCH 28/44] Version tests --- tests/test_versions.py | 249 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 tests/test_versions.py diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..6fa93cf --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,249 @@ +import base64 +import unittest + +from godot_parser import GDPackedScene, StringName, TypedArray, NodePath +from godot_parser.id_generator import SequentialHexGenerator +from godot_parser.objects import ( + PackedByteArray, + PackedVector4Array, + Vector4, + TypedDictionary, +) +from godot_parser.output import VersionOutputFormat + + +class TestVersions(unittest.TestCase): + def setUp(self): + self.scene = GDPackedScene() + root = self.scene.add_node("Root", "Node3D") + child = self.scene.add_node("Child", "Node", ".") + + ext1 = self.scene.add_ext_resource("res://external.tres", "CustomResource1") + root["resource1"] = ext1.reference + + extScript = self.scene.add_ext_resource("res://custom_resource.gd", "Script") + extScript2 = self.scene.add_ext_resource("res://custom_resource_2.gd", "Script") + + sub1 = self.scene.add_sub_resource("Resource") + sub1["script"] = extScript.reference + + sub2 = self.scene.add_sub_resource("Resource") + sub2["script"] = extScript.reference + + sub3 = self.scene.add_sub_resource("Resource") + sub3["script"] = extScript2.reference + + root["resourceArray"] = TypedArray( + extScript.reference, [sub1.reference, sub2.reference] + ) + root["typedDict"] = TypedDictionary( + "StringName", + extScript2.reference, + {StringName("Two"): sub3.reference}, + ) + + byte_array = bytes([10, 20, 15]) + self.byte_array_base64 = base64.b64encode(byte_array).decode("utf-8") + + child["str"] = "'hello\\me'\n\"HI\"" + child["string name"] = StringName("StringName") + child["packedByte"] = PackedByteArray(byte_array) + child["packedVector4"] = PackedVector4Array([Vector4(1, 3, 5, 7)]) + child["nodepath"] = NodePath(".") + + def test_godot_3(self): + self.assertEqual( + self.scene.output_to_string(VersionOutputFormat("3.6")), + """[gd_scene load_steps=7 format=2] + +[ext_resource path="res://external.tres" type="CustomResource1" id=1] + +[ext_resource path="res://custom_resource.gd" type="Script" id=2] + +[ext_resource path="res://custom_resource_2.gd" type="Script" id=3] + +[sub_resource type="Resource" id=1] +script = ExtResource( 2 ) + +[sub_resource type="Resource" id=2] +script = ExtResource( 2 ) + +[sub_resource type="Resource" id=3] +script = ExtResource( 3 ) + +[node name="Root" type="Node3D"] +resource1 = ExtResource( 1 ) +resourceArray = [ SubResource( 1 ), SubResource( 2 ) ] +typedDict = { +"Two": SubResource( 3 ) +} + +[node name="Child" type="Node" parent="."] +str = "'hello\\\\me' +\\\"HI\\\"" +"string name" = "StringName" +packedByte = PoolByteArray( 10, 20, 15 ) +packedVector4 = [ Vector4( 1, 3, 5, 7 ) ] +nodepath = NodePath( "." ) +""", + ) + + def test_godot_4_0(self): + output_format = VersionOutputFormat("4.0") + output_format._id_generator = SequentialHexGenerator() + + self.assertEqual( + self.scene.output_to_string(output_format), + """[gd_scene load_steps=7 format=3] + +[ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] + +[ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] + +[ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] + +[sub_resource type="Resource" id="Resource_4"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_5"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_6"] +script = ExtResource("3_3") + +[node name="Root" type="Node3D"] +resource1 = ExtResource("1_1") +resourceArray = Array[ExtResource("2_2")]([SubResource("Resource_4"), SubResource("Resource_5")]) +typedDict = { +&"Two": SubResource("Resource_6") +} + +[node name="Child" type="Node" parent="."] +str = "'hello\\\\me' +\\\"HI\\\"" +"string name" = &"StringName" +packedByte = PackedByteArray(10, 20, 15) +packedVector4 = Array[Vector4]([Vector4(1, 3, 5, 7)]) +nodepath = NodePath(".") +""", + ) + + def test_godot_4_3(self): + output_format = VersionOutputFormat("4.3") + output_format._id_generator = SequentialHexGenerator() + + self.assertEqual( + self.scene.output_to_string(output_format), + """[gd_scene load_steps=7 format=4] + +[ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] + +[ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] + +[ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] + +[sub_resource type="Resource" id="Resource_4"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_5"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_6"] +script = ExtResource("3_3") + +[node name="Root" type="Node3D"] +resource1 = ExtResource("1_1") +resourceArray = Array[ExtResource("2_2")]([SubResource("Resource_4"), SubResource("Resource_5")]) +typedDict = { +&"Two": SubResource("Resource_6") +} + +[node name="Child" type="Node" parent="."] +str = "'hello\\\\me' +\\\"HI\\\"" +"string name" = &"StringName" +packedByte = PackedByteArray("%s") +packedVector4 = PackedVector4Array(1, 3, 5, 7) +nodepath = NodePath(".") +""" % self.byte_array_base64, + ) + + def test_godot_4_4(self): + output_format = VersionOutputFormat("4.4") + output_format._id_generator = SequentialHexGenerator() + + self.assertEqual( + self.scene.output_to_string(output_format), + """[gd_scene load_steps=7 format=4] + +[ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] + +[ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] + +[ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] + +[sub_resource type="Resource" id="Resource_4"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_5"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_6"] +script = ExtResource("3_3") + +[node name="Root" type="Node3D"] +resource1 = ExtResource("1_1") +resourceArray = Array[ExtResource("2_2")]([SubResource("Resource_4"), SubResource("Resource_5")]) +typedDict = Dictionary[StringName, ExtResource("3_3")]({ +&"Two": SubResource("Resource_6") +}) + +[node name="Child" type="Node" parent="."] +str = "'hello\\\\me' +\\\"HI\\\"" +"string name" = &"StringName" +packedByte = PackedByteArray("%s") +packedVector4 = PackedVector4Array(1, 3, 5, 7) +nodepath = NodePath(".") +""" % self.byte_array_base64, + ) + + def test_godot_4_6(self): + output_format = VersionOutputFormat("4.6") + output_format._id_generator = SequentialHexGenerator() + + self.assertEqual( + self.scene.output_to_string(output_format), + """[gd_scene format=4] + +[ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] + +[ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] + +[ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] + +[sub_resource type="Resource" id="Resource_4"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_5"] +script = ExtResource("2_2") + +[sub_resource type="Resource" id="Resource_6"] +script = ExtResource("3_3") + +[node name="Root" type="Node3D"] +resource1 = ExtResource("1_1") +resourceArray = Array[ExtResource("2_2")]([SubResource("Resource_4"), SubResource("Resource_5")]) +typedDict = Dictionary[StringName, ExtResource("3_3")]({ +&"Two": SubResource("Resource_6") +}) + +[node name="Child" type="Node" parent="."] +str = "'hello\\\\me' +\\\"HI\\\"" +"string name" = &"StringName" +packedByte = PackedByteArray("%s") +packedVector4 = PackedVector4Array(1, 3, 5, 7) +nodepath = NodePath(".") +""" % self.byte_array_base64, + ) From 9e0d022f6b5c9643789393ba3cfe6ad914c63753 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 02:44:56 -0300 Subject: [PATCH 29/44] Testing with real projects and fixed accordingly --- godot_parser/files.py | 36 ++++++++++++---- godot_parser/objects.py | 26 ++++++++---- godot_parser/output.py | 4 ++ godot_parser/sections.py | 25 +++++++++-- godot_parser/util.py | 5 +++ tests/projects/3.3/custom_node.gd | 7 ++++ tests/projects/3.3/custom_resource.gd | 11 +++++ tests/projects/3.3/fresh_resource.tres | 13 ++++++ tests/projects/3.3/project.godot | 37 ++++++++++++++++ tests/projects/3.3/resource.tres | 13 ++++++ tests/projects/3.3/scene.tscn | 29 +++++++++++++ tests/projects/3.4/custom_node.gd | 7 ++++ tests/projects/3.4/custom_resource.gd | 11 +++++ tests/projects/3.4/fresh_resource.tres | 13 ++++++ tests/projects/3.4/project.godot | 37 ++++++++++++++++ tests/projects/3.4/resource.tres | 13 ++++++ tests/projects/3.4/scene.tscn | 29 +++++++++++++ tests/projects/3.5/custom_node.gd | 7 ++++ tests/projects/3.5/custom_resource.gd | 11 +++++ tests/projects/3.5/fresh_resource.tres | 13 ++++++ tests/projects/3.5/project.godot | 37 ++++++++++++++++ tests/projects/3.5/resource.tres | 13 ++++++ tests/projects/3.5/scene.tscn | 29 +++++++++++++ tests/projects/3.6/custom_node.gd | 7 ++++ tests/projects/3.6/custom_resource.gd | 11 +++++ tests/projects/3.6/fresh_resource.tres | 13 ++++++ tests/projects/3.6/project.godot | 37 ++++++++++++++++ tests/projects/3.6/resource.tres | 13 ++++++ tests/projects/3.6/scene.tscn | 29 +++++++++++++ tests/projects/4.0/custom_node.gd | 13 ++++++ tests/projects/4.0/custom_resource.gd | 15 +++++++ tests/projects/4.0/fresh_resource.tres | 13 ++++++ tests/projects/4.0/project.godot | 15 +++++++ tests/projects/4.0/resource.tres | 13 ++++++ tests/projects/4.0/scene.tscn | 41 ++++++++++++++++++ tests/projects/4.1/custom_node.gd | 13 ++++++ tests/projects/4.1/custom_resource.gd | 15 +++++++ tests/projects/4.1/fresh_resource.tres | 13 ++++++ tests/projects/4.1/project.godot | 15 +++++++ tests/projects/4.1/resource.tres | 13 ++++++ tests/projects/4.1/scene.tscn | 41 ++++++++++++++++++ tests/projects/4.2/custom_node.gd | 13 ++++++ tests/projects/4.2/custom_resource.gd | 15 +++++++ tests/projects/4.2/fresh_resource.tres | 13 ++++++ tests/projects/4.2/project.godot | 15 +++++++ tests/projects/4.2/resource.tres | 13 ++++++ tests/projects/4.2/scene.tscn | 41 ++++++++++++++++++ tests/projects/4.3/custom_node.gd | 13 ++++++ tests/projects/4.3/custom_resource.gd | 15 +++++++ tests/projects/4.3/fresh_resource.tres | 14 +++++++ tests/projects/4.3/project.godot | 15 +++++++ tests/projects/4.3/resource.tres | 14 +++++++ tests/projects/4.3/scene.tscn | 43 +++++++++++++++++++ tests/projects/4.4/custom_node.gd | 13 ++++++ tests/projects/4.4/custom_node.gd.uid | 1 + tests/projects/4.4/custom_resource.gd | 15 +++++++ tests/projects/4.4/custom_resource.gd.uid | 1 + tests/projects/4.4/fresh_resource.tres | 16 +++++++ tests/projects/4.4/project.godot | 15 +++++++ tests/projects/4.4/resource.tres | 15 +++++++ tests/projects/4.4/scene.tscn | 48 +++++++++++++++++++++ tests/projects/4.5/custom_node.gd | 13 ++++++ tests/projects/4.5/custom_node.gd.uid | 1 + tests/projects/4.5/custom_resource.gd | 15 +++++++ tests/projects/4.5/custom_resource.gd.uid | 1 + tests/projects/4.5/fresh_resource.tres | 7 ++++ tests/projects/4.5/project.godot | 15 +++++++ tests/projects/4.5/resource.tres | 7 ++++ tests/projects/4.5/scene.tscn | 39 +++++++++++++++++ tests/projects/4.6/custom_node.gd | 13 ++++++ tests/projects/4.6/custom_node.gd.uid | 1 + tests/projects/4.6/custom_resource.gd | 15 +++++++ tests/projects/4.6/custom_resource.gd.uid | 1 + tests/projects/4.6/fresh_resource.tres | 7 ++++ tests/projects/4.6/project.godot | 19 +++++++++ tests/projects/4.6/resource.tres | 7 ++++ tests/projects/4.6/scene.tscn | 39 +++++++++++++++++ tests/test_objects.py | 6 ++- tests/test_output.py | 26 ++++++++++-- tests/test_versions.py | 51 ++++++++++++++++------- 80 files changed, 1350 insertions(+), 37 deletions(-) create mode 100644 tests/projects/3.3/custom_node.gd create mode 100644 tests/projects/3.3/custom_resource.gd create mode 100644 tests/projects/3.3/fresh_resource.tres create mode 100644 tests/projects/3.3/project.godot create mode 100644 tests/projects/3.3/resource.tres create mode 100644 tests/projects/3.3/scene.tscn create mode 100644 tests/projects/3.4/custom_node.gd create mode 100644 tests/projects/3.4/custom_resource.gd create mode 100644 tests/projects/3.4/fresh_resource.tres create mode 100644 tests/projects/3.4/project.godot create mode 100644 tests/projects/3.4/resource.tres create mode 100644 tests/projects/3.4/scene.tscn create mode 100644 tests/projects/3.5/custom_node.gd create mode 100644 tests/projects/3.5/custom_resource.gd create mode 100644 tests/projects/3.5/fresh_resource.tres create mode 100644 tests/projects/3.5/project.godot create mode 100644 tests/projects/3.5/resource.tres create mode 100644 tests/projects/3.5/scene.tscn create mode 100644 tests/projects/3.6/custom_node.gd create mode 100644 tests/projects/3.6/custom_resource.gd create mode 100644 tests/projects/3.6/fresh_resource.tres create mode 100644 tests/projects/3.6/project.godot create mode 100644 tests/projects/3.6/resource.tres create mode 100644 tests/projects/3.6/scene.tscn create mode 100644 tests/projects/4.0/custom_node.gd create mode 100644 tests/projects/4.0/custom_resource.gd create mode 100644 tests/projects/4.0/fresh_resource.tres create mode 100644 tests/projects/4.0/project.godot create mode 100644 tests/projects/4.0/resource.tres create mode 100644 tests/projects/4.0/scene.tscn create mode 100644 tests/projects/4.1/custom_node.gd create mode 100644 tests/projects/4.1/custom_resource.gd create mode 100644 tests/projects/4.1/fresh_resource.tres create mode 100644 tests/projects/4.1/project.godot create mode 100644 tests/projects/4.1/resource.tres create mode 100644 tests/projects/4.1/scene.tscn create mode 100644 tests/projects/4.2/custom_node.gd create mode 100644 tests/projects/4.2/custom_resource.gd create mode 100644 tests/projects/4.2/fresh_resource.tres create mode 100644 tests/projects/4.2/project.godot create mode 100644 tests/projects/4.2/resource.tres create mode 100644 tests/projects/4.2/scene.tscn create mode 100644 tests/projects/4.3/custom_node.gd create mode 100644 tests/projects/4.3/custom_resource.gd create mode 100644 tests/projects/4.3/fresh_resource.tres create mode 100644 tests/projects/4.3/project.godot create mode 100644 tests/projects/4.3/resource.tres create mode 100644 tests/projects/4.3/scene.tscn create mode 100644 tests/projects/4.4/custom_node.gd create mode 100644 tests/projects/4.4/custom_node.gd.uid create mode 100644 tests/projects/4.4/custom_resource.gd create mode 100644 tests/projects/4.4/custom_resource.gd.uid create mode 100644 tests/projects/4.4/fresh_resource.tres create mode 100644 tests/projects/4.4/project.godot create mode 100644 tests/projects/4.4/resource.tres create mode 100644 tests/projects/4.4/scene.tscn create mode 100644 tests/projects/4.5/custom_node.gd create mode 100644 tests/projects/4.5/custom_node.gd.uid create mode 100644 tests/projects/4.5/custom_resource.gd create mode 100644 tests/projects/4.5/custom_resource.gd.uid create mode 100644 tests/projects/4.5/fresh_resource.tres create mode 100644 tests/projects/4.5/project.godot create mode 100644 tests/projects/4.5/resource.tres create mode 100644 tests/projects/4.5/scene.tscn create mode 100644 tests/projects/4.6/custom_node.gd create mode 100644 tests/projects/4.6/custom_node.gd.uid create mode 100644 tests/projects/4.6/custom_resource.gd create mode 100644 tests/projects/4.6/custom_resource.gd.uid create mode 100644 tests/projects/4.6/fresh_resource.tres create mode 100644 tests/projects/4.6/project.godot create mode 100644 tests/projects/4.6/resource.tres create mode 100644 tests/projects/4.6/scene.tscn diff --git a/godot_parser/files.py b/godot_parser/files.py index d5e951d..8b9c31e 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -21,6 +21,7 @@ GDSection, GDSectionHeader, GDSubResourceSection, + GDFileHeader, ) from .structure import scene_file from .util import find_project_root, gdpath_to_filepath @@ -213,10 +214,23 @@ def write(self, filename: str, output_format: Optional[OutputFormat] = None): ofile.write(self.output_to_string(output_format)) def _output_to_string(self, output_format: OutputFormat) -> str: - return ( - "\n\n".join([s.output_to_string(output_format) for s in self._sections]) - + "\n" - ) + output = "" + + last_section = None + + for cur_section in self._sections: + if ( + last_section is not None + and isinstance(cur_section, GDExtResourceSection) + and isinstance(last_section, GDExtResourceSection) + ): + output = output[:-1] + + output += cur_section.output_to_string(output_format) + "\n\n" + + last_section = cur_section + + return output.rstrip() + "\n" def __repr__(self) -> str: return "%s(%s)" % (type(self).__name__, self.__str__()) @@ -234,7 +248,7 @@ class GDCommonFile(GDFile): """Base class with common application logic for all Godot file types""" def __init__(self, name: str, *sections: GDSection) -> None: - super().__init__(GDSection(GDSectionHeader(name)), *sections) + super().__init__(GDSection(GDFileHeader(name)), *sections) def _output_to_string(self, output_format: OutputFormat) -> str: self.generate_resource_ids(output_format) @@ -263,6 +277,11 @@ def _output_to_string(self, output_format: OutputFormat) -> str: and output_format.packed_byte_array_base64_support ): header["format"] = 4 + if output_format._force_format_4_if_available and ( + output_format.packed_byte_array_base64_support + or output_format.packed_vector4_array_support + ): + header["format"] = 4 else: header["format"] = 2 @@ -413,8 +432,11 @@ def __init__( if type is not None: self._sections[0].header["type"] = type - self.resource_section = GDResourceSection(**attributes) - self.add_section(self.resource_section) + self.add_section(GDResourceSection(**attributes)) + + @property + def resource_section(self): + return self.find_section("resource") def __contains__(self, k: str) -> bool: return k in self.resource_section diff --git a/godot_parser/objects.py b/godot_parser/objects.py index dea9f1c..fc32dab 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -257,10 +257,12 @@ def a(self, a: float) -> None: class PackedVector4Array(GDObject): - def __init__(self, list_: List[Vector4]) -> None: - super().__init__( - "PackedVector4Array", *sum([[v.x, v.y, v.z, v.w] for v in list_], []) - ) + def __init__(self, *args) -> None: + super().__init__("PackedVector4Array", *args) + + @classmethod + def FromList(cls, list_: List[Vector4]) -> "PackedVector4Array": + return cls(*sum([[v.x, v.y, v.z, v.w] for v in list_], [])) def __contains__(self, idx: int) -> bool: return len(self.args) > (idx * 4) @@ -295,8 +297,12 @@ def _output_to_string(self, output_format: OutputFormat) -> str: class PackedByteArray(GDObject): - def __init__(self, bytes_: bytes) -> None: - super().__init__("PackedByteArray", *list(bytes_)) + def __init__(self, *args) -> None: + super().__init__("PackedByteArray", *args) + + @classmethod + def FromBytes(cls, bytes_: bytes) -> "PackedByteArray": + return cls(*list(bytes_)) def __stored_as_base64(self) -> bool: return len(self.args) == 1 and isinstance(self.args[0], str) @@ -334,8 +340,12 @@ def path(self, path: str) -> None: """Setter for path""" self.args[0] = path - def __str__(self) -> str: - return '%s("%s")' % (self.name, self.path) + def _output_to_string(self, output_format: OutputFormat) -> str: + original_punctuation_spaces = output_format.punctuation_spaces + output_format.punctuation_spaces = False + ret = super()._output_to_string(output_format) + output_format.punctuation_spaces = original_punctuation_spaces + return ret class ResourceReference(GDObject): diff --git a/godot_parser/output.py b/godot_parser/output.py index b1fad55..a01b597 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -9,6 +9,7 @@ class OutputFormat(object): def __init__( self, punctuation_spaces: bool = False, + single_line_on_empty_dict: bool = True, resource_ids_as_strings: bool = True, typed_array_support: bool = True, packed_byte_array_base64_support: bool = True, @@ -19,6 +20,7 @@ def __init__( packed_array_format="Packed%sArray", ): self.punctuation_spaces = punctuation_spaces + self.single_line_on_empty_dict = single_line_on_empty_dict self.resource_ids_as_strings = resource_ids_as_strings self.typed_array_support = typed_array_support self.packed_byte_array_base64_support = packed_byte_array_base64_support @@ -28,6 +30,7 @@ def __init__( self.load_steps = load_steps self.packed_array_format = packed_array_format + self._force_format_4_if_available = False self._id_generator = RandomIdGenerator() def surround_string( @@ -69,6 +72,7 @@ def __init__(self, version: Union[str, Version]): super().__init__( punctuation_spaces=version < self.__V40, + single_line_on_empty_dict=version >= self.__V40, resource_ids_as_strings=version >= self.__V40, typed_array_support=version >= self.__V40, packed_byte_array_base64_support=version >= self.__V43, diff --git a/godot_parser/sections.py b/godot_parser/sections.py index c7d2f60..7412f5a 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -52,18 +52,30 @@ def get(self, k: str, default: Any = None) -> Any: @classmethod def from_parser(cls: Type["GDSectionHeader"], parse_result) -> "GDSectionHeader": - header = cls(parse_result[0]) + factory = cls + + if parse_result[0] in ["gd_resource", "gd_scene"]: + factory = GDFileHeader + + header = factory(parse_result[0]) for attribute in parse_result[1:]: header.attributes[attribute[0]] = attribute[1] return header + def _get_key_priority(self, key: str) -> int: + return 0 + def _output_to_string(self, output_format: OutputFormat) -> str: attribute_str = "" if self.attributes: + keys = sorted( + self.attributes.keys(), key=self._get_key_priority, reverse=True + ) + attribute_str = " " + " ".join( [ - "%s=%s" % (k, stringify_object(v, output_format)) - for k, v in self.attributes.items() + "%s=%s" % (k, stringify_object(self.attributes[k], output_format)) + for k in keys ] ) return "[" + self.name + attribute_str + "]" @@ -80,6 +92,13 @@ def __ne__(self, other: Any) -> bool: return not self.__eq__(other) +class GDFileHeader(GDSectionHeader): + def _get_key_priority(self, key: str) -> int: + if key == "uid": + return -10 + return super()._get_key_priority(key) + + class GDSectionMeta(type): """Still trying to be too clever""" diff --git a/godot_parser/util.py b/godot_parser/util.py index 47f41bb..a297df3 100644 --- a/godot_parser/util.py +++ b/godot_parser/util.py @@ -15,6 +15,11 @@ def stringify_object(value, output_format: OutputFormat = OutputFormat()): elif isinstance(value, bool): return "true" if value else "false" elif isinstance(value, dict): + if len(value.values()) == 0: + if output_format.single_line_on_empty_dict: + return "{}" + else: + return "{\n}" return ( "{\n" + ",\n".join( diff --git a/tests/projects/3.3/custom_node.gd b/tests/projects/3.3/custom_node.gd new file mode 100644 index 0000000..8f96e74 --- /dev/null +++ b/tests/projects/3.3/custom_node.gd @@ -0,0 +1,7 @@ +extends Node +class_name CustomNode + +export var godot_version : String; +export var internalResource : Resource; +export var externalResource : Resource; +export(NodePath) var remoteNode; diff --git a/tests/projects/3.3/custom_resource.gd b/tests/projects/3.3/custom_resource.gd new file mode 100644 index 0000000..d3523ac --- /dev/null +++ b/tests/projects/3.3/custom_resource.gd @@ -0,0 +1,11 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +export(String, MULTILINE) var test_string : String; +export var test_vector3 : Vector3; +export(CustomEnum) var test_enum; +export(Array, Vector3) var test_array_vector3 : Array; +export(Dictionary) var test_dict : Dictionary; +export var test_array_pool : PoolVector2Array; diff --git a/tests/projects/3.3/fresh_resource.tres b/tests/projects/3.3/fresh_resource.tres new file mode 100644 index 0000000..3e0f964 --- /dev/null +++ b/tests/projects/3.3/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.3/project.godot b/tests/projects/3.3/project.godot new file mode 100644 index 0000000..0e81eff --- /dev/null +++ b/tests/projects/3.3/project.godot @@ -0,0 +1,37 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +_global_script_classes=[ { +"base": "Node", +"class": "CustomNode", +"language": "GDScript", +"path": "res://custom_node.gd" +}, { +"base": "Resource", +"class": "CustomResource", +"language": "GDScript", +"path": "res://custom_resource.gd" +} ] +_global_script_class_icons={ +"CustomNode": "", +"CustomResource": "" +} + +[application] + +config/name="SerializationTest" + +[global] + +env=false + +[physics] + +common/enable_pause_aware_picking=true diff --git a/tests/projects/3.3/resource.tres b/tests/projects/3.3/resource.tres new file mode 100644 index 0000000..278fc49 --- /dev/null +++ b/tests/projects/3.3/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "test" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.3/scene.tscn b/tests/projects/3.3/scene.tscn new file mode 100644 index 0000000..db108df --- /dev/null +++ b/tests/projects/3.3/scene.tscn @@ -0,0 +1,29 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://custom_node.gd" type="Script" id=1] +[ext_resource path="res://custom_resource.gd" type="Script" id=2] +[ext_resource path="res://resource.tres" type="Resource" id=3] + +[sub_resource type="Resource" id=1] +script = ExtResource( 2 ) +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3( 9, 0, 0 ) +test_enum = 1 +test_array_vector3 = [ Vector3( 0, 1, 0 ), Vector3( 2, 0, 2 ) ] +test_dict = { +1: "One", +1.1: "One dot One", +PoolIntArray( 2, 3 ): PoolByteArray( 255, 1, 1 ) +} +test_array_pool = PoolVector2Array( 50, 50, 20, 20 ) + +[node name="CustomNode" type="Node"] +script = ExtResource( 1 ) +godot_version = "3.3" +internalResource = SubResource( 1 ) +externalResource = ExtResource( 3 ) +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/3.4/custom_node.gd b/tests/projects/3.4/custom_node.gd new file mode 100644 index 0000000..8f96e74 --- /dev/null +++ b/tests/projects/3.4/custom_node.gd @@ -0,0 +1,7 @@ +extends Node +class_name CustomNode + +export var godot_version : String; +export var internalResource : Resource; +export var externalResource : Resource; +export(NodePath) var remoteNode; diff --git a/tests/projects/3.4/custom_resource.gd b/tests/projects/3.4/custom_resource.gd new file mode 100644 index 0000000..d3523ac --- /dev/null +++ b/tests/projects/3.4/custom_resource.gd @@ -0,0 +1,11 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +export(String, MULTILINE) var test_string : String; +export var test_vector3 : Vector3; +export(CustomEnum) var test_enum; +export(Array, Vector3) var test_array_vector3 : Array; +export(Dictionary) var test_dict : Dictionary; +export var test_array_pool : PoolVector2Array; diff --git a/tests/projects/3.4/fresh_resource.tres b/tests/projects/3.4/fresh_resource.tres new file mode 100644 index 0000000..3e0f964 --- /dev/null +++ b/tests/projects/3.4/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.4/project.godot b/tests/projects/3.4/project.godot new file mode 100644 index 0000000..0e81eff --- /dev/null +++ b/tests/projects/3.4/project.godot @@ -0,0 +1,37 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +_global_script_classes=[ { +"base": "Node", +"class": "CustomNode", +"language": "GDScript", +"path": "res://custom_node.gd" +}, { +"base": "Resource", +"class": "CustomResource", +"language": "GDScript", +"path": "res://custom_resource.gd" +} ] +_global_script_class_icons={ +"CustomNode": "", +"CustomResource": "" +} + +[application] + +config/name="SerializationTest" + +[global] + +env=false + +[physics] + +common/enable_pause_aware_picking=true diff --git a/tests/projects/3.4/resource.tres b/tests/projects/3.4/resource.tres new file mode 100644 index 0000000..278fc49 --- /dev/null +++ b/tests/projects/3.4/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "test" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.4/scene.tscn b/tests/projects/3.4/scene.tscn new file mode 100644 index 0000000..b98007a --- /dev/null +++ b/tests/projects/3.4/scene.tscn @@ -0,0 +1,29 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://custom_node.gd" type="Script" id=1] +[ext_resource path="res://custom_resource.gd" type="Script" id=2] +[ext_resource path="res://resource.tres" type="Resource" id=3] + +[sub_resource type="Resource" id=1] +script = ExtResource( 2 ) +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3( 9, 0, 0 ) +test_enum = 1 +test_array_vector3 = [ Vector3( 0, 1, 0 ), Vector3( 2, 0, 2 ) ] +test_dict = { +1: "One", +1.1: "One dot One", +PoolIntArray( 2, 3 ): PoolByteArray( 255, 1, 1 ) +} +test_array_pool = PoolVector2Array( 50, 50, 20, 20 ) + +[node name="CustomNode" type="Node"] +script = ExtResource( 1 ) +godot_version = "3.4" +internalResource = SubResource( 1 ) +externalResource = ExtResource( 3 ) +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/3.5/custom_node.gd b/tests/projects/3.5/custom_node.gd new file mode 100644 index 0000000..8f96e74 --- /dev/null +++ b/tests/projects/3.5/custom_node.gd @@ -0,0 +1,7 @@ +extends Node +class_name CustomNode + +export var godot_version : String; +export var internalResource : Resource; +export var externalResource : Resource; +export(NodePath) var remoteNode; diff --git a/tests/projects/3.5/custom_resource.gd b/tests/projects/3.5/custom_resource.gd new file mode 100644 index 0000000..d3523ac --- /dev/null +++ b/tests/projects/3.5/custom_resource.gd @@ -0,0 +1,11 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +export(String, MULTILINE) var test_string : String; +export var test_vector3 : Vector3; +export(CustomEnum) var test_enum; +export(Array, Vector3) var test_array_vector3 : Array; +export(Dictionary) var test_dict : Dictionary; +export var test_array_pool : PoolVector2Array; diff --git a/tests/projects/3.5/fresh_resource.tres b/tests/projects/3.5/fresh_resource.tres new file mode 100644 index 0000000..3e0f964 --- /dev/null +++ b/tests/projects/3.5/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.5/project.godot b/tests/projects/3.5/project.godot new file mode 100644 index 0000000..0e81eff --- /dev/null +++ b/tests/projects/3.5/project.godot @@ -0,0 +1,37 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +_global_script_classes=[ { +"base": "Node", +"class": "CustomNode", +"language": "GDScript", +"path": "res://custom_node.gd" +}, { +"base": "Resource", +"class": "CustomResource", +"language": "GDScript", +"path": "res://custom_resource.gd" +} ] +_global_script_class_icons={ +"CustomNode": "", +"CustomResource": "" +} + +[application] + +config/name="SerializationTest" + +[global] + +env=false + +[physics] + +common/enable_pause_aware_picking=true diff --git a/tests/projects/3.5/resource.tres b/tests/projects/3.5/resource.tres new file mode 100644 index 0000000..278fc49 --- /dev/null +++ b/tests/projects/3.5/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "test" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.5/scene.tscn b/tests/projects/3.5/scene.tscn new file mode 100644 index 0000000..e0591e1 --- /dev/null +++ b/tests/projects/3.5/scene.tscn @@ -0,0 +1,29 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://custom_node.gd" type="Script" id=1] +[ext_resource path="res://custom_resource.gd" type="Script" id=2] +[ext_resource path="res://resource.tres" type="Resource" id=3] + +[sub_resource type="Resource" id=1] +script = ExtResource( 2 ) +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3( 9, 0, 0 ) +test_enum = 1 +test_array_vector3 = [ Vector3( 0, 1, 0 ), Vector3( 2, 0, 2 ) ] +test_dict = { +1: "One", +1.1: "One dot One", +PoolIntArray( 2, 3 ): PoolByteArray( 255, 1, 1 ) +} +test_array_pool = PoolVector2Array( 50, 50, 20, 20 ) + +[node name="CustomNode" type="Node"] +script = ExtResource( 1 ) +godot_version = "3.5" +internalResource = SubResource( 1 ) +externalResource = ExtResource( 3 ) +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/3.6/custom_node.gd b/tests/projects/3.6/custom_node.gd new file mode 100644 index 0000000..8f96e74 --- /dev/null +++ b/tests/projects/3.6/custom_node.gd @@ -0,0 +1,7 @@ +extends Node +class_name CustomNode + +export var godot_version : String; +export var internalResource : Resource; +export var externalResource : Resource; +export(NodePath) var remoteNode; diff --git a/tests/projects/3.6/custom_resource.gd b/tests/projects/3.6/custom_resource.gd new file mode 100644 index 0000000..d3523ac --- /dev/null +++ b/tests/projects/3.6/custom_resource.gd @@ -0,0 +1,11 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +export(String, MULTILINE) var test_string : String; +export var test_vector3 : Vector3; +export(CustomEnum) var test_enum; +export(Array, Vector3) var test_array_vector3 : Array; +export(Dictionary) var test_dict : Dictionary; +export var test_array_pool : PoolVector2Array; diff --git a/tests/projects/3.6/fresh_resource.tres b/tests/projects/3.6/fresh_resource.tres new file mode 100644 index 0000000..3e0f964 --- /dev/null +++ b/tests/projects/3.6/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.6/project.godot b/tests/projects/3.6/project.godot new file mode 100644 index 0000000..0e81eff --- /dev/null +++ b/tests/projects/3.6/project.godot @@ -0,0 +1,37 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=4 + +_global_script_classes=[ { +"base": "Node", +"class": "CustomNode", +"language": "GDScript", +"path": "res://custom_node.gd" +}, { +"base": "Resource", +"class": "CustomResource", +"language": "GDScript", +"path": "res://custom_resource.gd" +} ] +_global_script_class_icons={ +"CustomNode": "", +"CustomResource": "" +} + +[application] + +config/name="SerializationTest" + +[global] + +env=false + +[physics] + +common/enable_pause_aware_picking=true diff --git a/tests/projects/3.6/resource.tres b/tests/projects/3.6/resource.tres new file mode 100644 index 0000000..278fc49 --- /dev/null +++ b/tests/projects/3.6/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" load_steps=2 format=2] + +[ext_resource path="res://custom_resource.gd" type="Script" id=1] + +[resource] +script = ExtResource( 1 ) +test_string = "test" +test_vector3 = Vector3( 0, 0, 0 ) +test_enum = 0 +test_array_vector3 = [ ] +test_dict = { +} +test_array_pool = PoolVector2Array( ) diff --git a/tests/projects/3.6/scene.tscn b/tests/projects/3.6/scene.tscn new file mode 100644 index 0000000..5ad3a3e --- /dev/null +++ b/tests/projects/3.6/scene.tscn @@ -0,0 +1,29 @@ +[gd_scene load_steps=5 format=2] + +[ext_resource path="res://custom_node.gd" type="Script" id=1] +[ext_resource path="res://custom_resource.gd" type="Script" id=2] +[ext_resource path="res://resource.tres" type="Resource" id=3] + +[sub_resource type="Resource" id=1] +script = ExtResource( 2 ) +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3( 9, 0, 0 ) +test_enum = 1 +test_array_vector3 = [ Vector3( 0, 1, 0 ), Vector3( 2, 0, 2 ) ] +test_dict = { +1: "One", +1.1: "One dot One", +PoolIntArray( 2, 3 ): PoolByteArray( 255, 1, 1 ) +} +test_array_pool = PoolVector2Array( 50, 50, 20, 20 ) + +[node name="CustomNode" type="Node"] +script = ExtResource( 1 ) +godot_version = "3.6" +internalResource = SubResource( 1 ) +externalResource = ExtResource( 3 ) +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.0/custom_node.gd b/tests/projects/4.0/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.0/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.0/custom_resource.gd b/tests/projects/4.0/custom_resource.gd new file mode 100644 index 0000000..6835c68 --- /dev/null +++ b/tests/projects/4.0/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +# @export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +# @export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.0/fresh_resource.tres b/tests/projects/4.0/fresh_resource.tres new file mode 100644 index 0000000..9642f94 --- /dev/null +++ b/tests/projects/4.0/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://bqjtw6e2p8hxm"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1_f8pg4"] + +[resource] +script = ExtResource("1_f8pg4") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.0/project.godot b/tests/projects/4.0/project.godot new file mode 100644 index 0000000..3828d54 --- /dev/null +++ b/tests/projects/4.0/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="SerializationTest" +config/features=PackedStringArray("4.0", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.0/resource.tres b/tests/projects/4.0/resource.tres new file mode 100644 index 0000000..17bbd40 --- /dev/null +++ b/tests/projects/4.0/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string_name = &"" +test_string = "test" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.0/scene.tscn b/tests/projects/4.0/scene.tscn new file mode 100644 index 0000000..f3b2440 --- /dev/null +++ b/tests/projects/4.0/scene.tscn @@ -0,0 +1,41 @@ +[gd_scene load_steps=6 format=3 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_pqy25"] +script = ExtResource("2") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray(255, 1, 1) +} +test_array_pool = PackedVector2Array(50, 50, 20, 20) +child_resource = SubResource("Resource_pqy25") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.0" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.1/custom_node.gd b/tests/projects/4.1/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.1/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.1/custom_resource.gd b/tests/projects/4.1/custom_resource.gd new file mode 100644 index 0000000..6835c68 --- /dev/null +++ b/tests/projects/4.1/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +# @export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +# @export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.1/fresh_resource.tres b/tests/projects/4.1/fresh_resource.tres new file mode 100644 index 0000000..19004db --- /dev/null +++ b/tests/projects/4.1/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://bqjtw6e2p8hxm"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1_8knv5"] + +[resource] +script = ExtResource("1_8knv5") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.1/project.godot b/tests/projects/4.1/project.godot new file mode 100644 index 0000000..6f4e5d1 --- /dev/null +++ b/tests/projects/4.1/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.1", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.1/resource.tres b/tests/projects/4.1/resource.tres new file mode 100644 index 0000000..17bbd40 --- /dev/null +++ b/tests/projects/4.1/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string_name = &"" +test_string = "test" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.1/scene.tscn b/tests/projects/4.1/scene.tscn new file mode 100644 index 0000000..33cb7af --- /dev/null +++ b/tests/projects/4.1/scene.tscn @@ -0,0 +1,41 @@ +[gd_scene load_steps=6 format=3 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_6ce7h"] +script = ExtResource("2") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray(255, 1, 1) +} +test_array_pool = PackedVector2Array(50, 50, 20, 20) +child_resource = SubResource("Resource_6ce7h") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.1" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.2/custom_node.gd b/tests/projects/4.2/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.2/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.2/custom_resource.gd b/tests/projects/4.2/custom_resource.gd new file mode 100644 index 0000000..6835c68 --- /dev/null +++ b/tests/projects/4.2/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +# @export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +# @export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.2/fresh_resource.tres b/tests/projects/4.2/fresh_resource.tres new file mode 100644 index 0000000..9c5e858 --- /dev/null +++ b/tests/projects/4.2/fresh_resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://bqjtw6e2p8hxm"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1_v7iwk"] + +[resource] +script = ExtResource("1_v7iwk") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.2/project.godot b/tests/projects/4.2/project.godot new file mode 100644 index 0000000..c61ddd6 --- /dev/null +++ b/tests/projects/4.2/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.2", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.2/resource.tres b/tests/projects/4.2/resource.tres new file mode 100644 index 0000000..17bbd40 --- /dev/null +++ b/tests/projects/4.2/resource.tres @@ -0,0 +1,13 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=3 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string_name = &"" +test_string = "test" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() diff --git a/tests/projects/4.2/scene.tscn b/tests/projects/4.2/scene.tscn new file mode 100644 index 0000000..5e02c68 --- /dev/null +++ b/tests/projects/4.2/scene.tscn @@ -0,0 +1,41 @@ +[gd_scene load_steps=6 format=3 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_l04e0"] +script = ExtResource("2") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray(255, 1, 1) +} +test_array_pool = PackedVector2Array(50, 50, 20, 20) +child_resource = SubResource("Resource_l04e0") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.2" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.3/custom_node.gd b/tests/projects/4.3/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.3/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.3/custom_resource.gd b/tests/projects/4.3/custom_resource.gd new file mode 100644 index 0000000..73b4ae2 --- /dev/null +++ b/tests/projects/4.3/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +# @export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +@export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.3/fresh_resource.tres b/tests/projects/4.3/fresh_resource.tres new file mode 100644 index 0000000..32764c2 --- /dev/null +++ b/tests/projects/4.3/fresh_resource.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://bqjtw6e2p8hxm"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1_eat75"] + +[resource] +script = ExtResource("1_eat75") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() diff --git a/tests/projects/4.3/project.godot b/tests/projects/4.3/project.godot new file mode 100644 index 0000000..aafeaa1 --- /dev/null +++ b/tests/projects/4.3/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.3", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.3/resource.tres b/tests/projects/4.3/resource.tres new file mode 100644 index 0000000..231c10f --- /dev/null +++ b/tests/projects/4.3/resource.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string_name = &"" +test_string = "test" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() diff --git a/tests/projects/4.3/scene.tscn b/tests/projects/4.3/scene.tscn new file mode 100644 index 0000000..44f5265 --- /dev/null +++ b/tests/projects/4.3/scene.tscn @@ -0,0 +1,43 @@ +[gd_scene load_steps=6 format=4 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_40tgu"] +script = ExtResource("2") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray("/wEB") +} +test_array_pool = PackedVector2Array(50, 50, 20, 20) +test_array_pool2 = PackedVector4Array(1, 2, 4, 8) +child_resource = SubResource("Resource_40tgu") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.3" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.4/custom_node.gd b/tests/projects/4.4/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.4/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.4/custom_node.gd.uid b/tests/projects/4.4/custom_node.gd.uid new file mode 100644 index 0000000..684b47a --- /dev/null +++ b/tests/projects/4.4/custom_node.gd.uid @@ -0,0 +1 @@ +uid://bvr5pt47ijphu diff --git a/tests/projects/4.4/custom_resource.gd b/tests/projects/4.4/custom_resource.gd new file mode 100644 index 0000000..67d5bc6 --- /dev/null +++ b/tests/projects/4.4/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +@export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +@export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.4/custom_resource.gd.uid b/tests/projects/4.4/custom_resource.gd.uid new file mode 100644 index 0000000..9399612 --- /dev/null +++ b/tests/projects/4.4/custom_resource.gd.uid @@ -0,0 +1 @@ +uid://d2fhcaebxigkv diff --git a/tests/projects/4.4/fresh_resource.tres b/tests/projects/4.4/fresh_resource.tres new file mode 100644 index 0000000..98dd895 --- /dev/null +++ b/tests/projects/4.4/fresh_resource.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://bqjtw6e2p8hxm"] + +[ext_resource type="Script" uid="uid://dwc8swu8kdiuo" path="res://custom_resource.gd" id="1_2f2vh"] + +[resource] +script = ExtResource("1_2f2vh") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_dict_int_str = Dictionary[int, String]({}) +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() +metadata/_custom_type_script = "uid://dwc8swu8kdiuo" diff --git a/tests/projects/4.4/project.godot b/tests/projects/4.4/project.godot new file mode 100644 index 0000000..3aeb29c --- /dev/null +++ b/tests/projects/4.4/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.4", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.4/resource.tres b/tests/projects/4.4/resource.tres new file mode 100644 index 0000000..ef86f72 --- /dev/null +++ b/tests/projects/4.4/resource.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string_name = &"" +test_string = "test" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_dict_int_str = Dictionary[int, String]({}) +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() diff --git a/tests/projects/4.4/scene.tscn b/tests/projects/4.4/scene.tscn new file mode 100644 index 0000000..4546133 --- /dev/null +++ b/tests/projects/4.4/scene.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=6 format=4 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" uid="uid://bvr5pt47ijphu" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_3253y"] +script = ExtResource("2") +test_string_name = &"" +test_string = "" +test_vector3 = Vector3(0, 0, 0) +test_enum = 0 +test_array_vector3 = Array[Vector3]([]) +test_dict = {} +test_dict_int_str = Dictionary[int, String]({}) +test_array_pool = PackedVector2Array() +test_array_pool2 = PackedVector4Array() +metadata/_custom_type_script = "uid://d2fhcaebxigkv" + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray("/wEB") +} +test_dict_int_str = Dictionary[int, String]({ +5: "five" +}) +test_array_pool = PackedVector2Array(50, 50, 20, 20) +test_array_pool2 = PackedVector4Array(1, 2, 4, 8) +child_resource = SubResource("Resource_3253y") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.4" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.5/custom_node.gd b/tests/projects/4.5/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.5/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.5/custom_node.gd.uid b/tests/projects/4.5/custom_node.gd.uid new file mode 100644 index 0000000..684b47a --- /dev/null +++ b/tests/projects/4.5/custom_node.gd.uid @@ -0,0 +1 @@ +uid://bvr5pt47ijphu diff --git a/tests/projects/4.5/custom_resource.gd b/tests/projects/4.5/custom_resource.gd new file mode 100644 index 0000000..67d5bc6 --- /dev/null +++ b/tests/projects/4.5/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +@export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +@export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.5/custom_resource.gd.uid b/tests/projects/4.5/custom_resource.gd.uid new file mode 100644 index 0000000..9399612 --- /dev/null +++ b/tests/projects/4.5/custom_resource.gd.uid @@ -0,0 +1 @@ +uid://d2fhcaebxigkv diff --git a/tests/projects/4.5/fresh_resource.tres b/tests/projects/4.5/fresh_resource.tres new file mode 100644 index 0000000..f053083 --- /dev/null +++ b/tests/projects/4.5/fresh_resource.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://cmqyvug1htvo6"] + +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="1_odbn7"] + +[resource] +script = ExtResource("1_odbn7") +metadata/_custom_type_script = "uid://d2fhcaebxigkv" diff --git a/tests/projects/4.5/project.godot b/tests/projects/4.5/project.godot new file mode 100644 index 0000000..4fa9330 --- /dev/null +++ b/tests/projects/4.5/project.godot @@ -0,0 +1,15 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.5", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.5/resource.tres b/tests/projects/4.5/resource.tres new file mode 100644 index 0000000..1593cb5 --- /dev/null +++ b/tests/projects/4.5/resource.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="CustomResource" load_steps=2 format=4 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string = "test" diff --git a/tests/projects/4.5/scene.tscn b/tests/projects/4.5/scene.tscn new file mode 100644 index 0000000..90eca5a --- /dev/null +++ b/tests/projects/4.5/scene.tscn @@ -0,0 +1,39 @@ +[gd_scene load_steps=6 format=4 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" uid="uid://bvr5pt47ijphu" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_3253y"] +script = ExtResource("2") +metadata/_custom_type_script = "uid://d2fhcaebxigkv" + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray("/wEB") +} +test_dict_int_str = Dictionary[int, String]({ +5: "five" +}) +test_array_pool = PackedVector2Array(50, 50, 20, 20) +test_array_pool2 = PackedVector4Array(1, 2, 4, 8) +child_resource = SubResource("Resource_3253y") + +[node name="CustomNode" type="Node"] +script = ExtResource("1") +godot_version = "4.5" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="."] diff --git a/tests/projects/4.6/custom_node.gd b/tests/projects/4.6/custom_node.gd new file mode 100644 index 0000000..dad20a5 --- /dev/null +++ b/tests/projects/4.6/custom_node.gd @@ -0,0 +1,13 @@ +@tool + +extends Node +class_name CustomNode + +@export var godot_version : String; +@export var internalResource : CustomResource; +@export var externalResource : CustomResource; +@export_node_path var remoteNode; + +func _ready(): + var file = FileAccess.open("res://resource.tres", FileAccess.READ) + print(file.get_as_text()) diff --git a/tests/projects/4.6/custom_node.gd.uid b/tests/projects/4.6/custom_node.gd.uid new file mode 100644 index 0000000..684b47a --- /dev/null +++ b/tests/projects/4.6/custom_node.gd.uid @@ -0,0 +1 @@ +uid://bvr5pt47ijphu diff --git a/tests/projects/4.6/custom_resource.gd b/tests/projects/4.6/custom_resource.gd new file mode 100644 index 0000000..67d5bc6 --- /dev/null +++ b/tests/projects/4.6/custom_resource.gd @@ -0,0 +1,15 @@ +extends Resource +class_name CustomResource + +enum CustomEnum{A, B, C} + +@export var test_string_name : StringName; +@export_multiline var test_string : String; +@export var test_vector3 : Vector3; +@export var test_enum : CustomEnum; +@export var test_array_vector3 : Array[Vector3]; +@export var test_dict : Dictionary; +@export var test_dict_int_str : Dictionary[int,String]; +@export var test_array_pool : PackedVector2Array; +@export var test_array_pool2 : PackedVector4Array; +@export var child_resource : CustomResource; diff --git a/tests/projects/4.6/custom_resource.gd.uid b/tests/projects/4.6/custom_resource.gd.uid new file mode 100644 index 0000000..9399612 --- /dev/null +++ b/tests/projects/4.6/custom_resource.gd.uid @@ -0,0 +1 @@ +uid://d2fhcaebxigkv diff --git a/tests/projects/4.6/fresh_resource.tres b/tests/projects/4.6/fresh_resource.tres new file mode 100644 index 0000000..36c32eb --- /dev/null +++ b/tests/projects/4.6/fresh_resource.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="CustomResource" format=4 uid="uid://cmqyvug1htvo6"] + +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="1_odbn7"] + +[resource] +script = ExtResource("1_odbn7") +metadata/_custom_type_script = "uid://d2fhcaebxigkv" diff --git a/tests/projects/4.6/project.godot b/tests/projects/4.6/project.godot new file mode 100644 index 0000000..6a15816 --- /dev/null +++ b/tests/projects/4.6/project.godot @@ -0,0 +1,19 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[animation] + +compatibility/default_parent_skeleton_in_mesh_instance_3d=true + +[application] + +config/name="Preload" +config/features=PackedStringArray("4.6", "GL Compatibility") +config/icon="res://icon.svg" diff --git a/tests/projects/4.6/resource.tres b/tests/projects/4.6/resource.tres new file mode 100644 index 0000000..a540712 --- /dev/null +++ b/tests/projects/4.6/resource.tres @@ -0,0 +1,7 @@ +[gd_resource type="Resource" script_class="CustomResource" format=4 uid="uid://c2aqhm1bli2jd"] + +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="1"] + +[resource] +script = ExtResource("1") +test_string = "test" diff --git a/tests/projects/4.6/scene.tscn b/tests/projects/4.6/scene.tscn new file mode 100644 index 0000000..2dbb944 --- /dev/null +++ b/tests/projects/4.6/scene.tscn @@ -0,0 +1,39 @@ +[gd_scene format=4 uid="uid://cqxcqnxlyn3vl"] + +[ext_resource type="Script" uid="uid://bvr5pt47ijphu" path="res://custom_node.gd" id="1"] +[ext_resource type="Script" uid="uid://d2fhcaebxigkv" path="res://custom_resource.gd" id="2"] +[ext_resource type="Resource" uid="uid://c2aqhm1bli2jd" path="res://resource.tres" id="3"] + +[sub_resource type="Resource" id="Resource_3253y"] +script = ExtResource("2") +metadata/_custom_type_script = "uid://d2fhcaebxigkv" + +[sub_resource type="Resource" id="1"] +script = ExtResource("2") +test_string_name = &"stringName" +test_string = "asd\\\\/\\'\" + +ç" +test_vector3 = Vector3(9, 0, 0) +test_enum = 1 +test_array_vector3 = Array[Vector3]([Vector3(0, 1, 0), Vector3(2, 0, 2)]) +test_dict = { +1: "One", +1.1: "One dot One", +PackedInt32Array(2, 3): PackedByteArray("/wEB") +} +test_dict_int_str = Dictionary[int, String]({ +5: "five" +}) +test_array_pool = PackedVector2Array(50, 50, 20, 20) +test_array_pool2 = PackedVector4Array(1, 2, 4, 8) +child_resource = SubResource("Resource_3253y") + +[node name="CustomNode" type="Node" unique_id=293065304] +script = ExtResource("1") +godot_version = "4.6" +internalResource = SubResource("1") +externalResource = ExtResource("3") +remoteNode = NodePath("ChildNode") + +[node name="ChildNode" type="Node" parent="." unique_id=1415315168] diff --git a/tests/test_objects.py b/tests/test_objects.py index 4f11e89..5f8a717 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -60,7 +60,9 @@ def test_vector3(self): def test_packed_vector4_array(self): """Test for PackedVector4Array""" - array = PackedVector4Array([Vector4(i, i * 2, i * 3, i * 4) for i in range(3)]) + array = PackedVector4Array.FromList( + [Vector4(i, i * 2, i * 3, i * 4) for i in range(3)] + ) self.assertEqual(array[0], Vector4(0, 0, 0, 0)) self.assertEqual(array[1], Vector4(1, 2, 3, 4)) self.assertEqual(array[2], Vector4(2, 4, 6, 8)) @@ -71,7 +73,7 @@ def test_packed_vector4_array(self): def test_packed_byte_array(self): """Test for PackedVector4Array""" - array = PackedByteArray(bytes(range(3))) + array = PackedByteArray.FromBytes(bytes(range(3))) self.assertEqual(array.bytes_[0], 0) self.assertEqual(array.bytes_[1], 1) self.assertEqual(array.bytes_[2], 2) diff --git a/tests/test_output.py b/tests/test_output.py index 623b0eb..68d73d6 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -98,6 +98,26 @@ def test_punctuation_spaces(self): array = [ Vector3( 1, 2, 3 ) ]\n""", ) + def test_single_line_dict(self): + resource = GDResource() + resource["dict"] = {} + + self.assertEqual( + resource.output_to_string(OutputFormat(single_line_on_empty_dict=True)), + """[gd_resource format=3] + +[resource] +dict = {}\n""", + ) + self.assertEqual( + resource.output_to_string(OutputFormat(single_line_on_empty_dict=False)), + """[gd_resource format=3] + +[resource] +dict = { +}\n""", + ) + def test_load_steps(self): resource = GDResource() resource["toggle"] = True @@ -314,7 +334,7 @@ def test_typed_dictionary_support(self): def test_packed_vector4_array_support(self): resource = GDResource() - resource["test"] = PackedVector4Array([Vector4(1, 2, 3, 4)]) + resource["test"] = PackedVector4Array.FromList([Vector4(1, 2, 3, 4)]) self.assertEqual( resource.output_to_string(OutputFormat(packed_vector4_array_support=True)), @@ -348,7 +368,7 @@ def test_packed_byte_array_base64_support(self): bytes_ = bytes([5, 88, 10]) resource = GDResource() - resource["test"] = PackedByteArray(bytes_) + resource["test"] = PackedByteArray.FromBytes(bytes_) self.assertEqual( resource.output_to_string( @@ -372,7 +392,7 @@ def test_packed_byte_array_base64_support(self): def test_packed_array_format(self): resource = GDResource() - resource["test1"] = PackedByteArray(bytes(range(4))) + resource["test1"] = PackedByteArray.FromBytes(bytes(range(4))) resource["test2"] = GDObject("PackedVector2Array", *range(4)) self.assertEqual( diff --git a/tests/test_versions.py b/tests/test_versions.py index 6fa93cf..289a0a6 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,7 +1,9 @@ import base64 +import os import unittest -from godot_parser import GDPackedScene, StringName, TypedArray, NodePath +import tests +from godot_parser import GDPackedScene, StringName, TypedArray, NodePath, parse from godot_parser.id_generator import SequentialHexGenerator from godot_parser.objects import ( PackedByteArray, @@ -47,8 +49,8 @@ def setUp(self): child["str"] = "'hello\\me'\n\"HI\"" child["string name"] = StringName("StringName") - child["packedByte"] = PackedByteArray(byte_array) - child["packedVector4"] = PackedVector4Array([Vector4(1, 3, 5, 7)]) + child["packedByte"] = PackedByteArray.FromBytes(byte_array) + child["packedVector4"] = PackedVector4Array.FromList([Vector4(1, 3, 5, 7)]) child["nodepath"] = NodePath(".") def test_godot_3(self): @@ -57,9 +59,7 @@ def test_godot_3(self): """[gd_scene load_steps=7 format=2] [ext_resource path="res://external.tres" type="CustomResource1" id=1] - [ext_resource path="res://custom_resource.gd" type="Script" id=2] - [ext_resource path="res://custom_resource_2.gd" type="Script" id=3] [sub_resource type="Resource" id=1] @@ -84,7 +84,7 @@ def test_godot_3(self): "string name" = "StringName" packedByte = PoolByteArray( 10, 20, 15 ) packedVector4 = [ Vector4( 1, 3, 5, 7 ) ] -nodepath = NodePath( "." ) +nodepath = NodePath(".") """, ) @@ -97,9 +97,7 @@ def test_godot_4_0(self): """[gd_scene load_steps=7 format=3] [ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] - [ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] - [ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] [sub_resource type="Resource" id="Resource_4"] @@ -137,9 +135,7 @@ def test_godot_4_3(self): """[gd_scene load_steps=7 format=4] [ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] - [ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] - [ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] [sub_resource type="Resource" id="Resource_4"] @@ -177,9 +173,7 @@ def test_godot_4_4(self): """[gd_scene load_steps=7 format=4] [ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] - [ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] - [ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] [sub_resource type="Resource" id="Resource_4"] @@ -217,9 +211,7 @@ def test_godot_4_6(self): """[gd_scene format=4] [ext_resource path="res://external.tres" type="CustomResource1" id="1_1"] - [ext_resource path="res://custom_resource.gd" type="Script" id="2_2"] - [ext_resource path="res://custom_resource_2.gd" type="Script" id="3_3"] [sub_resource type="Resource" id="Resource_4"] @@ -247,3 +239,34 @@ def test_godot_4_6(self): nodepath = NodePath(".") """ % self.byte_array_base64, ) + + +class TestRealProject(unittest.TestCase): + def test_projects(self): + project_folder = os.path.join(os.path.dirname(tests.__file__), "projects") + for root, _dirs, files in os.walk(project_folder, topdown=False): + for file in files: + ext = os.path.splitext(file)[1] + if ext not in [".tscn", ".tres"]: + continue + filepath = os.path.join(root, file) + version, filename = os.path.split( + os.path.relpath(filepath, project_folder) + ) + + with self.subTest("%s - %s" % (version, filename)): + output_format = VersionOutputFormat(version) + + # Required as Godot uses format=4 not if the tres contains a PackedVector4Array or + # base64 PackedByteArray, but if the originating script contains any such properties + # + # Since we have no access to the original script, we hack this with the prior knowledge that the + # files used for this test contained those types + output_format._force_format_4_if_available = True + + with open(filepath, "r", encoding="utf-8") as input_file: + file_content = input_file.read() + self.assertEqual( + file_content, + parse(file_content).output_to_string(output_format), + ) From 94014b6f50846c1e4900649281f1b72daeb85a6a Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 02:49:17 -0300 Subject: [PATCH 30/44] Verifications --- godot_parser/files.py | 9 ++++----- godot_parser/objects.py | 14 ++++++-------- tests/test_objects.py | 12 ++++++------ tests/test_output.py | 10 +++++----- tests/test_versions.py | 4 ++-- 5 files changed, 23 insertions(+), 26 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 8b9c31e..dcdc01c 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -5,23 +5,22 @@ from .objects import ( ExtResource, + GDIterable, GDObject, + PackedByteArray, + PackedVector4Array, ResourceReference, SubResource, - PackedVector4Array, - PackedByteArray, - GDIterable, ) from .output import Outputable, OutputFormat from .sections import ( GDBaseResourceSection, GDExtResourceSection, + GDFileHeader, GDNodeSection, GDResourceSection, GDSection, - GDSectionHeader, GDSubResourceSection, - GDFileHeader, ) from .structure import scene_file from .util import find_project_root, gdpath_to_filepath diff --git a/godot_parser/objects.py b/godot_parser/objects.py index fc32dab..a155c7e 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -4,7 +4,7 @@ import re from functools import partial from math import floor -from typing import Optional, Type, TypeVar, Union, List, Any, Iterable +from typing import Any, Iterable, List, Optional, Type, TypeVar, Union from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object @@ -264,10 +264,7 @@ def __init__(self, *args) -> None: def FromList(cls, list_: List[Vector4]) -> "PackedVector4Array": return cls(*sum([[v.x, v.y, v.z, v.w] for v in list_], [])) - def __contains__(self, idx: int) -> bool: - return len(self.args) > (idx * 4) - - def __getitem__(self, idx: int) -> Vector4: + def get_vector4(self, idx: int) -> Vector4: return Vector4( self.args[idx * 4 + 0], self.args[idx * 4 + 1], @@ -275,13 +272,13 @@ def __getitem__(self, idx: int) -> Vector4: self.args[idx * 4 + 3], ) - def __setitem__(self, idx: int, value: Vector4) -> None: + def set_vector4(self, idx: int, value: Vector4) -> None: self.args[idx * 4 + 0] = value.x self.args[idx * 4 + 1] = value.y self.args[idx * 4 + 2] = value.z self.args[idx * 4 + 3] = value.w - def __delitem__(self, idx: int) -> None: + def remove_vector4_at(self, idx: int) -> None: del self.args[idx * 4] del self.args[idx * 4] del self.args[idx * 4] @@ -292,7 +289,8 @@ def _output_to_string(self, output_format: OutputFormat) -> str: return super()._output_to_string(output_format) else: return TypedArray( - "Vector4", [self[i] for i in range(floor(len(self.args) / 4))] + "Vector4", + [self.get_vector4(i) for i in range(floor(len(self.args) / 4))], ).output_to_string(output_format) diff --git a/tests/test_objects.py b/tests/test_objects.py index 5f8a717..f109042 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -12,7 +12,7 @@ Vector2, Vector3, ) -from godot_parser.objects import PackedVector4Array, Vector4, PackedByteArray +from godot_parser.objects import PackedByteArray, PackedVector4Array, Vector4 class TestGDObjects(unittest.TestCase): @@ -63,13 +63,13 @@ def test_packed_vector4_array(self): array = PackedVector4Array.FromList( [Vector4(i, i * 2, i * 3, i * 4) for i in range(3)] ) - self.assertEqual(array[0], Vector4(0, 0, 0, 0)) - self.assertEqual(array[1], Vector4(1, 2, 3, 4)) - self.assertEqual(array[2], Vector4(2, 4, 6, 8)) + self.assertEqual(array.get_vector4(0), Vector4(0, 0, 0, 0)) + self.assertEqual(array.get_vector4(1), Vector4(1, 2, 3, 4)) + self.assertEqual(array.get_vector4(2), Vector4(2, 4, 6, 8)) - del array[1] + array.remove_vector4_at(1) - self.assertEqual(array[1], Vector4(2, 4, 6, 8)) + self.assertEqual(array.get_vector4(1), Vector4(2, 4, 6, 8)) def test_packed_byte_array(self): """Test for PackedVector4Array""" diff --git a/tests/test_output.py b/tests/test_output.py index 68d73d6..d068d57 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -3,19 +3,19 @@ from godot_parser import ( GDExtResourceSection, + GDObject, GDResource, GDSubResourceSection, - Vector3, - TypedArray, - GDObject, StringName, + TypedArray, + Vector3, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.objects import ( + PackedByteArray, PackedVector4Array, - Vector4, TypedDictionary, - PackedByteArray, + Vector4, ) from godot_parser.output import OutputFormat, VersionOutputFormat diff --git a/tests/test_versions.py b/tests/test_versions.py index 289a0a6..386a25b 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -3,13 +3,13 @@ import unittest import tests -from godot_parser import GDPackedScene, StringName, TypedArray, NodePath, parse +from godot_parser import GDPackedScene, NodePath, StringName, TypedArray, parse from godot_parser.id_generator import SequentialHexGenerator from godot_parser.objects import ( PackedByteArray, PackedVector4Array, - Vector4, TypedDictionary, + Vector4, ) from godot_parser.output import VersionOutputFormat From 7c6f2eb5f4f5102726e1e93a6a95741afb991de8 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 02:54:45 -0300 Subject: [PATCH 31/44] warnings --- godot_parser/structure.py | 2 +- godot_parser/values.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/godot_parser/structure.py b/godot_parser/structure.py index 2281850..b77b49e 100644 --- a/godot_parser/structure.py +++ b/godot_parser/structure.py @@ -15,7 +15,7 @@ from .sections import GDSection, GDSectionHeader from .values import value -key = QuotedString('"', escChar="\\", multiline=False).set_name("key") | Word( +key = QuotedString('"', esc_char="\\", multiline=False).set_name("key") | Word( alphanums + "_/:" ).set_name("key") var = Word(alphanums + "_").set_name("variable") diff --git a/godot_parser/values.py b/godot_parser/values.py index ab81265..9fabc42 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -24,7 +24,7 @@ null = Keyword("null").set_parse_action(lambda _: [None]) -_string = QuotedString('"', escChar="\\", multiline=True).set_name("string") +_string = QuotedString('"', esc_char="\\", multiline=True).set_name("string") _string_name = ( (Suppress("&") + _string) From 53d06c86c167f8c3a325fa37dab33331d0514d4f Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 02:57:11 -0300 Subject: [PATCH 32/44] Minor fixes --- godot_parser/objects.py | 3 +++ godot_parser/sections.py | 1 + 2 files changed, 4 insertions(+) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index a155c7e..caddc8a 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -21,6 +21,9 @@ "StringName", "TypedArray", "TypedDictionary", + "PackedByteArray", + "PackedVector4Array", + "GDIterable", ] GD_OBJECT_REGISTRY = {} diff --git a/godot_parser/sections.py b/godot_parser/sections.py index 7412f5a..94e4218 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -14,6 +14,7 @@ "GDExtResourceSection", "GDSubResourceSection", "GDResourceSection", + "GDFileHeader", ] From ec3c281e36bfd0dfc3b0435d5d26b8c6a812bf67 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 04:23:38 -0300 Subject: [PATCH 33/44] Version guessing --- godot_parser/files.py | 14 +++++++++-- godot_parser/objects.py | 8 +++--- godot_parser/output.py | 55 ++++++++++++++++++++++++++++++++++++++++- test_parse_files.py | 6 ++++- tests/test_tree.py | 2 +- tests/test_versions.py | 9 ++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index dcdc01c..6d7c552 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -12,7 +12,7 @@ ResourceReference, SubResource, ) -from .output import Outputable, OutputFormat +from .output import Outputable, OutputFormat, VersionOutputFormat from .sections import ( GDBaseResourceSection, GDExtResourceSection, @@ -249,6 +249,11 @@ class GDCommonFile(GDFile): def __init__(self, name: str, *sections: GDSection) -> None: super().__init__(GDSection(GDFileHeader(name)), *sections) + def output_to_string(self, output_format: Optional[OutputFormat] = None) -> str: + if output_format is None: + output_format = VersionOutputFormat.guess_version(self) + return super().output_to_string(output_format) + def _output_to_string(self, output_format: OutputFormat) -> str: self.generate_resource_ids(output_format) @@ -321,8 +326,13 @@ def _iter_resource_references( self, ) -> Iterator[Union[ExtResource, SubResource]]: def iter_resources(value): - if isinstance(value, (ExtResource, SubResource)): + if isinstance(value, ExtResource): + yield value + elif isinstance(value, SubResource): yield value + sub_resource = self.find_sub_resource(id=value.id) + if sub_resource is not None: + yield from iter_resources(sub_resource.properties) elif isinstance(value, list): for v in value: yield from iter_resources(v) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index caddc8a..9e77e0d 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -305,12 +305,12 @@ def __init__(self, *args) -> None: def FromBytes(cls, bytes_: bytes) -> "PackedByteArray": return cls(*list(bytes_)) - def __stored_as_base64(self) -> bool: + def _stored_as_base64(self) -> bool: return len(self.args) == 1 and isinstance(self.args[0], str) @property def bytes_(self) -> bytes: - if self.__stored_as_base64(): + if self._stored_as_base64(): return base64.b64decode(self.args[0]) return bytes(self.args) @@ -320,9 +320,9 @@ def bytes_(self, bytes_: bytes) -> None: def _output_to_string(self, output_format: OutputFormat) -> str: if output_format.packed_byte_array_base64_support: - if not self.__stored_as_base64(): + if not self._stored_as_base64(): self.args = [base64.b64encode(self.bytes_).decode("utf-8")] - elif self.__stored_as_base64(): + elif self._stored_as_base64(): self.bytes_ = self.bytes_ return super()._output_to_string(output_format) diff --git a/godot_parser/output.py b/godot_parser/output.py index a01b597..5923e1e 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union, Type from packaging.version import Version @@ -59,6 +59,7 @@ def generate_id(self, section: Any) -> str: class VersionOutputFormat(OutputFormat): + __V36 = Version("3.6") __V40 = Version("4.0") __V43 = Version("4.3") __V44 = Version("4.4") @@ -85,6 +86,58 @@ def __init__(self, version: Union[str, Version]): ), ) + @classmethod + def guess_version(cls, gd_common_file: "GDCommonFile") -> "VersionOutputFormat": + from .objects import ( + TypedDictionary, + PackedVector4Array, + PackedByteArray, + SubResource, + ExtResource, + ) + + format = None + + if "format" in gd_common_file._sections[0].header: + format = gd_common_file._sections[0].header["format"] + + version = cls.__V40 + + if format == 2: + version = cls.__V36 + return cls(version) + if not "load_steps" in gd_common_file._sections[0].header: + version = cls.__V46 + + force_format_4 = False + + if format == 4: + version = max(version, cls.__V43) + force_format_4 = True + + for reference in gd_common_file._iter_resource_references(): + if isinstance(reference, SubResource) and isinstance(reference.id, int): + version = cls.__V36 + return cls(version) + elif isinstance(reference, ExtResource) and isinstance(reference.id, int): + version = cls.__V36 + return cls(version) + elif isinstance(reference, TypedDictionary): + version = max(version, cls.__V44) + elif isinstance(reference, PackedVector4Array): + version = max(version, cls.__V43) + elif ( + isinstance(reference, PackedByteArray) and reference._stored_as_base64() + ): + version = max(version, cls.__V43) + + output_format = cls(version) + + if force_format_4: + output_format._force_format_4_if_available = True + + return output_format + class Outputable(object): def _output_to_string(self, output_format: OutputFormat) -> str: diff --git a/test_parse_files.py b/test_parse_files.py index c94189e..8cd0ae3 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -8,6 +8,7 @@ import traceback from godot_parser import parse +from godot_parser.output import VersionOutputFormat # Regex to detect space sequences space_re = re.compile(r" +") @@ -81,7 +82,9 @@ def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: with open(filename, "r", encoding="utf-8") as ifile: original_file = ifile.read() try: - parsed_file = str(parse(original_file)) + parsed_file = parse(original_file) + output_format = VersionOutputFormat.guess_version(parsed_file) + parsed_file = parsed_file.output_to_string(output_format) except Exception: print("! Parsing error on %s" % filename, file=sys.stderr) traceback.print_exc(file=sys.stderr) @@ -107,6 +110,7 @@ def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: return True print("! Difference detected on %s" % filename) + print(" Version detected: %s" % output_format.version) sys.stdout.writelines(diff) return False diff --git a/tests/test_tree.py b/tests/test_tree.py index a13b84e..dc56791 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -222,7 +222,7 @@ def test_inherit_properties(self): """Inherited nodes inherit properties""" scene = GDPackedScene.load(self.leaf_scene) with scene.use_tree() as tree: - self.assertEqual(tree.root["shape"], SubResource("1")) + self.assertEqual(tree.root["shape"], SubResource(1)) self.assertEqual(tree.root["collision_layer"], 4) self.assertEqual(tree.root.get("collision_layer"), 4) self.assertEqual(tree.root.get("missing"), None) diff --git a/tests/test_versions.py b/tests/test_versions.py index 386a25b..6d619e3 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -266,7 +266,14 @@ def test_projects(self): with open(filepath, "r", encoding="utf-8") as input_file: file_content = input_file.read() + parsed_file = parse(file_content) self.assertEqual( file_content, - parse(file_content).output_to_string(output_format), + parsed_file.output_to_string(output_format), + ) + self.assertEqual( + file_content, + parsed_file.output_to_string( + VersionOutputFormat.guess_version(parsed_file) + ), ) From df855371ac6f4f51a3f80fd66e1453bf8f0d6401 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 20:39:53 -0300 Subject: [PATCH 34/44] Fix StringName output --- godot_parser/objects.py | 7 +-- tests/projects/4.6/scene.tscn | 2 +- tests/test_gdfile.py | 81 ++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 9e77e0d..6022ff1 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -1,6 +1,7 @@ """Wrappers for Godot's non-primitive object types""" import base64 +import json import re from functools import partial from math import floor @@ -537,10 +538,10 @@ def from_parser(cls: Type["StringName"], parse_result) -> "StringName": return StringName(parse_result[0]) def _output_to_string(self, output_format: OutputFormat) -> str: - marker = "" if output_format.string_name_support: - marker = "&" - return marker + stringify_object(self.str, output_format) + return "&" + json.dumps(self.str, ensure_ascii=False).replace("'", "\\'") + else: + return stringify_object(self.str, output_format) def __repr__(self) -> str: return self.__str__() diff --git a/tests/projects/4.6/scene.tscn b/tests/projects/4.6/scene.tscn index 2dbb944..83efa33 100644 --- a/tests/projects/4.6/scene.tscn +++ b/tests/projects/4.6/scene.tscn @@ -10,7 +10,7 @@ metadata/_custom_type_script = "uid://d2fhcaebxigkv" [sub_resource type="Resource" id="1"] script = ExtResource("2") -test_string_name = &"stringName" +test_string_name = &"string\'Name" test_string = "asd\\\\/\\'\" ç" diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index fc3bc94..6c02c6d 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -10,6 +10,7 @@ Node, TypedArray, TypedDictionary, + StringName, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat @@ -327,14 +328,84 @@ def test_renumber_ids(self): self.assertEqual(res2.id, 1) def test_string_special_characters(self): - res = GDResource(str_value="\ta\"q'é'd\"\n\n\\") + input = "".join( + [ + " ", + '"', + "a", + " ", + "'", + "'", + "\\", + "\n", + "\t", + "\n", + "\\", + "n", + "\n", + ] + ) + + res = GDResource() + res["str_value"] = input + res["str_name"] = StringName(input) + + print(str(res)) + self.assertEqual( str(res), """[gd_resource format=3] [resource] -str_value = " a\\"q'é'd\\" - -\\\\" -""", +str_value = "%s" +str_name = &"%s" +""" + % ( + "".join( + [ + " ", + "\\", + '"', + "a", + " ", + "'", + "'", + "\\", + "\\", + "\n", + "\t", + "\n", + "\\", + "\\", + "n", + "\n", + ] + ), + "".join( + [ + " ", + "\\", + '"', + "a", + " ", + "\\", + "'", + "\\", + "'", + "\\", + "\\", + "\\", + "n", + "\\", + "t", + "\\", + "n", + "\\", + "\\", + "n", + "\\", + "n", + ] + ), + ), ) From dedb48fafd1ee64a9ac8b9144ff219cfbaf6bc79 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 21:00:58 -0300 Subject: [PATCH 35/44] Removed all diff sanitization from test_parse_files.py --- godot_parser/files.py | 10 ++++++++-- godot_parser/sections.py | 16 ++++++++++++++-- test_parse_files.py | 25 +++++++------------------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 6d7c552..0841113 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -212,6 +212,12 @@ def write(self, filename: str, output_format: Optional[OutputFormat] = None): with open(filename, "w", encoding="utf-8") as ofile: ofile.write(self.output_to_string(output_format)) + __keep_together_sections = [ + "ext_resource", + "connection", + "editable", + ] + def _output_to_string(self, output_format: OutputFormat) -> str: output = "" @@ -220,8 +226,8 @@ def _output_to_string(self, output_format: OutputFormat) -> str: for cur_section in self._sections: if ( last_section is not None - and isinstance(cur_section, GDExtResourceSection) - and isinstance(last_section, GDExtResourceSection) + and cur_section.header.name == last_section.header.name + and cur_section.header.name in self.__keep_together_sections ): output = output[:-1] diff --git a/godot_parser/sections.py b/godot_parser/sections.py index 94e4218..a4560f5 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -2,7 +2,7 @@ from collections import OrderedDict from typing import Any, List, Optional, Type, TypeVar, Union -from .objects import ExtResource, SubResource +from .objects import ExtResource, SubResource, StringName from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object @@ -75,7 +75,19 @@ def _output_to_string(self, output_format: OutputFormat) -> str: attribute_str = " " + " ".join( [ - "%s=%s" % (k, stringify_object(self.attributes[k], output_format)) + "%s=%s" + % ( + k, + ( + # Bizarre but consistent edge case as far as I could tell. + # Would be more than happy to just live with it though + " " + if isinstance(self.attributes[k], list) + and isinstance(self.attributes[k][0], StringName) + else "" + ) + + stringify_object(self.attributes[k], output_format), + ) for k in keys ] ) diff --git a/test_parse_files.py b/test_parse_files.py index 8cd0ae3..cac9dd0 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -76,7 +76,7 @@ def join_lines_within_quotes(input: list[str], unescape: bool): return lines -def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: +def _parse_and_test_file(filename: str, verbose: bool) -> bool: if verbose: print("Parsing %s" % filename) with open(filename, "r", encoding="utf-8") as ifile: @@ -90,18 +90,12 @@ def _parse_and_test_file(filename: str, verbose: bool, unescape: bool) -> bool: traceback.print_exc(file=sys.stderr) return False - original_file = join_lines_within_quotes( - [l.strip() for l in io.StringIO(original_file).readlines() if l.strip()], - unescape, - ) - parsed_file = join_lines_within_quotes( - [l.strip() for l in io.StringIO(str(parsed_file)).readlines() if l.strip()], - unescape, - ) - diff = list( difflib.context_diff( - original_file, parsed_file, fromfile=filename, tofile="PARSED FILE" + io.StringIO(original_file).readlines(), + io.StringIO(str(parsed_file)).readlines(), + fromfile=filename, + tofile="PARSED FILE", ) ) diff = [" " + "\n ".join(l.strip().split("\n")) + "\n" for l in diff] @@ -128,14 +122,9 @@ def main(): action="store_true", help="Prints all file paths as they're parsed", ) - parser.add_argument( - "--unescape", - action="store_true", - help="Attempts to unescape strings before comparison (Godot 4.5+ standard)", - ) args = parser.parse_args() if os.path.isfile(args.file_or_dir): - _parse_and_test_file(args.file_or_dir, args.verbose, args.unescape) + _parse_and_test_file(args.file_or_dir, args.verbose) else: all_passed = True for root, _dirs, files in os.walk(args.file_or_dir, topdown=False): @@ -144,7 +133,7 @@ def main(): if ext not in [".tscn", ".tres"]: continue filepath = os.path.join(root, file) - if not _parse_and_test_file(filepath, args.verbose, args.unescape): + if not _parse_and_test_file(filepath, args.verbose): all_passed = False if not args.all: return 1 From 9ef2d28adba965b4d54ff7a22ebb7974a4bfe995 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 21:09:39 -0300 Subject: [PATCH 36/44] Formatting --- godot_parser/files.py | 2 +- godot_parser/output.py | 10 +++++----- godot_parser/sections.py | 2 +- test_parse_files.py | 4 ++-- tests/test_gdfile.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/godot_parser/files.py b/godot_parser/files.py index 0841113..f4af56a 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -25,7 +25,7 @@ from .structure import scene_file from .util import find_project_root, gdpath_to_filepath -__all__ = ["GDFile", "GDPackedScene", "GDResource"] +__all__ = ["GDFile", "GDCommonFile", "GDPackedScene", "GDResource"] # Scene and resource files seem to group the section types together and sort them. # This is the order I've observed diff --git a/godot_parser/output.py b/godot_parser/output.py index 5923e1e..d29f83e 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Tuple, Union, Type +from typing import Any, Optional, Tuple, Union from packaging.version import Version @@ -87,13 +87,13 @@ def __init__(self, version: Union[str, Version]): ) @classmethod - def guess_version(cls, gd_common_file: "GDCommonFile") -> "VersionOutputFormat": + def guess_version(cls, gd_common_file) -> "VersionOutputFormat": from .objects import ( - TypedDictionary, - PackedVector4Array, + ExtResource, PackedByteArray, + PackedVector4Array, SubResource, - ExtResource, + TypedDictionary, ) format = None diff --git a/godot_parser/sections.py b/godot_parser/sections.py index a4560f5..39573af 100644 --- a/godot_parser/sections.py +++ b/godot_parser/sections.py @@ -2,7 +2,7 @@ from collections import OrderedDict from typing import Any, List, Optional, Type, TypeVar, Union -from .objects import ExtResource, SubResource, StringName +from .objects import ExtResource, StringName, SubResource from .output import Outputable, OutputFormat from .util import Identifiable, stringify_object diff --git a/test_parse_files.py b/test_parse_files.py index cac9dd0..6d92905 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -84,7 +84,7 @@ def _parse_and_test_file(filename: str, verbose: bool) -> bool: try: parsed_file = parse(original_file) output_format = VersionOutputFormat.guess_version(parsed_file) - parsed_file = parsed_file.output_to_string(output_format) + output_file = parsed_file.output_to_string(output_format) except Exception: print("! Parsing error on %s" % filename, file=sys.stderr) traceback.print_exc(file=sys.stderr) @@ -93,7 +93,7 @@ def _parse_and_test_file(filename: str, verbose: bool) -> bool: diff = list( difflib.context_diff( io.StringIO(original_file).readlines(), - io.StringIO(str(parsed_file)).readlines(), + io.StringIO(str(output_file)).readlines(), fromfile=filename, tofile="PARSED FILE", ) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 6c02c6d..d418966 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -8,9 +8,9 @@ GDResource, GDResourceSection, Node, + StringName, TypedArray, TypedDictionary, - StringName, ) from godot_parser.id_generator import SequentialHexGenerator from godot_parser.output import OutputFormat From 844800a1f8be0778a9e36d728331fdd7705ea86f Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 21:16:52 -0300 Subject: [PATCH 37/44] Add VersionOutputFormat to README example --- README.md | 5 +++-- godot_parser/__init__.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 586edae..fab3b77 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ functionality and make it easier to perform certain tasks. Let's look at an example by creating a new scene file for a Player: ```python - from godot_parser import GDPackedScene, Node +from godot_parser import GDPackedScene, Node, VersionOutputFormat scene = GDPackedScene() res = scene.add_ext_resource("res://PlayerSprite.png", "PackedScene") @@ -30,7 +30,8 @@ with scene.use_tree() as tree: properties={"texture": res.reference}, ) ) -scene.write("Player.tscn") + +scene.write("Player.tscn", VersionOutputFormat("4.6")) ``` It's much easier to use the high-level API when it's available, but it doesn't diff --git a/godot_parser/__init__.py b/godot_parser/__init__.py index 9473177..0c2c6dd 100644 --- a/godot_parser/__init__.py +++ b/godot_parser/__init__.py @@ -1,5 +1,6 @@ from .files import * from .objects import * +from .output import * from .sections import * from .tree import * From 8366347698b41101f4414654f17738b14e78b4b2 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 21:58:09 -0300 Subject: [PATCH 38/44] Packed and Pool Array detection --- godot_parser/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/godot_parser/objects.py b/godot_parser/objects.py index 6022ff1..94e3b06 100644 --- a/godot_parser/objects.py +++ b/godot_parser/objects.py @@ -78,7 +78,7 @@ def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: factory = GD_OBJECT_REGISTRY.get(name, partial(GDObject, name)) return factory(*parse_result[1:]) - __packed_array_re = re.compile(r"^Packed(?P[A-Z]\w+)Array$") + __packed_array_re = re.compile(r"^(Packed|Pool)(?P[A-Z]\w+)Array$") def _output_to_string(self, output_format: OutputFormat) -> str: name = self.name From b0519874c87808fcb0b4e538c5d63ba612f6a2c8 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 22:06:23 -0300 Subject: [PATCH 39/44] Removed unused function from test_parse_files --- test_parse_files.py | 66 --------------------------------------------- 1 file changed, 66 deletions(-) diff --git a/test_parse_files.py b/test_parse_files.py index 6d92905..6016924 100755 --- a/test_parse_files.py +++ b/test_parse_files.py @@ -3,78 +3,12 @@ import difflib import io import os -import re import sys import traceback from godot_parser import parse from godot_parser.output import VersionOutputFormat -# Regex to detect space sequences -space_re = re.compile(r" +") -# Regex to detect all spaces not surrounded by alphanumeric characters -line_normalizer_re = re.compile(r"(?<=\W) +| +(?=\W)") -# Regex to detect quotes and possible escape sequences -find_quote_re = re.compile(r"(\\*\")") - - -def join_lines_within_quotes(input: list[str], unescape: bool): - buffer_list = [] - lines = [] - buffer = "" - - for part in input: - # Find all quotes that are not escaped - # " is not escaped. \" is escaped. \\" is not escaped as \\ becomes \, leaving the quote unescaped - read_pos = 0 - for match in find_quote_re.finditer(part): - span = match.span() - match_text = part[span[0] : span[1]] - buffer += part[read_pos : span[1]] - read_pos = span[1] - if len(match_text) % 2 == 1: - buffer_list.append(buffer) - buffer = "" - buffer += part[read_pos:] - - if (len(buffer_list) % 2 == 0) and buffer: - buffer_list.append(buffer) - buffer = "" - - if buffer: - buffer += "\n" - else: - for i in range(len(buffer_list)): - if i % 2 == 0: - buffer_list[i] = space_re.sub(" ", buffer_list[i]) - buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) - elif unescape: - buffer_list[i] = ( - buffer_list[i] - .encode("latin-1", "backslashreplace") - .decode("unicode-escape") - ) - lines.append("".join(buffer_list) + "\n") - buffer_list = [] - buffer = "" - - if buffer: - buffer_list.append(buffer) - if buffer_list: - for i in range(len(buffer_list)): - if i % 2 == 0: - buffer_list[i] = space_re.sub(" ", buffer_list[i]) - buffer_list[i] = line_normalizer_re.sub("", buffer_list[i]) - elif unescape: - buffer_list[i] = ( - buffer_list[i] - .encode("latin-1", "backslashreplace") - .decode("unicode-escape") - ) - lines.append("".join(buffer_list) + "\n") - - return lines - def _parse_and_test_file(filename: str, verbose: bool) -> bool: if verbose: From edab6cb97be19204b4e79e2fdaed4b44e4516aee Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 22:23:46 -0300 Subject: [PATCH 40/44] Testing scene.is_inherited --- tests/test_gdfile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index d418966..d43ee59 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -153,9 +153,11 @@ def test_add_ext_node(self): """Test GDScene.add_ext_node""" scene = GDPackedScene() res = scene.add_ext_resource("res://Other.tscn", "PackedScene") + scene.generate_resource_ids() node = scene.add_ext_node("Root", res.id) self.assertEqual(node.name, "Root") self.assertEqual(node.instance, res.id) + self.assertTrue(scene.is_inherited) def test_write(self): """Test writing scene out to a file""" From 1d95cef8dc1825f7ba468740de647c5f91a016c3 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 23:14:09 -0300 Subject: [PATCH 41/44] OutputFormat documentation --- godot_parser/output.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/godot_parser/output.py b/godot_parser/output.py index d29f83e..8d60174 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -6,6 +6,46 @@ class OutputFormat(object): + """Controls how Godot files are generated + + Attributes: + punctuation_spaces : bool + Determines if spaces should be added between puncuation + ex: Vector2( 1, 2 ) vs Vector2(1, 2) + single_line_on_empty_dict: + Determines if empty dicts should be written on a single line or not + ex: "{}" vs "{\n}" + resource_ids_as_strings: + Determines if SubResources and ExtResources should be identified with string over int ids + ex: ExtResource(1) vs ExtResource("1_n2jr1") + ex: SubResource(1) vs SubResource("Resource_b1ij2") + typed_array_support: + Determines if TypedArrays are supported + If unsupported, will be output as regular arrays + ex: Array[int]([1]) vs [1] + packed_byte_array_base64_support: + Determines if PackedByteArrays should be output as base64 + ex: PackedByteArray("1qr3") vs PackedByteArray(1, 2, 3) + packed_vector4_array_support: + Determines if PackedVector4Arrays are supported + If unsupported, will be output as a Typed array. May fallback to untyped array + ex: PackedVector4Array(1, 2, 3, 4) vs Array[Vector4]([Vector4(1, 2, 3, 4)]) + typed_dictionary_support: + Determines if TypedDictionaries are supported: + If unsupported, will be output as regular dict + ex: Dictionary[int,String]({\n1: "One"\n}) vs {\n1: "One"\n} + string_name_support: + Determines if StringNames are supported + If unsupported, will be output as a regular string + ex: &"foo" vs "foo" + load_steps: + Determines if load_steps will be output on GDFile header + packed_array_format: + Determines type to be used on Packed arrays + Packed%sArray for Godot 4.x + Pool%sArray for Godot 3.x + """ + def __init__( self, punctuation_spaces: bool = False, From 4394fb4db0aedac2ae738cf914b9333b674b1fa3 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Mon, 23 Feb 2026 23:43:12 -0300 Subject: [PATCH 42/44] OutputFormat ocumentation formatting --- godot_parser/output.py | 52 +++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/godot_parser/output.py b/godot_parser/output.py index 8d60174..cce963b 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -10,40 +10,40 @@ class OutputFormat(object): Attributes: punctuation_spaces : bool - Determines if spaces should be added between puncuation - ex: Vector2( 1, 2 ) vs Vector2(1, 2) + Determines if spaces should be added between puncuation. + ex: Vector2( 1, 2 ) vs Vector2(1, 2); single_line_on_empty_dict: - Determines if empty dicts should be written on a single line or not - ex: "{}" vs "{\n}" + Determines if empty dicts should be written on a single line or not. + ex: "{}" vs "{\\n}"; resource_ids_as_strings: - Determines if SubResources and ExtResources should be identified with string over int ids - ex: ExtResource(1) vs ExtResource("1_n2jr1") - ex: SubResource(1) vs SubResource("Resource_b1ij2") + Determines if SubResources and ExtResources should be identified with string over int ids. + ex: ExtResource(1) vs ExtResource("1_n2jr1"); + ex: SubResource(1) vs SubResource("Resource_b1ij2"); typed_array_support: - Determines if TypedArrays are supported - If unsupported, will be output as regular arrays - ex: Array[int]([1]) vs [1] - packed_byte_array_base64_support: - Determines if PackedByteArrays should be output as base64 - ex: PackedByteArray("1qr3") vs PackedByteArray(1, 2, 3) + Determines if TypedArrays are supported. + If unsupported, will be output as regular arrays. + ex: Array[int]([1]) vs [1]; + packed_byte_array_base64_support: + Determines if PackedByteArrays should be output as base64. + ex: PackedByteArray("1qr3") vs PackedByteArray(1, 2, 3); packed_vector4_array_support: - Determines if PackedVector4Arrays are supported - If unsupported, will be output as a Typed array. May fallback to untyped array - ex: PackedVector4Array(1, 2, 3, 4) vs Array[Vector4]([Vector4(1, 2, 3, 4)]) + Determines if PackedVector4Arrays are supported. + If unsupported, will be output as a Typed array. May fallback to untyped array. + ex: PackedVector4Array(1, 2, 3, 4) vs Array[Vector4]([Vector4(1, 2, 3, 4)]); typed_dictionary_support: - Determines if TypedDictionaries are supported: - If unsupported, will be output as regular dict - ex: Dictionary[int,String]({\n1: "One"\n}) vs {\n1: "One"\n} + Determines if TypedDictionaries are supported. + If unsupported, will be output as regular dict. + ex: Dictionary[int,String]({\\n1: "One"\\n}) vs {\\n1: "One"\\n}; string_name_support: - Determines if StringNames are supported - If unsupported, will be output as a regular string - ex: &"foo" vs "foo" + Determines if StringNames are supported. + If unsupported, will be output as a regular string. + ex: &"foo" vs "foo"; load_steps: - Determines if load_steps will be output on GDFile header + Determines if load_steps will be output on GDFile header. packed_array_format: - Determines type to be used on Packed arrays - Packed%sArray for Godot 4.x - Pool%sArray for Godot 3.x + Determines type to be used on Packed arrays. + ex: Packed%sArray for Godot 4.x; + ex: Pool%sArray for Godot 3.x; """ def __init__( From fade4a0a44f46bd15854379bc7c3c3dd31179149 Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 24 Feb 2026 01:58:38 -0300 Subject: [PATCH 43/44] Improving coverage --- godot_parser/output.py | 16 ++-------------- tests/test_objects.py | 29 +++++++++++++++++++++++++++++ tests/test_versions.py | 2 +- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/godot_parser/output.py b/godot_parser/output.py index cce963b..3357adb 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -128,13 +128,7 @@ def __init__(self, version: Union[str, Version]): @classmethod def guess_version(cls, gd_common_file) -> "VersionOutputFormat": - from .objects import ( - ExtResource, - PackedByteArray, - PackedVector4Array, - SubResource, - TypedDictionary, - ) + from .objects import PackedByteArray, PackedVector4Array, TypedDictionary format = None @@ -156,13 +150,7 @@ def guess_version(cls, gd_common_file) -> "VersionOutputFormat": force_format_4 = True for reference in gd_common_file._iter_resource_references(): - if isinstance(reference, SubResource) and isinstance(reference.id, int): - version = cls.__V36 - return cls(version) - elif isinstance(reference, ExtResource) and isinstance(reference.id, int): - version = cls.__V36 - return cls(version) - elif isinstance(reference, TypedDictionary): + if isinstance(reference, TypedDictionary): version = max(version, cls.__V44) elif isinstance(reference, PackedVector4Array): version = max(version, cls.__V43) diff --git a/tests/test_objects.py b/tests/test_objects.py index f109042..d3d04f9 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -58,6 +58,35 @@ def test_vector3(self): self.assertEqual(v[1], 4) self.assertEqual(v[2], 5) + def test_vector4(self): + """Test for Vector4""" + v = Vector4(1, 2, 3, 4) + self.assertEqual(v[0], 1) + self.assertEqual(v[1], 2) + self.assertEqual(v[2], 3) + self.assertEqual(v[3], 4) + self.assertEqual(v.x, 1) + self.assertEqual(v.y, 2) + self.assertEqual(v.z, 3) + self.assertEqual(v.w, 4) + self.assertEqual(str(v), "Vector4(1, 2, 3, 4)") + v.x = 2 + v.y = 3 + v.z = 4 + v.w = 5 + self.assertEqual(v.x, 2) + self.assertEqual(v.y, 3) + self.assertEqual(v.z, 4) + self.assertEqual(v.w, 5) + v[0] = 3 + v[1] = 4 + v[2] = 5 + v[3] = 6 + self.assertEqual(v[0], 3) + self.assertEqual(v[1], 4) + self.assertEqual(v[2], 5) + self.assertEqual(v[3], 6) + def test_packed_vector4_array(self): """Test for PackedVector4Array""" array = PackedVector4Array.FromList( diff --git a/tests/test_versions.py b/tests/test_versions.py index 6d619e3..2112d49 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -14,7 +14,7 @@ from godot_parser.output import VersionOutputFormat -class TestVersions(unittest.TestCase): +class TestVersionOutput(unittest.TestCase): def setUp(self): self.scene = GDPackedScene() root = self.scene.add_node("Root", "Node3D") From 0a85ec5ff582eb760db1400182a2b77c6087825d Mon Sep 17 00:00:00 2001 From: DougVanny Date: Tue, 24 Feb 2026 11:03:29 -0300 Subject: [PATCH 44/44] Tiny doc fix --- godot_parser/output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/godot_parser/output.py b/godot_parser/output.py index 3357adb..d542ea2 100644 --- a/godot_parser/output.py +++ b/godot_parser/output.py @@ -9,7 +9,7 @@ class OutputFormat(object): """Controls how Godot files are generated Attributes: - punctuation_spaces : bool + punctuation_spaces: Determines if spaces should be added between puncuation. ex: Vector2( 1, 2 ) vs Vector2(1, 2); single_line_on_empty_dict: