Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 4 (#2199). osism/utils/__init__.py is 672 LOC and bundles several distinct concerns. Splitting it across three sub-issues keeps each test module manageable:
- This issue: connection / client initialization and the lazy
__getattr__ indirection.
- Companion issue: task output streaming + ansible/redis helpers.
- Companion issue: concurrency primitives (
RedisSemaphore, redlock) and task locks.
Scope
Add tests/unit/utils/test_init_connections.py covering the connection-init helpers and lazy attribute resolution in osism/utils/__init__.py.
Test targets
_init_redis() — __init__.py:22
Patch osism.utils.settings for REDIS_HOST / REDIS_PORT / REDIS_DB, and patch the imported redis.Redis class via mocker.patch("redis.Redis") so no real connection is made. Reset module global _redis between tests via autouse fixture.
- First call → instantiates
Redis(host, port, db, socket_keepalive=True) and calls .ping(); caches the instance in _redis
- Second call → returns the cached instance (no second
Redis(...) instantiation)
ping() raises → exception propagated (no swallowing)
_init_nb() — __init__.py:37
Patch get_netbox_connection. Reset _nb/_nb_initialized between tests.
- First call → delegates to
get_netbox_connection(NETBOX_URL, NETBOX_TOKEN, IGNORE_SSL_ERRORS)
- Sets
_nb_initialized=True even if get_netbox_connection returns None (e.g. URL/token missing)
- Subsequent calls → return cached
_nb (no second call to get_netbox_connection)
_init_secondary_nb_list() — __init__.py:47
Patch osism.utils.settings.NETBOX_SECONDARIES and get_netbox_connection. Reset _secondary_nb_list and _secondary_nb_initialized between tests.
- Two valid entries → returns list with two NetBox API objects (verify two
get_netbox_connection calls with the right args including IGNORE_SSL_ERRORS=True default)
- Entry with
NETBOX_NAME and NETBOX_SITE → those attributes are set on the returned object
- Setting is empty string / parses to
None → TypeError caught (non-list), _secondary_nb_list=[], error logged
- Setting parses to a dict (not a list) →
TypeError raised internally, caught, logged
- Element is not a dict (e.g. a string) →
TypeError caught, logged
- Element with unknown key (e.g.
NETBOX_FOO) → ValueError caught, logged
- Element missing
NETBOX_URL (or empty) → ValueError caught, logged
- Element missing
NETBOX_TOKEN (or empty/whitespace-only after strip) → ValueError caught, logged
IGNORE_SSL_ERRORS defaulted to True when omitted
NETBOX_TOKEN with surrounding whitespace → stripped before passing
- Invalid YAML →
yaml.YAMLError caught, _secondary_nb_list=[]
- After error →
_secondary_nb_initialized=True (cached negative result)
_get_timeout_http_adapter_class() — __init__.py:180
- First call → returns the
_TimeoutHTTPAdapter class (subclass of HTTPAdapter)
- Second call → returns the same class (cached on module global)
- Instance:
send(request, timeout=None) falls back to self.timeout; existing timeout=5 kwarg is preserved
NetBoxSessionManager.get_session(ignore_ssl_errors, timeout) — __init__.py:224
Patch requests.Session and urllib3.disable_warnings. Reset NetBoxSessionManager._session/_lock between tests.
- First call → creates
Session(), mounts the timeout adapter on http:// and https:// (verify mount calls)
ignore_ssl_errors=True → urllib3.disable_warnings() called and session.verify=False
ignore_ssl_errors=False → session.verify not modified, disable_warnings not called
- Subsequent call → returns cached session (no second
Session() instantiation)
- Custom
timeout=30 → propagated into the adapter's timeout
NetBoxSessionManager.close_session() — __init__.py:255
- After call:
_session.close() invoked and _session=None
- Calling again with no session → no-op (no error)
cleanup_netbox_sessions() — __init__.py:263
- Delegates to
NetBoxSessionManager.close_session() (verify the call)
get_netbox_connection(netbox_url, netbox_token, ignore_ssl_errors=False, timeout=20) — __init__.py:268
Patch pynetbox.api, NetBoxSessionManager.get_session, atexit.register. Reset _cleanup_registered between tests.
- Both URL and token provided → creates
pynetbox.api(url, token=token), sets nb.http_session to the shared session, registers atexit.register(cleanup_netbox_sessions) exactly once across multiple invocations
- URL missing → returns
None, no pynetbox.api call
- Token missing → returns
None
pynetbox.api returns falsy → atexit.register not invoked, but caller still gets the falsy value back
ignore_ssl_errors/timeout propagated to NetBoxSessionManager.get_session
- Second call →
atexit.register not called again (_cleanup_registered short-circuit)
get_openstack_connection() — __init__.py:304
Patch openstack.connect and keystoneauth1.exceptions.auth_plugins.MissingRequiredOptions (just import).
openstack.connect() succeeds → returns the connection
openstack.connect() raises MissingRequiredOptions → wrapped in RuntimeError with "missing required authentication options"
- Other exceptions propagate unchanged
__getattr__(name) — __init__.py:659
Reset globals() redis/nb/secondary_nb_list between tests.
osism.utils.redis → calls _init_redis, caches in globals()["redis"], second access uses the cached attribute (no second _init_redis call)
osism.utils.nb → calls _init_nb, caches in globals()["nb"]
osism.utils.secondary_nb_list → calls _init_secondary_nb_list, caches
- Unknown name (
osism.utils.foo) → raises AttributeError with the expected message
Mocking hints
- Use a fixture (
autouse=True) to reset module globals: _redis, _nb, _nb_initialized, _secondary_nb_list, _secondary_nb_initialized, _cleanup_registered, _TimeoutHTTPAdapterClass, NetBoxSessionManager._session, NetBoxSessionManager._lock, plus delete globals()["redis"|"nb"|"secondary_nb_list"] if present.
- Patch settings via
mocker.patch.multiple("osism.utils.settings", NETBOX_URL="https://nb", NETBOX_TOKEN="t", ...).
- For
NETBOX_SECONDARIES, set the setting to a YAML string (the production code calls yaml.safe_load(settings.NETBOX_SECONDARIES)).
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 4 (#2199).
osism/utils/__init__.pyis 672 LOC and bundles several distinct concerns. Splitting it across three sub-issues keeps each test module manageable:__getattr__indirection.RedisSemaphore, redlock) and task locks.Scope
Add
tests/unit/utils/test_init_connections.pycovering the connection-init helpers and lazy attribute resolution inosism/utils/__init__.py.Test targets
_init_redis()—__init__.py:22Patch
osism.utils.settingsforREDIS_HOST/REDIS_PORT/REDIS_DB, and patch the importedredis.Redisclass viamocker.patch("redis.Redis")so no real connection is made. Reset module global_redisbetween tests viaautousefixture.Redis(host, port, db, socket_keepalive=True)and calls.ping(); caches the instance in_redisRedis(...)instantiation)ping()raises → exception propagated (no swallowing)_init_nb()—__init__.py:37Patch
get_netbox_connection. Reset_nb/_nb_initializedbetween tests.get_netbox_connection(NETBOX_URL, NETBOX_TOKEN, IGNORE_SSL_ERRORS)_nb_initialized=Trueeven ifget_netbox_connectionreturnsNone(e.g. URL/token missing)_nb(no second call toget_netbox_connection)_init_secondary_nb_list()—__init__.py:47Patch
osism.utils.settings.NETBOX_SECONDARIESandget_netbox_connection. Reset_secondary_nb_listand_secondary_nb_initializedbetween tests.get_netbox_connectioncalls with the right args includingIGNORE_SSL_ERRORS=Truedefault)NETBOX_NAMEandNETBOX_SITE→ those attributes are set on the returned objectNone→TypeErrorcaught (non-list),_secondary_nb_list=[], error loggedTypeErrorraised internally, caught, loggedTypeErrorcaught, loggedNETBOX_FOO) →ValueErrorcaught, loggedNETBOX_URL(or empty) →ValueErrorcaught, loggedNETBOX_TOKEN(or empty/whitespace-only after strip) →ValueErrorcaught, loggedIGNORE_SSL_ERRORSdefaulted toTruewhen omittedNETBOX_TOKENwith surrounding whitespace → stripped before passingyaml.YAMLErrorcaught,_secondary_nb_list=[]_secondary_nb_initialized=True(cached negative result)_get_timeout_http_adapter_class()—__init__.py:180_TimeoutHTTPAdapterclass (subclass ofHTTPAdapter)send(request, timeout=None)falls back toself.timeout; existingtimeout=5kwarg is preservedNetBoxSessionManager.get_session(ignore_ssl_errors, timeout)—__init__.py:224Patch
requests.Sessionandurllib3.disable_warnings. ResetNetBoxSessionManager._session/_lockbetween tests.Session(), mounts the timeout adapter onhttp://andhttps://(verifymountcalls)ignore_ssl_errors=True→urllib3.disable_warnings()called andsession.verify=Falseignore_ssl_errors=False→session.verifynot modified,disable_warningsnot calledSession()instantiation)timeout=30→ propagated into the adapter'stimeoutNetBoxSessionManager.close_session()—__init__.py:255_session.close()invoked and_session=Nonecleanup_netbox_sessions()—__init__.py:263NetBoxSessionManager.close_session()(verify the call)get_netbox_connection(netbox_url, netbox_token, ignore_ssl_errors=False, timeout=20)—__init__.py:268Patch
pynetbox.api,NetBoxSessionManager.get_session,atexit.register. Reset_cleanup_registeredbetween tests.pynetbox.api(url, token=token), setsnb.http_sessionto the shared session, registersatexit.register(cleanup_netbox_sessions)exactly once across multiple invocationsNone, nopynetbox.apicallNonepynetbox.apireturns falsy →atexit.registernot invoked, but caller still gets the falsy value backignore_ssl_errors/timeoutpropagated toNetBoxSessionManager.get_sessionatexit.registernot called again (_cleanup_registeredshort-circuit)get_openstack_connection()—__init__.py:304Patch
openstack.connectandkeystoneauth1.exceptions.auth_plugins.MissingRequiredOptions(just import).openstack.connect()succeeds → returns the connectionopenstack.connect()raisesMissingRequiredOptions→ wrapped inRuntimeErrorwith"missing required authentication options"__getattr__(name)—__init__.py:659Reset
globals()redis/nb/secondary_nb_listbetween tests.osism.utils.redis→ calls_init_redis, caches inglobals()["redis"], second access uses the cached attribute (no second_init_rediscall)osism.utils.nb→ calls_init_nb, caches inglobals()["nb"]osism.utils.secondary_nb_list→ calls_init_secondary_nb_list, cachesosism.utils.foo) → raisesAttributeErrorwith the expected messageMocking hints
autouse=True) to reset module globals:_redis,_nb,_nb_initialized,_secondary_nb_list,_secondary_nb_initialized,_cleanup_registered,_TimeoutHTTPAdapterClass,NetBoxSessionManager._session,NetBoxSessionManager._lock, plus deleteglobals()["redis"|"nb"|"secondary_nb_list"]if present.mocker.patch.multiple("osism.utils.settings", NETBOX_URL="https://nb", NETBOX_TOKEN="t", ...).NETBOX_SECONDARIES, set the setting to a YAML string (the production code callsyaml.safe_load(settings.NETBOX_SECONDARIES)).Definition of Done
tests/unit/utils/test_init_connections.pycreatedpytest --cov=osism.utilsfor the targeted helpers ≥ 90 %pipenv run pytest tests/unit/utils/test_init_connections.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies