nac-test-pyats-common 0.1.1__py3-none-any.whl → 0.2.1__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.
@@ -29,7 +29,12 @@ __version__ = "1.0.0"
29
29
 
30
30
  # Public API - Import from subpackages
31
31
  from nac_test_pyats_common.aci import APICAuth, APICTestBase
32
- from nac_test_pyats_common.catc import CatalystCenterAuth, CatalystCenterTestBase
32
+ from nac_test_pyats_common.catc import (
33
+ CatalystCenterAuth,
34
+ CatalystCenterDeviceResolver,
35
+ CatalystCenterSSHTestBase,
36
+ CatalystCenterTestBase,
37
+ )
33
38
  from nac_test_pyats_common.sdwan import (
34
39
  SDWANDeviceResolver,
35
40
  SDWANManagerAuth,
@@ -49,4 +54,6 @@ __all__ = [
49
54
  # Catalyst Center
50
55
  "CatalystCenterAuth",
51
56
  "CatalystCenterTestBase",
57
+ "CatalystCenterSSHTestBase",
58
+ "CatalystCenterDeviceResolver",
52
59
  ]
@@ -14,17 +14,29 @@ The module implements a two-tier API design:
14
14
 
15
15
  This design ensures efficient token management by reusing valid tokens and only
16
16
  re-authenticating when necessary, reducing unnecessary API calls to the APIC controller.
17
+
18
+ Note on Fork Safety:
19
+ This module uses urllib instead of httpx for synchronous authentication requests.
20
+ httpx is NOT fork-safe on macOS - creating httpx.Client after fork() causes
21
+ silent crashes due to OpenSSL threading issues. urllib uses simpler primitives
22
+ that work correctly after fork().
17
23
  """
18
24
 
19
- import httpx
20
- from nac_test.pyats_core.common.auth_cache import (
21
- AuthCache, # type: ignore[import-untyped]
25
+ import os
26
+
27
+ from nac_test.pyats_core.common.auth_cache import AuthCache
28
+ from nac_test.pyats_core.common.subprocess_auth import (
29
+ SubprocessAuthError, # noqa: F401 - re-exported for callers to catch
30
+ execute_auth_subprocess,
22
31
  )
23
32
 
24
33
  # Default token lifetime for APIC authentication tokens in seconds
25
34
  # APIC tokens are typically valid for 10 minutes (600 seconds) by default
26
35
  APIC_TOKEN_LIFETIME_SECONDS: int = 600
27
36
 
37
+ # HTTP timeout for authentication request
38
+ AUTH_REQUEST_TIMEOUT_SECONDS: float = 30.0
39
+
28
40
 
29
41
  class APICAuth:
30
42
  """APIC-specific authentication implementation with token caching.
@@ -44,19 +56,27 @@ class APICAuth:
44
56
  """
45
57
 
46
58
  @staticmethod
47
- def authenticate(url: str, username: str, password: str) -> tuple[str, int]:
59
+ def authenticate(
60
+ url: str, username: str, password: str, verify_ssl: bool = False
61
+ ) -> tuple[str, int]:
48
62
  """Perform direct APIC authentication and obtain a session token.
49
63
 
50
64
  This method performs a direct authentication request to the APIC controller
51
65
  using the provided credentials. It returns both the token and its lifetime
52
66
  for proper cache management.
53
67
 
68
+ Internally uses execute_auth_subprocess() to run authentication in a clean
69
+ subprocess, avoiding the macOS fork+SSL crash issue where SSL operations
70
+ crash after fork().
71
+
54
72
  Args:
55
73
  url: Base URL of the APIC controller (e.g., "https://apic.example.com").
56
74
  Should not include trailing slashes or API paths.
57
75
  username: APIC username for authentication. This should be a valid user
58
76
  configured in the APIC with appropriate permissions.
59
77
  password: Password for the specified APIC user account.
78
+ verify_ssl: Whether to verify SSL certificates. Defaults to False for
79
+ backward compatibility with lab environments using self-signed certs.
60
80
 
61
81
  Returns:
62
82
  A tuple containing:
@@ -65,50 +85,100 @@ class APICAuth:
65
85
  - expires_in (int): Token lifetime in seconds (typically 600 seconds).
66
86
 
67
87
  Raises:
68
- httpx.HTTPStatusError: If the APIC returns a non-2xx status code,
69
- typically indicating authentication failure (401) or server error.
70
- httpx.RequestError: If the request fails due to network issues,
71
- connection timeouts, or other transport-level problems.
72
- KeyError: If the APIC response doesn't contain the expected JSON
73
- structure with token information.
74
- ValueError: If the APIC response contains malformed JSON that cannot
75
- be parsed.
88
+ SubprocessAuthError: If authentication subprocess fails or returns an error.
89
+ ValueError: If the APIC response contains malformed JSON or unexpected
90
+ structure that cannot be properly parsed.
91
+
92
+ Note:
93
+ SSL verification defaults to disabled to handle self-signed certificates
94
+ commonly used in lab and development APIC deployments. Set verify_ssl=True
95
+ for production environments with proper certificate validation.
76
96
  """
77
- # NOTE: SSL verification is disabled (verify=False) to handle self-signed
78
- # certificates commonly used in lab and development APIC deployments.
79
- # In production environments, proper certificate validation should be enabled
80
- # by either installing the APIC certificate in the trust store or providing
81
- # a custom CA bundle via the verify parameter.
82
- with httpx.Client(verify=False) as client:
83
- response = client.post(
84
- f"{url}/api/aaaLogin.json",
85
- json={"aaaUser": {"attributes": {"name": username, "pwd": password}}},
86
- )
87
- response.raise_for_status()
88
-
89
- # Parse the APIC response and extract the token
90
- # Response structure:
91
- # {"imdata": [{"aaaLogin": {"attributes": {"token": "..."}}}]}
92
- try:
93
- response_data = response.json()
94
- token = response_data["imdata"][0]["aaaLogin"]["attributes"]["token"]
95
- except (KeyError, IndexError) as e:
96
- # Provide a more informative error message for malformed responses
97
- raise ValueError(
98
- f"APIC returned unexpected response structure. "
99
- f"Expected JSON with 'imdata[0].aaaLogin.attributes.token' path. "
100
- f"Actual response: {response.text[:500]}"
101
- ) from e
102
- except ValueError as e:
103
- # Handle JSON parsing errors explicitly
104
- raise ValueError(
105
- f"APIC returned invalid JSON response: {response.text[:500]}"
106
- ) from e
107
-
108
- return token, APIC_TOKEN_LIFETIME_SECONDS
97
+ # Build auth parameters for subprocess
98
+ auth_params = {
99
+ "url": url,
100
+ "username": username,
101
+ "password": password,
102
+ "timeout": AUTH_REQUEST_TIMEOUT_SECONDS,
103
+ "verify_ssl": verify_ssl,
104
+ }
105
+
106
+ # APIC-specific authentication logic
107
+ # This script assumes `params` dict is already loaded by execute_auth_subprocess
108
+ auth_script_body = """
109
+ import json
110
+ import ssl
111
+ import urllib.request
112
+ import urllib.error
113
+
114
+ url = params["url"]
115
+ username = params["username"]
116
+ password = params["password"]
117
+ timeout = params["timeout"]
118
+ verify_ssl = params["verify_ssl"]
119
+
120
+ try:
121
+ # Create SSL context
122
+ ssl_context = ssl.create_default_context()
123
+ if not verify_ssl:
124
+ ssl_context.check_hostname = False
125
+ ssl_context.verify_mode = ssl.CERT_NONE
126
+
127
+ # Build JSON payload for APIC authentication
128
+ payload = json.dumps({
129
+ "aaaUser": {
130
+ "attributes": {
131
+ "name": username,
132
+ "pwd": password
133
+ }
134
+ }
135
+ }).encode("utf-8")
136
+
137
+ # Create request with proper headers
138
+ request = urllib.request.Request(
139
+ f"{url}/api/aaaLogin.json",
140
+ data=payload,
141
+ headers={"Content-Type": "application/json"},
142
+ method="POST"
143
+ )
144
+
145
+ # Create HTTPS handler with SSL context
146
+ https_handler = urllib.request.HTTPSHandler(context=ssl_context)
147
+ opener = urllib.request.build_opener(https_handler)
148
+
149
+ # Execute authentication request
150
+ response = opener.open(request, timeout=timeout)
151
+ response_body = response.read().decode("utf-8")
152
+ response_data = json.loads(response_body)
153
+
154
+ # Extract token from response
155
+ token = response_data["imdata"][0]["aaaLogin"]["attributes"]["token"]
156
+ result = {"token": token}
157
+
158
+ except urllib.error.HTTPError as e:
159
+ error_body = e.read().decode("utf-8") if e.fp else ""
160
+ result = {
161
+ "error": f"HTTP {e.code}: {e.reason}",
162
+ "response": error_body[:500]
163
+ }
164
+ except (KeyError, IndexError) as e:
165
+ result = {
166
+ "error": f"Unexpected response structure: {str(e)}",
167
+ "response": response_body[:500] if "response_body" in dir() else ""
168
+ }
169
+ except Exception as e:
170
+ result = {"error": str(e)}
171
+ """
172
+
173
+ # Execute authentication in subprocess
174
+ auth_data = execute_auth_subprocess(auth_params, auth_script_body)
175
+
176
+ return auth_data["token"], APIC_TOKEN_LIFETIME_SECONDS
109
177
 
110
178
  @classmethod
111
- def get_token(cls, url: str, username: str, password: str) -> str:
179
+ def get_token(
180
+ cls, url: str, username: str, password: str, verify_ssl: bool = False
181
+ ) -> str:
112
182
  """Get APIC token with automatic caching and renewal.
113
183
 
114
184
  This is the primary method that consumers should use to obtain APIC tokens.
@@ -126,6 +196,8 @@ class APICAuth:
126
196
  username: APIC username for authentication. This should be a valid user
127
197
  configured in the APIC with appropriate permissions.
128
198
  password: Password for the specified APIC user account.
199
+ verify_ssl: Whether to verify SSL certificates. Defaults to False for
200
+ backward compatibility with lab environments using self-signed certs.
129
201
 
130
202
  Returns:
131
203
  A valid APIC session token that can be used in API requests.
@@ -133,13 +205,9 @@ class APICAuth:
133
205
  API calls to the APIC controller.
134
206
 
135
207
  Raises:
136
- httpx.HTTPStatusError: If the APIC returns a non-2xx status code during
137
- authentication, typically indicating invalid credentials (401) or
138
- server issues (5xx).
139
- httpx.RequestError: If the request fails due to network issues,
140
- connection timeouts, or other transport-level problems.
141
- ValueError: If the APIC response contains malformed or unexpected JSON
142
- structure that cannot be properly parsed.
208
+ SubprocessAuthError: If authentication fails due to invalid credentials,
209
+ network issues, connection timeouts, or APIC server errors. The error
210
+ message will contain details from the authentication subprocess.
143
211
 
144
212
  Examples:
145
213
  >>> # Get a token for APIC access
@@ -159,5 +227,61 @@ class APICAuth:
159
227
  url=url,
160
228
  username=username,
161
229
  password=password,
162
- auth_func=cls.authenticate,
230
+ auth_func=lambda u, un, pw: cls.authenticate(u, un, pw, verify_ssl),
231
+ )
232
+
233
+ @classmethod
234
+ def get_auth(cls) -> str:
235
+ """Get APIC token with automatic caching, using environment variables.
236
+
237
+ This is the primary method that consumers should use to obtain APIC tokens
238
+ when using environment variable configuration. It leverages the AuthCache
239
+ to efficiently manage token lifecycle.
240
+
241
+ Environment Variables Required:
242
+ APIC_URL: Base URL of the APIC controller
243
+ APIC_USERNAME: APIC username for authentication
244
+ APIC_PASSWORD: APIC password for authentication
245
+ APIC_INSECURE: Optional. Set to "True" to disable SSL verification
246
+ (default: True for backward compatibility)
247
+
248
+ Returns:
249
+ A valid APIC session token.
250
+
251
+ Raises:
252
+ ValueError: If required environment variables are not set.
253
+ """
254
+ url = os.environ.get("APIC_URL")
255
+ username = os.environ.get("APIC_USERNAME")
256
+ password = os.environ.get("APIC_PASSWORD")
257
+ insecure = os.environ.get("APIC_INSECURE", "True").lower() in (
258
+ "true",
259
+ "1",
260
+ "yes",
163
261
  )
