Skip to content

Commit

Permalink
rework initalization script, add extra mod folders
Browse files Browse the repository at this point in the history
  • Loading branch information
apple1417 committed Jan 5, 2024
1 parent 1d30db3 commit 0d1c76e
Showing 1 changed file with 191 additions and 94 deletions.
285 changes: 191 additions & 94 deletions src/__main__.py
Original file line number Diff line number Diff line change
@@ -1,126 +1,223 @@
import importlib
import json
import os
import sys
import traceback
import zipfile
from collections.abc import Collection, Iterator
from pathlib import Path

from unrealsdk import logging

try:
import debugpy # pyright: ignore[reportMissingImports]

debugpy.listen( # pyright: ignore[reportUnknownMemberType]
("localhost", 5678),
in_process_debug_adapter=True,
)

# Make WrappedArrays resolve the same as lists
from _pydevd_bundle.pydevd_resolver import ( # pyright: ignore[reportMissingImports]
tupleResolver, # pyright: ignore[reportUnknownVariableType]
)
from _pydevd_bundle.pydevd_xml import ( # pyright: ignore[reportMissingImports]
_TYPE_RESOLVE_HANDLER, # pyright: ignore[reportUnknownVariableType]
)
from unrealsdk.unreal import WrappedArray

if not _TYPE_RESOLVE_HANDLER._initialized: # pyright: ignore[reportUnknownMemberType]
_TYPE_RESOLVE_HANDLER._initialize() # pyright: ignore[reportUnknownMemberType]
_TYPE_RESOLVE_HANDLER._default_type_map.append( # pyright: ignore[reportUnknownMemberType]
(WrappedArray, tupleResolver),
)

except (ImportError, AttributeError):
pass
# If true, displays the full traceback when a mod fails to import, rather than the shortened one
FULL_TRACEBACKS: bool = False
# If true, makes debugpy wait for a client before continuing - useful for debugging errors which
# happen at import time
WAIT_FOR_CLIENT: bool = False

_full_traceback = False
# A json list of paths to also to import mods from - you can add your repo to keep it separated
EXTRA_FOLDERS_ENV_VAR: str = "OAK_MOD_MANAGER_EXTRA_FOLDERS"


while not logging.is_console_ready():
pass
def init_debugpy() -> None:
"""Tries to import and setup debugpy. Does nothing if unable to."""
try:
import debugpy # pyright: ignore[reportMissingImports]

mods_to_import: list[str] = []
debugpy.listen( # pyright: ignore[reportUnknownMemberType]
("localhost", 5678),
in_process_debug_adapter=True,
)

for entry in Path(__file__).parent.iterdir():
if entry.is_dir():
if entry.name.startswith(".") or entry.name == "__pycache__":
continue
if WAIT_FOR_CLIENT:
debugpy.wait_for_client() # pyright: ignore[reportUnknownMemberType]
debugpy.breakpoint() # pyright: ignore[reportUnknownMemberType]

