Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
79e3a3c
add support for async
theGowda Nov 21, 2024
6a99a74
Merge branch 'luqasz:main' into main
theGowda Nov 21, 2024
e6d4a8f
add async timeout on open_connection
theGowda Nov 22, 2024
f0869da
added timeout; fix async iterator
theGowda Nov 23, 2024
c079b4f
bug fix; add timeout paramter
theGowda Nov 23, 2024
9080fef
Update login.py
theGowda Nov 23, 2024
f35463d
add async for add, remove, update
theGowda Nov 24, 2024
f3e4b0c
update test to use SYNC_DEFAULTS
theGowda Nov 25, 2024
072a33a
Update README.rst
theGowda Nov 25, 2024
4b19946
Merge branch 'main' into resolve-merge-conflict
theGowda Nov 25, 2024
648d4d7
lint fix
theGowda Nov 25, 2024
9276726
fix timeout argument in asyn_connect
theGowda Nov 25, 2024
6ecbcc6
type check fix
theGowda Nov 26, 2024
01639e9
ruff formatting
theGowda Nov 26, 2024
eca271a
add async_token function
theGowda Nov 26, 2024
14e4636
ruff formatting in test directory
theGowda Nov 26, 2024
92c3f1e
handle empty select in async
theGowda Dec 1, 2024
46ebf49
empty select for async
theGowda Dec 1, 2024
d9371c4
change typeError to attrubuteError when iter on async iterator
theGowda Dec 1, 2024
c26da6d
Merge branch 'luqasz:main' into main
theGowda Dec 1, 2024
364cd71
minor bug fix
theGowda Dec 1, 2024
4f2e235
bug fix remove iter
theGowda Dec 1, 2024
936eda0
added integration tests for async
theGowda Dec 2, 2024
efc634d
Merge branch 'main' into main
theGowda Dec 2, 2024
2bb2771
add default timeout; add unit tests
theGowda Dec 5, 2024
e61beeb
ruff check
theGowda Dec 5, 2024
d65abf8
updated docs for async usage
theGowda Dec 5, 2024
10627cf
Squashed commit of the following:
theGowda Dec 6, 2024
92599ec
combine async with sync integration tests
theGowda Dec 8, 2024
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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ MANIFEST
.vscode

Pipfile.lock

.venv
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ Documentation

Documentation resides over at
`readthedocs <https://librouteros.readthedocs.io/>`_

18 changes: 7 additions & 11 deletions apicli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

"""Command line interface for debugging purpouses."""


import logging
import getpass
from argparse import ArgumentParser
Expand All @@ -14,19 +13,16 @@
from librouteros import connect
from librouteros.exceptions import TrapError, FatalError

argParser = ArgumentParser(description='mikrotik api cli interface')
argParser.add_argument(
'host', type=str,
help="host to with to connect. may be fqdn, ipv4 or ipv6 address")
argParser.add_argument('-u', '--user', type=str, required=True, help="username")
argParser.add_argument(
'-p', '--port', type=int, default=8728, help="port to connect to (default 8728)")
argParser = ArgumentParser(description="mikrotik api cli interface")
argParser.add_argument("host", type=str, help="host to with to connect. may be fqdn, ipv4 or ipv6 address")
argParser.add_argument("-u", "--user", type=str, required=True, help="username")
argParser.add_argument("-p", "--port", type=int, default=8728, help="port to connect to (default 8728)")
args = argParser.parse_args()

mainlog = logging.getLogger('librouteros')
mainlog = logging.getLogger("librouteros")
console = logging.StreamHandler(stdout)
mainlog.setLevel(logging.DEBUG)
formatter = logging.Formatter(fmt='%(message)s')
formatter = logging.Formatter(fmt="%(message)s")
console.setFormatter(formatter)
mainlog.addHandler(console)

Expand Down Expand Up @@ -72,5 +68,5 @@ def main():
api.close()


if __name__ == '__main__':
if __name__ == "__main__":
main()
9 changes: 8 additions & 1 deletion docs/connect.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ Unencrypted

.. code-block:: python

from librouteros import connect
from librouteros import connect, async_connect
api = connect(
username='admin',
password='abc',
host='some.address.com',
)