262
+
263
+ # Validate environment variables and collect missing ones
264
+ missing_vars: list[str] = []
265
+ if not url:
266
+ missing_vars.append("APIC_URL")
267
+ if not username:
268
+ missing_vars.append("APIC_USERNAME")
269
+ if not password:
270
+ missing_vars.append("APIC_PASSWORD")
271
+
272
+ if missing_vars:
273
+ raise ValueError(
274
+ f"Missing required environment variables: {', '.join(missing_vars)}"
275
+ )
276
+
277
+ # Type narrowing: url, username, password are guaranteed to be str
278
+ # We raised ValueError above if any were None/empty
279
+ assert url is not None
280
+ assert username is not None
281
+ assert password is not None
282
+
283
+ # Normalize URL by removing trailing slash
284
+ url = url.rstrip("/")
285
+ verify_ssl = not insecure # APIC_INSECURE=True means verify=False
286
+
287
+ return cls.get_token(url, username, password, verify_ssl)
@@ -34,7 +34,8 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
34
34
 
35
35
  Attributes:
36
36
  token (str): APIC authentication token obtained during setup.
37
- client (httpx.AsyncClient): Wrapped async HTTP client configured for APIC.
37
+ client (httpx.AsyncClient | None): Wrapped async HTTP client configured
38
+ for APIC. Initialized to None, set during run_async_verification_test().
38
39
  controller_url (str): Base URL of the APIC controller (inherited).
