Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions PICMI_Python/applied_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
129 changes: 102 additions & 27 deletions PICMI_Python/base.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 !
Copy link
Member

@ax3l ax3l Mar 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: hard to enforce, maybe even error-prune API contract and maybe not ideal for something as common as __init__

(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)
28 changes: 28 additions & 0 deletions Test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Testing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding unit tests. Definitely something this repo needs.

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.
File renamed without changes.
1 change: 1 addition & 0 deletions Test/picmistandard
1 change: 1 addition & 0 deletions Test/unit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .base import *
4 changes: 4 additions & 0 deletions Test/unit/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from . import *

import unittest
unittest.main()
100 changes: 100 additions & 0 deletions Test/unit/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import picmistandard
import unittest
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):

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",
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_.*"):
DummyClass(mandatory_attr=2,
dummypicno_="None")

# args from other supported codes are still accepted
d2 = DummyClass(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.*"):
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)

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)