Skip to content

Add mechanism to allow subscribing to Driver Warning events #2099

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

vnktshr21
Copy link

  • This contribution adheres to CONTRIBUTING.md.
  • I've updated CHANGELOG.md if applicable.
  • I've added tests applicable for this pull request

What does this Pull Request accomplish?

  • Allows users to subscribe to driver warning events by defining and passing a callback function
  • This would be alongside the warnings.warn mechanism already available
  • Users can choose to take any action through the callback function for given a driver's warning, without having to override any global warning handlers
  • Subscription should be done per driver, in a given application, allowing flexibility to take different actions for different drivers
  • If there are no subscriptions, then warning event will not be triggered and driver warning response would just be based on warnings.warn configuration

List issues fixed by this Pull Request below, if any.

What testing has been done?

  • Ensured that callback functions are called into, in the event of a driver warning with appropriate warning details(DriverWarning object)
  • Unit tests have been added to validate the same

@codecov-commenter
Copy link

codecov-commenter commented May 16, 2025

Codecov Report

Attention: Patch coverage is 68.18182% with 42 lines in your changes missing coverage. Please review.

Project coverage is 91.17%. Comparing base (9efe042) to head (25ca3c0).
Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
...ated/nidcpower/nidcpower/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
...ated/nidigital/nidigital/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
generated/nidmm/nidmm/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
generated/nifgen/nifgen/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
...enerated/niscope/niscope/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
...erated/niswitch/niswitch/_grpc_stub_interpreter.py 33.33% 4 Missing ⚠️
...erated/nidcpower/nidcpower/_library_interpreter.py 60.00% 2 Missing ⚠️
...erated/nidigital/nidigital/_library_interpreter.py 60.00% 2 Missing ⚠️
generated/nidmm/nidmm/_library_interpreter.py 60.00% 2 Missing ⚠️
generated/nifgen/nifgen/_library_interpreter.py 60.00% 2 Missing ⚠️
... and 5 more