39
40
  username (str): APIC username for authentication (inherited).
40
41
  password (str): APIC password for authentication (inherited).
@@ -58,6 +59,8 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
58
59
  self.run_async_verification_test(steps)
59
60
  """
60
61
 
62
+ client: httpx.AsyncClient | None = None # MUST declare at class level
63
+
61
64
  @aetest.setup # type: ignore[misc, untyped-decorator]
62
65
  def setup(self) -> None:
63
66
  """Setup method that extends the generic base class setup.
@@ -65,7 +68,11 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
65
68
  Initializes the APIC test environment by:
66
69
  1. Calling the parent class setup method
67
70
  2. Obtaining an APIC authentication token using file-based locking
68
- 3. Creating and storing an APIC client for use in verification methods
71
+
72
+ Note: Client creation is deferred to run_async_verification_test() to avoid
73
+ macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
74
+ process before entering an async context can cause crashes on macOS due to
75
+ OpenSSL threading primitives that are not fork-safe.
69
76
 
70
77
  The authentication token is obtained through the APICAuth utility which
71
78
  manages token lifecycle and prevents duplicate authentication requests
@@ -74,12 +81,20 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
74
81
  super().setup()
75
82
 
76
83
  # Get shared APIC token using file-based locking
77
- self.token = APICAuth.get_token(
78
- self.controller_url, self.username, self.password
79
- )
80
-
81
- # Store the APIC client for use in verification methods
82
- self.client = self.get_apic_client()
84
+ # This reads from file cache - no httpx client creation here
85
+ try:
86
+ self.token = APICAuth.get_token(
87
+ self.controller_url, self.username, self.password
88
+ )
89
+ except (RuntimeError, ValueError) as e:
90
+ # Convert auth failures to FAILED (not ERRORED) - auth issues are
91
+ # expected failure conditions, not infrastructure errors
92
+ self.token = "" # Ensure attribute exists for cleanup code
93
+ self.failed(f"Authentication failed: {e}")
94
+ return
95
+
96
+ # NOTE: Client creation is deferred to run_async_verification_test()
97
+ # to avoid macOS fork() + httpx/SSL crash issues
83
98
 
