unirebuild 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.
Files changed (31) hide show
  1. unirebuild-0.0.1/PKG-INFO +12 -0
  2. unirebuild-0.0.1/README.md +1 -0
  3. unirebuild-0.0.1/pyproject.toml +25 -0
  4. unirebuild-0.0.1/src/unirebuild/__init__.py +1 -0
  5. unirebuild-0.0.1/src/unirebuild/constants.py +10 -0
  6. unirebuild-0.0.1/src/unirebuild/context.py +97 -0
  7. unirebuild-0.0.1/src/unirebuild/core.py +123 -0
  8. unirebuild-0.0.1/src/unirebuild/ios/cgbi.py +99 -0
  9. unirebuild-0.0.1/src/unirebuild/ios/plist.py +48 -0
  10. unirebuild-0.0.1/src/unirebuild/py.typed +0 -0
  11. unirebuild-0.0.1/src/unirebuild/steps/__init__.py +20 -0
  12. unirebuild-0.0.1/src/unirebuild/steps/apply_patches.py +28 -0
  13. unirebuild-0.0.1/src/unirebuild/steps/base.py +19 -0
  14. unirebuild-0.0.1/src/unirebuild/steps/copy_bundles.py +49 -0
  15. unirebuild-0.0.1/src/unirebuild/steps/copy_gitignore.py +17 -0
  16. unirebuild-0.0.1/src/unirebuild/steps/copy_overrides.py +20 -0
  17. unirebuild-0.0.1/src/unirebuild/steps/custom_action.py +16 -0
  18. unirebuild-0.0.1/src/unirebuild/steps/decode_fsb_audio.py +81 -0
  19. unirebuild-0.0.1/src/unirebuild/steps/deduplicate_assets.py +155 -0
  20. unirebuild-0.0.1/src/unirebuild/steps/delete_assets.py +30 -0
  21. unirebuild-0.0.1/src/unirebuild/steps/extract_app.py +25 -0
  22. unirebuild-0.0.1/src/unirebuild/steps/extract_app_icon.py +112 -0
  23. unirebuild-0.0.1/src/unirebuild/steps/generate_deterministic_guids.py +135 -0
  24. unirebuild-0.0.1/src/unirebuild/steps/git_commit.py +36 -0
  25. unirebuild-0.0.1/src/unirebuild/steps/git_init.py +20 -0
  26. unirebuild-0.0.1/src/unirebuild/steps/populate_texture_settings.py +122 -0
  27. unirebuild-0.0.1/src/unirebuild/steps/rebuild_patches.py +104 -0
  28. unirebuild-0.0.1/src/unirebuild/steps/reencode_wavs.py +30 -0
  29. unirebuild-0.0.1/src/unirebuild/steps/run_assetripper.py +107 -0
  30. unirebuild-0.0.1/src/unirebuild/steps/swap_files.py +27 -0
  31. unirebuild-0.0.1/src/unirebuild/steps/unity_upgrade.py +57 -0
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.3
2
+ Name: unirebuild
3
+ Version: 0.0.1
4
+ Summary: A modular framework for patching Unity games.
5
+ Author: Nuutti
6
+ Author-email: Nuutti <snuutti@protonmail.com>
7
+ Requires-Dist: colorlog>=6.10.1
8
+ Requires-Dist: pyyaml>=6.0.3
9
+ Requires-Python: >=3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # UniRebuild
@@ -0,0 +1 @@
1
+ # UniRebuild
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "unirebuild"
3
+ version = "0.0.1"
4
+ description = "A modular framework for patching Unity games."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Nuutti", email = "snuutti@protonmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "colorlog>=6.10.1",
12
+ "pyyaml>=6.0.3",
13
+ ]
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.11.15,<0.12.0"]
17
+ build-backend = "uv_build"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "ruff>=0.15.16",
22
+ ]
23
+
24
+ [tool.ruff.lint.per-file-ignores]
25
+ "__init__.py" = ["F401"]
@@ -0,0 +1 @@
1
+ from .core import UniRebuild
@@ -0,0 +1,10 @@
1
+ GUID_REFERENCE_EXTENSIONS = {
2
+ ".anim",
3
+ ".meta",
4
+ ".controller",
5
+ ".asset",
6
+ ".mat",
7
+ ".mixer",
8
+ ".prefab",
9
+ ".unity",
10
+ }
@@ -0,0 +1,97 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+
7
+
8
+ class PatcherContext:
9
+ def __init__(self, game_name: str, workspace_dir: str, temp_dir: str):
10
+ self.game_name = game_name
11
+ self.workspace_dir = os.path.abspath(workspace_dir)
12
+ self.temp_dir = os.path.abspath(temp_dir)
13
+ self.platform = sys.platform
14
+ self.is_ci = "CI" in os.environ
15
+ self.args = None
16
+ self.setup_steps = []
17
+ self.rebuild_steps = []
18
+ self.__executable_cache = {}
19
+
20
+ def get_temp_path(self, *paths: str) -> str:
21
+ """Resolves a path within the temporary directory."""
22
+ return os.path.join(self.temp_dir, *paths)
23
+
24
+ def get_extracted_path(self) -> str:
25
+ """Returns the path where the app was extracted."""
26
+ return self.get_temp_path("extracted_app")
27
+
28
+ def find_executable(self, name: str) -> str | None:
29
+ """Finds the path of an executable on the system path or in the current directory."""
30
+ if name in self.__executable_cache:
31
+ return self.__executable_cache[name]
32
+
33
+ search_name = name
34
+ if self.platform == "win32":
35
+ search_name += ".exe"
36
+
37
+ local_path = os.path.abspath(os.path.join(os.getcwd(), search_name))
38
+ if os.path.isfile(local_path) and os.access(local_path, os.X_OK):
39
+ self.__executable_cache[name] = local_path
40
+ return local_path
41
+
42
+ system_path = shutil.which(search_name)
43
+ if system_path:
44
+ abs_system_path = os.path.abspath(system_path)
45
+ self.__executable_cache[name] = abs_system_path
46
+ return abs_system_path
47
+
48
+ return None
49
+
50
+ def find_unity(self, version: str) -> list[str] | None:
51
+ """Finds the Unity executable for a given version."""
52
+ cache_key = f"unity_{version}"
53
+ if cache_key in self.__executable_cache:
54
+ return self.__executable_cache[cache_key]
55
+
56
+ if self.is_ci:
57
+ docker_cmd = [
58
+ "docker",
59
+ "run",
60
+ "--rm",
61
+ "-v",
62
+ f"{os.getcwd()}:{os.getcwd()}",
63
+ "-w",
64
+ os.getcwd(),
65
+ f"unityci/editor:ubuntu-{version}-linux-il2cpp-3",
66
+ "unity-editor",
67
+ ]
68
+ self.__executable_cache[cache_key] = docker_cmd
69
+ return docker_cmd
70
+
71
+ unity_path = ""
72
+ if self.platform == "linux":
73
+ unity_path = os.path.expanduser(
74
+ f"~/Unity/Hub/Editor/{version}/Editor/Unity"
75
+ )
76
+ elif self.platform == "darwin":
77
+ unity_path = f"/Applications/Unity/Hub/Editor/{version}/Unity.app/Contents/MacOS/Unity"
78
+ elif self.platform == "win32":
79
+ unity_path = f"C:/Program Files/Unity/Hub/Editor/{version}/Editor/Unity.exe"
80
+
81
+ if unity_path and os.path.isfile(unity_path):
82
+ cmd = [unity_path]
83
+ self.__executable_cache[cache_key] = cmd
84
+ return cmd
85
+
86
+ return None
87
+
88
+ def run_cmd(
89
+ self, cmd: list[str], cwd: str | None = None, ignore_errors: bool = False
90
+ ) -> int:
91
+ """Runs a command and returns the exit code."""
92
+ logging.info("Running command: %s", " ".join(cmd))
93
+ result = subprocess.run(cmd, cwd=cwd)
94
+ if result.returncode != 0 and not ignore_errors:
95
+ raise RuntimeError(f"Command failed with exit code {result.returncode}")
96
+
97
+ return result.returncode
@@ -0,0 +1,123 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import sys
6
+
7
+ import colorlog
8
+
9
+ from . import steps
10
+ from .steps import PatcherStep
11
+ from .context import PatcherContext
12
+
13
+
14
+ class UniRebuild:
15
+ def __init__(self, game_name: str, workspace_dir: str, temp_dir: str = "Temp"):
16
+ self.context = PatcherContext(game_name, workspace_dir, temp_dir)
17
+ self.setup_steps = []
18
+ self.rebuild_steps = []
19
+
20
+ def add_setup_steps(self, steps: list):
21
+ self.setup_steps.extend(steps)
22
+
23
+ def add_rebuild_steps(self, steps: list):
24
+ self.rebuild_steps.extend(steps)
25
+
26
+ def execute(self):
27
+ colorlog.basicConfig(
28
+ level=logging.INFO, format="%(log_color)s%(levelname)s: %(message)s"
29
+ )
30
+
31
+ sys.stdout.reconfigure(line_buffering=True)
32
+
33
+ if not self.rebuild_steps:
34
+ self.rebuild_steps.append(steps.RebuildPatches())
35
+
36
+ parser = argparse.ArgumentParser(
37
+ description=f"UniRebuild - {self.context.game_name} Patcher"
38
+ )
39
+ subparsers = parser.add_subparsers(dest="command", required=True)
40
+
41
+ parser_setup = subparsers.add_parser("setup", help="Set up the workspace")
42
+ parser_rebuild = subparsers.add_parser("rebuild", help="Rebuild all patches")
43
+
44
+ for step in self.setup_steps:
45
+ step.register_arguments(parser_setup)
46
+
47
+ for step in self.rebuild_steps:
48
+ step.register_arguments(parser_rebuild)
49
+
50
+ args = parser.parse_args()
51
+ self.context.args = args
52
+
53
+ self.context.setup_steps = self.setup_steps
54
+ self.context.rebuild_steps = self.rebuild_steps
55
+
56
+ if args.command == "setup":
57
+ if os.path.exists(self.context.workspace_dir):
58
+ logging.error(
59
+ "Workspace directory '%s' already exists. Please remove it before running setup.",
60
+ self.context.workspace_dir,
61
+ )
62
+ sys.exit(1)
63
+
64
+ if os.path.exists(self.context.temp_dir):
65
+ shutil.rmtree(self.context.temp_dir)
66
+
67
+ self.run_pipeline(self.setup_steps, True)
68
+
69
+ logging.info(
70
+ "Setup completed successfully! You can now open '%s' in Unity.",
71
+ self.context.workspace_dir,
72
+ )
73
+ elif args.command == "rebuild":
74
+ if not os.path.exists(self.context.workspace_dir):
75
+ logging.error(
76
+ "Workspace directory '%s' does not exist. Please run 'setup' first.",
77
+ self.context.workspace_dir,
78
+ )
79
+ sys.exit(1)
80
+
81
+ self.run_pipeline(self.rebuild_steps, False)
82
+
83
+ def run_pipeline(self, steps: list[PatcherStep], cleanup_temp: bool):
84
+ dependencies = set()
85
+ for step in steps:
86
+ for dependency in step.get_dependencies():
87
+ dependencies.add(dependency)
88
+
89
+ missing_tools = []
90
+ for dependency in dependencies:
91
+ if dependency.startswith("unity:"):
92
+ version = dependency.split(":")[1]
93
+ if not self.context.find_unity(version):
94
+ missing_tools.append(f"Unity {version}")
95
+ else:
96
+ if not self.context.find_executable(dependency):
97
+ missing_tools.append(dependency)
98
+
99
+ if missing_tools:
100
+ logging.error("The following required tools were not found:")
101
+ for tool in missing_tools:
102
+ logging.error(" - %s", tool)
103
+
104
+ sys.exit(1)
105
+
106
+ if cleanup_temp:
107
+ shutil.rmtree(self.context.temp_dir, ignore_errors=True)
108
+ os.makedirs(self.context.temp_dir, exist_ok=True)
109
+
110
+ try:
111
+ for step in steps:
112
+ step.execute(self.context)
113
+ except Exception as e:
114
+ logging.error("Patching failed!")
115
+ logging.exception(e)
116
+ sys.exit(1)
117
+ finally:
118
+ if cleanup_temp:
119
+ logging.info("Cleaning up temporary files...")
120
+ shutil.rmtree(self.context.temp_dir, ignore_errors=True)
121
+ # TODO: move this somewhere else
122
+ # AssetRipper
123
+ shutil.rmtree("temp", ignore_errors=True)
@@ -0,0 +1,99 @@
1
+ from struct import unpack, pack
2
+ from zlib import decompress, compress, crc32
3
+
4
+
5
+ # Copied from https://gist.github.com/urielka/3609051
6
+ def cgbi_to_png(cgbi_path: str) -> bytes | None:
7
+ png_header = b"\x89PNG\r\n\x1a\n"
8
+
9
+ with open(cgbi_path, "rb") as file:
10
+ cgbi_file = file.read()
11
+
12
+ if cgbi_file[:8] != png_header:
13
+ return None
14
+
15
+ png_file = cgbi_file[:8]
16
+ chunk_pos = len(png_file)
17
+ idat_acc = b""
18
+ break_loop = False
19
+
20
+ # For each chunk in the PNG file
21
+ while chunk_pos < len(cgbi_file):
22
+ skip = False
23
+
24
+ # Reading chunk
25
+ chunk_length = cgbi_file[chunk_pos : chunk_pos + 4]
26
+ chunk_length = unpack(">L", chunk_length)[0]
27
+ chunk_type = cgbi_file[chunk_pos + 4 : chunk_pos + 8]
28
+ chunk_data = cgbi_file[chunk_pos + 8 : chunk_pos + 8 + chunk_length]
29
+ chunk_crc = cgbi_file[
30
+ chunk_pos + chunk_length + 8 : chunk_pos + chunk_length + 12
31
+ ]
32
+ chunk_crc = unpack(">L", chunk_crc)[0]
33
+ chunk_pos += chunk_length + 12
34
+
35
+ # Parsing the header chunk
36
+ if chunk_type == b"IHDR":
37
+ width = unpack(">L", chunk_data[0:4])[0]
38
+ height = unpack(">L", chunk_data[4:8])[0]
39
+
40
+ # Parsing the image chunk
41
+ if chunk_type == b"IDAT":
42
+ idat_acc += chunk_data
43
+ skip = True
44
+
45
+ # Remove CgBI chunk
46
+ if chunk_type == b"CgBI":
47
+ skip = True
48
+
49
+ # When reaching the end chunk, process the accumulated IDAT data
50
+ if chunk_type == b"IEND":
51
+ try:
52
+ buf_size = width * height * 4 + height
53
+ chunk_data = decompress(idat_acc, -15, buf_size)
54
+ except Exception as e:
55
+ # The PNG image is already normalized
56
+ print(e)
57
+ return None
58
+
59
+ # Prepare new IDAT chunk
60
+ chunk_type = b"IDAT"
61
+
62
+ # Swap red & blue for each pixel
63
+ new_data = bytearray()
64
+ pos = 0
65
+ for y in range(height):
66
+ # Copy the filter byte for the scanline
67
+ new_data.append(chunk_data[pos])
68
+ pos += 1
69
+ for x in range(width):
70
+ # Original order: R, G, B, A
71
+ r = chunk_data[pos]
72
+ g = chunk_data[pos + 1]
73
+ b = chunk_data[pos + 2]
74
+ a = chunk_data[pos + 3]
75
+ # New order: B, G, R, A
76
+ new_data.append(b)
77
+ new_data.append(g)
78
+ new_data.append(r)
79
+ new_data.append(a)
80
+ pos += 4
81
+
82
+ # Compress the modified image data
83
+ chunk_data = compress(bytes(new_data))
84
+ chunk_length = len(chunk_data)
85
+ chunk_crc = crc32(chunk_type)
86
+ chunk_crc = crc32(chunk_data, chunk_crc)
87
+ chunk_crc = (chunk_crc + 0x100000000) % 0x100000000
88
+ break_loop = True
89
+
90
+ if not skip:
91
+ png_file += pack(">L", chunk_length)
92
+ png_file += chunk_type
93
+ if chunk_length > 0:
94
+ png_file += chunk_data
95
+ png_file += pack(">L", chunk_crc)
96
+ if break_loop:
97
+ break
98
+
99
+ return png_file
@@ -0,0 +1,48 @@
1
+ import os
2
+ import plistlib
3
+ from typing import Any
4
+
5
+
6
+ def extract_plist_icon_filenames(plist_path: str) -> list[str]:
7
+ if not os.path.exists(plist_path):
8
+ raise FileNotFoundError(f"The .plist file '{plist_path}' does not exist.")
9
+
10
+ with open(plist_path, "rb") as fp:
11
+ try:
12
+ plist_data = plistlib.load(fp)
13
+ except Exception as e:
14
+ raise ValueError(f"Failed to parse .plist file: {e}")
15
+
16
+ found_icons = []
17
+
18
+ def walk_plist(obj: Any, key: str | None = None):
19
+ if isinstance(obj, dict):
20
+ for k, v in obj.items():
21
+ walk_plist(v, k)
22
+ elif isinstance(obj, list):
23
+ if key and "CFBundleIconFiles" in key:
24
+ for item in obj:
25
+ if isinstance(item, str):
26
+ found_icons.append(item)
27
+ else:
28
+ for item in obj:
29
+ walk_plist(item, key)
30
+ elif isinstance(obj, str):
31
+ if key and ("CFBundleIconFile" in key or "CFBundleIconName" in key):
32
+ found_icons.append(obj)
33
+
34
+ walk_plist(plist_data)
35
+
36
+ valid_icons = []
37
+ for icon in found_icons:
38
+ icon_cleaned = icon.strip()
39
+ if not icon_cleaned:
40
+ continue
41
+
42
+ if not icon_cleaned.lower().endswith(".png"):
43
+ icon_cleaned += ".png"
44
+
45
+ if icon_cleaned not in valid_icons:
46
+ valid_icons.append(icon_cleaned)
47
+
48
+ return valid_icons
File without changes
@@ -0,0 +1,20 @@
1
+ from .base import PatcherStep
2
+ from .apply_patches import ApplyPatches
3
+ from .copy_bundles import CopyBundles
4
+ from .copy_gitignore import CopyGitignore
5
+ from .copy_overrides import CopyOverrides
6
+ from .custom_action import CustomAction
7
+ from .decode_fsb_audio import DecodeFsbAudio
8
+ from .deduplicate_assets import DeduplicateAssets
9
+ from .delete_assets import DeleteAssets
10
+ from .extract_app import ExtractApp
11
+ from .extract_app_icon import ExtractAppIcon
12
+ from .generate_deterministic_guids import GenerateDeterministicGuids
13
+ from .git_commit import GitCommit
14
+ from .git_init import GitInit
15
+ from .populate_texture_settings import PopulateTextureSettings
16
+ from .rebuild_patches import RebuildPatches
17
+ from .reencode_wavs import ReencodeWavs
18
+ from .run_assetripper import RunAssetRipper
19
+ from .swap_files import SwapFiles
20
+ from .unity_upgrade import UnityUpgrade
@@ -0,0 +1,28 @@
1
+ import glob
2
+ import logging
3
+ import os.path
4
+
5
+ from unirebuild.context import PatcherContext
6
+ from unirebuild.steps import PatcherStep
7
+
8
+
9
+ class ApplyPatches(PatcherStep):
10
+ def __init__(self, patches_dir: str):
11
+ self.patches_dir = patches_dir
12
+
13
+ def get_dependencies(self) -> list[str]:
14
+ return ["git"]
15
+
16
+ def execute(self, context: PatcherContext):
17
+ git_path = context.find_executable("git")
18
+ patches = sorted(
19
+ glob.glob(os.path.abspath(os.path.join(self.patches_dir, "*.patch")))
20
+ )
21
+ if patches:
22
+ logging.info("Applying %d patches...", len(patches))
23
+ context.run_cmd(
24
+ [git_path, "am", "--3way", "--ignore-whitespace"] + patches,
25
+ cwd=context.workspace_dir,
26
+ )
27
+ else:
28
+ logging.info("No patches found to apply.")
@@ -0,0 +1,19 @@
1
+ from argparse import ArgumentParser
2
+
3
+ from unirebuild.context import PatcherContext
4
+
5
+
6
+ class PatcherStep:
7
+ """Base class for all patcher steps."""
8
+
9
+ def register_arguments(self, parser: ArgumentParser):
10
+ """Registers command-line arguments for this step."""
11
+ pass
12
+
13
+ def get_dependencies(self) -> list[str]:
14
+ """Returns a list of tool dependencies required by this step."""
15
+ return []
16
+
17
+ def execute(self, context: PatcherContext):
18
+ """Executes the step using the provided context."""
19
+ raise NotImplementedError()
@@ -0,0 +1,49 @@
1
+ import logging
2
+ import os.path
3
+ import shutil
4
+ from argparse import ArgumentParser
5
+
6
+ from unirebuild.context import PatcherContext
7
+ from unirebuild.steps import PatcherStep
8
+
9
+
10
+ class CopyBundles(PatcherStep):
11
+ def __init__(
12
+ self, bundles_path_arg: str = "bundles", delete_existing: bool = False
13
+ ):
14
+ self.bundles_path_arg = bundles_path_arg
15
+ self.delete_existing = delete_existing
16
+
17
+ def register_arguments(self, parser: ArgumentParser):
18
+ parser.add_argument(
19
+ self.bundles_path_arg,
20
+ help="Path to a folder containing the game asset bundles",
21
+ )
22
+
23
+ def execute(self, context: PatcherContext):
24
+ bundles_src = getattr(context.args, self.bundles_path_arg)
25
+ bundles_dst = os.path.join(context.get_extracted_path(), "bundles")
26
+
27
+ if not os.path.exists(bundles_src):
28
+ raise FileNotFoundError(
29
+ f"Bundles directory '{bundles_src}' does not exist."
30
+ )
31
+
32
+ logging.info("Copying bundles from '%s' to '%s'...", bundles_src, bundles_dst)
33
+ shutil.copytree(bundles_src, bundles_dst)
34
+
35
+ if not self.delete_existing:
36
+ return
37
+
38
+ bundles = set()
39
+ for root, dirs, files in os.walk(bundles_dst):
40
+ for file in files:
41
+ bundles.add(file)
42
+
43
+ app_path = os.path.join(context.get_extracted_path(), "app")
44
+ for root, dirs, files in os.walk(app_path):
45
+ for file in files:
46
+ if file in bundles:
47
+ file_path = os.path.join(root, file)
48
+ logging.info("Deleting existing bundle '%s'...", file_path)
49
+ os.remove(file_path)
@@ -0,0 +1,17 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+
5
+ from unirebuild.context import PatcherContext
6
+ from unirebuild.steps import PatcherStep
7
+
8
+
9
+ class CopyGitignore(PatcherStep):
10
+ def __init__(self, source: str):
11
+ self.source = source
12
+
13
+ def execute(self, context: PatcherContext):
14
+ logging.info("Copy .gitignore...")
15
+ gitignore_src = os.path.abspath(self.source)
16
+ gitignore_dst = os.path.join(context.workspace_dir, ".gitignore")
17
+ shutil.copyfile(gitignore_src, gitignore_dst)
@@ -0,0 +1,20 @@
1
+ import logging
2
+ import os.path
3
+ import shutil
4
+
5
+ from unirebuild.context import PatcherContext
6
+ from unirebuild.steps import PatcherStep
7
+
8
+
9
+ class CopyOverrides(PatcherStep):
10
+ def __init__(self, overrides_dir: str):
11
+ self.overrides_dir = overrides_dir
12
+
13
+ def execute(self, context: PatcherContext):
14
+ if not os.path.exists(self.overrides_dir):
15
+ raise FileNotFoundError(
16
+ f"Overrides directory '{self.overrides_dir}' does not exist."
17
+ )
18
+
19
+ logging.info(f"Copying overrides from '{self.overrides_dir}'...")
20
+ shutil.copytree(self.overrides_dir, context.workspace_dir, dirs_exist_ok=True)
@@ -0,0 +1,16 @@
1
+ import logging
2
+ from typing import Callable
3
+
4
+ from unirebuild.context import PatcherContext
5
+ from unirebuild.steps import PatcherStep
6
+
7
+
8
+ class CustomAction(PatcherStep):
9
+ """A patcher step for executing a custom user-defined action."""
10
+
11
+ def __init__(self, action_func: Callable[[PatcherContext], None]):
12
+ self.action_func = action_func
13
+
14
+ def execute(self, context: PatcherContext):
15
+ logging.info("Running custom action '%s'...", self.action_func.__name__)
16
+ self.action_func(context)