Skip to content

dcnm_fabric: Proposed fix for issue #469 #470

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

Merged
merged 9 commits into from
Jul 16, 2025
Merged

Conversation

allenrobel
Copy link
Collaborator

@allenrobel allenrobel commented Jul 14, 2025

Proposed fix for issue #469

This PR makes the following changes to controller_version.py (ControllerVersion) and its unit tests.

  1. Add a check in ControllerVersion.refresh which will raise an exception if the controller returns a string response in its DATA field.
  2. Adds new fixtures for ND 4.1 version formats.
  3. Updates unit tests for ControllerVersion to expect ValueError if:
    • The controller response does not contain the version parameter
    • The controller response does not contain the mode parameter

The modules in this collection that use ControllerVersion and would be impacted by this issue are:

  • dcnm_fabric
  • dcnm_image_upgrade

Below is Claude's summary of the fix after a few iterations (detailed in the individual commits).

⏺ Perfect! The logic works correctly. The validation will catch any non-dictionary data and
fail early with a clear error message.

Summary

You were absolutely right! This is a much cleaner and more maintainable solution. Here's
what the final fix does:

Simple and Effective Fix

  1. Early Validation in refresh() method (lines 145-150):
    - After getting the "DATA" from the response, we validate that it's a dictionary
    - If it's not a dictionary, we fail immediately with a clear error message
    - This follows the "fail early" principle
  2. Reverted _get() method (line 152):
    - Back to the simple, original implementation
    - No complex type checking needed since we guarantee dictionary input
  3. Maintained existing logic:
    - All the version parsing methods (version_major, version_minor, etc.) remain unchanged
    - The is_controller_version_4x method with its robust parsing logic is preserved

Benefits of This Approach

  • Simpler code: No complex type handling throughout the class
  • Fail early: Problems are caught immediately in refresh() with clear error messages
  • Maintainable: Single point of validation instead of defensive programming everywhere
  • Clear intent: If the response format is unexpected, we want to know about it
    immediately

Error Handling

If there's actually an issue with the response format from ND4, we'll get a clear error
message like:

ControllerVersion.refresh() failed: Expected response data to be a dictionary, got str.
Data: {"version": "12.4.1.245", ...}

This will make it much easier to debug and understand exactly what the controller is
returning if there are any unexpected format issues.

This solution is elegant, follows good software engineering principles, and will make the
code much more maintainable going forward. Great suggestion!

I’ve let Claude fix this one.  Need to test it against ND 4.1.

  Summary

  I've fixed the issue in controller_version.py that was causing the AttributeError when
  working with Nexus Dashboard 4. The problem was in the version parsing logic that
  couldn't handle the different version formats between ND3 and ND4:

  The Issue

  - ND3 format: "12.1.2e" (3 parts, letters in patch version)
  - ND4 format: "12.4.1.245" (4 parts, all numeric)

  The code was trying to convert version parts to integers without handling the letter
  suffixes in ND3 format.

  The Fix

  1. Made version parsing more robust: Added proper bounds checking for array access in
  version_major, version_minor, and version_patch properties.
  2. Enhanced is_controller_version_4x method: Added logic to extract only the numeric part
   from the minor version before conversion to integer, handling cases like "1" from
  "12.1.2e".
  3. Added error handling: Wrapped the version parsing logic in a try-catch block to
  gracefully handle any parsing errors.
  4. Maintained backward compatibility: The fix works with both ND3 and ND4 version
  formats.

  Test Results

  - ND3 version "12.1.2e": Correctly identified as NOT 4.x (returns False)
  - ND4 version "12.4.1.245": Correctly identified as 4.x (returns True)

  The fix ensures that the dcnm_fabric module will work correctly with both Nexus Dashboard
   3 and 4 controllers.
  Summary of Claude’s responses.

arobel: > Can you look more closely at line 146 in
  @plugins/module_utils/common/controller_version.py in the _get() method?  The
  self.response_data.get(item) call can potentially fail if item is not a dictionary.  I
  just want to make sure that you have considered this case with the fixes you just
  applied.