84
99
  def get_apic_client(self) -> httpx.AsyncClient:
85
100
  """Get an httpx async client configured for APIC with response tracking.
@@ -114,9 +129,10 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
114
129
  Simple entry point that uses base class orchestration to run async
115
130
  verification tests. This thin wrapper:
116
131
  1. Creates and manages an event loop for async operations
117
- 2. Calls NACTestBase.run_verification_async() to execute tests
118
- 3. Passes results to NACTestBase.process_results_smart() for reporting
119
- 4. Ensures proper cleanup of async resources
132
+ 2. Creates the APIC client (deferred from setup for fork safety)
133
+ 3. Calls NACTestBase.run_verification_async() to execute tests
134
+ 4. Passes results to NACTestBase.process_results_smart() for reporting
135
+ 5. Ensures proper cleanup of async resources
120
136
 
121
137
  The actual verification logic is handled by:
122
138
  - get_items_to_verify() - must be implemented by the test class
@@ -131,10 +147,17 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
131
147
  This method creates its own event loop to ensure compatibility
132
148
  with PyATS synchronous test execution model. The loop and client
133
149
  connections are properly closed after test completion.
150
+
151
+ Client creation is done HERE (not in setup) to avoid macOS fork()
152
+ issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
153
+ before entering an async context can crash on macOS.
134
154
  """