# For async version use async_connect
api = await async_connect(
username='admin',
password='abc',
host='some.address.com',
)

Encrypted
---------
Expand Down
21 changes: 20 additions & 1 deletion docs/path.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ Get all
# This also will work, as well as anything else you can do with iterables
for item in interfaces:
print(item)


# async version
async for item in interfaces:
print(item)

# or you can use list comprehension
items = [item async for item in interfaces]

Add
---

Expand All @@ -34,6 +41,9 @@ Add
# Will return newly created .id
path.add(interface='ether1', address='172.31.31.1/24')

# async version
await path.add(interface='ether1', address='172.31.31.1/24')

Remove
------

Expand All @@ -42,6 +52,9 @@ Remove
# Pass each .id as an argument.
path.remove('*1', '*2')

# async version
await path.remove('*1', '*2')

.. note::

``.id`` change on reboot. Always read them first.
Expand All @@ -54,6 +67,9 @@ Update
params = {'disabled': True, '.id' :'*7'}
path.update(**params)

# async version
await path.update(**params)

.. note::

``.id`` change on reboot. Always read them first.
Expand All @@ -69,3 +85,6 @@ As a first argument, pass command that you wish to run without absolute path.
script = api.path('system', 'script')
# Will run /system/script/run with desired .id
tuple(script('run', **{'.id': '*1'}))

# async version
[item async for item in script('run', **{'.id': '*1'})]
59 changes: 54 additions & 5 deletions librouteros/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
# -*- coding: UTF-8 -*-

import asyncio

from socket import create_connection
from collections import ChainMap

from librouteros.exceptions import (
ConnectionClosed,
FatalError,
)
from librouteros.connections import SocketTransport
from librouteros.protocol import ApiProtocol
from librouteros.connections import SocketTransport, AsyncSocketTransport
from librouteros.protocol import ApiProtocol, AsyncApiProtocol
from librouteros.login import (
plain,
token, # noqa: F401 BACK_COMP
async_plain,
)
from librouteros.api import Api
from librouteros.api import Api, AsyncApi

DEFAULTS = {
SYNC_DEFAULTS = {
"timeout": 10,
"port": 8728,
"saddr": "",
Expand All @@ -25,6 +28,16 @@
"login_method": plain,
}

ASYNC_DEFAULTS = {
"timeout": 10,
"port": 8728,
"saddr": "",
"subclass": AsyncApi,
"encoding": "ASCII",
"ssl_wrapper": lambda sock: sock,
"login_method": async_plain,
}


def connect(host: str, username: str, password: str, **kwargs) -> Api:
"""
Expand All @@ -41,7 +54,7 @@ def connect(host: str, username: str, password: str, **kwargs) -> Api:
:param ssl_wrapper: Callable (e.g. ssl.SSLContext instance) to wrap socket with.
:param login_method: Callable with login method.
"""
arguments = ChainMap(kwargs, DEFAULTS)
arguments = ChainMap(kwargs, SYNC_DEFAULTS)
transport = create_transport(host, **arguments)
protocol = ApiProtocol(transport=transport, encoding=arguments["encoding"])
api: Api = arguments["subclass"](protocol=protocol)
Expand All @@ -54,7 +67,43 @@ def connect(host: str, username: str, password: str, **kwargs) -> Api:
raise


async def async_connect(host: str, username: str, password: str, **kwargs) -> AsyncApi:
"""
Connect and login to routeros device.
Upon success return a Api class.

:param host: Hostname to connecto to. May be ipv4,ipv6,FQDN.
:param username: Username to login with.
:param password: Password to login with. Only ASCII characters allowed.
:param timeout: Socket timeout. Defaults to 10.
:param port: Destination port to be used. Defaults to 8728.
:param saddr: Source address to bind to.
:param subclass: Subclass of Api class. Defaults to Api class from library.
:param ssl_wrapper: Callable (e.g. ssl.SSLContext instance) to wrap socket with.
:param login_method: Callable with login method.
"""
arguments = ChainMap(kwargs, ASYNC_DEFAULTS)
transport = await async_create_transport(host, **arguments)
protocol = AsyncApiProtocol(transport=transport, encoding=arguments["encoding"], timeout=arguments["timeout"])
api: AsyncApi = arguments["subclass"](protocol=protocol)

try:
await arguments["login_method"](api=api, username=username, password=password)
return api
except (ConnectionClosed, FatalError):
await transport.close()
raise


def create_transport(host: str, **kwargs) -> SocketTransport:
sock = create_connection((host, kwargs["port"]), kwargs["timeout"], (kwargs["saddr"], 0))
sock = kwargs["ssl_wrapper"](sock)
return SocketTransport(sock=sock)


async def async_create_transport(host: str, **kwargs) -> AsyncSocketTransport:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host=host, port=kwargs["port"]),
timeout=kwargs["timeout"],
)
return AsyncSocketTransport(reader=reader, writer=writer)
137 changes: 137 additions & 0 deletions librouteros/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
compose_word,
parse_word,
ApiProtocol,
AsyncApiProtocol,
)
from librouteros import query

