fameio 3.1.0__py3-none-any.whl → 3.2.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 (56) hide show
  1. fameio/cli/__init__.py +2 -3
  2. fameio/cli/convert_results.py +6 -4
  3. fameio/cli/make_config.py +6 -4
  4. fameio/cli/options.py +3 -3
  5. fameio/cli/parser.py +43 -31
  6. fameio/input/__init__.py +1 -9
  7. fameio/input/loader/__init__.py +9 -7
  8. fameio/input/loader/controller.py +64 -14
  9. fameio/input/loader/loader.py +14 -7
  10. fameio/input/metadata.py +37 -18
  11. fameio/input/resolver.py +5 -4
  12. fameio/input/scenario/__init__.py +7 -8
  13. fameio/input/scenario/agent.py +52 -19
  14. fameio/input/scenario/attribute.py +28 -29
  15. fameio/input/scenario/contract.py +161 -52
  16. fameio/input/scenario/exception.py +45 -22
  17. fameio/input/scenario/fameiofactory.py +63 -7
  18. fameio/input/scenario/generalproperties.py +17 -6
  19. fameio/input/scenario/scenario.py +111 -28
  20. fameio/input/scenario/stringset.py +27 -8
  21. fameio/input/schema/__init__.py +5 -5
  22. fameio/input/schema/agenttype.py +29 -11
  23. fameio/input/schema/attribute.py +174 -84
  24. fameio/input/schema/java_packages.py +8 -5
  25. fameio/input/schema/schema.py +35 -9
  26. fameio/input/validator.py +58 -42
  27. fameio/input/writer.py +139 -41
  28. fameio/logs.py +23 -17
  29. fameio/output/__init__.py +5 -1
  30. fameio/output/agent_type.py +93 -27
  31. fameio/output/conversion.py +48 -30
  32. fameio/output/csv_writer.py +88 -18
  33. fameio/output/data_transformer.py +12 -21
  34. fameio/output/input_dao.py +68 -32
  35. fameio/output/output_dao.py +26 -4
  36. fameio/output/reader.py +61 -18
  37. fameio/output/yaml_writer.py +18 -9
  38. fameio/scripts/__init__.py +9 -2
  39. fameio/scripts/convert_results.py +144 -52
  40. fameio/scripts/convert_results.py.license +1 -1
  41. fameio/scripts/exception.py +7 -0
  42. fameio/scripts/make_config.py +34 -12
  43. fameio/scripts/make_config.py.license +1 -1
  44. fameio/series.py +132 -47
  45. fameio/time.py +88 -37
  46. fameio/tools.py +9 -8
  47. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/METADATA +19 -13
  48. fameio-3.2.0.dist-info/RECORD +56 -0
  49. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/WHEEL +1 -1
  50. CHANGELOG.md +0 -279
  51. fameio-3.1.0.dist-info/RECORD +0 -56
  52. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/LICENSE.txt +0 -0
  53. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/LICENSES/Apache-2.0.txt +0 -0
  54. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/LICENSES/CC-BY-4.0.txt +0 -0
  55. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/LICENSES/CC0-1.0.txt +0 -0
  56. {fameio-3.1.0.dist-info → fameio-3.2.0.dist-info}/entry_points.txt +0 -0
fameio/input/resolver.py CHANGED
@@ -1,9 +1,10 @@
1
- # SPDX-FileCopyrightText: 2024 German Aerospace Center <fame@dlr.de>
1
+ # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
+ from __future__ import annotations
5
+
4
6
  import glob
5
7
  from os import path
6
- from typing import Optional
7
8
 
8
9
 
9
10
  class PathResolver:
@@ -23,7 +24,7 @@ class PathResolver:
23
24
  return glob.glob(absolute_path)
24
25
 
25
26
  # noinspection PyMethodMayBeStatic
