fameio 3.1.1__py3-none-any.whl → 3.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. fameio/cli/convert_results.py +10 -10
  2. fameio/cli/make_config.py +9 -9
  3. fameio/cli/options.py +6 -4
  4. fameio/cli/parser.py +87 -51
  5. fameio/cli/reformat.py +58 -0
  6. fameio/input/__init__.py +4 -4
  7. fameio/input/loader/__init__.py +13 -13
  8. fameio/input/loader/controller.py +64 -18
  9. fameio/input/loader/loader.py +25 -16
  10. fameio/input/metadata.py +57 -38
  11. fameio/input/resolver.py +9 -10
  12. fameio/input/scenario/agent.py +62 -26
  13. fameio/input/scenario/attribute.py +93 -40
  14. fameio/input/scenario/contract.py +160 -56
  15. fameio/input/scenario/exception.py +41 -18
  16. fameio/input/scenario/fameiofactory.py +57 -6
  17. fameio/input/scenario/generalproperties.py +22 -12
  18. fameio/input/scenario/scenario.py +117 -38
  19. fameio/input/scenario/stringset.py +29 -11
  20. fameio/input/schema/agenttype.py +27 -10
  21. fameio/input/schema/attribute.py +108 -45
  22. fameio/input/schema/java_packages.py +14 -12
  23. fameio/input/schema/schema.py +39 -15
  24. fameio/input/validator.py +198 -54
  25. fameio/input/writer.py +137 -46
  26. fameio/logs.py +28 -47
  27. fameio/output/__init__.py +5 -1
  28. fameio/output/agent_type.py +89 -28
  29. fameio/output/conversion.py +52 -37
  30. fameio/output/csv_writer.py +107 -27
  31. fameio/output/data_transformer.py +17 -24
  32. fameio/output/execution_dao.py +170 -0
  33. fameio/output/input_dao.py +71 -33
  34. fameio/output/output_dao.py +33 -11
  35. fameio/output/reader.py +64 -21
  36. fameio/output/yaml_writer.py +16 -8
  37. fameio/scripts/__init__.py +22 -4
  38. fameio/scripts/convert_results.py +126 -52
  39. fameio/scripts/convert_results.py.license +1 -1
  40. fameio/scripts/exception.py +7 -0
  41. fameio/scripts/make_config.py +34 -13
  42. fameio/scripts/make_config.py.license +1 -1
  43. fameio/scripts/reformat.py +71 -0
  44. fameio/scripts/reformat.py.license +3 -0
  45. fameio/series.py +174 -59
  46. fameio/time.py +79 -25
  47. fameio/tools.py +48 -8
  48. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/METADATA +50 -34
  49. fameio-3.3.0.dist-info/RECORD +60 -0
  50. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/WHEEL +1 -1
  51. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/entry_points.txt +1 -0
  52. CHANGELOG.md +0 -288
  53. fameio-3.1.1.dist-info/RECORD +0 -56
  54. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSE.txt +0 -0
  55. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/Apache-2.0.txt +0 -0
  56. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/CC-BY-4.0.txt +0 -0
  57. {fameio-3.1.1.dist-info → fameio-3.3.0.dist-info}/LICENSES/CC0-1.0.txt +0 -0
@@ -9,11 +9,12 @@ from typing import Any, NamedTuple, Final
9
9
 
10
10
  from fameio.input.metadata import Metadata, MetadataComponent
11
11
  from fameio.tools import keys_to_lower
12
- from .exception import log_and_raise
12
+ from .exception import log_scenario_error
13
+ from ...logs import log_error
13
14
 
14
15
 
15
16
  class Attribute(Metadata):
16
- """An Attribute of an agent in a scenario"""
17
+ """An Attribute of an agent in a scenario."""
17
18
 
18
19
  KEY_VALUE: Final[str] = "Value".lower()
19
20
  KEY_VALUES: Final[str] = "Values".lower()
@@ -21,37 +22,49 @@ class Attribute(Metadata):
21
22
  NAME_STRING_SEPARATOR: Final[str] = "."
22
23
 
23
24
  class __ValueMeta(NamedTuple):
24
- """NamedTuple for a primitive value associated with Metadata"""
25
+ """NamedTuple for a primitive value associated with Metadata."""
25
26
 
26
27
  value: str | Number
