nost-tools 2.0.1__py3-none-any.whl → 2.0.2__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.

nost_tools/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "2.0.1"
1
+ __version__ = "2.0.2"
2
2
 
3
3
  from .application import Application
4
4
  from .application_utils import ConnectionConfig, ModeStatusObserver, TimeStatusPublisher
nost_tools/application.py CHANGED
@@ -83,6 +83,7 @@ class Application:
83
83
  self.refresh_token = None
84
84
  self._token_refresh_thread = None
85
85
  self.token_refresh_interval = None
86
+ self._reconnect_delay = None
86
87
 
87
88
  def ready(self) -> None:
88
89
  """
@@ -158,14 +159,12 @@ class Application:
158
159
  def start_token_refresh_thread(self):
159
160
  """
160
161
  Starts a background thread to refresh the access token periodically.
161
-
162
- Args:
163
- config (:obj:`ConnectionConfig`): connection configuration
164
162
  """
165
163
  logger.debug("Starting refresh token thread.")
166
164
 
167
165
  def refresh_token_periodically():
168
166
  while not self._should_stop.wait(timeout=self.token_refresh_interval):
167
+ logger.debug("Token refresh thread is running.")
169
168
  try:
170
169
  access_token, refresh_token = self.new_access_token(
171
170
  self.refresh_token
@@ -283,16 +282,17 @@ class Application:
283
282
  # Set SSL options
284
283
  parameters.ssl_options = pika.SSLOptions(context)
285
284
 
286
- # Callback functions for connection
287
- def on_connection_open(connection):
288
- self.connection = connection
289
- self.connection.channel(on_open_callback=self.on_channel_open)
290
- logger.info("Connection established successfully.")
285
+ # Save connection parameters for reconnection
286
+ self._connection_parameters = parameters
287
+ self._reconnect_delay = (
288
+ self.config.rc.server_configuration.servers.rabbitmq.reconnect_delay
289
+ )
290
+ logger.info(f"Reconnect delay: {self._reconnect_delay}")
291
291
 
292
292
  # Establish non-blocking connection to RabbitMQ
293
293
  self.connection = pika.SelectConnection(
294
294
  parameters=parameters,
295
- on_open_callback=on_connection_open,
295
+ on_open_callback=self.on_connection_open,
296
296
  on_open_error_callback=self.on_connection_error,
297
297
  on_close_callback=self.on_connection_closed,
298
298
  )
@@ -327,11 +327,16 @@ class Application:
327
327
 
328
328
  def _start_io_loop(self):
329
329
  """
330
- Starts the I/O loop for the connection.
330
+ Starts the I/O loop in a separate thread. This allows the application to
331
+ run in the background while still being able to process messages from RabbitMQ.
331
332
  """
332
333
  self.stop_event = threading.Event()
333
334
  while not self.stop_event.is_set():
334
- self.connection.ioloop.start()
335
+ try:
336
+ self.connection.ioloop.start()
337
+ except Exception as e:
338
+ logger.error(f"I/O loop error: {e}")
339
+ break
335
340
 
336
341
  def on_channel_open(self, channel):
337
342
  """
@@ -341,9 +346,46 @@ class Application:
341
346
  channel (:obj:`pika.channel.Channel`): channel object
