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()