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
@@ -1,6 +1,8 @@
1
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
  from fnmatch import fnmatch
5
7
  from pathlib import Path
6
8
  from typing import Callable, IO, Any, Final
@@ -8,20 +10,22 @@ from typing import Callable, IO, Any, Final
8
10
  import yaml
9
11
 
10
12
  from fameio.input import YamlLoaderError
11
- from fameio.input.resolver import PathResolver
12
13
  from fameio.input.loader.loader import FameYamlLoader
14
+ from fameio.input.resolver import PathResolver
13
15
  from fameio.logs import log, log_critical
14
16
 
15
17
 
16
18
  class LoaderController:
17
- """
18
- Controls loading of YAML files by spawning one FameYamlLoader per file.
19
- Uses same PathResolver and encoding for all files
19
+ """Controls loading of YAML files by spawning one FameYamlLoader per file.
20
+
21
+ Uses same PathResolver and encoding for all files.
20
22
  """
21
23
 
22
24
  DISABLING_YAML_FILE_PREFIX: Final[str] = "IGNORE_"
23
25
  NODE_SPLIT_STRING: Final[str] = ":"
24
26
 
27
+ _ERR_FILE_OPEN_ERROR = "Could not open file: '{}'"
28
+ _ERR_FILE_LOAD_ERROR = "Could not parse YAML file due to errors in (line:column): ({}:{})"
25
29
  _ERR_NODE_MISSING = "'!include_node [{}, {}]': Cannot find '{}'"
26
30
  _ERR_NOT_LIST = "!include can only combine list-like elements from multiple files!"
27
31
  _WARN_NOTHING_TO_INCLUDE = "Could not find any files matching this '!include' directive '{}'"
@@ -31,23 +35,67 @@ class LoaderController:
31
35
  _DEBUG_LOAD_FILE = "Loaded included YAML file '{}'"
32
36
  _DEBUG_FILES_INCLUDED = "!include directive '{}' yielded these files: '{}'"
33
37
 
34
- def __init__(self, path_resolver: PathResolver = PathResolver(), encoding: str = None) -> None:
38
+ def __init__(self, path_resolver: PathResolver = PathResolver(), encoding: str | None = None) -> None:
35
39
  self._path_resolver = path_resolver
36
- self._encoding: str = encoding
40
+ self._encoding: str | None = encoding
37
41
 
38
42
  def load(self, yaml_file_path: Path) -> dict:
39
- """Spawns a new FameYamlLoader, loads the given `yaml_file_path` and returns its content"""
40
- with open(yaml_file_path, "r", encoding=self._encoding) as configfile:
41
- data = yaml.load(configfile, self._spawn_loader_builder())
43
+ """Spawns a new FameYamlLoader, loads the given `yaml_file_path` and returns its content.
44
+
45
+ Args:
46
+ yaml_file_path: path to YAML file that is to be loaded
47
+
48
+ Returns:
49
+ dictionary representation of loaded file
50
+
51
+ Raises:
52
+ YamlLoaderError: if file could not be read, logged with level "CRITICAL"
53
+ """
54
+ try:
55
+ with open(yaml_file_path, "r", encoding=self._encoding) as configfile:
56
+ try:
57
+ data = yaml.load(configfile, self._spawn_loader_builder()) # type: ignore[arg-type]
58
+ except yaml.YAMLError as e:
59
+ line, column = self._get_problem_position(e)
60
+ raise log_critical(YamlLoaderError(self._ERR_FILE_LOAD_ERROR.format(line, column))) from e
61
+ except OSError as e:
62
+ raise log_critical(YamlLoaderError(self._ERR_FILE_OPEN_ERROR.format(yaml_file_path))) from e
42
63
  return data
43
64
 
44
65
  @staticmethod
45
66
  def _spawn_loader_builder() -> Callable[[IO], FameYamlLoader]:
46
- """Returns a new Callable that instantiates a new FameYamlLoader with an IO-stream"""
67
+ """Returns a new Callable that instantiates a new FameYamlLoader with an IO-stream."""
47
68
  return lambda stream: FameYamlLoader(stream) # pylint: disable=unnecessary-lambda
48
69
 
70
+ @staticmethod
71
+ def _get_problem_position(exception: yaml.YAMLError) -> tuple[str, str]:
72
+ """Returns problematic line and column from given error (if available).
73
+
74
+ Args:
75
+ exception: error thrown by yaml.load()
76
+
77
+ Returns:
78
+ Line and Column of error (if available), else a tuple of questions marks
79
+ """
80
+ if hasattr(exception, "problem_mark"):
81
+ mark = exception.problem_mark
82
+ return str(mark.line + 1), str(mark.column + 1)
83
+ return "?", "?"
84
+
49
85
  def include(self, loader: FameYamlLoader, include_args: yaml.Node) -> Any:
50
- """Returns content loaded from the specified `include_args`"""
86
+ """Returns content loaded from the specified `include_args`.
87
+
88
+ Args:
89
+ loader: the YAML loader to be used to load the file(s) that are to be included
90
+ include_args: arguments of include statement
91
+
92
+ Returns:
93
+ content of file as specified by include
94
+
95
+ Raises:
96
+ YamlLoaderError: If !include statement could not be interpreted, included files could not be read,
97
+ or multiple included files could not be joined - logged with level "CRITICAL"
98
+ """
51
99
  root_path, file_pattern, node_pattern = loader.digest_include(include_args)
52
100
  files = self._resolve_imported_path(root_path, file_pattern)
53
101
  nodes = node_pattern.split(self.NODE_SPLIT_STRING)
@@ -62,8 +110,8 @@ class LoaderController:
62
110
  return joined_data
63
111
 
64
112
  def _resolve_imported_path(self, root_path: str, include_pattern: str) -> list[str]:
65
- """
66
- Returns a list of file paths matching the given `include_pattern` relative to the `root_path`.
113
+ """Returns a list of file paths matching the given `include_pattern` relative to the `root_path`.
114
+
67
115
  Ignores files starting with the `DISABLING_YAML_FILE_PREFIX`
68
116
  """