342
347
  """
343
348
  self.channel = channel
349
+ self.add_on_channel_close_callback()
350
+
344
351
  # Signal that connection is established
345
352
  self._is_connected.set()
346
353
 
354
+ def add_on_channel_close_callback(self):
355
+ """This method tells pika to call the on_channel_closed method if
356
+ RabbitMQ unexpectedly closes the channel.
357
+ """
358
+ logger.info("Adding channel close callback")
359
+ self.channel.add_on_close_callback(self.on_channel_closed)
360
+
361
+ def on_channel_closed(self, channel, reason):
362
+ """
363
+ Invoked by pika when RabbitMQ unexpectedly closes the channel.
364
+ Channels are usually closed if you attempt to do something that
365
+ violates the protocol, such as re-declare an exchange or queue with
366
+ different parameters. In this case, we'll close the connection
367
+ to shutdown the object.
368
+
369
+ Args:
370
+ channel (:obj:`pika.channel.Channel`): channel object
371
+ reason (Exception): exception representing reason for loss of connection
372
+ """
373
+ logger.warning(f"Channel {channel} was closed: {reason}")
374
+ self.close_connection()
375
+
376
+ def close_connection(self):
377
+ """
378
+ This method is invoked by pika when the connection to RabbitMQ is
379
+ closed. This method is called when the application is shutting down
380
+ or when the connection is closed unexpectedly.
381
+ """
382
+ self._consuming = False
383
+ if self.connection.is_closing or self.connection.is_closed:
384
+ logger.info("Connection is closing or already closed")
385
+ else:
386
+ logger.info("Closing connection")
387
+ self.connection.close()
388
+
347
389
  def on_connection_error(self, connection, error):
348
390
  """
349
391
  Callback function for when a connection error occurs.
@@ -362,18 +404,61 @@ class Application:
362
404
  RabbitMQ if it disconnects.
363
405
 
364
406
  Args:
365
- connection (:obj:`pika.connection.Connection`): closed connection object
407
+ connection (:obj:`pika.connection.Connection`): connection object
366
408
  reason (Exception): exception representing reason for loss of connection
367
409
  """
368
410
  self.channel = None
369
411
  if self._closing:
370
412
  self.connection.ioloop.stop()
413
+ else:
414
+ logger.warning(
415
+ f"Connection closed, reconnecting in {self._reconnect_delay} seconds: {reason}"
416
+ )
417
+ self.connection.ioloop.call_later(self._reconnect_delay, self.reconnect)
418
+
419
+ def on_connection_open(self, connection):
420
+ """
421
+ This method is invoked by pika when the connection to RabbitMQ has
422
+ been established. At this point we can create a channel and start
423
+ consuming messages.
424
+
425
+ Args:
426
+ connection (:obj:`pika.connection.Connection`): connection object
427
+ """
428
+ self.connection = connection
429
+ self.connection.channel(on_open_callback=self.on_channel_open)
430
+ logger.info("Connection established successfully.")
431
+
432
+ def reconnect(self):
433
+ """
434
+ Reconnect to RabbitMQ by reinitializing the connection.
435
+ """
436
+ if not self._closing:
437
+ try:
438
+ logger.info("Attempting to reconnect to RabbitMQ...")
439
+ self.connection = pika.SelectConnection(
440
+ parameters=self._connection_parameters,
441
+ on_open_callback=self.on_connection_open,
442
+ on_open_error_callback=self.on_connection_error,
443
+ on_close_callback=self.on_connection_closed,
444
+ )
445
+
446
+ # Start the I/O loop in a separate thread
447
+ self._io_thread = threading.Thread(target=self._start_io_loop)
448
+ self._io_thread.start()
449
+ self._is_connected.wait()
450
+ logger.info(
451
+ "Attempting to reconnect to RabbitMQ completed successfully."
452
+ )
453
+
454
+ except Exception as e:
455
+ logger.error(f"Reconnection attempt failed: {e}")
456
+ self.connection.ioloop.call_later(self._reconnect_delay, self.reconnect)
371
457
 
372
458
  def shut_down(self) -> None:
373
459
  """
374
460
  Shuts down the application by stopping the background event loop and disconnecting from the broker.
375
461
  """
376
- # self._should_stop.set()
377
462
  if self._time_status_publisher is not None:
378
463
  self.simulator.remove_observer(self._time_status_publisher)
379
464
  self._time_status_publisher = None
@@ -381,6 +466,11 @@ class Application:
381
466
  if self.connection:
382
467
  self.stop_application()
383
468
  self._consuming = False
469
+
470
+ # Stop the token refresh thread
471
+ if hasattr(self, "_should_stop"):
472
+ self._should_stop.set()
473
+
384
474
  logger.info(f"Application {self.app_name} successfully shut down.")
385
475
 
386
476
  def send_message(self, app_name, app_topics, payload: str) -> None:
@@ -466,9 +556,12 @@ class Application:
466
556
  ):
467
557
  """
