diff --git a/Docs/source/developer/developer.rst b/Docs/source/developer/developer.rst
new file mode 100644
index 0000000..c296f2c
--- /dev/null
+++ b/Docs/source/developer/developer.rst
@@ -0,0 +1,10 @@
+PCIMI Developer Documentation
+=============================
+
+This section is aimed at the developer of PICMI and guide them through general design and internal functionality of PICMI itself, i.e. the python module ``picmistandard``.
+It does **not** explain how to implement PICMI in an existing PIC simulation.
+
+.. toctree::
+ :maxdepth: 1
+
+ general_class_design
diff --git a/Docs/source/developer/general_class_design.rst b/Docs/source/developer/general_class_design.rst
new file mode 100644
index 0000000..1d1941e
--- /dev/null
+++ b/Docs/source/developer/general_class_design.rst
@@ -0,0 +1,85 @@
+General Class Design
+====================
+
+All PICMI classes inherit from ``_ClassWithInit``, which makes a class defined by purely attributes exhibit *the typical PICMI behavior*:
+
+.. code-block:: python
+
+ >>> class MyClass(_ClassWithInit):
+ ... mandatory_attr: typing.Any
+ ... name: str = ""
+ ... optional: typing.Optional[int] = 3
+ ... _protected = 1
+ >>> my_object = MyClass(mandatory_attr=[],
+ ... name="any string")
+ >>> [] == my_object.mandatory_attr
+ True
+ >>> "any string" == my_object.name
+ True
+ >>> 3 == my_object.optional
+ True
+ >>> my_object.optional = None
+ >>> my_object.check() # <- silently passes
+ >>> my_object.optional = "not an integer"
+ >>> my_object.check() # <- now throws
+ [...TypeError stack trace omitted...]
+
+
+General Functionality
+---------------------
+When a class has a set attributes, which may have a `PEP 484 type annotation `_, and a default value, a constructor is provided which behaves as follows:
+
+For all arguments (``kwargs``) check:
+
+- **allow** if:
+ - attribute is defined, or
+ - attribute starts with the current code name, or
+ - attribute starts with a known code name
+- **reject** if:
+ - attribute starts with a ``_`` (is private or protected), or
+ - is unkown (and not one of the exceptions above)
+
+If a type annotation is given, the value is checked against it, otherwise all types are allowed.
+
+If a type has **no default value** it is considered **mandatory**:
+The constructure will abort operation if it is not given.
+
+Additionally, an implementing class may implement a method ``_check(self) -> None``, which can perform additional checks.
+
+All checks (type checks and custom checks by ``_check()``) can be manually invoked by calling ``check()``.
+
+After construction ``check()`` will automatically be called.
+
+An implementation should call ``check()`` just before it begins it operation too.
+
+Mandatory and Optional Attributes
+---------------------------------
+
+Unless a default value is given, attributes are considered mandatory.
+Note that the default must conform to the given type specification -- to set a default to ``None``, the type specification must allow it.
+
+Type Checking
+-------------
+
+Type checking is performed only for attributes, and delegated to the library `typeguard `_.
+Methods must be checked by other means (e.g. by using typeguard ``@typechecked`` annotation).
+
+Other Checks
+------------
+
+The method ``check()`` provides type checking for the constructor and other objects.
+
+- ``check()``: **external** interface, performs typechecks and calls ``_check()``. **DO NOT OVERWRITE**
+- ``_check()``: **custom** hook, optional, automatically called from wrapper ``check()``. **DO OVERWRITE**
+
+To implement custom checks overwrite ``_check(self) -> None``, which will be called from the wrapper ``check()`` after all typechecks have passed.
+
+``_check()`` must raise when it encounters an error, passing silently will consider the check to have succeeded.
+
+Note that owned objects will not be checked by default, consider calling their respective ``check()`` inside the custom ``_check()``.
+
+Full Reference
+--------------
+
+.. autoclass:: picmistandard.base._ClassWithInit
+ :members: __init__, _check, check
diff --git a/Docs/source/index.rst b/Docs/source/index.rst
index b564a48..23bc739 100644
--- a/Docs/source/index.rst
+++ b/Docs/source/index.rst
@@ -31,3 +31,4 @@ For more details on the standard and how to use it, see the links below.
how_to_use/how_to_use.rst
standard/standard.rst
+ developer/developer.rst
diff --git a/PICMI_Python/applied_fields.py b/PICMI_Python/applied_fields.py
index 74def08..e1ebdad 100644
--- a/PICMI_Python/applied_fields.py
+++ b/PICMI_Python/applied_fields.py
@@ -19,24 +19,18 @@ class PICMI_ConstantAppliedField(_ClassWithInit):
- Bx: Constant Bx field (float) [T]
- By: Constant By field (float) [T]
- Bz: Constant Bz field (float) [T]
- - lower_bound=[None,None,None]: Lower bound of the region where the field is applied (vector) [m]
- - upper_bound=[None,None,None]: Upper bound of the region where the field is applied (vector) [m]
+ - lower_bound: Lower bound of the region where the field is applied (vector) [m]
+ - upper_bound: Upper bound of the region where the field is applied (vector) [m]
"""
- def __init__(self, Ex=None, Ey=None, Ez=None, Bx=None, By=None, Bz=None,
- lower_bound=[None,None,None], upper_bound=[None,None,None],
- **kw):
-
- self.Ex = Ex
- self.Ey = Ey
- self.Ez = Ez
- self.Bx = Bx
- self.By = By
- self.Bz = Bz
- self.lower_bound = lower_bound
- self.upper_bound = upper_bound
-
- self.handle_init(kw)
+ Ex = None
+ Ey = None
+ Ez = None
+ Bx = None
+ By = None
+ Bz = None
+ lower_bound = [None, None, None]
+ upper_bound = [None, None, None]
class PICMI_AnalyticAppliedField(_ClassWithInit):
diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py
index 4ffc178..d693044 100644
--- a/PICMI_Python/base.py
+++ b/PICMI_Python/base.py
@@ -1,6 +1,9 @@
"""base code for the PICMI standard
"""
+import typing
+import typeguard
+
codename = None
# --- The list of supported codes is needed to allow checking for bad arguments.
@@ -24,30 +27,159 @@ def register_constants(implementation_constants):
def _get_constants():
return _implementation_constants
-class _ClassWithInit(object):
- def handle_init(self, kw):
- # --- Grab all keywords for the current code.
- # --- Arguments for other supported codes are ignored.
- # --- If there is anything left over, it is an error.
- codekw = {}
- for k,v in kw.copy().items():
- code = k.split('_')[0]
- if code == codename:
- codekw[k] = v
- kw.pop(k)
- elif code in supported_codes:
- kw.pop(k)
-
- if kw:
- raise TypeError('Unexpected keyword argument: "%s"'%list(kw))
-
- # --- It is expected that init strips accepted keywords from codekw.
- self.init(codekw)
-
- if codekw:
- raise TypeError("Unexpected keyword argument for %s: '%s'"%(codename, list(codekw)))
-
- def init(self, kw):
- # --- The implementation of this routine should use kw.pop() to retrieve input arguments from kw.
- # --- This allows testing for any unused arguments and raising an error if found.
+
+class _ClassWithInit:
+ """
+ Use args from constructor as attributes
+
+ Non-given attributes are left untouched (i.e. at their default).
+
+ Attributes can be marked as mandatory by adding a type annotation.
+ Type annotations are enforced by check(), i.e. normal assignments for
+ attributes with wrong types still work.
+
+ Arguments that are prefixed with codename and underscore _ will be
+ accepted, as well as equivalent prefixes for other supported codes.
+ All other arguments will be rejected.
+ """
+
+ def __check_arg_valid(self, arg_name: str) -> None:
+ """
+ check if arg_name is acceptable for attr
+
+ If ok silently pass, else raise.
+
+ An arg is valid if:
+ - has a default
+ - has a type annotation (i.e. is mandatory)
+ - is prefixed with codename and underscore _
+ - is prefixed with any supported codename and underscore _
+ - is equal to codename/any supported codename
+ - does *NOT* begin with underscore _
+ """
+ assert codename is not None
+ self_type = type(self)
+
+ if arg_name.startswith("_"):
+ raise NameError(
+ f"protected/private attribute may NOT be accessed: {arg_name}")
+
+ if arg_name in self_type.__dict__:
+ # has default value i.e. is defined
+ return
+
+ if arg_name in typing.get_type_hints(self_type):
+ # mandatory arg (has no default)
+ return
+
+ # check prefix:
+ prefix = arg_name.split("_")[0]
+ if prefix == codename:
+ return
+
+ if prefix in supported_codes:
+ return
+
+ # arg name is not in allowed sets -> raise
+ raise NameError(f"unkown argument: {arg_name}")
+
+ def __get_mandatory_attrs(self) -> typing.Set[str]:
+ """
+ Retrieve list of mandatory attrs
+
+ Attributes are considered mandatory if they exist (which they
+ syntactically only can if they have a *type annotation*), but no
+ default value.
+ """
+ self_type = type(self)
+ has_type_annotion = typing.get_type_hints(type(self)).keys()
+
+ # ignore those with default values
+ return set(has_type_annotion - self_type.__dict__.keys())
+
+ def check(self) -> None:
+ """
+ checks self, raises on error, passes silently if okay
+
+ ! MUST NOT BE OVERWRITTEN !
+ -> overwrite _check() (note the leading _) instead
+
+ Performs the following checks:
+ 1. The type of all attributes are checked against their type
+ annotations (if present). On error a TypeError is raised.
+ 2. If (and only if) the typechecks passed the custom-overwriteable
+ method _check() is called.
+
+ Will be called inside of __init__(), and should be called before any
+ work on the data is performed.
+ """
+ for arg_name, annotation in typing.get_type_hints(type(self)).items():
+ # perform type check: will raise on error
+ typeguard.check_type(arg_name,
+ getattr(self, arg_name),
+ annotation)
+
+ self._check()
+
+ def __check_types_of_defaults(self) -> None:
+ """
+ run typechecks for defaults
+
+ Ensures that defaults for attributes conform to their type annotations.
+ Passes silently if okay, else raises type error.
+ """
+ for arg_name, annotation in typing.get_type_hints(type(self)).items():
+ if arg_name not in type(self).__dict__:
+ # there is no default -> skip loop iteration
+ continue
+ default_value = type(self).__dict__[arg_name]
+ # perform type check: will raise on error
+ try:
+ typeguard.check_type(arg_name, default_value, annotation)
+ except TypeError:
+ # replace by custom message
+ actual_type = type(default_value)
+ raise TypeError(f"default value for {arg_name} must be of "
+ "type {annotation}, but got {actual_type}")
+
+ def _check(self) -> None:
+ """
+ run checks that are not typechecks
+
+ Should be overwritten by child class.
+
+ Will be called from check(), and thereby from __init__().
+
+ When this method passes it guarantees that self is conforming to PICMI.
+ This includes all children (should there be any) -- their check()
+ method should be called from this method.
+
+ When it is not overwritten, it is replaced by this empty parent method.
+ """
+ # parent implementation: just pass
pass
+
+ def __init__(self, **kw):
+ """
+ parse kw and set class attributes accordingly
+
+ See class docstring for detailed description.
+
+ ! MUST NOT BE OVERWRITTEN !
+ (Constructors MUST NOT exhibit unpredictable behavior==behavior
+ different from the one specified here.)
+ """
+ mandatory_missing = self.__get_mandatory_attrs() - kw.keys()
+ if 0 != len(mandatory_missing):
+ raise RuntimeError(
+ "mandatory attributes are missing: {}"
+ .format(", ".join(mandatory_missing)))
+
+ self.__check_types_of_defaults()
+
+ for name, value in kw.items():
+ self.__check_arg_valid(name)
+ setattr(self, name, value)
+
+ # perform self-check -> will alert for invalid params
+ self.check()
diff --git a/PICMI_Python/requirements.txt b/PICMI_Python/requirements.txt
index c0ad38b..2aee4e9 100644
--- a/PICMI_Python/requirements.txt
+++ b/PICMI_Python/requirements.txt
@@ -1,2 +1,3 @@
numpy~=1.15
scipy~=1.5
+typeguard
diff --git a/Test/README.md b/Test/README.md
new file mode 100644
index 0000000..2d2928d
--- /dev/null
+++ b/Test/README.md
@@ -0,0 +1,28 @@
+# Testing
+PICMI is currently not thoroughly tested on its own.
+Feel free to contribute!
+
+[python unittest docs](https://docs.python.org/3.8/library/unittest.html)
+
+## Path Workaround
+To allow `import picmistandard` to refer to the actual source of this repository,
+this directory contains a symbolic link named `picmistandard` to the actual source.
+
+By supplying an appropriate `PYTHONPATH` this module is loaded.
+(The current directory is always available for imports, so this is not necessarily required.)
+
+## Unittests
+Unittests are launched from the `__main__` function from the unittest directory.
+This tests the currently available module `picmistandard`.
+
+The file structure follows the source 1-to-1.
+
+To test the development version run (from this directory):
+
+```
+python -m unit
+```
+
+## E2E
+Execute the example as end-to-end test by launching `./launch_e2e_test.sh` from this directory.
+Note that it requires the python module `fbpic` to be available.
diff --git a/Test/launch_test.sh b/Test/launch_e2e_test.sh
similarity index 100%
rename from Test/launch_test.sh
rename to Test/launch_e2e_test.sh
diff --git a/Test/picmistandard b/Test/picmistandard
new file mode 120000
index 0000000..6a399a8
--- /dev/null
+++ b/Test/picmistandard
@@ -0,0 +1 @@
+../PICMI_Python
\ No newline at end of file
diff --git a/Test/unit/__init__.py b/Test/unit/__init__.py
new file mode 100644
index 0000000..9b5ed21
--- /dev/null
+++ b/Test/unit/__init__.py
@@ -0,0 +1 @@
+from .base import *
diff --git a/Test/unit/__main__.py b/Test/unit/__main__.py
new file mode 100644
index 0000000..f2a5f91
--- /dev/null
+++ b/Test/unit/__main__.py
@@ -0,0 +1,4 @@
+from . import *
+
+import unittest
+unittest.main()
diff --git a/Test/unit/base.py b/Test/unit/base.py
new file mode 100644
index 0000000..9eb8c3b
--- /dev/null
+++ b/Test/unit/base.py
@@ -0,0 +1,226 @@
+import picmistandard
+import unittest
+import typing
+
+
+class Test_ClassWithInit(unittest.TestCase):
+ class MockClass(picmistandard.base._ClassWithInit):
+ # note: refer to .base b/c class name with _ will not be exposed
+ mandatory_attr: typing.Any
+ name = ""
+ optional = None
+ _protected = 1
+
+ class MockCheckTracer(picmistandard.base._ClassWithInit):
+ """
+ used to demonstrate the check interface
+ """
+ check_pass: bool = True
+ check_counter = 0
+ must_be_str: str = ""
+
+ # used as static constant (though they dont actually exist in python)
+ ERRORMSG = "apples-hammer-red"
+
+ def _check(self) -> None:
+ self.check_counter += 1
+ # note: assign a specific message to assert for this exact
+ # exception in tests
+ assert self.check_pass, self.ERRORMSG
+
+ def setUp(self):
+ picmistandard.register_codename("mockpic")
+
+ def test_arguments_used(self):
+ """init sets provided args to attrs"""
+ d = self.MockClass(mandatory_attr=None,
+ name="n",
+ optional=17)
+ self.assertEqual(None, d.mandatory_attr)
+ self.assertEqual("n", d.name)
+ self.assertEqual(17, d.optional)
+
+ def test_defaults(self):
+ """if not given, defaults are used"""
+ d = self.MockClass(mandatory_attr=42)
+ self.assertEqual("", d.name)
+ self.assertEqual(None, d.optional)
+
+ def test_unkown_rejected(self):
+ """unknown names are rejected"""
+ with self.assertRaisesRegex(NameError, ".*blabla.*"):
+ self.MockClass(mandatory_attr=1,
+ blabla="foo")
+
+ def test_codespecific(self):
+ """arbitrary attrs for code-specific args used"""
+ # args beginning with mockpic_ must be accepted
+ d1 = self.MockClass(mandatory_attr=2,
+ mockpic_foo="bar",
+ mockpic_baz="xyzzy",
+ mockpic=1,
+ mockpic_=3)
+ self.assertEqual("bar", d1.mockpic_foo)
+ self.assertEqual("xyzzy", d1.mockpic_baz)
+ self.assertEqual(1, d1.mockpic)
+ self.assertEqual(3, d1.mockpic_)
+
+ # _ separator is required:
+ with self.assertRaisesRegex(NameError, ".*mockpicno_.*"):
+ self.MockClass(mandatory_attr=2,
+ mockpicno_="None")
+
+ # args from other supported codes are still accepted
+ d2 = self.MockClass(mandatory_attr=None,
+ warpx_anyvar=1,
+ warpx=2,
+ warpx_=3,
+ fbpic=4)
+ self.assertEqual(None, d2.mandatory_attr)
+ self.assertEqual(1, d2.warpx_anyvar)
+ self.assertEqual(2, d2.warpx)
+ self.assertEqual(3, d2.warpx_)
+ self.assertEqual(4, d2.fbpic)
+
+ def test_mandatory_enforced(self):
+ """mandatory args must be given"""
+ with self.assertRaisesRegex(RuntimeError, ".*mandatory_attr.*"):
+ self.MockClass()
+
+ # ok:
+ d = self.MockClass(mandatory_attr="x")
+ self.assertEqual("x", d.mandatory_attr)
+
+ def test_typechecks(self):
+ """typechecks only in check()"""
+ class WithTypecheck(picmistandard.base._ClassWithInit):
+ attr: str
+ num: int = 0
+
+ w = WithTypecheck(attr="d", num=2)
+
+ # can overwrite vars, but then check fails
+ w.attr = None
+ with self.assertRaisesRegex(TypeError, ".*str.*"):
+ w.check()
+
+ # also checks in constructor:
+ with self.assertRaisesRegex(TypeError, ".*str.*"):
+ WithTypecheck(attr=7283)
+
+ with self.assertRaisesRegex(TypeError, ".*int.*"):
+ WithTypecheck(attr="", num=123.3)
+
+ def test_protected(self):
+ """protected args may *never* be accessed"""
+ with self.assertRaisesRegex(NameError, ".*_protected.*"):
+ self.MockClass(mandatory_attr=1,
+ _protected=42)
+
+ # though, *technically speaking*, it can be assigned
+ d = self.MockClass(mandatory_attr=1)
+ # ... this is evil, never do this!
+ d._protected = 3
+ self.assertEqual(3, d._protected)
+
+ def test_check_basic(self):
+ """simple demonstration of check() interface"""
+ # passes
+ check_tracer = self.MockCheckTracer()
+ check_tracer.check()
+
+ # make check() fail:
+ check_tracer.check_pass = False
+ with self.assertRaisesRegex(AssertionError,
+ self.MockCheckTracer.ERRORMSG):
+ check_tracer.check()
+
+ with self.assertRaisesRegex(AssertionError,
+ self.MockCheckTracer.ERRORMSG):
+ self.MockCheckTracer(check_pass=False)
+
+ def test_empty(self):
+ """empty object works"""
+ class MockEmpty(picmistandard.base._ClassWithInit):
+ pass
+
+ # both just pass
+ empty = MockEmpty()
+ empty.check()
+
+ def test_check_optional(self):
+ """implementing check() is not required"""
+ class MockNoCheck(picmistandard.base._ClassWithInit):
+ attr = 3
+
+ no_check = MockNoCheck()
+ # method exists & passes -- no matter the attribute value
+ for value in [1, None, {}, [], ""]:
+ no_check.attr = value
+ no_check.check()
+
+ def test_check_in_init(self):
+ """check called from constructor"""
+ check_tracer = self.MockCheckTracer()
+ # counter is already one
+ self.assertEqual(1, check_tracer.check_counter)
+
+ # ... even if its default is zero
+ self.assertEqual(0, check_tracer.__class__.__dict__["check_counter"])
+
+ # one more call -> counter increased by one
+ check_tracer.check()
+ self.assertEqual(2, check_tracer.check_counter)
+
+ def test_default_invalid_type(self):
+ """raises if default variable has invalid type"""
+ class MockInvalidDefaultType(picmistandard.base._ClassWithInit):
+ my_str_attr: str = None
+
+ with self.assertRaisesRegex(TypeError, ".*default.*my_str_attr.*"):
+ MockInvalidDefaultType()
+
+ def test_check_order(self):
+ """_check() is only called if typechecks pass"""
+ check_tracer = self.MockCheckTracer()
+
+ cnt_old = check_tracer.check_counter
+
+ # check will now fail *every time* when called
+ check_tracer.check_pass = False
+
+ # make type check break
+ check_tracer.must_be_str = None
+ with self.assertRaises(TypeError):
+ check_tracer.check()
+
+ # typecheck failed before _check() could be called
+ # -> counter at old state
+ self.assertEqual(cnt_old, check_tracer.check_counter)
+
+ # when the type checks pass, _check is called (which fails)
+ check_tracer.must_be_str = ""
+ with self.assertRaisesRegex(AssertionError,
+ self.MockCheckTracer.ERRORMSG):
+ check_tracer.check()
+
+ # counter increased
+ self.assertEqual(cnt_old + 1, check_tracer.check_counter)
+
+ def test_attribute_optional(self):
+ """attributes can be (explicitly) made optional"""
+ class MockOptionalAttrs(picmistandard.base._ClassWithInit):
+ mandatory: str
+ num_with_default: float = 3
+ optional_name: typing.Optional[str] = None
+
+ poa = MockOptionalAttrs(mandatory="", optional_name="foo")
+ # optional_name can be set to none, and still passes:
+ poa.optional_name = None
+ poa.check()
+
+ # but removing the mandatory arg raises:
+ poa.mandatory = None
+ with self.assertRaises(TypeError):
+ # note: type error b/c NoneType != str
+ poa.check()