diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f3ba2e..26f7df2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -113,6 +113,12 @@ jobs: # TODO: figure out whether we need/want to use other compilers too compiler: "gcc" version: "13" + - name: Set DLL directory environment variable + # Used when running the tests, see `tests/conftest.py` for details + if: ${{ startsWith(matrix.os, 'windows') }} + run: | + echo "PYTHON_ADD_DLL_DIRECTORY=C:\\mingw64\\bin" >> $GITHUB_ENV + - uses: ./.github/actions/setup with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a30cfcb..ebaec2e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,7 +28,6 @@ jobs: pip install -r {project}/requirements-only-tests-min-locked.txt CIBW_TEST_COMMAND: >- pytest {project}/tests - MACOSX_DEPLOYMENT_TARGET: "13.0" strategy: fail-fast: false matrix: @@ -57,6 +56,14 @@ jobs: # TODO: figure out whether we need/want to use other compilers too compiler: "gcc" version: "13" + - name: Set deployment target - MacOS 13.0 + if: ${{ matrix.os == 'macos-13' }} + run: | + echo "MACOSX_DEPLOYMENT_TARGET=13.0" >> $GITHUB_ENV + - name: Set deployment target - MacOS 14.0 + if: ${{ matrix.os == 'macos-14' }} + run: | + echo "MACOSX_DEPLOYMENT_TARGET=14.0" >> $GITHUB_ENV - name: Specify LLVM - windows-arm if: ${{ matrix.os == 'windows-11-arm' }} shell: pwsh diff --git a/Makefile b/Makefile index 9e2ce92..fb38ff5 100644 --- a/Makefile +++ b/Makefile @@ -49,8 +49,7 @@ test: ## run the tests (re-installs the package every time so you might want to # because it is looking for lines in `src` to be run, # but they're not because lines in `.venv` are run instead. # We don't have a solution to this yet. - # - # Coverage directory - needed to trick code cov to looking at the right place + uv run --no-sync python scripts/inject-srcs-into-meson-build.py uv run --no-sync python -c 'from pathlib import Path; import example_fgen_basic' || ( echo "Run make virtual-environment first" && false ) COV_DIR=$$(uv run --no-sync python -c 'from pathlib import Path; import example_fgen_basic; print(Path(example_fgen_basic.__file__).parent)'); \ uv run --no-editable --reinstall-package example-fgen-basic pytest -r a -v tests src --doctest-modules --doctest-report ndiff --cov=$$COV_DIR diff --git a/changelog/25.feature.md b/changelog/25.feature.md new file mode 100644 index 0000000..ab137ca --- /dev/null +++ b/changelog/25.feature.md @@ -0,0 +1 @@ +Added demonstration of wrapping Fortran derived types diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index fb48646..eb9e819 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -6,11 +6,11 @@ See https://oprypin.github.io/mkdocs-literate-nav/ - [Home](index.md) - [Installation](installation.md) - [How-to guides](how-to-guides/index.md) - - [Do a basic calculation](how-to-guides/basic-calculation.md) - - [Run code in a notebook](how-to-guides/run-code-in-a-notebook.py) + - [Basic demo](how-to-guides/basic-demo.py) - [Tutorials](tutorials/index.md) - [Further background](further-background/index.md) - [Dependency pinning and testing](further-background/dependency-pinning-and-testing.md) + - [Wrapping Fortran derived types](further-background/wrapping-fortran-derived-types.md) - [Development](development.md) - [API reference](api/example_fgen_basic/) - [Fortran API](fortran-api/home.html) diff --git a/docs/further-background/wrapping-fortran-derived-types.md b/docs/further-background/wrapping-fortran-derived-types.md new file mode 100644 index 0000000..1487621 --- /dev/null +++ b/docs/further-background/wrapping-fortran-derived-types.md @@ -0,0 +1,281 @@ +# Wrapping derived types + +Here we describe our approach to wrapping Fortran derived types. + +## What is the goal? + +The goal is to be able to run MAGICC, a model written in Fortran, from Python. +This means we need to be able to instantiate MAGICC's inputs in memory in Python, +pass them to Fortran to solve the model and get them back as results in Python. + +Our data is not easily represented as primitive types (floats, ints, strings, arrays) +because we want to have more robust data handling, e.g. attaching units to arrays. +As a result, we need to pass objects to Fortran and return Fortran derived types to Python. +It turns out that wrapping derived types is tricky. +Notably, [f2py](https://numpy.org/doc/stable/f2py/) +does not provide direct support for it. +As a result, we need to come up with our own solution. + +## Our solution + +Our solution is based on a key simplifying assumption. +Once we have passed data across the Python-Fortran interface, +there is no way to modify it again from the other side of the interface. +In other words, our wrappers are not views, +instead they are independent instantiations of the same (or as similar as possible) data models. + +For example, if I have an object in Python +and I pass this to a wrapped Fortran function which alters some attribute of this object, +that modification will only happen on the Fortran side, +the original Python object will remain unchanged +(as a note, to see the result, we must return a new Python object from the Fortran wrapper). + +This assumption makes ownership and memory management clear. +We do not need to keep instances around as views +and therefore do not need to worry about consistency across the Python-Fortran interface. +Instead, we simply pass data back and forth, +and the normal rules of data consistency within each programming language apply. + +To actually pass derived types back and forth across the Python-Fortran interface, +we introduce a 'manager' module for all derived types. + +The manager module has two key components: + +1. an allocatable array of instances of the derived type it manages, + call this `instance_array`. + The array of instances are instances which the manager owns. + In practice, they are essentially temporary variables. +1. an allocatable array of logical (boolean) values, + call this `available_array`. + The convention is that, if `available_array(i)` is `.true.`, + where `i` is an integer, + then the instance at `instance_array(i)` is available for the manager to use. + Otherwise, the manager assumes that the instance is already being used for some purpose + and therefore cannot be used for whatever operation is currently being performed. + +This setup allows us to effectively pass derived types back and forth between Python and Fortran. + +Whenever we need to return a derived type (or derived types) to Python, we: + +1. get the derived type(s) from whatever Fortran function or subroutine created it, + call this `derived_type_original` +1. find an index, `idx`, in `available_array` such that `available_array(idx)` is `.true.` +1. set `instance_array(idx)` equal to `derived_type_original` +1. we return `idx` to Python + - `idx` is an integer (or integers), so we can return this easily to Python using `f2py` +1. we then create a Python object (or objects) with an API that mirrors `derived_type_original` + using the class method `from_instance_index`. + This class method is auto-generated via `pyfgen` + (TODO: implement auto-generation) + and handles retrieval of all the attribute values of `derived_type_original` + from Fortran and sets them on the Python object that is being instantiated + - we can do this as, if you dig down deep enough, all attributes eventually + become primitive types which can be passed back and forth using `f2py`, + it can just be that multiple levels of recursion are needed + if you have derived types that themselves have derived type attributes +1. we then call the wrapper module's `finalise_instance` function to free the (temporary) instance(s) + that was used by the manager + (we cannot access the manager module directly as it cannot be wrapped by `f2py`) + - this instance is no longer needed because all the data has been transferred to Python +1. we end up with a Python instance(s) that has the result + and no extra/leftover memory footprint in Fortran + (and leave Fortran to decide whether to clean up `derived_type_original` or not) + +Whenever we need to pass a derived type (or derived types) to Fortran, we: + +1. get an instance index we can use to communicate with Fortran + 1. call the Python object's `build_fortran_instance` method, + which returns the instance index where the object was created on the Fortran side. + Under the hood, this calls the wrapper module's + `get_free_instance_index` and `build_instance` functions + 1. on the Fortran side, there is now an instantiated derived type, ready for use +1. call the wrapped Fortran function of interest, + except we pass the instance index instead of the actual Python object itself +1. on the Fortran side, retrieve the instantiated index from the manager module + and use this to call the Fortran function/subroutine of interest +1. return the result from Fortran back to Python +1. call the wrapper module's `finalise_instance` function to free the (temporary) instance + that was used to pass the instance in the first place + - this instance is no longer needed because all the data has been transferred and used by Fortran +1. we end up with the result of the Fortran callable back in Python + and no extra/leftover memory footprint in Fortran from the instance created by the manager module + +## Further background + +We initially started this project and took quite a different route. +The reason was that we were actually solving a different problem. +What we were trying to do was to provide views into underlying Fortran instances. +For example, we wanted to enable the following: + +```python +>>> from some_fortran_wrapper import SomeWrappedFortranDerivedType + + +>>> inst = SomeWrappedFortranDerivedType(value1=2, value2="hi") +>>> inst2 = inst +>>> inst.value1 = 5 +>>> # Updating the view via `inst` also affects `inst2` +>>> inst2.value1 +5 +``` + +Supporting views like this introduces a whole bunch of headaches, +mainly due to consistency and memory management. + +A first headache is consistency. +Consider the following, which is a common gotcha with numpy + +```python +>>> import numpy as np +>>> +>>> a = np.array([1.2, 2.2, 2.5]) +>>> b = a +>>> a[2] = 0.0 +>>> # b has been updated too - many users don't expect this +>>> b +array([1.2, 2.2, 0. ]) +``` + +The second is memory management. +For example, in the example above, if I delete variable `a`, +what should variable `b` become? + +With numpy, it turns out that the answer is that `b` is unaffected + +```python +>>> del a +>>> a +Traceback (most recent call last): + File "", line 1, in +NameError: name 'a' is not defined +>>> b +array([1.2, 2.2, 0. ]) +``` + +However, we would argue that this is not the only possibility. +It could also be that `b` should become undefined, +as the underlying array it views has been deleted. +Doing it like this must also be very complicated for numpy, +as they need to keep track of how many references +there are to the array underlying the Python variables +to know whether to actually free the memory or not. + +We don't want to solve these headaches, +which is why our solution does not support views, +instead only supporting the passing of data across the Python-Fortran interface +(which ensures that ownership is clear at all times +and normal Python rules apply in Python +(which doesn't mean there aren't gotchas, just that we won't introduce any new gotchas)). + +## Other solutions we rejected + +### Provide views rather than passing data + +Note: this section was never properly finished. +Once we started trying to write it, +we realised how hard it would be to avoid weird edge cases +so we stopped and changed to [our current solution][our-solution]. + +To pass derived types back and forth across the Python-Fortran interface, +we introduce a 'manager' module for all derived types. +This manager module is responsible for managing derived type instances +that are passed across the Python-Fortran interface +and is needed because we can't pass them directly using f2py. + +The manager module has two key components: + +1. an allocatable array of instances of the derived type it manages +1. an allocatable array of logical (boolean) values + +The array of instances are instances which the manager owns. +It holds onto these: can instantiate them, can make them have the same values +as results from Fortran functions etc. +(I think we need to decide whether this is an array of instances +or an array of pointers to instances (although I don't think that's a thing https://fortran-lang.discourse.group/t/arrays-of-pointers/4851/6, +so doing something like this might require yet another layer of abstraction). +Array of instances means we have to do quite some data copying +and be careful about changes made on the Fortran side propagating to the Python side, +I think (although we have to test as I don't know enough about whether Fortran is pass by reference or pass by value by default), +array of pointers would mean change propagation should be more automatic. +We're going to have to define our requirements and tests quite carefully then see what works and what doesn't. +I think this is also why I introduced the 'no setters' views, +as some changes just can't be propagated back to Fortran in a 'permanent' way. +We should probably read this and do some thinking: https://stackoverflow.com/questions/4730065/why-does-a-fortran-pointer-require-a-target). + +Whenever we need to return a derived type to Python, +we follow a recipe like the below: + +1. we firstly ask the manager to give us an index (i.e. an integer) such that `logical_array(index)` is `.false.`. + The convention is that `logical_array(index)` is `.false.` means that `instance_array(index)` is available for use. +1. We set `logical_array(index)` equal to `.true.`, making clear that we are now using `instance_array(index)` +1. We set the value of `instance_array(index)` to match the the derived type that we want to return +1. We return the index value (i.e. an integer) to Python +1. The Python side just holds onto this integer +1. When we want to get attributes (i.e. values) of the derived type, + we pass the index value (i.e. an integer) of interest from Python back to Fortran +1. The manager gets the derived type at `instance_array(index)` and then can return the atribute of interest back to Python +1. When we want to set attributes (i.e. values) of the derived type, + we pass the index value (i.e. an integer) of interest and the value to set from Python back to Fortran +1. The manager gets the derived type at `instance_array(index)` and then sets the desired atribute of interest on the Fortran side +1. When we finalise an instance from Python, + we pass the index value (i.e. an integer) of interest from Python back to Fortran + and then call any finalisation routines on `instance_array(index)` on the Fortran side, + while also setting `logical_array(index)` back to `.false.`, marking `instance_array(index)` + as being available for use for another purpose + +Doing it this means that ownership is easier to manage. +Let's assume we have two Python instances backed by the same Fortran instance, +call them `PythonObjA` and `PythonObjB`. +If we finalise the Fortran instance via `PythonObjA`, then `logical_array(index)` will now be marked as `.false.`. +Then, if we try and use this instance via `PythonObjB`, +we will see that `logical_array(index)` is `.false.`, +hence we know that the object has been finalised already hence the view that `PythonObjB` has is no longer valid. +(I can see an edge case where, we finalise via `PythonObjA`, +then initialise a new object that gets the (now free) instance index +used by `PythonObjB`, so when we look again via `PythonObjB`, +we see the new object, which could be very confusing. +We should a) test this to see if we can re-create such an edge case +then b) consider a fix (maybe we need an extra array which counts how many times +this index has been initialised and finalised so we can tell if we're still +looking at the same initialisation or a new one that has happened since we last looked).) + +This solution allows us to a) only pass integers across the Python-Fortran interface +(so we can use f2py) and b) keep track of ownership. +The tradeoff is that we use more memory (because we have arrays of instances and logicals), +are slightly slower (as we have extra layers of lookup to do) +and have slow reallocation calls sometimes (when we need to increase the number of available instances dynamically). +There is no perfect solution, and we think this way strikes the right balance of +'just works' for most users while also offering access to fine-grained memory control for 'power users'. + +### Pass pointers back and forth + +Example repository: https://github.com/Nicholaswogan/f2py-with-derived-types + +Another option is to pass pointers to objects back and forth. +We tried this initially. +Where this falls over is in ownership. +Basically, the situation that doesn't work is this. + +From Python, I create an object which is backed by a Fortran derived type. +Call this `PythonObjA`. +From Python, I create another object which is backed by the same Fortran derived type instance i.e. I get a pointer to the same Fortran derived type instance. +Call this `PythonObjB`. +If I now finalise `PythonObjA` from Python, this causes the following to happen. +The pointer that was used by `PythonObjA` is now pointing to `null`. +This is fine. +However, the pointer that is being used by `PythonObjB` is now in an undefined state +(see e.g. community.intel.com/t5/Intel-Fortran-Compiler/DEALLOCATING-DATA-TYPE-POINTERS/m-p/982338#M100027 +or https://www.ibm.com/docs/en/xl-fortran-aix/16.1.0?topic=attributes-deallocate). +As a result, whenever I try to do anything with `PythonObjB`, +the result cannot be predicted and there is no way to check +(see e.g. https://stackoverflow.com/questions/72140217/can-you-test-for-nullpointers-in-fortran), +either from Python or Fortran, what the state of the pointer used by `PythonObjB` is +(it is undefined). + +This unresolvable problem is why we don't use the purely pointer-based solution +and instead go for a slightly more involved solution with a much clearer ownership model/logic. +We could do something like add a reference counter or some other solution to make this work. +This feels very complicated though. +General advice also seems to be to avoid pointers where possible +(community.intel.com/t5/Intel-Fortran-Compiler/how-to-test-if-pointer-array-is-allocated/m-p/1138643#M136486), +prefering allocatable instead, which has also helped shape our current solution. diff --git a/docs/how-to-guides/basic-calculation.md b/docs/how-to-guides/basic-calculation.md deleted file mode 100644 index cbe227a..0000000 --- a/docs/how-to-guides/basic-calculation.md +++ /dev/null @@ -1,10 +0,0 @@ -# How to do a basic calculation - -An example of how to do a basic calculation using Example fgen - basic. - -```python ->>> from example_fgen_basic.operations import add_two - ->>> add_two(3.2, 4.3) -7.5 -``` diff --git a/docs/how-to-guides/basic-demo.py b/docs/how-to-guides/basic-demo.py new file mode 100644 index 0000000..1009b1e --- /dev/null +++ b/docs/how-to-guides/basic-demo.py @@ -0,0 +1,82 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.17.2 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# %% [markdown] +# # Basic demo +# +# Here we show a very basic demo of how to use the package. +# The actual behaviour isn't so interesting, +# but it demonstrates that we can wrap code +# that is ultimately written in Fortran. + +# %% [markdown] +# ## Imports + +# %% +import pint + +from example_fgen_basic.error_v import ErrorV +from example_fgen_basic.error_v.creation import create_error, create_errors +from example_fgen_basic.error_v.passing import pass_error, pass_errors +from example_fgen_basic.get_wavelength import get_wavelength, get_wavelength_plain + +# %% [markdown] +# ## Calculation with basic types +# +# Here we show how we can use a basic wrapped function. +# This functionality isn't actually specific to our wrappers +# (you can do the same with f2py), +# but it's a useful starting demonstration. + +# %% +# `_plain` because this works on plain floats, +# not quantities with units (see below for this demonstration) +get_wavelength_plain(400.0e12) + +# %% [markdown] +# With these python wrappers, +# we can also do nice things like support interfaces that use units +# (this would be much more work to implement directly in Fortran). + +# %% +ur = pint.get_application_registry() + +# %% +get_wavelength(ur.Quantity(400.0, "THz")).to("nm") + +# %% [markdown] +# ## Receiving and passing derived types +# +# TODO: more docs and cross-references on how this actually works + +# %% [markdown] +# We can receive a Python-equivalent of a Fortran derived type. + +# %% +create_error(3) + +# %% [markdown] +# Or multiple derived types. + +# %% +create_errors([1, 2, 1, 5]) + +# %% [markdown] +# We can also pass Python-equivalent of Fortran derived types back into Fortran. + +# %% +pass_error(ErrorV(code=0)) + +# %% +pass_errors([ErrorV(code=0), ErrorV(code=3), ErrorV(code=5), ErrorV(code=-2)]) diff --git a/docs/how-to-guides/index.md b/docs/how-to-guides/index.md index 76c5267..ea58c94 100644 --- a/docs/how-to-guides/index.md +++ b/docs/how-to-guides/index.md @@ -3,15 +3,3 @@ This part of the project documentation focuses on a **problem-oriented** approach. We'll go over how to solve common tasks. - -## How can I do a basic calculation? - - - -If you want to do a basic calculation, -see ["How to do a basic calculation"][how-to-do-a-basic-calculation]. diff --git a/docs/how-to-guides/run-code-in-a-notebook.py b/docs/how-to-guides/run-code-in-a-notebook.py deleted file mode 100644 index 6166066..0000000 --- a/docs/how-to-guides/run-code-in-a-notebook.py +++ /dev/null @@ -1,24 +0,0 @@ -# --- -# jupyter: -# jupytext: -# text_representation: -# extension: .py -# format_name: percent -# format_version: '1.3' -# jupytext_version: 1.16.4 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# %% [markdown] editable=true slideshow={"slide_type": ""} -# # How to run code in a notebook -# -# Here we show how you can make your docs via notebooks. - -# %% editable=true slideshow={"slide_type": ""} -from example_fgen_basic.operations import add_two - -# %% editable=true slideshow={"slide_type": ""} -add_two(3.2, 4.3) diff --git a/meson.build b/meson.build index 93437af..84134c5 100644 --- a/meson.build +++ b/meson.build @@ -12,6 +12,9 @@ project( ], ) +# https://mesonbuild.com/Fs-module.html +fs = import('fs') + # Some useful constants pyprojectwheelbuild_enabled = get_option('pyprojectwheelbuild').enabled() @@ -48,12 +51,21 @@ if pyprojectwheelbuild_enabled # These are the src with which Python interacts. # Injected with `script/inject-srcs-into-meson-build.py` srcs = files( + 'src/example_fgen_basic/error_v/creation_wrapper.f90', + 'src/example_fgen_basic/error_v/error_v_wrapper.f90', + 'src/example_fgen_basic/error_v/passing_wrapper.f90', 'src/example_fgen_basic/get_wavelength_wrapper.f90', ) # Specify all the other source Fortran files (original files and managers) # Injected with `script/inject-srcs-into-meson-build.py` srcs_ancillary_lib = files( + 'src/example_fgen_basic/error_v/creation.f90', + 'src/example_fgen_basic/error_v/error_v.f90', + 'src/example_fgen_basic/error_v/error_v_manager.f90', + 'src/example_fgen_basic/error_v/passing.f90', + 'src/example_fgen_basic/fpyfgen/base_finalisable.f90', + 'src/example_fgen_basic/fpyfgen/derived_type_manager_helpers.f90', 'src/example_fgen_basic/get_wavelength.f90', 'src/example_fgen_basic/kind_parameters.f90', ) @@ -62,10 +74,15 @@ if pyprojectwheelbuild_enabled # Injected with `script/inject-srcs-into-meson-build.py` python_srcs = files( 'src/example_fgen_basic/__init__.py', + 'src/example_fgen_basic/error_v/__init__.py', + 'src/example_fgen_basic/error_v/creation.py', + 'src/example_fgen_basic/error_v/error_v.py', + 'src/example_fgen_basic/error_v/passing.py', 'src/example_fgen_basic/exceptions.py', 'src/example_fgen_basic/get_wavelength.py', - 'src/example_fgen_basic/operations.py', - 'src/example_fgen_basic/runtime_helpers.py', + 'src/example_fgen_basic/pyfgen_runtime/__init__.py', + 'src/example_fgen_basic/pyfgen_runtime/exceptions.py', + 'src/example_fgen_basic/typing.py', ) # The ancillary library, @@ -134,7 +151,7 @@ if pyprojectwheelbuild_enabled foreach python_src : python_srcs py.install_sources( python_src, - subdir: python_project_name, + subdir: fs.parent(fs.relative_to(python_src, 'src')), pure: false, ) diff --git a/pyproject.toml b/pyproject.toml index 78510df..b9d8a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ docs = [ "jupyterlab>=4.4.5", "jupytext>=1.17.2", "mkdocs-jupyter>=0.25.1", + "pint>=0.24.4", ] # For minimum test dependencies. # These are used when running our minimum PyPI install tests. @@ -147,7 +148,8 @@ source = [ ] branch = true omit = [ - # TODO: check this file + "*typing.py", # Should not be needed at runtime + # TODO: test these files directly when splitting out pyfgen_runtime "*exceptions.py", "*runtime_helpers.py", ] diff --git a/requirements-docs-locked.txt b/requirements-docs-locked.txt index 1eb5ec2..98adf97 100644 --- a/requirements-docs-locked.txt +++ b/requirements-docs-locked.txt @@ -28,6 +28,8 @@ defusedxml==0.7.1 exceptiongroup==1.3.0 ; python_full_version < '3.11' executing==2.1.0 fastjsonschema==2.21.1 +flexcache==0.3 +flexparser==0.4 fonttools==4.59.0 ford==6.1.13 fqdn==1.5.1 @@ -103,6 +105,7 @@ parso==0.8.4 pathspec==0.12.1 pexpect==4.9.0 ; (python_full_version < '3.10' and sys_platform == 'emscripten') or (sys_platform != 'emscripten' and sys_platform != 'win32') pillow==11.3.0 +pint==0.24.4 platformdirs==4.3.6 prometheus-client==0.21.1 prompt-toolkit==3.0.48 diff --git a/src/example_fgen_basic/error_v/__init__.py b/src/example_fgen_basic/error_v/__init__.py new file mode 100644 index 0000000..4182384 --- /dev/null +++ b/src/example_fgen_basic/error_v/__init__.py @@ -0,0 +1,7 @@ +""" +Definition of an error value +""" + +from example_fgen_basic.error_v.error_v import ErrorV + +__all__ = ["ErrorV"] diff --git a/src/example_fgen_basic/error_v/creation.f90 b/src/example_fgen_basic/error_v/creation.f90 new file mode 100644 index 0000000..97aed75 --- /dev/null +++ b/src/example_fgen_basic/error_v/creation.f90 @@ -0,0 +1,68 @@ +!> Error creation +!> +!> A very basic demo to get the idea. +! +module m_error_v_creation + + use m_error_v, only: ErrorV, NO_ERROR_CODE + + implicit none (type, external) + private + + public :: create_error, create_errors + +contains + + function create_error(inv) result(err) + !! Create an error + !! + !! If an odd number is supplied, the error code is no error (TODO: cross-ref). + !! If an even number is supplied, the error code is 1. + !! If a negative number is supplied, the error code is 2. + + integer, intent(in) :: inv + !! Value to use to create the error + + type(ErrorV) :: err + !! Created error + + if (inv < 0) then + err = ErrorV(code=2, message="Negative number supplied") + return + end if + + if (mod(inv, 2) .eq. 0) then + err = ErrorV(code=1, message="Even number supplied") + else + err = ErrorV(code=NO_ERROR_CODE) + end if + + end function create_error + + function create_errors(invs, n) result(errs) + !! Create a number of errors + !! + !! If an odd number is supplied, the error code is no error (TODO: cross-ref). + !! If an even number is supplied, the error code is 1. + !! If a negative number is supplied, the error code is 2. + + integer, dimension(n), intent(in) :: invs + !! Values to use to create the error + + integer, intent(in) :: n + !! Number of values to create + + type(ErrorV), dimension(n) :: errs + !! Created errors + + integer :: i + + do i = 1, n + + errs(i) = create_error(invs(i)) + + end do + + end function create_errors + +end module m_error_v_creation diff --git a/src/example_fgen_basic/error_v/creation.py b/src/example_fgen_basic/error_v/creation.py new file mode 100644 index 0000000..a0695d6 --- /dev/null +++ b/src/example_fgen_basic/error_v/creation.py @@ -0,0 +1,92 @@ +""" +Wrappers of `m_error_v_creation` [TODO think about naming and x-referencing] + +At the moment, all written by hand. +We will auto-generate this in future. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from example_fgen_basic.error_v import ErrorV +from example_fgen_basic.pyfgen_runtime.exceptions import CompiledExtensionNotFoundError + +try: + from example_fgen_basic._lib import ( # type: ignore + m_error_v_w, + ) +except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover + raise CompiledExtensionNotFoundError("example_fgen_basic._lib.m_error_v_w") from exc +try: + from example_fgen_basic._lib import m_error_v_creation_w +except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover + raise CompiledExtensionNotFoundError( + "example_fgen_basic._lib.m_error_v_creation_w" + ) from exc + +if TYPE_CHECKING: + from example_fgen_basic.typing import NP_ARRAY_OF_INT + + +def create_error(inv: int) -> ErrorV: + """ + Create an error + + Parameters + ---------- + inv + Input value + + If odd, the error code is + [NO_ERROR_CODE][example_fgen_basic.error_v.error_v.NO_ERROR_CODE]. + If even, the error code is 1. + If a negative number is supplied, the error code is 2. + + Returns + ------- + : + Created error + """ + # Get the result, but receiving an instance index rather than the object itself + instance_index: int = m_error_v_creation_w.create_error(inv) + + # Initialise the result from the received index + res = ErrorV.from_instance_index(instance_index) + + # Tell Fortran to finalise the object on the Fortran side + # (all data has been copied to Python now) + m_error_v_w.finalise_instance(instance_index) + + return res + + +def create_errors(invs: NP_ARRAY_OF_INT) -> tuple[ErrorV, ...]: + """ + Create a number of errors + + Parameters + ---------- + invs + Input values from which to create errors + + For each value in `invs`, + if the value is even, an error is created, + if the value is odd, an error with a no error code is created. + + Returns + ------- + : + Created errors + """ + # Get the result, but receiving an instance index rather than the object itself + instance_indexes: NP_ARRAY_OF_INT = m_error_v_creation_w.create_errors(invs) + + # Initialise the result from the received index + res = tuple(ErrorV.from_instance_index(i) for i in instance_indexes) + + # Tell Fortran to finalise the object on the Fortran side + # (all data has been copied to Python now) + m_error_v_w.finalise_instances(instance_indexes) + + return res diff --git a/src/example_fgen_basic/error_v/creation_wrapper.f90 b/src/example_fgen_basic/error_v/creation_wrapper.f90 new file mode 100644 index 0000000..fbddaae --- /dev/null +++ b/src/example_fgen_basic/error_v/creation_wrapper.f90 @@ -0,0 +1,102 @@ +!> Wrapper for interfacing `m_error_v_creation` with Python +!> +!> Written by hand here. +!> Generation to be automated in future (including docstrings of some sort). +module m_error_v_creation_w + + ! => allows us to rename on import to avoid clashes + ! "o_" for original (TODO: better naming convention) + use m_error_v_creation, only: & + o_create_error => create_error, & + o_create_errors => create_errors + use m_error_v, only: ErrorV + + ! The manager module, which makes this all work + use m_error_v_manager, only: & + error_v_manager_get_available_instance_index => get_available_instance_index, & + error_v_manager_set_instance_index_to => set_instance_index_to, & + error_v_manager_ensure_instance_array_size_is_at_least => ensure_instance_array_size_is_at_least + + implicit none (type, external) + private + + public :: create_error, create_errors + +contains + + function create_error(inv) result(res_instance_index) + !! Wrapper around `m_error_v_creation.create_error` (TODO: x-ref) + + integer, intent(in) :: inv + !! Input value to use to create the error + !! + !! See docstring of `m_error_v_creation.create_error` for details. + !! [TODO: x-ref] + + integer :: res_instance_index + !! Instance index of the result + ! + ! This is the major trick for wrapping. + ! We return instance indexes (integers) to Python rather than the instance itself. + + type(ErrorV) :: res + + ! Do the Fortran call + res = o_create_error(inv) + + call error_v_manager_ensure_instance_array_size_is_at_least(1) + + ! Get the instance index to return to Python + call error_v_manager_get_available_instance_index(res_instance_index) + + ! Set the derived type value in the manager's array, + ! ready for its attributes to be retrieved from Python. + call error_v_manager_set_instance_index_to(res_instance_index, res) + + end function create_error + + function create_errors(invs, n) result(res_instance_indexes) + !! Wrapper around `m_error_v_creation.create_errors` (TODO: x-ref) + + integer, dimension(n), intent(in) :: invs + !! Input value to use to create the error + !! + !! See docstring of `m_error_v_creation.create_error` for details. + !! [TODO: x-ref] + + integer, intent(in) :: n + !! Number of values to create + + integer, dimension(n) :: res_instance_indexes + !! Instance indexes of the result + ! + ! This is the major trick for wrapping. + ! We return instance indexes (integers) to Python rather than the instance itself. + + type(ErrorV), dimension(n) :: res + + integer :: i, tmp + + ! Lots of ways resizing could work. + ! Optimising could be very tricky. + ! Just do something stupid for now to see the pattern. + call error_v_manager_ensure_instance_array_size_is_at_least(n) + + ! Do the Fortran call + res = o_create_errors(invs, n) + + do i = 1, n + + ! Get the instance index to return to Python + call error_v_manager_get_available_instance_index(tmp) + ! Set the derived type value in the manager's array, + ! ready for its attributes to be retrieved from Python. + call error_v_manager_set_instance_index_to(tmp, res(i)) + ! Set the result in the output array + res_instance_indexes(i) = tmp + + end do + + end function create_errors + +end module m_error_v_creation_w diff --git a/src/example_fgen_basic/error_v/error_v.f90 b/src/example_fgen_basic/error_v/error_v.f90 new file mode 100644 index 0000000..c0876bd --- /dev/null +++ b/src/example_fgen_basic/error_v/error_v.f90 @@ -0,0 +1,95 @@ +!> Error value +!> +!> Inspired by the excellent, MIT licensed +!> https://github.com/samharrison7/fortran-error-handler +!> +!> Fortran doesn't have a null value. +!> As a result, we introduce this derived type +!> with the convention that a code of 0 indicates no error. +module m_error_v + + implicit none (type, external) + private + + integer, parameter, public :: NO_ERROR_CODE = 0 + !! Code that indicates no error + + type, public :: ErrorV + !! Error value + + integer :: code = 1 + !! Error code + + character(len=128) :: message = "" + !! Error message + ! TODO: think about making the message allocatable to handle long messages + + ! TODO: think about adding idea of critical + ! (means you can stop but also unwind errors and traceback along the way) + + ! TODO: think about adding trace (might be simpler than compiling with traceback) + ! type(ErrorV), allocatable, dimension(:) :: causes + + contains + + private + + procedure, public :: build, finalise + ! get_res sort of not needed (?) + ! get_err sort of not needed (?) + + end type ErrorV + + interface ErrorV + !! Constructor interface - see build (TODO: figure out cross-ref syntax) for details + module procedure :: constructor + end interface ErrorV + +contains + + function constructor(code, message) result(self) + !! Constructor - see build (TODO: figure out cross-ref syntax) for details + + integer, intent(in) :: code + character(len=*), optional, intent(in) :: message + + type(ErrorV) :: self + + call self % build(code, message) + + end function constructor + + subroutine build(self, code, message) + !! Build instance + + class(ErrorV), intent(inout) :: self + ! Hopefully can leave without docstring (like Python) + + integer, intent(in) :: code + !! Error code + !! + !! Use [TODO: figure out xref] `NO_ERROR_CODE` if there is no error + + character(len=*), optional, intent(in) :: message + !! Error message + + self % code = code + if (present(message)) then + self % message = message + end if + + end subroutine build + + subroutine finalise(self) + !! Finalise the instance (i.e. free/deallocate) + + class(ErrorV), intent(inout) :: self + ! Hopefully can leave without docstring (like Python) + + ! If we make message allocatable, deallocate here + self % code = 1 + self % message = "" + + end subroutine finalise + +end module m_error_v diff --git a/src/example_fgen_basic/error_v/error_v.py b/src/example_fgen_basic/error_v/error_v.py new file mode 100644 index 0000000..c508148 --- /dev/null +++ b/src/example_fgen_basic/error_v/error_v.py @@ -0,0 +1,80 @@ +""" +Python equivalent of the Fortran `ErrorV` class [TODO: x-refs] + +At the moment, all written by hand. +We will auto-generate this in future. +""" + +from __future__ import annotations + +from attrs import define + +from example_fgen_basic.pyfgen_runtime.exceptions import CompiledExtensionNotFoundError + +try: + from example_fgen_basic._lib import ( # type: ignore + m_error_v_w, + ) +except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover + raise CompiledExtensionNotFoundError("example_fgen_basic._lib.m_error_v_w") from exc + +NO_ERROR_CODE = 0 +"""Code that indicates no error""" + + +@define +class ErrorV: + """ + Error value + """ + + code: int = 1 + """Error code""" + + message: str = "" + """Error message""" + + @classmethod + def from_instance_index(cls, instance_index: int) -> ErrorV: + """ + Initialise from an instance index received from Fortran + + Parameters + ---------- + instance_index + Instance index received from Fortran + + Returns + ------- + : + Initialised index + """ + # Different wrapping strategies are needed + + # Integer is very simple + code = m_error_v_w.get_code(instance_index) + + # String requires decode + message = m_error_v_w.get_message(instance_index).decode() + + res = cls(code=code, message=message) + + return res + + def build_fortran_instance(self) -> int: + """ + Build an instance equivalent to `self` on the Fortran side + + Intended for use mainly by wrapping functions. + Most users should not need to use this method directly. + + Returns + ------- + : + Instance index of the object which has been created on the Fortran side + """ + instance_index: int = m_error_v_w.build_instance( + code=self.code, message=self.message + ) + + return instance_index diff --git a/src/example_fgen_basic/error_v/error_v_manager.f90 b/src/example_fgen_basic/error_v/error_v_manager.f90 new file mode 100644 index 0000000..693a50f --- /dev/null +++ b/src/example_fgen_basic/error_v/error_v_manager.f90 @@ -0,0 +1,161 @@ +!> Manager of `ErrorV` (TODO: xref) across the Fortran-Python interface +!> +!> Written by hand here. +!> Generation to be automated in future (including docstrings of some sort). +module m_error_v_manager + + use m_error_v, only: ErrorV + + implicit none (type, external) + private + + type(ErrorV), dimension(:), allocatable :: instance_array + logical, dimension(:), allocatable :: instance_available + + ! TODO: think about ordering here, alphabetical probably easiest + public :: build_instance, finalise_instance, get_available_instance_index, get_instance, set_instance_index_to, & + ensure_instance_array_size_is_at_least + +contains + + function build_instance(code, message) result(instance_index) + !! Build an instance + + integer, intent(in) :: code + !! Error code + + character(len=*), optional, intent(in) :: message + !! Error message + + integer :: instance_index + !! Index of the built instance + + call ensure_instance_array_size_is_at_least(1) + call get_available_instance_index(instance_index) + call instance_array(instance_index) % build(code=code, message=message) + + end function build_instance + + subroutine finalise_instance(instance_index) + !! Finalise an instance + + integer, intent(in) :: instance_index + !! Index of the instance to finalise + + call check_index_claimed(instance_index) + + call instance_array(instance_index) % finalise() + instance_available(instance_index) = .true. + + end subroutine finalise_instance + + subroutine get_available_instance_index(available_instance_index) + !! Get a free instance index + + ! TODO: think through whether race conditions are possible + ! e.g. while returning a free index number to one Python call + ! a different one can be looking up a free instance index at the same time + ! and something goes wrong (maybe we need a lock) + + integer, intent(out) :: available_instance_index + !! Available instance index + + integer :: i + + do i = 1, size(instance_array) + + if (instance_available(i)) then + + instance_available(i) = .false. + available_instance_index = i + return + + end if + + end do + + ! TODO: switch to returning a Result type with an error set + error stop 1 + + end subroutine get_available_instance_index + + ! Change to pure function when we update check_index_claimed to be pure + function get_instance(instance_index) result(inst) + + integer, intent(in) :: instance_index + !! Index in `instance_array` of which to set the value equal to `val` + + type(ErrorV) :: inst + !! Instance at `instance_array(instance_index)` + + call check_index_claimed(instance_index) + inst = instance_array(instance_index) + + end function get_instance + + subroutine set_instance_index_to(instance_index, val) + + integer, intent(in) :: instance_index + !! Index in `instance_array` of which to set the value equal to `val` + + type(ErrorV), intent(in) :: val + + call check_index_claimed(instance_index) + instance_array(instance_index) = val + + end subroutine set_instance_index_to + + subroutine check_index_claimed(instance_index) + !! Check that an index has already been claimed + !! + !! Stops execution if the index has not been claimed. + + integer, intent(in) :: instance_index + !! Instance index to check + + if (instance_available(instance_index)) then + ! TODO: switch to errors here - will require some thinking + print *, "Index ", instance_index, " has not been claimed" + error stop 1 + end if + + if (instance_index < 1) then + ! TODO: switch to errors here - will require some thinking + print *, "Requested index is ", instance_index, " which is less than 1" + error stop 1 + end if + + end subroutine check_index_claimed + + subroutine ensure_instance_array_size_is_at_least(n) + !! Ensure that `instance_array` and `instance_available` have at least `n` slots + + integer, intent(in) :: n + + type(ErrorV), dimension(:), allocatable :: tmp_instances + logical, dimension(:), allocatable :: tmp_available + + if (.not. allocated(instance_array)) then + + allocate(instance_array(n)) + + allocate(instance_available(n)) + ! Race conditions ? + instance_available = .true. + + else if (size(instance_available) < n) then + + allocate(tmp_instances(n)) + tmp_instances(1:size(instance_array)) = instance_array + call move_alloc(tmp_instances, instance_array) + + allocate(tmp_available(n)) + tmp_available(1:size(instance_available)) = instance_available + tmp_available(size(instance_available) + 1:size(tmp_available)) = .true. + call move_alloc(tmp_available, instance_available) + + end if + + end subroutine ensure_instance_array_size_is_at_least + +end module m_error_v_manager diff --git a/src/example_fgen_basic/error_v/error_v_wrapper.f90 b/src/example_fgen_basic/error_v/error_v_wrapper.f90 new file mode 100644 index 0000000..7825cc9 --- /dev/null +++ b/src/example_fgen_basic/error_v/error_v_wrapper.f90 @@ -0,0 +1,131 @@ +!> Wrapper for interfacing `m_error_v` with Python +!> +!> Written by hand here. +!> Generation to be automated in future (including docstrings of some sort). +module m_error_v_w + + ! => allows us to rename on import to avoid clashes + use m_error_v, only: ErrorV + + ! The manager module, which makes this all work + use m_error_v_manager, only: & + error_v_manager_build_instance => build_instance, & + error_v_manager_finalise_instance => finalise_instance, & + error_v_manager_get_instance => get_instance, & + error_v_manager_ensure_instance_array_size_is_at_least => ensure_instance_array_size_is_at_least + + implicit none (type, external) + private + + public :: build_instance, finalise_instance, finalise_instances, & + ensure_at_least_n_instances_can_be_passed_simultaneously, & + get_code, get_message + +contains + + subroutine build_instance(code, message, instance_index) + !! Build an instance + + integer, intent(in) :: code + !! Error code + !! + !! Use [TODO: figure out xref] `NO_ERROR_CODE` if there is no error + + character(len=*), optional, intent(in) :: message + !! Error message + + integer, intent(out) :: instance_index + !! Instance index of the built instance + ! + ! This is the major trick for wrapping. + ! We pass instance indexes (integers) to Python rather than the instance itself. + + instance_index = error_v_manager_build_instance(code, message) + + end subroutine build_instance + + ! build_instances is very hard to do + ! because you need to pass an array of variable-length characters which is non-trivial. + ! Maybe we will try this another day, for now this isn't that important + ! (we can just use a loop from the Python side) + ! so we just don't bother implementing `build_instances`. + + subroutine finalise_instance(instance_index) + !! Finalise an instance + + integer, intent(in) :: instance_index + !! Instance index + ! + ! This is the major trick for wrapping. + ! We pass instance indexes (integers) to Python rather than the instance itself. + + call error_v_manager_finalise_instance(instance_index) + + end subroutine finalise_instance + + subroutine finalise_instances(instance_indexes) + !! Finalise an instance + + integer, dimension(:), intent(in) :: instance_indexes + !! Instance indexes to finalise + ! + ! This is the major trick for wrapping. + ! We pass instance indexes (integers) to Python rather than the instance itself. + + integer :: i + + do i = 1, size(instance_indexes) + call error_v_manager_finalise_instance(instance_indexes(i)) + end do + + end subroutine finalise_instances + + subroutine ensure_at_least_n_instances_can_be_passed_simultaneously(n) + !! Ensure that at least `n` instances of `ErrorV` can be passed via the manager simultaneously + + integer, intent(in) :: n + + call error_v_manager_ensure_instance_array_size_is_at_least(n) + + end subroutine ensure_at_least_n_instances_can_be_passed_simultaneously + + ! Full set of wrapping strategies to get/pass different types in e.g. + ! https://gitlab.com/magicc/fgen/-/blob/switch-to-uv/tests/test-data/exposed_attrs/src/exposed_attrs/exposed_attrs_wrapped.f90 + ! (we will do a full re-write of the code which generates this, + ! but the strategies will probably stay as they are) + subroutine get_code( & + instance_index, & + code & + ) + + integer, intent(in) :: instance_index + + integer, intent(out) :: code + + type(ErrorV) :: instance + + instance = error_v_manager_get_instance(instance_index) + + code = instance % code + + end subroutine get_code + + subroutine get_message( & + instance_index, & + message & + ) + + integer, intent(in) :: instance_index + + ! TODO: make this variable length + character(len=128), intent(out) :: message + + type(ErrorV) :: instance + + instance = error_v_manager_get_instance(instance_index) + + message = instance % message + + end subroutine get_message + +end module m_error_v_w diff --git a/src/example_fgen_basic/error_v/passing.f90 b/src/example_fgen_basic/error_v/passing.f90 new file mode 100644 index 0000000..c274eb7 --- /dev/null +++ b/src/example_fgen_basic/error_v/passing.f90 @@ -0,0 +1,55 @@ +!> Error passing +!> +!> A very basic demo to get the idea. +! +module m_error_v_passing + + use m_error_v, only: ErrorV, NO_ERROR_CODE + + implicit none (type, external) + private + + public :: pass_error, pass_errors + +contains + + function pass_error(inv) result(is_err) + !! Pass an error + !! + !! If an error is supplied, we return `.true.`, otherwise `.false.`. + + type(ErrorV), intent(in) :: inv + !! Input error value + + logical :: is_err + !! Whether `inv` is an error or not + + is_err = (inv % code /= NO_ERROR_CODE) + + end function pass_error + + function pass_errors(invs, n) result(is_errs) + !! Pass a number of errors + !! + !! For each value in `invs`, if an error is supplied, we return `.true.`, otherwise `.false.`. + + type(ErrorV), dimension(n), intent(in) :: invs + !! Input error values + + integer, intent(in) :: n + !! Number of values being passed + + logical, dimension(n) :: is_errs + !! Whether each value in `invs` is an error or not + + integer :: i + + do i = 1, n + + is_errs(i) = pass_error(invs(i)) + + end do + + end function pass_errors + +end module m_error_v_passing diff --git a/src/example_fgen_basic/error_v/passing.py b/src/example_fgen_basic/error_v/passing.py new file mode 100644 index 0000000..c48f77b --- /dev/null +++ b/src/example_fgen_basic/error_v/passing.py @@ -0,0 +1,94 @@ +""" +Wrappers of `m_error_v_passing` [TODO think about naming and x-referencing] + +At the moment, all written by hand. +We will auto-generate this in future. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from example_fgen_basic.error_v import ErrorV +from example_fgen_basic.pyfgen_runtime.exceptions import CompiledExtensionNotFoundError + +try: + from example_fgen_basic._lib import ( # type: ignore + m_error_v_w, + ) +except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover + raise CompiledExtensionNotFoundError("example_fgen_basic._lib.m_error_v_w") from exc +try: + from example_fgen_basic._lib import m_error_v_passing_w +except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover + raise CompiledExtensionNotFoundError( + "example_fgen_basic._lib.m_error_v_passing_w" + ) from exc + +if TYPE_CHECKING: + from example_fgen_basic.typing import NP_ARRAY_OF_BOOL, NP_ARRAY_OF_INT + + +def pass_error(inv: ErrorV) -> bool: + """ + Pass an error to Fortran + + Parameters + ---------- + inv + Input value to pass to Fortran + + Returns + ------- + : + If `inv` is an error, `True`, otherwise `False`. + """ + # Tell Fortran to build the object on the Fortran side + instance_index = inv.build_fortran_instance() + + # Call the Fortran function + # Boolean wrapping strategy, have to cast to bool + res_raw: int = m_error_v_passing_w.pass_error(instance_index) + res = bool(res_raw) + + # Tell Fortran to finalise the object on the Fortran side + # (all data has been used for the call now) + m_error_v_w.finalise_instance(instance_index) + + return res + + +def pass_errors(invs: tuple[ErrorV, ...]) -> NP_ARRAY_OF_BOOL: + """ + Pass a number of errors to Fortran + + Parameters + ---------- + invs + Errors to pass to Fortran + + Returns + ------- + : + Whether each value in `invs` is an error or not + """ + # Controlling memory from the Python side + m_error_v_w.ensure_at_least_n_instances_can_be_passed_simultaneously(len(invs)) + # TODO: consider adding `build_instances` too, might be headache + instance_indexes: NP_ARRAY_OF_INT = np.array( + [m_error_v_w.build_instance(code=inv.code, message=inv.message) for inv in invs] + ) + + # Convert the result to boolean + res_raw: NP_ARRAY_OF_INT = m_error_v_passing_w.pass_errors( + instance_indexes, n=instance_indexes.size + ) + res = res_raw.astype(bool) + + # Tell Fortran to finalise the objects on the Fortran side + # (all data has been copied to Python now) + m_error_v_w.finalise_instances(instance_indexes) + + return res diff --git a/src/example_fgen_basic/error_v/passing_wrapper.f90 b/src/example_fgen_basic/error_v/passing_wrapper.f90 new file mode 100644 index 0000000..7fd899b --- /dev/null +++ b/src/example_fgen_basic/error_v/passing_wrapper.f90 @@ -0,0 +1,81 @@ +!> Wrapper for interfacing `m_error_v_passing` with Python +!> +!> Written by hand here. +!> Generation to be automated in future (including docstrings of some sort). +module m_error_v_passing_w + + ! => allows us to rename on import to avoid clashes + ! "o_" for original (TODO: better naming convention) + use m_error_v_passing, only: & + o_pass_error => pass_error, & + o_pass_errors => pass_errors + use m_error_v, only: ErrorV + + ! The manager module, which makes this all work + use m_error_v_manager, only: & + error_v_manager_get_instance => get_instance + ! error_v_manager_get_available_instance_index => get_available_instance_index, & + ! error_v_manager_set_instance_index_to => set_instance_index_to, & + ! error_v_manager_ensure_instance_array_size_is_at_least => ensure_instance_array_size_is_at_least + + implicit none (type, external) + private + + public :: pass_error, pass_errors + +contains + + function pass_error(inv_instance_index) result(res) + !! Wrapper around `m_error_v_passing.pass_error` (TODO: x-ref) + + integer, intent(in) :: inv_instance_index + !! Input values + !! + !! See docstring of `m_error_v_passing.pass_error` for details. + !! [TODO: x-ref] + !! The trick here is to pass in the instance index, not the instance itself + + logical :: res + !! Whether the instance referred to by `inv_instance_index` is an error or not + + type(ErrorV) :: instance + + instance = error_v_manager_get_instance(inv_instance_index) + + ! Do the Fortran call + res = o_pass_error(instance) + + end function pass_error + + function pass_errors(inv_instance_indexes, n) result(res) + !! Wrapper around `m_error_v_passing.pass_errors` (TODO: x-ref) + + integer, dimension(n), intent(in) :: inv_instance_indexes + !! Input values + !! + !! See docstring of `m_error_v_passing.pass_errors` for details. + !! [TODO: x-ref] + + integer, intent(in) :: n + !! Number of values to pass + + logical, dimension(n) :: res + !! Whether each instance in the array backed by `inv_instance_indexes` is an error or not + ! + ! This is the major trick for wrapping. + ! We pass instance indexes (integers) from Python rather than the instance itself. + + type(ErrorV), dimension(n) :: instances + + integer :: i + + do i = 1, n + instances(i) = error_v_manager_get_instance(inv_instance_indexes(i)) + end do + + ! Do the Fortran call + res = o_pass_errors(instances, n) + + end function pass_errors + +end module m_error_v_passing_w diff --git a/src/example_fgen_basic/exceptions.py b/src/example_fgen_basic/exceptions.py index 85ca24e..5de3212 100644 --- a/src/example_fgen_basic/exceptions.py +++ b/src/example_fgen_basic/exceptions.py @@ -57,7 +57,7 @@ def __init__(self, instance: Any, method: Optional[Callable[..., Any]] = None): if method: error_msg = f"{instance} must be initialised before {method} is called" else: - error_msg = f"instance ({instance:r}) is not initialized yet" + error_msg = f"instance ({instance:r}) is not initialised yet" super().__init__(error_msg) diff --git a/src/example_fgen_basic/fpyfgen/base_finalisable.f90 b/src/example_fgen_basic/fpyfgen/base_finalisable.f90 new file mode 100644 index 0000000..617ecc0 --- /dev/null +++ b/src/example_fgen_basic/fpyfgen/base_finalisable.f90 @@ -0,0 +1,49 @@ +!> Base class for classes that can be wrapped with pyfgen. +!> +!> Such classes must always be finalisable, to help with memory management +!> across the Python-Fortran interface. +module fpyfgen_base_finalisable + + implicit none (type, external) + private + + integer, parameter, public :: INVALID_INSTANCE_INDEX = -1 + !! Value that denotes an invalid model index + + public :: BaseFinalisable + + type, abstract :: BaseFinalisable + + integer :: instance_index = INVALID_INSTANCE_INDEX + !! Unique identifier for the instance. + !! + !! Set to a value > 0 when the instance is in use, + !! set to `INVALID_INSTANCE_INDEX` (TODO xref) otherwise. + !! The value is linked to the position in a manager array stored elsewhere. + !! This value shouldn't be modified from outside the manager + !! unless you really know what you're doing. + + contains + + private + + procedure(derived_type_finalise), public, deferred :: finalise + + end type BaseFinalisable + + interface + + subroutine derived_type_finalise(self) + !! Finalise the instance (i.e. free/deallocate) + + import :: BaseFinalisable + + implicit none (type, external) + + class(BaseFinalisable), intent(inout) :: self + + end subroutine derived_type_finalise + + end interface + +end module fpyfgen_base_finalisable diff --git a/src/example_fgen_basic/fpyfgen/derived_type_manager_helpers.f90 b/src/example_fgen_basic/fpyfgen/derived_type_manager_helpers.f90 new file mode 100644 index 0000000..9a4148c --- /dev/null +++ b/src/example_fgen_basic/fpyfgen/derived_type_manager_helpers.f90 @@ -0,0 +1,79 @@ +!> Helpers for derived type managers +module fpyfgen_derived_type_manager_helpers + + use fpyfgen_base_finalisable, only: BaseFinalisable, invalid_instance_index + + implicit none (type, external) + private + + public :: get_derived_type_free_instance_number, & + finalise_derived_type_instance_number + +contains + + subroutine get_derived_type_free_instance_number(instance_index, n_instances, instance_avail, instance_array) + !! Get the next available instance number + !! + !! If successful, `instance_index` will contain a positive value. + !! If no available instances are found, + !! instance_index will be set to `invalid_instance_index`. + !! TODO: change the above to return a Result type instead + + integer, intent(out) :: instance_index + !! Free index + !! + !! If no available instances are found, set to `invalid_instance_index`. + + integer, intent(in) :: n_instances + !! Size of `instance_avail` + + logical, dimension(n_instances), intent(inout) :: instance_avail + !! Array that indicates whether each index is available or not + + class(BaseFinalisable), dimension(n_instances), intent(inout) :: instance_array + !! Array of instances + + integer :: i + + ! Default if no available models are found + instance_index = invalid_instance_index + + do i = 1, n_instances + + if (instance_avail(i)) then + + instance_avail(i) = .false. + instance_array(i) % instance_index = i + instance_index = i + return + + end if + + end do + + ! Should be an error or similar here + + end subroutine get_derived_type_free_instance_number + + subroutine finalise_derived_type_instance_number(instance_index, n_instances, instance_avail, instance_array) + !! Finalise the derived type with the given instance index + + integer, intent(in) :: instance_index + !! Index of the instance to finalise + + integer, intent(in) :: n_instances + !! Size of `instance_avail` + + logical, dimension(n_instances), intent(inout) :: instance_avail + !! Array that indicates whether each index is available or not + + class(BaseFinalisable), dimension(n_instances), intent(inout) :: instance_array + !! Array of instances + + call instance_array(instance_index) % finalise() + instance_array(instance_index) % instance_index = invalid_instance_index + instance_avail(instance_index) = .true. + + end subroutine finalise_derived_type_instance_number + +end module fpyfgen_derived_type_manager_helpers diff --git a/src/example_fgen_basic/get_wavelength.py b/src/example_fgen_basic/get_wavelength.py index c6127a6..c00c4b7 100644 --- a/src/example_fgen_basic/get_wavelength.py +++ b/src/example_fgen_basic/get_wavelength.py @@ -18,7 +18,9 @@ try: from example_fgen_basic._lib import m_get_wavelength_w # type: ignore except (ModuleNotFoundError, ImportError) as exc: # pragma: no cover - raise CompiledExtensionNotFoundError("example_fgen_basic._lib") from exc + raise CompiledExtensionNotFoundError( + "example_fgen_basic._lib.m_get_wavelength_w" + ) from exc def get_wavelength_plain(frequency: float) -> float: diff --git a/src/example_fgen_basic/meson.build b/src/example_fgen_basic/meson.build index 741b8be..8c67049 100644 --- a/src/example_fgen_basic/meson.build +++ b/src/example_fgen_basic/meson.build @@ -1,4 +1,7 @@ srcs += files( + 'error_v/creation.f90', + 'error_v/error_v.f90', + 'fpyfgen/base_finalisable.f90', 'get_wavelength.f90', 'kind_parameters.f90', ) diff --git a/src/example_fgen_basic/operations.py b/src/example_fgen_basic/operations.py deleted file mode 100644 index 84d8562..0000000 --- a/src/example_fgen_basic/operations.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Operations - -This module is just there to help with doc building etc. on project creation. -You will probably delete it early in the project. -""" - -from __future__ import annotations - - -def add_two(a: float, b: float) -> float: - """ - Add two numbers - - Parameters - ---------- - a - First number - - b - Second number - - Returns - ------- - : - Sum of the numbers - - Examples - -------- - >>> add_two(1, 3) - 4 - >>> add_two(1, -3) - -2 - """ - return a + b diff --git a/src/example_fgen_basic/pyfgen_runtime/__init__.py b/src/example_fgen_basic/pyfgen_runtime/__init__.py new file mode 100644 index 0000000..d9e9108 --- /dev/null +++ b/src/example_fgen_basic/pyfgen_runtime/__init__.py @@ -0,0 +1,7 @@ +""" +Runtime helpers for code generated with pyfgen + +Code that will eventually get moved into a standalone package +""" + +from __future__ import annotations diff --git a/src/example_fgen_basic/pyfgen_runtime/exceptions.py b/src/example_fgen_basic/pyfgen_runtime/exceptions.py new file mode 100644 index 0000000..0edd2ed --- /dev/null +++ b/src/example_fgen_basic/pyfgen_runtime/exceptions.py @@ -0,0 +1,80 @@ +""" +Runtime exceptions +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + + +class CompiledExtensionNotFoundError(ImportError): + """ + Raised when a compiled extension can't be imported i.e. found + """ + + def __init__(self, compiled_extension_name: str): + error_msg = f"Could not find compiled extension {compiled_extension_name!r}" + + super().__init__(error_msg) + + +class MissingOptionalDependencyError(ImportError): + """ + Raised when an optional dependency is missing + + For example, plotting dependencies like matplotlib + """ + + def __init__(self, callable_name: str, requirement: str) -> None: + """ + Initialise the error + + Parameters + ---------- + callable_name + The name of the callable that requires the dependency + + requirement + The name of the requirement + """ + error_msg = f"`{callable_name}` requires {requirement} to be installed" + super().__init__(error_msg) + + +class WrapperError(ValueError): + """ + Base exception for errors that arise from wrapper functionality + """ + + +class NotInitialisedError(WrapperError): + """ + Raised when the wrapper around the Fortran module hasn't been initialised yet + """ + + def __init__(self, instance: Any, method: Optional[Callable[..., Any]] = None): + if method: + error_msg = f"{instance} must be initialised before {method} is called" + else: + error_msg = f"instance ({instance:r}) is not initialised yet" + + super().__init__(error_msg) + + +# TODO: change or even remove this when we move to better error handling +class UnallocatedMemoryError(ValueError): + """ + Raised when we try to access memory that has not yet been allocated + + We can't always catch this error, but this is what we raise when we can. + """ + + def __init__(self, variable_name: str): + error_msg = ( + f"The memory required to access `{variable_name}` is unallocated. " + "You must allocate it before trying to access its value. " + "Unfortunately, we cannot provide more information " + "about why this memory is not yet allocated." + ) + + super().__init__(error_msg) diff --git a/src/example_fgen_basic/runtime_helpers.py b/src/example_fgen_basic/runtime_helpers.py deleted file mode 100644 index 3249812..0000000 --- a/src/example_fgen_basic/runtime_helpers.py +++ /dev/null @@ -1,385 +0,0 @@ -""" -Runtime helpers - -These would be moved to fgen-runtime or a similar package -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Iterable -from functools import wraps -from typing import Any, Callable, TypeVar - -import attrs -from attrs import define, field -from typing_extensions import Concatenate, ParamSpec - -from example_fgen_basic.exceptions import NotInitialisedError, UnallocatedMemoryError - -# Might be needed for Python 3.9 -# from typing_extensions import Concatenate, ParamSpec - - -# TODO: move this section to formatting module - - -def get_attribute_str_value(instance: FinalisableWrapperBase, attribute: str) -> str: - """ - Get the string version of an attribute's value - - Parameters - ---------- - instance - Instance from which to get the attribute - - attribute - Attribute for which to get the value - - Returns - ------- - String version of the attribute's value, with graceful handling of errors. - """ - try: - return f"{attribute}={getattr(instance, attribute)}" - except UnallocatedMemoryError: - # TODO: change this when we move to better error handling - return f"{attribute} is unallocated" - - -def to_str(instance: FinalisableWrapperBase, exposed_attributes: Iterable[str]) -> str: - """ - Convert an instance to its string representation - - Parameters - ---------- - instance - Instance to convert - - exposed_attributes - Attributes from Fortran that the instance exposes - - Returns - ------- - String representation of the instance - """ - if not instance.initialized: - return f"Uninitialised {instance!r}" - - if not exposed_attributes: - return repr(instance) - - attribute_values = [ - get_attribute_str_value(instance, v) for v in exposed_attributes - ] - - return f"{repr(instance)[:-1]}, {', '.join(attribute_values)})" - - -def to_pretty( - instance: FinalisableWrapperBase, - exposed_attributes: Iterable[str], - p: Any, - cycle: bool, - indent: int = 4, -) -> None: - """ - Pretty-print an instance - - Parameters - ---------- - instance - Instance to convert - - exposed_attributes - Attributes from Fortran that the instance exposes - - p - Pretty printing object - - cycle - Whether the pretty printer has detected a cycle or not. - - indent - Indent to apply to the pretty printing group - """ - if not instance.initialized: - p.text(str(instance)) - return - - if not exposed_attributes: - p.text(str(instance)) - return - - with p.group(indent, f"{repr(instance)[:-1]}", ")"): - for att in exposed_attributes: - p.text(",") - p.breakable() - - p.text(get_attribute_str_value(instance, att)) - - -def add_attribute_row( - attribute_name: str, attribute_value: str, attribute_rows: list[str] -) -> list[str]: - """ - Add a row for displaying an attribute's value to a list of rows - - Parameters - ---------- - attribute_name - Attribute's name - - attribute_value - Attribute's value - - attribute_rows - Existing attribute rows - - - Returns - ------- - Attribute rows, with the new row appended - """ - attribute_rows.append( - f"{attribute_name}{attribute_value}" # noqa: E501 - ) - - return attribute_rows - - -def to_html(instance: FinalisableWrapperBase, exposed_attributes: Iterable[str]) -> str: - """ - Convert an instance to its html representation - - Parameters - ---------- - instance - Instance to convert - - exposed_attributes - Attributes from Fortran that the instance exposes - - Returns - ------- - HTML representation of the instance - """ - if not instance.initialized: - return str(instance) - - if not exposed_attributes: - return str(instance) - - instance_class_name = repr(instance).split("(")[0] - - attribute_rows: list[str] = [] - for att in exposed_attributes: - try: - att_val = getattr(instance, att) - except UnallocatedMemoryError: - # TODO: change this when we move to better error handling - att_val = "Unallocated" - attribute_rows = add_attribute_row(att, att_val, attribute_rows) - continue - - try: - att_val = att_val._repr_html_() - except AttributeError: - att_val = str(att_val) - - attribute_rows = add_attribute_row(att, att_val, attribute_rows) - - attribute_rows_for_table = "\n ".join(attribute_rows) - - css_style = """.fgen-wrap { - /*font-family: monospace;*/ - width: 540px; -} - -.fgen-header { - padding: 6px 0 6px 3px; - border-bottom: solid 1px #777; - color: #555;; -} - -.fgen-header > div { - display: inline; - margin-top: 0; - margin-bottom: 0; -} - -.fgen-basefinalizable-cls, -.fgen-basefinalizable-instance-index { - margin-left: 2px; - margin-right: 10px; -} - -.fgen-basefinalizable-cls { - font-weight: bold; - color: #000000; -}""" - - return "\n".join( - [ - "
", - " ", - "
", - "
", - f"
{instance_class_name}
", - f"
instance_index={instance.instance_index}
", # noqa: E501 - " ", - f" {attribute_rows_for_table}", - "
", - "
", - "
", - "
", - ] - ) - - -# End of stuff to move to formatting module - -INVALID_INSTANCE_INDEX: int = -1 -""" -Value used to denote an invalid ``instance_index``. - -This can occur value when a wrapper class -has not yet been initialised (connected to a Fortran instance). -""" - - -@define -class FinalisableWrapperBase(ABC): - """ - Base class for Fortran derived type wrappers - """ - - instance_index: int = field( - validator=attrs.validators.instance_of(int), - default=INVALID_INSTANCE_INDEX, - ) - """ - Model index of wrapper Fortran instance - """ - - def __str__(self) -> str: - """ - Get string representation of self - """ - return to_str( - self, - self.exposed_attributes, - ) - - def _repr_pretty_(self, p: Any, cycle: bool) -> None: - """ - Get pretty representation of self - - Used by IPython notebooks and other tools - """ - to_pretty( - self, - self.exposed_attributes, - p=p, - cycle=cycle, - ) - - def _repr_html_(self) -> str: - """ - Get html representation of self - - Used by IPython notebooks and other tools - """ - return to_html( - self, - self.exposed_attributes, - ) - - @property - def initialized(self) -> bool: - """ - Is the instance initialised, i.e. connected to a Fortran instance? - """ - return self.instance_index != INVALID_INSTANCE_INDEX - - @property - @abstractmethod - def exposed_attributes(self) -> tuple[str, ...]: - """ - Attributes exposed by this wrapper - """ - ... - - # @classmethod - # @abstractmethod - # def from_new_connection(cls) -> FinalisableWrapperBase: - # """ - # Initialise by establishing a new connection with the Fortran module - # - # This requests a new model index from the Fortran module and then - # initialises a class instance - # - # Returns - # ------- - # New class instance - # """ - # ... - # - # @abstractmethod - # def finalize(self) -> None: - # """ - # Finalise the Fortran instance and set self back to being uninitialised - # - # This method resets ``self.instance_index`` back to - # ``_UNINITIALISED_instance_index`` - # - # Should be decorated with :func:`check_initialised` - # """ - # # call to Fortran module goes here when implementing - # self._uninitialise_instance_index() - - def _uninitialise_instance_index(self) -> None: - self.instance_index = INVALID_INSTANCE_INDEX - - -P = ParamSpec("P") -T = TypeVar("T") -Wrapper = TypeVar("Wrapper", bound=FinalisableWrapperBase) - - -def check_initialised( - method: Callable[Concatenate[Wrapper, P], T], -) -> Callable[Concatenate[Wrapper, P], T]: - """ - Check that the wrapper object has been initialised before executing the method - - Parameters - ---------- - method - Method to wrap - - Returns - ------- - : - Wrapped method - - Raises - ------ - InitialisationError - Wrapper is not initialised - """ - - @wraps(method) - def checked( - ref: Wrapper, - *args: P.args, - **kwargs: P.kwargs, - ) -> Any: - if not ref.initialized: - raise NotInitialisedError(ref, method) - - return method(ref, *args, **kwargs) - - return checked # type: ignore diff --git a/src/example_fgen_basic/typing.py b/src/example_fgen_basic/typing.py new file mode 100644 index 0000000..1938d5e --- /dev/null +++ b/src/example_fgen_basic/typing.py @@ -0,0 +1,44 @@ +""" +Type hints which are too annoying to remember +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Union + + import numpy as np + import numpy.typing as npt + from typing_extensions import Any, TypeAlias + + NP_ARRAY_OF_INT: TypeAlias = npt.NDArray[np.integer[Any]] + """ + Type alias for an array of numpy int + """ + + NP_ARRAY_OF_FLOAT: TypeAlias = npt.NDArray[np.floating[Any]] + """ + Type alias for an array of numpy floats + """ + + NP_FLOAT_OR_INT: TypeAlias = Union[np.floating[Any], np.integer[Any]] + """ + Type alias for a numpy float or int (not complex) + """ + + NP_ARRAY_OF_FLOAT_OR_INT: TypeAlias = npt.NDArray[NP_FLOAT_OR_INT] + """ + Type alias for an array of numpy float or int (not complex) + """ + + NP_ARRAY_OF_NUMBER: TypeAlias = npt.NDArray[np.number[Any]] + """ + Type alias for an array of numpy float or int (including complex) + """ + + NP_ARRAY_OF_BOOL: TypeAlias = npt.NDArray[np.bool] + """ + Type alias for an array of booleans + """ diff --git a/tests/conftest.py b/tests/conftest.py index a048f0f..902c989 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,3 +3,18 @@ See https://docs.pytest.org/en/7.1.x/reference/fixtures.html#conftest-py-sharing-fixtures-across-multiple-files """ + +import os + +if dll_directory_to_add := os.environ.get("PYTHON_ADD_DLL_DIRECTORY", None): + # Add the directory which has the libgfortran.dll file. + # + # A super deep dive into this is here: + # https://stackoverflow.com/a/78276248 + # The tl;dr is - mingw64's linker can be tricked by windows craziness + # into linking a dynamic library even when we wanted only static links, + # so if you want to avoid this, link with something else (e.g. the MS linker). + # (From what I know, this isn't an issue for the built wheels + # thanks to the cleverness of delvewheel) + os.add_dll_directory(dll_directory_to_add) + # os.add_dll_directory("C:\\mingw64\\bin") diff --git a/tests/unit/main.f90 b/tests/unit/main.f90 index b63d95b..c693d0f 100644 --- a/tests/unit/main.f90 +++ b/tests/unit/main.f90 @@ -3,6 +3,8 @@ program tester_unit use, intrinsic :: iso_fortran_env, only: error_unit use testdrive, only: run_testsuite, new_testsuite, testsuite_type, select_suite, run_selected, get_argument + + use test_error_v_creation, only: collect_error_v_creation_tests use test_get_wavelength, only: collect_get_wavelength_tests implicit none (type, external) @@ -14,7 +16,10 @@ program tester_unit stat = 0 ! add new tests here - testsuites = [new_testsuite("test_get_wavelength", collect_get_wavelength_tests)] + testsuites = [ & + new_testsuite("test_get_wavelength", collect_get_wavelength_tests), & + new_testsuite("test_error_v_creation", collect_error_v_creation_tests) & + ] call get_argument(1, suite_name) call get_argument(2, test_name) diff --git a/tests/unit/meson.build b/tests/unit/meson.build index 4e69e7c..fb79a84 100644 --- a/tests/unit/meson.build +++ b/tests/unit/meson.build @@ -7,6 +7,7 @@ testdrive_dep = dependency( test_file_stubs = [ 'test_get_wavelength', + 'test_error_v_creation', ] test_srcs = files( diff --git a/tests/unit/test_error_v_creation.f90 b/tests/unit/test_error_v_creation.f90 new file mode 100644 index 0000000..b5b8d85 --- /dev/null +++ b/tests/unit/test_error_v_creation.f90 @@ -0,0 +1,72 @@ +!> Tests of m_error_v_creation +module test_error_v_creation + + ! How to print to stdout + use, intrinsic :: ISO_Fortran_env, only: stdout => OUTPUT_UNIT + use testdrive, only: new_unittest, unittest_type, error_type, check + + use kind_parameters, only: dp + + implicit none (type, external) + private + + public :: collect_error_v_creation_tests + +contains + + subroutine collect_error_v_creation_tests(testsuite) + !> Collection of tests + type(unittest_type), allocatable, intent(out) :: testsuite(:) + + testsuite = [ & + new_unittest("test_error_v_creation_basic", test_error_v_creation_basic), & + new_unittest("test_error_v_creation_edge", test_error_v_creation_edge) & + ] + + end subroutine collect_error_v_creation_tests + + subroutine test_error_v_creation_basic(error) + use m_error_v, only: ErrorV + use m_error_v_passing, only: create_error + + type(error_type), allocatable, intent(out) :: error + + type(ErrorV) :: res + + res = create_error(1) + + ! ! How to print to stdout + ! write( stdout, '(e13.4e2)') res + ! write( stdout, '(e13.4e2)') exp + + call check(error, res % code, 0) + call check(error, res % message, "") + + end subroutine test_error_v_creation_basic + + subroutine test_error_v_creation_edge(error) + use m_error_v, only: ErrorV + use m_error_v_passing, only: create_error + + type(error_type), allocatable, intent(out) :: error + + ! type(ErrorV), target :: res + ! type(ErrorV), pointer :: res_ptr + ! + ! res = create_error(1) + ! res_ptr => res + type(ErrorV), pointer :: res + + allocate(res) + res = create_error(1) + + ! ! How to print to stdout + ! write( stdout, '(e13.4e2)') res + ! write( stdout, '(e13.4e2)') exp + + call check(error, res % code, 0) + call check(error, res % message, "") + + end subroutine test_error_v_creation_edge + +end module test_error_v_creation diff --git a/tests/unit/test_error_v_creation.py b/tests/unit/test_error_v_creation.py new file mode 100644 index 0000000..b3d3c7e --- /dev/null +++ b/tests/unit/test_error_v_creation.py @@ -0,0 +1,59 @@ +""" +Tests of `example_fgen_basic.error_v.creation` +""" + +import numpy as np + +from example_fgen_basic.error_v import ErrorV +from example_fgen_basic.error_v.creation import create_error, create_errors + + +def test_create_error_odd(): + res = create_error(1.0) + + assert isinstance(res, ErrorV) + + assert res.code == 0 + assert res.message == "" + + +def test_create_error_even(): + res = create_error(2.0) + + assert isinstance(res, ErrorV) + + assert res.code != 0 + assert res.code == 1 + assert res.message == "Even number supplied" + + +def test_create_error_negative(): + res = create_error(-1.0) + + assert isinstance(res, ErrorV) + + assert res.code == 2 + assert res.message == "Negative number supplied" + + +def test_create_error_lots_of_repeated_calls(): + # We should be able to just keep calling `create_error` + # without hitting segfaults or other weirdness. + # This is basically testing that we're freeing the temporary + # Fortran derived types correctly + # (and sort of a speed test, this shouldn't be noticeably slow) + # hence we may move this test somewhere more generic at some point. + for _ in range(int(1e5)): + create_error(1) + + +def test_create_multiple_errors(): + res = create_errors(np.arange(6)) + + for i, v in enumerate(res): + if i % 2 == 0: + assert v.code == 1 + assert v.message == "Even number supplied" + else: + assert v.code == 0 + assert v.message == "" diff --git a/tests/unit/test_error_v_passing.py b/tests/unit/test_error_v_passing.py new file mode 100644 index 0000000..341ac00 --- /dev/null +++ b/tests/unit/test_error_v_passing.py @@ -0,0 +1,37 @@ +""" +Tests of `example_fgen_basic.error_v.passing` +""" + +import numpy as np + +from example_fgen_basic.error_v import ErrorV +from example_fgen_basic.error_v.passing import pass_error, pass_errors + + +def test_pass_error_odd(): + res = pass_error(ErrorV(code=1, message="hi")) + + assert res + + +def test_pass_error_even(): + res = pass_error(ErrorV(code=0)) + + assert not res + + +def test_pass_error_lots_of_repeated_calls(): + # We should be able to just keep calling `pass_error` + # without hitting segfaults or other weirdness. + # This is basically testing that we're freeing the temporary + # Fortran derived types correctly + # (and sort of a speed test, this shouldn't be noticeably slow) + # hence we may move this test somewhere more generic at some point. + for _ in range(int(1e5)): + pass_error(ErrorV(code=0)) + + +def test_pass_multiple_errors(): + res = pass_errors([ErrorV(code=0), ErrorV(code=0), ErrorV(code=1)]) + + np.testing.assert_array_equal(res, np.array([False, False, True])) diff --git a/tests/unit/test_get_wavelength.f90 b/tests/unit/test_get_wavelength.f90 index d69c1b1..31c56b3 100644 --- a/tests/unit/test_get_wavelength.f90 +++ b/tests/unit/test_get_wavelength.f90 @@ -1,4 +1,4 @@ -!> Tests of get_wavelength +!> Tests of m_get_wavelength module test_get_wavelength ! How to print to stdout diff --git a/tests/unit/test_get_wavelength.py b/tests/unit/test_get_wavelength.py index c75ac87..ed5c3db 100644 --- a/tests/unit/test_get_wavelength.py +++ b/tests/unit/test_get_wavelength.py @@ -1,5 +1,5 @@ """ -Dummy tests +Tests of `example_fgen_basic.get_wavelength` """ import numpy as np diff --git a/tests/unit/test_operations.py b/tests/unit/test_operations.py deleted file mode 100644 index 1f7fcf1..0000000 --- a/tests/unit/test_operations.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Test operations - -This file is just there to help demonstrate the initial setup. -You will probably delete it early in the project. -""" - -from example_fgen_basic.operations import add_two - - -def test_add_two(): - assert add_two(3.3, 4.4) == 7.7 diff --git a/uv.lock b/uv.lock index 5099dba..c6813f1 100644 --- a/uv.lock +++ b/uv.lock @@ -821,6 +821,7 @@ docs = [ { name = "mkdocs-section-index" }, { name = "mkdocstrings-python" }, { name = "mkdocstrings-python-xref" }, + { name = "pint" }, { name = "pymdown-extensions" }, { name = "ruff" }, ] @@ -906,6 +907,7 @@ docs = [ { name = "mkdocs-section-index", specifier = ">=0.3.10" }, { name = "mkdocstrings-python", specifier = ">=1.16.12" }, { name = "mkdocstrings-python-xref", specifier = ">=1.16.3" }, + { name = "pint", specifier = ">=0.24.4" }, { name = "pymdown-extensions", specifier = ">=10.16.1" }, { name = "ruff", specifier = ">=0.12.8" }, ]