468
558
  Add callback for a topic, supporting wildcards (* and #) in routing keys.
559
+ (* matches exactly one word, # matches zero or more words)
469
560
 
470
- * matches exactly one word
471
- # matches zero or more words
561
+ Args:
562
+ app_name (str): application name
563
+ app_topic (str): topic name
564
+ user_callback (Callable): callback function to be called when a message is received
472
565
  """
473
566
  self.was_consuming = True
474
567
  self._consuming = True
@@ -521,6 +614,12 @@ class Application:
521
614
  """
522
615
  Callback for handling messages received from RabbitMQ.
523
616
  Supports both direct routing key matches and wildcard patterns.
617
+
618
+ Args:
619
+ ch (:obj:`pika.channel.Channel`): channel object
620
+ method (:obj:`pika.spec.Basic.Deliver`): method frame
621
+ properties (:obj:`pika.spec.BasicProperties`): properties frame
622
+ body (str): message body
524
623
  """
525
624
  routing_key = method.routing_key
526
625
  logger.debug(f"Received message with routing key: {routing_key}")
@@ -571,8 +670,8 @@ class Application:
571
670
  """Acknowledge the message delivery from RabbitMQ by sending a
572
671
  Basic.Ack RPC method for the delivery tag.
573
672
 
574
- :param int delivery_tag: The delivery tag from the Basic.Deliver frame
575
-
673
+ Args:
674
+ delivery_tag (str): The delivery tag of the message to acknowledge
576
675
  """
577
676
  try:
578
677
  logger.debug(f"Acknowledging message {delivery_tag}")
@@ -596,10 +695,10 @@ class Application:
596
695
  ) -> None:
597
696
  """
598
697
  Declares and binds a queue to the exchange. The queue is bound to the exchange using the routing key. The routing key is created using the application name and topic.
698
+
599
699
  Args:
600
- app_name (str): application name
601
- topic (str): topic name
602
- app_specific_extender (str): application specific extender, used to create a unique queue name for the application. If the app_specific_extender is not provided, the queue name is the same as the routing key.
700
+ routing_key (str): routing key
701
+ app_specific_extender (str): application-specific extender for the queue name
603
702
  """
604
703
  try:
605
704
  if app_specific_extender:
@@ -684,8 +783,10 @@ class Application:
684
783
  cancellation of a consumer. At this point we will close the channel.
685
784
  This will invoke the on_channel_closed method once the channel has been
686
785
  closed, which will in-turn close the connection.
687
- :param pika.frame.Method _unused_frame: The Basic.CancelOk frame
688
- :param str|unicode userdata: Extra user data (consumer tag)
786
+
787
+ Args:
788
+ _unused_frame (:obj:`pika.frame.Method`): The Basic.CancelOk frame
789
+ userdata (str|unicode): Extra user data (consumer tag)
689
790
  """
690
791
  self._consuming = False
691
792
  logger.info(
@@ -758,7 +859,7 @@ class Application:
758
859
  logger.info(f"Wallclock offset updated to {offset}.")
759
860
  return
760
861
  except ntplib.NTPException:
761
- logger.warn(
862
+ logger.warning(
762
863
  f"Could not connect to {host}, attempt #{i+1}/{max_retry} in {retry_delay_s} s."
763
864
  )
764
865
  time.sleep(retry_delay_s)
nost_tools/schemas.py CHANGED
@@ -244,6 +244,7 @@ class RabbitMQConfig(BaseModel):
244
244
  blocked_connection_timeout: int = Field(
245
245
  None, description="Timeout for blocked connections."
246
246
  )
247
+ reconnect_delay: int = Field(10, description="Reconnect delay, in seconds.")
247
248
 
248
249
 
249
250
  class KeycloakConfig(BaseModel):
@@ -258,7 +259,25 @@ class KeycloakConfig(BaseModel):
258
259
 
259
260
  class ServersConfig(BaseModel):
260
261
  rabbitmq: RabbitMQConfig = Field(..., description="RabbitMQ configuration.")
261
- keycloak: KeycloakConfig = Field(..., description="Keycloak configuration.")
262
+ keycloak: Optional[KeycloakConfig] = Field(
263
+ None, description="Keycloak configuration."
264
+ )
265
+
266
+ @model_validator(mode="before")
267
+ def validate_keycloak_authentication(cls, values):
268
+ rabbitmq_config = values.get("rabbitmq")
269
+ keycloak_config = values.get("keycloak")
270
+
271
+ # Check if rabbitmq_config is a dictionary and validate the keycloak_authentication key
272
+ if (
273
+ isinstance(rabbitmq_config, dict)
274
+ and rabbitmq_config.get("keycloak_authentication", False)
275
+ and not keycloak_config
276
+ ):
277
+ raise ValueError(
278
+ "Keycloak authentication is enabled, but the Keycloak configuration is missing."
279
+ )
280
+ return values
262
281
 
263
282
 
264
283
  class GeneralConfig(BaseModel):
@@ -301,8 +320,9 @@ class ManagerConfig(BaseModel):
301
320
  if "time_status_step" in values:
302
321
  time_status_step = values["time_status_step"]
303
322
  if isinstance(time_status_step, str):
323
+ hours, minutes, seconds = map(int, time_status_step.split(":"))
304
324
  time_status_step = timedelta(
305
- seconds=float(time_status_step.split(":")[-1])
325
+ hours=hours, minutes=minutes, seconds=seconds
306
326
  )
307
327
  if isinstance(time_status_step, timedelta):
308
328
  values["time_status_step"] = timedelta(
@@ -332,7 +352,8 @@ class ManagedApplicationConfig(BaseModel):
332
352
  if "time_step" in values:
333
353
  time_step = values["time_step"]
334
354
  if isinstance(time_step, str):
335
- time_step = timedelta(seconds=float(time_step.split(":")[-1]))
355
+ hours, minutes, seconds = map(int, time_step.split(":"))
356
+ time_step = timedelta(hours=hours, minutes=minutes, seconds=seconds)
336
357
  if isinstance(time_step, timedelta):
337
358
  values["time_step"] = timedelta(
338
359
  seconds=time_step.total_seconds() * time_scale_factor
@@ -341,8 +362,9 @@ class ManagedApplicationConfig(BaseModel):
341
362
  if "time_status_step" in values:
342
363
  time_status_step = values["time_status_step"]
343
364
  if isinstance(time_status_step, str):
365
+ hours, minutes, seconds = map(int, time_status_step.split(":"))
344
366
  time_status_step = timedelta(
345
- seconds=float(time_status_step.split(":")[-1])
367
+ hours=hours, minutes=minutes, seconds=seconds
346
368
  )
347
369
  if isinstance(time_status_step, timedelta):
348
370
  values["time_status_step"] = timedelta(
@@ -374,9 +396,13 @@ class LoggerApplicationConfig(BaseModel):
374
396
 
375
397
  class ExecConfig(BaseModel):
376
398
  general: GeneralConfig
377
- manager: Optional[ManagerConfig] = None
378
- managed_application: Optional[ManagedApplicationConfig] = None
379
- logger_application: Optional[LoggerApplicationConfig] = None
399
+ manager: Optional[ManagerConfig] = Field(None, description="Manager configuration.")
400
+ managed_application: Optional[ManagedApplicationConfig] = Field(
401
+ None, description="Managed application configuration."
402
+ )
403
+ logger_application: Optional[LoggerApplicationConfig] = Field(
404
+ None, description="Logger application configuration."
405
+ )
380
406
 
381
407
 
382
408
  class Config(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nost_tools
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Tools for Novel Observing Strategies Testbed (NOS-T) Applications
5
5
  Author-email: "Paul T. Grogan" <paul.grogan@asu.edu>, "Emmanuel M. Gonzalez" <emmanuelgonzalez@asu.edu>
6
6
  License: BSD License
@@ -1,5 +1,5 @@
1
- nost_tools/__init__.py,sha256=5P2IepQbREyX8rJd9Fjg5CLDkzv2i1SxlyhaAEEtmbE,873
2
- nost_tools/application.py,sha256=ne_6dVKDRNmV1bL5OwgxGgtiGHFmUcJ8EyH2bLpuJLU,32955
1
+ nost_tools/__init__.py,sha256=y9dH4r4TAovdcdkArBdlDS7LKF_W2iNFJxPEZmiFpxM,873
2
+ nost_tools/application.py,sha256=1mFCw6b5BCAlaof-ijzjFYCvVB-WCn9llpHHijxeIik,37027
3
3
  nost_tools/application_utils.py,sha256=_R39D26FYxgaO4uyTON24KXc4UQ4zAEDBZfEkHbEw64,9386
4
4
  nost_tools/configuration.py,sha256=ikNpZi8aofhZzJRbJf4x46afbAnp8r5C7Yr50Rnn1Nc,11639
5
5
  nost_tools/entity.py,sha256=AwbZMP3_H4RQuyU4voyQwYFkETxG0mfD-0BMHxrRFf8,2064
@@ -9,10 +9,10 @@ nost_tools/managed_application.py,sha256=uzWmOevHXo2kr_1cMhSdJU7g9_EVgo2s4_VuxQc
9
9
  nost_tools/manager.py,sha256=15MkZE6FucMbHt0mitYc5Hg__QjKIfCQe4W0ojER2C8,19914
10
10
  nost_tools/observer.py,sha256=w66jZQ11Fr7XSCcvcc2f5ISce2n8Ba7cXqheSTuyrmw,5519
11
11
  nost_tools/publisher.py,sha256=omU8tb0AXnA6RfhYSh0vnXbJtrRo4ukx1J5ANl4bDLQ,5291
12
- nost_tools/schemas.py,sha256=OMeV0-0Lpq3Tqi-FBzxH0wLp1a1HihFCsczQo6P-t0c,14055
12
+ nost_tools/schemas.py,sha256=7Vj0a_-R78EHgQKwQP0YQz9leTBliFg5UYBFwdD5Pkg,15210
13
13
  nost_tools/simulator.py,sha256=ALnGDmnA_ga-1Lq-bVWi2vcrspgjS4vtuDE0jWsI7fE,20191
14
- nost_tools-2.0.1.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
- nost_tools-2.0.1.dist-info/METADATA,sha256=gJrf_BKQlfvo4VWoYqqr2Ad6wFtpLT2WwLJbyWP7vkU,4244
16
- nost_tools-2.0.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
17
- nost_tools-2.0.1.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
- nost_tools-2.0.1.dist-info/RECORD,,
14
+ nost_tools-2.0.2.dist-info/licenses/LICENSE,sha256=aAMU-mTHTKpWkBsg9QhkhCQpEm3Gri7J_fVuJov8s3s,1539
15
+ nost_tools-2.0.2.dist-info/METADATA,sha256=PJCwxRDVyxWfil_gdP6OP6SnGOG7q5KxodHMJS7JnBQ,4244
16
+ nost_tools-2.0.2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
17
+ nost_tools-2.0.2.dist-info/top_level.txt,sha256=LNChUgrv2-wiym12O0r61kY83COjTpTiJ2Ly1Ca58A8,11
18
+ nost_tools-2.0.2.dist-info/RECORD,,