Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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

Copilot AI Apr 1, 2026

Copy link

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**
`_get_self()` now only sets known user attributes (`first_name`, `last_name`, `email`, `enable_two_factor`, `two_factor_verified`, `no_tracking`, `password_expiry`, `password_modified_time`) instead of setting arbitrary response keys as object attributes.
Comment thread
tmunzer-AIDE marked this conversation as resolved.
Outdated

---

## 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
68 changes: 62 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)
Comment on lines 740 to 745

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

Password clearing after successful login is a security-sensitive behavior change and should be covered by unit tests. Add tests that ensure (a) _password is cleared after a successful /api/v1/login, and (b) the 2FA flow (login_with_return with two_factor) still succeeds using the new /api/v1/login/two_factor endpoint after the password has been cleared.

Copilot uses AI. Check for mistakes.
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"
)
Comment thread
tmunzer-AIDE marked this conversation as resolved.
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,16 @@ 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",
Comment thread
tmunzer-AIDE marked this conversation as resolved.
"enable_two_factor",
"two_factor_verified",
"no_tracking",
"password_expiry",
"password_modified_time",

Copilot AI Apr 1, 2026

Copy link

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.
]:
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

Copilot AI Apr 1, 2026

Copy link

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
10 changes: 10 additions & 0 deletions src/mistapi/websockets/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ 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:
channels = [f"/sites/{site_id}/stats/maps/{mid}/assets" for mid in map_ids]
Expand All @@ -91,6 +92,7 @@ def __init__(
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
reconnect_backoff=reconnect_backoff,
max_reconnect_backoff=max_reconnect_backoff,
queue_maxsize=queue_maxsize,
)

Expand Down Expand Up @@ -160,6 +162,7 @@ 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:
channels = [f"/sites/{site_id}/stats/maps/{mid}/clients" for mid in map_ids]
Expand All @@ -171,6 +174,7 @@ def __init__(
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
reconnect_backoff=reconnect_backoff,
max_reconnect_backoff=max_reconnect_backoff,
queue_maxsize=queue_maxsize,
)

Expand Down Expand Up @@ -240,6 +244,7 @@ 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:
channels = [f"/sites/{site_id}/stats/maps/{mid}/sdkclients" for mid in map_ids]
Expand All @@ -251,6 +256,7 @@ def __init__(
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
reconnect_backoff=reconnect_backoff,
max_reconnect_backoff=max_reconnect_backoff,
queue_maxsize=queue_maxsize,
)

Expand Down Expand Up @@ -320,6 +326,7 @@ 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:
channels = [
Expand All @@ -333,6 +340,7 @@ def __init__(
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
reconnect_backoff=reconnect_backoff,
max_reconnect_backoff=max_reconnect_backoff,
queue_maxsize=queue_maxsize,
)

Expand Down Expand Up @@ -402,6 +410,7 @@ 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:
channels = [
Expand All @@ -415,5 +424,6 @@ def __init__(
auto_reconnect=auto_reconnect,
max_reconnect_attempts=max_reconnect_attempts,
reconnect_backoff=reconnect_backoff,
max_reconnect_backoff=max_reconnect_backoff,
queue_maxsize=queue_maxsize,
)
Loading
Loading