27
28
  meta: MetadataComponent
28
29
 
29
30
  class __NestedMeta(NamedTuple):
30
- """NamedTuple for a nested value associated with Metadata"""
31
+ """NamedTuple for a nested value associated with Metadata."""
31
32
 
32
33
  value: dict[str, Any]
33
34
  meta: MetadataComponent
34
35
 
35
36
  class __DefinitionType(Enum):
36
- """Indicates the type of data definition for an Attribute"""
37
+ """Indicates the type of data definition for an Attribute."""
37
38
 
38
39
  VALUE = auto()
39
40
  VALUE_LIST = auto()
40
41
  NESTED = auto()
41
42
  NESTED_LIST = auto()
42
43
 
43
- _ERR_VALUE_MISSING = "Value not specified for Attribute '{}' - leave out if default shall be used (if defined)."
44
- _ERR_LIST_EMPTY = "Attribute '{}' was assigned an empty list - please remove attribute or fill empty assignments."
45
- _ERR_DICT_EMPTY = "Attribute '{}' was assigned an empty dictionary - please remove or fill empty assignments."
46
- _ERR_MIXED_DATA = "Attribute '{}' was assigned a list with mixed complex and simple entries - please fix."
44
+ _ERR_CREATION = "Found error in specification of Attribute '{}'."
45
+ _ERR_VALUE_MISSING = "Value not specified for Attribute '{}' - leave this Attribute out or specify a value."
46
+ _ERR_LIST_EMPTY = "Attribute was assigned an empty list - please remove attribute or fill empty assignments."
47
+ _ERR_DICT_EMPTY = "Attribute was assigned an empty dictionary - please remove or fill empty assignments."
48
+ _ERR_MIXED_DATA = "Attribute was assigned a list with mixed complex and simple entries - please fix."
47
49
 
48
50
  def __init__(self, name: str, definitions: str | Number | list | dict) -> None:
49
- """Parses an Attribute's definition"""
51
+ """Creates a new Attribute.
52
+
53
+ Args:
54
+ name: full name of the Attribute including the parent Attribute(s) names
55
+ definitions: of this Attribute including its inner elements, if any
56
+
57
+ Raises:
58
+ ScenarioError: if this Attribute or its inner elements could not be created, logged with level "ERROR"
59
+ """
50
60
  self._full_name = name
51
61
  if definitions is None:
52
- log_and_raise(Attribute._ERR_VALUE_MISSING.format(name))
62
+ raise log_scenario_error(Attribute._ERR_VALUE_MISSING.format(name))
53
63
  super().__init__(definitions)
54
- data_type = Attribute._get_data_type(name, definitions)
64
+ try:
65
+ data_type = Attribute._get_data_type(definitions)
66
+ except ValueError as e:
67
+ raise log_scenario_error(Attribute._ERR_CREATION.format(name)) from e
55
68
 
56
69
  self._value: str | Number | None = None
57
70
  self._value_list: list[Attribute.__ValueMeta] | None = None
@@ -59,51 +72,80 @@ class Attribute(Metadata):
59
72
  self._nested_list: list[Attribute.__NestedMeta] | None = None
60
73
 
61
74
  if data_type is Attribute.__DefinitionType.VALUE:
62
- value = keys_to_lower(definitions)[Attribute.KEY_VALUE] if isinstance(definitions, dict) else definitions
63
- self._value = value
75
+ self._value = self._extract_value(definitions).value # type: ignore[arg-type]
64
76
  elif data_type is Attribute.__DefinitionType.VALUE_LIST:
65
- self._value_list = self._extract_values(definitions)
77
+ self._value_list = self._extract_values(definitions) # type: ignore[arg-type]
66
78
  elif data_type is Attribute.__DefinitionType.NESTED:
67
- self._nested = Attribute._build_attribute_dict(name, definitions)
79
+ self._nested = Attribute._build_attribute_dict(name, definitions) # type: ignore[arg-type]
68
80
  elif data_type is Attribute.__DefinitionType.NESTED_LIST:
69
81
  self._nested_list = []
70
82
  values = keys_to_lower(definitions)[Attribute.KEY_VALUES] if isinstance(definitions, dict) else definitions
71
- for list_index, definition in enumerate(values):
83
+ for list_index, definition in enumerate(values): # type: ignore[arg-type]
72
84
  list_meta = MetadataComponent(definition)
