diff --git a/.github/workflows/ci-arm.yml b/.github/workflows/ci-arm.yml new file mode 100644 index 0000000..0c308bd --- /dev/null +++ b/.github/workflows/ci-arm.yml @@ -0,0 +1,42 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: ARM64 Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: [self-hosted, linux, ARM64] + + steps: + - uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: | + 3.1.x + 6.0.x + + - name: Create virtualenv + run: | + virtualenv -p python3 venv + + - name: Install dependencies + run: | + source venv/bin/activate + python -m pip install --upgrade pip + pip install pytest cffi + + # Assumes recent Mono + + - name: Build + run: | + source venv/bin/activate + pip install -e . + + - name: Test with pytest + run: | + source venv/bin/activate + pytest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7627b1e..55f5d65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,16 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python: ['3.9', '3.8', '3.7', '3.6', pypy3] + python: ['3.10', '3.9', '3.8', '3.7'] # pypy3 steps: - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: '6.0.x' + - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v2 with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ed89bc2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,33 @@ +name: Documentation + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: ammaraskar/sphinx-action@master + with: + docs-folder: doc/ + - name: Upload artifact + # Automatically uploads an artifact from the './_site' directory by default + uses: actions/upload-pages-artifact@v1 + with: + path: doc/_build/html/ + + deploy: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 diff --git a/LICENSE b/LICENSE index 33e799f..b8989d3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Benedikt Reinartz +Copyright (c) 2019-2022 Benedikt Reinartz Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b8c47d5..e9bc6d5 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ [![CI](https://github.com/pythonnet/clr-loader/workflows/Python%20Tests/badge.svg)](https://github.com/pythonnet/clr-loader/actions) [![Pypi](https://img.shields.io/pypi/v/clr-loader.svg)](https://pypi.org/project/clr-loader/) +[![Conda Version](https://img.shields.io/conda/vn/conda-forge/clr_loader.svg)](https://anaconda.org/conda-forge/clr_loader) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Implements a generic interface for loading one of the CLR (.NET) runtime implementations and calling simple functions on them. +Implements a generic interface for loading one of the CLR (.NET) runtime +implementations and calling simple functions on them. + +Documentation is available at https://pythonnet.github.io/clr-loader/. diff --git a/clr_loader/__init__.py b/clr_loader/__init__.py index 779b514..d0d8ff2 100644 --- a/clr_loader/__init__.py +++ b/clr_loader/__init__.py @@ -1,56 +1,156 @@ +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", + "DotnetCoreRuntimeSpec", +] def get_mono( - domain: Optional[str] = None, - config_file: Optional[str] = None, - global_config_file: Optional[str] = None, - libmono: Optional[str] = None, + *, + # domain: 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: + """Get a Mono runtime instance + + :param config_file: + Path to the domain configuration file + :param global_config_file: + Path to the global configuration file to load (defaults to, e.g., + ``/etc/mono/config``) + :param libmono: + Path to the Mono runtime dll/so/dylib. If this is not specified, we try + to discover a globally installed instance using :py:func:`find_libmono` + :param sgen: + If ``libmono`` is not specified, this is passed to + :py:func:`find_libmono` + :param debug: + Whether to initialise Mono debugging + :param jit_options: + "Command line options" passed to Mono's ``mono_jit_parse_options`` + """ from .mono import Mono + libmono = _maybe_path(libmono) if libmono is None: - libmono = find_libmono(sgen) + libmono = find_libmono(sgen=sgen) impl = Mono( - domain=domain, + # 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: + """Get a CoreCLR (.NET Core) runtime instance + + The returned ``DotnetCoreRuntime`` also acts as a mapping of the config + properties. They can be retrieved using the index operator and can be + written until the runtime is initialized. The runtime is initialized when + the first function object is retrieved. + + :param runtime_config: + Pass to a ``runtimeconfig.json`` as generated by + ``dotnet publish``. If this parameter is not given, a temporary runtime + config will be generated. + :param dotnet_root: + The root directory of the .NET Core installation. If this is not + specified, we try to discover it using :py:func:`find_dotnet_root`. + :param properties: + Additional runtime properties. These can also be passed using the + ``configProperties`` section in the runtime config. + :param runtime_spec: + If the ``runtime_config`` is not specified, the concrete runtime to use + can be controlled by passing this parameter. Possible values can be + retrieved using :py:func:`find_runtimes`.""" 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( + *, domain: Optional[str] = None, config_file: Optional[StrOrPath] = None +) -> Runtime: + """Get a .NET Framework runtime instance -def get_netfx(name: Optional[str] = None, config_file: Optional[str] = None) -> Runtime: + :param domain: + Name of the domain to create. If no value is passed, assemblies will be + loaded into the root domain. + :param config_file: + Configuration file to use to initialize the ``AppDomain``. This will + only be used for non-root-domains as we can not control the + configuration of the implicitly loaded root domain. + """ from .netfx import NetFx - impl = NetFx(name=name, config_file=config_file) - return Runtime(impl) + impl = NetFx(domain=domain, 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 114803c..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") - if sys.maxsize > 2 ** 32: + 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/ffi/mono.py b/clr_loader/ffi/mono.py index 25a855a..ed48cff 100644 --- a/clr_loader/ffi/mono.py +++ b/clr_loader/ffi/mono.py @@ -18,6 +18,8 @@ MONO_DEBUG_FORMAT_DEBUGGER } MonoDebugFormat; +char* mono_get_runtime_build_info (void); + MonoDomain* mono_jit_init(const char *root_domain_name); void mono_jit_cleanup(MonoDomain *domain); void mono_jit_parse_options(int argc, char * argv[]); diff --git a/clr_loader/hostfxr.py b/clr_loader/hostfxr.py index fed2f77..dce5c82 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,17 +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 e4fcb44..fb8ab1b 100644 --- a/clr_loader/mono.py +++ b/clr_loader/mono.py @@ -1,34 +1,37 @@ import atexit -from typing import Optional, Sequence - -from .ffi import load_mono, ffi +import re +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 = {} + 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, ) @@ -37,11 +40,12 @@ def __init__( else: raise NotImplementedError - def get_callable(self, assembly_path, typename, function): + 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 @@ -57,6 +61,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): @@ -98,12 +116,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) @@ -122,6 +140,8 @@ def initialize( if jit_options: options = [ffi.new("char[]", o.encode("utf8")) for o in jit_options] _MONO.mono_jit_parse_options(len(options), options) + else: + options = [] if debug: _MONO.mono_debug_init(_MONO.MONO_DEBUG_FORMAT_MONO) @@ -129,10 +149,30 @@ def initialize( _ROOT_DOMAIN = _MONO.mono_jit_init(b"clr_loader") _MONO.mono_domain_set_config(_ROOT_DOMAIN, b".", config_encoded) _check_result(_ROOT_DOMAIN, "Failed to initialize Mono") - atexit.register(_release) + + 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")) + + 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." + ) + + 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) @@ -140,6 +180,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 6b6c003..24460ca 100644 --- a/clr_loader/netfx.py +++ b/clr_loader/netfx.py @@ -1,28 +1,49 @@ 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: Optional[Any] = None +_FW: Any = None -class NetFx: - def __init__(self, name: Optional[str] = None, config_file: Optional[str] = None): +class NetFx(Runtime): + def __init__( + self, domain: 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._domain_name = domain + self._config_file = config_file + self._domain = _FW.pyclr_create_appdomain(domain or ffi.NULL, config_file_s) + + def info(self) -> RuntimeInfo: + return RuntimeInfo( + kind=".NET Framework", + version="", + initialized=True, + shutdown=_FW is None, + properties=dict( + domain=self._domain_name or "", config_file=str(self._config_file) + ), ) - 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..15c1e30 --- /dev/null +++ b/clr_loader/types.py @@ -0,0 +1,146 @@ +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: + """Information on a Runtime instance + + An informative text can be retrieved from this by converting it to a + ``str``, in particular the following results in readable debug information: + + >>> ri = RuntimeInfo() + >>> print(ri) + 6.12.0.122 (tarball) + Runtime: Mono + ============= + Version: 6.12.0.122 (tarball) + Initialized: True + Shut down: False + Properties: + """ + + 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: + """Get a wrapped .NET function instance + + The function must be ``static``, and it must have the signature + ``int Func(IntPtr ptr, int size)``. The returned wrapped instance will + take a ``binary`` and call the .NET function with a pointer to that + buffer and the buffer length. The buffer is reflected using CFFI's + `from_buffer`. + + :param name: If ``func`` is not given, this is the fully qualified name + of the function. If ``func`` is given, this is the fully + qualified name of the containing class + :param func: Name of the function + :return: A function object that takes a single ``binary`` parameter + and returns an ``int`` + """ + 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): + """CLR Runtime + + Encapsulates the lifetime of a CLR (.NET) runtime. If the instance is + deleted, the runtime will be shut down. + """ + + @abstractmethod + def info(self) -> RuntimeInfo: + """Get configuration and version information""" + pass + + def get_assembly(self, assembly_path: StrOrPath) -> Assembly: + """Get an assembly wrapper + + This function does not guarantee that the respective assembly is or can + be loaded. Due to the design of the different hosting APIs, loading only + happens when the first function is referenced, and only then potential + errors will be raised.""" + return Assembly(self, assembly_path) + + @abstractmethod + def _get_callable( + self, assembly_path: StrOrPath, typename: str, function: str + ) -> Callable[[Any, int], Any]: + """Private function to retrieve a low-level callable object""" + pass + + @abstractmethod + def shutdown(self) -> None: + """Shut down the runtime as much as possible + + Implementations should still be able to "reinitialize", thus the final + cleanup will usually happen in an ``atexit`` handler.""" + 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 a38600e..daad769 100644 --- a/clr_loader/util/find.py +++ b/clr_loader/util/find.py @@ -2,60 +2,134 @@ import os.path import shutil import sys +from pathlib import Path +from typing import Iterator, Optional +from .runtime_spec import DotnetCoreRuntimeSpec + + +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: + """Try to discover the .NET Core root directory + + If the environment variable ``DOTNET_ROOT`` is defined, we will use that. + Otherwise, we probe the default installation paths on Windows and macOS. + + If none of these lead to a result, we try to discover the ``dotnet`` CLI + tool and use its (real) parent directory. + + Otherwise, this function raises an exception. + + :return: Path to the .NET Core root + """ -def find_dotnet_root() -> str: 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 os.path.isdir(dotnet_root): - return dotnet_root + 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 = Path("/usr/local/share/dotnet") + + 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 - 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_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) + + +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_runtimes() -> Iterator[DotnetCoreRuntimeSpec]: + """Find installed .NET Core runtimes -def find_libmono(sgen: bool = True) -> str: + If the ``dotnet`` CLI can be found, we will call it as ``dotnet + --list-runtimes`` and parse the result. + + If it is not available, we try to discover the dotnet root directory using + :py:func:`find_dotnet_root` and enumerate the runtimes installed in the + ``shared`` subdirectory. + + :return: Iterable of :py:class:`DotnetCoreRuntimeSpec` objects + """ + 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: + """Find a suitable libmono dynamic library + + On Windows and macOS, we check the default installation directories. + + :param sgen: + Whether to look for an SGen or Boehm GC instance. This parameter is + ignored on Windows, as only ``sgen`` is installed with the default + installer + :return: + Path to usable ``libmono`` + """ unix_name = f"mono{'sgen' if sgen else ''}-2.0" if sys.platform == "win32": - if sys.maxsize > 2 ** 32: + if sys.maxsize > 2**32: prog_files = os.environ.get("ProgramFiles") 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 = fr"{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: @@ -66,4 +140,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..2eaeb68 --- /dev/null +++ b/clr_loader/util/runtime_spec.py @@ -0,0 +1,33 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, TextIO + + +@dataclass +class DotnetCoreRuntimeSpec: + """Specification of an installed .NET Core runtime""" + + 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/doc/.gitignore b/doc/.gitignore new file mode 100644 index 0000000..69fa449 --- /dev/null +++ b/doc/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..5f97ad9 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,53 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = "clr-loader" +copyright = "2022, Benedikt Reinartz" +author = "Benedikt Reinartz" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc"] + +# autodoc_typehints = "both" + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..f7331e3 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,36 @@ +.. clr-loader documentation master file, created by + sphinx-quickstart on Fri Sep 16 17:57:02 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to clr-loader's documentation! +====================================== + +`clr_loader` provides a unified way to load one of the CLR (.NET) runtime +implementations (.NET Framework, .NET (Core) or Mono), load assemblies, and call +very simple functions. + +The only supported signature is + +.. code-block:: csharp + + public static int Function(IntPtr buffer, int size) + +A function like this can be called from Python with a single ``bytes`` +parameter. If more functionality is required, please consider using `Python.NET +`_ instead. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + usage + reference + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/doc/reference.rst b/doc/reference.rst new file mode 100644 index 0000000..d1922a6 --- /dev/null +++ b/doc/reference.rst @@ -0,0 +1,35 @@ +.. _reference: + +Reference +========= + +Factory functions +----------------- + +.. py:module:: clr_loader + +.. autofunction:: get_mono +.. autofunction:: get_coreclr +.. autofunction:: get_netfx + +Wrapper types +------------- + +.. autoclass:: Runtime + :members: + +.. autoclass:: Assembly + :members: + +Utilities +--------- + +.. autoclass:: RuntimeInfo + :members: + +.. autoclass:: DotnetCoreRuntimeSpec + :members: + +.. autofunction:: find_dotnet_root +.. autofunction:: find_libmono +.. autofunction:: find_runtimes diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..483a4e9 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1 @@ +sphinx_rtd_theme diff --git a/doc/usage.rst b/doc/usage.rst new file mode 100644 index 0000000..2c10f62 --- /dev/null +++ b/doc/usage.rst @@ -0,0 +1,99 @@ +Usage +===== + +Getting a runtime +----------------- + +To get a :py:class:`Runtime` instance, one of the ``get_*`` functions has to be +called. There are currently the factory functions :py:func:`get_mono`, +:py:func:`get_coreclr` and :py:func:`get_netfx`. All of these provide various +configuration options that are documented in the :ref:`Reference `. +They also provide reasonable defaults and can be called without parameters if +the respective runtime is installed globally: + +.. code-block:: python + + from clr_loader import get_coreclr + runtime = get_coreclr() + +After this, the runtime will usually already be initialized. The initialization +is delayed for .NET Core to allow adjusting the runtime properties beforehand. + +Information on the runtime, its version and parameters can be retrieved using +``runtime.info()`` (see :py:func:`Runtime.info`). + +Getting a callable function +--------------------------- + +A wrapped assembly can be retrieved from the runtime by calling +:py:func:`Runtime.get_assembly` with the path. + +The following example class is provided in the repository: + +.. code-block:: csharp + + using System.Text; + using System.Runtime.InteropServices; + using System; + + namespace Example + { + public class TestClass + { + public static int Test(IntPtr arg, int size) { + var buf = new byte[size]; + Marshal.Copy(arg, buf, 0, size); + var bufAsString = Encoding.UTF8.GetString(buf); + var result = bufAsString.Length; + Console.WriteLine($"Called {nameof(Test)} in {nameof(TestClass)} with {bufAsString}, returning {result}"); + Console.WriteLine($"Binary data: {Convert.ToBase64String(buf)}"); + + return result; + } + } + } + +Assuming it has been compiled to ``out/example.dll``, it can now be loaded using +:py:func:`Runtime.get_assembly`: + +.. code-block:: python + + assembly = runtime.get_assembly("path/to/assembly.dll") + +.. note:: + This does *not* guarantee that the DLL is already loaded and will not + necessarily trigger an error if that is not possible. Actually resolving the + DLL only happens (for all implementations but Mono) when retrieving the + concrete function. + +The ``assembly`` instance can now be used to get a wrapper instance of the +``Test`` function in Python. The given parameters are the fully qualified class +name and the function name. Alternatively, a single parameter can be provided, +and we assume that the last "component" is the function name. These are +equivalent: + +.. code-block:: python + + function = assembly.get_function("Example.TestClass", "Test") + function = assembly.get_function("Example.TestClass.Test") + +This function can now be called with a Python ``binary`` like this: + +.. code-block:: python + + result = function(b"testy mctestface") + +The ``IntPtr`` parameter in C# will now point directly at the ``binary`` buffer, +the ``int`` parameter will contain the size. The given call will thus result in +the output: + +.. code-block:: output + + Called Test in TestClass with testy mctestface, returning 16 + Binary data: dGVzdHkgbWN0ZXN0ZmFjZQ== + +``result`` will now be ``16``. + +.. warning:: + While the buffer can theoretically also be changed in the .NET function, this + is not tested. diff --git a/example/example.csproj b/example/example.csproj index f30cfb7..fd6d566 100644 --- a/example/example.csproj +++ b/example/example.csproj @@ -1,6 +1,6 @@  - netcoreapp31;netstandard20 + net60;netstandard20 true diff --git a/pyproject.toml b/pyproject.toml index 9787c3b..d344968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,50 @@ [build-system] -requires = ["setuptools", "wheel"] +requires = ["setuptools>=61", "wheel"] build-backend = "setuptools.build_meta" + +[project] +name = "clr_loader" +description = "Generic pure Python loader for .NET runtimes" +license = {text = "MIT"} +version = "0.2.3" + +readme = "README.md" + +dependencies = ["cffi>=1.13"] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", +] + +[[project.authors]] +name = "Benedikt Reinartz" +email = "filmor@gmail.com" + +[project.urls] +Sources = "https://github.com/pythonnet/clr-loader" +Documentation = "https://pythonnet.github.io/clr-loader/" + +[tool.setuptools] +zip-safe = false +package-data = {"clr_loader.ffi" = ["dlls/x86/*.dll", "dlls/amd64/*.dll"]} + +[tool.setuptools.packages.find] +include = ["clr_loader*"] + +[tool.pytest.ini_options] +xfail_strict = true +testpaths = [ + "tests" +] + +[tool.mypy] +allow-redefinition = true + +[tool.pyright] +pythonPlatform = "All" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 93a1104..0000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[metadata] -license_file = LICENSE - -[flake8] -# Recommend matching the black line length (default 88), -# rather than using the flake8 default of 79: -max-line-length = 88 -extend-ignore = - # See https://github.com/PyCQA/pycodestyle/issues/373 - E203, \ No newline at end of file diff --git a/setup.py b/setup.py index efd98bc..8d613b4 100755 --- a/setup.py +++ b/setup.py @@ -1,33 +1,64 @@ #!/usr/bin/env python -from setuptools import setup, find_packages, Command, Extension -from wheel.bdist_wheel import bdist_wheel +import distutils +from distutils.command.build import build as _build +from setuptools.command.develop import develop as _develop +from wheel.bdist_wheel import bdist_wheel as _bdist_wheel +from setuptools import Distribution +from setuptools import setup, Command +import os -class DotnetLib(Extension): +# Disable SourceLink during the build until it can read repo-format v1, #1613 +os.environ["EnableSourceControlManagerQueries"] = "false" + + +class DotnetLib: def __init__(self, name, path, **kwargs): + self.name = name self.path = path self.args = kwargs - super().__init__(name, sources=[]) -class BuildDotnet(Command): +class build_dotnet(Command): """Build command for dotnet-cli based builds""" description = "Build DLLs with dotnet-cli" - user_options = [("dotnet-config", None, "dotnet build configuration")] + user_options = [ + ("dotnet-config=", None, "dotnet build configuration"), + ( + "inplace", + "i", + "ignore build-lib and put compiled extensions into the source " + + "directory alongside your pure Python modules", + ), + ] def initialize_options(self): - self.dotnet_config = "release" + self.dotnet_config = None + self.build_lib = None + self.inplace = False def finalize_options(self): - pass + if self.dotnet_config is None: + self.dotnet_config = "release" - def get_source_files(self): - return [] + build = self.distribution.get_command_obj("build") + build.ensure_finalized() + if self.inplace: + self.build_lib = "." + else: + self.build_lib = build.build_lib def run(self): - for lib in self.distribution.ext_modules: + dotnet_modules = self.distribution.dotnet_libs + + for lib in dotnet_modules: + output = os.path.join( + os.path.abspath(self.build_lib), lib.args.pop("output") + ) + rename = lib.args.pop("rename", {}) + opts = sum( [ ["--" + name.replace("_", "-"), value] @@ -36,13 +67,46 @@ def run(self): [], ) - opts.append("--configuration") - opts.append(self.dotnet_config) + opts.extend(["--configuration", self.dotnet_config]) + opts.extend(["--output", output]) + self.announce("Running dotnet build...", level=distutils.log.INFO) self.spawn(["dotnet", "build", lib.path] + opts) + for k, v in rename.items(): + source = os.path.join(output, k) + dest = os.path.join(output, v) + + if os.path.isfile(source): + try: + os.remove(dest) + except OSError: + pass + + self.move_file(src=source, dst=dest, level=distutils.log.INFO) + else: + self.warn( + "Can't find file to rename: {}, current dir: {}".format( + source, os.getcwd() + ) + ) -class bdist_wheel_patched(bdist_wheel): + +# Add build_dotnet to the build tasks: +class build(_build): + sub_commands = _build.sub_commands + [("build_dotnet", None)] + + +class develop(_develop): + def install_for_development(self): + # Build extensions in-place + self.reinitialize_command("build_dotnet", inplace=1) + self.run_command("build_dotnet") + + return super().install_for_development() + + +class bdist_wheel(_bdist_wheel): def finalize_options(self): # Monkey patch bdist_wheel to think the package is pure even though we # include DLLs @@ -50,44 +114,32 @@ def finalize_options(self): self.root_is_pure = True -with open("README.md", "r") as f: - long_description = f.read() +# Monkey-patch Distribution s.t. it supports the dotnet_libs attribute +Distribution.dotnet_libs = None + +cmdclass = { + "build": build, + "build_dotnet": build_dotnet, + "develop": develop, + "bdist_wheel": bdist_wheel, +} + +dotnet_libs = [ + DotnetLib( + "netfx-loader-x86", + "netfx_loader/ClrLoader.csproj", + runtime="win-x86", + output="clr_loader/ffi/dlls/x86", + ), + DotnetLib( + "netfx-loader-amd64", + "netfx_loader/ClrLoader.csproj", + runtime="win-x64", + output="clr_loader/ffi/dlls/amd64", + ), +] setup( - name="clr_loader", - version="0.1.7", - description="Generic pure Python loader for .NET runtimes", - author="Benedikt Reinartz", - author_email="filmor@gmail.com", - long_description=long_description, - long_description_content_type="text/markdown", - license="MIT", - python_requires=">=3.6", - install_requires=["cffi>=1.13"], - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX :: Linux", - "Operating System :: MacOS :: MacOS X", - ], - package_data={"clr_loader.ffi": ["dlls/x86/*.dll", "dlls/amd64/*.dll"]}, - packages=find_packages(), - cmdclass={"build_ext": BuildDotnet, "bdist_wheel": bdist_wheel_patched}, - ext_modules={ - DotnetLib( - "netfx-loader-x86", - "netfx_loader/ClrLoader.csproj", - runtime="win-x86", - output="clr_loader/ffi/dlls/x86", - ), - DotnetLib( - "netfx-loader-amd64", - "netfx_loader/ClrLoader.csproj", - runtime="win-x64", - output="clr_loader/ffi/dlls/amd64", - ), - }, + cmdclass=cmdclass, + dotnet_libs=dotnet_libs, ) diff --git a/tests/test_common.py b/tests/test_common.py index 04fec64..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") @@ -11,14 +12,14 @@ def example_netstandard(tmpdir_factory): @pytest.fixture(scope="session") def example_netcore(tmpdir_factory): - return build_example(tmpdir_factory, "netcoreapp31") + return build_example(tmpdir_factory, "net60") 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)