Skip to content

Commit 27d9b78

Browse files
committed
Refactor device utility functions to use WebSocketWrapper for asynchronous operations
- Updated clear_sessions, clear_mac_table, and clear_learned_mac functions to utilize WebSocketWrapper for handling API calls and WebSocket events. - Enhanced logging for debugging purposes in various functions including ping, traceroute, and OSPF commands. - Removed synchronous trigger handling and replaced it with asynchronous WebSocket handling across multiple device utility functions. - Updated version number in uv.lock to 0.61.1.
1 parent 5654c75 commit 27d9b78

25 files changed

Lines changed: 742 additions & 601 deletions

README.md

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ A comprehensive Python package to interact with the Mist Cloud APIs, built from
3535
- [Callbacks](#callbacks)
3636
- [Available Channels](#available-channels)
3737
- [Usage Patterns](#usage-patterns)
38+
- [Async Usage](#async-usage)
39+
- [Running API Calls Asynchronously](#running-api-calls-asynchronously)
40+
- [Concurrent API Calls](#concurrent-api-calls)
41+
- [Combining with Device Utilities](#combining-with-device-utilities)
3842
- [Device Utilities](#device-utilities)
3943
- [Supported Devices](#supported-devices)
4044
- [Usage](#device-utilities-usage)
@@ -63,9 +67,10 @@ Support for all Mist cloud instances worldwide:
6367

6468
### Core Features
6569
- **Complete API Coverage**: Auto-generated from OpenAPI specs
70+
- **Async Support**: Run any API call asynchronously with `mistapi.arun()` — no changes to existing code
6671
- **Automatic Pagination**: Built-in support for paginated responses
6772
- **WebSocket Streaming**: Real-time event streaming for devices, clients, and location data
68-
- **Device Diagnostics**: High-level utilities for ping, traceroute, ARP, BGP, OSPF, and more
73+
- **Device Diagnostics**: High-level, non-blocking utilities for ping, traceroute, ARP, BGP, OSPF, and more
6974
- **Error Handling**: Detailed error responses and logging
7075
- **Proxy Support**: HTTP/HTTPS proxy configuration
7176
- **Log Sanitization**: Automatic redaction of sensitive data in logs
@@ -492,6 +497,82 @@ events = mistapi.api.v1.orgs.clients.searchOrgClientsEvents(
492497

493498
---
494499

500+
## Async Usage
501+
502+
All API functions in `mistapi.api.v1` are synchronous by default. To use them in an `asyncio` context (e.g., FastAPI, aiohttp, or any async application) without blocking the event loop, use `mistapi.arun()`.
503+
504+
`arun()` wraps any sync mistapi function in `asyncio.to_thread()`, running the blocking HTTP request in a thread pool while the event loop continues. No changes are needed to the existing API functions.
505+
506+
### Running API Calls Asynchronously
507+
508+
```python
509+
import asyncio
510+
import mistapi
511+
from mistapi.api.v1.sites import devices
512+
513+
apisession = mistapi.APISession(env_file="~/.mist_env")
514+
apisession.login()
515+
516+
async def main():
517+
# Wrap any sync API call with mistapi.arun()
518+
response = await mistapi.arun(
519+
devices.listSiteDevices, apisession, site_id
520+
)
521+
print(response.data)
522+
523+
asyncio.run(main())
524+
```
525+
526+
### Concurrent API Calls
527+
528+
Use `asyncio.gather()` to run multiple API calls concurrently:
529+
530+
```python
531+
import asyncio
532+
import mistapi
533+
from mistapi.api.v1.orgs import orgs
534+
from mistapi.api.v1.sites import devices
535+
536+
async def main():
537+
org_info, site_devices = await asyncio.gather(
538+
mistapi.arun(orgs.getOrg, apisession, org_id),
539+
mistapi.arun(devices.listSiteDevices, apisession, site_id),
540+
)
541+
print(f"Org: {org_info.data['name']}")
542+
print(f"Devices: {len(site_devices.data)}")
543+
544+
asyncio.run(main())
545+
```
546+
547+
### Combining with Device Utilities
548+
549+
Device utility functions are already non-blocking and return a `UtilResponse` that supports `await`. You can mix `arun()` for API calls and `await` for device utilities:
550+
551+
```python
552+
import asyncio
553+
import mistapi
554+
from mistapi.api.v1.sites import devices
555+
from mistapi.device_utils import ex
556+
557+
async def main():
558+
# Device utility — already non-blocking, supports await
559+
response = ex.retrieveArpTable(apisession, site_id, device_id)
560+
561+
# API call — use arun() to avoid blocking the event loop
562+
device_info = await mistapi.arun(
563+
devices.getSiteDevice, apisession, site_id, device_id
564+
)
565+
print(f"Device: {device_info.data['name']}")
566+
567+
# Await the device utility result
568+
await response
569+
print(f"ARP entries: {len(response.ws_data)}")
570+
571+
asyncio.run(main())
572+
```
573+
574+
---
575+
495576
## WebSocket Streaming
496577

497578
The package provides a WebSocket client for real-time event streaming from the Mist API (`wss://{host}/api-ws/v1/stream`). Authentication is handled automatically using the same session credentials (API token or login/password).
@@ -533,7 +614,7 @@ ws.connect()
533614
|-------|---------|-------------|
534615
| `mistapi.websockets.orgs.InsightsEvents` | `/orgs/{org_id}/insights/summary` | Real-time insights events for an organization |
535616
| `mistapi.websockets.orgs.MxEdgesStatsEvents` | `/orgs/{org_id}/stats/mxedges` | Real-time MX edges stats for an organization |
536-
| `mistapi.websockets.orgs.MxEdgesUpgradesEvents` | `/orgs/{org_id}/mxedges` | Real-time MX edges upgrades events for an organization |
617+
| `mistapi.websockets.orgs.MxEdgesEvents` | `/orgs/{org_id}/mxedges` | Real-time MX edges events for an organization |
537618

538619
#### Site Channels
539620

@@ -542,7 +623,7 @@ ws.connect()
542623
| `mistapi.websockets.sites.ClientsStatsEvents` | `/sites/{site_id}/stats/clients` | Real-time clients stats for a site |
543624
| `mistapi.websockets.sites.DeviceCmdEvents` | `/sites/{site_id}/devices/{device_id}/cmd` | Real-time device command events for a site |
544625
| `mistapi.websockets.sites.DeviceStatsEvents` | `/sites/{site_id}/stats/devices` | Real-time device stats for a site |
545-
| `mistapi.websockets.sites.DeviceUpgradesEvents` | `/sites/{site_id}/devices` | Real-time device upgrades events for a site |
626+
| `mistapi.websockets.sites.DeviceEvents` | `/sites/{site_id}/devices` | Real-time device events for a site |
546627
| `mistapi.websockets.sites.MxEdgesStatsEvents` | `/sites/{site_id}/stats/mxedges` | Real-time MX edges stats for a site |
547628
| `mistapi.websockets.sites.PcapEvents` | `/sites/{site_id}/pcap` | Real-time PCAP events for a site |
548629

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mistapi"
7-
version = "0.61.0"
7+
version = "0.61.1"
88
authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }]
99
description = "Python package to simplify the Mist System APIs usage"
1010
keywords = ["Mist", "Juniper", "API"]

src/mistapi/__api_request.py

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import json
1818
import os
1919
import re
20+
import threading
2021
import time
2122
import urllib.parse
2223
from collections.abc import Callable
@@ -45,6 +46,7 @@ def __init__(self) -> None:
4546
self._count: int = 0
4647
self._apitoken: list[str] = []
4748
self._apitoken_index: int = -1
49+
self._token_lock: threading.Lock = threading.Lock()
4850

4951
def get_request_count(self):
5052
"""
@@ -86,40 +88,41 @@ def _log_proxy(self) -> None:
8688
)
8789

8890
def _next_apitoken(self) -> None:
89-
logger.info("apirequest:_next_apitoken:rotating API Token")
90-
logger.debug(
91-
"apirequest:_next_apitoken:current API Token is %s...%s",
92-
self._apitoken[self._apitoken_index][:4],
93-
self._apitoken[self._apitoken_index][-4:],
94-
)
95-
new_index = self._apitoken_index + 1
96-
if new_index >= len(self._apitoken):
97-
new_index = 0
98-
if self._apitoken_index != new_index:
99-
self._apitoken_index = new_index
100-
self._session.headers.update(
101-
{"Authorization": "Token " + self._apitoken[self._apitoken_index]}
102-
)
91+
with self._token_lock:
92+
logger.info("apirequest:_next_apitoken:rotating API Token")
10393
logger.debug(
104-
"apirequest:_next_apitoken:new API Token is %s...%s",
94+
"apirequest:_next_apitoken:current API Token is %s...%s",
10595
self._apitoken[self._apitoken_index][:4],
10696
self._apitoken[self._apitoken_index][-4:],
10797
)
108-
else:
109-
logger.critical(" /!\\ API TOKEN CRITICAL ERROR /!\\")
110-
logger.critical(
111-
" There is no other API Token to use and the API"
112-
" Request limit has been reached for the current one"
113-
)
114-
logger.critical(
115-
" For large organization, it is recommended to configure"
116-
" multiple API Tokens (comma separated list) to avoid this issue"
117-
)
118-
raise RuntimeError(
119-
"API rate limit reached and no other API Token available. "
120-
"For large organizations, configure multiple API Tokens "
121-
"(comma separated list) to avoid this issue."
122-
)
98+
new_index = self._apitoken_index + 1
99+
if new_index >= len(self._apitoken):
100+
new_index = 0
101+
if self._apitoken_index != new_index:
102+
self._apitoken_index = new_index
103+
self._session.headers.update(
104+
{"Authorization": "Token " + self._apitoken[self._apitoken_index]}
105+
)
106+
logger.debug(
107+
"apirequest:_next_apitoken:new API Token is %s...%s",
108+
self._apitoken[self._apitoken_index][:4],
109+
self._apitoken[self._apitoken_index][-4:],
110+
)
111+
else:
112+
logger.critical(" /!\\ API TOKEN CRITICAL ERROR /!\\")
113+
logger.critical(
114+
" There is no other API Token to use and the API"
115+
" Request limit has been reached for the current one"
116+
)
117+
logger.critical(
118+
" For large organization, it is recommended to configure"
119+
" multiple API Tokens (comma separated list) to avoid this issue"
120+
)
121+
raise RuntimeError(
122+
"API rate limit reached and no other API Token available. "
123+
"For large organizations, configure multiple API Tokens "
124+
"(comma separated list) to avoid this issue."
125+
)
123126

124127
def _gen_query(self, query: dict[str, str] | None) -> str:
125128
if not query:
@@ -344,6 +347,7 @@ def mist_post_file(
344347
multipart_form_data,
345348
)
346349
generated_multipart_form_data: dict[str, Any] = {}
350+
opened_files: list = []
347351
for key in multipart_form_data:
348352
logger.debug(
349353
"apirequest:mist_post_file:multipart_form_data:%s = %s",
@@ -358,6 +362,7 @@ def mist_post_file(
358362
multipart_form_data[key],
359363
)
360364
f = open(multipart_form_data[key], "rb")
365+
opened_files.append(f)
361366
generated_multipart_form_data[key] = (
362367
os.path.basename(multipart_form_data[key]),
363368
f,
@@ -392,4 +397,8 @@ def _do_post_file():
392397
)
393398
return resp
394399

395-
return self._request_with_retry("mist_post_file", _do_post_file, url)
400+
try:
401+
return self._request_with_retry("mist_post_file", _do_post_file, url)
402+
finally:
403+
for f in opened_files:
404+
f.close()

src/mistapi/__api_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __init__(
5151
console.debug(f"Response Status Code: {response.status_code}")
5252

5353
try:
54-
self.raw_data = str(response.content)
54+
self.raw_data = response.text
5555
self.data = response.json()
5656
self._check_next()
5757
logger.debug("apiresponse:__init__:HTTP response processed")

src/mistapi/__init__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from mistapi.__version import __author__ as __author__
1818
from mistapi.__version import __version__ as __version__
1919

20+
import asyncio as _asyncio
21+
from collections.abc import Callable as _Callable
2022
from typing import TYPE_CHECKING
2123

2224
if TYPE_CHECKING:
@@ -41,3 +43,43 @@ def __getattr__(name: str):
4143
globals()[name] = module
4244
return module
4345
raise AttributeError(f"module 'mistapi' has no attribute {name!r}")
46+
47+
48+
async def arun(func: _Callable, *args, **kwargs):
49+
"""
50+
Run any sync mistapi function without blocking the event loop.
51+
52+
Wraps the function call in ``asyncio.to_thread()`` so the blocking
53+
HTTP request runs in a thread pool while the event loop continues.
54+
55+
EXAMPLE
56+
-----------
57+
::
58+
59+
import asyncio
60+
import mistapi
61+
from mistapi.api.v1.sites import devices
62+
63+
async def main():
64+
session = mistapi.APISession(env_file="~/.mist_env")
65+
session.login()
66+
67+
response = await mistapi.arun(
68+
devices.listSiteDevices, session, site_id
69+
)
70+
print(response.data)
71+
72+
asyncio.run(main())
73+
74+
PARAMS
75+
-----------
76+
func : callable
77+
Any sync mistapi API function.
78+
*args, **kwargs
79+
Arguments forwarded to *func*.
80+
81+
RETURNS
82+
-----------
83+
The return value of *func* (typically ``APIResponse``).
84+
"""
85+
return await _asyncio.to_thread(func, *args, **kwargs)

src/mistapi/__version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.61.0"
1+
__version__ = "0.61.1"
22
__author__ = "Thomas Munzer <tmunzer@juniper.net>"

src/mistapi/api/v1/sites/sle.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
@deprecation.deprecated(
2020
deprecated_in="0.59.2",
2121
removed_in="0.65.0",
22-
current_version="0.61.0",
22+
current_version="0.61.1",
2323
details="function replaced with getSiteSleClassifierSummaryTrend",
2424
)
2525
def getSiteSleClassifierDetails(
@@ -691,7 +691,7 @@ def listSiteSleImpactedWirelessClients(
691691
@deprecation.deprecated(
692692
deprecated_in="0.59.2",
693693
removed_in="0.65.0",
694-
current_version="0.61.0",
694+
current_version="0.61.1",
695695
details="function replaced with getSiteSleSummaryTrend",
696696
)
697697
def getSiteSleSummary(

src/mistapi/device_utils/__init__.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
--------------------------------------
1919
Import device-specific modules for a clean, organized API:
2020
21-
from mistapi.utils import ap, ex, srx, ssr
21+
from mistapi.device_utils import ap, ex, srx, ssr
2222
2323
# Use device-specific utilities
2424
ap.ping(session, site_id, device_id, host)
@@ -30,15 +30,6 @@
3030
- ex: Juniper EX Switches
3131
- srx: Juniper SRX Firewalls
3232
- ssr: Juniper Session Smart Routers
33-
34-
Function-Based Modules (Legacy)
35-
---------------------------------
36-
Original organization by function type (still available):
37-
38-
from mistapi.utils import arp, bgp, dhcp, mac, port, routes, tools
39-
40-
Available modules: arp, bgp, bpdu, dhcp, dns, dot1x, mac, policy, port, routes,
41-
service_path, tools
4233
"""
4334

4435
# Device-specific modules (recommended)

0 commit comments

Comments
 (0)