|
| 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