69
117
  file_list = self._path_resolver.resolve_file_pattern(root_path, include_pattern)
@@ -82,8 +130,7 @@ class LoaderController:
82
130
 
83
131
  @staticmethod
84
132
  def _extract_node(file_name: str, data: dict, node_address: list[str]) -> Any:
85
- """
86
- Returns only the part of the data that is at the specified node address
133
+ """Returns only the part of the data that is at the specified node address.
87
134
 
88
135
  Args:
89
136
  file_name: name of the file from which the data were read - used to enrich logging messages
@@ -106,9 +153,8 @@ class LoaderController:
106
153
  return data
107
154
 
108
155
  @staticmethod
109
- def _join_data(new_data: list, previous_data: list) -> list:
110
- """
111
- Joins two lists with data to a larger list
156
+ def _join_data(new_data: list, previous_data: list | None) -> list:
157
+ """Joins two lists with data to a larger list.
112
158
 
113
159
  Args:
114
160
  new_data: list of any data
@@ -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 os import path
@@ -7,11 +7,11 @@ from typing import IO, Final
7
7
  import yaml
8
8
 
9
9
  from fameio.input import YamlLoaderError
10
- from fameio.logs import log_critical_and_raise, log
10
+ from fameio.logs import log, log_critical
11
11
 
12
12
 
13
13
  class FameYamlLoader(yaml.SafeLoader):
14
- """Custom YAML Loader for `!include` constructor"""
14
+ """Custom YAML Loader for `!include` constructor."""
15
15
 
16
16
  INCLUDE_COMMAND: Final[str] = "!include"
17
17
 
@@ -29,8 +29,7 @@ class FameYamlLoader(yaml.SafeLoader):
29
29
  super().__init__(stream)
30
30
 
31
31
  def digest_include(self, node: yaml.Node) -> tuple[str, str, str]:
32
- """
33
- Reads arguments in an !include statement and returns information which files to include
32
+ """Reads arguments in an !include statement and returns information which files to include.
34
33
 
35
34
  Args:
36
35
  node: the current node that is to be deconstructed; could be a file-pattern to load;
@@ -43,9 +42,10 @@ class FameYamlLoader(yaml.SafeLoader):
43
42
  `root` is a path to the current file that was read by this FameYamlLoader,
