nost-tools 2.0.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.

Potentially problematic release.


This version of nost-tools might be problematic. Click here for more details.

@@ -0,0 +1,262 @@
1
+ """
2
+ Provides utility classes to help applications interact with the broker.
3
+ """
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ import yaml
9
+ from dotenv import dotenv_values
10
+ from pydantic import ValidationError
11
+
12
+ from .observer import Observer
13
+ from .publisher import ScenarioTimeIntervalPublisher
14
+ from .schemas import Config, ModeStatus, TimeStatus
15
+ from .simulator import Mode, Simulator
16
+
17
+ if TYPE_CHECKING:
18
+ from .application import Application
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ConnectionConfig(object):
24
+ """Connection configuration.
25
+
26
+ The configuration settings to establish a connection to the broker, including authentication for the
27
+ user and identification of the server.
28
+
29
+ Attributes:
30
+ username (str): client username, provided by NOS-T operator
31
+ password (str): client password, provided by NOS-T operator
32
+ host (str): broker hostname
33
+ rabbitmq_port (int): RabbitMQ broker port number
34
+ keycloak_port (int): Keycloak IAM port number
35
+ keycloak_realm (str): Keycloak realm name
36
+ client_id (str): Keycloak client ID
37
+ client_secret_key (str): Keycloak client secret key
38
+ virtual_host (str): RabbitMQ virtual host
39
+ is_tls (bool): True, if the connection uses Transport Layer Security (TLS)
40
+ env_file (str): Path to the .env file
41
+ yaml_file (str): Path to the YAML configuration file
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ username: str = None,
47
+ password: str = None,
48
+ host: str = None,
49
+ rabbitmq_port: int = None,
50
+ keycloak_port: int = None,
51
+ keycloak_realm: str = None,
52
+ client_id: str = None,
53
+ client_secret_key: str = None,
54
+ virtual_host: str = None,
55
+ is_tls: bool = True,
56
+ env_file: str = None,
57
+ yaml_file: str = None,
58
+ ):
59
+ """
60
+ Initializes a new connection configuration.
61
+
62
+ Args:
63
+ username (str): client username, provided by NOS-T operator
64
+ password (str): client password, provided by NOS-T operator
65
+ host (str): broker hostname
66
+ rabbitmq_port (int): RabbitMQ broker port number
67
+ keycloak_port (int): Keycloak IAM port number
68
+ keycloak_realm (str): Keycloak realm name
69
+ client_id (str): Keycloak client ID
70
+ client_secret_key (str): Keycloak client secret key
71
+ virtual_host (str): RabbitMQ virtual host
72
+ is_tls (bool): True, if the connection uses TLS
73
+ env_file (str): Path to the .env file
74
+ yaml_file (str): Path to the YAML configuration file
75
+ """
76
+ self.yaml_config = None # Initialize the attribute to store YAML data
77
+ self.yaml_mode = (
78
+ False # Initialize the attribute to store the mode of operation
79
+ )
80
+
81
+ if env_file and yaml_file:
82
+ logger.info(f"Loading configuration from {env_file} and {yaml_file}.")
83
+ self.load_config_from_files(env_file, yaml_file)
84
+ self.yaml_mode = True
85
+ else:
86
+ logger.info("Loading configuration from arguments.")
87
+ self.username = username
88
+ self.password = password
89
+ self.host = host
90
+ self.rabbitmq_port = rabbitmq_port
91
+ self.keycloak_port = keycloak_port
92
+ self.keycloak_realm = keycloak_realm
93
+ self.client_id = client_id
94
+ self.client_secret_key = client_secret_key
95
+ self.virtual_host = virtual_host
96
+ self.is_tls = is_tls
97
+
98
+ def load_config_from_files(self, env_file: str, yaml_file: str):
99
+ """
100
+ Loads configuration from .env and YAML files.
101
+
102
+ Args:
103
+ env_file (str): Path to the .env file
104
+ yaml_file (str): Path to the YAML configuration file
105
+ """
106
+ # Load .env file
107
+ credentials = dotenv_values(env_file)
108
+ self.username = credentials["USERNAME"]
109
+ self.password = credentials["PASSWORD"]
110
+ self.client_id = credentials["CLIENT_ID"]
111
+ self.client_secret_key = credentials["CLIENT_SECRET_KEY"]
112
+
113
+ # Load YAML file
114
+ with open(yaml_file, "r") as file:
115
+ yaml_data = yaml.safe_load(file)
116
+
117
+ try:
118
+ config = Config(**yaml_data)
119
+ self.yaml_config = yaml_data
120
+ except ValidationError as e:
121
+ raise ValueError(f"Invalid configuration: {e}")
122
+
123
+ self.host = config.servers.rabbitmq.host
124
+ self.rabbitmq_port = config.servers.rabbitmq.port
125
+ self.keycloak_port = config.servers.keycloak.port
126
+ self.keycloak_realm = config.servers.keycloak.realm
127
+ self.virtual_host = config.servers.rabbitmq.virtual_host
128
+ self.is_tls = config.servers.rabbitmq.tls and config.servers.keycloak.tls
129
+
130
+ if not self.is_tls:
131
+ raise ValueError("TLS must be enabled for both RabbitMQ and Keycloak.")
132
+
133
+
134
+ class ShutDownObserver(Observer):
135
+ """
136
+ Observer that shuts down an application after scenario termination.
137
+
138
+ Attributes:
139
+ app (:obj:`Application`): application to be shut down after termination
140
+ """
141
+
142
+ def __init__(self, app: "Application"):
143
+ """
144
+ Initializes a new shut down observer.
145
+
146
+ Args:
147
+ app (:obj:`Application`): application to be shut down after termination
148
+ """
149
+ self.app = app
150
+
151
+ def on_change(
152
+ self, source: object, property_name: str, old_value: object, new_value: object
153
+ ) -> None:
154
+ """
155
+ Shuts down the application in response to a transition to the TERMINATED mode.
156
+
157
+ Args:
158
+ source (object): object that triggered a property change
159
+ property_name (str): name of the changed property
160
+ old_value (obj): old value of the named property
161
+ new_value (obj): new value of the named property
162
+ """
163
+ if property_name == Simulator.PROPERTY_MODE and new_value == Mode.TERMINATED:
164
+ self.app.shut_down()
165
+
166
+
167
+ class TimeStatusPublisher(ScenarioTimeIntervalPublisher):
168
+ """
169
+ Publishes time status messages for an application at a regular interval.
170
+
171
+ Attributes:
172
+ app (:obj:`Application`): application to publish time status messages
173
+ """
174
+
175
+ def publish_message(self) -> None:
176
+ """
177
+ Publishes a time status message.
178
+ """
179
+ status = TimeStatus.model_validate(
180
+ {
181
+ "name": self.app.app_name,
182
+ "description": self.app.app_description,
183
+ "properties": {
184
+ "simTime": self.app.simulator.get_time(),
185
+ "time": self.app.simulator.get_wallclock_time(),
186
+ },
187
+ }
188
+ )
189
+ logger.info(
190
+ f"Sending time status {status.model_dump_json(by_alias=True,exclude_none=True)}."
191
+ )
192
+
193
+ self.app.send_message(
194
+ app_name=self.app.app_name,
195
+ app_topics="status.time",
196
+ payload=status.model_dump_json(by_alias=True, exclude_none=True),
197
+ )
198
+
199
+
200
+ class ModeStatusObserver(Observer):
201
+ """
202
+ Observer that publishes mode status messages for an application.
203
+
204
+ Attributes:
205
+ app (:obj:`Application`): application to publish mode status messages
206
+ """
207
+
208
+ def __init__(self, app: "Application"):
209
+ """
210
+ Initializes a new mode status observer.
211
+ """
212
+ self.app = app
213
+
214
+ def stop_application(self):
215
+ """
216
+ Stops the application by closing the channel and connection if they are open.
217
+ """
218
+ if self.app.channel.is_open:
219
+ self.app.channel.close()
220
+ logger.info(f"Channel closed for application {self.app.app_name}.")
221
+
222
+ if self.app.connection.is_open:
223
+ self.app.connection.close()
224
+ logger.info(f"Connection closed for application {self.app.app_name}.")
225
+
226
+ logger.info(f'Application "{self.app.app_name}" successfully stopped.')
227
+
228
+ def on_change(
229
+ self, source: object, property_name: str, old_value: object, new_value: object
230
+ ) -> None:
231
+ """
232
+ Publishes a mode status message in response to a mode transition.
233
+
234
+ Args:
235
+ source (object): observable that triggered the change
236
+ property_name (str): name of the changed property
237
+ old_value (obj): old value of the named property
238
+ new_value (obj): new value of the named property
239
+ """
240
+ if property_name == Simulator.PROPERTY_MODE:
241
+ status = ModeStatus.model_validate(
242
+ {
243
+ "name": self.app.app_name,
244
+ "description": self.app.app_description,
245
+ "properties": {"mode": self.app.simulator.get_mode()},
246
+ }
247
+ )
248
+ logger.info(
249
+ f"Sending mode status {status.model_dump_json(by_alias=True, exclude_none=True)}."
250
+ )
251
+
252
+ # Ensure self.prefix is a string
253
+ if not isinstance(self.app.prefix, str):
254
+ raise ValueError(f"Exchange ({self.app.prefix}) must be a string")
255
+
256
+ # Declare the topic exchange
257
+ if self.app.channel.is_open and self.app.connection.is_open:
258
+ self.app.send_message(
259
+ app_name=self.app.app_name,
260
+ app_topics="status.mode",
261
+ payload=status.model_dump_json(by_alias=True, exclude_none=True),
262
+ )
@@ -0,0 +1,304 @@
1
+ """
2
+ Configuration Settings.
3
+ """
4
+
5
+ import logging
6
+ import os
7
+
8
+ import yaml
9
+ from dotenv import find_dotenv, load_dotenv
10
+ from pydantic import ValidationError
11
+
12
+ from .errors import ConfigAssertionError, ConfigurationError, EnvironmentVariableError
13
+ from .schemas import (
14
+ ChannelConfig,
15
+ Config,
16
+ Credentials,
17
+ ExchangeConfig,
18
+ ExecConfig,
19
+ KeycloakConfig,
20
+ RabbitMQConfig,
21
+ RuntimeConfig,
22
+ ServersConfig,
23
+ SimulationConfig,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ class ConnectionConfig:
29
+ """Connection configuration.
30
+
31
+ The configuration settings to establish a connection to the broker, including authentication for the
32
+ user and identification of the server.
33
+
34
+ Attributes:
35
+ username (str): client username, provided by NOS-T operator
36
+ password (str): client password, provided by NOS-T operator
37
+ host (str): broker hostname
38
+ rabbitmq_port (int): RabbitMQ broker port number
39
+ keycloak_port (int): Keycloak IAM port number
40
+ keycloak_realm (str): Keycloak realm name
41
+ client_id (str): Keycloak client ID
42
+ client_secret_key (str): Keycloak client secret key
43
+ virtual_host (str): RabbitMQ virtual host
44
+ is_tls (bool): True, if the connection uses Transport Layer Security (TLS)
45
+ yaml_file (str): Path to the YAML configuration file
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ username: str = None,
51
+ password: str = None,
52
+ rabbitmq_host: str = None,
53
+ rabbitmq_port: int = None,
54
+ keycloak_host: str = None,
55
+ keycloak_port: int = None,
56
+ keycloak_realm: str = None,
57
+ client_id: str = None,
58
+ client_secret_key: str = None,
59
+ virtual_host: str = None,
60
+ is_tls: bool = True,
61
+ yaml_file: str = None,
62
+ ):
63
+ """
64
+ Initializes a new connection configuration.
65
+
66
+ Args:
67
+ username (str): client username, provided by NOS-T operator
68
+ password (str): client password, provided by NOS-T operator
69
+ host (str): broker hostname
70
+ rabbitmq_port (int): RabbitMQ broker port number
71
+ keycloak_port (int): Keycloak IAM port number
72
+ keycloak_realm (str): Keycloak realm name
73
+ client_id (str): Keycloak client ID
74
+ client_secret_key (str): Keycloak client secret key
75
+ virtual_host (str): RabbitMQ virtual host
76
+ is_tls (bool): True, if the connection uses TLS
77
+ yaml_file (str): Path to the YAML configuration file
78
+ """
79
+ self.username = username
80
+ self.password = password
81
+ self.rabbitmq_host = rabbitmq_host
82
+ self.keycloak_host = keycloak_host
83
+ self.rabbitmq_port = rabbitmq_port
84
+ self.keycloak_port = keycloak_port
85
+ self.keycloak_realm = keycloak_realm
86
+ self.client_id = client_id
87
+ self.client_secret_key = client_secret_key
88
+ self.virtual_host = virtual_host
89
+ self.is_tls = is_tls
90
+
91
+ self.yaml_config = None
92
+ self.predefined_exchanges_queues = False
93
+ self.yaml_file = yaml_file
94
+ self.unique_exchanges = {}
95
+ self.channel_configs = []
96
+
97
+ self.create_connection_config()
98
+
99
+ def get_exchanges(self):
100
+ """
101
+ Get exchanges from the YAML configuration file.
102
+ """
103
+ for app, app_channels in self.yaml_config.channels.items():
104
+ for channel, details in app_channels.items():
105
+ bindings = details.get("bindings", {}).get("amqp", {})
106
+ exchange = bindings.get("exchange", {})
107
+ exchange_name = exchange.get("name")
108
+ if exchange_name:
109
+ exchange_config = ExchangeConfig(
110
+ name=exchange_name,
111
+ type=exchange.get("type", "topic"),
112
+ durable=exchange.get("durable", True),
113
+ auto_delete=exchange.get("autoDelete", False),
114
+ vhost=exchange.get("vhost", "/"),
115
+ )
116
+ if exchange_name in self.unique_exchanges:
117
+ if (
118
+ self.unique_exchanges[exchange_name]
119
+ != exchange_config.model_dump()
120
+ ):
121
+ raise ValueError(
122
+ f"Conflicting configurations for exchange '{exchange_name}': {self.unique_exchanges[exchange_name]} vs {exchange_config.model_dump()}"
123
+ )
124
+ else:
125
+ self.unique_exchanges[exchange_name] = (
126
+ exchange_config.model_dump()
127
+ )
128
+
129
+ def get_channels(self):
130
+ """
131
+ Get channels from the YAML configuration file.
132
+ """
133
+ for app, app_channels in self.yaml_config.channels.items():
134
+ for channel, details in app_channels.items():
135
+ bindings = details.get("bindings", {}).get("amqp", {})
136
+ exchange = bindings.get("exchange", {})
137
+ exchange_name = exchange.get("name")
138
+ address = details.get("address")
139
+ if address and bindings:
140
+ channel_config = ChannelConfig(
141
+ app=app,
142
+ address=address,
143
+ exchange=exchange_name or "default_exchange",
144
+ durable=exchange.get("durable", True),
145
+ auto_delete=exchange.get("autoDelete", False),
146
+ vhost=exchange.get("vhost", "/"),
147
+ )
148
+ self.channel_configs.append(channel_config.model_dump())
149
+
150
+ def get_exchanges_channels(self):
151
+ """
152
+ Get exchanges and channels from the YAML configuration file.
153
+ """
154
+
155
+ self.get_exchanges(), self.get_channels()
156
+ if self.unique_exchanges and self.channel_configs:
157
+ self.predefined_exchanges_queues = True
158
+ self.simulation_config = SimulationConfig(
159
+ exchanges=self.unique_exchanges,
160
+ queues=self.channel_configs,
161
+ execution_parameters=self.yaml_config.execution,
162
+ predefined_exchanges_queues=self.predefined_exchanges_queues,
163
+ )
164
+
165
+ def load_environment_variables(self):
166
+ """
167
+ Loads an environment (.env) file and returns the parsed data.
168
+ """
169
+ dotenv_path = find_dotenv(usecwd=True)
170
+ if dotenv_path:
171
+ logger.info(f"Checking for credentials in the .env file: {dotenv_path}.")
172
+ load_dotenv(dotenv_path, override=True)
173
+ else:
174
+ logger.warning(
175
+ "Checking for credentials in the system environment variables."
176
+ )
177
+ if self.server_config.servers.rabbitmq.keycloak_authentication:
178
+ required_fields = [
179
+ "USERNAME",
180
+ "PASSWORD",
181
+ "CLIENT_ID",
182
+ "CLIENT_SECRET_KEY",
183
+ ]
184
+ else:
185
+ required_fields = ["USERNAME", "PASSWORD"]
186
+
187
+ env_data = {field: os.getenv(field) for field in required_fields}
188
+
189
+ missing_fields = [field for field, value in env_data.items() if value is None]
190
+ if missing_fields:
191
+ raise EnvironmentVariableError(
192
+ f"Missing required fields in .env file: {', '.join(missing_fields)}"
193
+ )
194
+
195
+ try:
196
+ if self.server_config.servers.rabbitmq.keycloak_authentication:
197
+ self.credentials_config = Credentials(
198
+ username=env_data["USERNAME"],
199
+ password=env_data["PASSWORD"],
200
+ client_id=env_data["CLIENT_ID"],
201
+ client_secret_key=env_data["CLIENT_SECRET_KEY"],
202
+ )
203
+ else:
204
+ self.credentials_config = Credentials(
205
+ username=env_data["USERNAME"],
206
+ password=env_data["PASSWORD"],
207
+ )
208
+ except ValidationError as err:
209
+ raise EnvironmentVariableError(f"Invalid environment variables: {err}")
210
+
211
+ def load_yaml_config_file(self):
212
+ """
213
+ Loads a YAML configuration file and returns the parsed data.
214
+ """
215
+ if not os.path.exists(self.yaml_file):
216
+ raise ConfigurationError("Couldn't load config file (not found)")
217
+
218
+ with open(self.yaml_file, "r", encoding="utf-8") as f:
219
+ try:
220
+ yaml_data = yaml.safe_load(f) # Store the parsed YAML data
221
+ except yaml.YAMLError as err:
222
+ raise ConfigurationError(f"Invalid YAML configuration: {err}")
223
+
224
+ try:
225
+ self.yaml_config = Config(**yaml_data)
226
+ except ValidationError as err:
227
+ raise ConfigurationError(f"Invalid configuration: {err}")
228
+
229
+ def create_connection_config(self):
230
+ """
231
+ Creates a connection configuration.
232
+ """
233
+ if self.yaml_file:
234
+ try:
235
+ self.load_yaml_config_file()
236
+ except ConfigurationError as e:
237
+ raise ValueError(f"Configuration error: {e}")
238
+
239
+ try:
240
+ assert all(
241
+ item in self.yaml_config.execution.required_apps
242
+ for item in self.yaml_config.channels.keys()
243
+ ), "Application names do not match the channels defined in the configuration file."
244
+ except ConfigAssertionError as e:
245
+ raise ValueError(f"Assertion error: {e}")
246
+ else:
247
+ try:
248
+ self.yaml_config = Config(
249
+ servers=ServersConfig(
250
+ rabbitmq=RabbitMQConfig(
251
+ host=self.rabbitmq_host,
252
+ port=self.rabbitmq_port,
253
+ virtual_host=self.virtual_host,
254
+ tls=self.is_tls,
255
+ ),
256
+ keycloak=KeycloakConfig(
257
+ host=self.keycloak_host,
258
+ port=self.keycloak_port,
259
+ realm=self.keycloak_realm,
260
+ tls=self.is_tls,
261
+ ),
262
+ ),
263
+ channels={},
264
+ execution=ExecConfig(),
265
+ )
266
+ except:
267
+ self.yaml_config = Config(
268
+ servers=ServersConfig(
269
+ rabbitmq=RabbitMQConfig(), keycloak=KeycloakConfig()
270
+ ),
271
+ channels={},
272
+ execution=ExecConfig(),
273
+ )
274
+
275
+ self.get_exchanges_channels()
276
+
277
+ server_config = self.yaml_config.copy()
278
+ if hasattr(server_config, "channels"):
279
+ del server_config.channels
280
+ if hasattr(server_config, "execution"):
281
+ del server_config.execution
282
+ self.server_config = server_config
283
+
284
+ if (
285
+ self.username is not None
286
+ and self.password is not None
287
+ and self.client_id is not None
288
+ and self.client_secret_key is not None
289
+ ):
290
+ logger.info("Using provided credentials.")
291
+ self.credentials_config = Credentials(
292
+ username=self.username,
293
+ password=self.password,
294
+ client_id=self.client_id,
295
+ client_secret_key=self.client_secret_key,
296
+ )
297
+ else:
298
+ self.load_environment_variables()
299
+
300
+ self.rc = RuntimeConfig(
301
+ credentials=self.credentials_config,
302
+ server_configuration=server_config,
303
+ simulation_configuration=self.simulation_config,
304
+ )
nost_tools/entity.py ADDED
@@ -0,0 +1,73 @@
1
+ """
2
+ Provides a base class to maintain state variables during scenario execution.
3
+ """
4
+
5
+ import logging
6
+ from datetime import datetime, timedelta
7
+
8
+ from .observer import Observable
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Entity(Observable):
14
+ """
15
+ A base entity that maintains its own clock (time) during scenario execution.
16
+
17
+ Notifies observers of changes to one observable property
18
+ * `time`: current scenario time
19
+
20
+ Attributes:
21
+ name (str): The entity name (optional)
22
+ """
23
+
24
+ PROPERTY_TIME = "time"
25
+
26
+ def __init__(self, name: str = None):
27
+ """
28
+ Initializes a new entity.
29
+
30
+ Args:
31
+ name (str): name of the entity (default: None)
32
+ """
33
+ super().__init__()
34
+ self.name = name
35
+ self._init_time = self._time = self._next_time = None
36
+
37
+ def initialize(self, init_time: datetime) -> None:
38
+ """
39
+ Initializes the entity at a designated initial scenario time.
40
+
41
+ Args:
42
+ init_time (:obj:`datetime`): initial scenario time
43
+ """
44
+ self._init_time = self._time = self._next_time = init_time
45
+
46
+ def tick(self, time_step: timedelta) -> None:
47
+ """
48
+ Computes the next state transition following an elapsed scenario duration (time step).
49
+
50
+ Args:
51
+ time_step (:obj:`timedelta`): elapsed scenario duration
52
+ """
53
+ self._next_time = self._time + time_step
54
+
55
+ def tock(self) -> None:
56
+ """
57
+ Commits the state transition pre-computed in `tick` and notifies observers of changes.
58
+ """
59
+ # update the time
60
+ if self._time != self._next_time:
61
+ prev_time = self._time
62
+ self._time = self._next_time
63
+ logger.debug(f"Entity {self.name} updated time to {self._time}.")
64
+ self.notify_observers(self.PROPERTY_TIME, prev_time, self._time)
65
+
66
+ def get_time(self) -> datetime:
67
+ """
68
+ Retrieves the current scenario time.
69
+
70
+ Returns:
71
+ :obj:`datetime`: current scenario time
72
+ """
73
+ return self._time
nost_tools/errors.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ Provides object models for common data structures.
3
+ """
4
+
5
+ class ConfigurationError(Exception):
6
+ """Configuration error"""
7
+
8
+
9
+ class EnvironmentVariableError(Exception):
10
+ """Environment variable error"""
11
+
12
+
13
+ class ConfigAssertionError(Exception): # Renamed to avoid shadowing built-in
14
+ """Assertion error for configuration validation"""