73
85
  list_extended_name = name + Attribute.NAME_STRING_SEPARATOR + str(list_index)
74
86
  nested_items = Attribute._build_attribute_dict(list_extended_name, definition)
75
87
  self._nested_list.append(Attribute.__NestedMeta(value=nested_items, meta=list_meta))
76
88
 
77
89
  @staticmethod
78
- def _get_data_type(name: str, definitions: Any) -> Attribute.__DefinitionType:
79
- """Returns type of data derived from given `definitions`"""
90
+ def _get_data_type(definitions: Any) -> Attribute.__DefinitionType:
91
+ """Returns type of data derived from given `definitions`.
92
+
93
+ Args:
94
+ definitions: to deduct the data type from
95
+
96
+ Returns:
97
+ data type derived from given definitions
98
+
99
+ Raises:
100
+ ValueError: if definitions are empty or could not be derived, logged with level "ERROR"
101
+ """
80
102
  if isinstance(definitions, list):
81
103
  if len(definitions) == 0:
82
- log_and_raise(Attribute._ERR_LIST_EMPTY.format(name))
104
+ raise log_error(ValueError(Attribute._ERR_LIST_EMPTY))
83
105
  return Attribute._get_data_type_list(definitions)
84
106
  if isinstance(definitions, dict):
85
107
  if len(definitions) == 0:
86
- log_and_raise(Attribute._ERR_DICT_EMPTY.format(name))
108
+ raise log_error(ValueError(Attribute._ERR_DICT_EMPTY))
87
109
  return Attribute._get_data_type_dict(definitions)
88
110
  return Attribute.__DefinitionType.VALUE
89
111
 
90
112
  @staticmethod
91
113
  def _get_data_type_list(definitions: list[Any]) -> Attribute.__DefinitionType:
92
- """Returns type of data from a given non-empty list `definitions`"""
114
+ """Returns type of data from a given non-empty list `definitions`.
115
+
116
+ Args:
117
+ definitions: list of data to derive data type from
118
+
119
+ Returns:
120
+ data type of data list
121
+
122
+ Raises:
123
+ ValueError: if definitions represent a mix of simple and complex entries, logged with level "ERROR"
124
+ """
93
125
  if all(Attribute._is_value_definition(entry) for entry in definitions):
94
126
  return Attribute.__DefinitionType.VALUE_LIST
95
127
  if Attribute._is_list_of_dict(definitions):
96
128
  return Attribute.__DefinitionType.NESTED_LIST
97
- log_and_raise(Attribute._ERR_MIXED_DATA.format(repr(definitions)))
129
+ raise log_error(ValueError(Attribute._ERR_MIXED_DATA))
98
130
 
99
131
  @staticmethod
100
132
  def _is_list_of_dict(definitions: list) -> bool:
101
- """Returns True if given `definitions` is a list of (only) dict"""
133
+ """Returns True if given `definitions` is a list of (only) dict."""
102
134
  return all(isinstance(entry, dict) for entry in definitions)
103
135
 
104
136
  @staticmethod
105
137
  def _get_data_type_dict(definitions: dict[str, Any]) -> Attribute.__DefinitionType:
106
- """Returns type of data from a given non-empty dict `definitions`"""
138
+ """Returns type of data from a given non-empty dict `definitions`.
139
+
140
+ Args:
141
+ definitions: to derive the data type from
142
+
143
+ Returns:
144
+ data type derived from given `definitions`
145
+
146
+ Raises:
147
+ ValueError: if definitions represent a mix of simple and complex entries, logged with level "ERROR"
148
+ """
107
149
  low_keys = keys_to_lower(definitions)
108
150
  if Attribute.KEY_VALUE in low_keys.keys():
109
151
  return Attribute.__DefinitionType.VALUE
@@ -113,19 +155,19 @@ class Attribute(Metadata):
113
155
  return Attribute.__DefinitionType.VALUE_LIST
114
156
  if Attribute._is_list_of_dict(values):
115
157
  return Attribute.__DefinitionType.NESTED_LIST
116
- log_and_raise(Attribute._ERR_MIXED_DATA.format(repr(values)))
158
+ raise log_error(ValueError(Attribute._ERR_MIXED_DATA))
117
159
  return Attribute.__DefinitionType.NESTED