44
43
  `files` is a file pattern,
45
44
  and nodes is an optional address (list of nodes) for name for the node that is to be returned
45
+
46
+ Raises:
47
+ YamlLoaderError: If !include statement could not be interpreted, logged with level "CRITICAL"
46
48
  """
47
- node_string = ""
48
- file_pattern = None
49
49
  if isinstance(node, yaml.nodes.ScalarNode):
50
50
  file_pattern, node_string = self._read_scalar_node(node)
51
51
  elif isinstance(node, yaml.nodes.SequenceNode):
@@ -53,12 +53,13 @@ class FameYamlLoader(yaml.SafeLoader):
53
53
  elif isinstance(node, yaml.nodes.MappingNode):
54
54
  file_pattern, node_string = self._read_mapping_node(node)
55
55
  else:
56
- log_critical_and_raise(YamlLoaderError(self._ERR_NODE_TYPE.format(node)))
56
+ raise log_critical(YamlLoaderError(self._ERR_NODE_TYPE.format(node)))
57
57
  return self._root_path, file_pattern, node_string
58
58
 
59
59
  def _read_scalar_node(self, args: yaml.nodes.ScalarNode) -> tuple[str, str]:
60
- """
61
- Reads and returns content of a scalar !include statement; Example: !include "file"
60
+ """Reads and returns content of a scalar !include statement.
61
+
62
+ Example: !include "file".
62
63
 
63
64
  Args:
64
65
  args: argument assigned to the !include statement
@@ -71,18 +72,22 @@ class FameYamlLoader(yaml.SafeLoader):
71
72
  return str(file_pattern), ""
72
73
 
73
74
  def _read_sequence_node(self, args: yaml.nodes.SequenceNode) -> tuple[str, str]:
74
- """
75
- Reads and returns content of a sequence !include statement; Example: !include ["file", Path:to:Node]
75
+ """Reads and returns content of a sequence !include statement.
76
+
77
+ Example: !include ["file", Path:to:Node].
76
78
 
77
79
  Args:
78
80
  args: argument assigned to the !include statement
79
81
 
80
82
  Returns:
81
83
  first part of argument as file path, the second part of argument as node-address
84
+
85
+ Raises:
86
+ YamlLoaderError: if argument count is not 1 or 2, logged with level "CRITICAL"
82
87
  """
83
88
  argument_list = self.construct_sequence(args)
84
89
  if len(argument_list) not in [1, 2]:
85
- log_critical_and_raise(YamlLoaderError(self._ERR_ARGUMENT_COUNT.format(str(args))))
90
+ raise log_critical(YamlLoaderError(self._ERR_ARGUMENT_COUNT.format(str(args))))
86
91
 
87
92
  file_pattern = argument_list[0]
88
93
  node_string = argument_list[1] if len(argument_list) == 2 else ""
@@ -90,18 +95,22 @@ class FameYamlLoader(yaml.SafeLoader):
90
95
  return file_pattern, node_string
91
96
 
92
97
  def _read_mapping_node(self, args: yaml.nodes.MappingNode) -> tuple[str, str]:
93
- """
94
- Reads and returns content of a mapping !include statement; Example: !include {file="file", node="Path:to:Node"}
98
+ """Reads and returns content of a mapping !include statement.
99
+
100
+ Example: !include {file="file", node="Path:to:Node"}
95
101
 
96
102
  Args:
97
103
  args: argument assigned to the !include statement
98
104
 
99
105
  Returns:
100
106
  file argument as file path, node argument as node-address