# A lot of people accidentally extract into double nested folders - they have a
# `sdk_mods/MyCoolMod/MyCoolMod/__init__.py` instead of a `sdk_mods/MyCoolMod/__init__.py`
# Usually this silently fails - we import `MyCoolMod` but there's nothing there
# Detect this and give a proper error message
if not (entry / "__init__.py").exists() and (entry / entry.name / "__init__.py").exists():
logging.error(
f"'{entry.name}' appears to be double nested, which may prevent it from being it"
f" from being loaded. Move the inner folder up a level.",
if "PYUNREALSDK_DEBUGPY" not in os.environ:
logging.dev_warning(
"Was able to start debugpy, but the `PYUNREALSDK_DEBUGPY` environment variable is"
" not set. This may prevent breakpoints from working properly.",
)
# Since it's a silent error, may as well continue in case it's actually what you wanted

# In the case we have a `sdk_mods/My Cool Mod v1.2`, python will try import `My Cool Mod v1`
# first, and fail when it doesn't exist. Try detect this to throw a better error.
# When this happens we're likely also double nested - `sdk_mods/My Cool Mod v1.2/MyCoolMod`
# - but we can't detect that as easily, and the problem's the same anyway
if "." in entry.name:
# Make WrappedArrays resolve the same as lists
from _pydevd_bundle.pydevd_resolver import ( # pyright: ignore[reportMissingImports]
tupleResolver, # pyright: ignore[reportUnknownVariableType]
)
from _pydevd_bundle.pydevd_xml import ( # pyright: ignore[reportMissingImports]
_TYPE_RESOLVE_HANDLER, # pyright: ignore[reportUnknownVariableType]
)
from unrealsdk.unreal import WrappedArray

if not _TYPE_RESOLVE_HANDLER._initialized: # pyright: ignore[reportUnknownMemberType]
_TYPE_RESOLVE_HANDLER._initialize() # pyright: ignore[reportUnknownMemberType]
_TYPE_RESOLVE_HANDLER._default_type_map.append( # pyright: ignore[reportUnknownMemberType]
(WrappedArray, tupleResolver),
)

except (ImportError, AttributeError):
pass


def validate_mod_folder(folder: Path) -> bool:
"""
Validates a mod folder, to check if it's something we should import.
Args:
folder: The folder to analyse.
Returns:
True if the file is a valid module to try import.
"""
if folder.name == "__pycache__":
return False

# A lot of people accidentally extract into double nested folders - they have a
# `sdk_mods/MyCoolMod/MyCoolMod/__init__.py` instead of a `sdk_mods/MyCoolMod/__init__.py`
# Usually this silently fails - we import `MyCoolMod` but there's nothing there
# Detect this and give a proper error message
if not (folder / "__init__.py").exists() and (folder / folder.name / "__init__.py").exists():
logging.error(
f"'{folder.name}' appears to be double nested, which may prevent it from being it from"
f" being loaded. Move the inner folder up a level.",
)
# Since it's a silent error, may as well continue in case it's actually what you wanted

# In the case we have a `sdk_mods/My Cool Mod v1.2`, python will try import `My Cool Mod v1`
# first, and fail when it doesn't exist. Try detect this to throw a better error.
# When this happens we're likely also double nested - `sdk_mods/My Cool Mod v1.2/MyCoolMod`
# - but we can't detect that as easily, and the problem's the same anyway
if "." in folder.name:
logging.error(
f"'{folder.name}' is not a valid python module - have you extracted the right folder?",
)
return False

return True


def validate_mod_file(file: Path) -> bool:
"""
Validates a mod file, to check if it's something we should import.
Args:
file: The file to analyse.
Returns:
True if the file is a valid .sdkmod to try import.
"""
match file.suffix.lower():
# Since hotfix mods can be any text file, this won't be exhaustive, but match and warn
# about what we can
# OHL often uses .url files to download the latest version of a mod, so also match that
case ".bl3hotfix" | ".wlhotfix" | ".url":
logging.error(
f"'{entry.name}' is not a valid python module - have you extracted the right"
f" folder?",
f"'{file.name}' appears to be a hotfix mod, not an SDK mod. Move it to your hotfix"
f" mods folder.",
)
continue
return False

mods_to_import.append(entry.name)
case ".sdkmod":
# Handled below
pass

elif entry.is_file():
if entry.name.startswith("."):
case _:
return False

valid_zip: bool
try:
zip_iter = zipfile.Path(file).iterdir()
zip_entry = next(zip_iter)
valid_zip = zip_entry.name == file.stem and next(zip_iter, None) is None
except (zipfile.BadZipFile, StopIteration, OSError):
valid_zip = False

if not valid_zip:
logging.error(
f"'{file.name}' does not appear to be valid, and has been ignored.",
)
logging.dev_warning(
"'.sdkmod' files must be a zip, and may only contain a single root folder, which must"
" be named the same as the zip (excluding suffix).",
)
return False

return True


def iter_mod_folders() -> Iterator[Path]:
"""
Iterates through all the mod folders to try search, adding them to sys.path along the way.
Yields:
Mod folder paths.
"""
yield Path(__file__).parent

extra_folders: list[Path]
try:
extra_folders = [Path(x) for x in json.loads(os.environ.get(EXTRA_FOLDERS_ENV_VAR, ""))]
except (json.JSONDecodeError, TypeError):
return

for folder in extra_folders:
if not folder.exists() or not folder.is_dir():
logging.dev_warning(f"Extra mod folder does not exist: {folder}")
continue
sys.path.append(str(folder.resolve()))

match entry.suffix.lower():
# Since hotfix mods can be any text file, this won't be exhaustive, but match and warn
# about what we can
# OHL often uses .url files to download the latest version of a mod, so also match that
case ".bl3hotfix" | ".wlhotfix" | ".url":
logging.error(
f"'{entry.name}' appears to be a hotfix mod, not an SDK mod. Move it to your"
f" hotfix mods folder.",
)
continue
yield folder


def get_mods_to_import() -> Collection[str]:
"""
Sets up sys.path and gathers all the mods to try import.
Returns:
A collection of the module names to import.
"""
mods_to_import: list[str] = []

case ".sdkmod":
# Handled below the match
pass
# We want all .sdkmods to appear at the end of sys.path, after all of the folders, so store them
# separately for now
dot_sdkmod_sys_path_entries: list[str] = []

case _:
for folder in iter_mod_folders():
for entry in folder.iterdir():
if entry.name.startswith("."):
continue

valid_zip: bool
if entry.is_dir() and validate_mod_folder(entry):
mods_to_import.append(entry.name)

elif entry.is_file() and validate_mod_file(entry):
dot_sdkmod_sys_path_entries.append(str(entry))
mods_to_import.append(entry.stem)

sys.path += dot_sdkmod_sys_path_entries

return mods_to_import


def import_mods() -> None:
"""Tries to import all mods."""
for name in get_mods_to_import():
try:
zip_iter = zipfile.Path(entry).iterdir()
zip_entry = next(zip_iter)
valid_zip = zip_entry.name == entry.stem and next(zip_iter, None) is None
except (zipfile.BadZipFile, StopIteration):
valid_zip = False
importlib.import_module(name)
except Exception as ex: # noqa: BLE001
logging.error(f"Failed to import mod '{name}'")

if not valid_zip:
logging.error(
f"'{entry.name}' does not appear to be valid, and has been ignored.",
)
logging.dev_warning(
"'.sdkmod' files must be a zip, and may only contain a single root folder, which"
" must be named the same as the zip (excluding suffix).",
)
continue
tb = traceback.extract_tb(ex.__traceback__)
if not FULL_TRACEBACKS:
tb = tb[-1:]

sys.path.append(str(entry))
mods_to_import.append(entry.stem)
logging.error("".join(traceback.format_exception_only(ex)))
logging.error("".join(traceback.format_list(tb)))

for name in mods_to_import:
try:
importlib.import_module(name)
except Exception as ex: # noqa: BLE001
logging.error(f"Failed to import mod '{name}'")

tb = traceback.extract_tb(ex.__traceback__)
if not _full_traceback:
tb = tb[-1:]
# Don't really want to put a `__name__` check here, since it's currently just `builtins`, and that
# seems a bit unstable, like something that pybind might eventually change

init_debugpy()

while not logging.is_console_ready():
pass

logging.error("".join(traceback.format_exception_only(ex)))
logging.error("".join(traceback.format_list(tb)))
import_mods()

0 comments on commit 0d1c76e

Please sign in to comment.