135
155
  loop = asyncio.new_event_loop()
136
156
  asyncio.set_event_loop(loop)
137
157
  try:
158
+ # Create client INSIDE the event loop context to avoid macOS fork+SSL crash
159
+ self.client = self.get_apic_client()
160
+
138
161
  # Call the base class generic orchestration
139
162
  results = loop.run_until_complete(self.run_verification_async())
140
163
 
@@ -142,6 +165,6 @@ class APICTestBase(NACTestBase): # type: ignore[misc]
142
165
  self.process_results_smart(results, steps)
143
166
  finally:
144
167
  # Clean up the APIC client connection
145
- if hasattr(self, "client"):
168
+ if self.client is not None: # MANDATORY: never use hasattr()
146
169
  loop.run_until_complete(self.client.aclose())
147
170
  loop.close()
@@ -3,15 +3,23 @@
3
3
 
4
4
  """Catalyst Center adapter module for NAC PyATS testing.
5
5
 
6
- This module provides Catalyst Center-specific authentication and test base class
7
- implementations for use with the nac-test framework. Catalyst Center (formerly
8
- DNA Center) is Cisco's enterprise network management platform.
6
+ This module provides Catalyst Center-specific authentication, test base classes, and
7
+ device resolver implementations for use with the nac-test framework. It includes support
8
+ for both Catalyst Center API testing and SSH-based device-to-device (D2D) testing.
9
9
 
10
10
  Classes:
11
- CatalystCenterAuth: Token-based authentication with automatic endpoint detection.
12
- CatalystCenterTestBase: Base class for Catalyst Center API tests with tracking.
11
+ CatalystCenterAuth: Token-based authentication with automatic endpoint
12
+ detection.
13
+ CatalystCenterTestBase: Base class for Catalyst Center API tests with
14
+ tracking.
15
+ CatalystCenterSSHTestBase: Base class for Catalyst Center SSH/D2D tests
16
+ with device inventory.
17
+ CatalystCenterDeviceResolver: Resolves device information from the
18
+ Catalyst Center data model.
13
19
 
14
20
  Example:
21
+ For Catalyst Center API testing:
22
+
15
23
  >>> from nac_test_pyats_common.catc import CatalystCenterTestBase
16
24
  >>>
17
25
  >>> class VerifyNetworkDevices(CatalystCenterTestBase):
@@ -23,16 +31,26 @@ Example:
23
31
  ... f"/dna/intent/api/v1/network-device/{item}"
24
32
  ... )
25
33
  ... return response.status_code == 200
26
- ...
34
+
35
+ For SSH/D2D testing:
36
+
37
+ >>> from nac_test_pyats_common.catc import CatalystCenterSSHTestBase
38
+ >>>
39
+ >>> class VerifyInterfaceStatus(CatalystCenterSSHTestBase):
27
40
  ... @aetest.test
28
- ... def verify_devices(self, steps):
29
- ... self.run_async_verification_test(steps)
41
+ ... def verify_interfaces(self, steps, device):
42
+ ... # SSH-based verification
43
+ ... pass
30
44
  """