107
+
108
+ Raises:
109
+ YamlLoaderError: if "file" key is missing, logged with level "CRITICAL"
101
110
  """
102
111
  argument_map = {str(k).lower(): v for k, v in self.construct_mapping(args).items()}
103
112
  if "file" not in argument_map.keys():
104
- log_critical_and_raise(YamlLoaderError(self._ERR_FILE_KEY_MISSING.format(str(args))))
113
+ raise log_critical(YamlLoaderError(self._ERR_FILE_KEY_MISSING.format(str(args))))
105
114
 
106
115
  file_pattern = argument_map["file"]
107
116
  node_string = argument_map.get("node", "")
fameio/input/metadata.py CHANGED
@@ -1,30 +1,35 @@
1
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
  from abc import ABC, abstractmethod
5
- from typing import Any, Optional, final, Union, Final
7
+ from typing import Any, final, Final
6
8
 
7
9
  from fameio.input import InputError
10
+ from fameio.logs import log_error
8
11
 
9
12
 
10
13
  class Metadata(ABC):
11
- """Hosts metadata of any kind - extend this class to optionally add metadata capability to the extending class"""
14
+ """Hosts metadata of any kind - extend this class to optionally add metadata capability to the extending class."""
12
15
 
13
16
  KEY_METADATA: Final[str] = "Metadata".lower()
14
17
 
15
- def __init__(self, definitions: Optional[Union[Any, dict[str, Any]]] = None):
16
- """
17
- Initialises the metadata by searching the given definitions' top level for metadata.
18
+ def __init__(self, definitions: Any | dict[str, Any] | None = None):
19
+ """Initialises the metadata.
20
+
21
+ Search the given definitions' top level for metadata.
18
22
  Alternatively, call `_extract_metadata()` to add metadata later on.
19
23
  If metadata are found on the definitions, they get removed.
20
24
  """
21
25
  self._metadata = self.__extract_metadata(definitions)
22
26
 
23
27
  @staticmethod
24
- def __extract_metadata(definitions: Optional[dict[str, Any]]) -> dict:
25
- """
28
+ def __extract_metadata(definitions: dict[str, Any] | None) -> dict:
29
+ """Extract metadata from `definitions` - if present.
30
+
26
31
  If keyword `metadata` is found on the highest level of given `definitions`, metadata are extracted (removed) and
27
- returned, otherwise, an empty dict is returned and definitions are not changed
32
+ returned, otherwise, an empty dict is returned and definitions are not changed.
28
33
  """
29
34
  if definitions and isinstance(definitions, dict):
30
35
  matching_key = [key for key in definitions.keys() if key.lower() == Metadata.KEY_METADATA]
@@ -33,50 +38,50 @@ class Metadata(ABC):
33
38
 
34
39
  @property
35
40
  def metadata(self) -> dict:
36
- """Returns metadata dictionary or an empty dict if no metadata are defined"""
41
+ """Returns metadata dictionary or an empty dict if no metadata are defined."""
37
42
  return self._metadata
38
43
 
39
44
  @final
40
- def _extract_metadata(self, definitions: Optional[dict[str, Any]]) -> None:
41
- """If keyword `metadata` is found on the highest level of given `definitions`, metadata are removed and set"""
45
+ def _extract_metadata(self, definitions: dict[str, Any] | None) -> None:
46
+ """If keyword `metadata` is found on the highest level of given `definitions`, metadata are removed and set."""
42
47
  self._metadata = self.__extract_metadata(definitions)
43
48
 
44
49
  @final
45
50
  def get_metadata_string(self) -> str:
46
- """Returns string representation of metadata dictionary or empty string if no metadata are present"""
51
+ """Returns string representation of metadata dictionary or empty string if no metadata are present."""
47
52
  return str(self._metadata) if self.has_metadata() else ""
48
53
 
49
54
  @final
50
55
  def has_metadata(self) -> bool:
51
- """Returns True if metadata are available"""
56
+ """Returns True if metadata are available."""
52
57
  return bool(self._metadata)
53
58
 
54
59
  @final
55
60
  def to_dict(self) -> dict:
56
- """Returns a dictionary representation of this item (using its _to_dict method) and adding its metadata"""
61
+ """Returns a dictionary representation of this item (using its _to_dict method) and adding its metadata."""
57
62
  child_data = self._to_dict()
58
63
  self.__enrich_with_metadata(child_data)
59
64
  return child_data
60
65
 
61
66
  @abstractmethod
62
67
  def _to_dict(self) -> dict:
63
- """Returns a dictionary representation of this item excluding its metadata"""
68
+ """Returns a dictionary representation of this item excluding its metadata."""
64
69
 
65
70
  @final
66
71
  def __enrich_with_metadata(self, data: dict) -> dict:
67
- """Returns data enriched with metadata field - if any metadata is available"""
72
+ """Returns data enriched with metadata field - if any metadata is available."""
68
73
  if self.has_metadata():
69
74
  data[self.KEY_METADATA] = self._metadata
70
75
  return data
71
76
 
72
77
 
73
78
  class MetadataComponent(Metadata):
74
- """
75
- A component that can contain metadata and may be associated with Objects that have metadata but do not extend
76
- Metadata itself, like, e.g., Strings in a list.
79
+ """A component that can contain metadata and may be associated with other Objects.
80
+
81
+ This can be attached to objects that cannot be derived from the Metadata class themselves.
77
82
  """
