diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 09:24:45 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-14 09:24:45 +0000 |
commit | b88b57aff93c95ae07f8a2f05a6a577b39cb1f1f (patch) | |
tree | a8c2cbdeefe4aba4936c128ebfd4cb600da23dc1 | |
parent | Initial commit. (diff) | |
download | aio-eapi-upstream.tar.xz aio-eapi-upstream.zip |
Adding upstream version 0.6.3.upstream/0.6.3upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r-- | .gitignore | 109 | ||||
-rw-r--r-- | .pre-commit-config.yaml | 30 | ||||
-rw-r--r-- | LICENSE | 202 | ||||
-rw-r--r-- | Makefile | 26 | ||||
-rw-r--r-- | README.md | 43 | ||||
-rw-r--r-- | aioeapi/__init__.py | 3 | ||||
-rw-r--r-- | aioeapi/aio_portcheck.py | 56 | ||||
-rw-r--r-- | aioeapi/config_session.py | 259 | ||||
-rw-r--r-- | aioeapi/device.py | 283 | ||||
-rw-r--r-- | aioeapi/errors.py | 30 | ||||
-rw-r--r-- | pyproject.toml | 37 |
11 files changed, 1078 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d8a435 --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +.pytest_tmpdir/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +failures.csv + +.idea/ +poetry.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..70bbacc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +default_language_version: + python: python3.10 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: debug-statements + - id: check-merge-conflict + - id: trailing-whitespace + - id: check-yaml + - id: check-toml + - id: check-added-large-files + +- repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + exclude: 'scratch-testing' + args: ['--ignore=E501,E203,W503,E731', + '--max-line-length=130', + '--per-file-ignores=__init__.py:F401'] + +- repo: https://github.com/psf/black + rev: 22.12.0 + hooks: + - id: black + args: ["."] @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020, Jeremy Schulman + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3838965 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: setup.py requirements.txt + +DIST_BASENAME := $(shell poetry version | tr ' ' '-') + +all: precheck + +.PHONY: prechck +precheck: + black . && \ + pre-commit run -a && \ + interrogate -c pyproject.toml + +package: setup.py requirements.txt + +setup.py: + poetry build && \ + tar --strip-components=1 -xvf dist/$(DIST_BASENAME).tar.gz '*/setup.py' + +requirements.txt: + poetry export --without-hashes > requirements.txt + +clean: + rm -rf dist *.egg-info .pytest_cache + rm -f requirements.txt setup.py + rm -f poetry.lock + find . -name '__pycache__' | xargs rm -rf diff --git a/README.md b/README.md new file mode 100644 index 0000000..622aeff --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Arista EOS API asyncio Client + +This repository contains an Arista EOS asyncio client. + +### Quick Example + +Thie following shows how to create a Device instance and run a list of +commands. + +Device will use HTTPS transport by default. The Device instance supports the +following initialization parameters: + + * `host` - The device hostname or IP address + * `username` - The login username + * `password` - The login password + * `proto` - *(Optional)* Choose either "https" or "http", defaults to "https" + * `port` - *(Optional)* Chose the protocol port to override proto default + +The Device class inherits directly from httpx.AsyncClient. As such, the Caller +can provide any initialization parameters. The above specific parameters are +all optional. + +```python +import json +from aioeapi import Device + +username = 'dummy-user' +password = 'dummy-password' + +async def run_test(host): + dev = Device(host=host, username=username, password=password) + res = await dev.cli(commands=['show hostname', 'show version']) + json.dumps(res) +``` + +### References + +Arista eAPI documents require an Arista Portal customer login. Once logged into the +system you can find the documents in the Software Download area. Select an EOS release +and then select the Docs folder. + +You can also take a look at the Arista community client, [here](https://github.com/arista-eosplus/pyeapi). + diff --git a/aioeapi/__init__.py b/aioeapi/__init__.py new file mode 100644 index 0000000..723a209 --- /dev/null +++ b/aioeapi/__init__.py @@ -0,0 +1,3 @@ +from .device import Device +from .errors import EapiCommandError +from .config_session import SessionConfig diff --git a/aioeapi/aio_portcheck.py b/aioeapi/aio_portcheck.py new file mode 100644 index 0000000..e82875d --- /dev/null +++ b/aioeapi/aio_portcheck.py @@ -0,0 +1,56 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from typing import Optional +import socket +import asyncio + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- + + +from httpx import URL + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["port_check_url"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +async def port_check_url(url: URL, timeout: Optional[int] = 5) -> bool: + """ + This function attempts to open the port designated by the URL given the + timeout in seconds. If the port is avaialble then return True; False + otherwise. + + Parameters + ---------- + url: + The URL that provides the target system + + timeout: optional, default is 5 seonds + Time to await for the port to open in seconds + """ + port = url.port or socket.getservbyname(url.scheme) + + try: + wr: asyncio.StreamWriter + _, wr = await asyncio.wait_for( + asyncio.open_connection(host=url.host, port=port), timeout=timeout + ) + + # MUST close if opened! + wr.close() + return True + + except Exception: # noqa + return False diff --git a/aioeapi/config_session.py b/aioeapi/config_session.py new file mode 100644 index 0000000..f1beb5e --- /dev/null +++ b/aioeapi/config_session.py @@ -0,0 +1,259 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- +import re +from typing import Optional, TYPE_CHECKING, Union, List + +if TYPE_CHECKING: + from .device import Device + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + +__all__ = ["SessionConfig"] + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class SessionConfig: + """ + The SessionConfig instance is used to send configuration to a device using + the EOS session mechanism. This is the preferred way of managing + configuraiton changes. + + Notes + ----- + This class definition is used by the parent Device class definition as + defined by `config_session`. A Caller can use the SessionConfig directly + as well, but it is not required. + """ + + CLI_CFG_FACTORY_RESET = "rollback clean-config" + + def __init__(self, device: "Device", name: str): + """ + Creates a new instance of the session config instance bound + to the given device instance, and using the session `name`. + + Parameters + ---------- + device: + The associated device instance + + name: + The name of the config session + """ + self._device = device + self._cli = device.cli + self._name = name + self._cli_config_session = f"configure session {self.name}" + + # ------------------------------------------------------------------------- + # properties for read-only attributes + # ------------------------------------------------------------------------- + + @property + def name(self) -> str: + """returns read-only session name attribute""" + return self._name + + @property + def device(self) -> "Device": + """returns read-only device instance attribute""" + return self._device + + # ------------------------------------------------------------------------- + # Public Methods + # ------------------------------------------------------------------------- + + async def status_all(self) -> dict: + """ + Get the status of the session config by running the command: + # show configuration sessions detail + + Returns + ------- + dict object of native EOS eAPI response; see `status` method for + details. + """ + return await self._cli("show configuration sessions detail") + + async def status(self) -> Union[dict, None]: + """ + Get the status of the session config by running the command: + # show configuration sessions detail + + And returning only the status dictionary for this session. If you want + all sessions, then use the `status_all` method. + + Returns + ------- + Dict instance of the session status. If the session does not exist, + then this method will return None. + + The native eAPI results from JSON output, see exmaple: + + Examples + -------- + all results: + { + "maxSavedSessions": 1, + "maxOpenSessions": 5, + "sessions": { + "jeremy1": { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + }, + "ansible_167510439362": { + "instances": {}, + "state": "completed", + "commitUser": "joe.bob", + "description": "", + "completedTime": 1675104396.4500246 + } + } + } + + if the session name was 'jeremy1', then this method would return + { + "instances": {}, + "state": "pending", + "commitUser": "", + "description": "" + } + """ + res = await self.status_all() + return res["sessions"].get(self.name) + + async def push( + self, content: Union[List[str], str], replace: Optional[bool] = False + ): + """ + Sends the configuration content to the device. If `replace` is true, + then the command "rollback clean-config" is issued before sendig the + configuration content. + + Parameters + ---------- + content: Union[List[str], str] + The text configuration CLI commands, as a list of strings, that + will be sent to the device. If the parameter is a string, and not + a list, then split the string across linebreaks. In either case + any empty lines will be discarded before they are send to the + device. + + replace: bool + When True, the content will replace the existing configuration + on the device. + """ + + # if given s string, we need to break it up into individual command + # lines. + + if isinstance(content, str): + content = content.splitlines() + + # prepare the initial set of command to enter the config session and + # rollback clean if the `replace` argument is True. + + commands = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + # add the Caller's commands, filtering out any blank lines. any command + # lines (!) are still included. + + commands.extend(filter(None, content)) + + await self._cli(commands=commands) + + async def commit(self, timer: Optional[str] = None): + """ + Commits the session config using the commands + # configure session <name> + # commit + + If the timer is specified, format is "hh:mm:ss", then a commit timer is + started. A second commit action must be made to confirm the config + session before the timer expires; otherwise the config-session is + automatically aborted. + """ + command = f"{self._cli_config_session} commit" + + if timer: + command += f" timer {timer}" + + await self._cli(command) + + async def abort(self): + """ + Aborts the configuration session using the command: + # configure session <name> abort + """ + await self._cli(f"{self._cli_config_session} abort") + + async def diff(self) -> str: + """ + Returns the "diff" of the session config relative to the running config, using + the command: + # show session-config named <name> diffs + + Returns + ------- + Returns a string in diff-patch format. + + References + ---------- + * https://www.gnu.org/software/diffutils/manual/diffutils.txt + """ + return await self._cli( + f"show session-config named {self.name} diffs", ofmt="text" + ) + + async def load_scp_file(self, filename: str, replace: Optional[bool] = False): + """ + This function is used to load the configuration from <filename> into + the session configuration. If the replace parameter is True then the + file contents will replace the existing session config (load-replace). + + Parameters + ---------- + filename: + The name of the configuration file. The caller is required to + specify the filesystem, for exmaple, the + filename="flash:thisfile.cfg" + + replace: + When True, the contents of the file will completely replace the + session config for a load-replace behavior. + + Raises + ------- + If there are any issues with loading the configuration file then a + RuntimeError is raised with the error messages content. + """ + commands = [self._cli_config_session] + if replace: + commands.append(self.CLI_CFG_FACTORY_RESET) + + commands.append(f"copy {filename} session-config") + res = await self._cli(commands=commands) + checks_re = re.compile(r"error|abort|invalid", flags=re.I) + messages = res[-1]["messages"] + + if any(map(checks_re.search, messages)): + raise RuntimeError("".join(messages)) + + async def write(self): + """ + Saves the running config to the startup config by issuing the command + "write" to the device. + """ + await self._cli("write") diff --git a/aioeapi/device.py b/aioeapi/device.py new file mode 100644 index 0000000..ae2aef5 --- /dev/null +++ b/aioeapi/device.py @@ -0,0 +1,283 @@ +# ----------------------------------------------------------------------------- +# System Imports +# ----------------------------------------------------------------------------- + +from typing import Optional, List, Union, Dict, AnyStr +from socket import getservbyname + +# ----------------------------------------------------------------------------- +# Public Imports +# ----------------------------------------------------------------------------- + +import httpx + +# ----------------------------------------------------------------------------- +# Private Imports +# ----------------------------------------------------------------------------- + +from .aio_portcheck import port_check_url +from .errors import EapiCommandError +from .config_session import SessionConfig + +# ----------------------------------------------------------------------------- +# Exports +# ----------------------------------------------------------------------------- + + +__all__ = ["Device"] + + +# ----------------------------------------------------------------------------- +# +# CODE BEGINS +# +# ----------------------------------------------------------------------------- + + +class Device(httpx.AsyncClient): + """ + The Device class represents the async JSON-RPC client that communicates with + an Arista EOS device. This class inherits directly from the + httpx.AsyncClient, so any initialization options can be passed directly. + """ + + auth = None + EAPI_OFMT_OPTIONS = ("json", "text") + EAPI_DEFAULT_OFMT = "json" + + def __init__( + self, + host: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + proto: Optional[str] = "https", + port=None, + **kwargs, + ): + """ + Initializes the Device class. As a subclass to httpx.AsyncClient, the + Caller can provide any of those initializers. Specific paramertes for + Device class are all optional and described below. + + Parameters + ---------- + host: Optional[str] + The EOS target device, either hostname (DNS) or ipaddress. + + username: Optional[str] + The login user-name; requires the password parameter. + + password: Optional[str] + The login password; requires the username parameter. + + proto: Optional[str] + The protocol, http or https, to communicate eAPI with the device. + + port: Optional[Union[str,int]] + If not provided, the proto value is used to look up the associated + port (http=80, https=443). If provided, overrides the port used to + communite with the device. + + Other Parameters + ---------------- + base_url: str + If provided, the complete URL to the device eAPI endpoint. + + auth: + If provided, used as the httpx authorization initializer value. If + not provided, then username+password is assumed by the Caller and + used to create a BasicAuth instance. + """ + + self.port = port or getservbyname(proto) + self.host = host + kwargs.setdefault("base_url", httpx.URL(f"{proto}://{self.host}:{self.port}")) + kwargs.setdefault("verify", False) + + if username and password: + self.auth = httpx.BasicAuth(username, password) + + kwargs.setdefault("auth", self.auth) + + super(Device, self).__init__(**kwargs) + self.headers["Content-Type"] = "application/json-rpc" + + async def check_connection(self) -> bool: + """ + This function checks the target device to ensure that the eAPI port is + open and accepting connections. It is recommended that a Caller checks + the connection before involing cli commands, but this step is not + required. + + Returns + ------- + True when the device eAPI is accessible, False otherwise. + """ + return await port_check_url(self.base_url) + + async def cli( + self, + command: Optional[AnyStr] = None, + commands: Optional[List[AnyStr]] = None, + ofmt: Optional[str] = None, + suppress_error: Optional[bool] = False, + version: Optional[Union[int, str]] = "latest", + **kwargs, + ): + """ + Execute one or more CLI commands. + + Parameters + ---------- + command: str + A single command to execute; results in a single output response + + commands: List[str] + A list of commands to execute; results in a list of output responses + + ofmt: str + Either 'json' or 'text'; indicates the output fromat for the CLI commands. + + suppress_error: Optional[bool] = False + When not False, then if the execution of the command would-have + raised an EapiCommandError, rather than raising this exception this + routine will return the value None. + + For example, if the following command had raised + EapiCommandError, now response would be set to None instead. + + response = dev.cli(..., suppress_error=True) + + version: Optional[int | string] + By default the eAPI will use "version 1" for all API object models. + This driver will, by default, always set version to "latest" so + that the behavior matches the CLI of the device. The caller can + override the "latest" behavior by explicity setting the version. + + + Other Parameters + ---------------- + autoComplete: Optional[bool] = False + Enabled/disables the command auto-compelete feature of the EAPI. Per the + documentation: + Allows users to use shorthand commands in eAPI calls. With this + parameter included a user can send 'sh ver' via eAPI to get the + output of 'show version'. + + expandAliases: Optional[bool] = False + Enables/disables the command use of User defined alias. Per the + documentation: + Allowed users to provide the expandAliases parameter to eAPI + calls. This allows users to use aliased commands via the API. + For example if an alias is configured as 'sv' for 'show version' + then an API call with sv and the expandAliases parameter will + return the output of show version. + + Returns + ------- + One or List of output respones, per the description above. + """ + if not any((command, commands)): + raise RuntimeError("Required 'command' or 'commands'") + + jsonrpc = self.jsoncrpc_command( + commands=[command] if command else commands, + ofmt=ofmt, + version=version, + **kwargs, + ) + + try: + res = await self.jsonrpc_exec(jsonrpc) + return res[0] if command else res + except EapiCommandError as eapi_error: + if suppress_error: + return None + raise eapi_error + + def jsoncrpc_command(self, commands, ofmt, version, **kwargs) -> dict: + """Used to create the JSON-RPC command dictionary object""" + + cmd = { + "jsonrpc": "2.0", + "method": "runCmds", + "params": { + "version": version, + "cmds": commands, + "format": ofmt or self.EAPI_DEFAULT_OFMT, + }, + "id": str(kwargs.get("req_id") or id(self)), + } + if "autoComplete" in kwargs: + cmd["params"]["autoComplete"] = kwargs["autoComplete"] + + if "expandAliases" in kwargs: + cmd["params"]["expandAliases"] = kwargs["expandAliases"] + + return cmd + + async def jsonrpc_exec(self, jsonrpc: dict) -> List[Union[Dict, AnyStr]]: + """ + Execute the JSON-RPC dictionary object. + + Parameters + ---------- + jsonrpc: dict + The JSON-RPC as created by the `meth`:jsonrpc_command(). + + Raises + ------ + EapiCommandError + In the event that a command resulted in an error response. + + Returns + ------- + The list of command results; either dict or text depending on the + JSON-RPC format pameter. + """ + res = await self.post("/command-api", json=jsonrpc) + res.raise_for_status() + body = res.json() + + commands = jsonrpc["params"]["cmds"] + ofmt = jsonrpc["params"]["format"] + + get_output = (lambda _r: _r["output"]) if ofmt == "text" else (lambda _r: _r) + + # if there are no errors then return the list of command results. + + if (err_data := body.get("error")) is None: + return [get_output(cmd_res) for cmd_res in body["result"]] + + # --------------------------------------------------------------------- + # if we are here, then there were some command errors. Raise a + # EapiCommandError exception with args (commands that failed, passed, + # not-executed). + # --------------------------------------------------------------------- + + cmd_data = err_data["data"] + len_data = len(cmd_data) + err_at = len_data - 1 + err_msg = err_data["message"] + + raise EapiCommandError( + passed=[ + get_output(cmd_data[cmd_i]) + for cmd_i, cmd in enumerate(commands[:err_at]) + ], + failed=commands[err_at], + errmsg=err_msg, + not_exec=commands[err_at + 1 :], + ) + + def config_session(self, name: str) -> SessionConfig: + """ + Factory method that returns a SessionConfig instance bound to this + device with the given session name. + + Parameters + ---------- + name: + The config-session name + """ + return SessionConfig(self, name) diff --git a/aioeapi/errors.py b/aioeapi/errors.py new file mode 100644 index 0000000..1c415b2 --- /dev/null +++ b/aioeapi/errors.py @@ -0,0 +1,30 @@ +import httpx + + +class EapiCommandError(RuntimeError): + """ + Exception class for EAPI command errors + + Attributes + ---------- + failed: str - the failed command + errmsg: str - a description of the failure reason + passed: List[dict] - a list of command results of the commands that passed + not_exec: List[str] - a list of commands that were not executed + """ + + def __init__(self, failed: str, errmsg: str, passed, not_exec): + """Initializer for the EapiCommandError exception""" + self.failed = failed + self.errmsg = errmsg + self.passed = passed + self.not_exec = not_exec + super(EapiCommandError, self).__init__() + + def __str__(self): + """returns the error message associated with the exception""" + return self.errmsg + + +# alias for exception during sending-receiving +EapiTransportError = httpx.HTTPStatusError diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4036f19 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[tool.poetry] +name = "aio-eapi" +version = "0.6.3" +description = "Arista EOS API asyncio client" +readme = "README.md" +authors = ["Jeremy Schulman"] +packages = [ + { include = 'aioeapi' }, +] + +[tool.poetry.dependencies] +python = ">=3.8" +httpx = "^0.23.3" + + +[tool.poetry.dev-dependencies] + pytest = "*" + invoke = "*" + black = "*" + flake8 = "*" + pytest-cov = "*" + pytest-asyncio = "*" + pre-commit = "*" + interrogate = "*" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.interrogate] + fail-under = 0 + verbose = 1 + color = true + ignore-module = true + exclude = ["examples", "build", "venv"] + + |