26
- def resolve_series_file_path(self, file_name: str) -> Optional[str]:
27
+ def resolve_series_file_path(self, file_name: str) -> str | None:
27
28
  """
28
29
  Searches for the file in the current working directory and returns its absolute file path
29
30
 
@@ -38,7 +39,7 @@ class PathResolver:
38
39
  return file_name if path.isabs(file_name) else PathResolver._search_file_in_directory(file_name, path.curdir)
39
40
 
40
41
  @staticmethod
41
- def _search_file_in_directory(file_name: str, directory: str) -> Optional[str]:
42
+ def _search_file_in_directory(file_name: str, directory: str) -> str | None:
42
43
  """Returns path to said `file_name` relative to specified `directory` if file was found there, None otherwise"""
43
44
  file_path = path.join(directory, file_name)
44
45
  return file_path if path.exists(file_path) else None
@@ -1,10 +1,9 @@
1
- # SPDX-FileCopyrightText: 2023 German Aerospace Center <fame@dlr.de>
1
+ # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
-
5
- from .agent import Agent
6
- from .attribute import Attribute
7
- from .contract import Contract
8
- from .generalproperties import GeneralProperties
9
- from .scenario import Scenario
10
- from .stringset import StringSet
4
+ from .agent import Agent # noqa: F401
5
+ from .attribute import Attribute # noqa: F401
6
+ from .contract import Contract # noqa: F401
7
+ from .generalproperties import GeneralProperties # noqa: F401
8
+ from .scenario import Scenario # noqa: F401
9
+ from .stringset import StringSet # noqa: F401
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2024 German Aerospace Center <fame@dlr.de>
1
+ # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
  from __future__ import annotations
@@ -7,9 +7,10 @@ import ast
7
7
  from typing import Any, Final
8
8
 
9
9
  from fameio.input.metadata import Metadata
10
+ from fameio.logs import log
10
11
  from fameio.tools import keys_to_lower
11
12
  from .attribute import Attribute
12
- from .exception import assert_or_raise, get_or_default, get_or_raise
13
+ from .exception import assert_or_raise, get_or_raise
13
14
 
14
15
 
15
16
  class Agent(Metadata):
@@ -18,37 +19,70 @@ class Agent(Metadata):
18
19
  KEY_TYPE: Final[str] = "Type".lower()
19
20
  KEY_ID: Final[str] = "Id".lower()
20
21
  KEY_ATTRIBUTES: Final[str] = "Attributes".lower()
22
+ KEY_EXT: Final[str] = "Ext".lower()
23
+ RESERVED_KEYS: set[str] = {KEY_TYPE, KEY_ID, KEY_ATTRIBUTES, KEY_EXT, Metadata.KEY_METADATA}
21
24
 
22
- _ERR_MISSING_KEY = "Agent requires `key` '{}' but is missing it."
23
- _ERR_MISSING_TYPE = "Agent requires `type` but is missing it."
24
- _ERR_MISSING_ID = "Agent requires a positive integer `id` but was '{}'."
25
+ _ERR_MISSING_KEY = "Agent definition requires key '{}' but is missing it."
26
+ _ERR_TYPE_EMPTY = "Agent `type` must not be empty."
27
+ _ERR_ILLEGAL_ID = "Agent requires a positive integer `id` but was '{}'."
25
28
  _ERR_DOUBLE_ATTRIBUTE = "Cannot add attribute '{}' to agent {} because it already exists."
26
29
  _ERR_ATTRIBUTE_OVERWRITE = "Agent's attributes are already set and would be overwritten."
30
+ _WARN_UNEXPECTED_KEY = "Ignoring unexpected key(s) {} in top level of agent with id: {}"
27
31
 
28
- def __init__(self, agent_id: int, type_name: str, metadata: dict = None) -> None:
32
+ def __init__(self, agent_id: int, type_name: str, metadata: dict | None = None) -> None:
29
33
  """Constructs a new Agent"""
30
34
  super().__init__({Agent.KEY_METADATA: metadata} if metadata else None)
31
- assert_or_raise(type(agent_id) is int and agent_id >= 0, self._ERR_MISSING_ID.format(agent_id))
32
- assert_or_raise(bool(type_name and type_name.strip()), self._ERR_MISSING_TYPE)
35
+ assert_or_raise(isinstance(agent_id, int) and agent_id >= 0, self._ERR_ILLEGAL_ID.format(agent_id))
36
+ assert_or_raise(bool(type_name and type_name.strip()), self._ERR_TYPE_EMPTY)
33
37
  self._id: int = agent_id
34
38
  self._type_name: str = type_name.strip()
35
39
  self._attributes: dict[str, Attribute] = {}
36
40
 
37
41
  @classmethod
38
42
  def from_dict(cls, definitions: dict) -> Agent:
39
- """Parses an agent from provided `definitions`"""
43
+ """
44
+ Args:
45
+ definitions: dictionary representation of an agent
46
+
47
+ Returns:
48
+ new agent
49
+
50
+ Raises:
51
+ ScenarioError: if definitions are incomplete or erroneous, logged on level "ERROR"
52
+ """
40
53
  definitions = keys_to_lower(definitions)
41
- agent_type = get_or_raise(definitions, Agent.KEY_TYPE, Agent._ERR_MISSING_TYPE)
42
- agent_id = get_or_raise(definitions, Agent.KEY_ID, Agent._ERR_MISSING_ID)
43
- agent = cls(agent_id, agent_type)
44
- agent._extract_metadata(definitions)
45
- attribute_definitions = get_or_default(definitions, Agent.KEY_ATTRIBUTES, {})
46
- agent.init_attributes_from_dict(attribute_definitions)
47
- agent._meta_data = get_or_default(definitions, Agent.KEY_METADATA, {})
54
+ agent_type = get_or_raise(definitions, Agent.KEY_TYPE, Agent._ERR_MISSING_KEY)
55
+ agent_id = get_or_raise(definitions, Agent.KEY_ID, Agent._ERR_MISSING_KEY)
56
+ Agent.validate_keys(definitions, agent_id)
57
+ metadata = definitions.get(Agent.KEY_METADATA, None)
58
+ agent = cls(agent_id, agent_type, metadata)
59
+ agent.init_attributes_from_dict(definitions.get(Agent.KEY_ATTRIBUTES, {}))
48
60
  return agent
49
61
 
62
+ @staticmethod
63
+ def validate_keys(data: dict, agent_id: int) -> None:
64
+ """
65
+ Logs a warning if any unexpected keys are presented at top level of `data`
66
+ Expected keys are defined in `Agent.RESERVED_KEYS`
67
+
68
+ Args:
69
+ data: agent definition to be checked
70
+ agent_id: id of agent to be checked
71
+ """
72
+ unexpected_keys = set(data.keys()) - Agent.RESERVED_KEYS
73
+ if unexpected_keys:
74
+ log().warning(Agent._WARN_UNEXPECTED_KEY.format(unexpected_keys, agent_id))
75
+
50
76
  def init_attributes_from_dict(self, attributes: dict[str, Any]) -> None:
51
- """Initialize Agent `attributes` from dict; Must only be called when creating a new Agent"""
77
+ """
78
+ Initialise agent `attributes` from dict; Must only be called when creating a new Agent
79
+
80
+ Args:
81
+ attributes: to be set
82
+
83
+ Raises:
84
+ ScenarioError: if attributes were already initialised
85
+ """
52
86
  assert_or_raise(not self._attributes, self._ERR_ATTRIBUTE_OVERWRITE)
53
87
  self._attributes = {}
54
88
  for name, value in attributes.items():
@@ -79,7 +113,6 @@ class Agent(Metadata):
79
113
 
80
114
  def _notify_data_changed(self):
81
115
  """Placeholder method used to signal data changes to derived types"""
82
- pass
83
116
 
84
117
  @property
85
118
  def id(self) -> int:
@@ -89,7 +122,7 @@ class Agent(Metadata):
89
122
  @property
90
123
  def display_id(self) -> str:
91
124
  """Returns the ID of the Agent as a string for display purposes"""
92
- return "#{}".format(self._id)
125
+ return f"#{self._id}"
93
126
 
94
127
  @property
95
128
  def type_name(self) -> str:
@@ -1,4 +1,4 @@
1
- # SPDX-FileCopyrightText: 2024 German Aerospace Center <fame@dlr.de>
1
+ # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
  from __future__ import annotations
@@ -9,7 +9,7 @@ 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
13
 
14
14
 
15
15
  class Attribute(Metadata):
@@ -49,7 +49,7 @@ class Attribute(Metadata):
49
49
  """Parses an Attribute's definition"""
