Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[submodule "mist_openapi"]
path = mist_openapi
url = https://github.com/mistsys/mist_openapi.git
branch = 2602.1.8
branch = master
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

Switching the mist_openapi submodule tracking from a versioned branch to master makes builds non-reproducible and can unexpectedly pull breaking API spec changes. Consider keeping this pinned to a specific release branch/tag/commit (or document why master is required) and reflect the spec version change in the changelog/release notes if it’s intentional.

Suggested change
branch = master
branch = v1.0.0

Copilot uses AI. Check for mistakes.
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,56 @@
# CHANGELOG
## Version 0.61.4 (April 2026)

**Released**: April 1, 2026

This release improves WebSocket reconnection, hardens credential handling, and fixes the two-factor authentication flow.

---

### 1. NEW FEATURES

#### **Capped Reconnect Backoff (`max_reconnect_backoff`)**
The `_MistWebsocket` client now supports a `max_reconnect_backoff` parameter to cap the exponential backoff delay during reconnection attempts:

```python
ws = mistapi.websockets.sites.DeviceStatsEvents(
apisession,
site_ids=["<site_id>"],
auto_reconnect=True,
max_reconnect_backoff=60.0 # Cap backoff at 60 seconds
)
```

#### **Unlimited Reconnect Attempts**
Setting `max_reconnect_attempts=0` now enables unlimited reconnection attempts:

```python
ws = mistapi.websockets.sites.DeviceStatsEvents(
apisession,
site_ids=["<site_id>"],
auto_reconnect=True,
max_reconnect_attempts=0 # Reconnect indefinitely
)
```

---

### 2. IMPROVEMENTS

#### **Credential Override Logging**
`APISession` now logs INFO-level messages when credentials (host, email, password, API token) are overridden by:
- Constructor parameters overriding environment variables
- Vault secrets overriding previously loaded values
- Keyring credentials overriding previously loaded values

#### **Security: Password Cleared After Login**
The stored password is now cleared from memory immediately after successful login authentication.

#### **User Attribute Handling**
`APISession._getself()` now only sets a fixed set of known user-related attributes instead of setting every response key as an object attribute.

---

## Version 0.61.3 (March 2026)

