diff --git a/godot_parser/__init__.py b/godot_parser/__init__.py index 9473177..bf29ab9 100644 --- a/godot_parser/__init__.py +++ b/godot_parser/__init__.py @@ -2,6 +2,9 @@ from .objects import * from .sections import * from .tree import * +from beartype.claw import beartype_this_package + +beartype_this_package() __version__ = "0.1.7" diff --git a/godot_parser/files.py b/godot_parser/files.py index adf5e73..0fc3282 100644 --- a/godot_parser/files.py +++ b/godot_parser/files.py @@ -7,7 +7,6 @@ Optional, Sequence, Type, - TypeVar, Union, cast, ) @@ -39,9 +38,6 @@ ] -GDFileType = TypeVar("GDFileType", bound="GDFile") - - class GodotFileException(Exception): """Thrown when there are errors in a Godot file""" @@ -259,7 +255,9 @@ def load_parent_scene(self) -> "GDScene": raise RuntimeError( "Could not find parent scene resource id(%d)" % root.instance ) - return GDScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) + scene = GDScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) + assert isinstance(scene, GDScene) + return scene @contextmanager def use_tree(self): @@ -302,12 +300,12 @@ def get_node(self, path: str = ".") -> Optional[GDNodeSection]: return node.section if node is not None else None @classmethod - def parse(cls: Type[GDFileType], contents: str) -> GDFileType: + def parse(cls, contents: str): """Parse the contents of a Godot file""" return cls.from_parser(scene_file.parse_string(contents, parseAll=True)) @classmethod - def load(cls: Type[GDFileType], filepath: str) -> GDFileType: + def load(cls, filepath: str): with open(filepath, "r", encoding="utf-8") as ifile: try: file = cls.parse(ifile.read()) @@ -320,7 +318,7 @@ def load(cls: Type[GDFileType], filepath: str) -> GDFileType: return file @classmethod - def from_parser(cls: Type[GDFileType], parse_result): + def from_parser(cls, parse_result): first_section = parse_result[0] if first_section.header.name == "gd_scene": scene = GDScene.__new__(GDScene) @@ -330,6 +328,7 @@ def from_parser(cls: Type[GDFileType], parse_result): resource = GDResource.__new__(GDResource) resource._sections = list(parse_result) return resource + return cls(*parse_result) def write(self, filename: str): @@ -382,6 +381,7 @@ def remove_at(self, index: int): section = self._sections.pop(index) if section.header.name in ["ext_resource", "sub_resource"]: self.load_steps -= 1 + return section def remove_unused_resources(self): self._remove_unused_resources(self.get_ext_resources(), ExtResource) @@ -456,3 +456,6 @@ def __init__(self, *sections: GDSection) -> None: class GDResource(GDCommonFile): def __init__(self, *sections: GDSection) -> None: super().__init__("gd_resource", *sections) + + +GDFileType = Union[GDFile, GDScene, GDResource] diff --git a/godot_parser/tree.py b/godot_parser/tree.py index 6f0ebfa..cdaad3e 100644 --- a/godot_parser/tree.py +++ b/godot_parser/tree.py @@ -329,6 +329,7 @@ def _load_parent_scene(root: Node, file: GDFile): parent_file: GDFile = file.load_parent_scene() parent_tree = Tree.build(parent_file) # Transfer parent scene's children to this scene + assert parent_tree.root is not None, "Parent scene has no root node" for child in parent_tree.root.get_children(): root.add_child(child) # Mark the entire parent tree as inherited diff --git a/godot_parser/values.py b/godot_parser/values.py index bcf9527..cf8891b 100644 --- a/godot_parser/values.py +++ b/godot_parser/values.py @@ -6,6 +6,8 @@ Group, Keyword, Opt, + ParseResults, + ParserElement, QuotedString, Suppress, Word, @@ -16,10 +18,20 @@ from .objects import GDObject -boolean = ( + +def parse_action_function(parse_results: ParseResults) -> bool: + result = parse_results[0] + if isinstance(result, bool): + return result + if isinstance(result, str): + return result.lower() == "true" + raise ValueError(f"Unexpected result: {result}") + + +boolean: ParserElement = ( (Keyword("true") | Keyword("false")) .set_name("bool") - .set_parse_action(lambda x: x[0].lower() == "true") + .set_parse_action(parse_action_function) ) null = Keyword("null").set_parse_action(lambda _: [None]) diff --git a/requirements_dev.txt b/requirements_dev.txt index 64e77bb..98730d0 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ tox twine bumpversion build +pyparsing>=3.* diff --git a/requirements_test.txt b/requirements_test.txt index c6bdd41..f595fea 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,3 +3,4 @@ black isort mypy pylint==2.17.7 +beartype diff --git a/setup.cfg b/setup.cfg index f3933c5..ad7c51f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,8 +13,6 @@ platforms = any classifiers = Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 @@ -23,7 +21,7 @@ classifiers = License :: OSI Approved :: MIT License [options] -python_requires = >=3.6 +python_requires = >=3.9 packages = find: install_requires = pyparsing>=3 diff --git a/tests/test_gdfile.py b/tests/test_gdfile.py index 6858d7c..6f25033 100644 --- a/tests/test_gdfile.py +++ b/tests/test_gdfile.py @@ -2,6 +2,7 @@ import unittest from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDScene, Node +from godot_parser.sections import GDExtResourceSection, GDSubResourceSection class TestGDFile(unittest.TestCase): @@ -174,10 +175,15 @@ def test_addremove_ext_res(self): node["texture_pool"] = GDObject("ResourcePool", res2.reference) s = scene.find_section(path="res://Res.tscn") + assert s is not None + scene.remove_section(s) scene.renumber_resource_ids() s = scene.find_section("ext_resource") + assert s is not None + assert isinstance(s, GDExtResourceSection) + self.assertEqual(s.id, 1) self.assertEqual(node["texture"], s.reference) self.assertEqual(node["textures"][0], s.reference) @@ -187,7 +193,7 @@ def test_addremove_ext_res(self): def test_remove_unused_resource(self): """Can remove unused resources""" scene = GDScene() - res = scene.add_ext_resource("res://Res.tscn", "PackedScene") + scene.add_ext_resource("res://Res.tscn", "PackedScene") scene.remove_unused_resources() resources = scene.get_sections("ext_resource") self.assertEqual(len(resources), 0) @@ -203,10 +209,15 @@ def test_addremove_sub_res(self): scene.add_section(resource) s = scene.find_sub_resource(type="CircleShape2D") + assert s is not None + scene.remove_section(s) scene.renumber_resource_ids() s = scene.find_section("sub_resource") + assert s is not None + assert isinstance(s, GDSubResourceSection) + self.assertEqual(s.id, 1) self.assertEqual(resource["shape"], s.reference) @@ -241,5 +252,7 @@ def test_file_equality(self): s2 = GDScene(GDResourceSection()) self.assertEqual(s1, s2) resource = s1.find_section("resource") + assert resource is not None + resource["key"] = "value" self.assertNotEqual(s1, s2) diff --git a/tests/test_parser.py b/tests/test_parser.py index e34a7bd..99f3120 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,13 +1,18 @@ import os +from typing import Optional import unittest from pyparsing import ParseException +from beartype.door import is_bearable + from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, parse +from godot_parser.files import GDFileType HERE = os.path.dirname(__file__) -TEST_CASES = [ + +TEST_CASES: list[tuple[str, GDFileType]] = [ ( "[gd_scene load_steps=5 format=2]", GDFile(GDSection(GDSectionHeader("gd_scene", load_steps=5, format=2))), @@ -49,7 +54,7 @@ GDSectionHeader("sub_resource", type="RectangleShape2D", id=1), extents=Vector2(12.7855, 17.0634), other=None, - **{"with spaces": 1} + **{"with spaces": 1}, ) ), ), @@ -69,7 +74,7 @@ "update": 0, "values": [Vector2(0, 0), Vector2(1, 0)], } - } + }, ) ), ), @@ -106,7 +111,7 @@ "0:0/0": 0, "0:0/0/physics_layer_0/linear_velocity": Vector2(0, 0), "0:0/0/physics_layer_0/angular_velocity": 0.0, - } + }, ) ), ), @@ -114,11 +119,9 @@ class TestParser(unittest.TestCase): - - """ """ - - def _run_test(self, string: str, expected): + def _run_test(self, string: str, expected: GDFileType): """Run a set of tests""" + parse_result: Optional[GDFileType] = None try: parse_result = parse(string) if expected == "error": @@ -126,8 +129,8 @@ def _run_test(self, string: str, expected): string, parse_result, ) - else: - self.assertEqual(parse_result, expected) + condition = is_bearable(parse_result, GDFileType) + assert condition, f"Expected {type(expected)}, got {type(parse_result)}" except ParseException as e: if expected != "error": print(string) diff --git a/tests/test_tree.py b/tests/test_tree.py index 8baea30..31ffa6c 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -4,6 +4,7 @@ import unittest from godot_parser import GDScene, Node, SubResource, TreeMutationException +from godot_parser.sections import GDNodeSection from godot_parser.util import find_project_root, gdpath_to_filepath @@ -29,6 +30,7 @@ def test_remove_node(self): # Remove by name with scene.use_tree() as tree: + assert tree.root is not None tree.root.remove_child("Child") node = scene.find_section("node", name="Child") self.assertIsNone(node) @@ -36,6 +38,7 @@ def test_remove_node(self): # Remove by index scene.add_node("Child", parent=".") with scene.use_tree() as tree: + assert tree.root is not None tree.root.remove_child(0) node = scene.find_section("node", name="Child") self.assertIsNone(node) @@ -43,6 +46,7 @@ def test_remove_node(self): # Remove by reference scene.add_node("Child", parent=".") with scene.use_tree() as tree: + assert tree.root is not None node = tree.root.get_children()[0] tree.root.remove_child(node) node = scene.find_section("node", name="Child") @@ -51,7 +55,9 @@ def test_remove_node(self): # Remove child scene.add_node("Child", parent=".") with scene.use_tree() as tree: + assert tree.root is not None node = tree.root.get_child(0) + assert node is not None node.remove_from_parent() node = scene.find_section("node", name="Child") self.assertIsNone(node) @@ -62,10 +68,15 @@ def test_insert_child(self): scene.add_node("RootNode") scene.add_node("Child1", parent=".") with scene.use_tree() as tree: + assert tree.root is not None child = Node("Child2", type="Node") tree.root.insert_child(0, child) + child1 = scene.find_section("node", name="Child1") child2 = scene.find_section("node", name="Child2") + assert child1 is not None + assert child2 is not None + idx1 = scene.get_sections().index(child1) idx2 = scene.get_sections().index(child2) self.assertLess(idx2, idx1) @@ -89,13 +100,17 @@ def test_properties(self): scene = GDScene() scene.add_node("RootNode") with scene.use_tree() as tree: + assert tree.root is not None tree.root["vframes"] = 10 self.assertEqual(tree.root["vframes"], 10) tree.root["hframes"] = 10 del tree.root["hframes"] del tree.root["hframes"] self.assertIsNone(tree.root.get("hframes")) + child = scene.find_section("node") + assert child is not None + self.assertEqual(child["vframes"], 10) def test_dunder(self): @@ -167,21 +182,30 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): super(TestInheritedScenes, cls).tearDownClass() + assert cls.project_dir is not None + if os.path.isdir(cls.project_dir): shutil.rmtree(cls.project_dir) def test_load_inherited(self): """Can load an inherited scene and read the nodes""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) + with scene.use_tree() as tree: node = tree.get_node("Health/LifeBar") - self.assertIsNotNone(node) + assert node is not None + self.assertEqual(node.type, "TextureProgress") def test_add_new_nodes(self): """Can add new nodes to an inherited scene""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) + with scene.use_tree() as tree: + assert tree.root is not None + tree.get_node("Health/LifeBar") node = Node("NewChild", type="Control") tree.root.add_child(node) @@ -189,7 +213,11 @@ def test_add_new_nodes(self): node.instance = 2 node.type = "Node2D" node.name = "NewChild2" + found = scene.find_section("node", name="NewChild2") + assert found is not None + assert isinstance(found, GDNodeSection) + self.assertIsNotNone(found) self.assertEqual(found.type, "Node2D") self.assertEqual(found.parent, ".") @@ -197,17 +225,24 @@ def test_add_new_nodes(self): def test_cannot_remove(self): """Cannot remove inherited nodes""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) + with scene.use_tree() as tree: + assert tree.root is not None node = tree.get_node("Health") - self.assertRaises(TreeMutationException, node.remove_from_parent) - self.assertRaises(TreeMutationException, lambda: tree.root.remove_child(0)) - self.assertRaises( - TreeMutationException, lambda: tree.root.remove_child("Health") - ) + assert node is not None + + with self.assertRaises(TreeMutationException): + node.remove_from_parent() + with self.assertRaises(TreeMutationException): + tree.root.remove_child(0) + with self.assertRaises(TreeMutationException): + tree.root.remove_child("Health") def test_cannot_mutate(self): """Cannot change the name/type/instance of inherited nodes""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) def change_name(x): @@ -227,21 +262,27 @@ def change_instance(x): def test_inherit_properties(self): """Inherited nodes inherit properties""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) with scene.use_tree() as tree: + assert tree.root is not None 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) - self.assertRaises(KeyError, lambda: tree.root["missing"]) + + with self.assertRaises(KeyError): + tree.root["missing"] def test_unchanged_sections(self): """Inherited nodes do not appear in sections""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) num_nodes = len(scene.get_nodes()) self.assertEqual(num_nodes, 2) with scene.use_tree() as tree: sprite = tree.get_node("Sprite") + assert sprite is not None sprite["flip_v"] = True # No new nodes num_nodes = len(scene.get_nodes()) @@ -249,9 +290,11 @@ def test_unchanged_sections(self): def test_overwrite_sections(self): """Inherited nodes appear in sections if we change their configuration""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) with scene.use_tree() as tree: node = tree.get_node("Health/LifeBar") + assert node is not None node["pause_mode"] = 2 num_nodes = len(scene.get_nodes()) self.assertEqual(num_nodes, 3) @@ -260,9 +303,11 @@ def test_overwrite_sections(self): def test_disappear_sections(self): """Inherited nodes are removed from sections if we change their configuration to match parent""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) with scene.use_tree() as tree: sprite = tree.get_node("Sprite") + assert sprite is not None sprite["flip_h"] = False # Sprite should match parent now, and not be in file node = scene.find_section("node", name="Sprite") @@ -270,6 +315,7 @@ def test_disappear_sections(self): def test_find_project_root(self): """Can find project root even if deep in folder""" + assert self.project_dir is not None os.mkdir(os.path.join(self.project_dir, "Dir1")) nested = os.path.join(self.project_dir, "Dir1", "Dir2") os.mkdir(nested) @@ -291,6 +337,7 @@ def test_missing_root(self): def test_missing_ext_resource(self): """Raise exception when GDScene is inherited but ext_resource is missing""" + assert self.leaf_scene is not None scene = GDScene.load(self.leaf_scene) for section in scene.get_ext_resources(): scene.remove_section(section) diff --git a/tox.ini b/tox.ini index 3db5ea1..b98279e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37, py38, py39, py310, py311, lint +envlist = py39, py310, py311, lint isolated_build = true [testenv] @@ -34,8 +34,6 @@ commands = [gh-actions] python = - 3.7: py37 - 3.8: py38 3.9: py39 3.10: py310 3.11: py311