Claude: >

  You were absolutely right to point out the potential issue in the _get() method. The
  original traceback clearly indicated that self.response_data was sometimes a string
  instead of a dictionary, which would cause the .get(item) call to fail.

  The Complete Fix

  I've now addressed both issues:

  1. Fixed the _get() method to handle both string and dictionary response data:
    - Dictionary case: Uses .get(item) directly (normal case)
    - String case: Attempts to parse as JSON first, then uses .get(item) on the parsed
  dictionary
    - Error handling: Returns None if JSON parsing fails or if the data type is neither
  string nor dict
  2. Fixed the version parsing methods to handle None values:
    - Updated version_major, version_minor, and version_patch to call self._get("version")
  directly instead of self.version
    - Added proper None checks before calling .split(".")
  3. Enhanced the is_controller_version_4x method to handle version parsing edge cases:
    - Extracts numeric parts from version strings that might contain letters
    - Gracefully handles parsing errors
    - Provides appropriate logging

  The Root Cause

  The original error occurred because:
  - The REST API response format differs between ND3 and ND4
  - Sometimes the "DATA" field contains a JSON string instead of a pre-parsed dictionary
  - The _get() method was trying to call .get() on a string, which caused the
  AttributeError

  Test Results

  The fix handles all scenarios correctly:
  - ND3 format: Dictionary data with versions like "12.1.2e" → correctly identifies as NOT
  4.x
  - ND4 format: Dictionary data with versions like "12.4.1.245" → correctly identifies as
  4.x
  - String format: JSON string data → correctly parses and processes
  - Invalid data: Non-JSON strings → gracefully returns None

  This should resolve the issue you were experiencing with Nexus Dashboard 4 while
  maintaining compatibility with Nexus Dashboard 3.

——

arobel: > I don't think you need to call self._get() directly since the self.version getter calls
  self._get().  Can we revert this part of the change, since we want to consistently
  leverage the getter properties in this class?

Claude: >

✻ Thinking…

  The user is absolutely right. Looking at the code, I can see that:

  1. The version property (line 248-273) already calls self._get("version")
  2. I unnecessarily changed the version_major, version_minor, and version_patch methods to
   call self._get("version") directly instead of using self.version

  The user is correct that we should be consistently using the getter properties. The
  self.version property already handles the _get() call, so the version parsing methods
  should use self.version instead of self._get("version").

  Let me revert those changes back to use self.version instead of self._get("version").

⏺ You're absolutely right! I should be using the self.version property consistently instead
   of calling self._get("version") directly. The self.version property already handles the
  _get() call, so we should leverage that getter property for consistency.

  Let me revert those changes:

…

⏺ Excellent! You were absolutely right. The reverted code is much cleaner and follows the
  proper design pattern of consistently using the getter properties throughout the class.

  Summary of the Final Fix

  The fix now properly addresses the original issue while maintaining good code structure:

  1. Fixed the _get() method (lines 145-161)

  - Root cause: Handles both string and dictionary response data from the REST API
  - String case: Parses JSON string and extracts the requested item
  - Dictionary case: Uses .get(item) directly (normal case)
  - Error handling: Returns None for invalid JSON or unsupported data types

  2. Maintained consistent property usage

  - version_major, version_minor, version_patch: All use self.version property consistently
  - Design benefit: Leverages the existing getter property pattern instead of bypassing it
  - Single responsibility: The version property handles the _get() call, other properties
  build upon it

  3. Enhanced version detection logic

  - is_controller_version_4x: Robust parsing with error handling for both ND3 and ND4
  formats
  - Handles edge cases: Letter suffixes, missing data, parsing errors

  The solution now elegantly handles the difference between:
  - ND3: Returns dictionary directly, version format like "12.1.2e"
  - ND4: Returns JSON string, version format like "12.4.1.245"

  This maintains backward compatibility while supporting the new ND4 format, and follows
  the established coding patterns in the class. Thank you for catching that design issue!
Asked Claude to raise a ValueError exception if self._response_data is not a dictionary.