50
50
  self._full_name = name
51
51
  if definitions is None:
52
- log_and_raise(Attribute._ERR_VALUE_MISSING.format(name))
52
+ raise log_scenario_error(Attribute._ERR_VALUE_MISSING.format(name))
53
53
  super().__init__(definitions)
54
54
  data_type = Attribute._get_data_type(name, definitions)
55
55
 
@@ -59,16 +59,15 @@ class Attribute(Metadata):
59
59
  self._nested_list: list[Attribute.__NestedMeta] | None = None
60
60
 
61
61
  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
62
+ self._value = self._extract_value(definitions).value # type: ignore[arg-type]
64
63
  elif data_type is Attribute.__DefinitionType.VALUE_LIST:
65
- self._value_list = self._extract_values(definitions)
64
+ self._value_list = self._extract_values(definitions) # type: ignore[arg-type]
66
65
  elif data_type is Attribute.__DefinitionType.NESTED:
67
- self._nested = Attribute._build_attribute_dict(name, definitions)
66
+ self._nested = Attribute._build_attribute_dict(name, definitions) # type: ignore[arg-type]
68
67
  elif data_type is Attribute.__DefinitionType.NESTED_LIST:
69
68
  self._nested_list = []
70
69
  values = keys_to_lower(definitions)[Attribute.KEY_VALUES] if isinstance(definitions, dict) else definitions