118
160
 
119
161
  @staticmethod
120
162
  def _is_value_definition(definition: Any) -> bool:
121
- """Returns True if given `definition` is either a dict with a key `Value` or a simple value"""
163
+ """Returns True if given `definition` is either a dict with a key `Value` or a simple value."""
122
164
  if isinstance(definition, dict):
123
165
  return Attribute.KEY_VALUE in keys_to_lower(definition).keys()
124
166
  return isinstance(definition, (str, Number))
125
167
 
126
168
  @staticmethod
127
169
  def _extract_value(definition: str | Number | dict[str, Any]) -> Attribute.__ValueMeta:
128
- """Creates a ValueMeta Tuple associating a Value with its optional metadata"""
170
+ """Creates a ValueMeta Tuple associating a Value with its optional metadata."""
129
171
  if isinstance(definition, dict):
130
172
  return Attribute.__ValueMeta(
131
173
  value=keys_to_lower(definition)[Attribute.KEY_VALUE], meta=MetadataComponent(definition)
@@ -134,27 +176,38 @@ class Attribute(Metadata):
134
176
 
135
177
  @staticmethod
136
178
  def _extract_values(definition: list | dict) -> list[Attribute.__ValueMeta]:
137
- """Creates a list of ValueMeta Tuples, each associating a value with optional metadata"""
179
+ """Creates a list of ValueMeta Tuples, each associating a value with optional metadata."""
138
180
  values = keys_to_lower(definition)[Attribute.KEY_VALUES] if isinstance(definition, dict) else definition
139
181
  return [Attribute._extract_value(entry) for entry in values]
140
182
 
141
183
  @staticmethod
142
- def _build_attribute_dict(name: str, definitions: dict[str, Any]) -> dict[str, Attribute]:
143
- """Returns a new dictionary containing Attributes generated from given `definitions`"""
184
+ def _build_attribute_dict(parent_name: str, definitions: dict[str, Any]) -> dict[str, Attribute]:
185
+ """Returns a new dictionary containing Attributes generated from given `definitions`.
186
+
187
+ Args:
188
+ parent_name: name of parent element
189
+ definitions: of the Attributes
190
+
191
+ Returns:
192
+ dictionary of Attributes created from given definitions
193
+
194
+ Raises:
195
+ ScenarioError: if any of the Attributes could not be created, logged with level "ERROR"
196
+ """
144
197
  inner_elements = {}
145
198
  for nested_name, value in definitions.items():
146
- full_name = name + Attribute.NAME_STRING_SEPARATOR + nested_name
199
+ full_name = parent_name + Attribute.NAME_STRING_SEPARATOR + nested_name
147
200
  inner_elements[nested_name] = Attribute(full_name, value)
148
201
  return inner_elements
149
202
 
150
203
  @property
151
204
  def has_value(self) -> bool:
152
- """Returns True if Attribute has any value assigned"""
205
+ """Returns True if Attribute has any value assigned."""
153
206
  return self._value is not None or self._value_list is not None
154
207
 
155
208
  @property
156
209
  def value(self) -> str | Number | list[str | Number] | None:
157
- """Returns value or list of values if available on this Attribute (ignoring any Metadata), else None"""
210
+ """Returns value or list of values if available on this Attribute (ignoring any Metadata), else None."""
158
211
  if self._value is not None:
159
212
  return self._value
160
213
  if self._value_list is not None:
@@ -163,23 +216,23 @@ class Attribute(Metadata):
163
216
 
164
217
  @property
165
218
  def has_nested(self) -> bool:
166
- """Returns True if nested Attributes are present, False otherwise; also returns False for nested lists"""
219
+ """Returns True if nested Attributes are present, False otherwise; also returns False for nested lists."""
167
220
  return self._nested is not None
168
221
 
169
222
  @property
170
223
  def nested(self) -> dict[str, Attribute]:
171
- """Returns dictionary of all nested Attributes if nested Attributes are present, else None"""
172
- return self._nested if self.has_nested else None
224
+ """Returns dictionary of all nested Attributes if nested Attributes are present, else empty dict."""
225
+ return self._nested if self._nested is not None else {}
173
226
 
174
227
  @property
175
228
  def has_nested_list(self) -> bool:
176
- """Returns True if list of nested items is present"""
229
+ """Returns True if list of nested items is present."""
177
230
  return self._nested_list is not None
178
231
 
179
232
  @property
180
233
  def nested_list(self) -> list[dict[str, Attribute]]:
181
- """Return list of all nested Attribute dictionaries if such are present, else None"""
182
- return [entry.value for entry in self._nested_list] if self.has_nested_list else None
234
+ """Return list of all nested Attribute dictionaries if such are present, else an empty list."""
235
+ return [entry.value for entry in self._nested_list] if self._nested_list is not None else []
183
236
 
184
237
  def __repr__(self) -> str:
185
238
  return self._full_name
@@ -3,18 +3,21 @@
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
  from __future__ import annotations
5
5
 
6
- from typing import Any, Optional, Final
6
+ from typing import Any, Final, overload
7
7
 
8
+ from fameio.input import InputError
8
9
  from fameio.input.metadata import Metadata
9
- from fameio.logs import log
10
- from fameio.time import FameTime
10
+ from fameio.input.scenario.attribute import Attribute
11
+ from fameio.logs import log, log_error
12
+ from fameio.time import FameTime, ConversionError
11
13
  from fameio.tools import ensure_is_list, keys_to_lower
12
- from .attribute import Attribute
13
- from .exception import get_or_default, get_or_raise, log_and_raise
14
14
 
15
15
 
16
16
  class Contract(Metadata):
17
- """Contract between two Agents of a scenario"""
17
+ """Contract between two Agents of a scenario."""
18
+
19
+ class ContractError(InputError):
20
+ """An error that occurred while parsing Contract definitions."""
18
21
 
19
22
  KEY_SENDER: Final[str] = "SenderId".lower()
20
23
  KEY_RECEIVER: Final[str] = "ReceiverId".lower()
@@ -26,13 +29,15 @@ class Contract(Metadata):
26
29
 
27
30
  _ERR_MISSING_KEY = "Contract requires key '{}' but is missing it."
28
31
  _ERR_MULTI_CONTRACT_CORRUPT = (
29
- "Definition of Contracts is valid only for One-to-One, One-to-many, Many-to-one, "
32
+ "Definition of Contracts is valid only for One-to-One, One-to-Many, Many-to-One, "
30
33
  "or N-to-N sender-to-receiver numbers. Found M-to-N pairing in Contract with "
31
34
  "Senders: {} and Receivers: {}."
32
35
  )
33
36
  _ERR_INTERVAL_NOT_POSITIVE = "Contract delivery interval must be a positive integer but was: {}"
34
37
  _ERR_SENDER_IS_RECEIVER = "Contract sender and receiver have the same id: {}"
35
38
  _ERR_DOUBLE_ATTRIBUTE = "Cannot add attribute '{}' to contract because it already exists."
39
+ _ERR_TIME_CONVERSION = "Contract item '{}' is an ill-formatted time: '{}'"
40
+ _ERR_PRODUCT_EMPTY = f"A Contract's `{KEY_PRODUCT}` must be a non-emtpy string."
36
41
 
37
42
  # pylint: disable=too-many-arguments, too-many-positional-arguments
38
43
  def __init__(
@@ -42,116 +47,198 @@ class Contract(Metadata):
42
47
  product_name: str,
43
48
  delivery_interval: int,
44
49
  first_delivery_time: int,
45
- expiration_time: Optional[int] = None,
46
- metadata: Optional[dict] = None,
50
+ expiration_time: int | None = None,
51
+ metadata: dict | None = None,
47
52
  ) -> None:
48
- """Constructs a new Contract"""
53
+ """Constructs a new Contract.
54
+
55
+ Args:
56
+ sender_id: unique id of sender
57
+ receiver_id: unique id of receiver
58
+ product_name: name of contracted product
59
+ delivery_interval: time interval in steps between contract deliveries
60
+ first_delivery_time: absolute time of first contract execution
61
+ expiration_time: absolute time at which contract execution stops
62
+ metadata: any metadata associated with the contract
63
+
64
+ Returns:
65
+ new Contract
66
+
67
+ Raises:
68
+ ContractError: if delivery interval is invalid, logged on level "ERROR"
69
+ """
49
70
  super().__init__({self.KEY_METADATA: metadata} if metadata else None)
50
- assert product_name != ""
71
+ if product_name.strip() == "":
72
+ raise log_error(self.ContractError(self._ERR_PRODUCT_EMPTY))
51
73
  if sender_id == receiver_id:
52
74
  log().warning(self._ERR_SENDER_IS_RECEIVER.format(sender_id))
53
75
  if delivery_interval <= 0:
54
- raise ValueError(self._ERR_INTERVAL_NOT_POSITIVE.format(delivery_interval))
76
+ raise log_error(self.ContractError(self._ERR_INTERVAL_NOT_POSITIVE.format(delivery_interval)))
55
77
  self._sender_id = sender_id
56
78
  self._receiver_id = receiver_id
57
79
  self._product_name = product_name
58
80
  self._delivery_interval = delivery_interval
59
81
  self._first_delivery_time = first_delivery_time
60
82
  self._expiration_time = expiration_time
61
- self._attributes = {}
83
+ self._attributes: dict = {}
62
84
 
63
85
  def _notify_data_changed(self):
64
- """Placeholder method used to signal data changes to derived types"""
86
+ """Placeholder method used to signal data changes to derived types."""
65
87
 
66
88
  @property
67
89
  def product_name(self) -> str:
68
- """Returns the product name of the contract"""
90
+ """Returns the product name of the contract."""
69
91
  return self._product_name
70
92
 
71
93
  @property
72
94
  def sender_id(self) -> int:
73
- """Returns the sender ID of the contract"""
95
+ """Returns the sender ID of the contract."""
74
96
  return self._sender_id
75
97
 
76
98
  @property
77
99
  def display_sender_id(self) -> str:
78
- """Returns the sender ID of the contract as a string for display purposes"""
100
+ """Returns the sender ID of the contract as a string for display purposes."""
79
101
  return f"#{self._sender_id}"
80
102
 
81
103
  @property
82
104
  def receiver_id(self) -> int:
83
- """Returns the receiver ID of the contract"""
105
+ """Returns the receiver ID of the contract."""
84
106
  return self._receiver_id
85
107
 
86
108
  @property
87
109
  def display_receiver_id(self) -> str:
88
- """Returns the receiver ID of the contract as a string for display purposes"""
110
+ """Returns the receiver ID of the contract as a string for display purposes."""
89
111
  return f"#{self._receiver_id}"
90
112
 
91
113
  @property
92
114
  def delivery_interval(self) -> int:
93
- """Returns the delivery interval of the contract (in steps)"""
115
+ """Returns the delivery interval of the contract (in steps)."""
94
116
  return self._delivery_interval
95
117
 
96
118
  @property
97
119
  def first_delivery_time(self) -> int:
98
- """Returns the first delivery time of the contract"""
120
+ """Returns the first delivery time of the contract."""
99
121
  return self._first_delivery_time
100
122
 
101
123
  @property
102
- def expiration_time(self) -> Optional[int]:
103
- """Returns the expiration time of the contract if available, None otherwise"""
124
+ def expiration_time(self) -> int | None:
125
+ """Returns the expiration time of the contract if available, None otherwise."""
104
126
  return self._expiration_time
105
127
 
106
128
  @property
107
129
  def attributes(self) -> dict[str, Attribute]:
108
- """Returns dictionary of all Attributes of the contract"""
130
+ """Returns dictionary of all Attributes of the contract."""
109
131
  return self._attributes
110
132
 
111
133
  def add_attribute(self, name: str, value: Attribute) -> None:
112
- """Adds a new attribute to the Contract (raise an error if it already exists)"""
134
+ """Adds a new attribute to the Contract.
135
+
136
+ Args:
137
+ name: of the attribute
138
+ value: of the attribute
139
+
140
+ Raises:
141
+ ContractError: if attribute already exists, logged on level "ERROR"
142
+ """
113
143
  if name in self._attributes:
114
- raise ValueError(self._ERR_DOUBLE_ATTRIBUTE.format(name))
144
+ raise log_error(self.ContractError(self._ERR_DOUBLE_ATTRIBUTE.format(name)))
115
145
  self._attributes[name] = value
116
146
  self._notify_data_changed()
117
147
 
118
148
  @classmethod
119
149
  def from_dict(cls, definitions: dict) -> Contract:
120
- """Parses Contract from given `definitions`"""
150
+ """Parses contract from given `definitions`.
151
+
152
+ Args:
153
+ definitions: dictionary representation of a contract
154
+
155
+ Returns:
156
+ new contract
157
+
158
+ Raises:
159
+ ContractError: if definitions are incomplete or erroneous, logged on level "ERROR"
160
+ """
121
161
  definitions = keys_to_lower(definitions)
122
- sender_id = get_or_raise(definitions, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY)
123
- receiver_id = get_or_raise(definitions, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY)
124
- product_name = get_or_raise(definitions, Contract.KEY_PRODUCT, Contract._ERR_MISSING_KEY)
125
- first_delivery_time = FameTime.convert_string_if_is_datetime(
126
- get_or_raise(definitions, Contract.KEY_FIRST_DELIVERY, Contract._ERR_MISSING_KEY)
127
- )
128
- delivery_interval = get_or_raise(definitions, Contract.KEY_INTERVAL, Contract._ERR_MISSING_KEY)
129
- expiration_time = get_or_default(definitions, Contract.KEY_EXPIRE, None)
130
- expiration_time = FameTime.convert_string_if_is_datetime(expiration_time) if expiration_time else None
131
-
132
- contract = cls(
133
- sender_id,
134
- receiver_id,
135
- product_name,
136
- delivery_interval,
137
- first_delivery_time,
138
- expiration_time,
139
- )
162
+ sender_id = Contract._get_or_raise(definitions, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY)
163
+ receiver_id = Contract._get_or_raise(definitions, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY)
164
+ product_name = Contract._get_or_raise(definitions, Contract.KEY_PRODUCT, Contract._ERR_MISSING_KEY)
165
+
166
+ first_delivery_time = Contract._get_time(definitions, Contract.KEY_FIRST_DELIVERY)
167
+ delivery_interval = Contract._get_or_raise(definitions, Contract.KEY_INTERVAL, Contract._ERR_MISSING_KEY)
168
+ expiration_time = Contract._get_time(definitions, Contract.KEY_EXPIRE, mandatory=False)
169
+
170
+ contract = cls(sender_id, receiver_id, product_name, delivery_interval, first_delivery_time, expiration_time)
140
171
  contract._extract_metadata(definitions)
141
- attribute_definitions = get_or_default(definitions, Contract.KEY_ATTRIBUTES, {})
142
- contract._init_attributes_from_dict(attribute_definitions)
172
+ contract._init_attributes_from_dict(definitions.get(Contract.KEY_ATTRIBUTES, {}))
143
173
  return contract
144
174
 
175
+ @staticmethod
176
+ def _get_or_raise(dictionary: dict, key: str, error_message: str) -> Any:
177
+ """Returns value associated with `key` in given `dictionary`, or raises exception if key or value is missing.
178
+
179
+ Args:
180
+ dictionary: to search the key in
181
+ key: to be searched
182
+ error_message: to be logged and included in the raised exception if key is missing
183
+
184
+ Returns:
185
+ value associated with given key in given dictionary
186
+
187
+ Raises:
188
+ ContractError: if given key is not in given dictionary or value is None, logged on level "ERROR"
189
+ """
190
+ if key not in dictionary or dictionary[key] is None:
191
+ raise log_error(Contract.ContractError(error_message.format(key)))
192
+ return dictionary[key]
193
+
194
+ @staticmethod
195
+ @overload
196
+ def _get_time(definitions: dict, key: str) -> int: ... # noqa: E704
197
+
198
+ @staticmethod
199
+ @overload
200
+ def _get_time(definitions: dict, key: str, mandatory: bool) -> int | None: ... # noqa: E704
201
+
202
+ @staticmethod
203
+ def _get_time(definitions: dict, key: str, mandatory: bool = True) -> int | None:
204
+ """Extract time representation value at given key, and, if present, convert to integer, else return None.
205
+
206
+ Args:
207
+ definitions: to search given key in
208
+ key: to check for an associated value
209
+ mandatory: if true, also raises an error if key is missing
210
+
211
+ Returns:
212
+ None if key is not mandatory/present, else the integer representation of the time value associated with key
213
+
214
+ Raises:
215
+ ContractError: if found value could not be converted or mandatory value is missing, logged on level "ERROR"
216
+ """
217
+ if key in definitions:
218
+ value = definitions[key]
219
+ try:
220
+ return FameTime.convert_string_if_is_datetime(value)
221
+ except ConversionError as e:
222
+ raise log_error(Contract.ContractError(Contract._ERR_TIME_CONVERSION.format(key, value))) from e
223
+ if mandatory:
224
+ raise log_error(Contract.ContractError(Contract._ERR_MISSING_KEY.format(key)))
225
+ return None
226
+
145
227
  def _init_attributes_from_dict(self, attributes: dict[str, Any]) -> None:
146
- """Resets Contract `attributes` from dict; Must only be called when creating a new Contract"""
147
- assert len(self._attributes) == 0
148
- self._attributes = {}
228
+ """Resets Contract `attributes` from dict.
229
+
230
+ Args:
231
+ attributes: key-value pairs of attributes to be set
232
+
233
+ Raises:
234
+ ContractError: if some of provided attributes were already present
235
+ """
149
236
  for name, value in attributes.items():
150
237
  full_name = f"{type}.{id}{name}"
151
238
  self.add_attribute(name, Attribute(full_name, value))
152
239
 
153
240
  def _to_dict(self) -> dict:
154
- """Serializes the Contract content to a dict"""
241
+ """Serializes the Contract content to a dict."""
155
242
  result = {
156
243
  self.KEY_SENDER: self.sender_id,
157
244
  self.KEY_RECEIVER: self.receiver_id,
@@ -169,7 +256,20 @@ class Contract(Metadata):
169
256
 
170
257
  @staticmethod
171
258
  def split_contract_definitions(multi_definition: dict) -> list[dict]:
172
- """Splits given `multi_definition` dictionary into list of individual Contract definitions"""
259
+ """Split given M:N `multi_definition` of contracts to multiple 1:1 contracts.
260
+
261
+ Splits given dictionary of contracts with potentially more than ore sender and/or receiver into a list
262
+ of individual contract definitions with one sender and one receiver.
263
+
264
+ Args:
265
+ multi_definition: contract definitions with potentially more than ore sender and/or receiver
266
+
267
+ Returns:
268
+ list of contract definitions with exactly one sender and receiver
269
+
270
+ Raises:
271
+ ContractError: if multi_definition is incomplete or erroneous, logged on level "ERROR"
272
+ """
173
273
  contracts = []
174
274
  base_data = {}
175
275
  multi_definition = keys_to_lower(multi_definition)
@@ -183,8 +283,12 @@ class Contract(Metadata):
183
283
  ]:
184
284
  if key in multi_definition:
185
285
  base_data[key] = multi_definition[key]
186
- senders = ensure_is_list(get_or_raise(multi_definition, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY))
187
- receivers = ensure_is_list(get_or_raise(multi_definition, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY))
286
+ senders = ensure_is_list(
287
+ Contract._get_or_raise(multi_definition, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY)
288
+ )
289
+ receivers = ensure_is_list(
290
+ Contract._get_or_raise(multi_definition, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY)
291
+ )
188
292
  if len(senders) > 1 and len(receivers) == 1:
189
293
  for index, sender in enumerate(senders):
190
294
  contracts.append(Contract._copy_contract(sender, receivers[0], base_data))
@@ -195,12 +299,12 @@ class Contract(Metadata):
195
299
  for index in range(len(senders)): # pylint: disable=consider-using-enumerate
196
300
  contracts.append(Contract._copy_contract(senders[index], receivers[index], base_data))
197
301
  else:
198
- log_and_raise(Contract._ERR_MULTI_CONTRACT_CORRUPT.format(senders, receivers))
302
+ raise log_error(Contract.ContractError(Contract._ERR_MULTI_CONTRACT_CORRUPT.format(senders, receivers)))
199
303
  return contracts
200
304
 
201
305
  @staticmethod
202
306
  def _copy_contract(sender: int, receiver: int, base_data: dict) -> dict:
203
- """Returns a new contract definition dictionary, with given `sender` and `receiver` and copied `base_data`"""
307
+ """Returns a new contract definition dictionary, with given `sender` and `receiver` and copied `base_data`."""
204
308
  contract = {
205
309
  Contract.KEY_SENDER: sender,
206
310
  Contract.KEY_RECEIVER: receiver,