31
45
 
46
+ from .api_test_base import CatalystCenterTestBase
32
47
  from .auth import CatalystCenterAuth
33
- from .test_base import CatalystCenterTestBase
48
+ from .device_resolver import CatalystCenterDeviceResolver
49
+ from .ssh_test_base import CatalystCenterSSHTestBase
34
50
 
35
51
  __all__ = [
36
52
  "CatalystCenterAuth",
37
53
  "CatalystCenterTestBase",
54
+ "CatalystCenterSSHTestBase",
55
+ "CatalystCenterDeviceResolver",
38
56
  ]
@@ -34,15 +34,16 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
34
34
  response capture. It serves as the foundation for all Catalyst Center-specific
35
35
  API test classes.
36
36
 
37
- The class follows the same pattern as APICTestBase and VManageTestBase for
37
+ The class follows the same pattern as APICTestBase and SDWANManagerTestBase for
38
38
  consistency across NAC architecture adapters. Token refresh is handled
39
39
  automatically by the AuthCache TTL mechanism.
40
40
 
41
41
  Attributes:
42
42
  auth_data (dict): Catalyst Center authentication data containing the
43
43
  token obtained during setup.
44
- client (httpx.AsyncClient): Wrapped async HTTP client configured for
45
- Catalyst Center.
44
+ client (httpx.AsyncClient | None): Wrapped async HTTP client configured for
45
+ Catalyst Center. Initialized to None, set during
46
+ run_async_verification_test().
46
47
  controller_url (str): Base URL of the Catalyst Center controller.
47
48
  verify_ssl (bool): Whether SSL verification is enabled.
48
49
 
@@ -67,6 +68,9 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
67
68
  self.run_async_verification_test(steps)
68
69
  """
69
70
 
71
+ client: httpx.AsyncClient | None = None # MUST declare at class level
72
+ auth_data: dict[str, Any] # Declared at class level for type checker compatibility
73
+
70
74
  @aetest.setup # type: ignore[misc, untyped-decorator]
71
75
  def setup(self) -> None:
72
76
  """Setup method that extends the generic base class setup.
@@ -75,7 +79,11 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
75
79
  1. Calling the parent class setup method
76
80
  2. Obtaining Catalyst Center authentication token using cached auth
77
81
  3. Configuring SSL verification from environment
78
- 4. Creating and storing a Catalyst Center client for use in verification methods
82
+
83
+ Note: Client creation is deferred to run_async_verification_test() to avoid
84
+ macOS fork() issues with httpx/SSL. Creating httpx.AsyncClient in a forked
85
+ process before entering an async context can cause crashes on macOS due to
86
+ OpenSSL threading primitives that are not fork-safe.
79
87
 
