Skip to content

Commit 5a53707

Browse files
committed
rework the legacy compat import hook, fix faulty constructor logic
1 parent 086124e commit 5a53707

File tree

3 files changed

+159
-64
lines changed

3 files changed

+159
-64
lines changed

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
### Legacy Compat v1.1
66
- Fixed that some legacy mods would not auto-enable properly.
7+
- Added a compat handler for object names - they didn't use to include the number. This previously
8+
caused crash when trying to load Exodus.
9+
- Added a fixup to Constructor, and Constructor-based mods, to handle the new mod folder location.
710

811
### [pyunrealsdk v1.5.2](https://github.com/bl-sdk/pyunrealsdk/blob/master/changelog.md#v152)
912
This version has no `pyunrealsdk` changes, it simply pulled in a new version of `unrealsdk`.

src/legacy_compat/__init__.py

Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,19 @@ def add_compat_module(name: str, module: ModuleType) -> None: # pyright: ignore
8989
ENABLED = False # pyright: ignore[reportConstantRedefinition]
9090
else:
9191
import ctypes
92-
import inspect
9392
import sys
9493
import warnings
95-
from collections.abc import Iterator, Sequence
94+
from collections.abc import Iterator
9695
from contextlib import contextmanager
9796
from functools import wraps
98-
from importlib.machinery import ModuleSpec, SourceFileLoader
99-
from importlib.util import spec_from_file_location
100-
from pathlib import Path
10197

10298
from unrealsdk.hooks import prevent_hooking_direct_calls
10399

104100
from mods_base import MODS_DIR
105101

106102
from . import ModMenu
107103
from . import unrealsdk as old_unrealsdk
104+
from .meta_path_finder import LegacyCompatMetaPathFinder
108105

109106
base_mod.components.append(base_mod.ComponentInfo("Legacy SDK Compat", __version__))
110107

@@ -161,65 +158,6 @@ def add_compat_module(name: str, module: ModuleType) -> None: # pyright: ignore
161158
"Mods.UserFeedback.ctypes": ctypes,
162159
}
163160