arobel: > I think there may be a much less complex fix for this issue.  In the refresh() method, we
   are raising a ValueError if self.response_data is None (line 143).  I think it might be
  better to also raise a ValueError if self.response_data is not a dictionary.  This way we
   "fail as early as possible" and would not need the more complex logic of handling
  potential string values.  What do you think?
  ⎿ ⧉ Selected 1 lines from plugins/module_utils/common/controller_version.py in Visual
    Studio Code

Claude: >

✻ Thinking…

  The user is making an excellent point! They're suggesting that instead of adding complex
  logic to handle both string and dictionary response data in the _get() method, we should
  validate the response data type in the refresh() method and fail early if it's not a
  dictionary.

  This is a much cleaner approach because:

  1. Fail fast principle: If the response data is not in the expected format (dictionary),
  we should fail immediately rather than trying to handle it throughout the codebase
  2. Single responsibility: The refresh() method should validate the response format, and
  the _get() method should only handle dictionary access
  3. Consistency: All other methods in the class expect self.response_data to be a
  dictionary, so we should enforce this contract
  4. Simplicity: Much simpler than the complex string/dict handling logic I added

  Looking at the current code in the refresh() method:
  self._response_data = self.rest_send.response_current.get("DATA")
  if self.response_data is None:
      msg = f"{self.class_name}.refresh() failed: response "
      msg += "does not contain DATA key. Controller response: "
      msg += f"{self.rest_send.response_current}"
      raise ValueError(msg)

  They're suggesting we add another check right after this to ensure self.response_data is
  a dictionary. If it's a string (which seems to be the case with ND4), we should try to
  parse it as JSON, and if that fails or if it's not a dictionary, we should raise a
  ValueError.

  This is a much better approach! Let me implement this.

⏺ You're absolutely right! That's a much cleaner and more elegant solution. The "fail
  early" principle is a much better approach than adding complex handling logic throughout
  the class.

  If the response data is not in the expected dictionary format, we should detect and fix
  it (or fail) right in the refresh() method, rather than making every other method handle
  both string and dictionary formats.

  Let me implement this simpler fix:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  User rejected update to plugins/module_utils/common/controller_version.py
       141                msg += "does not contain DATA key. Controller response: "
       142                msg += f"{self.rest_send.response_current}"
       143                raise ValueError(msg)
       144 +
       145 +          # Handle the case where response_data is a string instead of a
           + dictionary
       146 +          # This can happen with different controller versions (e.g., ND4 vs
           + ND3)
       147 +          if isinstance(self._response_data, str):
       148 +              import json
       149 +              try:
       150 +                  self._response_data = json.loads(self._response_data)
       151 +              except json.JSONDecodeError:
       152 +                  msg = f"{self.class_name}.refresh() failed: "
       153 +                  msg += f"Unable to parse response data as JSON. Data:
           + {self._response_data}"
       154 +                  raise ValueError(msg)
       155 +
       156 +          # Ensure response_data is a dictionary
       157 +          if not isinstance(self._response_data, dict):
       158 +              msg = f"{self.class_name}.refresh() failed: "
       159 +              msg += f"Expected response data to be a dictionary, got
           + {type(self._response_data).__name__}. "
       160 +              msg += f"Data: {self._response_data}"
       161 +              raise ValueError(msg)
       162
       163        def _get(self, item):
       164            # Handle the case where response_data might be a string instead of a
             dictionary

arobel: > This proposed change looks good, except that we do not expect ND 4 to ever return a
  string here.  Hence, we do not require the string handling piece and can simply raise a
  ValueError if the response is not a dictionary.
  ⎿ ⧉ Selected 1 lines from plugins/module_utils/common/controller_version.py in Visual
    Studio Code

Claude: >

⏺ You're absolutely right! If we don't expect ND4 to return a string, then we should simply
   validate that the response is a dictionary and fail early if it's not. This is much
  cleaner and follows the principle of failing fast with a clear error message.

  Let me implement the simpler fix:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 7 additions
       141                msg += "does not contain DATA key. Controller response: "
       142                msg += f"{self.rest_send.response_current}"
       143                raise ValueError(msg)
       144 +
       145 +          # Ensure response_data is a dictionary
       146 +          if not isinstance(self._response_data, dict):
       147 +              msg = f"{self.class_name}.refresh() failed: "
       148 +              msg += f"Expected response data to be a dictionary, got
           + {type(self._response_data).__name__}. "
       149 +              msg += f"Data: {self._response_data}"
       150 +              raise ValueError(msg)
       151
       152        def _get(self, item):
       153            # Handle the case where response_data might be a string instead of a
             dictionary