71
- for list_index, definition in enumerate(values):
70
+ for list_index, definition in enumerate(values): # type: ignore[arg-type]
72
71
  list_meta = MetadataComponent(definition)
73
72
  list_extended_name = name + Attribute.NAME_STRING_SEPARATOR + str(list_index)
74
73
  nested_items = Attribute._build_attribute_dict(list_extended_name, definition)
@@ -79,28 +78,27 @@ class Attribute(Metadata):
79
78
  """Returns type of data derived from given `definitions`"""
80
79
  if isinstance(definitions, list):
81
80
  if len(definitions) == 0:
82
- log_and_raise(Attribute._ERR_LIST_EMPTY.format(name))
81
+ raise log_scenario_error(Attribute._ERR_LIST_EMPTY.format(name))
83
82
  return Attribute._get_data_type_list(definitions)
84
- elif isinstance(definitions, dict):
83
+ if isinstance(definitions, dict):
85
84
  if len(definitions) == 0:
86
- log_and_raise(Attribute._ERR_DICT_EMPTY.format(name))
85
+ raise log_scenario_error(Attribute._ERR_DICT_EMPTY.format(name))
87
86
  return Attribute._get_data_type_dict(definitions)
88
- else:
89
- return Attribute.__DefinitionType.VALUE
87
+ return Attribute.__DefinitionType.VALUE
90
88
 
91
89
  @staticmethod
92
90
  def _get_data_type_list(definitions: list[Any]) -> Attribute.__DefinitionType:
93
91
  """Returns type of data from a given non-empty list `definitions`"""
94
- if all([Attribute._is_value_definition(entry) for entry in definitions]):
92
+ if all(Attribute._is_value_definition(entry) for entry in definitions):
95
93
  return Attribute.__DefinitionType.VALUE_LIST
96
- elif Attribute._is_list_of_dict(definitions):
94
+ if Attribute._is_list_of_dict(definitions):
97
95
  return Attribute.__DefinitionType.NESTED_LIST
98
- log_and_raise(Attribute._ERR_MIXED_DATA.format(repr(definitions)))
96
+ raise log_scenario_error(Attribute._ERR_MIXED_DATA.format(repr(definitions)))
99
97
 
100
98
  @staticmethod
101
99
  def _is_list_of_dict(definitions: list) -> bool:
102
100
  """Returns True if given `definitions` is a list of (only) dict"""
103
- return all([isinstance(entry, dict) for entry in definitions])
101
+ return all(isinstance(entry, dict) for entry in definitions)
104
102
 
105
103
  @staticmethod
106
104
  def _get_data_type_dict(definitions: dict[str, Any]) -> Attribute.__DefinitionType:
@@ -108,13 +106,13 @@ class Attribute(Metadata):
108
106
  low_keys = keys_to_lower(definitions)
109
107
  if Attribute.KEY_VALUE in low_keys.keys():
110
108
  return Attribute.__DefinitionType.VALUE
111
- elif Attribute.KEY_VALUES in low_keys.keys():
109
+ if Attribute.KEY_VALUES in low_keys.keys():
112
110
  values = low_keys[Attribute.KEY_VALUES]
113
- if all([Attribute._is_value_definition(entry) for entry in values]):
111
+ if all(Attribute._is_value_definition(entry) for entry in values):
114
112
  return Attribute.__DefinitionType.VALUE_LIST
115
- elif Attribute._is_list_of_dict(values):
113
+ if Attribute._is_list_of_dict(values):
116
114
  return Attribute.__DefinitionType.NESTED_LIST
117
- log_and_raise(Attribute._ERR_MIXED_DATA.format(repr(values)))
115
+ raise log_scenario_error(Attribute._ERR_MIXED_DATA.format(repr(values)))
118
116
  return Attribute.__DefinitionType.NESTED
119
117
 
120
118
  @staticmethod
@@ -158,7 +156,7 @@ class Attribute(Metadata):
158
156
  """Returns value or list of values if available on this Attribute (ignoring any Metadata), else None"""
159
157
  if self._value is not None:
160
158
  return self._value
161
- elif self._value_list is not None:
159
+ if self._value_list is not None:
162
160
  return [item.value for item in self._value_list]
163
161
  return None
164
162
 
@@ -169,8 +167,8 @@ class Attribute(Metadata):
169
167
 
170
168
  @property
171
169
  def nested(self) -> dict[str, Attribute]:
172
- """Returns dictionary of all nested Attributes if nested Attributes are present, else None"""
173
- return self._nested if self.has_nested else None
170
+ """Returns dictionary of all nested Attributes if nested Attributes are present, else empty dict"""
171
+ return self._nested if self._nested is not None else {}
174
172
 
175
173
  @property
176
174
  def has_nested_list(self) -> bool:
@@ -179,8 +177,8 @@ class Attribute(Metadata):
179
177
 
180
178
  @property
181
179
  def nested_list(self) -> list[dict[str, Attribute]]:
182
- """Return list of all nested Attribute dictionaries if such are present, else None"""
183
- return [entry.value for entry in self._nested_list] if self.has_nested_list else None
180
+ """Return list of all nested Attribute dictionaries if such are present, else an empty list"""
181
+ return [entry.value for entry in self._nested_list] if self._nested_list is not None else []
184
182
 
185
183
  def __repr__(self) -> str:
186
184
  return self._full_name
@@ -188,16 +186,17 @@ class Attribute(Metadata):
188
186
  def _to_dict(self) -> dict[str, Any]:
189
187
  if self._value is not None:
190
188
  return {self.KEY_VALUE: self._value}
191
- elif self._value_list is not None:
189
+ if self._value_list is not None:
192
190
  return {
193
191
  self.KEY_VALUES: [{self.KEY_VALUE: entry.value, **entry.meta.to_dict()} for entry in self._value_list]
194
192
  }
195
- elif self._nested is not None:
193
+ if self._nested is not None:
196
194
  return {name: attribute.to_dict() for name, attribute in self.nested.items()}
197
- elif self._nested_list is not None:
195
+ if self._nested_list is not None:
198
196
  return {
199
197
  self.KEY_VALUES: [
200
198
  {**{name: attribute.to_dict() for name, attribute in entry.value.items()}, **entry.meta.to_dict()}
201
199
  for entry in self._nested_list
202
200
  ]
203
201
  }