80
88
  The authentication token is obtained through the CatalystCenterAuth utility
81
89
  which manages token lifecycle and prevents duplicate authentication requests
@@ -84,7 +92,15 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
84
92
  super().setup()
85
93
 
86
94
  # Get Catalyst Center auth data (token)
87
- self.auth_data = CatalystCenterAuth.get_auth()
95
+ # This reads from file cache - no httpx client creation here
96
+ try:
97
+ self.auth_data = CatalystCenterAuth.get_auth()
98
+ except (RuntimeError, ValueError) as e:
99
+ # Convert auth failures to FAILED (not ERRORED) - auth issues are
100
+ # expected failure conditions, not infrastructure errors
101
+ self.auth_data = {} # Ensure attribute exists for cleanup code
102
+ self.failed(f"Authentication failed: {e}")
103
+ return
88
104
 
89
105
  # Get controller URL from environment
90
106
  self.controller_url = os.environ.get("CC_URL", "").rstrip("/")
@@ -93,8 +109,8 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
93
109
  insecure = os.environ.get("CC_INSECURE", "True").lower() in ("true", "1", "yes")
94
110
  self.verify_ssl = not insecure
95
111
 
96
- # Store the Catalyst Center client for use in verification methods
97
- self.client = self.get_catc_client()
112
+ # NOTE: Client creation is deferred to run_async_verification_test()
113
+ # to avoid macOS fork() + httpx/SSL crash issues
98
114
 
99
115
  def get_catc_client(self) -> httpx.AsyncClient:
100
116
  """Get an httpx async client configured for Catalyst Center.
@@ -144,9 +160,10 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
144
160
  Simple entry point that uses base class orchestration to run async
145
161
  verification tests. This thin wrapper:
146
162
  1. Creates and manages an event loop for async operations
147
- 2. Calls NACTestBase.run_verification_async() to execute tests
148
- 3. Passes results to NACTestBase.process_results_smart() for reporting
149
- 4. Ensures proper cleanup of async resources
163
+ 2. Creates the Catalyst Center client (deferred from setup for fork safety)
164
+ 3. Calls NACTestBase.run_verification_async() to execute tests
165
+ 4. Passes results to NACTestBase.process_results_smart() for reporting
166
+ 5. Ensures proper cleanup of async resources
150
167
 
151
168
  The actual verification logic is handled by:
152
169
  - get_items_to_verify() - must be implemented by the test class
@@ -161,10 +178,17 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
161
178
  This method creates its own event loop to ensure compatibility
162
179
  with PyATS synchronous test execution model. The loop and client
163
180
  connections are properly closed after test completion.
181
+
182
+ Client creation is done HERE (not in setup) to avoid macOS fork()
183
+ issues with httpx/SSL. Creating httpx.AsyncClient after fork() but
184
+ before entering an async context can crash on macOS.
164
185
  """
165
186
  loop = asyncio.new_event_loop()
166
187
  asyncio.set_event_loop(loop)
167
188
  try:
189
+ # Create client INSIDE the event loop context to avoid macOS fork+SSL crash
190
+ self.client = self.get_catc_client()
191
+
168
192
  # Call the base class generic orchestration
169
193
  results = loop.run_until_complete(self.run_verification_async())
170
194
 
@@ -172,6 +196,6 @@ class CatalystCenterTestBase(NACTestBase): # type: ignore[misc]
172
196
  self.process_results_smart(results, steps)
173
197
  finally:
174
198
  # Clean up the Catalyst Center client connection
175
- if hasattr(self, "client"):
199
+ if self.client is not None: # MANDATORY: never use hasattr()
176
200
  loop.run_until_complete(self.client.aclose())
177
201
  loop.close()