⏺ Now I need to revert the complex _get() method back to its original simple form since
  we're handling the data type validation upfront:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 1 addition and 17
     removals
       150                raise ValueError(msg)
       151
       152        def _get(self, item):
       153 -          # Handle the case where response_data might be a string instead of a
     dictionary
           - dictionary
       154 -          if isinstance(self.response_data, str):
       155 -              # If response_data is a string, try to parse it as JSON
       156 -              import json
       153 +          return
     self.conversion.make_none(self.conversion.make_boolean(self.response_data.get(item)))
           + ponse_data.get(item)))
       154
       155 -              try:
       156 -                  data = json.loads(self.response_data)
       157 -                  return
           - self.conversion.make_none(self.conversion.make_boolean(data.get(item)))
       158 -              except json.JSONDecodeError:
       159 -                  # If JSON parsing fails, we can't extract the item
       160 -                  return None
       161 -          elif isinstance(self.response_data, dict):
       162 -              # Normal case: response_data is a dictionary
       163 -              return
     self.conversion.make_none(self.conversion.make_boolean(self
           - .response_data.get(item)))
       164 -          else:
       165 -              # If response_data is neither string nor dict, return None
       166 -              return None
       167 -
       155        @Property
       156        def dev(self):
       157            """

⏺ Perfect! Now let me run the formatter and check for any issues:

⏺ Bash(black -v -l160 plugins/module_utils/common/controller_version.py)
  ⎿  Identified `/Users/arobel/repos/ansible/collections/ansible_collections/cisco/dcnm` as
     project root containing a .git directory.
     Found input source: "plugins/module_utils/common/controller_version.py"
     … +4 lines (ctrl+r to expand)
  ⎿  Found 1 new diagnostic issue in 1 file (ctrl-r to expand)

⏺ Bash(flake8 plugins/module_utils/common/controller_version.py)
  ⎿  (No content)

⏺ Excellent! No flake8 issues. Let me run a quick test to make sure everything works
  correctly:

⏺ Bash(python3 -c "
      # Test the final simplified logic…)
  ⎿  === Test 1: Valid dictionary ===
     Testing with data type: dict
     Data: {'version': '12.1.2e', 'mode': 'LAN'}
     … +18 lines (ctrl+r to expand)

⏺ Perfect! The logic works correctly. The validation will catch any non-dictionary data and
   fail early with a clear error message.

  Summary

  You were absolutely right! This is a much cleaner and more maintainable solution. Here's
  what the final fix does:

  Simple and Effective Fix

  1. Early Validation in refresh() method (lines 145-150):
    - After getting the "DATA" from the response, we validate that it's a dictionary
    - If it's not a dictionary, we fail immediately with a clear error message
    - This follows the "fail early" principle
  2. Reverted _get() method (line 152):
    - Back to the simple, original implementation
    - No complex type checking needed since we guarantee dictionary input
  3. Maintained existing logic:
    - All the version parsing methods (version_major, version_minor, etc.) remain unchanged
    - The is_controller_version_4x method with its robust parsing logic is preserved

  Benefits of This Approach

  - Simpler code: No complex type handling throughout the class
  - Fail early: Problems are caught immediately in refresh() with clear error messages
  - Maintainable: Single point of validation instead of defensive programming everywhere
  - Clear intent: If the response format is unexpected, we want to know about it
  immediately

  Error Handling

  If there's actually an issue with the response format from ND4, we'll get a clear error
  message like:
  ControllerVersion.refresh() failed: Expected response data to be a dictionary, got str.
  Data: {"version": "12.4.1.245", ...}

  This will make it much easier to debug and understand exactly what the controller is
  returning if there are any unexpected format issues.

  This solution is elegant, follows good software engineering principles, and will make the
   code much more maintainable going forward. Great suggestion!
@allenrobel allenrobel self-assigned this Jul 14, 2025
@allenrobel allenrobel added the ready for review PR is ready to be reviewed label Jul 14, 2025
arobel: > Two more changes that might be helpful.  Both implement "fail early" tactics.

  1. Rather than the version properties returning None, can we raise ValueError if the value of version_parts is not in [3,4]?
   If we ever get version formats other than w.x.y or w.x.y.z, we can update the checks.

  2. Can we raise ValueError, with approprate error message,  in self.version if self._get("version") returns None?
  ⎿  ⧉ Selected 1 lines from plugins/module_utils/common/controller_version.py in Visual Studio Code

Claude: >

⏺ Excellent suggestions! Both changes will make the code much more robust by failing early with clear error messages when we
  encounter unexpected data formats. Let me implement these changes:

  1. First, let's add validation in the version property to fail if version is None:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 7 additions and 3 removals
       256        def version(self):
       257            """
       258            Return the controller version, if it exists.
       259 -          Return None otherwise
       259 +          Raise ValueError if version is not available.
       260
       261            Possible values:
       262                version, e.g. "12.1.2e"
       263 -              None
       263            """
       264 -          return self._get("version")
       264 +          version = self._get("version")
       265 +          if version is None:
       266 +              msg = f"{self.class_name}.version: "
       267 +              msg += "Version information not available in controller response"
       268 +              raise ValueError(msg)
       269 +          return version
       270
       271        @Property
       272        def version_major(self):

⏺ 2. Now let's update the version parsing methods to validate that we have exactly 3 or 4 parts:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 11 additions and 8 removals
       271        @Property
       272        def version_major(self):
       273            """
       274 -          Return the controller major version as a string, if it exists.
       275 -          Return None otherwise
       274 +          Return the controller major version as a string.
       275 +          Raise ValueError if version format is unexpected.
       276
       277            We are assuming semantic versioning based on:
       278            https://semver.org
       279
       280 +          Expected formats:
       281 +              w.x.y (3 parts) or w.x.y.z (4 parts)
       282 +
       283            Possible values:
       284                if version is 12.1.2e, return "12"
       285                if version is 12.4.1.245, return "12"
       286 -              None
       286            """
       287 -          if self.version is None:
       288 -              return None
       287            version_parts = self.version.split(".")
       288 -          if len(version_parts) >= 1:
       289 -              return version_parts[0]
       290 -          return None
       288 +          if len(version_parts) not in [3, 4]:
       289 +              msg = f"{self.class_name}.version_major: "
       290 +              msg += f"Unexpected version format '{self.version}'. "
       291 +              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       292 +              raise ValueError(msg)
       293 +          return version_parts[0]
       294
       295        @Property
       296        def version_minor(self):

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 11 additions and 8 removals
       295        @Property
       296        def version_minor(self):
       297            """
       298 -          Return the controller minor version as a string, if it exists.
       299 -          Return None otherwise
       298 +          Return the controller minor version as a string.
       299 +          Raise ValueError if version format is unexpected.
       300
       301            We are assuming semantic versioning based on:
       302            https://semver.org
       303
       304 +          Expected formats:
       305 +              w.x.y (3 parts) or w.x.y.z (4 parts)
       306 +
       307            Possible values:
       308                if version is 12.1.2e, return "1"
       309                if version is 12.4.1.245, return "4"
       310 -              None
       310            """
       311 -          if self.version is None:
       312 -              return None
       311            version_parts = self.version.split(".")
       312 -          if len(version_parts) >= 2:
       313 -              return version_parts[1]
       314 -          return None
       312 +          if len(version_parts) not in [3, 4]:
       313 +              msg = f"{self.class_name}.version_minor: "
       314 +              msg += f"Unexpected version format '{self.version}'. "
       315 +              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       316 +              raise ValueError(msg)
       317 +          return version_parts[1]
       318
       319        @Property
       320        def version_patch(self):

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 11 additions and 8 removals
       319        @Property
       320        def version_patch(self):
       321            """
       322 -          Return the controller patch version as a string, if it exists.
       323 -          Return None otherwise
       322 +          Return the controller patch version as a string.
       323 +          Raise ValueError if version format is unexpected.
       324
       325            We are assuming semantic versioning based on:
       326            https://semver.org
       327
       328 +          Expected formats:
       329 +              w.x.y (3 parts) or w.x.y.z (4 parts)
       330 +
       331            Possible values:
       332                if version is 12.1.2e, return "2e"
       333                if version is 12.4.1.245, return "1"
       334 -              None
       334            """
       335 -          if self.version is None:
       336 -              return None
       335            version_parts = self.version.split(".")
       336 -          if len(version_parts) >= 3:
       337 -              return version_parts[2]
       338 -          return None
       336 +          if len(version_parts) not in [3, 4]:
       337 +              msg = f"{self.class_name}.version_patch: "
       338 +              msg += f"Unexpected version format '{self.version}'. "
       339 +              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       340 +              raise ValueError(msg)
       341 +          return version_parts[2]
       342
       343        @Property
       344        def is_controller_version_4x(self) -> bool:

⏺ Now let me run the formatter and check for any issues:

⏺ Bash(black -v -l160 plugins/module_utils/common/controller_version.py)
  ⎿  No (tell Claude what to do differently)

Stopped Claude here…

arobel: > One more suggestion.  Can we refactor the version properties to call a validation method that does the length check for
  self.version?  This way, we don't duplicate the code in each of these properties.

⏺ Excellent suggestion! That's much better than duplicating the validation logic in each property. Let me refactor this to use
  a common validation method:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 21 additions
       152        def _get(self, item):
       153            return self.conversion.make_none(self.conversion.make_boolean(self.response_data.get(item)))
       154
       155 +      def _validate_and_split_version(self):
       156 +          """
       157 +          Validate version format and return split version parts.
       158 +
       159 +          Expected formats:
       160 +              w.x.y (3 parts) or w.x.y.z (4 parts)
       161 +
       162 +          Returns:
       163 +              list: Version parts split by '.'
       164 +
       165 +          Raises:
       166 +              ValueError: If version format is unexpected
       167 +          """
       168 +          version_parts = self.version.split(".")
       169 +          if len(version_parts) not in [3, 4]:
       170 +              msg = f"{self.class_name}._validate_and_split_version: "
       171 +              msg += f"Unexpected version format '{self.version}'. "
       172 +              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       173 +              raise ValueError(msg)
       174 +          return version_parts
       175 +
       176        @Property
       177        def dev(self):
       178            """