202
+ return {}
@@ -1,21 +1,24 @@
1
- # SPDX-FileCopyrightText: 2023 German Aerospace Center <fame@dlr.de>
1
+ # SPDX-FileCopyrightText: 2025 German Aerospace Center <fame@dlr.de>
2
2
  #
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
17
  """Contract between two Agents of a scenario"""
18
18
 
19
+ class ContractError(InputError):
20
+ """An error that occurred while parsing Contract definitions"""
21
+
19
22
  KEY_SENDER: Final[str] = "SenderId".lower()
20
23
  KEY_RECEIVER: Final[str] = "ReceiverId".lower()
21
24
  KEY_PRODUCT: Final[str] = "ProductName".lower()
@@ -26,14 +29,17 @@ 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
 
42
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
37
43
  def __init__(
38
44
  self,
39
45
  sender_id: int,
@@ -41,27 +47,44 @@ class Contract(Metadata):
41
47
  product_name: str,
42
48
  delivery_interval: int,
43
49
  first_delivery_time: int,
44
- expiration_time: Optional[int] = None,
45
- metadata: Optional[dict] = None,
50
+ expiration_time: int | None = None,
51
+ metadata: dict | None = None,
46
52
  ) -> None:
47
- """Constructs a new Contract"""
53
+ """
54
+ Constructs a new Contract
55
+
56
+ Args:
57
+ sender_id: unique id of sender
58
+ receiver_id: unique id of receiver
59
+ product_name: name of contracted product
60
+ delivery_interval: time interval in steps between contract deliveries
61
+ first_delivery_time: absolute time of first contract execution
62
+ expiration_time: absolute time at which contract execution stops
63
+ metadata: any metadata associated with the contract
64
+
65
+ Returns:
66
+ new Contract
67
+
68
+ Raises:
69
+ ContractError: if delivery interval is invalid, logged on level "ERROR"
70
+ """
48
71
  super().__init__({self.KEY_METADATA: metadata} if metadata else None)
49
- assert product_name != ""
72
+ if product_name.strip() == "":
73
+ raise log_error(self.ContractError(self._ERR_PRODUCT_EMPTY))
50
74
  if sender_id == receiver_id:
51
75
  log().warning(self._ERR_SENDER_IS_RECEIVER.format(sender_id))
52
76
  if delivery_interval <= 0:
53
- raise ValueError(self._ERR_INTERVAL_NOT_POSITIVE.format(delivery_interval))
77
+ raise log_error(self.ContractError(self._ERR_INTERVAL_NOT_POSITIVE.format(delivery_interval)))
54
78
  self._sender_id = sender_id
55
79
  self._receiver_id = receiver_id
56
80
  self._product_name = product_name
57
81
  self._delivery_interval = delivery_interval
58
82
  self._first_delivery_time = first_delivery_time
59
83
  self._expiration_time = expiration_time
60
- self._attributes = {}
84
+ self._attributes: dict = {}
61
85
 
62
86
  def _notify_data_changed(self):
63
87
  """Placeholder method used to signal data changes to derived types"""
64
- pass
65
88
 
66
89
  @property
67
90
  def product_name(self) -> str:
@@ -76,7 +99,7 @@ class Contract(Metadata):
76
99
  @property
77
100
  def display_sender_id(self) -> str:
78
101
  """Returns the sender ID of the contract as a string for display purposes"""
79
- return "#{}".format(self._sender_id)
102
+ return f"#{self._sender_id}"
80
103
 
81
104
  @property
82
105
  def receiver_id(self) -> int:
@@ -86,7 +109,7 @@ class Contract(Metadata):
86
109
  @property
87
110
  def display_receiver_id(self) -> str:
88
111
  """Returns the receiver ID of the contract as a string for display purposes"""
89
- return "#{}".format(self._receiver_id)
112
+ return f"#{self._receiver_id}"
90
113
 
91
114
  @property
92
115
  def delivery_interval(self) -> int:
@@ -99,7 +122,7 @@ class Contract(Metadata):
99
122
  return self._first_delivery_time
100
123
 
101
124
  @property
