Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions Docs/source/developer/developer.rst
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions Docs/source/developer/general_class_design.rst
Original file line number Diff line number Diff line change
@@ -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 <https://peps.python.org/pep-0484/>`_, 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 <https://typeguard.readthedocs.io/en/latest/>`_.
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
1 change: 1 addition & 0 deletions Docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
184 changes: 158 additions & 26 deletions PICMI_Python/base.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 !
Copy link
Member

Choose a reason for hiding this comment

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

note: hard to enforce API contract

-> 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 !
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.)
"""
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()
1 change: 1 addition & 0 deletions PICMI_Python/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
numpy~=1.15
scipy~=1.5
typeguard
Copy link
Member

Choose a reason for hiding this comment

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

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