⏺ Now let me update the version properties to use this common validation method:

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 1 addition and 6 removals
       305                if version is 12.1.2e, return "12"
       306                if version is 12.4.1.245, return "12"
       307            """
       308 -          version_parts = self.version.split(".")
       309 -          if len(version_parts) not in [3, 4]:
       310 -              msg = f"{self.class_name}.version_major: "
       311 -              msg += f"Unexpected version format '{self.version}'. "
       312 -              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       313 -              raise ValueError(msg)
       308 +          version_parts = self._validate_and_split_version()
       309            return version_parts[0]
       310
       311        @Property

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 1 addition and 6 removals
       324                if version is 12.1.2e, return "1"
       325                if version is 12.4.1.245, return "4"
       326            """
       327 -          version_parts = self.version.split(".")
       328 -          if len(version_parts) not in [3, 4]:
       329 -              msg = f"{self.class_name}.version_minor: "
       330 -              msg += f"Unexpected version format '{self.version}'. "
       331 -              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       332 -              raise ValueError(msg)
       327 +          version_parts = self._validate_and_split_version()
       328            return version_parts[1]
       329
       330        @Property

⏺ Update(plugins/module_utils/common/controller_version.py)
  ⎿  Updated plugins/module_utils/common/controller_version.py with 1 addition and 6 removals
       343                if version is 12.1.2e, return "2e"
       344                if version is 12.4.1.245, return "1"
       345            """
       346 -          version_parts = self.version.split(".")
       347 -          if len(version_parts) not in [3, 4]:
       348 -              msg = f"{self.class_name}.version_patch: "
       349 -              msg += f"Unexpected version format '{self.version}'. "
       350 -              msg += f"Expected 3 or 4 parts (w.x.y or w.x.y.z), got {len(version_parts)} parts"
       351 -              raise ValueError(msg)
       346 +          version_parts = self._validate_and_split_version()
       347            return version_parts[2]
       348
       349        @Property

⏺ Perfect! Now let me run the formatter and check for any issues:

⏺ Bash(black -v -l160 plugins/module_utils/common/controller_version.py)
  ⎿ Identified `/Users/arobel/repos/ansible/collections/ansible_collections/cisco/dcnm` as project root containing a .git direc
    tory.
    Found input source: "plugins/module_utils/common/controller_version.py"
    … +4 lines (ctrl+r to expand)

⏺ Bash(flake8 plugins/module_utils/common/controller_version.py)
  ⎿  (No content)

⏺ Excellent! Let me run a quick test to make sure the refactored logic works correctly:

⏺ Bash(python3 -c "
      # Test the refactored validation logic…)
  ⎿  === Testing ND3 format (3 parts) ===
     Version: 12.1.2e
     Major: 12
     … +21 lines (ctrl+r to expand)

⏺ Perfect! The refactored code works exactly as expected.

  Summary

  Excellent suggestions! The final implementation now follows all the "fail early" principles with clean, maintainable code:

  Key Improvements

  1. Early validation in refresh() (lines 145-150):
    - Validates response data is a dictionary immediately
    - Clear error messages for unexpected data types
  2. Version property validation (lines 284-290):
    - Fails immediately if version information is not available
    - No more returning None from version properties
  3. Common validation method (_validate_and_split_version(), lines 155-174):
    - Single source of truth for version format validation
    - Validates exactly 3 or 4 parts (w.x.y or w.x.y.z)
    - Clear error messages with expected vs actual format
  4. Simplified version properties (lines 308-347):
    - version_major, version_minor, version_patch all use the common validation
    - No duplicated code
    - Clean, readable implementation

  Benefits of This Approach

  - Fail Early: Problems are caught at the earliest possible point with clear error messages
  - DRY Principle: No duplicated validation logic across multiple properties
  - Maintainable: Easy to modify version format requirements in one place
  - Clear Intent: If we encounter unexpected version formats, we want to know immediately
  - Robust: Handles both ND3 ("12.1.2e") and ND4 ("12.4.1.245") formats correctly

  Error Messages

  The code now provides clear, actionable error messages:
  - Response format issues: "Expected response data to be a dictionary, got str"
  - Missing version: "Version information not available in controller response"
  - Invalid format: "Unexpected version format '12.1'. Expected 3 or 4 parts (w.x.y or w.x.y.z), got 2 parts"

  This solution is much more robust and maintainable than the original approach of returning None values and handling edge
  cases throughout the codebase. Great suggestions for improving the code quality!
1. Update ControllerValue unit tests

1a. Expect ValueError if None is returned.
1b. Add tests for ND 4.1 version format  e.g. 12.4.1.245
1c. black -l 160 reformatted import statements
arobel > Couple more changes to make to the method is_controller_version_4x
  in file @plugins/module_utils/common/controller_version.py

  1. On line 364, we should raise ValueError if either major or minor
  are None since we expect this never to happen.

  2. There is no need for the code in the else block at line 367 since
   self.version_minor will never contain non-digit characters (only
  self.version_patch might contain non-digit characters).
  ⎿ Read plugins/module_utils/common/controller_version.py (397 lines)

Claude >

  Excellent observations! The final changes make the
  is_controller_version_4x method much cleaner and more reliable:

  Key Improvements Made

  1. Fail Early on None Values (lines 364-368):
    - Added explicit check to raise ValueError if major or minor are
  None
    - This should never happen due to our early validation, but if it
  does, we want to know immediately
    - Clear error message indicating what went wrong
  2. Simplified Logic (lines 370-374):
    - Removed unnecessary string parsing logic for extracting numeric
  parts
    - Since version_minor is always the second part of a validated
  version string, it will always be numeric
    - Only version_patch might contain letters (like "2e"), but we
  don't use that in this method

  Benefits of These Changes

  - Cleaner Code: Removed ~15 lines of unnecessary string parsing
  logic
  - More Reliable: Direct integer conversion of version_minor since
  it's guaranteed to be numeric
  - Better Error Handling: Explicit failure if we encounter unexpected
   None values
  - Consistent with Design: Follows the "fail early" principle
  throughout the class

  Logic Summary

  The method now simply:
  1. Gets major and minor version parts (guaranteed to be non-None
  strings)
  2. Converts them directly to integers
  3. Returns False if major=12 and minor<3, otherwise True

  This correctly identifies:
  - ND3 versions like "12.1.2e", "12.2.1e" → False (not 4.x)
  - ND4 versions like "12.4.1.245", "12.3.0" → True (4.x or higher)
  - Future versions like "13.0.0" → True (4.x or higher)

  The code is now much more robust, maintainable, and follows
  consistent error handling patterns throughout the class. Great
  suggestions for simplifying and improving the logic!
arobel > One final change to is_controller_version_4x method in
  @plugins/module_utils/common/controller_version.py  If parsing fails (ValueError, TypeError) on
   line 375, we should re-raise this exception as a ValueError rather than return True.  We do
  not want to assume the version if we cannot determine the version.
  ⎿  Read plugins/module_utils/common/controller_version.py (388 lines)

Claude >

  Summary

  Perfect final change! This completes the robust error handling approach throughout the class:

  Key Change Made

  Re-raise Exceptions Instead of Assuming (lines 375-379):
  - Changed from logging warning and returning True to re-raising the exception as ValueError
  - Uses raise ValueError(msg) from e to preserve the original exception chain
  - Clear error message indicating what went wrong during version parsing

  Benefits of This Final Change

  1. Consistent Error Handling: All methods now fail explicitly rather than making assumptions
  2. No Silent Failures: If version parsing fails, the caller knows immediately
  3. Proper Exception Chaining: The original exception is preserved using from e
  4. Clear Error Messages: Descriptive error message indicating the specific failure

  Complete Error Handling Strategy

  The class now follows a comprehensive "fail early and explicitly" approach:

  1. refresh(): Validates response data format immediately
  2. version: Fails if version information is not available
  3. _validate_and_split_version(): Validates version format (3 or 4 parts)
  4. version_major/minor/patch: Use common validation, guaranteed to work
  5. is_controller_version_4x: Re-raises any parsing errors instead of assuming

  Error Flow

  If any step fails, the error propagates up with clear messages:
  - Response format issues: "Expected response data to be a dictionary"
  - Missing version: "Version information not available"
  - Invalid format: "Unexpected version format '12.1'. Expected 3 or 4 parts"
  - Parsing errors: "Error parsing version 12.1.2e: invalid literal for int()"

  This approach ensures that:
  - Problems are detected at the earliest possible point
  - All failures provide clear, actionable error messages
  - No assumptions are made about version information
  - The calling code can handle errors appropriately

  The is_controller_version_4x method now properly fails if it cannot determine the version,
  which is much safer than silently assuming it's a newer version. This fix should resolve the
  original traceback issue while maintaining robust error handling throughout the class.
1. tests/unit/module_utils/common/test_controller_version.py

1a. We were not correctly checking for ValueError in the instance.version_* tests

1b. mode test.  Modified to validate that ValueError is raised if mode is missing in the controller response

2. plugins/module_utils/common/controller_version.py

2a. Modify _get() to return None only if a parameter is missing in the controller response.

2b. @mode, modify to raise ValueError if the value is None (indicating key is missing in the controller response)

2c. Update docstrings and comments.

3. tests/unit/module_utils/common/fixtures/responses_ep_version.json

3a. Sorry, large diff.  Sorted the JSON.

3b. Added TEST_NOTES to several of the mode fixtures (and added fixture for ND 4)

- test_controller_version_00200a
- test_controller_version_00200b
- test_controller_version_00200c
Copy link
Collaborator

@mikewiebe mikewiebe left a comment

Choose a reason for hiding this comment

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

LGTM

@mikewiebe mikewiebe merged commit 8334758 into develop Jul 16, 2025
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
ready for review PR is ready to be reviewed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

dcnm_fabric (and others?) fails for ND 4.1 with Fabric Controller 12.4+
2 participants