102
- def expiration_time(self) -> Optional[int]:
125
+ def expiration_time(self) -> int | None:
103
126
  """Returns the expiration time of the contract if available, None otherwise"""
104
127
  return self._expiration_time
105
128
 
@@ -109,43 +132,113 @@ class Contract(Metadata):
109
132
  return self._attributes
110
133
 
111
134
  def add_attribute(self, name: str, value: Attribute) -> None:
112
- """Adds a new attribute to the Contract (raise an error if it already exists)"""
135
+ """
136
+ Adds a new attribute to the Contract
137
+
138
+ Args:
139
+ name: of the attribute
140
+ value: of the attribute
141
+
142
+ Raises:
143
+ ContractError: if attribute already exists, logged on level "ERROR"
144
+ """
113
145
  if name in self._attributes:
114
- raise ValueError(self._ERR_DOUBLE_ATTRIBUTE.format(name))
146
+ raise log_error(self.ContractError(self._ERR_DOUBLE_ATTRIBUTE.format(name)))
115
147
  self._attributes[name] = value
116
148
  self._notify_data_changed()
117
149
 
118
150
  @classmethod
119
151
  def from_dict(cls, definitions: dict) -> Contract:
120
- """Parses Contract from given `definitions`"""
152
+ """
153
+ Parses contract from given `definitions`
154
+
155
+ Args:
156
+ definitions: dictionary representation of a contract
157
+
158
+ Returns:
159
+ new contract
160
+
161
+ Raises:
162
+ ContractError: if definitions are incomplete or erroneous, logged on level "ERROR"
163
+ """
121
164
  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
- )
165
+ sender_id = Contract._get_or_raise(definitions, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY)
166
+ receiver_id = Contract._get_or_raise(definitions, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY)
167
+ product_name = Contract._get_or_raise(definitions, Contract.KEY_PRODUCT, Contract._ERR_MISSING_KEY)
168
+
169
+ first_delivery_time = Contract._get_time(definitions, Contract.KEY_FIRST_DELIVERY)
170
+ delivery_interval = Contract._get_or_raise(definitions, Contract.KEY_INTERVAL, Contract._ERR_MISSING_KEY)
171
+ expiration_time = Contract._get_time(definitions, Contract.KEY_EXPIRE, mandatory=False)
172
+
173
+ contract = cls(sender_id, receiver_id, product_name, delivery_interval, first_delivery_time, expiration_time)
140
174
  contract._extract_metadata(definitions)
141
- attribute_definitions = get_or_default(definitions, Contract.KEY_ATTRIBUTES, {})
142
- contract._init_attributes_from_dict(attribute_definitions)
175
+ contract._init_attributes_from_dict(definitions.get(Contract.KEY_ATTRIBUTES, {}))
143
176
  return contract
144
177
 
178
+ @staticmethod
179
+ def _get_or_raise(dictionary: dict, key: str, error_message: str) -> Any:
180
+ """
181
+ Returns value associated with `key` in given `dictionary`, or raises exception if key or value is missing
182
+
183
+ Args:
184
+ dictionary: to search the key in
185
+ key: to be searched
186
+ error_message: to be logged and included in the raised exception if key is missing
187
+
188
+ Returns:
189
+ value associated with given key in given dictionary
190
+
191
+ Raises:
192
+ ContractError: if given key is not in given dictionary or value is None, logged on level "ERROR"
193
+ """
194
+ if key not in dictionary or dictionary[key] is None:
195
+ raise log_error(Contract.ContractError(error_message.format(key)))
196
+ return dictionary[key]
197
+
198
+ @staticmethod
199
+ @overload
200
+ def _get_time(definitions: dict, key: str) -> int: ... # noqa: E704
201
+
202
+ @staticmethod
203
+ @overload
204
+ def _get_time(definitions: dict, key: str, mandatory: bool) -> int | None: ... # noqa: E704
205
+
206
+ @staticmethod
207
+ def _get_time(definitions: dict, key: str, mandatory: bool = True) -> int | None:
208
+ """
209
+ Extract time representation value at given key, and, if present, convert to integer, else return None
210
+
211
+ Args:
212
+ definitions: to search given key in
213
+ key: to check for an associated value
214
+ mandatory: if true, also raises an error if key is missing
215
+
216
+ Returns:
217
+ None if key is not mandatory/present, else the integer representation of the time value associated with key
218
+
219
+ Raises:
220
+ ContractError: if found value could not be converted or mandatory value is missing, logged on level "ERROR"
221
+ """
222
+ if key in definitions:
223
+ value = definitions[key]
224
+ try:
225
+ return FameTime.convert_string_if_is_datetime(value)
226
+ except ConversionError as e:
227
+ raise log_error(Contract.ContractError(Contract._ERR_TIME_CONVERSION.format(key, value))) from e
228
+ if mandatory:
229
+ raise log_error(Contract.ContractError(Contract._ERR_MISSING_KEY.format(key)))
230
+ return None
231
+
145
232
  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 = {}