❌ Your patch status has failed because the patch coverage (68.18%) is below the target coverage (70.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #2099      +/-   ##
==========================================
- Coverage   91.34%   91.17%   -0.17%     
==========================================
  Files          66       66              
  Lines       16292    16382      +90     
==========================================
+ Hits        14882    14937      +55     
- Misses       1410     1445      +35     
Flag Coverage Δ
codegenunittests 84.95% <ø> (ø)
nidcpowersystemtests 94.41% <57.14%> (-0.15%) ⬇️
nidcpowerunittests 89.53% <ø> (ø)
nidigitalsystemtests 92.10% <57.14%> (-0.16%) ⬇️
nidigitalunittests 68.48% <66.66%> (+0.04%) ⬆️
nidmmsystemtests 92.44% <57.14%> (-0.29%) ⬇️
nifakeunittests 87.44% <100.00%> (+0.20%) ⬆️
nifgensystemtests 94.65% <57.14%> (-0.21%) ⬇️
nimodinstsystemtests 73.61% <71.42%> (-0.25%) ⬇️
nimodinstunittests 94.24% <100.00%> (+0.04%) ⬆️
niscopesystemtests 92.75% <57.14%> (-0.19%) ⬇️
niscopeunittests 43.20% <ø> (ø)
nisesystemtests 91.06% <71.42%> (-0.44%) ⬇️
niswitchsystemtests 81.75% <57.14%> (-0.29%) ⬇️
nitclksystemtests 94.48% <80.00%> (-0.40%) ⬇️
nitclkunittests 98.30% <100.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
generated/nidcpower/nidcpower/session.py 96.69% <100.00%> (+<0.01%) ⬆️
generated/nidigital/nidigital/session.py 95.93% <100.00%> (+<0.01%) ⬆️
generated/nidmm/nidmm/session.py 94.91% <100.00%> (+0.01%) ⬆️
generated/nifake/nifake/_grpc_stub_interpreter.py 84.71% <100.00%> (+0.25%) ⬆️
generated/nifake/nifake/errors.py 96.00% <100.00%> (+0.91%) ⬆️
generated/nifake/nifake/session.py 80.96% <100.00%> (+0.04%) ⬆️
generated/nifgen/nifgen/session.py 92.46% <100.00%> (+0.01%) ⬆️
generated/nimodinst/nimodinst/session.py 94.24% <100.00%> (+0.04%) ⬆️
generated/niscope/niscope/session.py 94.61% <100.00%> (+<0.01%) ⬆️
generated/nise/nise/session.py 90.52% <100.00%> (+0.10%) ⬆️
... and 17 more

... and 1 file with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 9efe042...25ca3c0. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@@ -112,6 +113,14 @@ class LibraryInterpreter(object):
def get_session_handle(self):
return self._${config['session_handle_parameter_name']}

def generate_driver_warning_event(self, driverwarning: errors.DriverWarning):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing an underscore
driverwarning -> driver_warning


Returns:
session (nidcpower.Session): A session object representing the device.

'''
driver_warning_event = errors.DriverWarningEvent()
Copy link
Collaborator

@ni-jfitzger ni-jfitzger May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I can tell, this doesn't actually allow users to subscribe to these events.
The Session object is unconditionally creating the errors.DriverWarningEvent().
Users can't access it without breaking encapsulation. Even then, not easily. It's a private member of the interpreter object, which is, itself, a private member of the Session object.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming might be the culprit here. errors.DriverWarningEvent() is not the event itself, but a class which allows handling of warnings as events. I've handled it somewhat similar to session_handle which gets created during session initialize at session object and then gets passed into library_interpreter for future use. The same instance of DriverWarningEvent() object is at play at the session object(public member) and library interpreter(private member), somewhat like a shared_ptr. Hence the addition of generate_warning_event method in library interpreter to access its private member. Encapsulation is not broken as such here as per my interpretation.

Also, the functionality of this is tested already and I've added a unit test in nifake to check the subscription and notification operation.

I see interpreter member name in session object not starting with _. Maybe there was a need to not treat interpreter as private?

@ni-jfitzger
Copy link
Collaborator

Asynchronous handling is mentioned in the linked issue. Is that important, because I don't see how this provides asynchronous handling.

@@ -120,6 +120,28 @@ class SelfTestError(Error):


% endif
class DriverWarningEvent:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Shouldn't this class have the word "handler" in its name? It doesn't represent an event, it represents a class that handles warnings.
  2. Does the word "Event" add value? Is it some Python convention that there are "warning events" or can we just say "warning"?
  3. Is this class something that customers interact with or is it internal?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The driver_warning_event member of session object will be of this class type. Thought process behind the name was that since user is going to call subscribe on this member, driver_warning_event.subscribe(callback_function) would look more apt than driver_warning_event_handler.subscribe(callback_function). The class essentially provides methods for handling of warnings as events, but its the handle_error method which actually handles warnings overall.

DriverWarning is already being used as the name for the class which houses the details of warning thrown by the driver(inherits native python Warning class). I guess "Event" here is only conveying that the action is going to be taken only in the event of a driver warning.

Maybe we can have the name of the class as DriverWarningEventHandler and the member instance in the session object can still have the name driver_warning_event?

def __init__(self):
self.subscribers = []

def subscribe(self, callback):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing callback is not a callback, but rather a function? name should be descriptive and accurate.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Will change this to say function_to_callback instead.

def subscribe(self, callback):
"""Subscribe to warning events."""
if callback not in self.subscribers:
self.subscribers.append(callback)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you support subscribing the same thing N times? what is the use case?
if not: you should raise and have unit test coverage for the logic

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current implementation does not restrict the users to have just one callback function. Went with this approach mostly to match the RFSA/G .NET API behavior. I do not see a reason for restricting as such, however I do not have a known use case for this either. This could enable handling different warnings in their own way by defining different callbacks and registering all of them. Gives a flexibility that potentially can lead to a cleaner user application.

if callback not in self.subscribers:
self.subscribers.append(callback)

def unsubscribe(self, callback):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it ok to unsubscribe a callback function that was not subscribed?
If yes, make sure that's covered in unit tests with the proper behavior.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current implementation, yes it is ok for the users to do this. Thought process was to not be strict on this regard since the result of unsubscription or not being notified again occurs anyways.

Will add a unit test for this behavior.

@@ -120,6 +120,28 @@ class SelfTestError(Error):


% endif
class DriverWarningEvent:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you guys invent this whole thing from scratch or did you derive from another NI driver or something else?

I'm wondering about terminology, etc. but not if it introduces inconsistency across our drivers or even Python conventions.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea of warning event is derived from RFSG and RFSA drivers' .NET API experience (explained here). Implementation was just based on expected behavior. I do not have a single reference as such from other python modules, but it was sort of the approach taken generally wherever folks have implemented a event handler in python.

@@ -245,17 +245,34 @@ if grpc_supported:
'use_in_python_api': False,
},
)

ctor_for_docs['parameters'].append(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

injecting metadata in a mako template seems very hacky
@ni-jfitzger do we have prior art for something like this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ni-jfitzger do we have prior art for something like this?

Unfortunately, we do.
It's just a few lines up.

ctor_for_docs['parameters'].append(

I agree. It's hacky. It should only be done if it's not possible to include directly in our metadata.
I consider the "prior art" to be tech debt.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is here since my initial approach was to try to restrict this only for rfsg. This was conditional inject at that point. Given the approach now is to apply for all drivers, I could move this to config metadata and consume from there.

{
'default_value': None,
'direction': 'in',
'documentation': { 'description': 'Driver warning event which can be subscribed to, with a callback method.\nSample callback method:\n\ndef sample_callback_method(driver_warning: ' + module_name + '.DriverWarning):\n print(str(driver_warning))\n' },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find this description to be very clear or helpful. Once we nail the class terminology we need to workshop this.

def generate_driver_warning_event(self, driverwarning: errors.DriverWarning):
'''generate_driver_warning_event

Generates a driver warning event.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean to "generate a driver warning event"?
Are you trying to say this is a function that should be called whenever the driver returns a warning?
What calls this function (is it internal only or is it called by clients of this class)? In other words should this be prefixed by an underscore?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call this function from handle_error function in our errors.py(similar to get_error_description placed next to this method). Hence the lack of underscore. This should be public method of library interpreter which owns the warning_event_handler and has interaction with a utility function in errors.py. Completely internal though. Idea was to not expose warning event handler's internal operations in session object. Library interpreter object owning the current session's warning_event_handler instance was making sense since there should be just one handler per session.

@@ -101,6 +101,28 @@ def __init__(self, code, msg):
super(SelfTestError, self).__init__('Self-test failed with code {}: {}'.format(code, msg))


class DriverWarningEvent:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bkeryan Hey Brad, question for you:

As of now, nimi-python drivers just log warnings. This PR adds a way for clients to pass a callback function for whenever a warning occurs so that clients can customize the behavior.

Do you know what other driver Python APIs do with warnings?
Does this seem like a mechanism worth standardizing across the NI driver Python APIs so that customers benefit from the consistency?

I don't want to invent something only to have nisyscfg or nidaqmx invent something different for the sake of being different.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bkeryan , any thoughts on this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of now, nimi-python drivers just log warnings.

I think using the standard Python warnings module is more flexible than just logging. It also allows applications to catch warnings or treat them as errors, and testing tools like pytest show warnings more prominently than logging.

Do you know what other driver Python APIs do with warnings?

nidaqmx reports them via the standard Python warnings module.

The DAQmx .NET API uses an event like this, though.

Does this seem like a mechanism worth standardizing across the NI driver Python APIs so that customers benefit from the consistency?

I don't know. I think #2088 is the only request I've seen for this capability in Python.

setattr(self, f, MagicMock(spec_set=getattr(self, f), side_effect=_mock_helper.MockFunctionCallError(f)))

def setup_method(self, method):
self.patched_library_interpreter = self.PatchedLibraryInterpreter(None)
self.driver_warning_event = nifake.errors.DriverWarningEvent()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the intent, that the client modifies the member directly if they want to customize the behavior for warnings?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client would just call subscribe function on the driver_warning_event member of session object by passing a callback function. There is no modification of member necessary. Do you see a need to restrict users from overwriting the driver_warning_event member itself?

@vnktshr21
Copy link
Author

Asynchronous handling is mentioned in the linked issue. Is that important, because I don't see how this provides asynchronous handling.

Thats a good point. In the current approach, we will execute all the registered callback functions before the API function execution returns. We will need to spawn asynchronous threads for executing callback functions to get the asynchronous behavior. I will check on the requirement for asynchronousity and get back on this.

@@ -101,6 +101,28 @@ def __init__(self, code, msg):
super(SelfTestError, self).__init__('Self-test failed with code {}: {}'.format(code, msg))


class DriverWarningEvent:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As of now, nimi-python drivers just log warnings.

I think using the standard Python warnings module is more flexible than just logging. It also allows applications to catch warnings or treat them as errors, and testing tools like pytest show warnings more prominently than logging.

Do you know what other driver Python APIs do with warnings?

nidaqmx reports them via the standard Python warnings module.

The DAQmx .NET API uses an event like this, though.

Does this seem like a mechanism worth standardizing across the NI driver Python APIs so that customers benefit from the consistency?

I don't know. I think #2088 is the only request I've seen for this capability in Python.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparison with other APIs:

NIDCPower .NET warnings:

  • You use session.DriverOperation.Warning to register/unregister warning event handlers
  • The event handler delegate takes object sender as well as the warning event args. I think the sender is the session object, so you can call back into it.

DAQmx .NET warnings:

  • You use the DaqSystem.Warning property to register/unregister warning event handlers. This is on a singleton object.
  • You can use the DaqSystem.LastDaqWarning property to get the most recent warning and DaqSystem.ClearLastDaqWarning() to clear it. These are thread-local.

DAQmx Python events:

Copy link
Contributor

@bkeryan bkeryan Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One missing feature is the ability to pass the session to the warning handler so it can call back into the session. You can work around this by using a different callable for each session.

Note that this creates a reference cycle: session -> interpreter -> warning_event_handler -> subscribers -> callable -> session

It's good if closing the session breaks the cycle, but apparently Python's GC can handle reference cycles.

Customers may want to use a weak ref to break the cycle. If the Session class has slots, it needs to include the __weakref__ slot.


Returns:
session (nidcpower.Session): A session object representing the device.

'''
driver_warning_event = errors.DriverWarningEvent()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a member variable. It's a local variable in the __init__ method.

@@ -7498,16 +7498,23 @@ def __init__(self, resource_name, channels=None, reset=False, options={}, indepe

grpc_options (nidcpower.grpc_session_options.GrpcSessionOptions): MeasurementLink gRPC session options

driver_warning_event (nidcpower.DriverWarningEvent): Driver warning event which can be subscribed to, with a callback method.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the documentation for instance attributes. It's the documentation for the __init__ method arguments, but the variable is not actually an argument.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Need a way to subscribe to DriverWarning as events with a callback
5 participants