78
83
 
79
- def __init__(self, additional_definition: Optional[dict] = None) -> None:
84
+ def __init__(self, additional_definition: dict | None = None) -> None:
80
85
  super().__init__(additional_definition)
81
86
 
82
87
  def _to_dict(self) -> dict[str, dict]:
@@ -84,49 +89,64 @@ class MetadataComponent(Metadata):
84
89
 
85
90
 
86
91
  class ValueContainer:
87
- """A container for values of any type with optional associated metadata"""
92
+ """A container for values of any type with optional associated metadata."""
88
93
 
89
94
  class ParseError(InputError):
90
- """An error that occurred while parsing content for metadata-annotated simple values"""
95
+ """An error that occurred while parsing content for metadata-annotated simple values."""
96
+
97
+ _ERR_VALUES_ILL_FORMATTED = "Only Lists or Dictionaries are supported for value definitions, but was: {}"
91
98
 
92
- _ERR_VALUES_ILL_FORMATTED = "Only Lists and Dictionaries are supported here, but was: {}"
99
+ def __init__(self, definition: dict[str, Any] | list | None = None) -> None:
100
+ """Sets data (and metadata - if any) from given `definition`.
101
+
102
+ Args:
103
+ definition: dictionary representation of value(s) with potential associated metadata
93
104
 
94
- def __init__(self, definition: Union[dict[str, Any], list[Any]] = None) -> None:
95
- """Sets data (and metadata - if any) from given `definition`"""
96
- self._values = self._extract_values(definition)
105
+ Raises:
106
+ ParseError: if value definition is ill-formatted
107
+ """
108
+ self._values: dict[Any, MetadataComponent] = self._extract_values(definition)
97
109
 
98
110
  @staticmethod
99
- def _extract_values(definition: Union[dict[str, Any], list[Any]]) -> dict[Any, MetadataComponent]:
100
- """Returns value data (and optional metadata) extracted from given `definition`"""
111
+ def _extract_values(definition: dict[str, Any] | list | None) -> dict[Any, MetadataComponent]:
112
+ """Returns value data (and optional metadata) extracted from given `definition`.
113
+
114
+ Args:
115
+ definition: dictionary representation of value with potential associated metadata
116
+
117
+ Returns:
118
+ value linked with associated metadata (if any)
119
+
120
+ Raises:
121
+ ParseError: if value definition is ill-formatted, logged on level "ERROR"
122
+ """
101
123
  if definition is None:
102
124
  return {}
103
125
  if isinstance(definition, dict):
104
126
  return {key: MetadataComponent(key_definition) for key, key_definition in definition.items()}
105
127
  if isinstance(definition, list):
106
128
  return {key: MetadataComponent() for key in definition}
107
- raise ValueContainer.ParseError(ValueContainer._ERR_VALUES_ILL_FORMATTED.format(repr(definition)))
129
+ raise log_error(ValueContainer.ParseError(ValueContainer._ERR_VALUES_ILL_FORMATTED.format(repr(definition))))
108
130
 
109
131
  @property
110
132
  def values(self) -> dict[str, MetadataComponent]:
111
- """Returns stored values and each associated MetadataComponent"""
133
+ """Returns stored values and each associated MetadataComponent."""
112
134
  return self._values
113
135
 
114
136
  def as_list(self) -> list[Any]:
115
- """Returns all values as list - excluding any metadata"""
137
+ """Returns all values as list - excluding any metadata."""
116
138
  return list(self._values.keys())
117
139
 
118
140
  def to_dict(self) -> dict[Any, dict[str, dict]]:
119
- """
120
- Gives all values in dictionary representation
141
+ """Gives all values in dictionary representation.
121
142
 
122
143
  Returns:
123
144
  If metadata are present they are mapped to each value; values without metadata associate with an empty dict
124
145
  """
125
146
  return {value: component_metadata.to_dict() for value, component_metadata in self._values.items()}
126
147
 
127
- def has_value(self, to_search) -> bool:
128
- """
129
- Returns True if given value `to_search` is a key in this ValueContainer
148
+ def has_value(self, to_search: Any) -> bool:
149
+ """Returns True if given value `to_search` is a key in this ValueContainer.
130
150
 
131
151
  Args:
132
152
  to_search: value that is searched for in the keys of this ValueContainer
@@ -137,8 +157,7 @@ class ValueContainer:
137
157
  return to_search in self._values.keys()
138
158
 
139
159
  def is_empty(self) -> bool:
140
- """
141
- Returns True if no values are stored herein
160
+ """Returns True if no values are stored herein.
142
161
 
143
162
  Returns:
144
163
  True if no values are stored in this container, False otherwise
fameio/input/resolver.py CHANGED
@@ -1,14 +1,14 @@
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:
10
- """
11
- Class responsible for locating files referenced in a scenario.
11
+ """Class responsible for locating files referenced in a scenario.
12
12
 
13
13
  Such files can be the ones referenced via the YAML `!include` extension, or simply the data files (time_series)
14
14
  referenced in attributes.
@@ -18,14 +18,13 @@ class PathResolver:
18
18
 
19
19
  # noinspection PyMethodMayBeStatic
20
20
  def resolve_file_pattern(self, root_path: str, file_pattern: str) -> list[str]:
21
- """Returns a list of file paths matching the given `file_pattern` in the specified `root_path`"""
21
+ """Returns a list of file paths matching the given `file_pattern` in the specified `root_path`."""
22
22
  absolute_path = path.abspath(path.join(root_path, file_pattern))
23
23
  return glob.glob(absolute_path)
24
24
 
25
25
  # noinspection PyMethodMayBeStatic
26
- def resolve_series_file_path(self, file_name: str) -> Optional[str]:
27
- """
28
- Searches for the file in the current working directory and returns its absolute file path
26
+ def resolve_series_file_path(self, file_name: str) -> str | None:
27
+ """Searches for the file in the current working directory and returns its absolute file path.
29
28
 
30
29
  Args:
31
30
  file_name: name of the file that is to be searched
@@ -38,7 +37,7 @@ class PathResolver:
38
37
  return file_name if path.isabs(file_name) else PathResolver._search_file_in_directory(file_name, path.curdir)
39
38
 
40
39
  @staticmethod
41
- def _search_file_in_directory(file_name: str, directory: str) -> Optional[str]:
42
- """Returns path to said `file_name` relative to specified `directory` if file was found there, None otherwise"""
40
+ def _search_file_in_directory(file_name: str, directory: str) -> str | None:
41
+ """Returns path to `file_name` relative to specified `directory` if file was found there, None otherwise."""
43
42
  file_path = path.join(directory, file_name)
44
43
  return file_path if path.exists(file_path) else None
@@ -7,48 +7,84 @@ 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):
16
- """Contains specifications for an agent in a scenario"""
17
+ """Contains specifications for an agent in a scenario."""
17
18
 
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:
29
- """Constructs a new Agent"""
32
+ def __init__(self, agent_id: int, type_name: str, metadata: dict | None = None) -> None:
33
+ """Constructs a new Agent."""
30
34
  super().__init__({Agent.KEY_METADATA: metadata} if metadata else None)
31
- assert_or_raise(isinstance(agent_id, 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
+ """Create new Agent from given `definitions`
44
+
45
+ Args:
46
+ definitions: dictionary representation of an agent
47
+
48
+ Returns:
49
+ new agent created from `definitions`
50
+
51
+ Raises:
52
+ ScenarioError: if definitions are incomplete or erroneous, logged on level "ERROR"
53
+ """
40
54
  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, {})
55
+ agent_type = get_or_raise(definitions, Agent.KEY_TYPE, Agent._ERR_MISSING_KEY)
56
+ agent_id = get_or_raise(definitions, Agent.KEY_ID, Agent._ERR_MISSING_KEY)
57
+ Agent.validate_keys(definitions, agent_id)
58
+ metadata = definitions.get(Agent.KEY_METADATA, None)
59
+ agent = cls(agent_id, agent_type, metadata)
60
+ agent.init_attributes_from_dict(definitions.get(Agent.KEY_ATTRIBUTES, {}))
48
61
  return agent
49
62
 
63
+ @staticmethod
64
+ def validate_keys(data: dict, agent_id: int) -> None:
65
+ """Logs a warning if any unexpected keys are presented at top level of `data`.
66
+
67
+ Expected keys are defined in `Agent.RESERVED_KEYS`
68
+
69
+ Args:
70
+ data: agent definition to be checked
71
+ agent_id: id of agent to be checked
72
+ """
73
+ unexpected_keys = set(data.keys()) - Agent.RESERVED_KEYS
74
+ if unexpected_keys:
75
+ log().warning(Agent._WARN_UNEXPECTED_KEY.format(unexpected_keys, agent_id))
76
+
50
77
  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"""
78
+ """Initialise agent `attributes` from dict.
79
+
80
+ Must only be called when creating a new Agent.
81
+
82
+ Args:
83
+ attributes: to be set
84
+
85
+ Raises:
86
+ ScenarioError: if attributes were already initialised
87
+ """
52
88
  assert_or_raise(not self._attributes, self._ERR_ATTRIBUTE_OVERWRITE)
53
89
  self._attributes = {}
54
90
  for name, value in attributes.items():
@@ -56,21 +92,21 @@ class Agent(Metadata):
56
92
  self.add_attribute(name, Attribute(full_name, value))
57
93
 
58
94
  def add_attribute(self, name: str, value: Attribute) -> None:
59
- """Adds a new attribute to the Agent (raise an error if it already exists)"""
95
+ """Adds a new attribute to the Agent (raise an error if it already exists)."""
60
96
  if name in self._attributes:
61
97
  raise ValueError(self._ERR_DOUBLE_ATTRIBUTE.format(name, self.display_id))
62
98
  self._attributes[name] = value
63
99
  self._notify_data_changed()
64
100
 
65
101
  def _to_dict(self) -> dict:
66
- """Serializes the Agent's content to a dict"""
102
+ """Serializes the Agent's content to a dict."""
67
103
  result = {Agent.KEY_TYPE: self.type_name, Agent.KEY_ID: self.id}
68
104
  if self._attributes:
69
105
  result[self.KEY_ATTRIBUTES] = {name: value.to_dict() for name, value in self._attributes.items()}
70
106
  return result
71
107
 
72
108
  def to_string(self) -> str:
73
- """Serializes this agent to a string"""
109
+ """Serializes this agent to a string."""
74
110
  return repr(self.to_dict())
75
111
 
76
112
  @classmethod
@@ -78,24 +114,24 @@ class Agent(Metadata):
78
114
  return cls.from_dict(ast.literal_eval(definitions))
79
115
 
80
116
  def _notify_data_changed(self):
81
- """Placeholder method used to signal data changes to derived types"""
117
+ """Placeholder method used to signal data changes to derived types."""
82
118
 
83
119
  @property
84
120
  def id(self) -> int:
85
- """Returns the ID of the Agent"""
121
+ """Returns the ID of the Agent."""
86
122
  return self._id
87
123
 
88
124
  @property
89
125
  def display_id(self) -> str:
90
- """Returns the ID of the Agent as a string for display purposes"""
126
+ """Returns the ID of the Agent as a string for display purposes."""
91
127
  return f"#{self._id}"
92
128
 
93
129
  @property
94
130
  def type_name(self) -> str:
95
- """Returns the name of the Agent type"""
131
+ """Returns the name of the Agent type."""
96
132
  return self._type_name
97
133
 
98
134
  @property
99
135
  def attributes(self) -> dict[str, Attribute]:
100
- """Returns dictionary of all Attributes of this agent"""
136
+ """Returns dictionary of all Attributes of this agent."""
101
137
  return self._attributes