from librouteros.types import (
ReplyDict,
ResponseIter,
AsyncResponseIter,
Response,
)

Expand Down Expand Up @@ -140,3 +142,138 @@ def update(self, **kwargs: typing.Any) -> None:
**kwargs,
)
)


class AsyncApi:
def __init__(self, protocol: AsyncApiProtocol):
self.protocol = protocol

async def __call__(self, cmd: str, **kwargs: typing.Any) -> AsyncResponseIter:
"""
Call Api with given command.
Yield each row.

:param cmd: Command word. eg. /ip/address/print
:param kwargs: Dictionary with optional arguments.
"""
words = (compose_word(key, value) for key, value in kwargs.items())
await self.protocol.writeSentence(cmd, *words)
response = await self.readResponse()
for item in response:
yield item

async def rawCmd(self, cmd: str, *words: str) -> AsyncResponseIter:
"""
Call Api with given command and raw words.
End user is responsible to properly format each api word argument.
:param cmd: Command word. eg. /ip/address/print
:param args: Iterable with optional plain api arguments.
"""
await self.protocol.writeSentence(cmd, *words)
response = await self.readResponse()
for item in response:
yield item

async def readSentence(self) -> typing.Tuple[str, ReplyDict]:
"""
Read one sentence and parse words.
"""
# Assuming readSentence is also an async method in the protocol
reply_word, words = await self.protocol.readSentence()
return reply_word, dict(parse_word(word) for word in words)

async def readResponse(self) -> Response:
"""
Yield each sentence untill !done is received.

:throws TrapError: If one !trap is received.
:throws MultiTrapError: If > 1 !trap is received.
"""
traps = []
reply_word = None
response = []
while reply_word != "!done":
reply_word, words = await self.readSentence()
if reply_word == "!trap":
traps.append(TrapError(**words))
elif reply_word in ("!re", "!done") and words:
response.append(words)

if len(traps) > 1:
raise MultiTrapError(*traps)
if len(traps) == 1:
raise traps[0]
return response

async def close(self) -> None:
await self.protocol.close()

def path(self, *path: str):
return AyncPath(
path="",
api=self,
).join(*path)


class AyncPath:
"""Represents absolute command path."""

def __init__(self, path: str, api: AsyncApi):
self.path = path
self.api = api

def select(self, *keys: query.Key) -> query.AsyncQuery:
return query.AsyncQuery(path=self, keys=keys, api=self.api)

def __str__(self) -> str:
return self.path

def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self}>"

async def __aiter__(self) -> AsyncResponseIter:
async for response in self("print"):
yield response

async def __call__(self, cmd: str, **kwargs: typing.Any) -> AsyncResponseIter:
async for response in self.api(
self.join(cmd).path,
**kwargs,
):
yield response

def join(self, *path: str):
"""Join current path with one or more path strings."""
return AyncPath(
api=self.api,
path=pjoin("/", self.path, *path).rstrip("/"),
)

async def remove(self, *ids: str) -> None:
joined = ",".join(ids)
[
response
async for response in self(
"remove",
**{".id": joined},
)
]

async def add(self, **kwargs: typing.Any) -> str:
response = [
response
async for response in self(
"add",
**kwargs,
)
]
return response[0]["ret"]

async def update(self, **kwargs: typing.Any) -> None:
[
response
async for response in self(
"set",
**kwargs,
)
]
Loading