diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..edac6e1 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,29 @@ +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 + 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/clr_loader/__init__.py b/clr_loader/__init__.py index 446c170..d0d8ff2 100644 --- a/clr_loader/__init__.py +++ b/clr_loader/__init__.py @@ -17,12 +17,13 @@ "Runtime", "Assembly", "RuntimeInfo", + "DotnetCoreRuntimeSpec", ] def get_mono( *, - domain: Optional[str] = None, + # domain: Optional[str] = None, config_file: Optional[StrOrPath] = None, global_config_file: Optional[StrOrPath] = None, libmono: Optional[StrOrPath] = None, @@ -30,14 +31,32 @@ def get_mono( 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=_maybe_path(config_file), @@ -54,6 +73,27 @@ def get_coreclr( 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) @@ -91,11 +131,21 @@ def get_coreclr( def get_netfx( - *, name: Optional[str] = None, config_file: Optional[StrOrPath] = None + *, domain: Optional[str] = None, config_file: Optional[StrOrPath] = None ) -> Runtime: + """Get a .NET Framework runtime instance + + :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=_maybe_path(config_file)) + impl = NetFx(domain=domain, config_file=_maybe_path(config_file)) return impl diff --git a/clr_loader/hostfxr.py b/clr_loader/hostfxr.py index 9171def..dce5c82 100644 --- a/clr_loader/hostfxr.py +++ b/clr_loader/hostfxr.py @@ -79,7 +79,7 @@ def __iter__(self) -> Generator[Tuple[str, str], None, None]: for i in range(size_ptr[0]): yield (decode(keys_ptr[i]), decode(values_ptr[i])) - def get_callable(self, assembly_path: StrOrPath, 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_initialized = True diff --git a/clr_loader/mono.py b/clr_loader/mono.py index c9123ca..3558822 100644 --- a/clr_loader/mono.py +++ b/clr_loader/mono.py @@ -41,7 +41,7 @@ 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: diff --git a/clr_loader/netfx.py b/clr_loader/netfx.py index 9f18a16..f98cd74 100644 --- a/clr_loader/netfx.py +++ b/clr_loader/netfx.py @@ -9,16 +9,18 @@ class NetFx(Runtime): - def __init__(self, name: Optional[str] = None, config_file: Optional[Path] = None): + def __init__( + self, domain: Optional[str] = None, config_file: Optional[Path] = None + ): initialize() if config_file is not None: config_file_s = str(config_file) else: config_file_s = ffi.NULL - self._name = name + self._domain = domain self._config_file = config_file - self._domain = _FW.pyclr_create_appdomain(name or ffi.NULL, config_file_s) + self._domain = _FW.pyclr_create_appdomain(domain or ffi.NULL, config_file_s) def info(self) -> RuntimeInfo: return RuntimeInfo( @@ -26,10 +28,12 @@ def info(self) -> RuntimeInfo: version="", initialized=True, shutdown=_FW is None, - properties={}, + properties=dict( + domain=self._domain or "", config_file=str(self._config_file) + ), ) - def get_callable(self, assembly_path: StrOrPath, typename: str, function: str): + def _get_callable(self, assembly_path: StrOrPath, typename: str, function: str): func = _FW.pyclr_get_function( self._domain, str(Path(assembly_path)).encode("utf8"), diff --git a/clr_loader/types.py b/clr_loader/types.py index 85c234d..15c1e30 100644 --- a/clr_loader/types.py +++ b/clr_loader/types.py @@ -10,6 +10,22 @@ @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 @@ -39,7 +55,7 @@ def __init__( self._class = typename self._name = func_name - self._callable = runtime.get_callable(assembly, typename, func_name) + self._callable = runtime._get_callable(assembly, typename, func_name) def __call__(self, buffer: bytes) -> int: from .ffi import ffi @@ -57,6 +73,21 @@ def __init__(self, runtime: "Runtime", path: StrOrPath): 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) @@ -67,21 +98,39 @@ def __repr__(self) -> str: 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( + 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: diff --git a/clr_loader/util/find.py b/clr_loader/util/find.py index 8114df8..daad769 100644 --- a/clr_loader/util/find.py +++ b/clr_loader/util/find.py @@ -17,6 +17,19 @@ def find_dotnet_cli() -> Optional[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 + """ + dotnet_root = os.environ.get("DOTNET_ROOT", None) if dotnet_root is not None: return Path(dotnet_root) @@ -69,6 +82,17 @@ def find_runtimes_in_root(dotnet_root: Path) -> Iterator[DotnetCoreRuntimeSpec]: def find_runtimes() -> Iterator[DotnetCoreRuntimeSpec]: + """Find installed .NET Core runtimes + + 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) @@ -77,7 +101,18 @@ def find_runtimes() -> Iterator[DotnetCoreRuntimeSpec]: return find_runtimes_in_root(dotnet_root) -def find_libmono(sgen: bool = True) -> Path: +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: diff --git a/clr_loader/util/runtime_spec.py b/clr_loader/util/runtime_spec.py index e874d1e..2eaeb68 100644 --- a/clr_loader/util/runtime_spec.py +++ b/clr_loader/util/runtime_spec.py @@ -6,6 +6,8 @@ @dataclass class DotnetCoreRuntimeSpec: + """Specification of an installed .NET Core runtime""" + name: str version: str path: 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.