├── .bumpversion.cfg ├── .envrc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pylintrc ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── godot_parser ├── __init__.py ├── files.py ├── objects.py ├── py.typed ├── sections.py ├── structure.py ├── tree.py ├── util.py └── values.py ├── pyproject.toml ├── requirements_dev.txt ├── requirements_test.txt ├── setup.cfg ├── setup.py ├── test_parse_files.py ├── tests ├── __init__.py ├── test_gdfile.py ├── test_objects.py ├── test_parser.py ├── test_sections.py └── test_tree.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.1.7 3 | tag_name = {new_version} 4 | files = setup.cfg godot_parser/__init__.py 5 | commit = True 6 | tag = True 7 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-22.04 10 | strategy: 11 | matrix: 12 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Running tests for ${{ matrix.python-version }} with tox 21 | uses: ymyzk/run-tox-gh-actions@main 22 | - name: Publish coverage 23 | if: ${{ matrix.python-version == 3.12 }} 24 | run: tox -e coveralls 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .direnv/ 132 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=E1101,W0511,W0612,W0613,W0212,W0221,W0703,W0622,C,R 3 | 4 | [BASIC] 5 | argument-rgx=[a-z_][a-z0-9_]{0,30}$ 6 | variable-rgx=[a-z_][a-z0-9_]{0,30}$ 7 | function-rgx=[a-z_][a-z0-9_]{0,30}$ 8 | attr-rgx=[a-z_][a-z0-9_]{0,30}$ 9 | method-rgx=([a-z_][a-z0-9_]{0,50}|setUp|tearDown|setUpClass|tearDownClass)$ 10 | no-docstring-rgx=((__.*__)|setUp|tearDown)$ 11 | 12 | [REPORTS] 13 | reports=no 14 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 15 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.7 4 | 5 | - Add py.typed file to provide type information [\#8](https://github.com/stevearc/godot_parser/issues/8), [\#10](https://github.com/stevearc/godot_parser/issues/10) 6 | - Allow colon in key names [\#9](https://github.com/stevearc/godot_parser/issues/9) 7 | - Drop support for python 3.6 8 | 9 | ## 0.1.6 10 | 11 | - Fix errors when using new version of pyparsing 12 | 13 | ## 0.1.5 14 | 15 | - Fix quote escaping during serialization 16 | [\#7](https://github.com/stevearc/godot_parser/issues/7) 17 | - Better error message for binary scenes 18 | [\#6](https://github.com/stevearc/godot_parser/issues/6) 19 | 20 | ## 0.1.4 21 | 22 | - Supports node groups 23 | [\#5](https://github.com/stevearc/godot_parser/pull/5) 24 | 25 | ## 0.1.3 26 | 27 | - Supports trailing commas in list definitions. 28 | - Supports quoted keys in resources. 29 | 30 | ## 0.1.2 31 | 32 | - Better support for inherited scenes 33 | 34 | ## 0.1.1 35 | 36 | - Support for `use_tree()` with inherited scenes 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Steven Arcangeli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md CHANGES.md requirements_test.txt py.typed 2 | recursive-exclude tests * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot Parser 2 | 3 | [![Build Status](https://github.com/stevearc/godot_parser/actions/workflows/tests.yml/badge.svg)](https://github.com/stevearc/godot_parser/actions) 4 | [![Coverage Status](https://coveralls.io/repos/github/stevearc/godot_parser/badge.svg?branch=master)](https://coveralls.io/github/stevearc/godot_parser?branch=master) 5 | [![Downloads](http://pepy.tech/badge/godot_parser)](https://pypi.org/pypi/godot_parser) 6 | 7 | This is a python library for parsing Godot scene (.tscn) and resource (.tres) 8 | files. It's intended to make it easier to automate certain aspects of editing 9 | scene files or resources in Godot. 10 | 11 | ## High-level API 12 | godot_parser has roughly two levels of API. The low-level API has no 13 | Godot-specific logic and is just a dumb wrapper for the file format. 14 | 15 | The high-level API has a bit of application logic on top to mirror Godot 16 | functionality and make it easier to perform certain tasks. Let's look at an 17 | example by creating a new scene file for a Player: 18 | 19 | ```python 20 | from godot_parser import GDScene, Node 21 | 22 | scene = GDScene() 23 | res = scene.add_ext_resource("res://PlayerSprite.png", "PackedScene") 24 | with scene.use_tree() as tree: 25 | tree.root = Node("Player", type="KinematicBody2D") 26 | tree.root.add_child( 27 | Node( 28 | "Sprite", 29 | type="Sprite", 30 | properties={"texture": res.reference}, 31 | ) 32 | ) 33 | scene.write("Player.tscn") 34 | ``` 35 | 36 | It's much easier to use the high-level API when it's available, but it doesn't 37 | cover everything. Note that `use_tree()` *does* support inherited scenes, and 38 | will generally function as expected (e.g. nodes on the parent scene will be 39 | available, and making edits will properly override fields in the child scene). 40 | There is no support yet for changing the inheritence of a scene. 41 | 42 | ## Low-level API 43 | Let's look at creating that same Player scene with the low-level API: 44 | 45 | ```python 46 | from godot_parser import GDFile, ExtResource, GDSection, GDSectionHeader 47 | 48 | scene = GDFile(GDSection(GDSectionHeader("gd_scene", load_steps=2, format=2))) 49 | scene.add_section( 50 | GDSection( 51 | GDSectionHeader( 52 | "ext_resource", path="res://PlayerSprite.png", type="PackedScene", id=1 53 | ) 54 | ) 55 | ) 56 | scene.add_section( 57 | GDSection(GDSectionHeader("node", name="Player", type="KinematicBody2D")) 58 | ) 59 | scene.add_section( 60 | GDSection( 61 | GDSectionHeader("node", name="Sprite", type="Sprite", parent="."), 62 | texture=ExtResource(1), 63 | ) 64 | ) 65 | scene.write("Player.tscn") 66 | ``` 67 | 68 | You can see that this requires you to manage more of the application logic 69 | yourself, such as resource IDs and node structure, but it can be used to create 70 | any kind of TSCN file. 71 | 72 | ## More Examples 73 | Here are some more examples of how you can use this library. 74 | 75 | Find all scenes in your project with a "Sensor" node and change the 76 | `collision_layer`: 77 | 78 | ```python 79 | import os 80 | import sys 81 | from godot_parser import load 82 | 83 | 84 | def main(project): 85 | for root, _dirs, files in os.walk(project): 86 | for file in files: 87 | if os.path.splitext(file)[1] == ".tscn": 88 | update_collision_layer(os.path.join(root, file)) 89 | 90 | 91 | def update_collision_layer(filepath): 92 | scene = load(filepath) 93 | updated = False 94 | with scene.use_tree() as tree: 95 | sensor = tree.get_node("Sensor") 96 | if sensor is not None: 97 | sensor["collision_layer"] = 5 98 | updated = True 99 | 100 | if updated: 101 | scene.write(filepath) 102 | 103 | 104 | main(sys.argv[1]) 105 | ``` 106 | 107 | ## Caveats 108 | This was written with the help of the [Godot TSCN 109 | docs](https://godot-es-docs.readthedocs.io/en/latest/development/file_formats/tscn.html), 110 | but it's still mostly based on visual inspection of the Godot files I'm working 111 | on. If you find a situation godot_parser doesn't handle or a feature it doesn't 112 | support, file an issue with the scene file and an explanation of the desired 113 | behavior. If you want to dig in and submit a pull request, so much the better! 114 | 115 | If you want to run a quick sanity check for this tool, you can use the 116 | `test_parse_files.py` script. Pass in your root Godot directory and it will 117 | verify that it can correctly parse and re-serialize all scene and resource files 118 | in your project. 119 | -------------------------------------------------------------------------------- /godot_parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .files import * 2 | from .objects import * 3 | from .sections import * 4 | from .tree import * 5 | 6 | __version__ = "0.1.7" 7 | 8 | parse = GDFile.parse 9 | 10 | load = GDFile.load 11 | -------------------------------------------------------------------------------- /godot_parser/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | from typing import ( 4 | Iterable, 5 | Iterator, 6 | List, 7 | Optional, 8 | Sequence, 9 | Type, 10 | TypeVar, 11 | Union, 12 | cast, 13 | ) 14 | 15 | from .objects import ExtResource, GDObject, SubResource 16 | from .sections import ( 17 | GDExtResourceSection, 18 | GDNodeSection, 19 | GDSection, 20 | GDSectionHeader, 21 | GDSubResourceSection, 22 | ) 23 | from .structure import scene_file 24 | from .util import find_project_root, gdpath_to_filepath 25 | 26 | __all__ = ["GDFile", "GDScene", "GDResource"] 27 | 28 | # Scene and resource files seem to group the section types together and sort them. 29 | # This is the order I've observed 30 | SCENE_ORDER = [ 31 | "gd_scene", 32 | "gd_resource", 33 | "ext_resource", 34 | "sub_resource", 35 | "resource", 36 | "node", 37 | "connection", 38 | "editable", 39 | ] 40 | 41 | 42 | GDFileType = TypeVar("GDFileType", bound="GDFile") 43 | 44 | 45 | class GodotFileException(Exception): 46 | """Thrown when there are errors in a Godot file""" 47 | 48 | 49 | class GDFile(object): 50 | """Base class representing the contents of a Godot file""" 51 | 52 | project_root: Optional[str] = None 53 | 54 | def __init__(self, *sections: GDSection) -> None: 55 | self._sections = list(sections) 56 | 57 | def add_section(self, new_section: GDSection) -> int: 58 | """Add a section to the file and return the index of that section""" 59 | new_idx = SCENE_ORDER.index(new_section.header.name) 60 | for i, section in enumerate(self._sections): 61 | idx = SCENE_ORDER.index(section.header.name) 62 | if new_idx < idx: # type: ignore 63 | self._sections.insert(i, new_section) 64 | return i 65 | self._sections.append(new_section) 66 | return len(self._sections) - 1 67 | 68 | def remove_section(self, section: GDSection) -> bool: 69 | """Remove a section from the file""" 70 | idx = -1 71 | for i, s in enumerate(self._sections): 72 | if section == s: 73 | idx = i 74 | break 75 | if idx == -1: 76 | return False 77 | self.remove_at(idx) 78 | return True 79 | 80 | def remove_at(self, index: int) -> GDSection: 81 | """Remove a section at an index""" 82 | return self._sections.pop(index) 83 | 84 | def get_sections(self, name: Optional[str] = None) -> List[GDSection]: 85 | """Get all sections, or all sections of a given type""" 86 | if name is None: 87 | return self._sections 88 | return [s for s in self._sections if s.header.name == name] 89 | 90 | def get_nodes(self) -> List[GDNodeSection]: 91 | """Get all [node] sections""" 92 | return cast(List[GDNodeSection], self.get_sections("node")) 93 | 94 | def get_ext_resources(self) -> List[GDExtResourceSection]: 95 | """Get all [ext_resource] sections""" 96 | return cast(List[GDExtResourceSection], self.get_sections("ext_resource")) 97 | 98 | def get_sub_resources(self) -> List[GDSubResourceSection]: 99 | """Get all [sub_resource] sections""" 100 | return cast(List[GDSubResourceSection], self.get_sections("sub_resource")) 101 | 102 | def find_node( 103 | self, property_constraints: Optional[dict] = None, **constraints 104 | ) -> Optional[GDNodeSection]: 105 | """Find first [node] section that matches (see find_section)""" 106 | return cast( 107 | GDNodeSection, 108 | self.find_section("node", property_constraints, **constraints), 109 | ) 110 | 111 | def find_ext_resource( 112 | self, property_constraints: Optional[dict] = None, **constraints 113 | ) -> Optional[GDExtResourceSection]: 114 | """Find first [ext_resource] section that matches (see find_section)""" 115 | return cast( 116 | GDExtResourceSection, 117 | self.find_section("ext_resource", property_constraints, **constraints), 118 | ) 119 | 120 | def find_sub_resource( 121 | self, property_constraints: Optional[dict] = None, **constraints 122 | ) -> Optional[GDSubResourceSection]: 123 | """Find first [sub_resource] section that matches (see find_section)""" 124 | return cast( 125 | GDSubResourceSection, 126 | self.find_section("sub_resource", property_constraints, **constraints), 127 | ) 128 | 129 | def find_section( 130 | self, 131 | section_name_: Optional[str] = None, 132 | property_constraints: Optional[dict] = None, 133 | **constraints 134 | ) -> Optional[GDSection]: 135 | """ 136 | Find the first section that matches 137 | 138 | You may pass in a section_name, which will match the header name (e.g. 'node'). 139 | You may also pass in kwargs that act as filters. For example:: 140 | 141 | # Find the first node 142 | scene.find_section('node') 143 | # Find the first Sprite 144 | scene.find_section('node', type='Sprite') 145 | # Find the first ext_resource that references Health.tscn 146 | scene.find_section('ext_resource', path='Health.tscn') 147 | """ 148 | for section in self.find_all( 149 | section_name_, property_constraints=property_constraints, **constraints 150 | ): 151 | return section 152 | return None 153 | 154 | def find_all( 155 | self, 156 | section_name_: Optional[str] = None, 157 | property_constraints: Optional[dict] = None, 158 | **constraints 159 | ) -> Iterable[GDSection]: 160 | """Same as find_section, but returns all matches""" 161 | for section in self.get_sections(section_name_): 162 | found = True 163 | for k, v in constraints.items(): 164 | if getattr(section, k, None) == v: 165 | continue 166 | if section.header.get(k) == v: 167 | continue 168 | found = False 169 | break 170 | if property_constraints is not None: 171 | for k, v in property_constraints.items(): 172 | if section.get(k) != v: 173 | found = False 174 | break 175 | if found: 176 | yield section 177 | 178 | def add_ext_resource(self, path: str, type: str) -> GDExtResourceSection: 179 | """Add an ext_resource""" 180 | next_id = 1 + max([s.id for s in self.get_ext_resources()] + [0]) 181 | section = GDExtResourceSection(path, type, next_id) 182 | self.add_section(section) 183 | return section 184 | 185 | def add_sub_resource(self, type: str, **kwargs) -> GDSubResourceSection: 186 | """Add a sub_resource""" 187 | next_id = 1 + max([s.id for s in self.get_sub_resources()] + [0]) 188 | section = GDSubResourceSection(type, next_id, **kwargs) 189 | self.add_section(section) 190 | return section 191 | 192 | def add_node( 193 | self, 194 | name: str, 195 | type: Optional[str] = None, 196 | parent: Optional[str] = None, 197 | index: Optional[int] = None, 198 | instance: Optional[int] = None, 199 | groups: Optional[List[str]] = None, 200 | ) -> GDNodeSection: 201 | """ 202 | Simple API for adding a node 203 | 204 | For a friendlier, tree-oriented API use use_tree() 205 | """ 206 | node = GDNodeSection( 207 | name, 208 | type=type, 209 | parent=parent, 210 | index=index, 211 | instance=instance, 212 | groups=groups, 213 | ) 214 | self.add_section(node) 215 | return node 216 | 217 | def add_ext_node( 218 | self, 219 | name: str, 220 | instance: int, 221 | parent: Optional[str] = None, 222 | index: Optional[int] = None, 223 | ) -> GDNodeSection: 224 | """ 225 | Simple API for adding a node that instances an ext_resource 226 | 227 | For a friendlier, tree-oriented API use use_tree() 228 | """ 229 | node = GDNodeSection.ext_node(name, instance, parent=parent, index=index) 230 | self.add_section(node) 231 | return node 232 | 233 | @property 234 | def is_inherited(self) -> bool: 235 | root = self.find_node(parent=None) 236 | if root is None: 237 | return False 238 | return root.instance is not None 239 | 240 | def get_parent_scene(self) -> Optional[str]: 241 | root = self.find_node(parent=None) 242 | if root is None or root.instance is None: 243 | return None 244 | parent_res = self.find_ext_resource(id=root.instance) 245 | if parent_res is None: 246 | return None 247 | return parent_res.path 248 | 249 | def load_parent_scene(self) -> "GDScene": 250 | if self.project_root is None: 251 | raise RuntimeError( 252 | "load_parent_scene() requires a project_root on the GDFile" 253 | ) 254 | root = self.find_node(parent=None) 255 | if root is None or root.instance is None: 256 | raise RuntimeError("Cannot load parent scene; scene is not inherited") 257 | parent_res = self.find_ext_resource(id=root.instance) 258 | if parent_res is None: 259 | raise RuntimeError( 260 | "Could not find parent scene resource id(%d)" % root.instance 261 | ) 262 | return GDScene.load(gdpath_to_filepath(self.project_root, parent_res.path)) 263 | 264 | @contextmanager 265 | def use_tree(self): 266 | """ 267 | Helper API for working with the nodes in a tree structure 268 | 269 | This temporarily builds the nodes into a tree, and flattens them back into the 270 | GD file format when done. 271 | 272 | Example:: 273 | 274 | with scene.use_tree() as tree: 275 | tree.root = Node('MyScene') 276 | tree.root.add_child(Node('Sensor', type='Area2D')) 277 | tree.root.add_child(Node('HealthBar', instance=1)) 278 | scene.write("MyScene.tscn") 279 | """ 280 | from .tree import Tree 281 | 282 | tree = Tree.build(self) 283 | yield tree 284 | for i in range(len(self._sections) - 1, -1, -1): 285 | section = self._sections[i] 286 | if section.header.name == "node": 287 | self._sections.pop(i) 288 | nodes = tree.flatten() 289 | if not nodes: 290 | return 291 | # Let's find out where the root node belongs and then bulk add the rest at that 292 | # index 293 | i = self.add_section(nodes[0]) 294 | self._sections[i + 1 : i + 1] = nodes[1:] 295 | 296 | def get_node(self, path: str = ".") -> Optional[GDNodeSection]: 297 | """Mimics the Godot get_node API""" 298 | with self.use_tree() as tree: 299 | if tree.root is None: 300 | return None 301 | node = tree.root.get_node(path) 302 | return node.section if node is not None else None 303 | 304 | @classmethod 305 | def parse(cls: Type[GDFileType], contents: str) -> GDFileType: 306 | """Parse the contents of a Godot file""" 307 | return cls.from_parser(scene_file.parse_string(contents, parseAll=True)) 308 | 309 | @classmethod 310 | def load(cls: Type[GDFileType], filepath: str) -> GDFileType: 311 | with open(filepath, "r", encoding="utf-8") as ifile: 312 | try: 313 | file = cls.parse(ifile.read()) 314 | except UnicodeDecodeError: 315 | raise NotImplementedError( # pylint: disable=W0707 316 | "Error loading %s: godot_parser does not support binary scenes" 317 | % filepath 318 | ) 319 | file.project_root = find_project_root(filepath) 320 | return file 321 | 322 | @classmethod 323 | def from_parser(cls: Type[GDFileType], parse_result): 324 | first_section = parse_result[0] 325 | if first_section.header.name == "gd_scene": 326 | scene = GDScene.__new__(GDScene) 327 | scene._sections = list(parse_result) 328 | return scene 329 | elif first_section.header.name == "gd_resource": 330 | resource = GDResource.__new__(GDResource) 331 | resource._sections = list(parse_result) 332 | return resource 333 | return cls(*parse_result) 334 | 335 | def write(self, filename: str): 336 | """Writes this to a file""" 337 | os.makedirs(os.path.dirname(filename), exist_ok=True) 338 | with open(filename, "w", encoding="utf-8") as ofile: 339 | ofile.write(str(self)) 340 | 341 | def __str__(self) -> str: 342 | return "\n\n".join([str(s) for s in self._sections]) + "\n" 343 | 344 | def __repr__(self) -> str: 345 | return "%s(%s)" % (type(self).__name__, self.__str__()) 346 | 347 | def __eq__(self, other) -> bool: 348 | if not isinstance(other, GDFile): 349 | return False 350 | return self._sections == other._sections 351 | 352 | def __ne__(self, other) -> bool: 353 | return not self.__eq__(other) 354 | 355 | 356 | class GDCommonFile(GDFile): 357 | """Base class with common application logic for all Godot file types""" 358 | 359 | def __init__(self, name: str, *sections: GDSection) -> None: 360 | super().__init__( 361 | GDSection(GDSectionHeader(name, load_steps=1, format=2)), *sections 362 | ) 363 | self.load_steps = ( 364 | 1 + len(self.get_ext_resources()) + len(self.get_sub_resources()) 365 | ) 366 | 367 | @property 368 | def load_steps(self) -> int: 369 | return self._sections[0].header["load_steps"] 370 | 371 | @load_steps.setter 372 | def load_steps(self, steps: int): 373 | self._sections[0].header["load_steps"] = steps 374 | 375 | def add_section(self, new_section: GDSection) -> int: 376 | idx = super().add_section(new_section) 377 | if new_section.header.name in ["ext_resource", "sub_resource"]: 378 | self.load_steps += 1 379 | return idx 380 | 381 | def remove_at(self, index: int): 382 | section = self._sections.pop(index) 383 | if section.header.name in ["ext_resource", "sub_resource"]: 384 | self.load_steps -= 1 385 | 386 | def remove_unused_resources(self): 387 | self._remove_unused_resources(self.get_ext_resources(), ExtResource) 388 | self._remove_unused_resources(self.get_sub_resources(), SubResource) 389 | 390 | def _remove_unused_resources( 391 | self, 392 | sections: Sequence[Union[GDExtResourceSection, GDSubResourceSection]], 393 | reference_type: Type[Union[ExtResource, SubResource]], 394 | ) -> None: 395 | seen = set() 396 | for ref in self._iter_node_resource_references(): 397 | if isinstance(ref, reference_type): 398 | seen.add(ref.id) 399 | if len(seen) < len(sections): 400 | to_remove = [s for s in sections if s.id not in seen] 401 | for s in to_remove: 402 | self.remove_section(s) 403 | 404 | def renumber_resource_ids(self): 405 | """Refactor all resource IDs to be sequential with no gaps""" 406 | self._renumber_resource_ids(self.get_ext_resources(), ExtResource) 407 | self._renumber_resource_ids(self.get_sub_resources(), SubResource) 408 | 409 | def _iter_node_resource_references( 410 | self, 411 | ) -> Iterator[Union[ExtResource, SubResource]]: 412 | def iter_resources(value): 413 | if isinstance(value, (ExtResource, SubResource)): 414 | yield value 415 | elif isinstance(value, list): 416 | for v in value: 417 | yield from iter_resources(v) 418 | elif isinstance(value, dict): 419 | for v in value.values(): 420 | yield from iter_resources(v) 421 | elif isinstance(value, GDObject): 422 | for v in value.args: 423 | yield from iter_resources(v) 424 | 425 | for node in self.get_nodes(): 426 | yield from iter_resources(node.header.attributes) 427 | yield from iter_resources(node.properties) 428 | for resource in self.get_sections("resource"): 429 | yield from iter_resources(resource.properties) 430 | 431 | def _renumber_resource_ids( 432 | self, 433 | sections: Sequence[Union[GDExtResourceSection, GDSubResourceSection]], 434 | reference_type: Type[Union[ExtResource, SubResource]], 435 | ) -> None: 436 | id_map = {} 437 | # First we renumber all the resource IDs so there are no gaps 438 | for i, section in enumerate(sections): 439 | id_map[section.id] = i + 1 440 | section.id = i + 1 441 | 442 | # Now we update all references to use the new number 443 | for ref in self._iter_node_resource_references(): 444 | if isinstance(ref, reference_type): 445 | try: 446 | ref.id = id_map[ref.id] 447 | except KeyError as e: 448 | raise GodotFileException("Unknown resource ID %d" % ref.id) from e 449 | 450 | 451 | class GDScene(GDCommonFile): 452 | def __init__(self, *sections: GDSection) -> None: 453 | super().__init__("gd_scene", *sections) 454 | 455 | 456 | class GDResource(GDCommonFile): 457 | def __init__(self, *sections: GDSection) -> None: 458 | super().__init__("gd_resource", *sections) 459 | -------------------------------------------------------------------------------- /godot_parser/objects.py: -------------------------------------------------------------------------------- 1 | """Wrappers for Godot's non-primitive object types""" 2 | 3 | from functools import partial 4 | from typing import Type, TypeVar 5 | 6 | from .util import stringify_object 7 | 8 | __all__ = [ 9 | "GDObject", 10 | "Vector2", 11 | "Vector3", 12 | "Color", 13 | "NodePath", 14 | "ExtResource", 15 | "SubResource", 16 | ] 17 | 18 | GD_OBJECT_REGISTRY = {} 19 | 20 | 21 | class GDObjectMeta(type): 22 | """ 23 | This is me trying to be too clever for my own good 24 | 25 | Odds are high that it'll cause some weird hard-to-debug issues at some point, but 26 | isn't it neeeeeat? -_- 27 | """ 28 | 29 | def __new__(cls, name, bases, dct): 30 | x = super().__new__(cls, name, bases, dct) 31 | GD_OBJECT_REGISTRY[name] = x 32 | return x 33 | 34 | 35 | GDObjectType = TypeVar("GDObjectType", bound="GDObject") 36 | 37 | 38 | class GDObject(metaclass=GDObjectMeta): 39 | """ 40 | Base class for all GD Object types 41 | 42 | Can be used to represent any GD type. For example:: 43 | 44 | GDObject('Vector2', 1, 2) == Vector2(1, 2) 45 | """ 46 | 47 | def __init__(self, name, *args) -> None: 48 | self.name = name 49 | self.args = list(args) 50 | 51 | @classmethod 52 | def from_parser(cls: Type[GDObjectType], parse_result) -> GDObjectType: 53 | name = parse_result[0] 54 | factory = GD_OBJECT_REGISTRY.get(name, partial(GDObject, name)) 55 | return factory(*parse_result[1:]) 56 | 57 | def __str__(self) -> str: 58 | return "%s( %s )" % ( 59 | self.name, 60 | ", ".join([stringify_object(v) for v in self.args]), 61 | ) 62 | 63 | def __repr__(self) -> str: 64 | return self.__str__() 65 | 66 | def __eq__(self, other) -> bool: 67 | if not isinstance(other, GDObject): 68 | return False 69 | return self.name == other.name and self.args == other.args 70 | 71 | def __ne__(self, other) -> bool: 72 | return not self.__eq__(other) 73 | 74 | 75 | class Vector2(GDObject): 76 | def __init__(self, x: float, y: float) -> None: 77 | super().__init__("Vector2", x, y) 78 | 79 | def __getitem__(self, idx) -> float: 80 | return self.args[idx] 81 | 82 | def __setitem__(self, idx: int, value: float): 83 | self.args[idx] = value 84 | 85 | @property 86 | def x(self) -> float: 87 | """Getter for x""" 88 | return self.args[0] 89 | 90 | @x.setter 91 | def x(self, x: float) -> None: 92 | """Setter for x""" 93 | self.args[0] = x 94 | 95 | @property 96 | def y(self) -> float: 97 | """Getter for y""" 98 | return self.args[1] 99 | 100 | @y.setter 101 | def y(self, y: float) -> None: 102 | """Setter for y""" 103 | self.args[1] = y 104 | 105 | 106 | class Vector3(GDObject): 107 | def __init__(self, x: float, y: float, z: float) -> None: 108 | super().__init__("Vector3", x, y, z) 109 | 110 | def __getitem__(self, idx: int) -> float: 111 | return self.args[idx] 112 | 113 | def __setitem__(self, idx: int, value: float) -> None: 114 | self.args[idx] = value 115 | 116 | @property 117 | def x(self) -> float: 118 | """Getter for x""" 119 | return self.args[0] 120 | 121 | @x.setter 122 | def x(self, x: float) -> None: 123 | """Setter for x""" 124 | self.args[0] = x 125 | 126 | @property 127 | def y(self) -> float: 128 | """Getter for y""" 129 | return self.args[1] 130 | 131 | @y.setter 132 | def y(self, y: float) -> None: 133 | """Setter for y""" 134 | self.args[1] = y 135 | 136 | @property 137 | def z(self) -> float: 138 | """Getter for z""" 139 | return self.args[2] 140 | 141 | @z.setter 142 | def z(self, z: float) -> None: 143 | """Setter for z""" 144 | self.args[2] = z 145 | 146 | 147 | class Color(GDObject): 148 | def __init__(self, r: float, g: float, b: float, a: float) -> None: 149 | assert 0 <= r <= 1 150 | assert 0 <= g <= 1 151 | assert 0 <= b <= 1 152 | assert 0 <= a <= 1 153 | super().__init__("Color", r, g, b, a) 154 | 155 | def __getitem__(self, idx: int) -> float: 156 | return self.args[idx] 157 | 158 | def __setitem__(self, idx: int, value: float) -> None: 159 | self.args[idx] = value 160 | 161 | @property 162 | def r(self) -> float: 163 | """Getter for r""" 164 | return self.args[0] 165 | 166 | @r.setter 167 | def r(self, r: float) -> None: 168 | """Setter for r""" 169 | self.args[0] = r 170 | 171 | @property 172 | def g(self) -> float: 173 | """Getter for g""" 174 | return self.args[1] 175 | 176 | @g.setter 177 | def g(self, g: float) -> None: 178 | """Setter for g""" 179 | self.args[1] = g 180 | 181 | @property 182 | def b(self) -> float: 183 | """Getter for b""" 184 | return self.args[2] 185 | 186 | @b.setter 187 | def b(self, b: float) -> None: 188 | """Setter for b""" 189 | self.args[2] = b 190 | 191 | @property 192 | def a(self) -> float: 193 | """Getter for a""" 194 | return self.args[3] 195 | 196 | @a.setter 197 | def a(self, a: float) -> None: 198 | """Setter for a""" 199 | self.args[3] = a 200 | 201 | 202 | class NodePath(GDObject): 203 | def __init__(self, path: str) -> None: 204 | super().__init__("NodePath", path) 205 | 206 | @property 207 | def path(self) -> str: 208 | """Getter for path""" 209 | return self.args[0] 210 | 211 | @path.setter 212 | def path(self, path: str) -> None: 213 | """Setter for path""" 214 | self.args[0] = path 215 | 216 | def __str__(self) -> str: 217 | return '%s("%s")' % (self.name, self.path) 218 | 219 | 220 | class ExtResource(GDObject): 221 | def __init__(self, id: int) -> None: 222 | super().__init__("ExtResource", id) 223 | 224 | @property 225 | def id(self) -> int: 226 | """Getter for id""" 227 | return self.args[0] 228 | 229 | @id.setter 230 | def id(self, id: int) -> None: 231 | """Setter for id""" 232 | self.args[0] = id 233 | 234 | 235 | class SubResource(GDObject): 236 | def __init__(self, id: int) -> None: 237 | super().__init__("SubResource", id) 238 | 239 | @property 240 | def id(self) -> int: 241 | """Getter for id""" 242 | return self.args[0] 243 | 244 | @id.setter 245 | def id(self, id: int) -> None: 246 | """Setter for id""" 247 | self.args[0] = id 248 | -------------------------------------------------------------------------------- /godot_parser/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /godot_parser/sections.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | from typing import Any, List, Optional, Type, TypeVar 4 | 5 | from .objects import ExtResource, SubResource 6 | from .util import stringify_object 7 | 8 | __all__ = [ 9 | "GDSectionHeader", 10 | "GDSection", 11 | "GDNodeSection", 12 | "GDExtResourceSection", 13 | "GDSubResourceSection", 14 | "GDResourceSection", 15 | ] 16 | 17 | 18 | GD_SECTION_REGISTRY = {} 19 | 20 | 21 | class GDSectionHeader(object): 22 | """ 23 | Represents the header for a section 24 | 25 | example:: 26 | 27 | [node name="Sprite" type="Sprite" index="3"] 28 | """ 29 | 30 | def __init__(self, _name: str, **kwargs) -> None: 31 | self.name = _name 32 | self.attributes = OrderedDict() 33 | for k, v in kwargs.items(): 34 | self.attributes[k] = v 35 | 36 | def __getitem__(self, k: str) -> Any: 37 | return self.attributes[k] 38 | 39 | def __setitem__(self, k: str, v: Any) -> None: 40 | self.attributes[k] = v 41 | 42 | def __delitem__(self, k: str): 43 | try: 44 | del self.attributes[k] 45 | except KeyError: 46 | pass 47 | 48 | def get(self, k: str, default: Any = None) -> Any: 49 | return self.attributes.get(k, default) 50 | 51 | @classmethod 52 | def from_parser(cls: Type["GDSectionHeader"], parse_result) -> "GDSectionHeader": 53 | header = cls(parse_result[0]) 54 | for attribute in parse_result[1:]: 55 | header.attributes[attribute[0]] = attribute[1] 56 | return header 57 | 58 | def __str__(self) -> str: 59 | attribute_str = "" 60 | if self.attributes: 61 | attribute_str = " " + " ".join( 62 | ["%s=%s" % (k, stringify_object(v)) for k, v in self.attributes.items()] 63 | ) 64 | return "[" + self.name + attribute_str + "]" 65 | 66 | def __repr__(self) -> str: 67 | return "GDSectionHeader(%s)" % self.__str__() 68 | 69 | def __eq__(self, other: Any) -> bool: 70 | if not isinstance(other, GDSectionHeader): 71 | return False 72 | return self.name == other.name and self.attributes == other.attributes 73 | 74 | def __ne__(self, other: Any) -> bool: 75 | return not self.__eq__(other) 76 | 77 | 78 | class GDSectionMeta(type): 79 | """Still trying to be too clever""" 80 | 81 | def __new__(cls, name, bases, dct): 82 | x = super().__new__(cls, name, bases, dct) 83 | section_name_camel = name[2:-7] 84 | section_name = re.sub(r"(? None: 104 | self.header = header 105 | self.properties = OrderedDict() 106 | for k, v in kwargs.items(): 107 | self.properties[k] = v 108 | 109 | def __getitem__(self, k: str) -> Any: 110 | return self.properties[k] 111 | 112 | def __setitem__(self, k: str, v: Any) -> None: 113 | self.properties[k] = v 114 | 115 | def __delitem__(self, k: str) -> None: 116 | try: 117 | del self.properties[k] 118 | except KeyError: 119 | pass 120 | 121 | def get(self, k: str, default: Any = None) -> Any: 122 | return self.properties.get(k, default) 123 | 124 | @classmethod 125 | def from_parser(cls: Type[GDSectionType], parse_result) -> GDSectionType: 126 | header = parse_result[0] 127 | factory = GD_SECTION_REGISTRY.get(header.name, cls) 128 | section = factory.__new__(factory) 129 | section.header = header 130 | section.properties = OrderedDict() 131 | for k, v in parse_result[1:]: 132 | section[k] = v 133 | return section 134 | 135 | def __str__(self) -> str: 136 | ret = str(self.header) 137 | if self.properties: 138 | ret += "\n" + "\n".join( 139 | [ 140 | "%s = %s" % (k, stringify_object(v)) 141 | for k, v in self.properties.items() 142 | ] 143 | ) 144 | return ret 145 | 146 | def __repr__(self) -> str: 147 | return "%s(%s)" % (type(self).__name__, self.__str__()) 148 | 149 | def __eq__(self, other: Any) -> bool: 150 | if not isinstance(other, GDSection): 151 | return False 152 | return self.header == other.header and self.properties == other.properties 153 | 154 | def __ne__(self, other: Any) -> bool: 155 | return not self.__eq__(other) 156 | 157 | 158 | class GDExtResourceSection(GDSection): 159 | """Section representing an [ext_resource]""" 160 | 161 | def __init__(self, path: str, type: str, id: int): 162 | super().__init__(GDSectionHeader("ext_resource", path=path, type=type, id=id)) 163 | 164 | @property 165 | def path(self) -> str: 166 | return self.header["path"] 167 | 168 | @path.setter 169 | def path(self, path: str) -> None: 170 | self.header["path"] = path 171 | 172 | @property 173 | def type(self) -> str: 174 | return self.header["type"] 175 | 176 | @type.setter 177 | def type(self, type: str) -> None: 178 | self.header["type"] = type 179 | 180 | @property 181 | def id(self) -> int: 182 | return self.header["id"] 183 | 184 | @id.setter 185 | def id(self, id: int) -> None: 186 | self.header["id"] = id 187 | 188 | @property 189 | def reference(self) -> ExtResource: 190 | return ExtResource(self.id) 191 | 192 | 193 | class GDSubResourceSection(GDSection): 194 | """Section representing a [sub_resource]""" 195 | 196 | def __init__(self, type: str, id: int, **kwargs): 197 | super().__init__(GDSectionHeader("sub_resource", type=type, id=id), **kwargs) 198 | 199 | @property 200 | def type(self) -> str: 201 | return self.header["type"] 202 | 203 | @type.setter 204 | def type(self, type: str) -> None: 205 | self.header["type"] = type 206 | 207 | @property 208 | def id(self) -> int: 209 | return self.header["id"] 210 | 211 | @id.setter 212 | def id(self, id: int) -> None: 213 | self.header["id"] = id 214 | 215 | @property 216 | def reference(self) -> SubResource: 217 | return SubResource(self.id) 218 | 219 | 220 | class GDNodeSection(GDSection): 221 | """Section representing a [node]""" 222 | 223 | def __init__( 224 | self, 225 | name: str, 226 | type: Optional[str] = None, 227 | parent: Optional[str] = None, 228 | instance: Optional[int] = None, 229 | index: Optional[int] = None, 230 | groups: Optional[List[str]] = None, 231 | # TODO: instance_placeholder, owner are referenced in the docs, but I 232 | # haven't seen them come up yet in my project 233 | ): 234 | kwargs = { 235 | "name": name, 236 | "type": type, 237 | "parent": parent, 238 | "instance": ExtResource(instance) if instance is not None else None, 239 | "index": str(index) if index is not None else None, 240 | "groups": groups, 241 | } 242 | super().__init__( 243 | GDSectionHeader( 244 | "node", **{k: v for k, v in kwargs.items() if v is not None} 245 | ) 246 | ) 247 | 248 | @classmethod 249 | def ext_node( 250 | cls, 251 | name: str, 252 | instance: int, 253 | parent: Optional[str] = None, 254 | index: Optional[int] = None, 255 | ): 256 | return cls(name, parent=parent, instance=instance, index=index) 257 | 258 | @property 259 | def name(self) -> str: 260 | return self.header["name"] 261 | 262 | @name.setter 263 | def name(self, name: str) -> None: 264 | self.header["name"] = name 265 | 266 | @property 267 | def type(self) -> Optional[str]: 268 | return self.header.get("type") 269 | 270 | @type.setter 271 | def type(self, type: Optional[str]) -> None: 272 | if type is None: 273 | del self.header["type"] 274 | else: 275 | self.header["type"] = type 276 | self.instance = None 277 | 278 | @property 279 | def parent(self) -> Optional[str]: 280 | return self.header.get("parent") 281 | 282 | @parent.setter 283 | def parent(self, parent: Optional[str]) -> None: 284 | if parent is None: 285 | del self.header["parent"] 286 | else: 287 | self.header["parent"] = parent 288 | 289 | @property 290 | def instance(self) -> Optional[int]: 291 | resource = self.header.get("instance") 292 | if resource is not None: 293 | return resource.id 294 | return None 295 | 296 | @instance.setter 297 | def instance(self, instance: Optional[int]) -> None: 298 | if instance is None: 299 | del self.header["instance"] 300 | else: 301 | self.header["instance"] = ExtResource(instance) 302 | self.type = None 303 | 304 | @property 305 | def index(self) -> Optional[int]: 306 | idx = self.header.get("index") 307 | if idx is not None: 308 | return int(idx) 309 | return None 310 | 311 | @index.setter 312 | def index(self, index: Optional[int]) -> None: 313 | if index is None: 314 | del self.header["index"] 315 | else: 316 | self.header["index"] = str(index) 317 | 318 | @property 319 | def groups(self) -> Optional[List[str]]: 320 | return self.header.get("groups") 321 | 322 | @groups.setter 323 | def groups(self, groups: Optional[List[str]]) -> None: 324 | if groups is None: 325 | del self.header["groups"] 326 | else: 327 | self.header["groups"] = groups 328 | 329 | 330 | class GDResourceSection(GDSection): 331 | """Represents a [resource] section""" 332 | 333 | def __init__(self, **kwargs): 334 | super().__init__(GDSectionHeader("resource"), **kwargs) 335 | -------------------------------------------------------------------------------- /godot_parser/structure.py: -------------------------------------------------------------------------------- 1 | """The grammar of the larger structures in the GD file format""" 2 | 3 | from pyparsing import ( 4 | DelimitedList, 5 | Empty, 6 | Group, 7 | LineEnd, 8 | Opt, 9 | QuotedString, 10 | Suppress, 11 | Word, 12 | alphanums, 13 | ) 14 | 15 | from .sections import GDSection, GDSectionHeader 16 | from .values import value 17 | 18 | key = QuotedString('"', escChar="\\", multiline=False).set_name("key") | Word( 19 | alphanums + "_/:" 20 | ).set_name("key") 21 | var = Word(alphanums + "_").set_name("variable") 22 | attribute = Group(var + Suppress("=") + value) 23 | 24 | # [node name="Node2D"] 25 | section_header = ( 26 | ( 27 | Suppress("[") 28 | + var.set_results_name("section_type") 29 | + Opt(DelimitedList(attribute, Empty())) 30 | + Suppress("]") 31 | + Suppress(LineEnd()) 32 | ) 33 | .set_name("section_header") 34 | .set_parse_action(GDSectionHeader.from_parser) 35 | ) 36 | 37 | # texture = ExtResource( 1 ) 38 | section_entry = Group(key + Suppress("=") + value + Suppress(LineEnd())).set_name( 39 | "section_entry" 40 | ) 41 | section_contents = DelimitedList(section_entry, Empty()).set_name("section_contents") 42 | 43 | # [node name="Sprite" type="Sprite"] 44 | # texture = ExtResource( 1 ) 45 | section = ( 46 | (section_header + Opt(section_contents)) 47 | .set_name("section") 48 | .set_parse_action(GDSection.from_parser) 49 | ) 50 | 51 | # Exports 52 | 53 | scene_file = DelimitedList(section, Empty()) 54 | -------------------------------------------------------------------------------- /godot_parser/tree.py: -------------------------------------------------------------------------------- 1 | """Helper API for working with the Godot scene tree structure""" 2 | 3 | from collections import OrderedDict 4 | from typing import Any, List, Optional, Union 5 | 6 | from .files import GDFile 7 | from .sections import GDNodeSection 8 | 9 | __all__ = ["Node", "TreeMutationException"] 10 | SENTINEL = object() 11 | 12 | 13 | class TreeMutationException(Exception): 14 | """Raised when attempting to mutate the tree in an unsupported way""" 15 | 16 | 17 | class Node(object): 18 | """ 19 | Wraps a GDNodeSection object 20 | 21 | Provides a way to access the node sections that is spatially-aware. It is stored in 22 | a tree structure instead of the flat list that the file format demands. 23 | """ 24 | 25 | _children: List["Node"] 26 | _parent: Optional["Node"] 27 | _index: Optional[int] 28 | 29 | def __init__( 30 | self, 31 | name: str, 32 | type: Optional[str] = None, 33 | instance: Optional[int] = None, 34 | section: Optional[GDNodeSection] = None, 35 | groups: Optional[List[str]] = None, 36 | properties: Optional[dict] = None, 37 | ): 38 | self._name = name 39 | self._type = type 40 | self._instance = instance 41 | self._parent = None 42 | self._index = None 43 | self.section = section or GDNodeSection(name) 44 | self._groups = groups 45 | self.properties = ( 46 | OrderedDict() if properties is None else OrderedDict(properties) 47 | ) 48 | self._children = [] # type: ignore 49 | self._inherited_node: Optional["Node"] = None 50 | 51 | def _mark_inherited(self) -> None: 52 | clone = self.clone() 53 | clone._inherited_node = self._inherited_node 54 | self._inherited_node = clone 55 | self.properties.clear() 56 | self._type = None 57 | self._instance = None 58 | self.section = GDNodeSection(self.name) 59 | 60 | def clone(self) -> "Node": 61 | return Node( 62 | self.name, self.type, self.instance, properties=OrderedDict(self.properties) 63 | ) 64 | 65 | @property 66 | def parent(self) -> Optional["Node"]: 67 | return self._parent 68 | 69 | @property 70 | def name(self) -> str: 71 | return self._name 72 | 73 | @name.setter 74 | def name(self, new_name: str) -> None: 75 | if self._inherited_node is not None: 76 | raise TreeMutationException("Cannot change the name of an inherited node") 77 | self._name = new_name 78 | 79 | @property 80 | def type(self) -> Optional[str]: 81 | if self._inherited_node is not None: 82 | return self._inherited_node.type 83 | return self._type 84 | 85 | @type.setter 86 | def type(self, new_type: Optional[str]) -> None: 87 | if self.is_inherited: 88 | raise TreeMutationException("Cannot change the type of an inherited node") 89 | if new_type is not None: 90 | self._instance = None 91 | self._type = new_type 92 | 93 | @property 94 | def instance(self) -> Optional[int]: 95 | if self._inherited_node is not None: 96 | return self._inherited_node.instance 97 | return self._instance 98 | 99 | @instance.setter 100 | def instance(self, new_instance: Optional[int]) -> None: 101 | if self.is_inherited: 102 | raise TreeMutationException( 103 | "Cannot change the instance of an inherited node" 104 | ) 105 | if new_instance is not None: 106 | self._type = None 107 | self._instance = new_instance 108 | 109 | def __getitem__(self, k: str) -> Any: 110 | v = self.properties.get(k, SENTINEL) 111 | if v is SENTINEL: 112 | if self._inherited_node is not None: 113 | return self._inherited_node[k] 114 | raise KeyError("No property %s found on node %s" % (k, self.name)) 115 | return v 116 | 117 | def __setitem__(self, k: str, v: Any) -> None: 118 | if self._inherited_node is not None and v == self._inherited_node.get( 119 | k, SENTINEL 120 | ): 121 | del self[k] 122 | else: 123 | self.properties[k] = v 124 | 125 | def __delitem__(self, k: str) -> None: 126 | try: 127 | del self.properties[k] 128 | except KeyError: 129 | pass 130 | 131 | def get(self, k: str, default: Any = None) -> Any: 132 | v = self.properties.get(k, SENTINEL) 133 | if v is SENTINEL: 134 | if self._inherited_node is not None: 135 | return self._inherited_node.get(k, default) 136 | return default 137 | return v 138 | 139 | @classmethod 140 | def from_section(cls, section: GDNodeSection): 141 | """Create a Node from a GDNodeSection""" 142 | return cls( 143 | section.name, 144 | section.type, 145 | section.instance, 146 | section, 147 | properties=section.properties, 148 | ) 149 | 150 | def flatten(self, path: Optional[str] = None): 151 | """ 152 | Write values to GDNodeSection and iterate over children 153 | 154 | This call will copy the existing values on this node into the GDNodeSection and 155 | iterate over self and all child nodes, calling flatten on them as well. 156 | """ 157 | 158 | self._update_section(path) 159 | 160 | yield self 161 | if path is None: 162 | child_path = "." 163 | elif path == ".": 164 | child_path = self.name 165 | else: 166 | child_path = path + "/" + self.name 167 | child_idx = 0 168 | # Assign an index to children if we were assigned one, or if we are the root 169 | # node of an inherited scene 170 | use_index = self._index is not None or ( 171 | self.parent is None and self._instance is not None 172 | ) 173 | for child in self._children: 174 | if use_index: 175 | child._index = child_idx 176 | child_idx += 1 177 | yield from child.flatten(child_path) 178 | 179 | def _update_section(self, path: Optional[str] = None) -> None: 180 | self.section.name = self.name 181 | self.section.type = self._type 182 | self.section.parent = path 183 | self.section.instance = self._instance 184 | self.section.groups = self._groups 185 | self.section.properties = self.properties 186 | if self._index is not None: 187 | self.section.index = self._index 188 | 189 | @property 190 | def is_inherited(self) -> bool: 191 | return self._inherited_node is not None 192 | 193 | @property 194 | def has_changes(self) -> bool: 195 | return bool(self.properties) 196 | 197 | def get_children(self) -> List["Node"]: 198 | """Get all children of this node""" 199 | return self._children 200 | 201 | def get_child(self, name_or_index: Union[int, str]) -> Optional["Node"]: 202 | """Get a child by name or index""" 203 | if isinstance(name_or_index, int): 204 | return self._children[name_or_index] 205 | for node in self._children: 206 | if node.name == name_or_index: 207 | return node 208 | return None 209 | 210 | def get_node(self, path: str) -> Optional["Node"]: 211 | """Mimics the Godot get_node() behavior""" 212 | if path in (".", ""): 213 | return self 214 | pieces = path.split("/") 215 | child = self.get_child(pieces[0]) 216 | if child is None: 217 | return None 218 | return child.get_node("/".join(pieces[1:])) 219 | 220 | def add_child(self, node: "Node") -> None: 221 | """Add a child to the current node""" 222 | self._children.append(node) 223 | node._parent = self 224 | 225 | def insert_child(self, index: int, node: "Node") -> None: 226 | """Add a child to the current node before the specified index""" 227 | self._children.insert(index, node) 228 | node._parent = self 229 | 230 | def _merge_child(self, section: GDNodeSection) -> None: 231 | """Add a child that may be an inherited node""" 232 | for child in self._children: 233 | if child.name == section.name: 234 | child.section = section 235 | child.properties = section.properties 236 | return 237 | self.add_child(Node.from_section(section)) 238 | 239 | def remove_from_parent(self) -> None: 240 | """Remove this node from its parent""" 241 | if self.parent is not None: 242 | self.parent.remove_child(self) 243 | 244 | def remove_child(self, node_or_name_or_index: Union[str, int, "Node"]) -> None: 245 | """ 246 | Remove a child 247 | 248 | You can pass in a Node, the name of a Node, or the index of the child 249 | """ 250 | child = None 251 | if isinstance(node_or_name_or_index, str): 252 | for i, node in enumerate(self._children): 253 | if node.name == node_or_name_or_index: 254 | if node.is_inherited: 255 | raise TreeMutationException( 256 | "Cannot remove inherited node %s" % node.name 257 | ) 258 | child = self._children.pop(i) 259 | break 260 | elif isinstance(node_or_name_or_index, int): 261 | child = self._children[node_or_name_or_index] 262 | if child.is_inherited: 263 | raise TreeMutationException( 264 | "Cannot remove inherited node %s" % child.name 265 | ) 266 | self._children.pop(node_or_name_or_index) 267 | else: 268 | child = node_or_name_or_index 269 | if child.is_inherited: 270 | raise TreeMutationException( 271 | "Cannot remove inherited node %s" % child.name 272 | ) 273 | self._children.remove(node_or_name_or_index) 274 | if child is not None: 275 | child._parent = None 276 | 277 | def __str__(self): 278 | return "Node(%s)" % self.name 279 | 280 | def __repr__(self): 281 | return str(self) 282 | 283 | 284 | class Tree(object): 285 | """Container for the scene tree""" 286 | 287 | def __init__(self, root: Optional[Node] = None): 288 | self.root = root 289 | 290 | def get_node(self, path: str) -> Optional[Node]: 291 | """Mimics the Godot get_node() behavior""" 292 | if self.root is None: 293 | return None 294 | return self.root.get_node(path) 295 | 296 | @classmethod 297 | def build(cls, file: GDFile): 298 | """Build the Tree from a flat list of [node]'s""" 299 | tree = cls() 300 | # Makes assumptions that the nodes are well-ordered 301 | for section in file.get_nodes(): 302 | if section.parent is None: 303 | root = Node.from_section(section) 304 | tree.root = root 305 | if root.instance is not None: 306 | _load_parent_scene(root, file) 307 | else: 308 | parent = tree.get_node(section.parent) 309 | if parent is None: 310 | raise TreeMutationException( 311 | "Cannot find parent node %s of %s" 312 | % (section.parent, section.name) 313 | ) 314 | parent._merge_child(section) 315 | return tree 316 | 317 | def flatten(self) -> List[GDNodeSection]: 318 | """Flatten the tree back into a list of GDNodeSection""" 319 | ret: List[GDNodeSection] = [] 320 | if self.root is None: 321 | return ret 322 | for node in self.root.flatten(): 323 | if node.is_inherited and not node.has_changes and node.parent is not None: 324 | continue 325 | ret.append(node.section) 326 | return ret 327 | 328 | 329 | def _load_parent_scene(root: Node, file: GDFile): 330 | parent_file: GDFile = file.load_parent_scene() 331 | parent_tree = Tree.build(parent_file) 332 | # Transfer parent scene's children to this scene 333 | for child in parent_tree.root.get_children(): 334 | root.add_child(child) 335 | # Mark the entire parent tree as inherited 336 | for node in parent_tree.root.flatten(): 337 | node._mark_inherited() 338 | # Mark the root node as inherited 339 | root._inherited_node = parent_tree.root 340 | -------------------------------------------------------------------------------- /godot_parser/util.py: -------------------------------------------------------------------------------- 1 | """Utils""" 2 | 3 | import json 4 | import os 5 | from typing import Optional 6 | 7 | 8 | def stringify_object(value): 9 | """Serialize a value to the godot file format""" 10 | if value is None: 11 | return "null" 12 | elif isinstance(value, str): 13 | return json.dumps(value) 14 | elif isinstance(value, bool): 15 | return "true" if value else "false" 16 | elif isinstance(value, dict): 17 | return ( 18 | "{\n" 19 | + ",\n".join( 20 | ['"%s": %s' % (k, stringify_object(v)) for k, v in value.items()] 21 | ) 22 | + "\n}" 23 | ) 24 | elif isinstance(value, list): 25 | return "[ " + ", ".join([stringify_object(v) for v in value]) + " ]" 26 | else: 27 | return str(value) 28 | 29 | 30 | def find_project_root(start: str) -> Optional[str]: 31 | curdir = start 32 | if os.path.isfile(start): 33 | curdir = os.path.dirname(start) 34 | while True: 35 | if os.path.isfile(os.path.join(curdir, "project.godot")): 36 | return curdir 37 | next_dir = os.path.realpath(os.path.join(curdir, os.pardir)) 38 | if next_dir == curdir: 39 | return None 40 | curdir = next_dir 41 | 42 | 43 | def gdpath_to_filepath(root: str, path: str) -> str: 44 | if not path.startswith("res://"): 45 | raise ValueError("'%s' is not a godot resource path" % path) 46 | pieces = path[6:].split("/") 47 | return os.path.join(root, *pieces) 48 | 49 | 50 | def filepath_to_gdpath(root: str, path: str) -> str: 51 | return "res://" + os.path.relpath(path, root).replace("\\", "/") 52 | 53 | 54 | def is_gd_path(path: str) -> bool: 55 | return path.startswith("res://") 56 | -------------------------------------------------------------------------------- /godot_parser/values.py: -------------------------------------------------------------------------------- 1 | """The grammar of low-level values in the GD file format""" 2 | 3 | from pyparsing import ( 4 | DelimitedList, 5 | Forward, 6 | Group, 7 | Keyword, 8 | Opt, 9 | QuotedString, 10 | Suppress, 11 | Word, 12 | alphanums, 13 | alphas, 14 | common, 15 | ) 16 | 17 | from .objects import GDObject 18 | 19 | boolean = ( 20 | (Keyword("true") | Keyword("false")) 21 | .set_name("bool") 22 | .set_parse_action(lambda x: x[0].lower() == "true") 23 | ) 24 | 25 | null = Keyword("null").set_parse_action(lambda _: [None]) 26 | 27 | 28 | primitive = ( 29 | null | QuotedString('"', escChar="\\", multiline=True) | boolean | common.number 30 | ) 31 | value = Forward() 32 | 33 | # Vector2( 1, 2 ) 34 | obj_type = ( 35 | Word(alphas, alphanums).set_results_name("object_name") 36 | + Suppress("(") 37 | + DelimitedList(value) 38 | + Suppress(")") 39 | ).set_parse_action(GDObject.from_parser) 40 | 41 | # [ 1, 2 ] or [ 1, 2, ] 42 | list_ = ( 43 | Group( 44 | Suppress("[") + Opt(DelimitedList(value)) + Opt(Suppress(",")) + Suppress("]") 45 | ) 46 | .set_name("list") 47 | .set_parse_action(lambda p: p.as_list()) 48 | ) 49 | key_val = Group(QuotedString('"', escChar="\\") + Suppress(":") + value) 50 | 51 | # { 52 | # "_edit_use_anchors_": false 53 | # } 54 | dict_ = ( 55 | (Suppress("{") + Opt(DelimitedList(key_val)) + Suppress("}")) 56 | .set_name("dict") 57 | .set_parse_action(lambda d: {k: v for k, v in d}) 58 | ) 59 | 60 | # Exports 61 | 62 | value <<= primitive | list_ | dict_ | obj_type 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | -r requirements_test.txt 3 | tox 4 | twine 5 | bumpversion 6 | build 7 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | black 3 | isort 4 | mypy 5 | pylint==3.3.5 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = godot_parser 3 | version = 0.1.7 4 | author = Steven Arcangeli 5 | author_email = stevearc@stevearc.com 6 | description = Python library for parsing Godot scene files 7 | long_description = file: README.md, CHANGES.md 8 | long_description_content_type = text/markdown 9 | license = MIT 10 | url = https://github.com/stevearc/godot_parser 11 | keywords = godot parse parser scene 12 | platforms = any 13 | classifiers = 14 | Programming Language :: Python 15 | Programming Language :: Python :: 3 16 | Programming Language :: Python :: 3.9 17 | Programming Language :: Python :: 3.10 18 | Programming Language :: Python :: 3.11 19 | Programming Language :: Python :: 3.12 20 | Programming Language :: Python :: 3.13 21 | Development Status :: 3 - Alpha 22 | Intended Audience :: Developers 23 | License :: OSI Approved :: MIT License 24 | 25 | [options] 26 | python_requires = >=3.6 27 | packages = find: 28 | install_requires = 29 | pyparsing>=3 30 | 31 | [options.package_data] 32 | godot_parser = 33 | py.typed 34 | 35 | [options.packages.find] 36 | exclude = tests 37 | 38 | [nosetests] 39 | match = ^test 40 | 41 | [wheel] 42 | 43 | [pycodestyle] 44 | ignore=E,W 45 | 46 | [mypy] 47 | ignore_missing_imports = True 48 | 49 | [isort] 50 | multi_line_output=3 51 | include_trailing_comma=True 52 | force_grid_wrap=0 53 | use_parentheses=True 54 | line_length=88 55 | ignore_whitespace=True 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /test_parse_files.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import os 4 | import sys 5 | from itertools import zip_longest 6 | 7 | from godot_parser import load, parse 8 | 9 | 10 | def _parse_and_test_file(filename: str) -> bool: 11 | print("Parsing %s" % filename) 12 | with open(filename, "r") as ifile: 13 | contents = ifile.read() 14 | try: 15 | data = parse(contents) 16 | except Exception: 17 | print(" Parsing error!") 18 | import traceback 19 | 20 | traceback.print_exc() 21 | return False 22 | 23 | f = load(filename) 24 | with f.use_tree() as tree: 25 | pass 26 | 27 | data_lines = [l for l in str(data).split("\n") if l] 28 | content_lines = [l for l in contents.split("\n") if l] 29 | if data_lines != content_lines: 30 | print(" Error!") 31 | max_len = max([len(l) for l in content_lines]) 32 | if max_len < 100: 33 | for orig, parsed in zip_longest(content_lines, data_lines, fillvalue=""): 34 | c = " " if orig == parsed else "x" 35 | print("%s <%s> %s" % (orig.ljust(max_len), c, parsed)) 36 | else: 37 | for orig, parsed in zip_longest( 38 | content_lines, data_lines, fillvalue="----EMPTY----" 39 | ): 40 | c = " " if orig == parsed else "XXX)" 41 | print("%s\n%s%s" % (orig, c, parsed)) 42 | return False 43 | return True 44 | 45 | 46 | def main(): 47 | """Test the parsing of one tscn file or all files in directory""" 48 | parser = argparse.ArgumentParser(description=main.__doc__) 49 | parser.add_argument("file_or_dir", help="Parse file or files under this directory") 50 | args = parser.parse_args() 51 | if os.path.isfile(args.file_or_dir): 52 | _parse_and_test_file(args.file_or_dir) 53 | else: 54 | for root, _dirs, files in os.walk(args.file_or_dir, topdown=False): 55 | for file in files: 56 | ext = os.path.splitext(file)[1] 57 | if ext not in [".tscn", ".tres"]: 58 | continue 59 | filepath = os.path.join(root, file) 60 | if not _parse_and_test_file(filepath): 61 | sys.exit(1) 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevearc/godot_parser/8b78569019cf76755ed680cba796182f0a5a71bd/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_gdfile.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import unittest 3 | 4 | from godot_parser import GDFile, GDObject, GDResource, GDResourceSection, GDScene, Node 5 | 6 | 7 | class TestGDFile(unittest.TestCase): 8 | """Tests for GDFile""" 9 | 10 | def test_basic_scene(self): 11 | """Run the parsing test cases""" 12 | self.assertEqual(str(GDScene()), "[gd_scene load_steps=1 format=2]\n") 13 | 14 | def test_all_data_types(self): 15 | """Run the parsing test cases""" 16 | res = GDResource() 17 | res.add_section( 18 | GDResourceSection( 19 | list=[1, 2.0, "string"], 20 | map={"key": ["nested", GDObject("Vector2", 1, 1)]}, 21 | empty=None, 22 | escaped='foo("bar")', 23 | ) 24 | ) 25 | self.assertEqual( 26 | str(res), 27 | """[gd_resource load_steps=1 format=2] 28 | 29 | [resource] 30 | list = [ 1, 2.0, "string" ] 31 | map = { 32 | "key": [ "nested", Vector2( 1, 1 ) ] 33 | } 34 | empty = null 35 | escaped = "foo(\\"bar\\")" 36 | """, 37 | ) 38 | 39 | def test_ext_resource(self): 40 | """Test serializing a scene with an ext_resource""" 41 | scene = GDScene() 42 | scene.add_ext_resource("res://Other.tscn", "PackedScene") 43 | self.assertEqual( 44 | str(scene), 45 | """[gd_scene load_steps=2 format=2] 46 | 47 | [ext_resource path="res://Other.tscn" type="PackedScene" id=1] 48 | """, 49 | ) 50 | 51 | def test_sub_resource(self): 52 | """Test serializing a scene with an sub_resource""" 53 | scene = GDScene() 54 | scene.add_sub_resource("Animation") 55 | self.assertEqual( 56 | str(scene), 57 | """[gd_scene load_steps=2 format=2] 58 | 59 | [sub_resource type="Animation" id=1] 60 | """, 61 | ) 62 | 63 | def test_node(self): 64 | """Test serializing a scene with a node""" 65 | scene = GDScene() 66 | scene.add_node("RootNode", type="Node2D") 67 | scene.add_node("Child", type="Area2D", parent=".") 68 | self.assertEqual( 69 | str(scene), 70 | """[gd_scene load_steps=1 format=2] 71 | 72 | [node name="RootNode" type="Node2D"] 73 | 74 | [node name="Child" type="Area2D" parent="."] 75 | """, 76 | ) 77 | 78 | def test_tree_create(self): 79 | """Test creating a scene with the tree API""" 80 | scene = GDScene() 81 | with scene.use_tree() as tree: 82 | tree.root = Node("RootNode", type="Node2D") 83 | tree.root.add_child( 84 | Node("Child", type="Area2D", properties={"visible": False}) 85 | ) 86 | self.assertEqual( 87 | str(scene), 88 | """[gd_scene load_steps=1 format=2] 89 | 90 | [node name="RootNode" type="Node2D"] 91 | 92 | [node name="Child" type="Area2D" parent="."] 93 | visible = false 94 | """, 95 | ) 96 | 97 | def test_tree_deep_create(self): 98 | """Test creating a scene with nested children using the tree API""" 99 | scene = GDScene() 100 | with scene.use_tree() as tree: 101 | tree.root = Node("RootNode", type="Node2D") 102 | child = Node("Child", type="Node") 103 | tree.root.add_child(child) 104 | child.add_child(Node("ChildChild", type="Node")) 105 | child.add_child(Node("ChildChild2", type="Node")) 106 | self.assertEqual( 107 | str(scene), 108 | """[gd_scene load_steps=1 format=2] 109 | 110 | [node name="RootNode" type="Node2D"] 111 | 112 | [node name="Child" type="Node" parent="."] 113 | 114 | [node name="ChildChild" type="Node" parent="Child"] 115 | 116 | [node name="ChildChild2" type="Node" parent="Child"] 117 | """, 118 | ) 119 | 120 | def test_remove_section(self): 121 | """Test GDScene.remove_section""" 122 | scene = GDFile() 123 | res = scene.add_ext_resource("res://Other.tscn", "PackedScene") 124 | result = scene.remove_section(GDResourceSection()) 125 | self.assertFalse(result) 126 | self.assertEqual(len(scene.get_sections()), 1) 127 | result = scene.remove_section(res) 128 | self.assertTrue(result) 129 | self.assertEqual(len(scene.get_sections()), 0) 130 | 131 | def test_section_ordering(self): 132 | """Sections maintain an ordering""" 133 | scene = GDScene() 134 | node = scene.add_node("RootNode") 135 | scene.add_ext_resource("res://Other.tscn", "PackedScene") 136 | res = scene.find_section("ext_resource") 137 | self.assertEqual(scene.get_sections()[1:], [res, node]) 138 | 139 | def test_add_ext_node(self): 140 | """Test GDScene.add_ext_node""" 141 | scene = GDScene() 142 | res = scene.add_ext_resource("res://Other.tscn", "PackedScene") 143 | node = scene.add_ext_node("Root", res.id) 144 | self.assertEqual(node.name, "Root") 145 | self.assertEqual(node.instance, res.id) 146 | 147 | def test_write(self): 148 | """Test writing scene out to a file""" 149 | scene = GDScene() 150 | outfile = tempfile.mkstemp()[1] 151 | scene.write(outfile) 152 | with open(outfile, "r", encoding="utf-8") as ifile: 153 | gen_scene = GDScene.parse(ifile.read()) 154 | self.assertEqual(scene, gen_scene) 155 | 156 | def test_get_node_none(self): 157 | """get_node() works with no nodes""" 158 | scene = GDScene() 159 | n = scene.get_node() 160 | self.assertIsNone(n) 161 | 162 | def test_addremove_ext_res(self): 163 | """Test adding and removing an ext_resource""" 164 | scene = GDScene() 165 | res = scene.add_ext_resource("res://Res.tscn", "PackedScene") 166 | self.assertEqual(res.id, 1) 167 | res2 = scene.add_ext_resource("res://Sprite.png", "Texture") 168 | self.assertEqual(res2.id, 2) 169 | node = scene.add_node("Sprite", "Sprite") 170 | node["texture"] = res2.reference 171 | node["textures"] = [res2.reference] 172 | node["texture_map"] = {"tex": res2.reference} 173 | node["texture_pool"] = GDObject("ResourcePool", res2.reference) 174 | 175 | s = scene.find_section(path="res://Res.tscn") 176 | scene.remove_section(s) 177 | scene.renumber_resource_ids() 178 | 179 | s = scene.find_section("ext_resource") 180 | self.assertEqual(s.id, 1) 181 | self.assertEqual(node["texture"], s.reference) 182 | self.assertEqual(node["textures"][0], s.reference) 183 | self.assertEqual(node["texture_map"]["tex"], s.reference) 184 | self.assertEqual(node["texture_pool"].args[0], s.reference) 185 | 186 | def test_remove_unused_resource(self): 187 | """Can remove unused resources""" 188 | scene = GDScene() 189 | res = scene.add_ext_resource("res://Res.tscn", "PackedScene") 190 | scene.remove_unused_resources() 191 | resources = scene.get_sections("ext_resource") 192 | self.assertEqual(len(resources), 0) 193 | 194 | def test_addremove_sub_res(self): 195 | """Test adding and removing a sub_resource""" 196 | scene = GDResource() 197 | res = scene.add_sub_resource("CircleShape2D") 198 | self.assertEqual(res.id, 1) 199 | res2 = scene.add_sub_resource("AnimationNodeAnimation") 200 | self.assertEqual(res2.id, 2) 201 | resource = GDResourceSection(shape=res2.reference) 202 | scene.add_section(resource) 203 | 204 | s = scene.find_sub_resource(type="CircleShape2D") 205 | scene.remove_section(s) 206 | scene.renumber_resource_ids() 207 | 208 | s = scene.find_section("sub_resource") 209 | self.assertEqual(s.id, 1) 210 | self.assertEqual(resource["shape"], s.reference) 211 | 212 | def test_find_constraints(self): 213 | """Test for the find_section constraints""" 214 | scene = GDScene() 215 | res1 = scene.add_sub_resource("CircleShape2D", radius=1) 216 | res2 = scene.add_sub_resource("CircleShape2D", radius=2) 217 | 218 | found = list(scene.find_all("sub_resource")) 219 | self.assertCountEqual(found, [res1, res2]) 220 | 221 | found = list(scene.find_all("sub_resource", id=res1.id)) 222 | self.assertEqual(found, [res1]) 223 | 224 | found = list(scene.find_all("sub_resource", {"radius": 2})) 225 | self.assertEqual(found, [res2]) 226 | 227 | def test_find_node(self): 228 | """Test GDScene.find_node""" 229 | scene = GDScene() 230 | n1 = scene.add_node("Root", "Node") 231 | n2 = scene.add_node("Child", "Node", parent=".") 232 | node = scene.find_node(name="Root") 233 | self.assertEqual(node, n1) 234 | node = scene.find_node(parent=".") 235 | self.assertEqual(node, n2) 236 | 237 | def test_file_equality(self): 238 | """Tests for GDFile == GDFile""" 239 | s1 = GDScene(GDResourceSection()) 240 | s2 = GDScene(GDResourceSection()) 241 | self.assertEqual(s1, s2) 242 | resource = s1.find_section("resource") 243 | resource["key"] = "value" 244 | self.assertNotEqual(s1, s2) 245 | -------------------------------------------------------------------------------- /tests/test_objects.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from godot_parser import Color, ExtResource, NodePath, SubResource, Vector2, Vector3 4 | 5 | 6 | class TestGDObjects(unittest.TestCase): 7 | """Tests for GD object wrappers""" 8 | 9 | def test_vector2(self): 10 | """Test for Vector2""" 11 | v = Vector2(1, 2) 12 | self.assertEqual(v[0], 1) 13 | self.assertEqual(v[1], 2) 14 | self.assertEqual(v.x, 1) 15 | self.assertEqual(v.y, 2) 16 | self.assertEqual(str(v), "Vector2( 1, 2 )") 17 | v.x = 2 18 | v.y = 3 19 | self.assertEqual(v.x, 2) 20 | self.assertEqual(v.y, 3) 21 | v[0] = 3 22 | v[1] = 4 23 | self.assertEqual(v[0], 3) 24 | self.assertEqual(v[1], 4) 25 | 26 | def test_vector3(self): 27 | """Test for Vector3""" 28 | v = Vector3(1, 2, 3) 29 | self.assertEqual(v[0], 1) 30 | self.assertEqual(v[1], 2) 31 | self.assertEqual(v[2], 3) 32 | self.assertEqual(v.x, 1) 33 | self.assertEqual(v.y, 2) 34 | self.assertEqual(v.z, 3) 35 | self.assertEqual(str(v), "Vector3( 1, 2, 3 )") 36 | v.x = 2 37 | v.y = 3 38 | v.z = 4 39 | self.assertEqual(v.x, 2) 40 | self.assertEqual(v.y, 3) 41 | self.assertEqual(v.z, 4) 42 | v[0] = 3 43 | v[1] = 4 44 | v[2] = 5 45 | self.assertEqual(v[0], 3) 46 | self.assertEqual(v[1], 4) 47 | self.assertEqual(v[2], 5) 48 | 49 | def test_color(self): 50 | """Test for Color""" 51 | c = Color(0.1, 0.2, 0.3, 0.4) 52 | self.assertEqual(c[0], 0.1) 53 | self.assertEqual(c[1], 0.2) 54 | self.assertEqual(c[2], 0.3) 55 | self.assertEqual(c[3], 0.4) 56 | self.assertEqual(c.r, 0.1) 57 | self.assertEqual(c.g, 0.2) 58 | self.assertEqual(c.b, 0.3) 59 | self.assertEqual(c.a, 0.4) 60 | self.assertEqual(str(c), "Color( 0.1, 0.2, 0.3, 0.4 )") 61 | c.r = 0.2 62 | c.g = 0.3 63 | c.b = 0.4 64 | c.a = 0.5 65 | self.assertEqual(c.r, 0.2) 66 | self.assertEqual(c.g, 0.3) 67 | self.assertEqual(c.b, 0.4) 68 | self.assertEqual(c.a, 0.5) 69 | c[0] = 0.3 70 | c[1] = 0.4 71 | c[2] = 0.5 72 | c[3] = 0.6 73 | self.assertEqual(c[0], 0.3) 74 | self.assertEqual(c[1], 0.4) 75 | self.assertEqual(c[2], 0.5) 76 | self.assertEqual(c[3], 0.6) 77 | 78 | def test_node_path(self): 79 | """Test for NodePath""" 80 | n = NodePath("../Sibling") 81 | self.assertEqual(n.path, "../Sibling") 82 | n.path = "../Other" 83 | self.assertEqual(n.path, "../Other") 84 | self.assertEqual(str(n), 'NodePath("../Other")') 85 | 86 | def test_ext_resource(self): 87 | """Test for ExtResource""" 88 | r = ExtResource(1) 89 | self.assertEqual(r.id, 1) 90 | r.id = 2 91 | self.assertEqual(r.id, 2) 92 | self.assertEqual(str(r), "ExtResource( 2 )") 93 | 94 | def test_sub_resource(self): 95 | """Test for SubResource""" 96 | r = SubResource(1) 97 | self.assertEqual(r.id, 1) 98 | r.id = 2 99 | self.assertEqual(r.id, 2) 100 | self.assertEqual(str(r), "SubResource( 2 )") 101 | 102 | def test_dunder(self): 103 | """Test the __magic__ methods on GDObject""" 104 | v = Vector2(1, 2) 105 | self.assertEqual(repr(v), "Vector2( 1, 2 )") 106 | v2 = Vector2(1, 2) 107 | self.assertEqual(v, v2) 108 | v2.x = 10 109 | self.assertNotEqual(v, v2) 110 | self.assertNotEqual(v, (1, 2)) 111 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from pyparsing import ParseException 5 | 6 | from godot_parser import GDFile, GDObject, GDSection, GDSectionHeader, Vector2, parse 7 | 8 | HERE = os.path.dirname(__file__) 9 | 10 | TEST_CASES = [ 11 | ( 12 | "[gd_scene load_steps=5 format=2]", 13 | GDFile(GDSection(GDSectionHeader("gd_scene", load_steps=5, format=2))), 14 | ), 15 | ( 16 | "[gd_resource load_steps=5 format=2]", 17 | GDFile(GDSection(GDSectionHeader("gd_resource", load_steps=5, format=2))), 18 | ), 19 | ( 20 | '[ext_resource path="res://Sample.tscn" type="PackedScene" id=1]', 21 | GDFile( 22 | GDSection( 23 | GDSectionHeader( 24 | "ext_resource", path="res://Sample.tscn", type="PackedScene", id=1 25 | ) 26 | ) 27 | ), 28 | ), 29 | ( 30 | """[gd_scene load_steps=5 format=2] 31 | [ext_resource path="res://Sample.tscn" type="PackedScene" id=1]""", 32 | GDFile( 33 | GDSection(GDSectionHeader("gd_scene", load_steps=5, format=2)), 34 | GDSection( 35 | GDSectionHeader( 36 | "ext_resource", path="res://Sample.tscn", type="PackedScene", id=1 37 | ) 38 | ), 39 | ), 40 | ), 41 | ( 42 | """[sub_resource type="RectangleShape2D" id=1] 43 | extents = Vector2( 12.7855, 17.0634 ) 44 | other = null 45 | "with spaces" = 1 46 | """, 47 | GDFile( 48 | GDSection( 49 | GDSectionHeader("sub_resource", type="RectangleShape2D", id=1), 50 | extents=Vector2(12.7855, 17.0634), 51 | other=None, 52 | **{"with spaces": 1} 53 | ) 54 | ), 55 | ), 56 | ( 57 | """[sub_resource type="Animation" id=2] 58 | tracks/0/keys = { 59 | "transitions": PoolRealArray( 1, 1 ), 60 | "update": 0, 61 | "values": [ Vector2( 0, 0 ), Vector2( 1, 0 ) ] 62 | }""", 63 | GDFile( 64 | GDSection( 65 | GDSectionHeader("sub_resource", type="Animation", id=2), 66 | **{ 67 | "tracks/0/keys": { 68 | "transitions": GDObject("PoolRealArray", 1, 1), 69 | "update": 0, 70 | "values": [Vector2(0, 0), Vector2(1, 0)], 71 | } 72 | } 73 | ) 74 | ), 75 | ), 76 | ( 77 | """[resource] 78 | 0/name = "Sand" 79 | """, 80 | GDFile(GDSection(GDSectionHeader("resource"), **{"0/name": "Sand"})), 81 | ), 82 | ( 83 | """[node name="Label" parent="." groups=["foo", "bar"]] 84 | text = "Hello 85 | " 86 | """, 87 | GDFile( 88 | GDSection( 89 | GDSectionHeader( 90 | "node", name="Label", parent=".", groups=["foo", "bar"] 91 | ), 92 | text="Hello\n ", 93 | ) 94 | ), 95 | ), 96 | ( 97 | """[sub_resource type="TileSetAtlasSource" id=2] 98 | 0:0/0 = 0 99 | 0:0/0/physics_layer_0/linear_velocity = Vector2(0, 0) 100 | 0:0/0/physics_layer_0/angular_velocity = 0.0 101 | """, 102 | GDFile( 103 | GDSection( 104 | GDSectionHeader("sub_resource", type="TileSetAtlasSource", id=2), 105 | **{ 106 | "0:0/0": 0, 107 | "0:0/0/physics_layer_0/linear_velocity": Vector2(0, 0), 108 | "0:0/0/physics_layer_0/angular_velocity": 0.0, 109 | } 110 | ) 111 | ), 112 | ), 113 | ] 114 | 115 | 116 | class TestParser(unittest.TestCase): 117 | """ """ 118 | 119 | def _run_test(self, string: str, expected): 120 | """Run a set of tests""" 121 | try: 122 | parse_result = parse(string) 123 | if expected == "error": 124 | assert False, "Parsing '%s' should have failed.\nGot: %s" % ( 125 | string, 126 | parse_result, 127 | ) 128 | else: 129 | self.assertEqual(parse_result, expected) 130 | except ParseException as e: 131 | if expected != "error": 132 | print(string) 133 | print(" " * e.loc + "^") 134 | print(str(e)) 135 | raise 136 | except AssertionError: 137 | print("Parsing : %s" % string) 138 | print("Expected: %r" % expected) 139 | print("Got : %r" % parse_result) 140 | raise 141 | 142 | def test_cases(self): 143 | """Run the parsing test cases""" 144 | for string, expected in TEST_CASES: 145 | self._run_test(string, expected) 146 | -------------------------------------------------------------------------------- /tests/test_sections.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from godot_parser import ( 4 | GDExtResourceSection, 5 | GDNodeSection, 6 | GDSection, 7 | GDSectionHeader, 8 | GDSubResourceSection, 9 | ) 10 | 11 | 12 | class TestGDSections(unittest.TestCase): 13 | """Tests for GD file sections""" 14 | 15 | def test_header_dunder(self): 16 | """Tests for __magic__ methods on GDSectionHeader""" 17 | h = GDSectionHeader("node") 18 | self.assertEqual(str(h), "[node]") 19 | self.assertEqual(repr(h), "GDSectionHeader([node])") 20 | h2 = GDSectionHeader("node") 21 | self.assertEqual(h, h2) 22 | h2["type"] = "Node2D" 23 | self.assertNotEqual(h, h2) 24 | self.assertNotEqual(h, "[node]") 25 | 26 | def test_section_dunder(self): 27 | """Tests for __magic__ methods on GDSection""" 28 | h = GDSectionHeader("node") 29 | s = GDSection(h, vframes=10) 30 | self.assertEqual(str(s), "[node]\nvframes = 10") 31 | self.assertEqual(repr(s), "GDSection([node]\nvframes = 10)") 32 | self.assertEqual(s["vframes"], 10) 33 | 34 | s2 = GDSection(GDSectionHeader("node"), vframes=10) 35 | self.assertEqual(s, s2) 36 | s2["vframes"] = 100 37 | self.assertNotEqual(s, s2) 38 | self.assertNotEqual(s, "[node]\nvframes = 10") 39 | 40 | del s["vframes"] 41 | self.assertEqual(s.get("vframes"), None) 42 | del s["vframes"] 43 | 44 | def test_ext_resource(self): 45 | """Test for GDExtResourceSection""" 46 | s = GDExtResourceSection("res://Other.tscn", type="PackedScene", id=1) 47 | self.assertEqual(s.path, "res://Other.tscn") 48 | self.assertEqual(s.type, "PackedScene") 49 | self.assertEqual(s.id, 1) 50 | s.path = "res://New.tscn" 51 | self.assertEqual(s.path, "res://New.tscn") 52 | s.type = "Texture" 53 | self.assertEqual(s.type, "Texture") 54 | s.id = 2 55 | self.assertEqual(s.id, 2) 56 | 57 | def test_sub_resource(self): 58 | """Test for GDSubResourceSection""" 59 | s = GDSubResourceSection(type="CircleShape2D", id=1) 60 | self.assertEqual(s.type, "CircleShape2D") 61 | self.assertEqual(s.id, 1) 62 | s.type = "Animation" 63 | self.assertEqual(s.type, "Animation") 64 | s.id = 2 65 | self.assertEqual(s.id, 2) 66 | 67 | def test_node(self): 68 | """Test for GDNodeSection""" 69 | s = GDNodeSection("Sprite", type="Sprite", groups=["foo", "bar"]) 70 | self.assertIsNone(s.instance) 71 | self.assertIsNone(s.index) 72 | self.assertEqual(s.type, "Sprite") 73 | self.assertEqual(s.groups, ["foo", "bar"]) 74 | 75 | # Setting the instance removes the type 76 | s.instance = 1 77 | self.assertEqual(s.instance, 1) 78 | self.assertEqual(s.type, None) 79 | 80 | # Setting the type removes the instance 81 | s.type = "Sprite" 82 | self.assertEqual(s.type, "Sprite") 83 | self.assertIsNone(s.instance) 84 | 85 | s.index = 3 86 | self.assertEqual(s.index, 3) 87 | s.index = None 88 | self.assertEqual(s.index, None) 89 | 90 | # Setting groups 91 | s.groups = ["baz"] 92 | self.assertEqual(s.groups, ["baz"]) 93 | -------------------------------------------------------------------------------- /tests/test_tree.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | import unittest 5 | 6 | from godot_parser import GDScene, Node, SubResource, TreeMutationException 7 | from godot_parser.util import find_project_root, gdpath_to_filepath 8 | 9 | 10 | class TestTree(unittest.TestCase): 11 | """Test the the high-level tree API""" 12 | 13 | def test_get_node(self): 14 | """Test for get_node()""" 15 | scene = GDScene() 16 | scene.add_node("RootNode") 17 | scene.add_node("Child", parent=".") 18 | child = scene.add_node("Child2", parent="Child") 19 | node = scene.get_node("Child/Child2") 20 | self.assertEqual(node, child) 21 | 22 | def test_remove_node(self): 23 | """Test for remove_node()""" 24 | scene = GDScene() 25 | scene.add_node("RootNode") 26 | scene.add_node("Child", parent=".") 27 | node = scene.find_section("node", name="Child") 28 | self.assertIsNotNone(node) 29 | 30 | # Remove by name 31 | with scene.use_tree() as tree: 32 | tree.root.remove_child("Child") 33 | node = scene.find_section("node", name="Child") 34 | self.assertIsNone(node) 35 | 36 | # Remove by index 37 | scene.add_node("Child", parent=".") 38 | with scene.use_tree() as tree: 39 | tree.root.remove_child(0) 40 | node = scene.find_section("node", name="Child") 41 | self.assertIsNone(node) 42 | 43 | # Remove by reference 44 | scene.add_node("Child", parent=".") 45 | with scene.use_tree() as tree: 46 | node = tree.root.get_children()[0] 47 | tree.root.remove_child(node) 48 | node = scene.find_section("node", name="Child") 49 | self.assertIsNone(node) 50 | 51 | # Remove child 52 | scene.add_node("Child", parent=".") 53 | with scene.use_tree() as tree: 54 | node = tree.root.get_child(0) 55 | node.remove_from_parent() 56 | node = scene.find_section("node", name="Child") 57 | self.assertIsNone(node) 58 | 59 | def test_insert_child(self): 60 | """Test for insert_child()""" 61 | scene = GDScene() 62 | scene.add_node("RootNode") 63 | scene.add_node("Child1", parent=".") 64 | with scene.use_tree() as tree: 65 | child = Node("Child2", type="Node") 66 | tree.root.insert_child(0, child) 67 | child1 = scene.find_section("node", name="Child1") 68 | child2 = scene.find_section("node", name="Child2") 69 | idx1 = scene.get_sections().index(child1) 70 | idx2 = scene.get_sections().index(child2) 71 | self.assertLess(idx2, idx1) 72 | 73 | def test_empty_scene(self): 74 | """Empty scenes should not crash""" 75 | scene = GDScene() 76 | with scene.use_tree() as tree: 77 | n = tree.get_node("Any") 78 | self.assertIsNone(n) 79 | 80 | def test_get_missing_node(self): 81 | """get_node on missing node should return None""" 82 | scene = GDScene() 83 | scene.add_node("RootNode") 84 | node = scene.get_node("Foo/Bar/Baz") 85 | self.assertIsNone(node) 86 | 87 | def test_properties(self): 88 | """Test for changing properties on a node""" 89 | scene = GDScene() 90 | scene.add_node("RootNode") 91 | with scene.use_tree() as tree: 92 | tree.root["vframes"] = 10 93 | self.assertEqual(tree.root["vframes"], 10) 94 | tree.root["hframes"] = 10 95 | del tree.root["hframes"] 96 | del tree.root["hframes"] 97 | self.assertIsNone(tree.root.get("hframes")) 98 | child = scene.find_section("node") 99 | self.assertEqual(child["vframes"], 10) 100 | 101 | def test_dunder(self): 102 | """Test __magic__ methods on Node""" 103 | n = Node("Player") 104 | self.assertEqual(str(n), "Node(Player)") 105 | self.assertEqual(repr(n), "Node(Player)") 106 | 107 | 108 | class TestInheritedScenes(unittest.TestCase): 109 | """Test the the high-level tree API for inherited scenes""" 110 | 111 | project_dir = None 112 | root_scene = None 113 | mid_scene = None 114 | leaf_scene = None 115 | 116 | @classmethod 117 | def setUpClass(cls): 118 | super(TestInheritedScenes, cls).setUpClass() 119 | cls.project_dir = tempfile.mkdtemp() 120 | with open( 121 | os.path.join(cls.project_dir, "project.godot"), "w", encoding="utf-8" 122 | ) as ofile: 123 | ofile.write("fake project") 124 | cls.root_scene = os.path.join(cls.project_dir, "Root.tscn") 125 | cls.mid_scene = os.path.join(cls.project_dir, "Mid.tscn") 126 | cls.leaf_scene = os.path.join(cls.project_dir, "Leaf.tscn") 127 | scene = GDScene.parse( 128 | """ 129 | [gd_scene load_steps=1 format=2] 130 | [node name="Root" type="KinematicBody2D"] 131 | collision_layer = 3 132 | [node name="CollisionShape2D" type="CollisionShape2D" parent="."] 133 | disabled = true 134 | [node name="Sprite" type="Sprite" parent="."] 135 | flip_h = false 136 | [node name="Health" type="Control" parent="."] 137 | [node name="LifeBar" type="TextureProgress" parent="Health"] 138 | """ 139 | ) 140 | scene.write(cls.root_scene) 141 | 142 | scene = GDScene.parse( 143 | """ 144 | [gd_scene load_steps=2 format=2] 145 | [ext_resource path="res://Root.tscn" type="PackedScene" id=1] 146 | [node name="Mid" instance=ExtResource( 1 )] 147 | collision_layer = 4 148 | [node name="Health" parent="." index="2"] 149 | pause_mode = 2 150 | """ 151 | ) 152 | scene.write(cls.mid_scene) 153 | 154 | scene = GDScene.parse( 155 | """ 156 | [gd_scene load_steps=2 format=2] 157 | [ext_resource path="res://Mid.tscn" type="PackedScene" id=1] 158 | [sub_resource type="CircleShape2D" id=1] 159 | [node name="Leaf" instance=ExtResource( 1 )] 160 | shape = SubResource( 1 ) 161 | [node name="Sprite" type="Sprite" parent="." index="1"] 162 | flip_h = true 163 | """ 164 | ) 165 | scene.write(cls.leaf_scene) 166 | 167 | @classmethod 168 | def tearDownClass(cls): 169 | super(TestInheritedScenes, cls).tearDownClass() 170 | if os.path.isdir(cls.project_dir): 171 | shutil.rmtree(cls.project_dir) 172 | 173 | def test_load_inherited(self): 174 | """Can load an inherited scene and read the nodes""" 175 | scene = GDScene.load(self.leaf_scene) 176 | with scene.use_tree() as tree: 177 | node = tree.get_node("Health/LifeBar") 178 | self.assertIsNotNone(node) 179 | self.assertEqual(node.type, "TextureProgress") 180 | 181 | def test_add_new_nodes(self): 182 | """Can add new nodes to an inherited scene""" 183 | scene = GDScene.load(self.leaf_scene) 184 | with scene.use_tree() as tree: 185 | tree.get_node("Health/LifeBar") 186 | node = Node("NewChild", type="Control") 187 | tree.root.add_child(node) 188 | # Non-inherited node can change name, type, instance 189 | node.instance = 2 190 | node.type = "Node2D" 191 | node.name = "NewChild2" 192 | found = scene.find_section("node", name="NewChild2") 193 | self.assertIsNotNone(found) 194 | self.assertEqual(found.type, "Node2D") 195 | self.assertEqual(found.parent, ".") 196 | self.assertEqual(found.index, 3) 197 | 198 | def test_cannot_remove(self): 199 | """Cannot remove inherited nodes""" 200 | scene = GDScene.load(self.leaf_scene) 201 | with scene.use_tree() as tree: 202 | node = tree.get_node("Health") 203 | self.assertRaises(TreeMutationException, node.remove_from_parent) 204 | self.assertRaises(TreeMutationException, lambda: tree.root.remove_child(0)) 205 | self.assertRaises( 206 | TreeMutationException, lambda: tree.root.remove_child("Health") 207 | ) 208 | 209 | def test_cannot_mutate(self): 210 | """Cannot change the name/type/instance of inherited nodes""" 211 | scene = GDScene.load(self.leaf_scene) 212 | 213 | def change_name(x): 214 | x.name = "foo" 215 | 216 | def change_type(x): 217 | x.type = "foo" 218 | 219 | def change_instance(x): 220 | x.instance = 2 221 | 222 | with scene.use_tree() as tree: 223 | node = tree.get_node("Health") 224 | self.assertRaises(TreeMutationException, lambda: change_name(node)) 225 | self.assertRaises(TreeMutationException, lambda: change_type(node)) 226 | self.assertRaises(TreeMutationException, lambda: change_instance(node)) 227 | 228 | def test_inherit_properties(self): 229 | """Inherited nodes inherit properties""" 230 | scene = GDScene.load(self.leaf_scene) 231 | with scene.use_tree() as tree: 232 | self.assertEqual(tree.root["shape"], SubResource(1)) 233 | self.assertEqual(tree.root["collision_layer"], 4) 234 | self.assertEqual(tree.root.get("collision_layer"), 4) 235 | self.assertEqual(tree.root.get("missing"), None) 236 | self.assertRaises(KeyError, lambda: tree.root["missing"]) 237 | 238 | def test_unchanged_sections(self): 239 | """Inherited nodes do not appear in sections""" 240 | scene = GDScene.load(self.leaf_scene) 241 | num_nodes = len(scene.get_nodes()) 242 | self.assertEqual(num_nodes, 2) 243 | with scene.use_tree() as tree: 244 | sprite = tree.get_node("Sprite") 245 | sprite["flip_v"] = True 246 | # No new nodes 247 | num_nodes = len(scene.get_nodes()) 248 | self.assertEqual(num_nodes, 2) 249 | 250 | def test_overwrite_sections(self): 251 | """Inherited nodes appear in sections if we change their configuration""" 252 | scene = GDScene.load(self.leaf_scene) 253 | with scene.use_tree() as tree: 254 | node = tree.get_node("Health/LifeBar") 255 | node["pause_mode"] = 2 256 | num_nodes = len(scene.get_nodes()) 257 | self.assertEqual(num_nodes, 3) 258 | node = scene.find_section("node", name="LifeBar", parent="Health") 259 | self.assertIsNotNone(node) 260 | 261 | def test_disappear_sections(self): 262 | """Inherited nodes are removed from sections if we change their configuration to match parent""" 263 | scene = GDScene.load(self.leaf_scene) 264 | with scene.use_tree() as tree: 265 | sprite = tree.get_node("Sprite") 266 | sprite["flip_h"] = False 267 | # Sprite should match parent now, and not be in file 268 | node = scene.find_section("node", name="Sprite") 269 | self.assertIsNone(node) 270 | 271 | def test_find_project_root(self): 272 | """Can find project root even if deep in folder""" 273 | os.mkdir(os.path.join(self.project_dir, "Dir1")) 274 | nested = os.path.join(self.project_dir, "Dir1", "Dir2") 275 | os.mkdir(nested) 276 | root = find_project_root(nested) 277 | self.assertEqual(root, self.project_dir) 278 | 279 | def test_invalid_tree(self): 280 | """Raise exception when tree is invalid""" 281 | scene = GDScene() 282 | scene.add_node("RootNode") 283 | scene.add_node("Child", parent="Missing") 284 | self.assertRaises(TreeMutationException, lambda: scene.get_node("Child")) 285 | 286 | def test_missing_root(self): 287 | """Raise exception when GDScene is inherited but missing project_root""" 288 | scene = GDScene() 289 | scene.add_ext_node("Root", 1) 290 | self.assertRaises(RuntimeError, lambda: scene.get_node("Root")) 291 | 292 | def test_missing_ext_resource(self): 293 | """Raise exception when GDScene is inherited but ext_resource is missing""" 294 | scene = GDScene.load(self.leaf_scene) 295 | for section in scene.get_ext_resources(): 296 | scene.remove_section(section) 297 | self.assertRaises(RuntimeError, lambda: scene.get_node("Root")) 298 | 299 | 300 | class TestUtil(unittest.TestCase): 301 | """Tests for util""" 302 | 303 | def test_bad_gdpath(self): 304 | """Raise exception on bad gdpath""" 305 | self.assertRaises(ValueError, lambda: gdpath_to_filepath("/", "foobar")) 306 | 307 | def test_no_project(self): 308 | """If no Godot project is found, return None""" 309 | root = find_project_root(tempfile.gettempdir()) 310 | self.assertIsNone(root) 311 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39, py310, py311, py312, py313, lint 3 | isolated_build = true 4 | 5 | [testenv] 6 | deps = -rrequirements_test.txt 7 | commands = 8 | coverage run --source=godot_parser --branch -m unittest 9 | 10 | [testenv:lint] 11 | ignore_errors = true 12 | commands = 13 | black --check godot_parser tests test_parse_files.py 14 | isort -c godot_parser tests test_parse_files.py 15 | mypy godot_parser tests 16 | pylint --rcfile=.pylintrc godot_parser tests 17 | 18 | [testenv:format] 19 | commands = 20 | isort --atomic godot_parser tests test_parse_files.py 21 | black godot_parser tests test_parse_files.py 22 | 23 | [testenv:coveralls] 24 | deps = 25 | wheel 26 | coveralls 27 | passenv = 28 | GITHUB_ACTIONS 29 | GITHUB_TOKEN 30 | GITHUB_REF 31 | GITHUB_HEAD_REF 32 | commands = 33 | coveralls --service=github 34 | 35 | [gh-actions] 36 | python = 37 | 3.9: py39 38 | 3.10: py310 39 | 3.11: py311 40 | 3.12: py312, lint 41 | 3.13: py313 42 | --------------------------------------------------------------------------------