164-
# On top of this, we do actually need a full import hook to redirect other mod-specific imports
165-
166-
class LegacyCompatMetaPathFinder:
167-
@staticmethod
168-
def get_importing_file() -> Path:
169-
"""
170-
Gets the file which triggered the in progress import.
171-
172-
Returns:
173-
The importing file.
174-
"""
175-
# Skip the frame for this function and find_spec below
176-
for frame in inspect.stack()[2:]:
177-
# Then skip everything in the import machinery
178-
if "importlib" in frame.filename:
179-
continue
180-
return Path(frame.filename)
181-
raise RuntimeError
182-
183-
@classmethod
184-
def find_spec(
185-
cls,
186-
fullname: str,
187-
path: Sequence[str] | None = None, # noqa: ARG003
188-
target: ModuleType | None = None, # noqa: ARG003
189-
) -> ModuleSpec | None:
190-
# EridiumLib adds it's dist folder with a path relative to the executable - fix that
191-
# We also have some problems with it's copy of requests, so redirect that to our copy
192-
if fullname == "requests" and cls.get_importing_file().parent.name == "EridiumLib":
193-
# Can't easily load the real requests, but turns out all we actually need is a get
194-
# method, which is allowed to just throw
195-
# Using a custom loader to inject it rather than loading from file, since the latter
196-
# doesn't work properly if we're packaged inside a .sdkmod
197-
class FakeRequestsLoader(SourceFileLoader):
198-
def get_data(self, path: str) -> bytes: # noqa: ARG002
199-
return (
200-
b"def get(url: str, timeout: int) -> str: # noqa: D103\n"
201-
b" raise NotImplementedError"
202-
)
203-
204-
return spec_from_file_location(
205-
"Mods.EridiumLib.fake_dist.requests",
206-
"<fake location>",
207-
loader=FakeRequestsLoader(
208-
"Mods.EridiumLib.fake_dist.requests",
209-
"<fake location>",
210-
),
211-
)
212-
if (
213-
fullname == "semver"
214-
and (mod_folder := cls.get_importing_file().parent).name == "EridiumLib"
215-
):
216-
return spec_from_file_location(
217-
"Mods.EridiumLib.fake_dist.semver",
218-
mod_folder / "dist" / "semver.py",
219-
)
220-
221-
return None
222-
223161
@contextmanager
224162
def import_compat_handler() -> Iterator[None]:
225163
"""Context manager to add the import compatibility."""
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import inspect
2+
import re
3+
from collections.abc import Callable, Sequence
4+
from importlib.abc import FileLoader, SourceLoader
5+
from importlib.machinery import ModuleSpec, PathFinder, SourceFileLoader
6+
from importlib.util import spec_from_file_location
7+
from pathlib import Path
8+
from types import ModuleType
9+
10+
# While just messing with `Mod.__path__` is enough for most most mods, there are a few we need to do
11+
# more advanced import hooking on.
12+
13+
14+
class StringSourceLoader(SourceFileLoader):
15+
source: bytes
16+
17+
def __init__(self, fullname: str, path: str, source: bytes) -> None:
18+
super().__init__(fullname, path)
19+
self.source = source
20+
21+
def get_data(self, path: str) -> bytes: # noqa: ARG002, D102
22+
return self.source
23+
24+
25+
def spec_from_string(fullname: str, source: bytes) -> ModuleSpec | None:
26+
"""
27+
Creates a module spec from a hardcoded string.
28+
29+
Args:
30+
fullname: The fully resolved module name.
31+
source: The source code.
32+
"""
33+
return spec_from_file_location(
34+
fullname,
35+
"<fake location>",
36+
loader=StringSourceLoader(fullname, "<fake location>", source),
37+
)
38+
39+
40+
# Inheriting straight from SourceFileLoade causes some other machinery to expect bytecode?
41+
class ReplacementSourceLoader(FileLoader, SourceLoader): # type: ignore
42+
type Pattern = tuple[bytes | re.Pattern[bytes], bytes | Callable[[re.Match[bytes]], bytes]]
43+
44+
replacements: Sequence[Pattern]
45+
46+
def __init__(self, fullname: str, path: str, replacements: Sequence[Pattern]) -> None:
47+
super().__init__(fullname, path)
48+
self.replacements = replacements
49+
50+
def get_data(self, path: str) -> bytes: # noqa: D102
51+
data = Path(path).read_bytes()
52+
for pattern, replacement in self.replacements:
53+
data = re.sub(pattern, replacement, data)
54+
return data
55+
56+
57+
def spec_with_replacements(
58+
fullname: str,
59+
path: Sequence[str] | None = None,
60+
target: ModuleType | None = None,
61+
*replacements: ReplacementSourceLoader.Pattern,
62+
) -> ModuleSpec | None:
63+
"""
64+
Creates a module spec based on the existing module, but applying a set of regex replacements.
65+
66+
Args:
67+
fullname: The fully resolved module name, copied from the original call.
68+
path: The paths to search, copied from the original call.
69+
target: The target copied from the original call.
70+
*replacements: Tuples of a regex pattern and it's replacement.
71+
"""
72+
original_spec = PathFinder.find_spec(fullname, path, target)
73+
if original_spec is None or not original_spec.has_location or original_spec.origin is None:
74+
return None
75+
76+
return spec_from_file_location(
77+
fullname,
78+
original_spec.origin,
79+
loader=ReplacementSourceLoader(fullname, original_spec.origin, replacements),
80+
)
81+
82+
83+
class LegacyCompatMetaPathFinder:
84+
@staticmethod
85+
def get_importing_file() -> Path:
86+
"""
87+
Gets the file which triggered the in progress import.
88+
89+
Returns:
90+
The importing file.
91+
"""
92+
# Skip the frame for this function and find_spec below
93+
for frame in inspect.stack()[2:]:
94+
# Then skip everything in the import machinery
95+
if "importlib" in frame.filename:
96+
continue
97+
return Path(frame.filename)
98+
raise RuntimeError
99+
100+
@classmethod
101+
def find_spec( # noqa: D102
102+
cls,
103+
fullname: str,
104+
path: Sequence[str] | None = None,
105+
target: ModuleType | None = None,
106+
) -> ModuleSpec | None:
107+
importing_file = cls.get_importing_file()
108+
109+
match importing_file.parent.name, fullname:
110+
# EridiumLib adds it's dist folder with a path relative to the executable - fix that
111+
case "EridiumLib", "semver":
112+
return spec_from_file_location(
113+
"Mods.EridiumLib.fake_dist.semver",
114+
importing_file.parent / "dist" / "semver.py",
115+
)
116+
117+
# Something about trying to load a requests build from 6 major versions ago completely
118+
# breaks, we can't easily get it to load.
119+
# However, it turns out all EridiumLib needs is a get method, which is allowed to just
120+
# throw.
121+
case "EridiumLib", "requests":
122+
return spec_from_string(
123+
"Mods.EridiumLib.fake_dist.requests",
124+
(
125+
b"def get(url: str, timeout: int) -> str: # noqa: D103\n"
126+
b" raise NotImplementedError"
127+
),
128+
)
129+
130+
# Constructor has a bad bit of logic for getting the relative file path to execute.
131+
# Since we moved the mods folder, it breaks. Replace it with some proper logic.
132+
case (
133+
("Constructor", "Mods.Constructor.hotfix_manager")
134+
| ("Exodus", "Mods.Exodus.hotfix_manager")
135+
| ("ExodusTPS", "Mods.ExodusTPS.hotfix_manager")
136+
| ("Snowbound", "Mods.Snowbound.hotfix_manager")
137+
):
138+
return spec_with_replacements(
139+
fullname,
140+
path,
141+
target,
142+
# Import on one line just to avoid changing line numbers
143+
(b"import os", b"import os, sys"),
144+
(
145+
rb'( +)exec_path = str\(file\)\.split\("Binaries\\+", 1\)\[1\]([\r\n]+)'
146+
rb'( +)bl2tools.console_command\("exec " \+ exec_path, False\)([\r\n]+)',
147+
rb"\1exec_path = os.path.relpath(file,"
148+
rb' os.path.join(sys.executable, "..", ".."))\2'
149+
rb"\3bl2tools.console_command(f'exec \"{exec_path}\"', False)\4",
150+
),
151+
)
152+
153+
case _, _:
154+
return None

0 commit comments

Comments
 (0)