From e5e72c5c0203341305bcbccea7a4341308091849 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 2 Mar 2022 10:28:08 +0000 Subject: [PATCH 01/19] add unit test skeleton Rename Test/launch_test.sh to reflect that it does *not* run unittests. Add Test/README.md for clarification. Apply workaround to make `import picmistandard` work in tests using a symlink -- in the future maybe mv source to (more canonical) /lib/python/picmistandard ? --- Test/README.md | 27 +++++++++++++++++++++ Test/{launch_test.sh => launch_e2e_test.sh} | 0 Test/picmistandard | 1 + Test/unit/__init__.py | 1 + Test/unit/__main__.py | 4 +++ Test/unit/base.py | 0 6 files changed, 33 insertions(+) create mode 100644 Test/README.md rename Test/{launch_test.sh => launch_e2e_test.sh} (100%) create mode 120000 Test/picmistandard create mode 100644 Test/unit/__init__.py create mode 100644 Test/unit/__main__.py create mode 100644 Test/unit/base.py diff --git a/Test/README.md b/Test/README.md new file mode 100644 index 0000000..f58108f --- /dev/null +++ b/Test/README.md @@ -0,0 +1,27 @@ +# 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. + +## 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: + +``` +PYTHONPATH=.:$PYTHONPATH 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..e69de29 From e1ab33f4f91ff019f4bd9be25151a2e86871bf2f Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 2 Mar 2022 18:00:10 +0000 Subject: [PATCH 02/19] fix note on pythonpath in Test/README.md --- Test/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Test/README.md b/Test/README.md index f58108f..2d2928d 100644 --- a/Test/README.md +++ b/Test/README.md @@ -9,6 +9,7 @@ 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. @@ -16,10 +17,10 @@ This tests the currently available module `picmistandard`. The file structure follows the source 1-to-1. -To test the development version run: +To test the development version run (from this directory): ``` -PYTHONPATH=.:$PYTHONPATH python -m unit +python -m unit ``` ## E2E From 14451ba999fe7ac5aed8d2427115e247d1682423 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 2 Mar 2022 18:00:30 +0000 Subject: [PATCH 03/19] add classwithinit tests --- Test/unit/base.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/Test/unit/base.py b/Test/unit/base.py index e69de29..1e40a99 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -0,0 +1,75 @@ +import picmistandard +import unittest +import typing + + +class Test_ClassWithInit(unittest.TestCase): + class DummyClass(picmistandard.base._ClassWithInit): + # note: refer to .base b/c class name with _ will not be exposed + mandatory_attr: typing.Any + name = "" + optional = None + + def setUp(self): + picmistandard.register_codename("dummypic") + + def test_arguments_used(self): + """init sets provided args to attrs""" + d = DummyClass(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 = DummyClass(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.*"): + DummyClass(mandatory_attr=1, + blabla="foo") + + def test_codespecific(self): + """arbitrary attrs for code-specific args used""" + # args beginning with dummypic_ must be accepted + d1 = DummyClass(mandatory_attr=2, + dummypic_foo="bar", + dummypic_baz="xyzzy") + self.assertEqual("bar", d1.dummypic_foo) + self.assertEqual("xyzzy", d1.dummypic_baz) + + # _ separator is required: + with self.assertRaisesRegex(NameError, ".*dummypicno_.*"): + DummyClass(mandatory_attr=2, + dummypicno_="None") + + # args from other supported codes are still accepted + d2 = DummyClass(mandatory_attr=None, + warpx_anyvar=1) + self.assertEqual(None, d2.mandatory_attr) + self.assertEqual(1, d2.warpx_anyvar) + + def test_mandatory_enforced(self): + """mandatory args must be given""" + with self.assertRaisesRegex(RuntimeError, ".*mandatory_attr.*"): + DummyClass() + + # ok: + d = DummyClass(mandatory_attr="x") + self.assertEqual("x", d.mandatory_attr) + + def test_no_typechecks(self): + """no typechecks, explicit type annotations are rejected""" + class WithTypecheck(picmistandard.base._ClassWithInit): + attr: str + num: int = 0 + + with self.assertRaises(SyntaxError): + # must complain purely b/c typecheck is *there* + # (even if it would enforceable) + WithTypecheck(attr="d", num=2) From 6be78e766c3fe36080ebfa48cf239a337931c3ca Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 2 Mar 2022 18:49:50 +0000 Subject: [PATCH 04/19] re-implement class with init --- PICMI_Python/base.py | 129 ++++++++++++++++++++++++++++++++++--------- Test/unit/base.py | 39 ++++++++++--- 2 files changed, 134 insertions(+), 34 deletions(-) diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py index 4ffc178..a0b7ce1 100644 --- a/PICMI_Python/base.py +++ b/PICMI_Python/base.py @@ -1,6 +1,8 @@ """base code for the PICMI standard """ +import typing + codename = None # --- The list of supported codes is needed to allow checking for bad arguments. @@ -24,30 +26,103 @@ 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. - pass + +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 + `typing.Any`; any other type annotation will be rejected. + + 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_type_annotations(self) -> None: + """ + enforce that only typing.Any is used as type annotation + """ + for arg_name, annotation in typing.get_type_hints(type(self)).items(): + if annotation != typing.Any: + raise SyntaxError( + f"type hints not supported, use typing.Any for {arg_name}") + + 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.) + """ + self.__check_type_annotations() + + mandatory = self.__get_mandatory_attrs() + mandatory_missing = self.__get_mandatory_attrs() - kw.keys() + if 0 != len(mandatory_missing): + raise RuntimeError( + "mandatory attributes are missing: {}" + .format(", ".join(mandatory_missing))) + + for name, value in kw.items(): + self.__check_arg_valid(name) + setattr(self, name, value) diff --git a/Test/unit/base.py b/Test/unit/base.py index 1e40a99..80fdb31 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -3,12 +3,15 @@ import typing +class DummyClass(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 Test_ClassWithInit(unittest.TestCase): - class DummyClass(picmistandard.base._ClassWithInit): - # note: refer to .base b/c class name with _ will not be exposed - mandatory_attr: typing.Any - name = "" - optional = None def setUp(self): picmistandard.register_codename("dummypic") @@ -39,9 +42,13 @@ def test_codespecific(self): # args beginning with dummypic_ must be accepted d1 = DummyClass(mandatory_attr=2, dummypic_foo="bar", - dummypic_baz="xyzzy") + dummypic_baz="xyzzy", + dummypic=1, + dummypic_=3) self.assertEqual("bar", d1.dummypic_foo) self.assertEqual("xyzzy", d1.dummypic_baz) + self.assertEqual(1, d1.dummypic) + self.assertEqual(3, d1.dummypic_) # _ separator is required: with self.assertRaisesRegex(NameError, ".*dummypicno_.*"): @@ -50,9 +57,15 @@ def test_codespecific(self): # args from other supported codes are still accepted d2 = DummyClass(mandatory_attr=None, - warpx_anyvar=1) + 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""" @@ -73,3 +86,15 @@ class WithTypecheck(picmistandard.base._ClassWithInit): # must complain purely b/c typecheck is *there* # (even if it would enforceable) WithTypecheck(attr="d", num=2) + + def test_protected(self): + """protected args may *never* be accessed""" + with self.assertRaisesRegex(NameError, ".*_protected.*"): + DummyClass(mandatory_attr=1, + _protected=42) + + # though, *technically speaking*, it can be assigned + d = DummyClass(mandatory_attr=1) + # ... this is evil, never do this! + d._protected = 3 + self.assertEqual(3, d._protected) From 3ef954d8f5470fd4d65e59e33f66d9a584fd6325 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 2 Mar 2022 20:26:41 +0000 Subject: [PATCH 05/19] transform PICMI_ConstantAppliedField to use reworked _ClassWithInit --- PICMI_Python/applied_fields.py | 41 +++++++++++++++++----------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/PICMI_Python/applied_fields.py b/PICMI_Python/applied_fields.py index 74def08..70acea8 100644 --- a/PICMI_Python/applied_fields.py +++ b/PICMI_Python/applied_fields.py @@ -13,30 +13,31 @@ class PICMI_ConstantAppliedField(_ClassWithInit): """ Describes a constant applied field - - Ex: Constant Ex field (float) [V/m] - - Ey: Constant Ey field (float) [V/m] - - Ez: Constant Ez field (float) [V/m] - - 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] """ - 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 + Ex = None + """Constant Ex field (float) [V/m]""" - self.lower_bound = lower_bound - self.upper_bound = upper_bound + Ey = None + """Constant Ey field (float) [V/m]""" - self.handle_init(kw) + Ez = None + """Constant Ez field (float) [V/m]""" + + Bx = None + """Constant Bx field (float) [T]""" + + By = None + """Constant By field (float) [T]""" + + Bz = None + """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]""" class PICMI_AnalyticAppliedField(_ClassWithInit): From f18ef90cc1b4b9574794039c648fa19313b84880 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Fri, 11 Mar 2022 12:39:08 +0000 Subject: [PATCH 06/19] mv attribute docstrings The format before this commit has been proposed in PEP 224, which has been rejected. Aim of this format is to avoid duplicating the list of attributes in the class body and class docstring. However, the old format is only understood by some tools, so we return to plain PEP 257 docstrings. Note: An alternative would be to add a __doc_ATTRNAME__ string, which would only exist as convention. (Could be enforced inside PICMI though.) --- PICMI_Python/applied_fields.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/PICMI_Python/applied_fields.py b/PICMI_Python/applied_fields.py index 70acea8..e1ebdad 100644 --- a/PICMI_Python/applied_fields.py +++ b/PICMI_Python/applied_fields.py @@ -13,31 +13,24 @@ class PICMI_ConstantAppliedField(_ClassWithInit): """ Describes a constant applied field + - Ex: Constant Ex field (float) [V/m] + - Ey: Constant Ey field (float) [V/m] + - Ez: Constant Ez field (float) [V/m] + - Bx: Constant Bx field (float) [T] + - By: Constant By field (float) [T] + - Bz: Constant Bz field (float) [T] + - 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] """ Ex = None - """Constant Ex field (float) [V/m]""" - Ey = None - """Constant Ey field (float) [V/m]""" - Ez = None - """Constant Ez field (float) [V/m]""" - Bx = None - """Constant Bx field (float) [T]""" - By = None - """Constant By field (float) [T]""" - Bz = None - """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 = [None, None, None] + upper_bound = [None, None, None] class PICMI_AnalyticAppliedField(_ClassWithInit): From 892599f30bd51ead1fcd79d2ebc79425c1ce3c24 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 13:21:07 +0000 Subject: [PATCH 07/19] s/dummy/placeholder/g --- Test/unit/base.py | 62 +++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index 80fdb31..a432e8c 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -3,7 +3,7 @@ import typing -class DummyClass(picmistandard.base._ClassWithInit): +class PlaceholderClass(picmistandard.base._ClassWithInit): # note: refer to .base b/c class name with _ will not be exposed mandatory_attr: typing.Any name = "" @@ -14,53 +14,53 @@ class DummyClass(picmistandard.base._ClassWithInit): class Test_ClassWithInit(unittest.TestCase): def setUp(self): - picmistandard.register_codename("dummypic") + picmistandard.register_codename("placeholderpic") def test_arguments_used(self): """init sets provided args to attrs""" - d = DummyClass(mandatory_attr=None, - name="n", - optional=17) + d = PlaceholderClass(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 = DummyClass(mandatory_attr=42) + d = PlaceholderClass(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.*"): - DummyClass(mandatory_attr=1, - blabla="foo") + PlaceholderClass(mandatory_attr=1, + blabla="foo") def test_codespecific(self): """arbitrary attrs for code-specific args used""" - # args beginning with dummypic_ must be accepted - d1 = DummyClass(mandatory_attr=2, - dummypic_foo="bar", - dummypic_baz="xyzzy", - dummypic=1, - dummypic_=3) - self.assertEqual("bar", d1.dummypic_foo) - self.assertEqual("xyzzy", d1.dummypic_baz) - self.assertEqual(1, d1.dummypic) - self.assertEqual(3, d1.dummypic_) + # args beginning with placeholderpic_ must be accepted + d1 = PlaceholderClass(mandatory_attr=2, + placeholderpic_foo="bar", + placeholderpic_baz="xyzzy", + placeholderpic=1, + placeholderpic_=3) + self.assertEqual("bar", d1.placeholderpic_foo) + self.assertEqual("xyzzy", d1.placeholderpic_baz) + self.assertEqual(1, d1.placeholderpic) + self.assertEqual(3, d1.placeholderpic_) # _ separator is required: - with self.assertRaisesRegex(NameError, ".*dummypicno_.*"): - DummyClass(mandatory_attr=2, - dummypicno_="None") + with self.assertRaisesRegex(NameError, ".*placeholderpicno_.*"): + PlaceholderClass(mandatory_attr=2, + placeholderpicno_="None") # args from other supported codes are still accepted - d2 = DummyClass(mandatory_attr=None, - warpx_anyvar=1, - warpx=2, - warpx_=3, - fbpic=4) + d2 = PlaceholderClass(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) @@ -70,10 +70,10 @@ def test_codespecific(self): def test_mandatory_enforced(self): """mandatory args must be given""" with self.assertRaisesRegex(RuntimeError, ".*mandatory_attr.*"): - DummyClass() + PlaceholderClass() # ok: - d = DummyClass(mandatory_attr="x") + d = PlaceholderClass(mandatory_attr="x") self.assertEqual("x", d.mandatory_attr) def test_no_typechecks(self): @@ -90,11 +90,11 @@ class WithTypecheck(picmistandard.base._ClassWithInit): def test_protected(self): """protected args may *never* be accessed""" with self.assertRaisesRegex(NameError, ".*_protected.*"): - DummyClass(mandatory_attr=1, - _protected=42) + PlaceholderClass(mandatory_attr=1, + _protected=42) # though, *technically speaking*, it can be assigned - d = DummyClass(mandatory_attr=1) + d = PlaceholderClass(mandatory_attr=1) # ... this is evil, never do this! d._protected = 3 self.assertEqual(3, d._protected) From 079b916c2b477690c902c17aff7da6b0ae0f4f40 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 13:25:35 +0000 Subject: [PATCH 08/19] mv testing placeholderclass inside of test class --- Test/unit/base.py | 60 +++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index a432e8c..c00f7c9 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -3,48 +3,46 @@ import typing -class PlaceholderClass(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 Test_ClassWithInit(unittest.TestCase): + class PlaceholderClass(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 def setUp(self): picmistandard.register_codename("placeholderpic") def test_arguments_used(self): """init sets provided args to attrs""" - d = PlaceholderClass(mandatory_attr=None, - name="n", - optional=17) + d = self.PlaceholderClass(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 = PlaceholderClass(mandatory_attr=42) + d = self.PlaceholderClass(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.*"): - PlaceholderClass(mandatory_attr=1, - blabla="foo") + self.PlaceholderClass(mandatory_attr=1, + blabla="foo") def test_codespecific(self): """arbitrary attrs for code-specific args used""" # args beginning with placeholderpic_ must be accepted - d1 = PlaceholderClass(mandatory_attr=2, - placeholderpic_foo="bar", - placeholderpic_baz="xyzzy", - placeholderpic=1, - placeholderpic_=3) + d1 = self.PlaceholderClass(mandatory_attr=2, + placeholderpic_foo="bar", + placeholderpic_baz="xyzzy", + placeholderpic=1, + placeholderpic_=3) self.assertEqual("bar", d1.placeholderpic_foo) self.assertEqual("xyzzy", d1.placeholderpic_baz) self.assertEqual(1, d1.placeholderpic) @@ -52,15 +50,15 @@ def test_codespecific(self): # _ separator is required: with self.assertRaisesRegex(NameError, ".*placeholderpicno_.*"): - PlaceholderClass(mandatory_attr=2, - placeholderpicno_="None") + self.PlaceholderClass(mandatory_attr=2, + placeholderpicno_="None") # args from other supported codes are still accepted - d2 = PlaceholderClass(mandatory_attr=None, - warpx_anyvar=1, - warpx=2, - warpx_=3, - fbpic=4) + d2 = self.PlaceholderClass(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) @@ -70,10 +68,10 @@ def test_codespecific(self): def test_mandatory_enforced(self): """mandatory args must be given""" with self.assertRaisesRegex(RuntimeError, ".*mandatory_attr.*"): - PlaceholderClass() + self.PlaceholderClass() # ok: - d = PlaceholderClass(mandatory_attr="x") + d = self.PlaceholderClass(mandatory_attr="x") self.assertEqual("x", d.mandatory_attr) def test_no_typechecks(self): @@ -90,11 +88,11 @@ class WithTypecheck(picmistandard.base._ClassWithInit): def test_protected(self): """protected args may *never* be accessed""" with self.assertRaisesRegex(NameError, ".*_protected.*"): - PlaceholderClass(mandatory_attr=1, - _protected=42) + self.PlaceholderClass(mandatory_attr=1, + _protected=42) # though, *technically speaking*, it can be assigned - d = PlaceholderClass(mandatory_attr=1) + d = self.PlaceholderClass(mandatory_attr=1) # ... this is evil, never do this! d._protected = 3 self.assertEqual(3, d._protected) From 0881c67b035876f2f0b8c36f581494b4dc5032cb Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 13:41:39 +0000 Subject: [PATCH 09/19] self-check interface: add tests --- Test/unit/base.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Test/unit/base.py b/Test/unit/base.py index c00f7c9..2bc3244 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -11,6 +11,22 @@ class PlaceholderClass(picmistandard.base._ClassWithInit): optional = None _protected = 1 + class PlaceholderCheckTracer(picmistandard.base._ClassWithInit): + """ + used to demonstrate the check interface + """ + check_pass = True + check_counter = 0 + + # 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("placeholderpic") @@ -96,3 +112,50 @@ def test_protected(self): # ... 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.PlaceholderCheckTracer() + check_tracer.check() + + # make check() fail: + check_tracer.check_pass = False + with self.assertRaises(AssertionError, + self.PlaceholderCheckTracer.ERRORMSG): + check_tracer.check() + + with self.assertRaises(AssertionError, + self.PlaceholderCheckTracer.ERRORMSG): + self.PlaceholderCheckTracer(check_pass=False) + + def test_empty(self): + """empty object works""" + class PlaceholderEmpty(picmistandard.base._ClassWithInit): + pass + + # both just pass + empty = PlaceholderEmpty() + empty.check() + + def test_check_optional(self): + """implementing check() is not required""" + class PlaceholderNoCheck(): + attr = 3 + + no_check = PlaceholderNoCheck() + # method exists & passes + no_check.check() + + def test_check_in_init(self): + """check called from constructor""" + check_tracer = self.PlaceholderCheckTracer() + # 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) From 1d1f2f373425172542563d18feadacc9ab2328d1 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 13:52:23 +0000 Subject: [PATCH 10/19] self-check interface: implement in base class --- PICMI_Python/base.py | 20 +++++++++++++++++++- Test/unit/base.py | 10 +++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py index a0b7ce1..a34fa10 100644 --- a/PICMI_Python/base.py +++ b/PICMI_Python/base.py @@ -104,6 +104,22 @@ def __check_type_annotations(self) -> None: raise SyntaxError( f"type hints not supported, use typing.Any for {arg_name}") + def check(self) -> None: + """ + checks self, raises on error, passes silently if okay + + Should be overwritten by child class. + + Will be called inside of __init__(), and should be called before any + work on the data is performed. + + When this method passes it guarantees that self is conforming to PICMI. + + 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 @@ -116,7 +132,6 @@ def __init__(self, **kw): """ self.__check_type_annotations() - mandatory = self.__get_mandatory_attrs() mandatory_missing = self.__get_mandatory_attrs() - kw.keys() if 0 != len(mandatory_missing): raise RuntimeError( @@ -126,3 +141,6 @@ def __init__(self, **kw): 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/Test/unit/base.py b/Test/unit/base.py index 2bc3244..b15e80d 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -121,12 +121,12 @@ def test_check_basic(self): # make check() fail: check_tracer.check_pass = False - with self.assertRaises(AssertionError, - self.PlaceholderCheckTracer.ERRORMSG): + with self.assertRaisesRegex(AssertionError, + self.PlaceholderCheckTracer.ERRORMSG): check_tracer.check() - with self.assertRaises(AssertionError, - self.PlaceholderCheckTracer.ERRORMSG): + with self.assertRaisesRegex(AssertionError, + self.PlaceholderCheckTracer.ERRORMSG): self.PlaceholderCheckTracer(check_pass=False) def test_empty(self): @@ -140,7 +140,7 @@ class PlaceholderEmpty(picmistandard.base._ClassWithInit): def test_check_optional(self): """implementing check() is not required""" - class PlaceholderNoCheck(): + class PlaceholderNoCheck(picmistandard.base._ClassWithInit): attr = 3 no_check = PlaceholderNoCheck() From 02331cb4992c783771e7f064ef3b9eb17280b59e Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 14:26:18 +0000 Subject: [PATCH 11/19] check interface: test for type checking --- Test/unit/base.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index b15e80d..ca80c37 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -21,7 +21,7 @@ class PlaceholderCheckTracer(picmistandard.base._ClassWithInit): # used as static constant (though they dont actually exist in python) ERRORMSG = "apples-hammer-red" - def check(self) -> None: + def _check(self) -> None: self.check_counter += 1 # note: assign a specific message to assert for this exact # exception in tests @@ -90,16 +90,25 @@ def test_mandatory_enforced(self): d = self.PlaceholderClass(mandatory_attr="x") self.assertEqual("x", d.mandatory_attr) - def test_no_typechecks(self): - """no typechecks, explicit type annotations are rejected""" + def test_typechecks(self): + """typechecks only in check()""" class WithTypecheck(picmistandard.base._ClassWithInit): attr: str num: int = 0 - with self.assertRaises(SyntaxError): - # must complain purely b/c typecheck is *there* - # (even if it would enforceable) - WithTypecheck(attr="d", num=2) + 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""" From b317ce82549331cb1cff9c829972275234990082 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 14:26:38 +0000 Subject: [PATCH 12/19] check interface: add comment --- PICMI_Python/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py index a34fa10..cb59e4c 100644 --- a/PICMI_Python/base.py +++ b/PICMI_Python/base.py @@ -114,6 +114,8 @@ def check(self) -> None: work on the data is performed. 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. """ From a4969f4bbb60915287fb9dc69f5ee19419aaaa5f Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 14:51:17 +0000 Subject: [PATCH 13/19] check interface: expand tests for typechecks --- Test/unit/base.py | 62 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index ca80c37..48b4636 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -15,8 +15,9 @@ class PlaceholderCheckTracer(picmistandard.base._ClassWithInit): """ used to demonstrate the check interface """ - check_pass = True + 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" @@ -153,8 +154,10 @@ class PlaceholderNoCheck(picmistandard.base._ClassWithInit): attr = 3 no_check = PlaceholderNoCheck() - # method exists & passes - no_check.check() + # 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""" @@ -168,3 +171,56 @@ def test_check_in_init(self): # 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 PlaceholderInvalidDefaultType(picmistandard.base._ClassWithInit): + my_str_attr: str = None + + with self.assertRaisesRegex(TypeError, ".*default.*my_str_attr.*"): + PlaceholderInvalidDefaultType() + + def test_check_order(self): + """_check() is only called if typechecks pass""" + check_tracer = self.PlaceholderCheckTracer() + + 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.asertEqual(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.PlaceholderCheckTracer.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 PlaceholderOptionalAttrs(picmistandard.base._ClassWithInit): + mandatory: str + num_with_default: float = 3 + optional_name: typing.Optional[str] = None + + poa = PlaceholderOptionalAttrs(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() From 72407f04e9159d971efe3faed1498602d2005d68 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 14:51:34 +0000 Subject: [PATCH 14/19] check interface: rename methods to accomodate type checks --- PICMI_Python/base.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py index cb59e4c..843a980 100644 --- a/PICMI_Python/base.py +++ b/PICMI_Python/base.py @@ -108,10 +108,27 @@ def check(self) -> None: """ checks self, raises on error, passes silently if okay - Should be overwritten by child class. + ! 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. + """ + self._check() + + 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() From 6c0dc945a80c0ac6d6404f797f72b1a96496cac3 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 15:41:36 +0000 Subject: [PATCH 15/19] implement type checks --- PICMI_Python/base.py | 46 +++++++++++++++++++++++++---------- PICMI_Python/requirements.txt | 1 + Test/unit/base.py | 2 +- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/PICMI_Python/base.py b/PICMI_Python/base.py index 843a980..d693044 100644 --- a/PICMI_Python/base.py +++ b/PICMI_Python/base.py @@ -2,6 +2,7 @@ """ import typing +import typeguard codename = None @@ -33,8 +34,9 @@ class _ClassWithInit: Non-given attributes are left untouched (i.e. at their default). - Attributes can be marked as mandatory by adding a type annotation - `typing.Any`; any other type annotation will be rejected. + 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. @@ -95,15 +97,6 @@ def __get_mandatory_attrs(self) -> typing.Set[str]: # ignore those with default values return set(has_type_annotion - self_type.__dict__.keys()) - def __check_type_annotations(self) -> None: - """ - enforce that only typing.Any is used as type annotation - """ - for arg_name, annotation in typing.get_type_hints(type(self)).items(): - if annotation != typing.Any: - raise SyntaxError( - f"type hints not supported, use typing.Any for {arg_name}") - def check(self) -> None: """ checks self, raises on error, passes silently if okay @@ -120,8 +113,35 @@ def check(self) -> None: 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 @@ -149,14 +169,14 @@ def __init__(self, **kw): (Constructors MUST NOT exhibit unpredictable behavior==behavior different from the one specified here.) """ - self.__check_type_annotations() - 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) 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/unit/base.py b/Test/unit/base.py index 48b4636..bfe9078 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -196,7 +196,7 @@ def test_check_order(self): # typecheck failed before _check() could be called # -> counter at old state - self.asertEqual(cnt_old, check_tracer.check_counter) + self.assertEqual(cnt_old, check_tracer.check_counter) # when the type checks pass, _check is called (which fails) check_tracer.must_be_str = "" From 8323784df2d79a71308daa011915216773582f56 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 15:42:43 +0000 Subject: [PATCH 16/19] s/placeholder/mock/g --- Test/unit/base.py | 78 +++++++++++++++++++++++------------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index bfe9078..9eb8c3b 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -4,14 +4,14 @@ class Test_ClassWithInit(unittest.TestCase): - class PlaceholderClass(picmistandard.base._ClassWithInit): + 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 PlaceholderCheckTracer(picmistandard.base._ClassWithInit): + class MockCheckTracer(picmistandard.base._ClassWithInit): """ used to demonstrate the check interface """ @@ -29,11 +29,11 @@ def _check(self) -> None: assert self.check_pass, self.ERRORMSG def setUp(self): - picmistandard.register_codename("placeholderpic") + picmistandard.register_codename("mockpic") def test_arguments_used(self): """init sets provided args to attrs""" - d = self.PlaceholderClass(mandatory_attr=None, + d = self.MockClass(mandatory_attr=None, name="n", optional=17) self.assertEqual(None, d.mandatory_attr) @@ -42,36 +42,36 @@ def test_arguments_used(self): def test_defaults(self): """if not given, defaults are used""" - d = self.PlaceholderClass(mandatory_attr=42) + 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.PlaceholderClass(mandatory_attr=1, + self.MockClass(mandatory_attr=1, blabla="foo") def test_codespecific(self): """arbitrary attrs for code-specific args used""" - # args beginning with placeholderpic_ must be accepted - d1 = self.PlaceholderClass(mandatory_attr=2, - placeholderpic_foo="bar", - placeholderpic_baz="xyzzy", - placeholderpic=1, - placeholderpic_=3) - self.assertEqual("bar", d1.placeholderpic_foo) - self.assertEqual("xyzzy", d1.placeholderpic_baz) - self.assertEqual(1, d1.placeholderpic) - self.assertEqual(3, d1.placeholderpic_) + # 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, ".*placeholderpicno_.*"): - self.PlaceholderClass(mandatory_attr=2, - placeholderpicno_="None") + with self.assertRaisesRegex(NameError, ".*mockpicno_.*"): + self.MockClass(mandatory_attr=2, + mockpicno_="None") # args from other supported codes are still accepted - d2 = self.PlaceholderClass(mandatory_attr=None, + d2 = self.MockClass(mandatory_attr=None, warpx_anyvar=1, warpx=2, warpx_=3, @@ -85,10 +85,10 @@ def test_codespecific(self): def test_mandatory_enforced(self): """mandatory args must be given""" with self.assertRaisesRegex(RuntimeError, ".*mandatory_attr.*"): - self.PlaceholderClass() + self.MockClass() # ok: - d = self.PlaceholderClass(mandatory_attr="x") + d = self.MockClass(mandatory_attr="x") self.assertEqual("x", d.mandatory_attr) def test_typechecks(self): @@ -114,11 +114,11 @@ class WithTypecheck(picmistandard.base._ClassWithInit): def test_protected(self): """protected args may *never* be accessed""" with self.assertRaisesRegex(NameError, ".*_protected.*"): - self.PlaceholderClass(mandatory_attr=1, + self.MockClass(mandatory_attr=1, _protected=42) # though, *technically speaking*, it can be assigned - d = self.PlaceholderClass(mandatory_attr=1) + d = self.MockClass(mandatory_attr=1) # ... this is evil, never do this! d._protected = 3 self.assertEqual(3, d._protected) @@ -126,34 +126,34 @@ def test_protected(self): def test_check_basic(self): """simple demonstration of check() interface""" # passes - check_tracer = self.PlaceholderCheckTracer() + check_tracer = self.MockCheckTracer() check_tracer.check() # make check() fail: check_tracer.check_pass = False with self.assertRaisesRegex(AssertionError, - self.PlaceholderCheckTracer.ERRORMSG): + self.MockCheckTracer.ERRORMSG): check_tracer.check() with self.assertRaisesRegex(AssertionError, - self.PlaceholderCheckTracer.ERRORMSG): - self.PlaceholderCheckTracer(check_pass=False) + self.MockCheckTracer.ERRORMSG): + self.MockCheckTracer(check_pass=False) def test_empty(self): """empty object works""" - class PlaceholderEmpty(picmistandard.base._ClassWithInit): + class MockEmpty(picmistandard.base._ClassWithInit): pass # both just pass - empty = PlaceholderEmpty() + empty = MockEmpty() empty.check() def test_check_optional(self): """implementing check() is not required""" - class PlaceholderNoCheck(picmistandard.base._ClassWithInit): + class MockNoCheck(picmistandard.base._ClassWithInit): attr = 3 - no_check = PlaceholderNoCheck() + no_check = MockNoCheck() # method exists & passes -- no matter the attribute value for value in [1, None, {}, [], ""]: no_check.attr = value @@ -161,7 +161,7 @@ class PlaceholderNoCheck(picmistandard.base._ClassWithInit): def test_check_in_init(self): """check called from constructor""" - check_tracer = self.PlaceholderCheckTracer() + check_tracer = self.MockCheckTracer() # counter is already one self.assertEqual(1, check_tracer.check_counter) @@ -174,15 +174,15 @@ def test_check_in_init(self): def test_default_invalid_type(self): """raises if default variable has invalid type""" - class PlaceholderInvalidDefaultType(picmistandard.base._ClassWithInit): + class MockInvalidDefaultType(picmistandard.base._ClassWithInit): my_str_attr: str = None with self.assertRaisesRegex(TypeError, ".*default.*my_str_attr.*"): - PlaceholderInvalidDefaultType() + MockInvalidDefaultType() def test_check_order(self): """_check() is only called if typechecks pass""" - check_tracer = self.PlaceholderCheckTracer() + check_tracer = self.MockCheckTracer() cnt_old = check_tracer.check_counter @@ -201,7 +201,7 @@ def test_check_order(self): # when the type checks pass, _check is called (which fails) check_tracer.must_be_str = "" with self.assertRaisesRegex(AssertionError, - self.PlaceholderCheckTracer.ERRORMSG): + self.MockCheckTracer.ERRORMSG): check_tracer.check() # counter increased @@ -209,12 +209,12 @@ def test_check_order(self): def test_attribute_optional(self): """attributes can be (explicitly) made optional""" - class PlaceholderOptionalAttrs(picmistandard.base._ClassWithInit): + class MockOptionalAttrs(picmistandard.base._ClassWithInit): mandatory: str num_with_default: float = 3 optional_name: typing.Optional[str] = None - poa = PlaceholderOptionalAttrs(mandatory="", optional_name="foo") + poa = MockOptionalAttrs(mandatory="", optional_name="foo") # optional_name can be set to none, and still passes: poa.optional_name = None poa.check() From e5000b1593c01db825b926054f95875a03c2230a Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 16:00:09 +0000 Subject: [PATCH 17/19] add test for dict cast --- Test/unit/base.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Test/unit/base.py b/Test/unit/base.py index 9eb8c3b..24c653f 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -224,3 +224,40 @@ class MockOptionalAttrs(picmistandard.base._ClassWithInit): with self.assertRaises(TypeError): # note: type error b/c NoneType != str poa.check() + + def test_dict_cast(self): + """object can be cast to dict""" + # this checks if a quirk of python is circumvented: + # if you provide a default value for a class attribute, e.g. + # >>> class MyClass: + # ... attr = 2 + # will result in + # >>> 2 == MyClass().attr + # True + # + # However, when casting the *instance* to a dictionary, + # this variable will be missing: + # + # >>> myobject = MyClass() + # >>> myobject.__dict__ + # {} + # + # only after explicitly setting the attribute will it become available: + # >>> myobject.attr = myobject.attr + # >>> myobject.__dict__ + # {'attr': 2} + # + # To aid serialization, we expect the __dict__ cast after construction + # to be complete, e.g. for making class variables available in free + # expressions. + + class MockWithDefaults(picmistandard.base._ClassWithInit): + is_one = 1 + empty_str = "" + empty_list = [] + + mock_object = MockWithDefaults() + self.assertEqual(mock_object.__dict__, + {"is_one": 1, + "empty_str": "", + "empty_list": []}) From 8c17e659a98c7f332088923dd66c63b60f50e0e8 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 16:12:28 +0000 Subject: [PATCH 18/19] Revert "add test for dict cast" This reverts commit e5000b1593c01db825b926054f95875a03c2230a. The case it tried to cover might bite our back down the road, but it is very non-pythonic (difficult, dirty) and does not perform well once methods get involved, so dropped it. --- Test/unit/base.py | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/Test/unit/base.py b/Test/unit/base.py index 24c653f..9eb8c3b 100644 --- a/Test/unit/base.py +++ b/Test/unit/base.py @@ -224,40 +224,3 @@ class MockOptionalAttrs(picmistandard.base._ClassWithInit): with self.assertRaises(TypeError): # note: type error b/c NoneType != str poa.check() - - def test_dict_cast(self): - """object can be cast to dict""" - # this checks if a quirk of python is circumvented: - # if you provide a default value for a class attribute, e.g. - # >>> class MyClass: - # ... attr = 2 - # will result in - # >>> 2 == MyClass().attr - # True - # - # However, when casting the *instance* to a dictionary, - # this variable will be missing: - # - # >>> myobject = MyClass() - # >>> myobject.__dict__ - # {} - # - # only after explicitly setting the attribute will it become available: - # >>> myobject.attr = myobject.attr - # >>> myobject.__dict__ - # {'attr': 2} - # - # To aid serialization, we expect the __dict__ cast after construction - # to be complete, e.g. for making class variables available in free - # expressions. - - class MockWithDefaults(picmistandard.base._ClassWithInit): - is_one = 1 - empty_str = "" - empty_list = [] - - mock_object = MockWithDefaults() - self.assertEqual(mock_object.__dict__, - {"is_one": 1, - "empty_str": "", - "empty_list": []}) From d1b39501bec0d27b01cd97384a9a83e8670f1501 Mon Sep 17 00:00:00 2001 From: Hannes T <80697868+s9105947@users.noreply.github.com> Date: Wed, 16 Mar 2022 16:57:16 +0000 Subject: [PATCH 19/19] add init design documentation --- Docs/source/developer/developer.rst | 10 +++ .../source/developer/general_class_design.rst | 85 +++++++++++++++++++ Docs/source/index.rst | 1 + 3 files changed, 96 insertions(+) create mode 100644 Docs/source/developer/developer.rst create mode 100644 Docs/source/developer/general_class_design.rst 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