Source code for mph.node

"""Provides the wrapper class for a model node."""

########################################
# Dependencies                         #
########################################
from numpy import array, ndarray       # numerical array
from jpype import JBoolean             # Java boolean
from jpype import JInt                 # Java integer
from jpype import JDouble              # Java float
from jpype import JString              # Java string
from jpype import JArray               # Java array
from jpype import JClass               # Java class
from numpy import integer              # NumPy integer
from pathlib import Path               # file-system path
from re import split                   # string splitting
from json import load as json_load     # JSON parser
from difflib import get_close_matches  # fuzzy matching
from functools import lru_cache        # function cache
from logging import getLogger          # event logging

########################################
# Globals                              #
########################################
log = getLogger(__package__)           # event log


########################################
# Node                                #
########################################
[docs]class Node: """ Represents a model node. This class makes it possible to navigate the model tree, inspect a node, namely its properties, and manipulate it, like toggling it on/off, creating child nodes, or "running" it. Instances of this class reference a node in the model tree and work similarly to [`Path`](python:pathlib.Path) objects from Python's standard library. They support string concatenation to the right with the division operator in order to reference child nodes: ```python >>> node = model/'functions' >>> node Node('functions') >>> node/'step' Node('functions/step') ``` Note how the {class}`model <Model>` object also supports the division operator in order to generate node references. As mere references, nodes must must not necessarily exist in the model tree: ```python >>> (node/'new function').exists() False ``` In interactive sessions, the convenience function {func}`mph.tree` may prove useful to see the node's branch in the model tree at a glance: ```console >>> mph.tree(model/'physics') physics ├─ electrostatic │ ├─ Laplace equation │ ├─ zero charge │ ├─ initial values │ ├─ anode │ └─ cathode └─ electric currents ├─ current conservation ├─ insulation ├─ initial values ├─ anode └─ cathode ``` In rare cases, the node name itself might contain a forward slash, such as the dataset `sweep/solution` that happens to exist in the demo model from the [Tutorial](../tutorial.md). These literal forward slashes can be escaped by doubling the character: ```python >>> node = model/'datasets/sweep//solution' >>> node.name() 'sweep//solution' >>> node.parent() Node('datasets') ``` If the node refers to an existing model feature, then the instance wraps the corresponding Java object, which could belong to a variety of classes, but would necessarily implement the [`com.comsol.model.ModelEntity`][1] interface. That Java object can be accessed directly via the `.java` property. The full Comsol functionality is thus available if needed. The convenience function {func}`mph.inspect` is provided for introspection of the Java object in an interactive session. [1]: https://doc.comsol.com/5.6/doc/com.comsol.help.comsol/api\ /com/comsol/model/ModelEntity.html """ #################################### # Internal # #################################### def __init__(self, model, path=None): if path is None: path = ('',) elif isinstance(path, str): path = parse(path) elif isinstance(path, Node): path = path.path else: error = f'Node path {path!r} is not a string or Node instance.' log.error(error) raise TypeError(error) self.model = model """Model object this node refers to.""" self.groups = { 'parameters': 'self.model.java.param().group()', 'functions': 'self.model.java.func()', 'components': 'self.model.java.component()', 'geometries': 'self.model.java.geom()', 'views': 'self.model.java.view()', 'selections': 'self.model.java.selection()', 'coordinates': 'self.model.java.coordSystem()', 'variables': 'self.model.java.variable()', 'couplings': 'self.model.java.cpl()', 'physics': 'self.model.java.physics()', 'multiphysics': 'self.model.java.multiphysics()', 'materials': 'self.model.java.material()', 'meshes': 'self.model.java.mesh()', 'studies': 'self.model.java.study()', 'solutions': 'self.model.java.sol()', 'batches': 'self.model.java.batch()', 'datasets': 'self.model.java.result().dataset()', 'evaluations': 'self.model.java.result().numerical()', 'tables': 'self.model.java.result().table()', 'plots': 'self.model.java.result()', 'exports': 'self.model.java.result().export()', } """Mapping of the built-in groups to corresponding Java objects.""" self.alias = { 'parameter': 'parameters', 'function': 'functions', 'component': 'components', 'geometry': 'geometries', 'view': 'views', 'selection': 'selections', 'variable': 'variables', 'coupling': 'couplings', 'material': 'materials', 'mesh': 'meshes', 'study': 'studies', 'solution': 'solutions', 'batch': 'batches', 'dataset': 'datasets', 'evaluation': 'evaluations', 'table': 'tables', 'plot': 'plots', 'result': 'plots', 'results': 'plots', 'export': 'exports', } """Accepted aliases for the names of built-in groups.""" if path[0] in self.alias: path = (self.alias[path[0]],) + path[1:] self.path = path """Path of this node reference from the model's root.""" def __str__(self): return join(self.path) def __repr__(self): return f"{self.__class__.__name__}('{self}')" def __eq__(self, other): return (self.path == other.path and self.model == other.model) def __truediv__(self, other): if isinstance(other, str): other = other.lstrip('/') return self.__class__(self.model, join(parse(f'{self}/{other}'))) return NotImplemented def __contains__(self, node): if isinstance(node, str): if (self/node).exists(): return True elif isinstance(node, Node): if node.parent() == self and node.exists(): return True return False def __iter__(self): yield from self.children() @property def java(self): """ Java object this node maps to, if any. Note that this is a property, not an attribute. Internally, it is a function that performs a top-down search of the model tree in order to resolve the node reference. So it introduces a certain overhead every time it is accessed. """ if self.is_root(): return self.model.java name = self.name() if self.is_group(): return eval(self.groups.get(name)) parent = self.parent() java = parent.java if not java: return None container = java if parent.is_group() else java.feature() for tag in container.tags(): member = container.get(tag) if name == escape(member.label()): return member #################################### # Navigation # ####################################
[docs] def name(self): """Returns the node's name.""" return f'{self.model}' if self.is_root() else escape(self.path[-1])
[docs] def tag(self): """Returns the node's tag.""" java = self.java return str(java.tag()) if java else None
[docs] def type(self): """ Returns the node's feature type. This a something like `'Block'` for "a right-angled solid or surface block in 3D". Refer to the Comsol documentation for details. Feature types are displayed in the Comsol GUI at the top of the `Settings` tab. """ java = self.java return str(java.getType()) if hasattr(java, 'getType') else None
[docs] def parent(self): """Returns the parent node.""" if self.is_root(): return None else: return self.__class__(self.model, join(self.path[:-1]))
[docs] def children(self): """Returns all child nodes.""" java = self.java if self.is_root(): return [self.__class__(self.model, group) for group in self.groups] elif self.is_group(): return [self/escape(java.get(tag).label()) for tag in java.tags()] elif hasattr(java, 'feature'): return [self/escape(java.feature(tag).label()) for tag in java.feature().tags()] else: return []
[docs] def is_root(self): """Checks if the node is the model's root node.""" return bool(len(self.path) == 1 and not self.path[0])
[docs] def is_group(self): """Checks if the node refers to a built-in group.""" return bool(len(self.path) == 1 and self.path[0])
[docs] def exists(self): """Checks if the node exists in the model tree.""" return (self.java is not None)
#################################### # Inspection # ####################################
[docs] def comment(self, text=None): """Returns or sets the comment attached to the node.""" java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) if text is None: return str(java.comments()) else: java.comments(text)
[docs] def problems(self): """ Returns problems reported by the node and its descendants. The problems are returned as a list of dictionaries, each with an entry for `'message'` (the warning or error message), `'category'` (either `'warning'` or `'error'`), `'node'` (either this one or a node beneath it in the model tree), and `'selection'` (an empty string if not applicable). Calling this method on the root node returns all warnings and errors in geometry, mesh, and solver sequences. """ java = self.java stack = [] if hasattr(java, 'problem'): for tag in java.problem().tags(): stack.append(java.problem(tag)) items = [] while stack: problem = stack.pop() item = { 'message': '', 'category': '', 'node': self, 'selection': '', } if hasattr(problem, 'message'): item['message'] = str(problem.message()).strip() elif problem.hasProperty('message'): item['message'] = str(problem.getString('message')).strip() if 'error' in str(problem.getType()).lower(): item['category'] = 'error' elif 'warning' in str(problem.getType()).lower(): item['category'] = 'warning' if hasattr(problem, 'hasSelection') and problem.hasSelection(): item['selection'] = str(problem.selection()) items.append(item) if hasattr(problem, 'problem'): for tag in problem.problem().tags(): stack.append(problem.problem(tag)) for child in self.children(): items += child.problems() return items
#################################### # Interaction # ####################################
[docs] def rename(self, name): """Renames the node.""" if self.is_root(): error = 'Cannot rename the root node.' log.error(error) raise PermissionError(error) if self.is_group(): error = 'Cannot rename a built-in group.' log.error(error) raise PermissionError(error) java = self.java if java: java.label(name) self.path = self.path[:-1] + (name,)
[docs] def retag(self, tag): """Assigns a new tag to the node.""" if self.is_root(): error = 'Cannot change tag of root node.' log.error(error) raise PermissionError(error) if self.is_group(): error = 'Cannot change tag of built-in group.' log.error(error) raise PermissionError(error) java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) java.tag(tag)
[docs] def property(self, name, value=None): """ Returns or changes the value of the named property. If no `value` is given, returns the value of property `name`. Otherwise sets the property to the given value. """ if value is None: return get(self.java, name) else: self.java.set(name, cast(value))
[docs] def properties(self): """ Returns names and values of all node properties as a dictionary. In the Comsol GUI, properties are displayed in the Settings tab of the model node (not to be confused with the Properties tab). """ java = self.java if not hasattr(java, 'properties'): return {} names = sorted(str(name) for name in java.properties()) return {name: get(java, name) for name in names}
[docs] def select(self, entity): """ Assigns `entity` as the node's selection. `entity` can either be another node representing a selection feature, in which case a "named" selection is created. Or it can be a list/array of integers denoting domain, boundary, edge, or point numbers (depending on which of those the selection requires), producing a "manual" selection. It may also be `'all'` to select everything or `None` to clear the selection. Raises `NotImplementedError` if the node (that this method is called on) is a geometry node. These may be supported in a future release. Meanwhile, access their Java methods directly. Raises `TypeError` if the node does not have a selection and is not itself an "explicit" selection. """ java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) if isinstance(java, JClass('com.comsol.model.GeomFeature')): error = "Use the Java layer to change a geometry node's selection." log.error(error) raise NotImplementedError(error) try: java = java.selection() except Exception: if any(not hasattr(java, attr) for attr in ('set', 'all')): error = f'Node "{self}" has no and is no (explicit) selection.' log.error(error) raise TypeError(error) from None if isinstance(entity, Node): if not hasattr(java, 'named'): error = f'Node "{self}" does not support named selections.' log.error(error) raise TypeError(error) node = entity if not node.exists(): error = f'Assigned node "{node}" does not exist.' log.error(error) raise LookupError(error) java.named(node.tag()) elif isinstance(entity, (list, ndarray)): java.set(cast(entity) if len(entity) else None) elif isinstance(entity, (int, integer)): java.set(cast(entity)) elif entity == 'all': java.all() elif entity is None: java.set(cast(None)) else: error = "Entity must be a node, 'all', or an array of integers." log.error(error) raise ValueError(error)
[docs] def selection(self): """ Returns the entity or entities the node has selected. If it is a "named" selection, the corresponding selection node is returned. If it is a "manual" selection, an array of domain, boundary, edge, or point numbers is returned (depending on which of those the selection holds). `None` is returned if nothing is selected. Raises `NotImplementedError` if the node is a geometry node. These may be supported in a future release. Meanwhile, access their Java methods directly. Raises `TypeError` if the node does not have a selection and is not itself a selection. """ java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) if isinstance(java, JClass('com.comsol.model.GeomFeature')): error = "Use the Java layer to query a geometry node's selection." log.error(error) raise NotImplementedError(error) try: java = java.selection() except Exception: if not hasattr(java, 'entities'): error = f'Node "{self}" has no and is no selection.' log.error(error) raise TypeError(error) from None tag = str(java.named()) if hasattr(java, 'named') else None if tag: for node in self.model/'selections': if tag == node.tag(): break else: error = f'Found no selection with reported tag "{tag}".' log.error(error) raise LookupError(error) return node else: entities = java.entities() return array(entities) if entities else None
[docs] def toggle(self, action='flip'): """ Enables or disables the node. If `action` is `'flip'` (the default), it enables the feature in the model tree if it is currently disabled or disables it if enabled. Pass `'enable'` or `'on'` to enable the feature regardless of its current state. Pass `'disable'` or `'off'` to disable it. """ java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) if action == 'flip': java.active(not java.isActive()) elif action in ('enable', 'on', 'activate'): java.active(True) elif action in ('disable', 'off', 'deactivate'): java.active(False)
[docs] def run(self): """Performs the "run" action if the node implements it.""" java = self.java if not java: error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) if not hasattr(java, 'run'): error = f'Node "{self}" does not implement "run" operation.' log.error(error) raise RuntimeError(error) java.run()
[docs] def import_(self, file): """ Imports external data from the given `file`. Note the trailing underscore in the method name. It is needed so that the Python parser does not treat the name as an `import` statement. """ file = Path(file) if not file.exists(): error = f'File "{file}" does not exist.' log.error(error) raise IOError(error) log.info(f'Loading external data from file "{file.name}".') self.property('filename', f'{file}') self.java.discardData() self.java.importData() log.info('Finished loading external data.')
[docs] def create(self, *arguments, name=None): """ Creates a new child node and returns that node instance. Refer to the Comsol documentation for the values of valid `arguments`. It is often just the feature type of the child node to be created, given as a string such as `'Block'`, but may also require different or more arguments. If `name` is not given, a unique name/label will be assigned automatically. """ if self.is_root(): error = 'Cannot create nodes at root of model tree.' log.error(error) raise PermissionError(error) java = self.java container = None if self.is_group(): if not hasattr(java, 'uniquetag') and hasattr(java, 'feature'): container = java.feature() elif hasattr(java, 'uniquetag') and hasattr(java, 'create'): container = java elif hasattr(java, 'feature'): container = java.feature() if not hasattr(container, 'uniquetag'): error = f'Node {self} does not support feature creation.' log.error(error) raise RuntimeError(error) for argument in arguments: if isinstance(argument, str): type = argument break else: type = '?' pattern = tag_pattern(feature_path(self) + [type]) if pattern.endswith('*'): tag = container.uniquetag(pattern[:-1]) elif pattern in container.tags(): tag = container.uniquetag(pattern) else: tag = pattern if not arguments: container.create(tag) else: container.create(tag, *[cast(argument) for argument in arguments]) if name: container.get(tag).label(unescape(name)) else: name = escape(container.get(tag).label()) child = self/name check = tag_pattern(feature_path(child)) if pattern != check: pattern = check if pattern.endswith('*'): tag = container.uniquetag(pattern[:-1]) elif pattern in container.tags(): tag = container.uniquetag(pattern) else: tag = pattern log.debug(f'Retagging "{child}": "{child.tag()}" → "{tag}".') child.retag(tag) return child
[docs] def remove(self): """Removes the node from the model tree.""" if self.is_root(): error = 'Cannot remove the root node.' log.error(error) raise PermissionError(error) if self.is_group(): error = 'Cannot remove a built-in group.' log.error(error) raise PermissionError(error) if not self.exists(): error = f'Node "{self}" does not exist in model tree.' log.error(error) raise LookupError(error) parent = self.parent() container = parent.java if parent.is_group() else parent.java.feature() container.remove(self.java.tag())
######################################## # Name parsing # ######################################## def parse(string): """Parses a node path given as string to a tuple.""" # Force-cast str subclasses to str, just like `pathlib` does. # See bugs.python.org/issue21127 for the rationale. string = str(string) # Remove all leading and trailing forward slashes. string = string.lstrip('/').rstrip('/') # Split at forward slashes, but not double forward slashes. path = tuple(unescape(name) for name in split(r'(?<!/)/(?!/)', string)) return path def join(path): """Joins a node path given as tuple into a string.""" return '/'.join(escape(name) for name in path) def escape(name): """Escapes forward slashes in a node name.""" # Also accept Java strings, but always return Python string. name = str(name) return name.replace('/', '//') def unescape(name): """Reverses escaping of forward slashes in a node name.""" return name.replace('//', '/') ######################################## # Tag patterns # ######################################## @lru_cache(maxsize=1) def load_patterns(): """Loads the look-up table for tag patterns indexed by feature path.""" file = Path(__file__).parent/'tags.json' with file.open(encoding='UTF-8-sig') as stream: patterns = json_load(stream) return patterns def feature_path(node): """Returns the feature path of a node.""" if node.is_group(): return [node.name()] type = node.type() if not type: type = '?' return feature_path(node.parent()) + [type] def tag_pattern(feature_path): """Looks up the tag pattern for the best match to given feature path.""" (group, type) = (feature_path[0], feature_path[-1]) patterns = load_patterns() selected = [key for key in patterns if key.startswith(group) and key.endswith(type)] matches = get_close_matches(' → '.join(feature_path), selected) if matches: return patterns[matches[0]] elif type != '?': return type.lower()[:3] + '*' else: return 'tag*' ######################################## # Type casting # ######################################## def cast(value): """Casts a value from its Python data type to a suitable Java data type.""" if isinstance(value, Node): return JString(value.tag()) elif value is None: return value elif isinstance(value, bool): return JBoolean(value) elif isinstance(value, int): return JInt(value) elif isinstance(value, integer): return JInt(int(value)) elif isinstance(value, float): return JDouble(value) elif isinstance(value, str): return JString(value) elif isinstance(value, Path): return JString(str(value)) elif isinstance(value, (list, tuple)): dimension = 0 item = value while isinstance(item, (list, tuple)): dimension += 1 if not len(item): datatype = JString value = [] break item = item[0] else: datatype = cast(item).__class__ value = [cast(item) for item in value] return JArray(datatype, dimension)(value) elif isinstance(value, ndarray): if value.dtype.kind == 'b': return JArray(JBoolean, value.ndim)(value) elif value.dtype.kind == 'f': return JArray(JDouble, value.ndim)(value) elif value.dtype.kind == 'i': return JArray(JInt, value.ndim)(value) elif value.dtype.kind == 'O': if value.ndim > 2: error = 'Cannot cast object arrays of dimension higher than 2.' log.error(error) raise TypeError(error) if len(value) > 2: error = 'Will not cast object arrays with more than two rows.' log.error(error) raise TypeError(error) rows = [row.astype(float) for row in value] return JArray(JDouble, 2)(rows) else: error = f'Cannot cast arrays of data type "{value.dtype}".' log.error(error) raise TypeError(error) else: error = f'Cannot cast values of data type "{type(value).__name__}".' log.error(error) raise TypeError(error) def get(java, name): """Returns the value of a Java node property as a Python data type.""" datatype = java.getValueType(name) if datatype == 'Boolean': return java.getBoolean(name) elif datatype == 'BooleanArray': return array(java.getBooleanArray(name)) elif datatype == 'BooleanMatrix': return array([line for line in java.getBooleanMatrix(name)]) elif datatype == 'Double': return java.getDouble(name) elif datatype == 'DoubleArray': return array(java.getDoubleArray(name)) elif datatype == 'DoubleMatrix': return array([line for line in java.getDoubleMatrix(name)]) elif datatype == 'DoubleRowMatrix': value = java.getDoubleMatrix(name) if len(value) == 0: rows = [] elif len(value) == 1: rows = [array(value[0])] elif len(value) == 2: rows = [array(value[0]), array(value[1])] else: error = 'Cannot convert double-row matrix with more than two rows.' log.error(error) raise TypeError(error) return array(rows, dtype=object) elif datatype == 'File': return Path(str(java.getString(name))) elif datatype == 'Int': return int(java.getInt(name)) elif datatype == 'IntArray': return array(java.getIntArray(name)) elif datatype == 'IntMatrix': return array([line for line in java.getIntMatrix(name)]) elif datatype == 'None': return None elif datatype == 'Selection': return [str(string) for string in java.getEntryKeys(name)] elif datatype == 'String': value = java.getString(name) return str(value) if value else None elif datatype == 'StringArray': return [str(string) for string in java.getStringArray(name)] elif datatype == 'StringMatrix': value = java.getStringMatrix(name) if value: return [[str(string) for string in line] for line in value] else: return [[]] else: error = f'Cannot convert Java data type "{datatype}".' log.error(error) raise TypeError(error) ######################################## # Inspection # ########################################
[docs]def tree(node, max_depth=None): """ Displays the model tree. This is a convenience function to visualize, in an interactive Python session, the branch of the model tree underneath a given {class}`node <Node>`. It produces console output such as this: ```console >>> mph.tree(model/'physics') physics ├─ electrostatic │ ├─ Laplace equation │ ├─ zero charge │ ├─ initial values │ ├─ anode │ └─ cathode └─ electric currents ├─ current conservation ├─ insulation ├─ initial values ├─ anode └─ cathode ``` Specify `max_depth` to possibly limit the number of lower branches. Often the node would refer to the model's root in order to inspect the entire model tree. A {class}`Model` object is therefore also accepted as a value for `node`. Note that this function performs poorly in client–server mode, the default on Linux and macOS, especially for complex models. The client–server communication introduces inefficiencies that do not occur in stand-alone mode, the default on Windows, where the model tree, i.e. the hierarchy of related Java objects, can be traversed reasonably fast. """ def traverse(node, levels, max_depth=None): if max_depth and len(levels) > max_depth: return markers = ''.join(' ' if last else '│ ' for last in levels[:-1]) markers += '' if not levels else '└─ ' if levels[-1] else '├─ ' print(f'{markers}{node.name()}') children = node.children() last = len(children) - 1 for (index, child) in enumerate(children): traverse(child, levels + [index == last], max_depth) if not isinstance(node, Node): # Assume node is actually a model object and traverse from root. node = node/None return traverse(node, [], max_depth)
[docs]def inspect(java): """ Inspects a Java node object. This is a convenience function to facilitate exploring Comsol's Java API in an interactive Python session. It expects a Java node object, such as the one returned by the `.java` property of an existing node reference, which would implement the [`com.comsol.model.ModelEntity`][1] interface. Like any object, it could also be inspected with Python's built-in `dir` command. This function here outputs a "pretty-fied" version of that. It displays (prints to the console) the methods implemented by the Java node as well as its properties, if any are defined. ```console >>> mph.inspect((model/'studies').java) name: StudyList tag: study display: StudyList doc: StudyList methods: clear copy copyTo create duplicate duplicateTo equals forEach get getContainer index iterator model move remove scope size spliterator tags uniquetag ``` The node's name, tag, and documentation reference marker are listed first. These access methods and a few others, which are common to all objects, are suppressed in the method list further down, for the sake of clarity. [1]: https://doc.comsol.com/5.6/doc/com.comsol.help.comsol/api\ /com/comsol/model/ModelEntity.html """ # Also accept Node and Model instances. if hasattr(java, 'java'): java = java.java # Display general information about the feature. print(f'name: {java.label()}') print(f'tag: {java.tag()}') if hasattr(java, 'getType'): print(f'type: {java.getType()}') print(f'display: {java.getDisplayString()}') print(f'doc: {java.docMarker()}') # Display comments and notify if feature is deactivated or has warnings. comments = str(java.comments()) if comments: print(f'comment: {comments}') if not java.isActive(): print('This feature is currently deactivated.') # Introspect the feature's attributes. attributes = [attribute for attribute in dir(java)] # Display properties if any are defined. if 'properties' in attributes: print('properties:') names = [str(name) for name in java.properties()] for name in names: try: value = get(java, name) except Exception as error: value = f'<{error}>' print(f' {name}: {value}') # Define a list of common methods to be suppressed in the output. suppress = ['name', 'label', 'tag', 'getType', 'getDisplayString', 'docMarker', 'help', 'comments', 'toString', 'icon', 'properties', 'hasProperty', 'set', 'getEntryKeys', 'getEntryKeyIndex', 'getValueType', 'getInt', 'getIntArray', 'getIntMatrix', 'getBoolean', 'getBooleanArray', 'getBooleanMatrix', 'getDouble', 'getDoubleArray', 'getDoubleMatrix', 'getString', 'getStringArray', 'getStringMatrix', 'version', 'author', 'resetAuthor', 'lastModifiedBy', 'dateCreated', 'dateModified', 'timeCreated', 'timeModified', 'active', 'isActive', 'isactive', 'class_', 'getClass', 'hashCode', 'notify', 'notifyAll', 'wait'] # Display the feature's methods. print('methods:') for name in attributes: if name.startswith('_') or name in suppress: continue print(f' {name}')