From f1924aa35736c54d47f62524cdc0792262422bfe Mon Sep 17 00:00:00 2001 From: Benedikt Reinartz Date: Mon, 25 Jul 2022 00:04:06 +0200 Subject: [PATCH] Refactoring to improve API and allow configless .NET Core loading - Drop `Runtime` wrapper in favour of an abstract base class - Use `pathlib` throughout and allow it as parameter - Expose a `shutdown` method (that is a noop in most cases, but at least it exists) - Add functions to find all installed .NET Core runtimes - If no runtime configuration is given, generate a simple one in a temporary directory --- clr_loader/__init__.py | 80 ++++++++++++++++++++++----- clr_loader/ffi/__init__.py | 30 +++++----- clr_loader/ffi/hostfxr.py | 1 + clr_loader/hostfxr.py | 86 ++++++++++++++++++++--------- clr_loader/mono.py | 89 ++++++++++++++++++------------ clr_loader/netfx.py | 33 ++++++++--- clr_loader/types.py | 97 +++++++++++++++++++++++++++++++++ clr_loader/util/__init__.py | 24 ++++++-- clr_loader/util/find.py | 92 +++++++++++++++++++++---------- clr_loader/util/runtime_spec.py | 31 +++++++++++ clr_loader/wrappers.py | 52 ------------------ pyproject.toml | 6 ++ tests/test_common.py | 24 +++++--- 13 files changed, 457 insertions(+), 188 deletions(-) create mode 100644 clr_loader/types.py create mode 100644 clr_loader/util/runtime_spec.py delete mode 100644 clr_loader/wrappers.py diff --git a/clr_loader/__init__.py b/clr_loader/__init__.py index 779b514..446c170 100644 --- a/clr_loader/__init__.py +++ b/clr_loader/__init__.py @@ -1,22 +1,38 @@ +from pathlib import Path +from tempfile import TemporaryDirectory from typing import Dict, Optional, Sequence -from .wrappers import Runtime -from .util.find import find_libmono, find_dotnet_root +from .types import Assembly, Runtime, RuntimeInfo +from .util import StrOrPath +from .util.find import find_dotnet_root, find_libmono, find_runtimes +from .util.runtime_spec import DotnetCoreRuntimeSpec -__all__ = ["get_mono", "get_netfx", "get_coreclr"] +__all__ = [ + "get_mono", + "get_netfx", + "get_coreclr", + "find_dotnet_root", + "find_libmono", + "find_runtimes", + "Runtime", + "Assembly", + "RuntimeInfo", +] def get_mono( + *, domain: Optional[str] = None, - config_file: Optional[str] = None, - global_config_file: Optional[str] = None, - libmono: Optional[str] = None, + config_file: Optional[StrOrPath] = None, + global_config_file: Optional[StrOrPath] = None, + libmono: Optional[StrOrPath] = None, sgen: bool = True, debug: bool = False, jit_options: Optional[Sequence[str]] = None, ) -> Runtime: from .mono import Mono + libmono = _maybe_path(libmono) if libmono is None: libmono = find_libmono(sgen) @@ -24,33 +40,67 @@ def get_mono( domain=domain, debug=debug, jit_options=jit_options, - config_file=config_file, - global_config_file=global_config_file, + config_file=_maybe_path(config_file), + global_config_file=_maybe_path(global_config_file), libmono=libmono, ) - return Runtime(impl) + return impl def get_coreclr( - runtime_config: str, - dotnet_root: Optional[str] = None, + *, + runtime_config: Optional[StrOrPath] = None, + dotnet_root: Optional[StrOrPath] = None, properties: Optional[Dict[str, str]] = None, + runtime_spec: Optional[DotnetCoreRuntimeSpec] = None, ) -> Runtime: from .hostfxr import DotnetCoreRuntime + dotnet_root = _maybe_path(dotnet_root) if dotnet_root is None: dotnet_root = find_dotnet_root() + temp_dir = None + runtime_config = _maybe_path(runtime_config) + if runtime_config is None: + if runtime_spec is None: + candidates = [ + rt for rt in find_runtimes() if rt.name == "Microsoft.NETCore.App" + ] + candidates.sort(key=lambda spec: spec.version, reverse=True) + if not candidates: + raise RuntimeError("Failed to find a suitable runtime") + + runtime_spec = candidates[0] + + temp_dir = TemporaryDirectory() + runtime_config = Path(temp_dir.name) / "runtimeconfig.json" + + with open(runtime_config, "w") as f: + runtime_spec.write_config(f) + impl = DotnetCoreRuntime(runtime_config=runtime_config, dotnet_root=dotnet_root) if properties: for key, value in properties.items(): impl[key] = value - return Runtime(impl) + if temp_dir: + temp_dir.cleanup() + return impl -def get_netfx(name: Optional[str] = None, config_file: Optional[str] = None) -> Runtime: + +def get_netfx( + *, name: Optional[str] = None, config_file: Optional[StrOrPath] = None +) -> Runtime: from .netfx import NetFx - impl = NetFx(name=name, config_file=config_file) - return Runtime(impl) + impl = NetFx(name=name, config_file=_maybe_path(config_file)) + return impl + + +def _maybe_path(p: Optional[StrOrPath]) -> Optional[Path]: + if p is None: + return None + else: + return Path(p) diff --git a/clr_loader/ffi/__init__.py b/clr_loader/ffi/__init__.py index 285fe6d..208824f 100644 --- a/clr_loader/ffi/__init__.py +++ b/clr_loader/ffi/__init__.py @@ -1,6 +1,5 @@ -import glob -import os import sys +from pathlib import Path from typing import Optional import cffi # type: ignore @@ -9,46 +8,51 @@ __all__ = ["ffi", "load_hostfxr", "load_mono", "load_netfx"] -ffi = cffi.FFI() +ffi = cffi.FFI() # type: ignore for cdef in hostfxr.cdef + mono.cdef + netfx.cdef: ffi.cdef(cdef) -def load_hostfxr(dotnet_root: str): +def load_hostfxr(dotnet_root: Path): hostfxr_name = _get_dll_name("hostfxr") - hostfxr_path = os.path.join(dotnet_root, "host", "fxr", "?.*", hostfxr_name) - for hostfxr_path in reversed(sorted(glob.glob(hostfxr_path))): + # This will fail as soon as .NET hits version 10, but hopefully by then + # we'll have a more robust way of finding the libhostfxr + hostfxr_path = dotnet_root / "host" / "fxr" + hostfxr_paths = hostfxr_path.glob(f"?.*/{hostfxr_name}") + + for hostfxr_path in reversed(sorted(hostfxr_paths)): try: - return ffi.dlopen(hostfxr_path) + return ffi.dlopen(str(hostfxr_path)) except Exception: pass raise RuntimeError(f"Could not find a suitable hostfxr library in {dotnet_root}") -def load_mono(path: Optional[str] = None): +def load_mono(path: Optional[Path] = None): # Preload C++ standard library, Mono needs that and doesn't properly link against it - if sys.platform.startswith("linux"): + if sys.platform == "linux": ffi.dlopen("stdc++", ffi.RTLD_GLOBAL) - return ffi.dlopen(path, ffi.RTLD_GLOBAL) + path_str = str(path) if path else None + return ffi.dlopen(path_str, ffi.RTLD_GLOBAL) def load_netfx(): if sys.platform != "win32": raise RuntimeError(".NET Framework is only supported on Windows") - dirname = os.path.join(os.path.dirname(__file__), "dlls") + dirname = Path(__file__).parent / "dlls" if sys.maxsize > 2**32: arch = "amd64" else: arch = "x86" - path = os.path.join(dirname, arch, "ClrLoader.dll") + path = dirname / arch / "ClrLoader.dll" - return ffi.dlopen(path) + return ffi.dlopen(str(path)) def _get_dll_name(name: str) -> str: diff --git a/clr_loader/ffi/hostfxr.py b/clr_loader/ffi/hostfxr.py index 9dc47ee..f5d74bc 100644 --- a/clr_loader/ffi/hostfxr.py +++ b/clr_loader/ffi/hostfxr.py @@ -2,6 +2,7 @@ import sys + cdef = [] if sys.platform == "win32": diff --git a/clr_loader/hostfxr.py b/clr_loader/hostfxr.py index 51ca26a..9171def 100644 --- a/clr_loader/hostfxr.py +++ b/clr_loader/hostfxr.py @@ -1,29 +1,48 @@ -import os import sys +from pathlib import Path +from typing import Generator, Tuple from .ffi import ffi, load_hostfxr -from .util import check_result, find_dotnet_root +from .types import Runtime, RuntimeInfo, StrOrPath +from .util import check_result __all__ = ["DotnetCoreRuntime"] +_IS_SHUTDOWN = False -class DotnetCoreRuntime: - def __init__(self, runtime_config: str, dotnet_root: str): - self._dotnet_root = dotnet_root or find_dotnet_root() + +class DotnetCoreRuntime(Runtime): + def __init__(self, runtime_config: Path, dotnet_root: Path, **params: str): + if _IS_SHUTDOWN: + raise RuntimeError("Runtime can not be reinitialized") + + self._dotnet_root = Path(dotnet_root) self._dll = load_hostfxr(self._dotnet_root) - self._is_finalized = False + self._is_initialized = False self._handle = _get_handle(self._dll, self._dotnet_root, runtime_config) self._load_func = _get_load_func(self._dll, self._handle) + for key, value in params.items(): + self[key] = value + + # TODO: Get version + self._version = "" + @property - def dotnet_root(self) -> str: + def dotnet_root(self) -> Path: return self._dotnet_root @property - def is_finalized(self) -> bool: - return self._is_finalized + def is_initialized(self) -> bool: + return self._is_initialized + + @property + def is_shutdown(self) -> bool: + return _IS_SHUTDOWN def __getitem__(self, key: str) -> str: + if self.is_shutdown: + raise RuntimeError("Runtime is shut down") buf = ffi.new("char_t**") res = self._dll.hostfxr_get_runtime_property_value( self._handle, encode(key), buf @@ -34,15 +53,17 @@ def __getitem__(self, key: str) -> str: return decode(buf[0]) def __setitem__(self, key: str, value: str) -> None: - if self.is_finalized: - raise RuntimeError("Already finalized") + if self.is_initialized: + raise RuntimeError("Already initialized") res = self._dll.hostfxr_set_runtime_property_value( self._handle, encode(key), encode(value) ) check_result(res) - def __iter__(self): + def __iter__(self) -> Generator[Tuple[str, str], None, None]: + if self.is_shutdown: + raise RuntimeError("Runtime is shut down") max_size = 100 size_ptr = ffi.new("size_t*") size_ptr[0] = max_size @@ -51,25 +72,26 @@ def __iter__(self): values_ptr = ffi.new("char_t*[]", max_size) res = self._dll.hostfxr_get_runtime_properties( - self._dll._handle, size_ptr, keys_ptr, values_ptr + self._handle, size_ptr, keys_ptr, values_ptr ) check_result(res) for i in range(size_ptr[0]): yield (decode(keys_ptr[i]), decode(values_ptr[i])) - def get_callable(self, assembly_path: str, typename: str, function: str): + def get_callable(self, assembly_path: StrOrPath, typename: str, function: str): # TODO: Maybe use coreclr_get_delegate as well, supported with newer API # versions of hostfxr - self._is_finalized = True + self._is_initialized = True # Append assembly name to typename - assembly_name, _ = os.path.splitext(os.path.basename(assembly_path)) + assembly_path = Path(assembly_path) + assembly_name = assembly_path.stem typename = f"{typename}, {assembly_name}" delegate_ptr = ffi.new("void**") res = self._load_func( - encode(assembly_path), + encode(str(assembly_path)), encode(typename), encode(function), ffi.NULL, @@ -79,27 +101,39 @@ def get_callable(self, assembly_path: str, typename: str, function: str): check_result(res) return ffi.cast("component_entry_point_fn", delegate_ptr[0]) + def _check_initialized(self) -> None: + if self._handle is None: + raise RuntimeError("Runtime is shut down") + elif not self._is_initialized: + raise RuntimeError("Runtime is not initialized") + def shutdown(self) -> None: if self._handle is not None: self._dll.hostfxr_close(self._handle) self._handle = None - def __del__(self): - self.shutdown() + def info(self): + return RuntimeInfo( + kind="CoreCLR", + version=self._version, + initialized=self._handle is not None, + shutdown=self._handle is None, + properties=dict(self) if not _IS_SHUTDOWN else {}, + ) -def _get_handle(dll, dotnet_root: str, runtime_config: str): +def _get_handle(dll, dotnet_root: StrOrPath, runtime_config: StrOrPath): params = ffi.new("hostfxr_initialize_parameters*") params.size = ffi.sizeof("hostfxr_initialize_parameters") # params.host_path = ffi.new("char_t[]", encode(sys.executable)) params.host_path = ffi.NULL - dotnet_root_p = ffi.new("char_t[]", encode(dotnet_root)) + dotnet_root_p = ffi.new("char_t[]", encode(str(Path(dotnet_root)))) params.dotnet_root = dotnet_root_p handle_ptr = ffi.new("hostfxr_handle*") res = dll.hostfxr_initialize_for_runtime_config( - encode(runtime_config), params, handle_ptr + encode(str(Path(runtime_config))), params, handle_ptr ) check_result(res) @@ -119,16 +153,16 @@ def _get_load_func(dll, handle): if sys.platform == "win32": - def encode(string): + def encode(string: str): return string - def decode(char_ptr): + def decode(char_ptr) -> str: return ffi.string(char_ptr) else: - def encode(string): + def encode(string: str): return string.encode("utf8") - def decode(char_ptr): + def decode(char_ptr) -> str: return ffi.string(char_ptr).decode("utf8") diff --git a/clr_loader/mono.py b/clr_loader/mono.py index d9279af..c9123ca 100644 --- a/clr_loader/mono.py +++ b/clr_loader/mono.py @@ -1,37 +1,40 @@ import atexit import re -from typing import Optional, Sequence, Dict, Any - -from .ffi import load_mono, ffi +from pathlib import Path +from typing import Any, Dict, Optional, Sequence +from .ffi import ffi, load_mono +from .types import Runtime, RuntimeInfo +from .util import optional_path_as_string, path_as_string __all__ = ["Mono"] -_MONO = None -_ROOT_DOMAIN = None +_MONO: Any = None +_ROOT_DOMAIN: Any = None -class Mono: +class Mono(Runtime): def __init__( self, - libmono, + libmono: Optional[Path], *, - domain=None, - debug=False, + domain: Optional[str] = None, + debug: bool = False, jit_options: Optional[Sequence[str]] = None, - config_file: Optional[str] = None, - global_config_file: Optional[str] = None, + config_file: Optional[Path] = None, + global_config_file: Optional[Path] = None, ): - self._assemblies: Dict[str, Any] = {} + self._assemblies: Dict[Path, Any] = {} - initialize( - config_file=config_file, + self._version = initialize( + config_file=optional_path_as_string(config_file), debug=debug, jit_options=jit_options, - global_config_file=global_config_file, + global_config_file=optional_path_as_string(global_config_file), libmono=libmono, ) + print(self._version) if domain is None: self._domain = _ROOT_DOMAIN @@ -39,10 +42,11 @@ def __init__( raise NotImplementedError def get_callable(self, assembly_path, typename, function): + assembly_path = Path(assembly_path) assembly = self._assemblies.get(assembly_path) if not assembly: assembly = _MONO.mono_domain_assembly_open( - self._domain, assembly_path.encode("utf8") + self._domain, path_as_string(assembly_path).encode("utf8") ) _check_result(assembly, f"Unable to load assembly {assembly_path}") self._assemblies[assembly_path] = assembly @@ -58,6 +62,20 @@ def get_callable(self, assembly_path, typename, function): return MonoMethod(method) + def info(self) -> RuntimeInfo: + return RuntimeInfo( + kind="Mono", + version=self._version, + initialized=True, + shutdown=_MONO is None, + properties={}, + ) + + def shutdown(self) -> None: + # We don't implement non-root-domains, yet. When we do, it needs to be + # released here. + pass + class MethodDesc: def __init__(self, typename, function): @@ -99,12 +117,12 @@ def __call__(self, ptr, size): def initialize( - libmono: str, + libmono: Optional[Path], debug: bool = False, jit_options: Optional[Sequence[str]] = None, config_file: Optional[str] = None, global_config_file: Optional[str] = None, -) -> None: +) -> str: global _MONO, _ROOT_DOMAIN if _MONO is None: _MONO = load_mono(libmono) @@ -133,28 +151,29 @@ def initialize( _MONO.mono_domain_set_config(_ROOT_DOMAIN, b".", config_encoded) _check_result(_ROOT_DOMAIN, "Failed to initialize Mono") - build = _MONO.mono_get_runtime_build_info() - _check_result(build, "Failed to get Mono version") - ver_str = ffi.string(build).decode("utf8") # e.g. '6.12.0.122 (tarball)' + build = _MONO.mono_get_runtime_build_info() + _check_result(build, "Failed to get Mono version") + ver_str = ffi.string(build).decode("utf8") # e.g. '6.12.0.122 (tarball)' - ver = re.match(r"^(?P\d+)\.(?P\d+)\.[\d.]+", ver_str) - if ver is not None: - major = int(ver.group("major")) - minor = int(ver.group("minor")) + ver = re.match(r"^(?P\d+)\.(?P\d+)\.[\d.]+", ver_str) + if ver is not None: + major = int(ver.group("major")) + minor = int(ver.group("minor")) - if major < 6 or (major == 6 and minor < 12): - import warnings + if major < 6 or (major == 6 and minor < 12): + import warnings - warnings.warn( - "Hosting Mono versions before v6.12 is known to be problematic. " - "If the process crashes shortly after you see this message, try " - "updating Mono to at least v6.12." - ) + warnings.warn( + "Hosting Mono versions before v6.12 is known to be problematic. " + "If the process crashes shortly after you see this message, try " + "updating Mono to at least v6.12." + ) - atexit.register(_release) + atexit.register(_release) + return ver_str -def _release(): +def _release() -> None: global _MONO, _ROOT_DOMAIN if _ROOT_DOMAIN is not None and _MONO is not None: _MONO.mono_jit_cleanup(_ROOT_DOMAIN) @@ -162,6 +181,6 @@ def _release(): _ROOT_DOMAIN = None -def _check_result(res, msg): +def _check_result(res: Any, msg: str) -> None: if res == ffi.NULL or not res: raise RuntimeError(msg) diff --git a/clr_loader/netfx.py b/clr_loader/netfx.py index 00323f5..9f18a16 100644 --- a/clr_loader/netfx.py +++ b/clr_loader/netfx.py @@ -1,28 +1,45 @@ import atexit -from typing import Optional, Any +from pathlib import Path +from typing import Any, Optional + from .ffi import ffi, load_netfx +from .types import Runtime, RuntimeInfo, StrOrPath _FW: Any = None -class NetFx: - def __init__(self, name: Optional[str] = None, config_file: Optional[str] = None): +class NetFx(Runtime): + def __init__(self, name: Optional[str] = None, config_file: Optional[Path] = None): initialize() - self._domain = _FW.pyclr_create_appdomain( - name or ffi.NULL, config_file or ffi.NULL + if config_file is not None: + config_file_s = str(config_file) + else: + config_file_s = ffi.NULL + + self._name = name + self._config_file = config_file + self._domain = _FW.pyclr_create_appdomain(name or ffi.NULL, config_file_s) + + def info(self) -> RuntimeInfo: + return RuntimeInfo( + kind=".NET Framework", + version="", + initialized=True, + shutdown=_FW is None, + properties={}, ) - def get_callable(self, assembly_path: str, typename: str, function: str): + def get_callable(self, assembly_path: StrOrPath, typename: str, function: str): func = _FW.pyclr_get_function( self._domain, - assembly_path.encode("utf8"), + str(Path(assembly_path)).encode("utf8"), typename.encode("utf8"), function.encode("utf8"), ) return func - def __del__(self): + def shutdown(self): if self._domain and _FW: _FW.pyclr_close_appdomain(self._domain) diff --git a/clr_loader/types.py b/clr_loader/types.py new file mode 100644 index 0000000..85c234d --- /dev/null +++ b/clr_loader/types.py @@ -0,0 +1,97 @@ +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass, field +from os import PathLike +from typing import Any, Callable, Dict, Optional, Union + +__all__ = ["StrOrPath"] + +StrOrPath = Union[str, PathLike] + + +@dataclass +class RuntimeInfo: + kind: str + version: str + initialized: bool + shutdown: bool + properties: Dict[str, str] = field(repr=False) + + def __str__(self) -> str: + return ( + f"Runtime: {self.kind}\n" + "=============\n" + f" Version: {self.version}\n" + f" Initialized: {self.initialized}\n" + f" Shut down: {self.shutdown}\n" + f" Properties:\n" + + "\n".join( + f" {key} = {_truncate(value, 65 - len(key))}" + for key, value in self.properties.items() + ) + ) + + +class ClrFunction: + def __init__( + self, runtime: "Runtime", assembly: StrOrPath, typename: str, func_name: str + ): + self._assembly = assembly + self._class = typename + self._name = func_name + + self._callable = runtime.get_callable(assembly, typename, func_name) + + def __call__(self, buffer: bytes) -> int: + from .ffi import ffi + + buf_arr = ffi.from_buffer("char[]", buffer) + return self._callable(ffi.cast("void*", buf_arr), len(buf_arr)) + + def __repr__(self) -> str: + return f"" + + +class Assembly: + def __init__(self, runtime: "Runtime", path: StrOrPath): + self._runtime = runtime + self._path = path + + def get_function(self, name: str, func: Optional[str] = None) -> ClrFunction: + if func is None: + name, func = name.rsplit(".", 1) + + return ClrFunction(self._runtime, self._path, name, func) + + def __repr__(self) -> str: + return f"" + + +class Runtime(metaclass=ABCMeta): + @abstractmethod + def info(self) -> RuntimeInfo: + pass + + def get_assembly(self, assembly_path: StrOrPath) -> Assembly: + return Assembly(self, assembly_path) + + @abstractmethod + def get_callable( + self, assembly_path: StrOrPath, typename: str, function: str + ) -> Callable[[Any, int], Any]: + pass + + @abstractmethod + def shutdown(self) -> None: + pass + + def __del__(self) -> None: + self.shutdown() + + +def _truncate(string: str, length: int) -> str: + if length <= 1: + raise TypeError("length must be > 1") + if len(string) > length - 1: + return f"{string[:length-1]}…" + else: + return string diff --git a/clr_loader/util/__init__.py b/clr_loader/util/__init__.py index 1498039..b1a3f16 100644 --- a/clr_loader/util/__init__.py +++ b/clr_loader/util/__init__.py @@ -1,9 +1,28 @@ +from pathlib import Path +from typing import Optional + +from ..types import StrOrPath from .clr_error import ClrError from .coreclr_errors import get_coreclr_error from .find import find_dotnet_root from .hostfxr_errors import get_hostfxr_error -__all__ = ["check_result", "find_dotnet_root"] +__all__ = [ + "check_result", + "find_dotnet_root", + "path_as_string", + "optional_path_as_string", +] + + +def optional_path_as_string(path: Optional[StrOrPath]) -> Optional[str]: + if path is None: + return None + return path_as_string(path) + + +def path_as_string(path: StrOrPath) -> str: + return str(Path(path)) def check_result(err_code: int) -> None: @@ -15,12 +34,9 @@ def check_result(err_code: int) -> None: if err_code < 0: hresult = err_code & 0xFFFF_FFFF - error = get_coreclr_error(hresult) if not error: error = get_hostfxr_error(hresult) - if not error: error = ClrError(hresult) - raise error diff --git a/clr_loader/util/find.py b/clr_loader/util/find.py index b090fa4..8114df8 100644 --- a/clr_loader/util/find.py +++ b/clr_loader/util/find.py @@ -2,48 +2,82 @@ import os.path import shutil import sys +from pathlib import Path +from typing import Iterator, Optional +from .runtime_spec import DotnetCoreRuntimeSpec -def find_dotnet_root() -> str: + +def find_dotnet_cli() -> Optional[Path]: + dotnet_path = shutil.which("dotnet") + if not dotnet_path: + return None + else: + return Path(dotnet_path) + + +def find_dotnet_root() -> Path: dotnet_root = os.environ.get("DOTNET_ROOT", None) if dotnet_root is not None: - return dotnet_root + return Path(dotnet_root) if sys.platform == "win32": # On Windows, the host library is stored separately from dotnet.exe for x86 prog_files = os.environ.get("ProgramFiles") - dotnet_root = os.path.join(prog_files, "dotnet") + if not prog_files: + raise RuntimeError("Could not find ProgramFiles") + prog_files = Path(prog_files) + dotnet_root = prog_files / "dotnet" elif sys.platform == "darwin": - dotnet_root = "/usr/local/share/dotnet" + dotnet_root = Path("/usr/local/share/dotnet") - if dotnet_root is not None and os.path.isdir(dotnet_root): + if dotnet_root is not None and dotnet_root.is_dir(): return dotnet_root # Try to discover dotnet from PATH otherwise - dotnet_path = shutil.which("dotnet") - if not dotnet_path: + dotnet_cli = find_dotnet_cli() + if not dotnet_cli: raise RuntimeError("Can not determine dotnet root") - try: - # Pypy does not provide os.readlink right now - if hasattr(os, "readlink"): - dotnet_tmp_path = os.readlink(dotnet_path) - else: - dotnet_tmp_path = dotnet_path + return dotnet_cli.resolve().parent + + +def find_runtimes_using_cli(dotnet_cli: Path) -> Iterator[DotnetCoreRuntimeSpec]: + import re + from subprocess import check_output + + out = check_output([str(dotnet_cli), "--list-runtimes"], encoding="UTF8") + runtime_re = re.compile(r"(?P\S+) (?P\S+) \[(?P[^\]]+)\]") + + for line in out.splitlines(): + m = re.match(runtime_re, line) + if m: + path = Path(m.group("path")) + version = m.group("version") + if path.is_dir(): + yield DotnetCoreRuntimeSpec(m.group("name"), version, path / version) - if os.path.isabs(dotnet_tmp_path): - dotnet_path = dotnet_tmp_path - else: - dotnet_path = os.path.abspath( - os.path.join(os.path.dirname(dotnet_path), dotnet_tmp_path) - ) - except OSError: - pass - return os.path.dirname(dotnet_path) +def find_runtimes_in_root(dotnet_root: Path) -> Iterator[DotnetCoreRuntimeSpec]: + shared = dotnet_root / "shared" + for runtime in shared.iterdir(): + if runtime.is_dir(): + name = runtime.name + for version_path in runtime.iterdir(): + if version_path.is_dir(): + yield DotnetCoreRuntimeSpec(name, version_path.name, version_path) -def find_libmono(sgen: bool = True) -> str: +def find_runtimes() -> Iterator[DotnetCoreRuntimeSpec]: + dotnet_cli = find_dotnet_cli() + if dotnet_cli is not None: + return find_runtimes_using_cli(dotnet_cli) + else: + dotnet_root = find_dotnet_root() + return find_runtimes_in_root(dotnet_root) + + +def find_libmono(sgen: bool = True) -> Path: unix_name = f"mono{'sgen' if sgen else ''}-2.0" if sys.platform == "win32": if sys.maxsize > 2**32: @@ -51,14 +85,16 @@ def find_libmono(sgen: bool = True) -> str: else: prog_files = os.environ.get("ProgramFiles(x86)") + if prog_files is None: + raise RuntimeError("Could not determine Program Files location") + # Ignore sgen on Windows, the main installation only contains this DLL - path = rf"{prog_files}\Mono\bin\mono-2.0-sgen.dll" + path = Path(prog_files) / "Mono/bin/mono-2.0-sgen.dll" elif sys.platform == "darwin": path = ( - "/Library/Frameworks/Mono.framework/Versions/" - "Current" - f"/lib/lib{unix_name}.dylib" + Path("/Library/Frameworks/Mono.framework/Versions/Current/lib") + / f"lib{unix_name}.dylib" ) else: @@ -69,4 +105,4 @@ def find_libmono(sgen: bool = True) -> str: if path is None: raise RuntimeError("Could not find libmono") - return path + return Path(path) diff --git a/clr_loader/util/runtime_spec.py b/clr_loader/util/runtime_spec.py new file mode 100644 index 0000000..e874d1e --- /dev/null +++ b/clr_loader/util/runtime_spec.py @@ -0,0 +1,31 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, TextIO + + +@dataclass +class DotnetCoreRuntimeSpec: + name: str + version: str + path: Path + + @property + def tfm(self) -> str: + return f"net{self.version[:3]}" + + @property + def floor_version(self) -> str: + return f"{self.version[:3]}.0" + + @property + def runtime_config(self) -> Dict[str, Any]: + return { + "runtimeOptions": { + "tfm": self.tfm, + "framework": {"name": self.name, "version": self.floor_version}, + } + } + + def write_config(self, f: TextIO) -> None: + json.dump(self.runtime_config, f) diff --git a/clr_loader/wrappers.py b/clr_loader/wrappers.py deleted file mode 100644 index e8f2a89..0000000 --- a/clr_loader/wrappers.py +++ /dev/null @@ -1,52 +0,0 @@ -from os.path import basename -from typing import Any, Optional -from .ffi import ffi - -RuntimeImpl = Any - - -class ClrFunction: - def __init__( - self, runtime: RuntimeImpl, assembly: str, typename: str, func_name: str - ): - self._assembly = assembly - self._class = typename - self._name = func_name - - self._callable = runtime.get_callable(assembly, typename, func_name) - - def __call__(self, buffer: bytes) -> int: - buf_arr = ffi.from_buffer("char[]", buffer) - return self._callable(ffi.cast("void*", buf_arr), len(buf_arr)) - - def __repr__(self) -> str: - return f"" - - -class Assembly: - def __init__(self, runtime: RuntimeImpl, path: str): - self._runtime = runtime - self._path = path - - def get_function(self, name: str, func: Optional[str] = None) -> ClrFunction: - if func is None: - name, func = name.rsplit(".", 1) - - return ClrFunction(self._runtime, self._path, name, func) - - def __getitem__(self, name: str) -> ClrFunction: - return self.get_function(name) - - def __repr__(self) -> str: - return f"" - - -class Runtime: - def __init__(self, impl: RuntimeImpl): - self._impl = impl - - def get_assembly(self, path: str) -> Assembly: - return Assembly(self._impl, path) - - def __getitem__(self, path: str) -> Assembly: - return self.get_assembly(path) diff --git a/pyproject.toml b/pyproject.toml index 5a66918..002ef1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,3 +41,9 @@ xfail_strict = true testpaths = [ "tests" ] + +[tool.mypy] +allow-redefinition = true + +[tool.pyright] +pythonPlatform = "All" diff --git a/tests/test_common.py b/tests/test_common.py index 1822b6d..1191102 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,6 +2,7 @@ from subprocess import check_call import os import sys +from pathlib import Path @pytest.fixture(scope="session") @@ -15,10 +16,10 @@ def example_netcore(tmpdir_factory): def build_example(tmpdir_factory, framework): - out = str(tmpdir_factory.mktemp(f"example-{framework}")) - proj_path = os.path.join(os.path.dirname(__file__), "../example") + out = Path(tmpdir_factory.mktemp(f"example-{framework}")) + proj_path = Path(__file__).parent.parent / "example" - check_call(["dotnet", "build", proj_path, "-o", out, "-f", framework]) + check_call(["dotnet", "build", str(proj_path), "-o", str(out), "-f", framework]) return out @@ -27,7 +28,7 @@ def test_mono(example_netstandard): from clr_loader import get_mono mono = get_mono() - asm = mono.get_assembly(os.path.join(example_netstandard, "example.dll")) + asm = mono.get_assembly(example_netstandard / "example.dll") run_tests(asm) @@ -41,7 +42,7 @@ def test_mono_debug(example_netstandard): "--debugger-agent=address=0.0.0.0:5831,transport=dt_socket,server=y" ], ) - asm = mono.get_assembly(os.path.join(example_netstandard, "example.dll")) + asm = mono.get_assembly(example_netstandard / "example.dll") run_tests(asm) @@ -49,8 +50,17 @@ def test_mono_debug(example_netstandard): def test_coreclr(example_netcore): from clr_loader import get_coreclr - coreclr = get_coreclr(os.path.join(example_netcore, "example.runtimeconfig.json")) - asm = coreclr.get_assembly(os.path.join(example_netcore, "example.dll")) + coreclr = get_coreclr(runtime_config=example_netcore / "example.runtimeconfig.json") + asm = coreclr.get_assembly(example_netcore / "example.dll") + + run_tests(asm) + + +def test_coreclr_autogenerated_runtimeconfig(example_netstandard): + from clr_loader import get_coreclr + + coreclr = get_coreclr() + asm = coreclr.get_assembly(example_netstandard / "example.dll") run_tests(asm)