poly-hammer-utils 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.1
2
+ Name: poly-hammer-utils
3
+ Version: 0.0.1
4
+ Summary:
5
+ Author: Poly Hammer
6
+ Author-email: info@polyhammer.com
7
+ Requires-Python: >=3.11,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Dist: requirements-parser (>=0.11.0,<0.12.0)
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Poly Hammer Utils
15
+
16
+ [![Publish](https://github.com/poly-hammer/poly-hammer-utils/actions/workflows/publish.yaml/badge.svg)](https://github.com/poly-hammer/poly-hammer-utils/actions/workflows/publish.yaml)
17
+
18
+
19
+ A general purpose package that holds our dev utilities.
@@ -0,0 +1,6 @@
1
+ # Poly Hammer Utils
2
+
3
+ [![Publish](https://github.com/poly-hammer/poly-hammer-utils/actions/workflows/publish.yaml/badge.svg)](https://github.com/poly-hammer/poly-hammer-utils/actions/workflows/publish.yaml)
4
+
5
+
6
+ A general purpose package that holds our dev utilities.
@@ -0,0 +1,16 @@
1
+ [tool.poetry]
2
+ name = "poly-hammer-utils"
3
+ version = "0.0.1"
4
+ description = ""
5
+ authors = ["Poly Hammer <info@polyhammer.com>"]
6
+ readme = "README.md"
7
+ packages = [{include = "poly_hammer_utils", from = "src"}]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.11"
11
+ requirements-parser = "^0.11.0"
12
+
13
+
14
+ [build-system]
15
+ requires = ["poetry-core"]
16
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,229 @@
1
+ import os
2
+ import ast
3
+ import sys
4
+ import shutil
5
+ import logging
6
+ import subprocess
7
+ import requirements
8
+ from pathlib import Path
9
+
10
+ try:
11
+ import bpy
12
+ import addon_utils
13
+ except ImportError:
14
+ pass
15
+
16
+ REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent
17
+
18
+
19
+ IGNORE_PATTERNS = [
20
+ "__pycache__",
21
+ "*.pyc"
22
+ ]
23
+
24
+
25
+ class AddonPackager:
26
+
27
+ def __init__(self, addon_name, addon_folder_path, output_folder):
28
+ """
29
+ Initializes the class and sets the addon module name.
30
+ """
31
+ self.addon_name = addon_name
32
+ self.addon_folder_path = addon_folder_path
33
+ self.output_folder = output_folder
34
+
35
+ @staticmethod
36
+ def get_dict_from_python_file(python_file, dict_name):
37
+ """
38
+ Gets the first dictionary from the given file that matches the given variable name.
39
+
40
+ :param object python_file: A file object to read from.
41
+ :param str dict_name: The variable name of a dictionary.
42
+ :return dict: The value of the dictionary.
43
+ """
44
+ dictionary = {}
45
+ tree = ast.parse(python_file.read())
46
+
47
+ for item in tree.body:
48
+ if hasattr(item, 'targets'):
49
+ for target in item.targets:
50
+ if getattr(target, 'id', None) == dict_name:
51
+ for index, key in enumerate(item.value.keys):
52
+ # add string as dictionary value
53
+ if hasattr(item.value.values[index], 's'):
54
+ dictionary[key.s] = item.value.values[index].s
55
+
56
+ # add number as dictionary value
57
+ elif hasattr(item.value.values[index], 'n'):
58
+ dictionary[key.s] = item.value.values[index].n
59
+
60
+ # add list as dictionary value
61
+ elif hasattr(item.value.values[index], 'elts'):
62
+ list_value = []
63
+ for element in item.value.values[index].elts:
64
+ # add a number to the list
65
+ if hasattr(element, 'n'):
66
+ list_value.append(element.n)
67
+
68
+ # add a string to the list
69
+ elif hasattr(element, 's'):
70
+ list_value.append(element.s)
71
+
72
+ dictionary[key.s] = list_value
73
+ break
74
+ return dictionary
75
+
76
+ def get_addon_version_number(self, addon_folder_path):
77
+ """
78
+ Gets the version number from the addons bl_info
79
+
80
+ :param str addon_folder_path: The path to the addon folder.
81
+ :return str: The version of the addon.
82
+ """
83
+ addon_init = open(os.path.join(addon_folder_path, '__init__.py'))
84
+
85
+ bl_info = self.get_dict_from_python_file(addon_init, 'bl_info')
86
+ version_numbers = [str(number) for number in bl_info['version']]
87
+ version_number = '.'.join(version_numbers)
88
+ return version_number
89
+
90
+ def get_addon_zip_path(self):
91
+ """
92
+ Gets the path to the addons zip file.
93
+
94
+ :return str: The full path to the released zip file.
95
+ """
96
+ # get the versioned addon paths
97
+ version_number = self.get_addon_version_number(self.addon_folder_path)
98
+ addon_zip_file = f'{self.addon_name}_{version_number}.zip'
99
+ output_folder_path = os.path.join(self.output_folder, addon_zip_file)
100
+ if not os.path.exists(os.path.dirname(output_folder_path)):
101
+ os.makedirs(os.path.dirname(output_folder_path))
102
+
103
+ return output_folder_path
104
+
105
+ @staticmethod
106
+ def set_folder_contents_permissions(folder_path, permission_level):
107
+ """
108
+ Goes through all files and folders contained in the folder and modifies their permissions to
109
+ the given permissions.
110
+
111
+ :param str folder_path: The full path to the folder you would like to modify permissions on.
112
+ :param octal permission_level: The octal permissions value.
113
+ """
114
+ for root, directories, files in os.walk(folder_path):
115
+ for directory in directories:
116
+ os.chmod(os.path.join(root, directory), permission_level)
117
+ for file in files:
118
+ os.chmod(os.path.join(root, file), permission_level)
119
+
120
+ def copy_addon(self):
121
+ """
122
+ Copy the addon.
123
+ """
124
+ destination = os.path.join(self.output_folder, self.addon_name)
125
+
126
+ logging.debug(f'Copying addon "{self.addon_folder_path}" to "{destination}"')
127
+
128
+ # change the permissions to allow the folders contents to be modified.
129
+ if sys.platform == 'win32':
130
+ self.set_folder_contents_permissions(os.path.join(self.addon_folder_path, os.pardir), 0o777)
131
+
132
+ shutil.rmtree(destination, ignore_errors=True)
133
+ shutil.copytree(self.addon_folder_path, destination, ignore=shutil.ignore_patterns('*pyc'))
134
+
135
+ # change the permissions to allow the folders contents to be modified.
136
+ if sys.platform == 'win32':
137
+ self.set_folder_contents_permissions(os.path.join(self.output_folder, os.pardir), 0o777)
138
+
139
+ def zip_addon(self):
140
+ """
141
+ Zips up the addon.
142
+ """
143
+ logging.debug(f'zipping addon "{self.addon_name}" to "{self.output_folder}"')
144
+ # get the folder paths
145
+ versioned_zip_file_path = self.get_addon_zip_path()
146
+ versioned_folder_path = versioned_zip_file_path.replace('.zip', '')
147
+
148
+ # change the permissions to allow the folders contents to be modified.
149
+ if sys.platform == 'win32':
150
+ self.set_folder_contents_permissions(os.path.join(self.addon_folder_path, os.pardir), 0o777)
151
+
152
+ # remove the existing zip archive
153
+ if os.path.exists(versioned_zip_file_path):
154
+ try:
155
+ os.remove(versioned_zip_file_path)
156
+ except PermissionError:
157
+ logging.warning(f'Could not delete {versioned_folder_path}!')
158
+
159
+ # copy the addon module in to the versioned directory with its addon module name as a sub directory
160
+ shutil.copytree(
161
+ self.addon_folder_path,
162
+ os.path.join(versioned_folder_path, self.addon_name),
163
+ ignore=shutil.ignore_patterns(*IGNORE_PATTERNS)
164
+ )
165
+
166
+ # make a zip archive of the copied folder
167
+ shutil.make_archive(versioned_folder_path, 'zip', versioned_folder_path)
168
+
169
+ # remove the copied directory
170
+ shutil.rmtree(versioned_folder_path)
171
+
172
+ # return the full path to the zip file
173
+ return versioned_zip_file_path
174
+
175
+ def install_addon(self):
176
+ """
177
+ Installs the given addons from the release folder.
178
+ """
179
+ zip_file_path = self.get_addon_zip_path()
180
+
181
+ # install addon if it isn't installed
182
+ if not self.is_addon_installed(self.addon_name):
183
+ bpy.ops.preferences.addon_install(filepath=zip_file_path)
184
+
185
+ # enable addon if it is disabled
186
+ if self.addon_name not in bpy.context.preferences.addons.keys():
187
+ bpy.ops.preferences.addon_enable(module=self.addon_name)
188
+
189
+ @staticmethod
190
+ def is_addon_installed(module_name):
191
+ """
192
+ Checks if the given addon module is currently installed.
193
+
194
+ :param str module_name: The addon module name.
195
+ :return bool: True or False depending on whether the addon exists.
196
+ """
197
+ return module_name in [module.bl_info.get('name') for module in addon_utils.modules()]
198
+
199
+
200
+ def install_package(package):
201
+ subprocess.check_call([
202
+ sys.executable,
203
+ "-m",
204
+ "pip",
205
+ "install",
206
+ "--upgrade",
207
+ "--target=./src/addons/metahuman/resources/packages",
208
+ package
209
+ ],
210
+ cwd=REPO_ROOT
211
+ )
212
+
213
+
214
+ def install_requirements():
215
+ requirements_file_path = os.path.join(os.path.dirname(__file__), 'requirements.txt')
216
+ with open(requirements_file_path, 'r') as requirements_file:
217
+ for requirement in requirements.parse(requirements_file):
218
+ versioned_package = f'{requirement.name}{"".join(requirement.specs[0])}'
219
+ install_package(versioned_package)
220
+
221
+ if __name__ == "__main__":
222
+ addon_packager = AddonPackager(
223
+ addon_name='metahuman',
224
+ addon_folder_path=str(REPO_ROOT / 'src' / 'addons' / 'metahuman'),
225
+ output_folder=str(REPO_ROOT / 'release')
226
+ )
227
+
228
+ addon_packager.zip_addon()
229
+
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ RESOURCES_FOLDER = Path(__file__).parent / 'resources'
4
+
5
+ BLENDER_STARTUP_SCRIPT = RESOURCES_FOLDER / 'scripts' / 'blender' / 'startup.py'
@@ -0,0 +1,66 @@
1
+ import os
2
+ import bpy
3
+ import sys
4
+ import importlib
5
+ from types import ModuleType
6
+ repo_folder = os.path.join(os.path.dirname(__file__), os.pardir)
7
+ # from addon_packager import AddonPackager
8
+
9
+
10
+ def deep_reload(m: ModuleType):
11
+ name = m.__name__ # get the name that is used in sys.modules
12
+ name_ext = name + '.' # support finding sub modules or packages
13
+
14
+ def compare(loaded: str):
15
+ return (loaded == name) or loaded.startswith(name_ext)
16
+
17
+ all_mods = tuple(sys.modules) # prevent changing iterable while iterating over it
18
+ sub_mods = filter(compare, all_mods)
19
+ for pkg in sorted(sub_mods, key=lambda item: item.count('.'), reverse=True):
20
+ importlib.reload(sys.modules[pkg]) # reload packages, beginning with the most deeply nested
21
+
22
+
23
+ def reload_addon_source_code(addons, only_unregister=False):
24
+ """"
25
+ Does a full reload of the addons directly from their source code in the repo. This
26
+ tends to be the preferred method of working since stack traces will link back to the
27
+ source code.
28
+
29
+ :param list addons: A list of addon names.
30
+ :param bool only_unregister: Whether or not to only unregister the addon code.
31
+ """
32
+ # forces reloading of modules, regeneration of properties, and sends all errors
33
+ # to stderr instead of a dialog
34
+ for addon in addons:
35
+ os.environ[f'{addon.upper()}_DEV'] = '1'
36
+ addon = importlib.import_module(addon)
37
+
38
+ addon.unregister()
39
+ deep_reload(addon)
40
+ # importlib.reload(addon)
41
+
42
+ if not only_unregister:
43
+ addon.register()
44
+
45
+
46
+ def reload_addon_zips(addons):
47
+ """
48
+ Does a full install and reload of the addons from their zip files. This is useful when
49
+ testing the full addon installation, and when you need to see the addon preferences UI which
50
+ is only available when a addon is physically installed.
51
+
52
+ :param list addons: A list of addon names.
53
+ """
54
+ # unregister any registered addons
55
+ reload_addon_source_code(addons, only_unregister=True)
56
+
57
+ # zip up each addon
58
+ for addon in addons:
59
+ os.environ[f'{addon.upper()}_DEV'] = '1'
60
+ addon_folder_path = os.path.join(repo_folder, addon)
61
+ release_folder_path = os.path.join(repo_folder, 'release')
62
+ addon_packager = AddonPackager(addon, addon_folder_path, release_folder_path)
63
+ addon_packager.zip_addon()
64
+ addon_packager.install_addon()
65
+
66
+ bpy.ops.script.reload()
@@ -0,0 +1,21 @@
1
+ import sys
2
+ from .utilities import launch_blender, launch_unreal
3
+ app_name = sys.argv[1]
4
+ app_version = sys.argv[2]
5
+ debug_on = sys.argv[3]
6
+
7
+
8
+ if __name__ == '__main__':
9
+ debug_on = '1' if debug_on.lower() == 'yes' else '0'
10
+
11
+ if app_name == 'blender':
12
+ launch_blender(
13
+ version=app_version,
14
+ debug=debug_on
15
+ )
16
+
17
+ if app_name == 'unreal':
18
+ launch_unreal(
19
+ version=app_version,
20
+ debug=debug_on
21
+ )
@@ -0,0 +1,45 @@
1
+ import os
2
+ import sys
3
+ import bpy
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ logging.basicConfig(level=logging.DEBUG)
10
+
11
+ if int(os.environ.get('BLENDER_DEBUGGING_ON', '0')):
12
+ try:
13
+ import debugpy
14
+ port = int(os.environ.get('BLENDER_DEBUG_PORT', 5678))
15
+ debugpy.configure(python=sys.executable)
16
+ debugpy.listen(port)
17
+ logger.info(f'Waiting for debugger to attach on port {port}...')
18
+ debugpy.wait_for_client()
19
+ except ImportError:
20
+ logger.error(
21
+ 'Failed to initialize debugger because debugpy is not available '
22
+ 'in the current python environment.'
23
+ )
24
+
25
+ BLENDER_SCRIPTS_FOLDERS = [Path(i) for i in os.environ.get('BLENDER_SCRIPTS_FOLDERS', '').split(os.pathsep)]
26
+
27
+ for scripts_folder in BLENDER_SCRIPTS_FOLDERS:
28
+ script_directory = bpy.context.preferences.filepaths.script_directories.get(scripts_folder.parent.name)
29
+ if script_directory:
30
+ bpy.context.preferences.filepaths.script_directories.remove(script_directory)
31
+
32
+ script_directory = bpy.context.preferences.filepaths.script_directories.new()
33
+ script_directory.name = scripts_folder.parent.name
34
+ script_directory.directory = str(scripts_folder)
35
+
36
+
37
+ try:
38
+ bpy.ops.script.reload()
39
+ except ValueError:
40
+ pass
41
+
42
+ for scripts_folder in BLENDER_SCRIPTS_FOLDERS:
43
+ for addon in os.listdir(scripts_folder / 'addons'):
44
+ if (scripts_folder / 'addons' / addon).is_dir():
45
+ bpy.ops.preferences.addon_enable(module=addon)
@@ -0,0 +1,22 @@
1
+ import os
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ logging.basicConfig(level=logging.DEBUG)
9
+
10
+
11
+ if int(os.environ.get('UNREAL_DEBUGGING_ON', '0')):
12
+ import debugpy
13
+ platform_folder = 'Linux'
14
+ if sys.platform == 'win32':
15
+ platform_folder = 'Win64'
16
+
17
+ port = int(os.environ.get('UNREAL_DEBUG_PORT', 5678))
18
+ python_exe_path = Path(sys.executable).parent.parent / 'ThirdParty' / 'Python3' / platform_folder / 'python'
19
+ debugpy.configure(python=os.environ.get('UNREAL_PYTHON_EXE', str(python_exe_path)))
20
+ debugpy.listen(port)
21
+ logger.info(f'Waiting for debugger to attach on port {port}...')
22
+ debugpy.wait_for_client()
@@ -0,0 +1,109 @@
1
+ import os
2
+ import sys
3
+ import site
4
+ import subprocess
5
+ import poly_hammer_utils
6
+ from pathlib import Path
7
+ from poly_hammer_utils.constants import BLENDER_STARTUP_SCRIPT
8
+
9
+
10
+ REPO_ROOT = Path(__file__).parent.parent.parent.parent
11
+ UNREAL_PROJECT = Path(os.environ['UNREAL_PROJECT'])
12
+ UNREAL_EXE = os.environ.get('UNREAL_EXE')
13
+ UNREAL_STARTUP_SCRIPT = Path(__file__).parent / 'resources' / 'scripts' / 'unreal' / 'init_unreal.py'
14
+
15
+
16
+ def shell(command: str, **kwargs):
17
+ """
18
+ Runs the command is a fully qualified shell.
19
+
20
+ Args:
21
+ command (str): A command.
22
+
23
+ Raises:
24
+ OSError: The error cause by the shell.
25
+ """
26
+ process = subprocess.Popen(
27
+ command,
28
+ shell=True,
29
+ universal_newlines=True,
30
+ stdout=subprocess.PIPE,
31
+ stderr=subprocess.STDOUT,
32
+ **kwargs
33
+ )
34
+
35
+ output = []
36
+ for line in iter(process.stdout.readline, ""): # type: ignore
37
+ output += [line.rstrip()]
38
+ sys.stdout.write(line)
39
+
40
+ process.wait()
41
+
42
+ if process.returncode != 0:
43
+ raise OSError("\n".join(output))
44
+
45
+
46
+ def launch_blender(version: str, debug: str):
47
+ if sys.platform == 'win32':
48
+ exe_path = rf"C:\Program Files\Blender Foundation\Blender {version}\blender.exe"
49
+ else:
50
+ exe_path = None
51
+
52
+ if exe_path:
53
+ command = f'"{exe_path}" --python-use-system-env --python "{BLENDER_STARTUP_SCRIPT}"'
54
+ shell(
55
+ command,
56
+ env={
57
+ **os.environ.copy(),
58
+ 'PYTHONUNBUFFERED': '1',
59
+ 'BLENDER_APP_VERSION': version,
60
+ 'BLENDER_DEBUGGING_ON': debug,
61
+ 'PYTHONPATH': ';'.join(
62
+ site.getsitepackages() + [str(Path(poly_hammer_utils.__file__).parent.parent)]
63
+ )
64
+ }
65
+ )
66
+
67
+
68
+ def launch_unreal(version: str, debug: str):
69
+ if sys.platform == 'win32':
70
+ exe_path = rf'C:\Program Files\Epic Games\UE_{version}\Engine\Binaries\Win64\UnrealEditor.exe'
71
+ else:
72
+ exe_path = None
73
+
74
+ if UNREAL_EXE:
75
+ exe_path = UNREAL_EXE
76
+
77
+ if exe_path:
78
+ command = f'"{exe_path}" "{UNREAL_PROJECT}" -stdout -nopause -forcelogflush -verbose'
79
+ shell(
80
+ command,
81
+ env={
82
+ **os.environ.copy(),
83
+ 'UNREAL_APP_VERSION': version,
84
+ 'UNREAL_DEBUGGING_ON': debug,
85
+ 'UE_PYTHONPATH': ';'.join([
86
+ *site.getsitepackages(),
87
+ str(Path(poly_hammer_utils.__file__).parent.parent.absolute()),
88
+ str(UNREAL_STARTUP_SCRIPT.parent.absolute())
89
+ ])
90
+ }
91
+ )
92
+
93
+
94
+ if __name__ == "__main__":
95
+ app_name = sys.argv[1]
96
+ app_version = sys.argv[2]
97
+ debug_on = sys.argv[3]
98
+
99
+ if app_name == 'blender':
100
+ launch_blender(
101
+ version=app_version,
102
+ debug=debug_on
103
+ )
104
+
105
+ if app_name == 'unreal':
106
+ launch_unreal(
107
+ version=app_version,
108
+ debug=debug_on
109
+ )