233
+ """
234
+ Resets Contract `attributes` from dict
235
+
236
+ Args:
237
+ attributes: key-value pairs of attributes to be set
238
+
239
+ Raises:
240
+ ContractError: if some of provided attributes were already present
241
+ """
149
242
  for name, value in attributes.items():
150
243
  full_name = f"{type}.{id}{name}"
151
244
  self.add_attribute(name, Attribute(full_name, value))
@@ -169,7 +262,19 @@ class Contract(Metadata):
169
262
 
170
263
  @staticmethod
171
264
  def split_contract_definitions(multi_definition: dict) -> list[dict]:
172
- """Splits given `multi_definition` dictionary into list of individual Contract definitions"""
265
+ """
266
+ Splits given dictionary of contracts with potentially more than ore sender and/or receiver into a list
267
+ of individual contract definitions with one sender and one receiver
268
+
269
+ Args:
270
+ multi_definition: contract definitions with potentially more than ore sender and/or receiver
271
+
272
+ Returns:
273
+ list of contract definitions with exactly one sender and receiver
274
+
275
+ Raises:
276
+ ContractError: if multi_definition is incomplete or erroneous, logged on level "ERROR"
277
+ """
173
278
  contracts = []
174
279
  base_data = {}
175
280
  multi_definition = keys_to_lower(multi_definition)
@@ -183,19 +288,23 @@ class Contract(Metadata):
183
288
  ]:
184
289
  if key in multi_definition:
185
290
  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))
291
+ senders = ensure_is_list(
292
+ Contract._get_or_raise(multi_definition, Contract.KEY_SENDER, Contract._ERR_MISSING_KEY)
293
+ )
294
+ receivers = ensure_is_list(
295
+ Contract._get_or_raise(multi_definition, Contract.KEY_RECEIVER, Contract._ERR_MISSING_KEY)
296
+ )
188
297
  if len(senders) > 1 and len(receivers) == 1:
189
- for index in range(len(senders)):
190
- contracts.append(Contract._copy_contract(senders[index], receivers[0], base_data))
298
+ for index, sender in enumerate(senders):
299
+ contracts.append(Contract._copy_contract(sender, receivers[0], base_data))
191
300
  elif len(senders) == 1 and len(receivers) > 1:
192
- for index in range(len(receivers)):
193
- contracts.append(Contract._copy_contract(senders[0], receivers[index], base_data))
301
+ for index, receiver in enumerate(receivers):
302
+ contracts.append(Contract._copy_contract(senders[0], receiver, base_data))
194
303
  elif len(senders) == len(receivers):
195
- for index in range(len(senders)):
304
+ for index in range(len(senders)): # pylint: disable=consider-using-enumerate
196
305
  contracts.append(Contract._copy_contract(senders[index], receivers[index], base_data))
197
306
  else:
198
- log_and_raise(Contract._ERR_MULTI_CONTRACT_CORRUPT.format(senders, receivers))
307
+ raise log_error(Contract.ContractError(Contract._ERR_MULTI_CONTRACT_CORRUPT.format(senders, receivers)))
199
308
  return contracts
200
309
 
201
310
  @staticmethod