**Released**: March 18, 2026
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mistapi"
version = "0.61.3"
version = "0.61.4"
authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }]
description = "Python package to simplify the Mist System APIs usage"
keywords = ["Mist", "Juniper", "API"]
Expand Down
71 changes: 65 additions & 6 deletions src/mistapi/__api_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,28 @@ def __init__(
self._session.proxies.update(filtered_proxies)

if host:
if self._cloud_uri:
LOGGER.info(
"apisession:__init__: overriding previously loaded MIST_HOST with constructor parameter"
)
self.set_cloud(host)
if email:
if self.email:
LOGGER.info(
"apisession:__init__: overriding previously loaded MIST_USER with constructor parameter"
)
self.set_email(email)
if password:
if self._password:
LOGGER.info(
"apisession:__init__: overriding previously loaded MIST_PASSWORD with constructor parameter"
)
self.set_password(password)
if apitoken:
if self._apitoken:
LOGGER.info(
"apisession:__init__: overriding previously loaded MIST_APITOKEN with constructor parameter"
)
self.set_api_token(apitoken)
self.first_name: str = ""
self.last_name: str = ""
Expand Down Expand Up @@ -244,10 +260,18 @@ def _load_vault(
mist_host = read_response["data"]["data"].get("MIST_HOST", None)
LOGGER.info("apisession:_load_vault: MIST_HOST=%s", mist_host)
if mist_host:
if self._cloud_uri:
LOGGER.info(
"apisession:_load_vault: overriding previously loaded MIST_HOST"
)
self.set_cloud(mist_host)

mist_apitoken = read_response["data"]["data"].get("MIST_APITOKEN", None)
if mist_apitoken:
if self._apitoken:
LOGGER.info(
"apisession:_load_vault: overriding previously loaded MIST_APITOKEN"
)
self.set_api_token(mist_apitoken)
except (KeyError, TypeError, AttributeError):
LOGGER.error("apisession:_load_vault: Failed to retrieve secret")
Expand All @@ -270,10 +294,18 @@ def _load_keyring(self, keyring_service) -> None:
try:
mist_host = keyring.get_password(keyring_service, "MIST_HOST")
if mist_host:
if self._cloud_uri:
LOGGER.info(
"apisession:_load_keyring: overriding previously loaded MIST_HOST"
)
LOGGER.info("apisession:_load_keyring: MIST_HOST=%s", mist_host)
self.set_cloud(mist_host)
mist_apitoken = keyring.get_password(keyring_service, "MIST_APITOKEN")
if mist_apitoken:
if self._apitoken:
LOGGER.info(
"apisession:_load_keyring: overriding previously loaded MIST_APITOKEN"
)
if isinstance(mist_apitoken, str):
for token in mist_apitoken.split(","):
token = token.strip()
Expand All @@ -285,10 +317,18 @@ def _load_keyring(self, keyring_service) -> None:
self.set_api_token(mist_apitoken)
mist_user = keyring.get_password(keyring_service, "MIST_USER")
if mist_user:
if self.email:
LOGGER.info(
"apisession:_load_keyring: overriding previously loaded MIST_USER"
)
LOGGER.info("apisession:_load_keyring: MIST_USER retrieved")
self.set_email(mist_user)
mist_password = keyring.get_password(keyring_service, "MIST_PASSWORD")
if mist_password:
if self._password:
LOGGER.info(
"apisession:_load_keyring: overriding previously loaded MIST_PASSWORD"
)
LOGGER.info("apisession:_load_keyring: MIST_PASSWORD retrieved")
self.set_password(mist_password)
except Exception as e:
Expand Down Expand Up @@ -701,6 +741,7 @@ def _process_login(self, retry: bool = True) -> str | None:
if resp.status_code == 200:
LOGGER.info("apisession:_process_login:authentication successful!")
CONSOLE.info("Authentication successful!")
self._password = None
self._set_authenticated(True)
else:
error = resp.json().get("detail")
Expand Down Expand Up @@ -818,14 +859,22 @@ def login_with_return(
elif self.email and self._password:
if two_factor:
LOGGER.debug("apisession:login_with_return:login/pwd provided with 2FA")
if self._two_factor_authentication(two_factor):
error_login = self._process_login(retry=False)
if error_login:
LOGGER.error(
"apisession:login_with_return:login/pwd auth failed: %s",
error_login,
)
return {"authenticated": False, "error": error_login}
if not self._two_factor_authentication(two_factor):
LOGGER.error(
"apisession:login_with_return:login/pwd auth failed: 2FA authentication failed"
"apisession:login_with_return:2FA authentication failed"
)
return {
"authenticated": False,
"error": "2FA authentication failed",
}
LOGGER.info("apisession:login_with_return:authenticated with 2FA")
else:
LOGGER.debug("apisession:login_with_return:login/pwd provided w/o 2FA")
error_login = self._process_login(retry=False)
Expand Down Expand Up @@ -1044,10 +1093,8 @@ def _two_factor_authentication(self, two_factor: str) -> bool:
True if authentication succeed, False otherwise
"""
LOGGER.debug("apisession:_two_factor_authentication")
uri = "/api/v1/login"
uri = "/api/v1/login/two_factor"
body = {
"email": self.email,
"password": self._password,
"two_factor": two_factor,
}
resp = self._session.post(self._url(uri), json=body)
Expand Down Expand Up @@ -1102,7 +1149,19 @@ def _getself(self) -> bool:
elif key == "tags":
for tag in resp.data["tags"]:
self.tags.append(tag)
else:
elif key in [
"first_name",
"last_name",
"email",
"phone",
"enable_two_factor",
"two_factor_verified",
"no_tracking",
"oauth_google",
"password_expiry",
"password_modified_time",
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

_getself() now only sets a small whitelist of keys via setattr(). This drops fields that the class already models/prints elsewhere (e.g., via_sso/phone/session_expiry in str), so those values will no longer reflect the /api/v1/self response. Either expand the whitelist to include the supported APISession attributes (at least those referenced by str/public API), or update str/attribute model to remove fields you no longer intend to populate.

Suggested change
"password_modified_time",
"password_modified_time",
"via_sso",
"phone",
"session_expiry",

Copilot uses AI. Check for mistakes.
"via_sso",
]:
setattr(self, key, val)
if self._show_cli_notif:
print()
Expand Down
2 changes: 1 addition & 1 deletion src/mistapi/__version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version__ = "0.61.3"
__version__ = "0.61.4"
__author__ = "Thomas Munzer <tmunzer@juniper.net>"
4 changes: 2 additions & 2 deletions src/mistapi/api/v1/sites/sle.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@deprecation.deprecated(
deprecated_in="0.59.2",
removed_in="0.65.0",
current_version="0.61.3",
current_version="0.61.4",
details="function replaced with getSiteSleClassifierSummaryTrend",
)
def getSiteSleClassifierDetails(
Expand Down Expand Up @@ -690,7 +690,7 @@ def listSiteSleImpactedWirelessClients(
@deprecation.deprecated(
deprecated_in="0.59.2",
removed_in="0.65.0",
current_version="0.61.3",
current_version="0.61.4",
details="function replaced with getSiteSleSummaryTrend",
)
def getSiteSleSummary(
Expand Down
36 changes: 25 additions & 11 deletions src/mistapi/websockets/__ws_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,15 @@ def __init__(
auto_reconnect: bool = False,
max_reconnect_attempts: int = 5,
reconnect_backoff: float = 2.0,
max_reconnect_backoff: float | None = None,
queue_maxsize: int = 0,
) -> None:
if max_reconnect_attempts < 0:
raise ValueError("max_reconnect_attempts must be >= 0")
raise ValueError("max_reconnect_attempts must be >= 0 (0 = unlimited)")
if reconnect_backoff <= 0:
raise ValueError("reconnect_backoff must be > 0")
if max_reconnect_backoff is not None and max_reconnect_backoff <= 0:
raise ValueError("max_reconnect_backoff must be > 0")
Comment on lines 69 to +80
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

New reconnect behavior (unlimited attempts when max_reconnect_attempts=0 and capped delay via max_reconnect_backoff) is introduced here, but the unit tests don’t cover either case. Add tests that (1) verify the attempt limit is not enforced when max_reconnect_attempts=0 and (2) verify delay is capped when max_reconnect_backoff is set (e.g., by patching Event.wait / logger to observe the computed timeout).

Copilot uses AI. Check for mistakes.
if queue_maxsize < 0:
raise ValueError("queue_maxsize must be >= 0")

Expand All @@ -85,6 +88,7 @@ def __init__(
self._auto_reconnect = auto_reconnect
self._max_reconnect_attempts = max_reconnect_attempts
self._reconnect_backoff = reconnect_backoff
self._max_reconnect_backoff = max_reconnect_backoff
self._lock = threading.Lock()
self._ws: websocket.WebSocketApp | None = None
self._thread: threading.Thread | None = None
Expand Down Expand Up @@ -305,22 +309,32 @@ def _run_forever_safe(self) -> None:
break

self._reconnect_attempts += 1
if self._reconnect_attempts > self._max_reconnect_attempts:
if (
self._max_reconnect_attempts > 0
and self._reconnect_attempts > self._max_reconnect_attempts
):
logger.warning(
"Max reconnect attempts (%d) reached, giving up",
self._max_reconnect_attempts,
)
break

delay = self._reconnect_backoff * (
2 ** (self._reconnect_attempts - 1)
)
logger.info(
"Reconnecting in %.1fs (attempt %d/%d)",
delay,
self._reconnect_attempts,
self._max_reconnect_attempts,
)
delay = self._reconnect_backoff * (2 ** (self._reconnect_attempts - 1))
if self._max_reconnect_backoff is not None:
delay = min(delay, self._max_reconnect_backoff)
if self._max_reconnect_attempts > 0:
logger.info(
"Reconnecting in %.1fs (attempt %d/%d)",
delay,
self._reconnect_attempts,
self._max_reconnect_attempts,
)
else:
logger.info(
"Reconnecting in %.1fs (attempt %d, unlimited)",
delay,
self._reconnect_attempts,
)
if self._user_disconnect.wait(timeout=delay):
break # disconnect() called during backoff

Expand Down
Loading
Loading