├── libyang ├── py.typed ├── log.py ├── __init__.py ├── util.py ├── keyed_list.py ├── extension.py ├── diff.py └── xpath.py ├── tests ├── __init__.py ├── yang-old │ ├── omg │ │ └── omg-extensions.yang │ ├── wtf │ │ └── wtf-types.yang │ └── yolo │ │ └── yolo-system.yang ├── yang │ ├── yang-library.json │ ├── yolo │ │ ├── yolo-leafref-extended.yang │ │ ├── yolo-leafref-search.yang │ │ ├── yolo-leafref-search-extmod.yang │ │ ├── yolo-nodetypes.yang │ │ └── yolo-system.yang │ ├── omg │ │ └── omg-extensions.yang │ ├── wtf │ │ └── wtf-types.yang │ └── ietf │ │ └── ietf-netconf@2011-06-01.yang ├── data │ └── config.json ├── test_log.py ├── test_keyedlist.py ├── test_diff.py ├── test_context.py ├── test_extension.py └── test_xpath.py ├── MANIFEST.in ├── pyproject.toml ├── cffi ├── source.c └── build.py ├── Makefile ├── .editorconfig ├── setup.cfg ├── LICENSE ├── tox-install.sh ├── tox.ini ├── .github └── workflows │ └── ci.yml ├── check-commits.sh ├── setup.py ├── README.rst └── pylintrc /libyang/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include cffi/build.py 2 | include cffi/cdefs.h 3 | include cffi/source.c 4 | include libyang/*.py 5 | include libyang/py.typed 6 | include libyang/VERSION 7 | include setup.py 8 | include setup.cfg 9 | include LICENSE 10 | include README.rst 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | # The minimum setuptools version is specific to the PEP 517 backend, 4 | # and may be stricter than the version required in `setup.py` 5 | "setuptools>=40.6.0", 6 | "cffi; platform_python_implementation != 'PyPy'", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | -------------------------------------------------------------------------------- /cffi/source.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2018-2022 Robin Jarry 3 | * SPDX-License-Identifier: MIT 4 | */ 5 | 6 | #include 7 | #include 8 | 9 | #if LY_VERSION_MAJOR * 10000 + LY_VERSION_MINOR * 100 + LY_VERSION_MICRO < 30801 10 | #error "This version of libyang bindings only works with libyang soversion 3.8.1+" 11 | #endif 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2023 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | 4 | all: lint tests 5 | 6 | lint: 7 | tox -e lint 8 | 9 | tests: 10 | tox -e py3 11 | 12 | format: 13 | tox -e format 14 | 15 | LYPY_COMMIT_RANGE ?= origin/master.. 16 | 17 | check-commits: 18 | ./check-commits.sh $(LYPY_COMMIT_RANGE) 19 | 20 | .PHONY: lint tests format check-commits 21 | -------------------------------------------------------------------------------- /tests/yang-old/omg/omg-extensions.yang: -------------------------------------------------------------------------------- 1 | module omg-extensions { 2 | namespace "urn:yang:omg:extensions"; 3 | prefix e; 4 | 5 | extension require-admin { 6 | description 7 | "Extend an rpc or node to require admin permissions."; 8 | } 9 | 10 | extension human-name { 11 | description 12 | "Extend a node to provide a human readable name."; 13 | argument name; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/yang/yang-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "ietf-yang-library:modules-state": { 3 | "module-set-id": "e595da11ace92c0d881995fa7e56bbe86f1f48e9", 4 | "module": [ 5 | { 6 | "name": "yolo-system", 7 | "revision": "1999-04-01", 8 | "namespace": "urn:yang:yolo:system", 9 | "conformance-type": "implement" 10 | } 11 | ] 12 | }, 13 | "ietf-yang-library:yang-library": { 14 | "content-id": "321566" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # ex: ft=dosini 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | 10 | [*.py] 11 | indent_style = space 12 | indent_size = 4 13 | max_line_length = 88 14 | 15 | [*.{yang,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.rst] 20 | indent_style = space 21 | indent_size = 3 22 | 23 | [Makefile] 24 | indent_style = tab 25 | indent_size = tab 26 | 27 | [*.sh] 28 | indent_style = tab 29 | indent_size = tab 30 | 31 | [*.{h,c}] 32 | indent_style = tab 33 | indent_size = tab 34 | -------------------------------------------------------------------------------- /tests/data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "yolo-system:conf": { 3 | "hostname": "foo", 4 | "speed": 1234, 5 | "number": [ 6 | 1000, 7 | 2000, 8 | 3000 9 | ], 10 | "url": [ 11 | { 12 | "proto": "https", 13 | "host": "github.com", 14 | "path": "/CESNET/libyang-python", 15 | "enabled": false 16 | }, 17 | { 18 | "proto": "http", 19 | "host": "foobar.com", 20 | "port": 8080, 21 | "path": "/index.html", 22 | "enabled": true 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/yang/yolo/yolo-leafref-extended.yang: -------------------------------------------------------------------------------- 1 | module yolo-leafref-extended { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:leafref-extended"; 4 | prefix leafref-ext; 5 | 6 | revision 2025-01-25 { 7 | description 8 | "Initial version."; 9 | } 10 | 11 | list list1 { 12 | key leaf1; 13 | leaf leaf1 { 14 | type string; 15 | } 16 | leaf-list leaflist2 { 17 | type string; 18 | } 19 | } 20 | 21 | leaf ref1 { 22 | type leafref { 23 | path "../list1/leaf1"; 24 | } 25 | } 26 | leaf ref2 { 27 | type leafref { 28 | path "deref(../ref1)/../leaflist2"; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/yang/yolo/yolo-leafref-search.yang: -------------------------------------------------------------------------------- 1 | module yolo-leafref-search { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:leafref-search"; 4 | prefix leafref-search; 5 | 6 | import wtf-types { prefix types; } 7 | 8 | revision 2025-02-11 { 9 | description 10 | "Initial version."; 11 | } 12 | 13 | list my_list { 14 | key my_leaf_string; 15 | leaf my_leaf_string { 16 | type string; 17 | } 18 | leaf my_leaf_number { 19 | description 20 | "A number."; 21 | type types:number; 22 | } 23 | } 24 | 25 | leaf refstr { 26 | type leafref { 27 | path "../my_list/my_leaf_string"; 28 | } 29 | } 30 | 31 | leaf refnum { 32 | type leafref { 33 | path "../my_list/my_leaf_number"; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/yang/omg/omg-extensions.yang: -------------------------------------------------------------------------------- 1 | module omg-extensions { 2 | namespace "urn:yang:omg:extensions"; 3 | prefix e; 4 | 5 | extension require-admin { 6 | description 7 | "Extend an rpc or node to require admin permissions."; 8 | } 9 | 10 | extension human-name { 11 | description 12 | "Extend a node to provide a human readable name."; 13 | argument name; 14 | } 15 | 16 | extension type-desc { 17 | description 18 | "Extend a type to add a desc."; 19 | argument name; 20 | } 21 | 22 | extension parse-validation { 23 | description 24 | "Example of parse-validation extension which should be put only under leaf nodes."; 25 | } 26 | 27 | extension compile-validation { 28 | description 29 | "Example of compile-validation extension which should be put only under leaf nodes."; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [sdist] 2 | formats = gztar 3 | owner = root 4 | group = root 5 | 6 | [bdist_wheel] 7 | universal = false 8 | 9 | [coverage:run] 10 | include = libyang/* 11 | omit = tests/test_* 12 | 13 | [coverage:report] 14 | skip_covered = false 15 | ignore_errors = true 16 | sort = Cover 17 | 18 | [flake8] 19 | # E713 test for membership should be 'not in' 20 | # C801 Copyright notice not present 21 | select = 22 | E713, 23 | C801, 24 | copyright-check = True 25 | copyright-min-file-size = 1 26 | copyright-regexp = Copyright \(c\) \d{4}(-\d{4})?.*\n.*SPDX-License-Identifier: MIT 27 | 28 | [isort] 29 | multi_line_output=3 30 | include_trailing_comma=True 31 | force_grid_wrap=0 32 | use_parentheses=True 33 | line_length=88 34 | lines_after_imports = 2 35 | force_sort_within_sections = True 36 | known_third_party = cffi 37 | known_first_party = _libyang 38 | default_section = FIRSTPARTY 39 | no_lines_before = LOCALFOLDER 40 | -------------------------------------------------------------------------------- /tests/yang-old/wtf/wtf-types.yang: -------------------------------------------------------------------------------- 1 | module wtf-types { 2 | namespace "urn:yang:wtf:types"; 3 | prefix t; 4 | 5 | typedef str { 6 | type string; 7 | } 8 | 9 | typedef host { 10 | type str { 11 | pattern "[a-z]+"; 12 | } 13 | } 14 | 15 | typedef unsigned { 16 | type union { 17 | type uint16; 18 | type uint32; 19 | } 20 | } 21 | 22 | typedef signed { 23 | type union { 24 | type int16; 25 | type int32; 26 | } 27 | } 28 | 29 | typedef number { 30 | type union { 31 | type unsigned; 32 | type signed; 33 | } 34 | } 35 | 36 | typedef protocol { 37 | type enumeration { 38 | enum http; 39 | enum https; 40 | enum ftp; 41 | enum sftp; 42 | enum tftp; 43 | } 44 | } 45 | 46 | typedef permissions { 47 | type bits { 48 | bit read; 49 | bit write; 50 | bit execute; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/yang/yolo/yolo-leafref-search-extmod.yang: -------------------------------------------------------------------------------- 1 | module yolo-leafref-search-extmod { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:leafref-search-extmod"; 4 | prefix leafref-search-extmod; 5 | 6 | import wtf-types { prefix types; } 7 | 8 | import yolo-leafref-search { 9 | prefix leafref-search; 10 | } 11 | 12 | revision 2025-02-11 { 13 | description 14 | "Initial version."; 15 | } 16 | 17 | list my_extref_list { 18 | key my_leaf_string; 19 | leaf my_leaf_string { 20 | type string; 21 | } 22 | leaf my_extref { 23 | type leafref { 24 | path "/leafref-search:my_list/leafref-search:my_leaf_string"; 25 | } 26 | } 27 | leaf my_extref_union { 28 | type union { 29 | type leafref { 30 | path "/leafref-search:my_list/leafref-search:my_leaf_string"; 31 | } 32 | type leafref { 33 | path "/leafref-search:my_list/leafref-search:my_leaf_number"; 34 | } 35 | type types:number; 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2020 Robin Jarry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cffi/build.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | 4 | import os 5 | import shlex 6 | from typing import List 7 | 8 | import cffi 9 | 10 | 11 | HERE = os.path.dirname(__file__) 12 | 13 | BUILDER = cffi.FFI() 14 | with open(os.path.join(HERE, "cdefs.h"), encoding="utf-8") as f: 15 | BUILDER.cdef(f.read()) 16 | 17 | 18 | def search_paths(env_var: str) -> List[str]: 19 | paths = [] 20 | for p in os.environ.get(env_var, "").strip().split(":"): 21 | p = p.strip() 22 | if p: 23 | paths.append(p) 24 | return paths 25 | 26 | 27 | HEADERS = search_paths("LIBYANG_HEADERS") 28 | LIBRARIES = search_paths("LIBYANG_LIBRARIES") 29 | EXTRA_CFLAGS = ["-Werror", "-std=c99"] 30 | EXTRA_CFLAGS += shlex.split(os.environ.get("LIBYANG_EXTRA_CFLAGS", "")) 31 | EXTRA_LDFLAGS = shlex.split(os.environ.get("LIBYANG_EXTRA_LDFLAGS", "")) 32 | 33 | with open(os.path.join(HERE, "source.c"), encoding="utf-8") as f: 34 | BUILDER.set_source( 35 | "_libyang", 36 | f.read(), 37 | libraries=["yang"], 38 | extra_compile_args=EXTRA_CFLAGS, 39 | extra_link_args=EXTRA_LDFLAGS, 40 | include_dirs=HEADERS, 41 | library_dirs=LIBRARIES, 42 | py_limited_api=False, 43 | ) 44 | 45 | if __name__ == "__main__": 46 | BUILDER.compile() 47 | -------------------------------------------------------------------------------- /tests/yang/wtf/wtf-types.yang: -------------------------------------------------------------------------------- 1 | module wtf-types { 2 | namespace "urn:yang:wtf:types"; 3 | prefix t; 4 | 5 | import omg-extensions { prefix ext; } 6 | 7 | typedef str { 8 | type string; 9 | } 10 | 11 | typedef host { 12 | type str { 13 | pattern "[a-z]+"; 14 | } 15 | description 16 | "my host type."; 17 | } 18 | 19 | extension signed; 20 | extension unsigned; 21 | 22 | typedef unsigned { 23 | type union { 24 | type uint16 { 25 | t:unsigned; 26 | ext:type-desc ""; 27 | } 28 | type uint32 { 29 | ext:type-desc ""; 30 | t:unsigned; 31 | } 32 | } 33 | } 34 | 35 | typedef signed { 36 | type union { 37 | type int16 { 38 | ext:type-desc ""; 39 | t:signed; 40 | } 41 | type int32 { 42 | ext:type-desc ""; 43 | t:signed; 44 | } 45 | } 46 | } 47 | 48 | typedef number { 49 | type union { 50 | type unsigned; 51 | type signed; 52 | } 53 | } 54 | 55 | typedef protocol { 56 | type enumeration { 57 | enum http; 58 | enum https; 59 | enum ftp { 60 | status deprecated; 61 | } 62 | enum sftp; 63 | } 64 | } 65 | 66 | typedef permissions { 67 | type bits { 68 | bit read; 69 | bit write; 70 | bit execute; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tox-install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright (c) 2020 Robin Jarry 3 | # SPDX-License-Identifier: MIT 4 | 5 | set -e 6 | 7 | venv="$1" 8 | shift 1 9 | 10 | download() 11 | { 12 | url="$1" 13 | branch="$2" 14 | dir="$3" 15 | if which git >/dev/null 2>&1; then 16 | git clone --single-branch --branch "$branch" --depth 1 \ 17 | "$url.git" "$dir" 18 | elif which curl >/dev/null 2>&1; then 19 | mkdir -p "$dir" 20 | curl -L "$url/archive/$branch.tar.gz" | \ 21 | tar --strip-components=1 -zx -C "$dir" 22 | elif which wget >/dev/null 2>&1; then 23 | mkdir -p "$dir" 24 | wget -O - "$url/archive/$branch.tar.gz" | \ 25 | tar --strip-components=1 -zx -C "$dir" 26 | else 27 | echo "ERROR: neither git nor curl nor wget are available" >&2 28 | exit 1 29 | fi 30 | } 31 | 32 | # build and install libyang into the virtualenv 33 | src="${LIBYANG_SRC:-$venv/.src}" 34 | if ! [ -d "$src" ]; then 35 | libyang_branch="${LIBYANG_BRANCH:-master}" 36 | download "https://github.com/CESNET/libyang" "$libyang_branch" "$src" 37 | fi 38 | 39 | prefix="$venv/.ly" 40 | 41 | build="$venv/.build" 42 | mkdir -p "$build" 43 | cmake -DCMAKE_BUILD_TYPE=debug \ 44 | -DENABLE_BUILD_TESTS=OFF \ 45 | -DENABLE_VALGRIND_TESTS=OFF \ 46 | -DENABLE_CALGRIND_TESTS=OFF \ 47 | -DENABLE_BUILD_FUZZ_TARGETS=OFF \ 48 | -DCMAKE_INSTALL_PREFIX="$prefix" \ 49 | -DCMAKE_INSTALL_LIBDIR=lib \ 50 | -DGEN_LANGUAGE_BINDINGS=OFF \ 51 | -H"$src" -B"$build" 52 | make --no-print-directory -C "$build" -j`nproc` 53 | make --no-print-directory -C "$build" install 54 | 55 | prefix=$(readlink -ve $prefix) 56 | 57 | LIBYANG_HEADERS="$prefix/include" \ 58 | LIBYANG_LIBRARIES="$prefix/lib" \ 59 | LIBYANG_EXTRA_LDFLAGS="-Wl,--enable-new-dtags,-rpath=$prefix/lib" \ 60 | python -m pip install "$@" 61 | -------------------------------------------------------------------------------- /tests/test_log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025, LabN Consulting, L.L.C. 2 | # SPDX-License-Identifier: MIT 3 | 4 | import logging 5 | import os 6 | import sys 7 | import unittest 8 | 9 | from libyang import Context, LibyangError, configure_logging, temp_log_options 10 | 11 | 12 | YANG_DIR = os.path.join(os.path.dirname(__file__), "yang") 13 | 14 | 15 | class LogTest(unittest.TestCase): 16 | def setUp(self): 17 | self.ctx = Context(YANG_DIR) 18 | configure_logging(False, logging.INFO) 19 | 20 | def tearDown(self): 21 | if self.ctx is not None: 22 | self.ctx.destroy() 23 | self.ctx = None 24 | 25 | def _cause_log(self): 26 | try: 27 | assert self.ctx is not None 28 | _ = self.ctx.parse_data_mem("bad", fmt="xml") 29 | except LibyangError: 30 | pass 31 | 32 | @unittest.skipIf(sys.version_info < (3, 10), "Test requires Python 3.10+") 33 | def test_configure_logging(self): 34 | """Test configure_logging API.""" 35 | with self.assertNoLogs("libyang", level="ERROR"): 36 | self._cause_log() 37 | 38 | configure_logging(True, logging.INFO) 39 | with self.assertLogs("libyang", level="ERROR"): 40 | self._cause_log() 41 | 42 | @unittest.skipIf(sys.version_info < (3, 10), "Test requires Python 3.10+") 43 | def test_with_temp_log(self): 44 | """Test configure_logging API.""" 45 | configure_logging(True, logging.INFO) 46 | 47 | with self.assertLogs("libyang", level="ERROR"): 48 | self._cause_log() 49 | 50 | with self.assertNoLogs("libyang", level="ERROR"): 51 | with temp_log_options(0): 52 | self._cause_log() 53 | 54 | with self.assertLogs("libyang", level="ERROR"): 55 | self._cause_log() 56 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = format,lint,py{39,310,311,312,313,py3},lydevel,coverage 3 | skip_missing_interpreters = true 4 | isolated_build = true 5 | distdir = {toxinidir}/dist 6 | 7 | [tox:.package] 8 | basepython = python3 9 | 10 | [testenv] 11 | description = Compile extension and run tests against {envname}. 12 | changedir = tests/ 13 | install_command = {toxinidir}/tox-install.sh {envdir} {opts} {packages} 14 | allowlist_externals = 15 | {toxinidir}/tox-install.sh 16 | commands = python -Wd -m unittest discover -c 17 | 18 | [testenv:lydevel] 19 | setenv = 20 | LIBYANG_BRANCH=devel 21 | 22 | [testenv:coverage] 23 | changedir = . 24 | deps = coverage 25 | install_command = {toxinidir}/tox-install.sh {envdir} {opts} {packages} 26 | allowlist_externals = 27 | {toxinidir}/tox-install.sh 28 | commands = 29 | python -Wd -m coverage run -m unittest discover -c tests/ 30 | python -m coverage report 31 | python -m coverage html 32 | python -m coverage xml 33 | 34 | [testenv:format] 35 | basepython = python3 36 | description = Format python code using isort and black. 37 | changedir = . 38 | deps = 39 | black~=25.1.0 40 | isort~=6.0.0 41 | skip_install = true 42 | install_command = python3 -m pip install {opts} {packages} 43 | allowlist_externals = 44 | /bin/sh 45 | /usr/bin/sh 46 | commands = 47 | sh -ec 'python3 -m isort $(git ls-files "*.py")' 48 | sh -ec 'python3 -m black -t py36 $(git ls-files "*.py")' 49 | 50 | [testenv:lint] 51 | basepython = python3 52 | description = Run coding style checks. 53 | changedir = . 54 | deps = 55 | astroid~=3.3.8 56 | black~=25.1.0 57 | flake8~=7.1.1 58 | isort~=6.0.0 59 | pycodestyle~=2.12.1 60 | pyflakes~=3.2.0 61 | pylint~=3.3.4 62 | setuptools~=75.8.0 63 | allowlist_externals = 64 | /bin/sh 65 | /usr/bin/sh 66 | {toxinidir}/tox-install.sh 67 | commands = 68 | sh -ec 'python3 -m black -t py36 --diff --check $(git ls-files "*.py")' 69 | sh -ec 'python3 -m flake8 $(git ls-files "*.py")' 70 | sh -ec 'python3 -m isort --diff --check-only $(git ls-files "*.py")' 71 | sh -ec 'python3 -m pylint $(git ls-files "*.py")' 72 | -------------------------------------------------------------------------------- /libyang/log.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # Copyright (c) 2020 6WIND S.A. 3 | # SPDX-License-Identifier: MIT 4 | 5 | from contextlib import contextmanager 6 | import logging 7 | 8 | from _libyang import ffi, lib 9 | from .util import c2str 10 | 11 | 12 | # ------------------------------------------------------------------------------------- 13 | LOG = logging.getLogger("libyang") 14 | LOG.addHandler(logging.NullHandler()) 15 | LOG_LEVELS = { 16 | lib.LY_LLERR: logging.ERROR, 17 | lib.LY_LLWRN: logging.WARNING, 18 | lib.LY_LLVRB: logging.INFO, 19 | lib.LY_LLDBG: logging.DEBUG, 20 | } 21 | 22 | 23 | def get_libyang_level(py_level): 24 | for ly_lvl, py_lvl in LOG_LEVELS.items(): 25 | if py_lvl == py_level: 26 | return ly_lvl 27 | return None 28 | 29 | 30 | @contextmanager 31 | def temp_log_options(opt: int = 0): 32 | opts = ffi.new("uint32_t *", opt) 33 | 34 | lib.ly_temp_log_options(opts) 35 | yield 36 | lib.ly_temp_log_options(ffi.NULL) 37 | 38 | 39 | @ffi.def_extern(name="lypy_log_cb") 40 | def libyang_c_logging_callback(level, msg, data_path, schema_path, line): 41 | args = [c2str(msg)] 42 | fmt = "%s" 43 | if data_path: 44 | fmt += ": %s" 45 | args.append(c2str(data_path)) 46 | if schema_path: 47 | fmt += ": %s" 48 | args.append(c2str(schema_path)) 49 | if line != 0: 50 | fmt += " line %u" 51 | args.append(line) 52 | LOG.log(LOG_LEVELS.get(level, logging.NOTSET), fmt, *args) 53 | 54 | 55 | def configure_logging(enable_py_logger: bool, level: int = logging.ERROR) -> None: 56 | """ 57 | Configure libyang logging behaviour. 58 | 59 | :arg enable_py_logger: 60 | If False, configure libyang to store the errors in the context until they are 61 | consumed when Context.error() is called. This is the default behaviour. 62 | 63 | If True, libyang log messages will be sent to the python "libyang" logger and 64 | will be processed according to the python logging configuration. Note that by 65 | default, the "libyang" python logger is created with a NullHandler() which means 66 | that all messages are lost until another handler is configured for that logger. 67 | :arg level: 68 | Python logging level. By default only ERROR messages are stored/logged. 69 | """ 70 | ly_level = get_libyang_level(level) 71 | if ly_level is not None: 72 | lib.ly_log_level(ly_level) 73 | if enable_py_logger: 74 | lib.ly_log_options(lib.LY_LOLOG | lib.LY_LOSTORE) 75 | lib.ly_set_log_clb(lib.lypy_log_cb) 76 | else: 77 | lib.ly_log_options(lib.LY_LOSTORE) 78 | lib.ly_set_log_clb(ffi.NULL) 79 | 80 | 81 | configure_logging(False, logging.ERROR) 82 | -------------------------------------------------------------------------------- /tests/yang-old/yolo/yolo-system.yang: -------------------------------------------------------------------------------- 1 | module yolo-system { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:system"; 4 | prefix sys; 5 | 6 | import omg-extensions { prefix ext; } 7 | import wtf-types { prefix types; } 8 | 9 | description 10 | "YOLO."; 11 | 12 | revision 1999-04-01 { 13 | description 14 | "Version update."; 15 | reference "RFC 2549 - IP over Avian Carriers with Quality of Service."; 16 | ext:human-name "2549"; 17 | } 18 | 19 | revision 1990-04-01 { 20 | description 21 | "Initial version."; 22 | reference "RFC 1149 - A Standard for the Transmission of IP Datagrams on Avian Carriers."; 23 | ext:human-name "1149"; 24 | } 25 | 26 | feature turbo-boost { 27 | description 28 | "Goes faster."; 29 | } 30 | 31 | feature networking { 32 | description 33 | "Supports networking."; 34 | } 35 | 36 | grouping tree { 37 | leaf hostname { 38 | description 39 | "The hostname."; 40 | type types:host; 41 | } 42 | 43 | leaf deprecated-leaf { 44 | description 45 | "A deprecated leaf."; 46 | type string; 47 | } 48 | 49 | leaf obsolete-leaf { 50 | description 51 | "An obsolete leaf."; 52 | type string; 53 | } 54 | 55 | list url { 56 | description 57 | "An URL."; 58 | key "proto host"; 59 | leaf proto { 60 | type types:protocol; 61 | } 62 | leaf host { 63 | type string { 64 | pattern "[a-z.]+"; 65 | pattern "1" { 66 | modifier "invert-match"; 67 | } 68 | } 69 | } 70 | leaf port { 71 | type uint16; 72 | } 73 | leaf path { 74 | type string; 75 | } 76 | } 77 | 78 | leaf number { 79 | description 80 | "A number."; 81 | type types:number; 82 | } 83 | 84 | leaf speed { 85 | description 86 | "The speed."; 87 | if-feature turbo-boost; 88 | type uint64; 89 | } 90 | 91 | leaf isolation-level { 92 | description 93 | "The level of isolation."; 94 | if-feature "turbo-boost or networking"; 95 | type uint32; 96 | } 97 | } 98 | 99 | container conf { 100 | description 101 | "Configuration."; 102 | uses tree; 103 | } 104 | 105 | container state { 106 | description 107 | "State."; 108 | config false; 109 | uses tree; 110 | } 111 | 112 | rpc chmod { 113 | description 114 | "Change permissions on path."; 115 | ext:require-admin; 116 | input { 117 | leaf path { 118 | type string; 119 | } 120 | leaf perms { 121 | type types:permissions; 122 | } 123 | } 124 | } 125 | 126 | rpc format-disk { 127 | description 128 | "Format disk."; 129 | ext:require-admin; 130 | input { 131 | leaf disk { 132 | ext:human-name "Disk"; 133 | type types:str; 134 | } 135 | } 136 | output { 137 | leaf duration { 138 | type uint32; 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/yang/yolo/yolo-nodetypes.yang: -------------------------------------------------------------------------------- 1 | module yolo-nodetypes { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:nodetypes"; 4 | prefix sys; 5 | 6 | import ietf-inet-types { 7 | prefix inet; 8 | revision-date 2013-07-15; 9 | } 10 | 11 | description 12 | "YOLO Nodetypes."; 13 | 14 | revision 2024-01-25 { 15 | description 16 | "Initial version."; 17 | } 18 | 19 | list records { 20 | key id; 21 | leaf id { 22 | type string; 23 | } 24 | leaf name { 25 | type string; 26 | default "ASD"; 27 | } 28 | ordered-by user; 29 | } 30 | 31 | container conf { 32 | presence "enable conf"; 33 | description 34 | "Configuration."; 35 | leaf percentage { 36 | type decimal64 { 37 | fraction-digits 2; 38 | } 39 | default 10.2; 40 | must ". = 10.6" { 41 | error-message "ERROR1"; 42 | } 43 | } 44 | 45 | leaf-list ratios { 46 | type decimal64 { 47 | fraction-digits 2; 48 | } 49 | default 2.5; 50 | default 2.6; 51 | } 52 | 53 | leaf-list bools { 54 | type boolean; 55 | default true; 56 | } 57 | 58 | leaf-list integers { 59 | type uint32; 60 | default 10; 61 | default 20; 62 | } 63 | 64 | list list1 { 65 | key leaf1; 66 | unique "leaf2 leaf3"; 67 | min-elements 2; 68 | max-elements 10; 69 | leaf leaf1 { 70 | type string; 71 | } 72 | leaf leaf2 { 73 | type string; 74 | } 75 | leaf leaf3 { 76 | type string; 77 | } 78 | } 79 | 80 | list list2 { 81 | key leaf1; 82 | leaf leaf1 { 83 | type string; 84 | } 85 | } 86 | 87 | leaf-list leaf-list1 { 88 | type string; 89 | min-elements 3; 90 | max-elements 11; 91 | } 92 | 93 | leaf-list leaf-list2 { 94 | type string; 95 | } 96 | } 97 | 98 | leaf test1 { 99 | type uint8 { 100 | range "2..20"; 101 | } 102 | } 103 | 104 | grouping grp1 { 105 | container cont3 { 106 | leaf leaf1 { 107 | type string; 108 | } 109 | } 110 | } 111 | 112 | container cont2 { 113 | presence "special container enabled"; 114 | uses grp1 { 115 | refine cont3/leaf1 { 116 | mandatory true; 117 | } 118 | augment cont3 { 119 | leaf leaf2 { 120 | type int8; 121 | } 122 | } 123 | } 124 | notification interface-enabled { 125 | leaf by-user { 126 | type string; 127 | } 128 | } 129 | } 130 | 131 | anydata any1 { 132 | when "../cont2"; 133 | } 134 | 135 | extension identity-name { 136 | description 137 | "Extend an identity to provide an alternative name."; 138 | argument name; 139 | } 140 | 141 | identity base1 { 142 | description 143 | "Base 1."; 144 | reference "Some reference."; 145 | } 146 | identity base2; 147 | 148 | identity derived1 { 149 | base base1; 150 | } 151 | 152 | identity derived2 { 153 | base base1; 154 | sys:identity-name "Derived2"; 155 | } 156 | 157 | identity derived3 { 158 | base derived1; 159 | } 160 | 161 | leaf identity_ref { 162 | type identityref { 163 | base base1; 164 | base base2; 165 | } 166 | } 167 | 168 | leaf ip-address { 169 | type inet:ipv4-address; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | --- 4 | name: CI 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - master 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | - v* 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-24.04 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-python@v4 22 | with: 23 | python-version: 3.x 24 | - uses: actions/cache@v3 25 | with: 26 | path: ~/.cache/pip 27 | key: pip 28 | restore-keys: pip 29 | - run: python -m pip install --upgrade pip setuptools wheel 30 | - run: python -m pip install --upgrade tox 31 | - run: python -m tox -e lint 32 | 33 | check-commits: 34 | if: ${{ github.event.pull_request.commits }} 35 | runs-on: ubuntu-24.04 36 | env: 37 | LYPY_COMMIT_RANGE: "HEAD~${{ github.event.pull_request.commits }}.." 38 | steps: 39 | - run: sudo apt-get install git make jq curl 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | ref: ${{ github.event.pull_request.head.sha || github.ref }} 44 | - run: make check-commits 45 | 46 | test: 47 | runs-on: ubuntu-24.04 48 | strategy: 49 | matrix: 50 | include: 51 | - python: "3.9" 52 | toxenv: py39 53 | - python: "3.10" 54 | toxenv: py310 55 | - python: "3.11" 56 | toxenv: py311 57 | - python: "3.12" 58 | toxenv: py312 59 | - python: "3.13" 60 | toxenv: py313 61 | - python: pypy3.9 62 | toxenv: pypy3 63 | steps: 64 | - uses: actions/checkout@v3 65 | - uses: actions/setup-python@v4 66 | with: 67 | python-version: "${{ matrix.python }}" 68 | - uses: actions/cache@v3 69 | with: 70 | path: ~/.cache/pip 71 | key: pip 72 | restore-keys: pip 73 | - run: python -m pip install --upgrade pip setuptools wheel 74 | - run: python -m pip install --upgrade tox 75 | - run: python -m tox -e ${{ matrix.toxenv }} 76 | 77 | coverage: 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/checkout@v3 81 | - uses: actions/setup-python@v4 82 | with: 83 | python-version: 3.x 84 | - uses: actions/cache@v3 85 | with: 86 | path: ~/.cache/pip 87 | key: pip 88 | restore-keys: pip 89 | - run: python -m pip install --upgrade pip setuptools wheel 90 | - run: python -m pip install --upgrade tox 91 | - run: python -m tox -e coverage 92 | - uses: codecov/codecov-action@v3 93 | 94 | deploy: 95 | needs: [lint, test] 96 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 97 | runs-on: ubuntu-24.04 98 | steps: 99 | - uses: actions/checkout@v3 100 | - uses: actions/setup-python@v4 101 | with: 102 | python-version: 3.x 103 | - uses: actions/cache@v3 104 | with: 105 | path: ~/.cache/pip 106 | key: pip 107 | restore-keys: pip 108 | - run: python -m pip install --upgrade pip setuptools wheel 109 | - run: python -m pip install --upgrade pep517 twine 110 | - run: python -m pep517.build --out-dir dist/ --source . 111 | - uses: pypa/gh-action-pypi-publish@release/v1 112 | with: 113 | user: ${{ secrets.pypi_user }} 114 | password: ${{ secrets.pypi_password }} 115 | -------------------------------------------------------------------------------- /check-commits.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | revision_range="${1?revision range}" 6 | 7 | valid=0 8 | revisions=$(git rev-list --reverse "$revision_range") 9 | total=$(echo $revisions | wc -w) 10 | if [ "$total" -eq 0 ]; then 11 | exit 0 12 | fi 13 | tmp=$(mktemp) 14 | trap "rm -f $tmp" EXIT 15 | 16 | allowed_trailers=" 17 | Closes 18 | Fixes 19 | Link 20 | Suggested-by 21 | Requested-by 22 | Reported-by 23 | Co-authored-by 24 | Signed-off-by 25 | Tested-by 26 | Reviewed-by 27 | Acked-by 28 | " 29 | 30 | n=0 31 | title= 32 | shortrev= 33 | fail=false 34 | repo=CESNET/libyang-python 35 | repo_url=https://github.com/$repo 36 | api_url=https://api.github.com/repos/$repo 37 | 38 | err() { 39 | 40 | echo "error: commit $shortrev (\"$title\") $*" >&2 41 | fail=true 42 | } 43 | 44 | check_issue() { 45 | curl -f -X GET -L --no-progress-meter \ 46 | -H "Accept: application/vnd.github+json" \ 47 | -H "X-GitHub-Api-Version: 2022-11-28" \ 48 | "$api_url/issues/${1##*/}" | jq -r .state | grep -Fx open 49 | } 50 | 51 | for rev in $revisions; do 52 | n=$((n + 1)) 53 | title=$(git log --format='%s' -1 "$rev") 54 | fail=false 55 | shortrev=$(printf '%-12.12s' $rev) 56 | 57 | if [ "$(echo "$title" | wc -m)" -gt 72 ]; then 58 | err "title is longer than 72 characters, please make it shorter" 59 | fi 60 | if ! echo "$title" | grep -qE '^[a-z0-9,{}/_-]+: '; then 61 | err "title lacks a lowercase topic prefix (e.g. 'data: ')" 62 | fi 63 | if echo "$title" | grep -qE '^[a-z0-9,{}/_-]+: [A-Z][a-z]'; then 64 | err "title starts with an capital letter, please use lower case" 65 | fi 66 | if ! echo "$title" | grep -qE '[A-Za-z0-9]$'; then 67 | err "title ends with punctuation, please remove it" 68 | fi 69 | 70 | author=$(git log --format='%an <%ae>' -1 "$rev") 71 | if ! git log --format="%(trailers:key=Signed-off-by,only,valueonly,unfold)" -1 "$rev" | 72 | grep -qFx "$author"; then 73 | err "'Signed-off-by: $author' trailer is missing" 74 | fi 75 | 76 | for trailer in $(git log --format="%(trailers:only,keyonly)" -1 "$rev"); do 77 | if ! echo "$allowed_trailers" | grep -qFx "$trailer"; then 78 | err "trailer '$trailer' is misspelled or not in the sanctioned list" 79 | fi 80 | done 81 | 82 | git log --format="%(trailers:key=Closes,only,valueonly,unfold)" -1 "$rev" > $tmp 83 | while read -r value; do 84 | if [ -z "$value" ]; then 85 | continue 86 | fi 87 | case "$value" in 88 | $repo_url/*/[0-9]*) 89 | if ! check_issue "$value"; then 90 | err "'$value' does not reference a valid open issue" 91 | fi 92 | ;; 93 | \#[0-9]*) 94 | err "please use the full issue URL: 'Closes: $repo_url/issues/$value'" 95 | ;; 96 | *) 97 | err "invalid trailer value '$value'. The 'Closes:' trailer must only be used to reference issue URLs" 98 | ;; 99 | esac 100 | done < "$tmp" 101 | 102 | git log --format="%(trailers:key=Fixes,only,valueonly,unfold)" -1 "$rev" > $tmp 103 | while read -r value; do 104 | if [ -z "$value" ]; then 105 | continue 106 | fi 107 | fixes_rev=$(echo "$value" | sed -En 's/([A-Fa-f0-9]{7,}[[:space:]]\(".*"\))/\1/p') 108 | if ! git cat-file commit "$fixes_rev" >/dev/null; then 109 | err "trailer '$value' does not refer to a known commit" 110 | fi 111 | done < "$tmp" 112 | 113 | body=$(git log --format='%b' -1 "$rev") 114 | body=${body%$(git log --format='%(trailers)' -1 "$rev")} 115 | if [ "$(echo "$body" | wc -w)" -lt 3 ]; then 116 | err "body has less than three words, please describe your changes" 117 | fi 118 | 119 | if [ "$fail" = true ]; then 120 | continue 121 | fi 122 | echo "ok commit $shortrev (\"$title\")" 123 | valid=$((valid + 1)) 124 | done 125 | 126 | echo "$valid/$total valid commit messages" 127 | if [ "$valid" -ne "$total" ]; then 128 | exit 1 129 | fi 130 | -------------------------------------------------------------------------------- /tests/test_keyedlist.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 6WIND S.A. 2 | # SPDX-License-Identifier: MIT 3 | 4 | import unittest 5 | 6 | import libyang as ly 7 | 8 | 9 | # ------------------------------------------------------------------------------------- 10 | class KeyedListTest(unittest.TestCase): 11 | def test_keylist_base(self): 12 | l = ly.KeyedList(["x"]) 13 | with self.assertRaises(TypeError): 14 | l.index("x") 15 | with self.assertRaises(TypeError): 16 | l.insert(0, "x") 17 | with self.assertRaises(TypeError): 18 | l.reverse() 19 | with self.assertRaises(TypeError): 20 | l.sort() 21 | with self.assertRaises(TypeError): 22 | l["x"] = 2 23 | with self.assertRaises(TypeError): 24 | l[0] # pylint: disable=pointless-statement 25 | with self.assertRaises(TypeError): 26 | del l[0] 27 | with self.assertRaises(TypeError): 28 | l.pop() 29 | with self.assertRaises(TypeError): 30 | l.pop(0) 31 | 32 | def test_keylist_leaflist(self): 33 | l = ly.KeyedList("abcdef") 34 | self.assertEqual(len(l), 6) 35 | self.assertIn("e", l) 36 | l.remove("e") 37 | self.assertNotIn("e", l) 38 | self.assertIn("c", l) 39 | del l["c"] 40 | self.assertNotIn("c", l) 41 | with self.assertRaises(KeyError): 42 | del l["x"] 43 | with self.assertRaises(KeyError): 44 | l.remove("x") 45 | self.assertEqual(l.pop("x", "not-found"), "not-found") 46 | self.assertEqual(l.pop("b"), "b") 47 | self.assertNotIn("b", l) 48 | l.clear() 49 | self.assertEqual(len(l), 0) 50 | self.assertEqual(l, []) 51 | 52 | def test_keylist_list_one_key(self): 53 | l = ly.KeyedList( 54 | [ 55 | {"mtu": 1500, "name": "eth0"}, 56 | {"mtu": 512, "name": "eth2"}, 57 | {"mtu": 1280, "name": "eth4"}, 58 | ], 59 | key_name="name", 60 | ) 61 | self.assertIn("eth2", l) 62 | self.assertEqual(l["eth4"], {"mtu": 1280, "name": "eth4"}) 63 | del l["eth0"] 64 | self.assertEqual( 65 | l, [{"mtu": 512, "name": "eth2"}, {"mtu": 1280, "name": "eth4"}] 66 | ) 67 | l.append({"name": "eth6", "mtu": 6}) 68 | l.append({"name": "eth5", "mtu": 5}) 69 | l.append({"name": "eth10", "mtu": 10}) 70 | self.assertEqual( 71 | l, 72 | # order does not matter 73 | [ 74 | {"mtu": 10, "name": "eth10"}, 75 | {"mtu": 512, "name": "eth2"}, 76 | {"mtu": 5, "name": "eth5"}, 77 | {"mtu": 6, "name": "eth6"}, 78 | {"mtu": 1280, "name": "eth4"}, 79 | ], 80 | ) 81 | self.assertEqual(l.pop("eth10000", {}), {}) 82 | self.assertEqual(l.pop("eth10"), {"mtu": 10, "name": "eth10"}) 83 | self.assertNotIn("eth10", l) 84 | l.clear() 85 | self.assertEqual(len(l), 0) 86 | self.assertEqual(l, []) 87 | 88 | def test_keylist_list_multiple_keys(self): 89 | l = ly.KeyedList( 90 | [ 91 | {"id": 5, "size": 10, "desc": "foo"}, 92 | {"id": 8, "size": 5, "desc": "bar"}, 93 | {"id": 2, "size": 14, "desc": "baz"}, 94 | ], 95 | key_name=("id", "size"), 96 | ) 97 | self.assertIn(("8", "5"), l) 98 | self.assertEqual(l["8", "5"], {"id": 8, "size": 5, "desc": "bar"}) 99 | self.assertEqual(l[("2", "14")], {"id": 2, "size": 14, "desc": "baz"}) 100 | del l["2", "14"] 101 | self.assertEqual( 102 | l, 103 | [{"id": 5, "size": 10, "desc": "foo"}, {"id": 8, "size": 5, "desc": "bar"}], 104 | ) 105 | l.clear() 106 | self.assertEqual(len(l), 0) 107 | self.assertEqual(l, []) 108 | -------------------------------------------------------------------------------- /tests/test_diff.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | 4 | import os 5 | import unittest 6 | 7 | from libyang import ( 8 | BaseTypeAdded, 9 | BaseTypeRemoved, 10 | Context, 11 | DefaultAdded, 12 | EnumRemoved, 13 | EnumStatusAdded, 14 | EnumStatusRemoved, 15 | ExtensionAdded, 16 | NodeTypeAdded, 17 | NodeTypeRemoved, 18 | SNodeAdded, 19 | SNodeRemoved, 20 | StatusAdded, 21 | StatusRemoved, 22 | schema_diff, 23 | ) 24 | 25 | 26 | OLD_YANG_DIR = os.path.join(os.path.dirname(__file__), "yang-old") 27 | NEW_YANG_DIR = os.path.join(os.path.dirname(__file__), "yang") 28 | 29 | 30 | # ------------------------------------------------------------------------------------- 31 | class DiffTest(unittest.TestCase): 32 | expected_diffs = frozenset( 33 | ( 34 | (BaseTypeAdded, "/yolo-system:conf/speed"), 35 | (BaseTypeAdded, "/yolo-system:state/speed"), 36 | (BaseTypeRemoved, "/yolo-system:conf/speed"), 37 | (BaseTypeRemoved, "/yolo-system:state/speed"), 38 | (DefaultAdded, "/yolo-system:conf/speed"), 39 | (DefaultAdded, "/yolo-system:state/speed"), 40 | (NodeTypeAdded, "/yolo-system:conf/number"), 41 | (NodeTypeAdded, "/yolo-system:state/number"), 42 | (NodeTypeRemoved, "/yolo-system:conf/number"), 43 | (NodeTypeRemoved, "/yolo-system:state/number"), 44 | (SNodeAdded, "/yolo-system:conf/full"), 45 | (SNodeAdded, "/yolo-system:state/full"), 46 | (SNodeAdded, "/yolo-system:conf/hostname-ref"), 47 | (SNodeAdded, "/yolo-system:conf/url/enabled"), 48 | (SNodeAdded, "/yolo-system:conf/url/fetch"), 49 | ( 50 | SNodeAdded, 51 | "/yolo-system:conf/url/fetch/input/timeout", 52 | ), 53 | ( 54 | SNodeAdded, 55 | "/yolo-system:conf/url/fetch/output/result", 56 | ), 57 | (SNodeAdded, "/yolo-system:state/hostname-ref"), 58 | (SNodeAdded, "/yolo-system:state/url/enabled"), 59 | (SNodeAdded, "/yolo-system:state/url/fetch"), 60 | ( 61 | SNodeAdded, 62 | "/yolo-system:state/url/fetch/input/timeout", 63 | ), 64 | ( 65 | SNodeAdded, 66 | "/yolo-system:state/url/fetch/output/result", 67 | ), 68 | (StatusAdded, "/yolo-system:conf/deprecated-leaf"), 69 | (StatusAdded, "/yolo-system:conf/obsolete-leaf"), 70 | (StatusAdded, "/yolo-system:state/deprecated-leaf"), 71 | (StatusAdded, "/yolo-system:state/obsolete-leaf"), 72 | (StatusRemoved, "/yolo-system:conf/deprecated-leaf"), 73 | (StatusRemoved, "/yolo-system:conf/obsolete-leaf"), 74 | (StatusRemoved, "/yolo-system:state/deprecated-leaf"), 75 | (StatusRemoved, "/yolo-system:state/obsolete-leaf"), 76 | (SNodeAdded, "/yolo-system:alarm-triggered"), 77 | (SNodeAdded, "/yolo-system:alarm-triggered/severity"), 78 | (SNodeAdded, "/yolo-system:alarm-triggered/description"), 79 | (SNodeAdded, "/yolo-system:config-change"), 80 | (SNodeAdded, "/yolo-system:config-change/edit"), 81 | (SNodeAdded, "/yolo-system:config-change/edit/target"), 82 | (EnumRemoved, "/yolo-system:conf/url/proto"), 83 | (EnumRemoved, "/yolo-system:state/url/proto"), 84 | (EnumStatusAdded, "/yolo-system:conf/url/proto"), 85 | (EnumStatusAdded, "/yolo-system:state/url/proto"), 86 | (ExtensionAdded, "/yolo-system:conf/url/proto"), 87 | (ExtensionAdded, "/yolo-system:state/url/proto"), 88 | (EnumStatusRemoved, "/yolo-system:conf/url/proto"), 89 | (EnumStatusRemoved, "/yolo-system:state/url/proto"), 90 | (SNodeAdded, "/yolo-system:conf/pill/red/out"), 91 | (SNodeAdded, "/yolo-system:state/pill/red/out"), 92 | (SNodeAdded, "/yolo-system:conf/pill/blue/in"), 93 | (SNodeAdded, "/yolo-system:state/pill/blue/in"), 94 | (SNodeAdded, "/yolo-system:alarm-triggered/severity"), 95 | (SNodeAdded, "/yolo-system:alarm-triggered/description"), 96 | ) 97 | ) 98 | 99 | def test_diff(self): 100 | with Context(OLD_YANG_DIR) as ctx_old, Context(NEW_YANG_DIR) as ctx_new: 101 | mod = ctx_old.load_module("yolo-system") 102 | mod.feature_enable_all() 103 | mod = ctx_new.load_module("yolo-system") 104 | mod.feature_enable_all() 105 | diffs = [] 106 | for d in schema_diff(ctx_old, ctx_new): 107 | if isinstance(d, (SNodeAdded, SNodeRemoved)): 108 | diffs.append((d.__class__, d.node.schema_path())) 109 | else: 110 | diffs.append((d.__class__, d.new.schema_path())) 111 | 112 | self.assertEqual(frozenset(diffs), self.expected_diffs) 113 | -------------------------------------------------------------------------------- /tests/yang/yolo/yolo-system.yang: -------------------------------------------------------------------------------- 1 | module yolo-system { 2 | yang-version 1.1; 3 | namespace "urn:yang:yolo:system"; 4 | prefix sys; 5 | 6 | import omg-extensions { prefix ext; } 7 | import wtf-types { prefix types; } 8 | 9 | description 10 | "YOLO."; 11 | 12 | revision 1999-04-01 { 13 | description 14 | "Version update."; 15 | reference "RFC 2549 - IP over Avian Carriers with Quality of Service."; 16 | ext:human-name "2549"; 17 | } 18 | 19 | revision 1990-04-01 { 20 | description 21 | "Initial version."; 22 | reference "RFC 1149 - A Standard for the Transmission of IP Datagrams on Avian Carriers."; 23 | ext:human-name "1149"; 24 | } 25 | 26 | feature turbo-boost { 27 | description 28 | "Goes faster."; 29 | } 30 | 31 | feature networking { 32 | description 33 | "Supports networking."; 34 | } 35 | 36 | grouping tree { 37 | leaf hostname { 38 | description 39 | "The hostname."; 40 | type types:host; 41 | } 42 | 43 | leaf hostname-ref { 44 | type leafref { 45 | path "../hostname"; 46 | require-instance false; 47 | } 48 | } 49 | 50 | leaf deprecated-leaf { 51 | status deprecated; 52 | description 53 | "A deprecated leaf."; 54 | type string; 55 | } 56 | 57 | leaf obsolete-leaf { 58 | status obsolete; 59 | description 60 | "An obsolete leaf."; 61 | type string; 62 | } 63 | 64 | choice pill { 65 | case red { 66 | leaf out { 67 | type boolean; 68 | } 69 | } 70 | case blue { 71 | leaf in { 72 | type boolean; 73 | } 74 | } 75 | default red; 76 | } 77 | 78 | list url { 79 | description 80 | "An URL."; 81 | key "proto host"; 82 | leaf proto { 83 | type types:protocol { 84 | ext:type-desc ""; 85 | } 86 | ext:parse-validation; 87 | } 88 | leaf host { 89 | type string { 90 | pattern "[a-z.]+" { 91 | error-message "ERROR1"; 92 | } 93 | pattern "1" { 94 | modifier "invert-match"; 95 | } 96 | } 97 | } 98 | leaf port { 99 | type uint16; 100 | } 101 | leaf path { 102 | type string; 103 | } 104 | leaf enabled { 105 | type boolean; 106 | } 107 | action fetch { 108 | input { 109 | leaf timeout { 110 | type uint16; 111 | } 112 | } 113 | output { 114 | leaf result { 115 | type string; 116 | } 117 | } 118 | ext:compile-validation; 119 | } 120 | } 121 | 122 | leaf-list number { 123 | description 124 | "A number."; 125 | type types:number; 126 | } 127 | 128 | leaf speed { 129 | description 130 | "The speed."; 131 | if-feature turbo-boost; 132 | type uint32; 133 | default 4321; 134 | } 135 | 136 | leaf offline { 137 | description 138 | "When networking is disabled."; 139 | if-feature "not networking"; 140 | type uint32; 141 | } 142 | 143 | leaf full { 144 | description 145 | "Fast and online."; 146 | if-feature "turbo-boost and networking"; 147 | type uint32; 148 | } 149 | 150 | leaf isolation-level { 151 | description 152 | "The level of isolation."; 153 | if-feature "turbo-boost or networking"; 154 | type uint32; 155 | } 156 | } 157 | 158 | ext:compile-validation "module-level" { 159 | ext:compile-validation "module-sub-level"; 160 | } 161 | 162 | container conf { 163 | description 164 | "Configuration."; 165 | uses tree; 166 | } 167 | 168 | container state { 169 | description 170 | "State."; 171 | config false; 172 | uses tree; 173 | } 174 | 175 | rpc chmod { 176 | description 177 | "Change permissions on path."; 178 | ext:require-admin; 179 | input { 180 | leaf path { 181 | type string; 182 | } 183 | leaf perms { 184 | type types:permissions; 185 | } 186 | } 187 | } 188 | 189 | rpc format-disk { 190 | description 191 | "Format disk."; 192 | ext:require-admin; 193 | input { 194 | leaf disk { 195 | ext:human-name "Disk"; 196 | type types:str; 197 | } 198 | choice xml-or-json { 199 | case xml { 200 | anyxml html-info; 201 | } 202 | case json { 203 | anydata json-info; 204 | } 205 | } 206 | } 207 | output { 208 | leaf duration { 209 | type uint32; 210 | } 211 | } 212 | } 213 | 214 | notification alarm-triggered { 215 | description 216 | "Notification about a new alarm."; 217 | leaf description { 218 | type string; 219 | } 220 | leaf severity { 221 | type uint32; 222 | } 223 | } 224 | 225 | notification config-change { 226 | list edit { 227 | leaf target { 228 | type string; 229 | } 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /libyang/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # Copyright (c) 2020 6WIND S.A. 3 | # Copyright (c) 2021 RACOM s.r.o. 4 | # SPDX-License-Identifier: MIT 5 | 6 | 7 | from .context import Context 8 | from .data import ( 9 | DAnydata, 10 | DAnyxml, 11 | DContainer, 12 | DLeaf, 13 | DLeafList, 14 | DList, 15 | DNode, 16 | DNodeAttrs, 17 | DNotif, 18 | DRpc, 19 | ) 20 | from .diff import ( 21 | BaseTypeAdded, 22 | BaseTypeRemoved, 23 | BitAdded, 24 | BitRemoved, 25 | BitStatusAdded, 26 | BitStatusRemoved, 27 | ConfigFalseAdded, 28 | ConfigFalseRemoved, 29 | DefaultAdded, 30 | DefaultRemoved, 31 | DescriptionAdded, 32 | DescriptionRemoved, 33 | EnumAdded, 34 | EnumRemoved, 35 | EnumStatusAdded, 36 | EnumStatusRemoved, 37 | ExtensionAdded, 38 | ExtensionRemoved, 39 | KeyAdded, 40 | KeyRemoved, 41 | LengthAdded, 42 | LengthRemoved, 43 | MandatoryAdded, 44 | MandatoryRemoved, 45 | MustAdded, 46 | MustRemoved, 47 | NodeTypeAdded, 48 | NodeTypeRemoved, 49 | OrderedByUserAdded, 50 | OrderedByUserRemoved, 51 | PatternAdded, 52 | PatternRemoved, 53 | PresenceAdded, 54 | PresenceRemoved, 55 | RangeAdded, 56 | RangeRemoved, 57 | SNodeAdded, 58 | SNodeDiff, 59 | SNodeRemoved, 60 | StatusAdded, 61 | StatusRemoved, 62 | UnitsAdded, 63 | UnitsRemoved, 64 | schema_diff, 65 | ) 66 | from .extension import ExtensionPlugin, LibyangExtensionError 67 | from .keyed_list import KeyedList 68 | from .log import configure_logging, temp_log_options 69 | from .schema import ( 70 | Enum, 71 | Extension, 72 | ExtensionCompiled, 73 | ExtensionParsed, 74 | Feature, 75 | Identity, 76 | IfAndFeatures, 77 | IfFeature, 78 | IfFeatureExpr, 79 | IfFeatureExprTree, 80 | IfNotFeature, 81 | IfOrFeatures, 82 | Module, 83 | Must, 84 | PAction, 85 | PActionInOut, 86 | PAnydata, 87 | Pattern, 88 | PAugment, 89 | PCase, 90 | PChoice, 91 | PContainer, 92 | PEnum, 93 | PGrouping, 94 | PIdentity, 95 | PLeaf, 96 | PLeafList, 97 | PList, 98 | PNode, 99 | PNotif, 100 | PRefine, 101 | PType, 102 | PUses, 103 | Revision, 104 | SAnydata, 105 | SAnyxml, 106 | SCase, 107 | SChoice, 108 | SContainer, 109 | SLeaf, 110 | SLeafList, 111 | SList, 112 | SNode, 113 | SRpc, 114 | SRpcInOut, 115 | Type, 116 | Typedef, 117 | ) 118 | from .util import DataType, IOType, LibyangError 119 | from .xpath import ( 120 | xpath_del, 121 | xpath_get, 122 | xpath_getall, 123 | xpath_move, 124 | xpath_set, 125 | xpath_setdefault, 126 | xpath_split, 127 | ) 128 | 129 | 130 | __all__ = ( 131 | "BaseTypeAdded", 132 | "BaseTypeRemoved", 133 | "BitAdded", 134 | "BitRemoved", 135 | "ConfigFalseAdded", 136 | "ConfigFalseRemoved", 137 | "Context", 138 | "DContainer", 139 | "DLeaf", 140 | "DLeafList", 141 | "DList", 142 | "DNode", 143 | "DRpc", 144 | "DefaultAdded", 145 | "DefaultRemoved", 146 | "DescriptionAdded", 147 | "DescriptionRemoved", 148 | "Enum", 149 | "EnumAdded", 150 | "EnumRemoved", 151 | "Extension", 152 | "ExtensionAdded", 153 | "ExtensionCompiled", 154 | "ExtensionParsed", 155 | "ExtensionPlugin", 156 | "ExtensionRemoved", 157 | "Feature", 158 | "Identity", 159 | "IfAndFeatures", 160 | "IfFeature", 161 | "IfFeatureExpr", 162 | "IfFeatureExprTree", 163 | "IfNotFeature", 164 | "IfOrFeatures", 165 | "KeyAdded", 166 | "KeyedList", 167 | "KeyRemoved", 168 | "LengthAdded", 169 | "LengthRemoved", 170 | "LibyangError", 171 | "IOType", 172 | "DataType", 173 | "MandatoryAdded", 174 | "MandatoryRemoved", 175 | "Module", 176 | "Must", 177 | "MustAdded", 178 | "MustRemoved", 179 | "NodeTypeAdded", 180 | "NodeTypeRemoved", 181 | "OrderedByUserAdded", 182 | "OrderedByUserRemoved", 183 | "PAction", 184 | "PActionInOut", 185 | "PAnydata", 186 | "PAugment", 187 | "PCase", 188 | "PChoice", 189 | "PContainer", 190 | "PEnum", 191 | "PGrouping", 192 | "PIdentity", 193 | "PLeaf", 194 | "PLeafList", 195 | "PList", 196 | "PNode", 197 | "PNotif", 198 | "PRefine", 199 | "PType", 200 | "PUses", 201 | "Pattern", 202 | "PatternAdded", 203 | "PatternRemoved", 204 | "PresenceAdded", 205 | "PresenceRemoved", 206 | "RangeAdded", 207 | "RangeRemoved", 208 | "Revision", 209 | "SAnydata", 210 | "SAnyxml", 211 | "SCase", 212 | "SChoice", 213 | "SContainer", 214 | "SLeaf", 215 | "SLeafList", 216 | "SList", 217 | "SNode", 218 | "SNodeAdded", 219 | "SNodeDiff", 220 | "SNodeRemoved", 221 | "SRpc", 222 | "SRpcInOut", 223 | "StatusAdded", 224 | "StatusRemoved", 225 | "Type", 226 | "UnitsAdded", 227 | "UnitsRemoved", 228 | "configure_logging", 229 | "schema_diff", 230 | "xpath_del", 231 | "xpath_get", 232 | "xpath_getall", 233 | "xpath_move", 234 | "xpath_set", 235 | "xpath_setdefault", 236 | "xpath_split", 237 | ) 238 | -------------------------------------------------------------------------------- /libyang/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # Copyright (c) 2021 RACOM s.r.o. 3 | # SPDX-License-Identifier: MIT 4 | 5 | from dataclasses import dataclass 6 | import enum 7 | from typing import Iterable, Optional 8 | import warnings 9 | 10 | from _libyang import ffi, lib 11 | 12 | 13 | # ------------------------------------------------------------------------------------- 14 | @dataclass(frozen=True) 15 | class LibyangErrorItem: 16 | msg: Optional[str] 17 | data_path: Optional[str] 18 | schema_path: Optional[str] 19 | line: Optional[int] 20 | 21 | 22 | # ------------------------------------------------------------------------------------- 23 | class LibyangError(Exception): 24 | def __init__( 25 | self, message: str, *args, errors: Optional[Iterable[LibyangErrorItem]] = None 26 | ): 27 | super().__init__(message, *args) 28 | self.message = message 29 | self.errors = tuple(errors or ()) 30 | 31 | def __str__(self): 32 | return self.message 33 | 34 | 35 | # ------------------------------------------------------------------------------------- 36 | def deprecated(old: str, new: str, removed_in: str) -> None: 37 | msg = "%s has been replaced by %s, it will be removed in version %s" 38 | msg %= (old, new, removed_in) 39 | warnings.warn(msg, DeprecationWarning, stacklevel=3) 40 | 41 | 42 | # ------------------------------------------------------------------------------------- 43 | def str2c(s: Optional[str], encode: bool = True): 44 | if s is None: 45 | return ffi.NULL 46 | if encode and hasattr(s, "encode"): 47 | s = s.encode("utf-8") 48 | return ffi.new("char []", s) 49 | 50 | 51 | # ------------------------------------------------------------------------------------- 52 | def c2str(c, decode: bool = True): 53 | if c == ffi.NULL: # C type: "char *" 54 | return None 55 | s = ffi.string(c) 56 | if decode and hasattr(s, "decode"): 57 | s = s.decode("utf-8") 58 | return s 59 | 60 | 61 | # ------------------------------------------------------------------------------------- 62 | def p_str2c(s: Optional[str], encode: bool = True): 63 | s_p = str2c(s, encode) 64 | return ffi.new("char **", s_p) 65 | 66 | 67 | # ------------------------------------------------------------------------------------- 68 | def ly_array_count(cdata): 69 | if cdata == ffi.NULL: 70 | return 0 71 | return ffi.cast("uint64_t *", cdata)[-1] 72 | 73 | 74 | # ------------------------------------------------------------------------------------- 75 | def ly_array_iter(cdata): 76 | for i in range(ly_array_count(cdata)): 77 | yield cdata[i] 78 | 79 | 80 | # ------------------------------------------------------------------------------------- 81 | def ly_list_iter(cdata): 82 | item = cdata 83 | while item != ffi.NULL: 84 | yield item 85 | item = item.next 86 | 87 | 88 | # ------------------------------------------------------------------------------------- 89 | class IOType(enum.Enum): 90 | FD = enum.auto() 91 | FILE = enum.auto() 92 | FILEPATH = enum.auto() 93 | MEMORY = enum.auto() 94 | 95 | 96 | # ------------------------------------------------------------------------------------- 97 | class DataType(enum.Enum): 98 | DATA_YANG = lib.LYD_TYPE_DATA_YANG 99 | RPC_YANG = lib.LYD_TYPE_RPC_YANG 100 | NOTIF_YANG = lib.LYD_TYPE_NOTIF_YANG 101 | REPLY_YANG = lib.LYD_TYPE_REPLY_YANG 102 | RPC_NETCONF = lib.LYD_TYPE_RPC_NETCONF 103 | NOTIF_NETCONF = lib.LYD_TYPE_NOTIF_NETCONF 104 | REPLY_NETCONF = lib.LYD_TYPE_REPLY_NETCONF 105 | RPC_RESTCONF = lib.LYD_TYPE_RPC_RESTCONF 106 | NOTIF_RESTCONF = lib.LYD_TYPE_NOTIF_RESTCONF 107 | REPLY_RESTCONF = lib.LYD_TYPE_REPLY_RESTCONF 108 | 109 | 110 | # ------------------------------------------------------------------------------------- 111 | def init_output(out_type, out_target, out_data): 112 | output = None 113 | if out_type == IOType.FD: 114 | ret = lib.ly_out_new_fd(out_target.fileno(), out_data) 115 | 116 | elif out_type == IOType.FILE: 117 | ret = lib.ly_out_new_file(out_target, out_data) 118 | 119 | elif out_type == IOType.FILEPATH: 120 | out_target = str2c(out_target) 121 | ret = lib.ly_out_new_filepath(out_target, out_data) 122 | 123 | elif out_type == IOType.MEMORY: 124 | output = ffi.new("char **") 125 | ret = lib.ly_out_new_memory(output, 0, out_data) 126 | 127 | else: 128 | raise ValueError("invalid output") 129 | 130 | return ret, output 131 | 132 | 133 | # ------------------------------------------------------------------------------------- 134 | def data_load(in_type, in_data, data, data_keepalive, encode=True): 135 | if in_type == IOType.FD: 136 | ret = lib.ly_in_new_fd(in_data.fileno(), data) 137 | elif in_type == IOType.FILE: 138 | ret = lib.ly_in_new_file(in_data, data) 139 | elif in_type == IOType.FILEPATH: 140 | ret = lib.ly_in_new_filepath(str2c(in_data), len(in_data), data) 141 | elif in_type == IOType.MEMORY: 142 | c_str = str2c(in_data, encode=encode) 143 | data_keepalive.append(c_str) 144 | ret = lib.ly_in_new_memory(c_str, data) 145 | else: 146 | raise ValueError("invalid input") 147 | return ret 148 | -------------------------------------------------------------------------------- /libyang/keyed_list.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 6WIND S.A. 2 | # SPDX-License-Identifier: MIT 3 | 4 | from collections.abc import Hashable 5 | import copy 6 | from typing import Any, Iterable, Optional, Tuple, Union 7 | 8 | 9 | # ------------------------------------------------------------------------------------- 10 | ListKeyVal = Union[str, Tuple[str, ...]] 11 | 12 | 13 | class KeyedList(list): 14 | """ 15 | YANG lists are not ordered by default but indexed by keys. This class mimics some 16 | parts of the list API but allows direct access to elements when their keys are 17 | known. 18 | 19 | Note that even though this class inherits from list, it is not really a list. 20 | Internally, it behaves as a dict. Some parts of the list API that assume any 21 | ordering are not supported for that reason. 22 | 23 | The inheritance is only to avoid confusion and make isinstance return something that 24 | looks like a list. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | init: Iterable = None, 30 | key_name: Optional[Union[str, Tuple[str, ...]]] = None, 31 | ) -> None: 32 | """ 33 | :arg init: 34 | Values to initialize the list with. 35 | :arg key_name: 36 | Name of keys in the element when the element is a dict. If there is only one 37 | key, it is a plain string, if there is more than one key, it is a tuple of 38 | strings. If there is no key (leaf-list), key_name is None. 39 | """ 40 | super().__init__() # this is not a real list anyway 41 | self._key_name = key_name 42 | self._map = {} # type: Dict[ListKeyVal, Any] 43 | if init is not None: 44 | self.extend(init) 45 | 46 | def _element_key(self, element: Any) -> Hashable: 47 | if self._key_name is None: 48 | return py_to_yang(element) 49 | if not isinstance(element, dict): 50 | raise TypeError("element must be a dict") 51 | if isinstance(self._key_name, str): 52 | return py_to_yang(element[self._key_name]) 53 | return tuple(py_to_yang(element[k]) for k in self._key_name) 54 | 55 | def clear(self) -> None: 56 | self._map.clear() 57 | 58 | def copy(self) -> "KeyedList": 59 | return KeyedList(self._map.values(), key_name=self._key_name) 60 | 61 | def append(self, element: Any) -> None: 62 | key = self._element_key(element) 63 | if key in self._map: 64 | raise ValueError("element with key %r already in list" % (key,)) 65 | self._map[key] = element 66 | 67 | def extend(self, iterable: Iterable) -> None: 68 | for element in iterable: 69 | self.append(element) 70 | 71 | def pop(self, key: ListKeyVal = None, default: Any = None) -> Any: 72 | if key is None or isinstance(key, (int, slice)): 73 | raise TypeError("non-ordered lists cannot be accessed by index") 74 | return self._map.pop(key, default) 75 | 76 | def remove(self, element: Any) -> None: 77 | key = self._element_key(element) 78 | del self._map[key] 79 | 80 | def __getitem__(self, key: ListKeyVal) -> Any: 81 | if isinstance(key, (int, slice)): 82 | raise TypeError("non-ordered lists cannot be accessed by index") 83 | return self._map[key] 84 | 85 | def __delitem__(self, key: ListKeyVal) -> None: 86 | if isinstance(key, (int, slice)): 87 | raise TypeError("non-ordered lists cannot be accessed by index") 88 | del self._map[key] 89 | 90 | def __eq__(self, other: Any) -> bool: 91 | if isinstance(other, KeyedList): 92 | return other._map == self._map 93 | if not isinstance(other, list): 94 | return False 95 | try: 96 | other_map = {} 97 | for e in other: 98 | other_map[self._element_key(e)] = e 99 | return other_map == self._map 100 | except (KeyError, TypeError): 101 | return False 102 | 103 | def __ne__(self, other: Any) -> bool: 104 | return not self.__eq__(other) 105 | 106 | def __iter__(self): 107 | return iter(self._map.values()) 108 | 109 | def __len__(self): 110 | return len(self._map) 111 | 112 | def __repr__(self): 113 | return self.__str__() 114 | 115 | def __str__(self): 116 | return str(list(self._map.values())) 117 | 118 | def count(self, element: Any): 119 | if element in self: 120 | return 1 121 | return 0 122 | 123 | def __contains__(self, element: Any) -> bool: 124 | try: 125 | if isinstance(element, dict) or element not in self._map: 126 | key = self._element_key(element) 127 | else: 128 | key = element 129 | return key in self._map 130 | except (KeyError, TypeError): 131 | return False 132 | 133 | def __copy__(self) -> "KeyedList": 134 | return self.copy() 135 | 136 | def __deepcopy__(self, memo) -> "KeyedList": 137 | k = KeyedList.__new__(KeyedList) 138 | memo[id(self)] = k 139 | k._key_name = copy.deepcopy(self._key_name, memo) 140 | k._map = copy.deepcopy(self._map, memo) 141 | return k 142 | 143 | # unsupported list API methods 144 | def __unsupported(self, *args, **kwargs): 145 | raise TypeError("unsupported operation for non-ordered lists") 146 | 147 | index = __unsupported 148 | insert = __unsupported 149 | reverse = __unsupported 150 | sort = __unsupported 151 | __add__ = __unsupported 152 | __ge__ = __unsupported 153 | __gt__ = __unsupported 154 | __iadd__ = __unsupported 155 | __imul__ = __unsupported 156 | __le__ = __unsupported 157 | __lt__ = __unsupported 158 | __mul__ = __unsupported 159 | __reversed__ = __unsupported 160 | __rmul__ = __unsupported 161 | __setitem__ = __unsupported 162 | 163 | 164 | # ------------------------------------------------------------------------------------- 165 | def py_to_yang(val: Any) -> str: 166 | """ 167 | Convert a python value to a string following how it would be stored in a libyang 168 | data tree. Also suitable for comparison with YANG list keys (which are always 169 | strings). 170 | """ 171 | if isinstance(val, str): 172 | return val 173 | if val is True: 174 | return "true" 175 | if val is False: 176 | return "false" 177 | return str(val) 178 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2018-2020 Robin Jarry 3 | # SPDX-License-Identifier: MIT 4 | 5 | import datetime 6 | import os 7 | import re 8 | import subprocess 9 | 10 | import setuptools 11 | import setuptools.command.sdist 12 | 13 | 14 | # ------------------------------------------------------------------------------------- 15 | if os.environ.get("LIBYANG_INSTALL", "system") == "embed": 16 | raise NotImplementedError( 17 | "LIBYANG_INSTALL=embed is no longer supported. " 18 | "Please install libyang separately first." 19 | ) 20 | 21 | 22 | # ------------------------------------------------------------------------------------- 23 | def git_describe_to_pep440(version): 24 | """ 25 | ``git describe`` produces versions in the form: `v0.9.8-20-gf0f45ca` where 26 | 20 is the number of commit since last release, and gf0f45ca is the short 27 | commit id preceded by 'g' we parse this a transform into a pep440 release 28 | version 0.9.9.dev20 (increment last digit and add dev before 20) 29 | """ 30 | match = re.search( 31 | r""" 32 | v(?P\d+)\. 33 | (?P\d+)\. 34 | (?P\d+) 35 | ((\.post|-)(?P\d+)(?!-g))? 36 | ([\+~](?P.*?))? 37 | (-(?P\d+))?(-g(?P.+))? 38 | """, 39 | version, 40 | flags=re.VERBOSE, 41 | ) 42 | if not match: 43 | raise ValueError("unknown tag format") 44 | dic = { 45 | "major": int(match.group("major")), 46 | "minor": int(match.group("minor")), 47 | "patch": int(match.group("patch")), 48 | } 49 | fmt = "{major}.{minor}.{patch}" 50 | if match.group("dev"): 51 | dic["patch"] += 1 52 | dic["dev"] = int(match.group("dev")) 53 | fmt += ".dev{dev}" 54 | elif match.group("post"): 55 | dic["post"] = int(match.group("post")) 56 | fmt += ".post{post}" 57 | if match.group("local_segment"): 58 | dic["local_segment"] = match.group("local_segment") 59 | fmt += "+{local_segment}" 60 | return fmt.format(**dic) 61 | 62 | 63 | # ------------------------------------------------------------------------------------- 64 | def get_version_from_archive_id(git_archive_id="1762970121 (HEAD -> master, tag: v3.3.0)"): 65 | """ 66 | Extract the tag if a source is from git archive. 67 | 68 | When source is exported via `git archive`, the git_archive_id init value is 69 | modified and placeholders are expanded to the "archived" revision: 70 | 71 | %ct: committer date, UNIX timestamp 72 | %d: ref names, like the --decorate option of git-log 73 | 74 | See man gitattributes(5) and git-log(1) (PRETTY FORMATS) for more details. 75 | """ 76 | # mangle the magic string to make sure it is not replaced by git archive 77 | if git_archive_id.startswith("$For" "mat:"): # pylint: disable=implicit-str-concat 78 | raise ValueError("source was not modified by git archive") 79 | 80 | # source was modified by git archive, try to parse the version from 81 | # the value of git_archive_id 82 | match = re.search(r"tag:\s*([^,)]+)", git_archive_id) 83 | if match: 84 | # archived revision is tagged, use the tag 85 | return git_describe_to_pep440(match.group(1)) 86 | 87 | # archived revision is not tagged, use the commit date 88 | tstamp = git_archive_id.strip().split()[0] 89 | d = datetime.datetime.utcfromtimestamp(int(tstamp)) 90 | return d.strftime("2.%Y.%m.%d") 91 | 92 | 93 | # ------------------------------------------------------------------------------------- 94 | def read_file(fpath, encoding="utf-8"): 95 | with open(fpath, "r", encoding=encoding) as f: 96 | return f.read().strip() 97 | 98 | 99 | # ------------------------------------------------------------------------------------- 100 | def get_version(): 101 | try: 102 | return read_file("libyang/VERSION") 103 | except IOError: 104 | pass 105 | 106 | if "LIBYANG_PYTHON_FORCE_VERSION" in os.environ: 107 | return os.environ["LIBYANG_PYTHON_FORCE_VERSION"] 108 | 109 | try: 110 | return get_version_from_archive_id() 111 | except ValueError: 112 | pass 113 | 114 | try: 115 | if os.path.isdir(".git"): 116 | out = subprocess.check_output( 117 | ["git", "describe", "--tags", "--always"], stderr=subprocess.DEVNULL 118 | ) 119 | return git_describe_to_pep440(out.decode("utf-8").strip()) 120 | except Exception: 121 | pass 122 | 123 | return "2.99999.99999" 124 | 125 | 126 | # ------------------------------------------------------------------------------------- 127 | class SDistCommand(setuptools.command.sdist.sdist): 128 | def write_lines(self, file, lines): 129 | with open(file, "w", encoding="utf-8") as f: 130 | for line in lines: 131 | f.write(line + "\n") 132 | 133 | def make_release_tree(self, base_dir, files): 134 | super().make_release_tree(base_dir, files) 135 | version_file = os.path.join(base_dir, "libyang/VERSION") 136 | self.execute( 137 | self.write_lines, 138 | (version_file, [self.distribution.metadata.version]), 139 | "Writing %s" % version_file, 140 | ) 141 | 142 | 143 | # ------------------------------------------------------------------------------------- 144 | setuptools.setup( 145 | name="libyang", 146 | version=get_version(), 147 | description="CFFI bindings to libyang", 148 | long_description=read_file("README.rst"), 149 | url="https://github.com/CESNET/libyang-python", 150 | license="MIT", 151 | author="Robin Jarry", 152 | author_email="robin@jarry.cc", 153 | keywords=["libyang", "cffi"], 154 | classifiers=[ 155 | "Development Status :: 4 - Beta", 156 | "Environment :: Console", 157 | "Intended Audience :: Developers", 158 | "License :: OSI Approved :: MIT License", 159 | "Operating System :: Unix", 160 | "Programming Language :: Python :: 3", 161 | "Topic :: Software Development :: Libraries", 162 | ], 163 | packages=["libyang"], 164 | zip_safe=False, 165 | include_package_data=True, 166 | python_requires=">=3.6", 167 | setup_requires=["setuptools", 'cffi; platform_python_implementation != "PyPy"'], 168 | install_requires=['cffi; platform_python_implementation != "PyPy"'], 169 | cffi_modules=["cffi/build.py:BUILDER"], 170 | cmdclass={"sdist": SDistCommand}, 171 | ) 172 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | 4 | import os 5 | import unittest 6 | 7 | from libyang import Context, LibyangError, Module, SContainer, SLeaf, SLeafList 8 | from libyang.util import c2str 9 | 10 | 11 | YANG_DIR = os.path.join(os.path.dirname(__file__), "yang") 12 | 13 | 14 | # ------------------------------------------------------------------------------------- 15 | 16 | 17 | class ContextTest(unittest.TestCase): 18 | def test_ctx_no_dir(self): 19 | with Context() as ctx: 20 | self.assertIsNot(ctx, None) 21 | 22 | def test_ctx_yanglib(self): 23 | ctx = Context(YANG_DIR, yanglib_path=YANG_DIR + "/yang-library.json") 24 | ctx.load_module("yolo-system") 25 | dnode = ctx.get_yanglib_data() 26 | j = dnode.print_mem("json", with_siblings=True) 27 | self.assertIsInstance(j, str) 28 | 29 | def test_ctx_dir(self): 30 | with Context(YANG_DIR) as ctx: 31 | self.assertIsNot(ctx, None) 32 | 33 | def test_ctx_duplicate_searchpath(self): 34 | duplicate_search_path = ":".join([YANG_DIR, YANG_DIR]) 35 | try: 36 | Context(duplicate_search_path) 37 | except LibyangError: 38 | self.fail("Context.__init__ should not raise LibyangError") 39 | 40 | def test_ctx_invalid_dir(self): 41 | with Context("/does/not/exist") as ctx: 42 | self.assertIsNot(ctx, None) 43 | 44 | def test_ctx_missing_dir(self): 45 | with Context(os.path.join(YANG_DIR, "yolo")) as ctx: 46 | self.assertIsNot(ctx, None) 47 | with self.assertRaises(LibyangError): 48 | ctx.load_module("yolo-system") 49 | 50 | def test_ctx_env_search_dir(self): 51 | try: 52 | os.environ["YANGPATH"] = ":".join( 53 | [os.path.join(YANG_DIR, "omg"), os.path.join(YANG_DIR, "wtf")] 54 | ) 55 | with Context(os.path.join(YANG_DIR, "yolo")) as ctx: 56 | mod = ctx.load_module("yolo-system") 57 | self.assertIsInstance(mod, Module) 58 | finally: 59 | del os.environ["YANGPATH"] 60 | 61 | def test_ctx_load_module(self): 62 | with Context(YANG_DIR) as ctx: 63 | mod = ctx.load_module("yolo-system") 64 | self.assertIsInstance(mod, Module) 65 | 66 | def test_ctx_load_module_with_features(self): 67 | with Context(YANG_DIR) as ctx: 68 | mod = ctx.load_module("yolo-system", None, ["*"]) 69 | self.assertIsInstance(mod, Module) 70 | for f in list(mod.features()): 71 | self.assertTrue(f.state()) 72 | 73 | def test_ctx_get_module(self): 74 | with Context(YANG_DIR) as ctx: 75 | ctx.load_module("yolo-system") 76 | mod = ctx.get_module("wtf-types") 77 | self.assertIsInstance(mod, Module) 78 | 79 | def test_ctx_get_invalid_module(self): 80 | with Context(YANG_DIR) as ctx: 81 | ctx.load_module("wtf-types") 82 | with self.assertRaises(LibyangError): 83 | ctx.get_module("yolo-system") 84 | 85 | def test_ctx_load_invalid_module(self): 86 | with Context(YANG_DIR) as ctx: 87 | with self.assertRaises(LibyangError): 88 | ctx.load_module("invalid-module") 89 | 90 | def test_ctx_find_path(self): 91 | with Context(YANG_DIR) as ctx: 92 | ctx.load_module("yolo-system") 93 | node = next(ctx.find_path("/yolo-system:conf/offline")) 94 | self.assertIsInstance(node, SLeaf) 95 | node2 = next(ctx.find_path("../number", root_node=node)) 96 | self.assertIsInstance(node2, SLeafList) 97 | 98 | def test_ctx_find_xpath_atoms(self): 99 | with Context(YANG_DIR) as ctx: 100 | ctx.load_module("yolo-system") 101 | node_iter = ctx.find_xpath_atoms("/yolo-system:conf/offline") 102 | node = next(node_iter) 103 | self.assertIsInstance(node, SContainer) 104 | node = next(node_iter) 105 | self.assertIsInstance(node, SLeaf) 106 | node_iter = ctx.find_xpath_atoms("../number", root_node=node) 107 | node = next(node_iter) 108 | self.assertIsInstance(node, SLeaf) 109 | node = next(node_iter) 110 | self.assertIsInstance(node, SContainer) 111 | node = next(node_iter) 112 | self.assertIsInstance(node, SLeafList) 113 | 114 | def test_ctx_iter_modules(self): 115 | with Context(YANG_DIR) as ctx: 116 | ctx.load_module("yolo-system") 117 | modules = list(iter(ctx)) 118 | self.assertGreater(len(modules), 0) 119 | 120 | YOLO_MOD_PATH = os.path.join(YANG_DIR, "yolo/yolo-system.yang") 121 | 122 | def test_ctx_parse_module(self): 123 | with open(self.YOLO_MOD_PATH, encoding="utf-8") as f: 124 | mod_str = f.read() 125 | with Context(YANG_DIR) as ctx: 126 | mod = ctx.parse_module_str(mod_str, features=["turbo-boost", "networking"]) 127 | self.assertIsInstance(mod, Module) 128 | 129 | with open(self.YOLO_MOD_PATH, encoding="utf-8") as f: 130 | with Context(YANG_DIR) as ctx: 131 | mod = ctx.parse_module_file(f, features=["turbo-boost", "networking"]) 132 | self.assertIsInstance(mod, Module) 133 | 134 | def test_ctx_leafref_extended(self): 135 | with Context(YANG_DIR, leafref_extended=True) as ctx: 136 | mod = ctx.load_module("yolo-leafref-extended") 137 | self.assertIsInstance(mod, Module) 138 | 139 | def test_context_dict(self): 140 | with Context(YANG_DIR) as ctx: 141 | orig_str = "teststring" 142 | handle = ctx.add_to_dict(orig_str) 143 | self.assertEqual(orig_str, c2str(handle)) 144 | ctx.remove_from_dict(orig_str) 145 | 146 | def test_ctx_disable_searchdirs(self): 147 | with Context(YANG_DIR, disable_searchdirs=True) as ctx: 148 | with self.assertRaises(LibyangError): 149 | ctx.load_module("yolo-nodetypes") 150 | 151 | def test_ctx_using_clb(self): 152 | def get_module_valid_clb(mod_name, *_): 153 | YOLO_NODETYPES_MOD_PATH = os.path.join(YANG_DIR, "yolo/yolo-nodetypes.yang") 154 | self.assertEqual(mod_name, "yolo-nodetypes") 155 | with open(YOLO_NODETYPES_MOD_PATH, encoding="utf-8") as f: 156 | mod_str = f.read() 157 | return "yang", mod_str 158 | 159 | def get_module_invalid_clb(mod_name, *_): 160 | return None 161 | 162 | with Context(YANG_DIR, disable_searchdirs=True) as ctx: 163 | with self.assertRaises(LibyangError): 164 | ctx.load_module("yolo-nodetypes") 165 | 166 | ctx.external_module_loader.set_module_data_clb(get_module_invalid_clb) 167 | with self.assertRaises(LibyangError): 168 | mod = ctx.load_module("yolo-nodetypes") 169 | 170 | ctx.external_module_loader.set_module_data_clb(get_module_valid_clb) 171 | mod = ctx.load_module("yolo-nodetypes") 172 | self.assertIsInstance(mod, Module) 173 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # SPDX-License-Identifier: MIT 3 | 4 | import logging 5 | import os 6 | from typing import Any, Optional 7 | import unittest 8 | 9 | from libyang import ( 10 | Context, 11 | ExtensionCompiled, 12 | ExtensionParsed, 13 | ExtensionPlugin, 14 | LibyangError, 15 | LibyangExtensionError, 16 | Module, 17 | PLeaf, 18 | SLeaf, 19 | ) 20 | 21 | 22 | YANG_DIR = os.path.join(os.path.dirname(__file__), "yang") 23 | 24 | 25 | # ------------------------------------------------------------------------------------- 26 | class TestExtensionPlugin(ExtensionPlugin): 27 | def __init__(self, context: Context) -> None: 28 | super().__init__( 29 | "omg-extensions", 30 | "type-desc", 31 | "omg-extensions-type-desc-plugin-v1", 32 | context, 33 | parse_clb=self._parse_clb, 34 | compile_clb=self._compile_clb, 35 | parse_free_clb=self._parse_free_clb, 36 | compile_free_clb=self._compile_free_clb, 37 | ) 38 | self.parse_clb_called = 0 39 | self.compile_clb_called = 0 40 | self.parse_free_clb_called = 0 41 | self.compile_free_clb_called = 0 42 | self.parse_clb_exception: Optional[LibyangExtensionError] = None 43 | self.compile_clb_exception: Optional[LibyangExtensionError] = None 44 | self.parse_parent_stmt = None 45 | 46 | def reset(self) -> None: 47 | self.parse_clb_called = 0 48 | self.compile_clb_called = 0 49 | self.parse_free_clb_called = 0 50 | self.compile_free_clb_called = 0 51 | self.parse_clb_exception = None 52 | self.compile_clb_exception = None 53 | 54 | def _parse_clb(self, module: Module, ext: ExtensionParsed) -> None: 55 | self.parse_clb_called += 1 56 | if self.parse_clb_exception is not None: 57 | raise self.parse_clb_exception 58 | self.parse_substmts(ext) 59 | self.parse_parent_stmt = self.stmt2str(ext.cdata.parent_stmt) 60 | 61 | def _compile_clb(self, pext: ExtensionParsed, cext: ExtensionCompiled) -> None: 62 | self.compile_clb_called += 1 63 | if self.compile_clb_exception is not None: 64 | raise self.compile_clb_exception 65 | self.compile_substmts(pext, cext) 66 | 67 | def _parse_free_clb(self, ext: ExtensionParsed) -> None: 68 | self.parse_free_clb_called += 1 69 | self.free_parse_substmts(ext) 70 | 71 | def _compile_free_clb(self, ext: ExtensionCompiled) -> None: 72 | self.compile_free_clb_called += 1 73 | self.free_compile_substmts(ext) 74 | 75 | 76 | # ------------------------------------------------------------------------------------- 77 | class ExtensionTest(unittest.TestCase): 78 | def setUp(self): 79 | self.ctx = Context(YANG_DIR) 80 | self.plugin = TestExtensionPlugin(self.ctx) 81 | 82 | def tearDown(self): 83 | self.ctx.destroy() 84 | self.ctx = None 85 | 86 | def test_extension_basic(self): 87 | self.ctx.load_module("yolo-system") 88 | self.assertEqual(5, self.plugin.parse_clb_called) 89 | self.assertEqual(6, self.plugin.compile_clb_called) 90 | self.assertEqual(0, self.plugin.parse_free_clb_called) 91 | self.assertEqual(0, self.plugin.compile_free_clb_called) 92 | self.assertEqual("type", self.plugin.parse_parent_stmt) 93 | self.ctx.destroy() 94 | self.assertEqual(5, self.plugin.parse_clb_called) 95 | self.assertEqual(6, self.plugin.compile_clb_called) 96 | self.assertEqual(5, self.plugin.parse_free_clb_called) 97 | self.assertEqual(6, self.plugin.compile_free_clb_called) 98 | 99 | def test_extension_invalid_parse(self): 100 | self.plugin.parse_clb_exception = LibyangExtensionError( 101 | "this extension cannot be parsed", 102 | self.plugin.ERROR_NOT_VALID, 103 | logging.ERROR, 104 | ) 105 | with self.assertRaises(LibyangError): 106 | self.ctx.load_module("yolo-system") 107 | 108 | def test_extension_invalid_compile(self): 109 | self.plugin.compile_clb_exception = LibyangExtensionError( 110 | "this extension cannot be compiled", 111 | self.plugin.ERROR_NOT_VALID, 112 | logging.ERROR, 113 | ) 114 | with self.assertRaises(LibyangError): 115 | self.ctx.load_module("yolo-system") 116 | 117 | 118 | # ------------------------------------------------------------------------------------- 119 | class ExampleParseExtensionPlugin(ExtensionPlugin): 120 | def __init__(self, context: Context) -> None: 121 | super().__init__( 122 | "omg-extensions", 123 | "parse-validation", 124 | "omg-extensions-parse-validation-plugin-v1", 125 | context, 126 | parse_clb=self._parse_clb, 127 | ) 128 | 129 | def _verify_single(self, parent: Any) -> None: 130 | count = 0 131 | for e in parent.extensions(): 132 | if e.name() == self.name and e.module().name() == self.module_name: 133 | count += 1 134 | if count > 1: 135 | raise LibyangExtensionError( 136 | f"Extension {self.name} is allowed to be defined just once per given " 137 | "parent node context.", 138 | self.ERROR_NOT_VALID, 139 | logging.ERROR, 140 | ) 141 | 142 | def _parse_clb(self, _, ext: ExtensionParsed) -> None: 143 | parent = ext.parent_node() 144 | if not isinstance(parent, PLeaf): 145 | raise LibyangExtensionError( 146 | f"Extension {ext.name()} is allowed only in leaf nodes", 147 | self.ERROR_NOT_VALID, 148 | logging.ERROR, 149 | ) 150 | self._verify_single(parent) 151 | # here you put code to perform something reasonable actions you need for your extension 152 | 153 | 154 | class ExampleCompileExtensionPlugin(ExtensionPlugin): 155 | def __init__(self, context: Context) -> None: 156 | super().__init__( 157 | "omg-extensions", 158 | "compile-validation", 159 | "omg-extensions-compile-validation-plugin-v1", 160 | context, 161 | compile_clb=self._compile_clb, 162 | ) 163 | 164 | def _compile_clb(self, pext: ExtensionParsed, cext: ExtensionCompiled) -> None: 165 | parent = cext.parent_node() 166 | if not isinstance(parent, SLeaf): 167 | raise LibyangExtensionError( 168 | f"Extension {cext.name()} is allowed only in leaf nodes", 169 | self.ERROR_NOT_VALID, 170 | logging.ERROR, 171 | ) 172 | # here you put code to perform something reasonable actions you need for your extension 173 | 174 | 175 | class ExtensionExampleTest(unittest.TestCase): 176 | def setUp(self): 177 | self.ctx = Context(YANG_DIR) 178 | self.plugins = [] 179 | 180 | def tearDown(self): 181 | self.plugins.clear() 182 | self.ctx.destroy() 183 | self.ctx = None 184 | 185 | def test_parse_validation_example(self): 186 | self.plugins.append(ExampleParseExtensionPlugin(self.ctx)) 187 | self.ctx.load_module("yolo-system") 188 | 189 | def test_compile_validation_example(self): 190 | self.plugins.append(ExampleParseExtensionPlugin(self.ctx)) 191 | self.plugins.append(ExampleCompileExtensionPlugin(self.ctx)) 192 | with self.assertRaises(LibyangError): 193 | self.ctx.load_module("yolo-system") 194 | -------------------------------------------------------------------------------- /libyang/extension.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018-2019 Robin Jarry 2 | # Copyright (c) 2020 6WIND S.A. 3 | # Copyright (c) 2021 RACOM s.r.o. 4 | # SPDX-License-Identifier: MIT 5 | 6 | from typing import Callable, Optional 7 | 8 | from _libyang import ffi, lib 9 | from .context import Context 10 | from .log import get_libyang_level 11 | from .schema import ExtensionCompiled, ExtensionParsed, Module 12 | from .util import LibyangError, c2str, str2c 13 | 14 | 15 | # ------------------------------------------------------------------------------------- 16 | extensions_plugins = {} 17 | 18 | 19 | class LibyangExtensionError(LibyangError): 20 | def __init__(self, message: str, ret: int, log_level: int) -> None: 21 | super().__init__(message) 22 | self.ret = ret 23 | self.log_level = log_level 24 | 25 | 26 | @ffi.def_extern(name="lypy_lyplg_ext_parse_clb") 27 | def libyang_c_lyplg_ext_parse_clb(pctx, pext): 28 | plugin = extensions_plugins[pext.record.plugin] 29 | module_cdata = lib.lyplg_ext_parse_get_cur_pmod(pctx).mod 30 | context = Context(cdata=module_cdata.ctx) 31 | module = Module(context, module_cdata) 32 | parsed_ext = ExtensionParsed(context, pext, module) 33 | plugin.set_parse_ctx(pctx) 34 | try: 35 | plugin.parse_clb(module, parsed_ext) 36 | return lib.LY_SUCCESS 37 | except LibyangExtensionError as e: 38 | ly_level = get_libyang_level(e.log_level) 39 | if ly_level is None: 40 | ly_level = lib.LY_EPLUGIN 41 | e_str = str(e) 42 | plugin.add_error_message(e_str) 43 | lib.lyplg_ext_parse_log(pctx, pext, ly_level, e.ret, str2c(e_str)) 44 | return e.ret 45 | 46 | 47 | @ffi.def_extern(name="lypy_lyplg_ext_compile_clb") 48 | def libyang_c_lyplg_ext_compile_clb(cctx, pext, cext): 49 | plugin = extensions_plugins[pext.record.plugin] 50 | context = Context(cdata=lib.lyplg_ext_compile_get_ctx(cctx)) 51 | module = Module(context, cext.module) 52 | parsed_ext = ExtensionParsed(context, pext, module) 53 | compiled_ext = ExtensionCompiled(context, cext) 54 | plugin.set_compile_ctx(cctx) 55 | try: 56 | plugin.compile_clb(parsed_ext, compiled_ext) 57 | return lib.LY_SUCCESS 58 | except LibyangExtensionError as e: 59 | ly_level = get_libyang_level(e.log_level) 60 | if ly_level is None: 61 | ly_level = lib.LY_EPLUGIN 62 | e_str = str(e) 63 | plugin.add_error_message(e_str) 64 | lib.lyplg_ext_compile_log(cctx, cext, ly_level, e.ret, str2c(e_str)) 65 | return e.ret 66 | 67 | 68 | @ffi.def_extern(name="lypy_lyplg_ext_parse_free_clb") 69 | def libyang_c_lyplg_ext_parse_free_clb(ctx, pext): 70 | plugin = extensions_plugins[pext.record.plugin] 71 | context = Context(cdata=ctx) 72 | parsed_ext = ExtensionParsed(context, pext, None) 73 | plugin.parse_free_clb(parsed_ext) 74 | 75 | 76 | @ffi.def_extern(name="lypy_lyplg_ext_compile_free_clb") 77 | def libyang_c_lyplg_ext_compile_free_clb(ctx, cext): 78 | plugin = extensions_plugins[getattr(cext, "def").plugin] 79 | context = Context(cdata=ctx) 80 | compiled_ext = ExtensionCompiled(context, cext) 81 | plugin.compile_free_clb(compiled_ext) 82 | 83 | 84 | class ExtensionPlugin: 85 | ERROR_SUCCESS = lib.LY_SUCCESS 86 | ERROR_MEM = lib.LY_EMEM 87 | ERROR_INVALID_INPUT = lib.LY_EINVAL 88 | ERROR_NOT_VALID = lib.LY_EVALID 89 | ERROR_DENIED = lib.LY_EDENIED 90 | ERROR_NOT = lib.LY_ENOT 91 | 92 | def __init__( 93 | self, 94 | module_name: str, 95 | name: str, 96 | id_str: str, 97 | context: Optional[Context] = None, 98 | parse_clb: Optional[Callable[[Module, ExtensionParsed], None]] = None, 99 | compile_clb: Optional[ 100 | Callable[[ExtensionParsed, ExtensionCompiled], None] 101 | ] = None, 102 | parse_free_clb: Optional[Callable[[ExtensionParsed], None]] = None, 103 | compile_free_clb: Optional[Callable[[ExtensionCompiled], None]] = None, 104 | ) -> None: 105 | """ 106 | Set the callback functions, which will be called if libyang will be processing 107 | given extension defined by name from module defined by module_name. 108 | 109 | :arg self: 110 | This instance of extension plugin 111 | :arg module_name: 112 | The name of module in which the extension is defined 113 | :arg name: 114 | The name of extension itself 115 | :arg id_str: 116 | The unique ID of extension plugin within the libyang context 117 | :arg context: 118 | The context in which the extension plugin will be used. If set to None, 119 | the plugin will be used for all existing and even future contexts 120 | :arg parse_clb: 121 | The optional callback function of which will be called during extension parsing 122 | Expected arguments are: 123 | module: The module which is being parsed 124 | extension: The exact extension instance 125 | Expected raises: 126 | LibyangExtensionError in case of processing error 127 | :arg compile_clb: 128 | The optional callback function of which will be called during extension compiling 129 | Expected arguments are: 130 | extension_parsed: The parsed extension instance 131 | extension_compiled: The compiled extension instance 132 | Expected raises: 133 | LibyangExtensionError in case of processing error 134 | :arg parse_free_clb 135 | The optional callback function of which will be called during freeing of parsed extension 136 | Expected arguments are: 137 | extension: The parsed extension instance to be freed 138 | :arg compile_free_clb 139 | The optional callback function of which will be called during freeing of compiled extension 140 | Expected arguments are: 141 | extension: The compiled extension instance to be freed 142 | """ 143 | self.context = context 144 | self.module_name = module_name 145 | self.module_name_cstr = str2c(self.module_name) 146 | self.name = name 147 | self.name_cstr = str2c(self.name) 148 | self.id_str = id_str 149 | self.id_cstr = str2c(self.id_str) 150 | self.parse_clb = parse_clb 151 | self.compile_clb = compile_clb 152 | self.parse_free_clb = parse_free_clb 153 | self.compile_free_clb = compile_free_clb 154 | self._error_messages = [] 155 | self._pctx = ffi.NULL 156 | self._cctx = ffi.NULL 157 | 158 | self.cdata = ffi.new("struct lyplg_ext_record[2]") 159 | self.cdata[0].module = self.module_name_cstr 160 | self.cdata[0].name = self.name_cstr 161 | self.cdata[0].plugin.id = self.id_cstr 162 | if self.parse_clb is not None: 163 | self.cdata[0].plugin.parse = lib.lypy_lyplg_ext_parse_clb 164 | if self.compile_clb is not None: 165 | self.cdata[0].plugin.compile = lib.lypy_lyplg_ext_compile_clb 166 | if self.parse_free_clb is not None: 167 | self.cdata[0].plugin.pfree = lib.lypy_lyplg_ext_parse_free_clb 168 | if self.compile_free_clb is not None: 169 | self.cdata[0].plugin.cfree = lib.lypy_lyplg_ext_compile_free_clb 170 | ret = lib.lyplg_add_extension_plugin( 171 | context.cdata if context is not None else ffi.NULL, 172 | lib.LYPLG_EXT_API_VERSION, 173 | ffi.cast("const void *", self.cdata), 174 | ) 175 | if ret != lib.LY_SUCCESS: 176 | raise LibyangError("Unable to add extension plugin") 177 | if self.cdata[0].plugin not in extensions_plugins: 178 | extensions_plugins[self.cdata[0].plugin] = self 179 | 180 | def __del__(self) -> None: 181 | if self.cdata[0].plugin in extensions_plugins: 182 | del extensions_plugins[self.cdata[0].plugin] 183 | 184 | @staticmethod 185 | def stmt2str(stmt: int) -> str: 186 | return c2str(lib.lyplg_ext_stmt2str(stmt)) 187 | 188 | def add_error_message(self, err_msg: str) -> None: 189 | self._error_messages.append(err_msg) 190 | 191 | def clear_error_messages(self) -> None: 192 | self._error_messages.clear() 193 | 194 | def set_parse_ctx(self, pctx) -> None: 195 | self._pctx = pctx 196 | 197 | def set_compile_ctx(self, cctx) -> None: 198 | self._cctx = cctx 199 | 200 | def parse_substmts(self, ext: ExtensionParsed) -> int: 201 | return lib.lyplg_ext_parse_extension_instance(self._pctx, ext.cdata) 202 | 203 | def compile_substmts(self, pext: ExtensionParsed, cext: ExtensionCompiled) -> int: 204 | return lib.lyplg_ext_compile_extension_instance( 205 | self._cctx, pext.cdata, cext.cdata 206 | ) 207 | 208 | def free_parse_substmts(self, ext: ExtensionParsed) -> None: 209 | lib.lyplg_ext_pfree_instance_substatements( 210 | self.context.cdata, ext.cdata.substmts 211 | ) 212 | 213 | def free_compile_substmts(self, ext: ExtensionCompiled) -> None: 214 | lib.lyplg_ext_cfree_instance_substatements( 215 | self.context.cdata, ext.cdata.substmts 216 | ) 217 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | libyang-python 3 | ============== 4 | 5 | Python CFFI bindings to libyang__. 6 | 7 | __ https://github.com/CESNET/libyang/ 8 | 9 | |pypi-project|__ |python-versions|__ |build-status|__ |license|__ |code-style|__ 10 | 11 | __ https://pypi.org/project/libyang 12 | __ https://github.com/CESNET/libyang-python/actions 13 | __ https://github.com/CESNET/libyang-python/actions 14 | __ https://github.com/CESNET/libyang-python/blob/master/LICENSE 15 | __ https://github.com/psf/black 16 | 17 | .. |pypi-project| image:: https://img.shields.io/pypi/v/libyang.svg 18 | .. |python-versions| image:: https://img.shields.io/pypi/pyversions/libyang.svg 19 | .. |build-status| image:: https://github.com/CESNET/libyang-python/workflows/CI/badge.svg 20 | .. |license| image:: https://img.shields.io/github/license/CESNET/libyang-python.svg 21 | .. |code-style| image:: https://img.shields.io/badge/code%20style-black-000000.svg 22 | 23 | Installation 24 | ============ 25 | 26 | :: 27 | 28 | pip install libyang 29 | 30 | This assumes ``libyang.so`` is installed in the system and that ``libyang.h`` is 31 | available in the system include dirs. 32 | 33 | You need the following system dependencies installed: 34 | 35 | - Python development headers 36 | - GCC 37 | - FFI development headers 38 | 39 | On a Debian/Ubuntu system:: 40 | 41 | sudo apt-get install python3-dev gcc python3-cffi 42 | 43 | Compatibility 44 | ------------- 45 | 46 | The current version requires at least C `libyang 2.25`__. 47 | 48 | The last version of the bindings that works with C `libyang 1.x`__ is v1.7.0__. 49 | 50 | __ https://github.com/CESNET/libyang/commit/d2f1608b348f 51 | __ https://github.com/CESNET/libyang/tree/libyang1 52 | __ https://pypi.org/project/libyang/1.7.0/ 53 | 54 | Compilation Flags 55 | ----------------- 56 | 57 | If libyang headers and libraries are installed in a non-standard location, you 58 | can specify them with the ``LIBYANG_HEADERS`` and ``LIBYANG_LIBRARIES`` 59 | variables. Additionally, for finer control, you may use ``LIBYANG_EXTRA_CFLAGS`` 60 | and ``LIBYANG_EXTRA_LDFLAGS``:: 61 | 62 | LIBYANG_HEADERS=/home/build/opt/ly/include \ 63 | LIBYANG_LIBRARIES=/home/build/opt/ly/lib \ 64 | LIBYANG_EXTRA_CFLAGS="-O3" \ 65 | LIBYANG_EXTRA_LDFLAGS="-rpath=/opt/ly/lib" \ 66 | pip install libyang 67 | 68 | Examples 69 | ======== 70 | 71 | Schema Introspection 72 | -------------------- 73 | 74 | .. code-block:: pycon 75 | 76 | >>> import libyang 77 | >>> ctx = libyang.Context('/usr/local/share/yang/modules') 78 | >>> module = ctx.load_module('ietf-system') 79 | >>> print(module) 80 | module: ietf-system 81 | +--rw system 82 | | +--rw contact? string 83 | | +--rw hostname? ietf-inet-types:domain-name 84 | | +--rw location? string 85 | | +--rw clock 86 | | | +--rw (timezone)? 87 | | | +--:(timezone-utc-offset) 88 | | | +--rw timezone-utc-offset? int16 89 | | +--rw dns-resolver 90 | | +--rw search* ietf-inet-types:domain-name 91 | | +--rw server* [name] 92 | | | +--rw name string 93 | | | +--rw (transport) 94 | | | +--:(udp-and-tcp) 95 | | | +--rw udp-and-tcp 96 | | | +--rw address ietf-inet-types:ip-address 97 | | +--rw options 98 | | +--rw timeout? uint8 <5> 99 | | +--rw attempts? uint8 <2> 100 | +--ro system-state 101 | +--ro platform 102 | | +--ro os-name? string 103 | | +--ro os-release? string 104 | | +--ro os-version? string 105 | | +--ro machine? string 106 | +--ro clock 107 | +--ro current-datetime? ietf-yang-types:date-and-time 108 | +--ro boot-datetime? ietf-yang-types:date-and-time 109 | 110 | rpcs: 111 | +---x set-current-datetime 112 | | +---- input 113 | | +---w current-datetime ietf-yang-types:date-and-time 114 | +---x system-restart 115 | +---x system-shutdown 116 | 117 | >>> xpath = '/ietf-system:system/ietf-system:dns-resolver/ietf-system:server' 118 | >>> dnsserver = next(ctx.find_path(xpath)) 119 | >>> dnsserver 120 | 121 | >>> print(dnsserver.description()) 122 | List of the DNS servers that the resolver should query. 123 | 124 | When the resolver is invoked by a calling application, it 125 | sends the query to the first name server in this list. If 126 | no response has been received within 'timeout' seconds, 127 | the resolver continues with the next server in the list. 128 | If no response is received from any server, the resolver 129 | continues with the first server again. When the resolver 130 | has traversed the list 'attempts' times without receiving 131 | any response, it gives up and returns an error to the 132 | calling application. 133 | 134 | Implementations MAY limit the number of entries in this 135 | list. 136 | >>> dnsserver.ordered() 137 | True 138 | >>> for node in dnsserver: 139 | ... print(repr(node)) 140 | ... 141 | 142 | 143 | >>> ctx.destroy() 144 | >>> 145 | 146 | Data Tree 147 | --------- 148 | 149 | .. code-block:: pycon 150 | 151 | >>> import libyang 152 | >>> ctx = libyang.Context() 153 | >>> module = ctx.parse_module_str(''' 154 | ... module example { 155 | ... namespace "urn:example"; 156 | ... prefix "ex"; 157 | ... container data { 158 | ... list interface { 159 | ... key name; 160 | ... leaf name { 161 | ... type string; 162 | ... } 163 | ... leaf address { 164 | ... type string; 165 | ... } 166 | ... } 167 | ... leaf hostname { 168 | ... type string; 169 | ... } 170 | ... } 171 | ... } 172 | ... ''') 173 | >>> print(module.print_mem('tree')) 174 | module: example 175 | +--rw data 176 | +--rw interface* [name] 177 | | +--rw name string 178 | | +--rw address? string 179 | +--rw hostname? string 180 | >>> node = module.parse_data_dict({ 181 | ... 'data': { 182 | ... 'hostname': 'foobar', 183 | ... 'interface': [ 184 | ... {'name': 'eth0', 'address': '1.2.3.4/24'}, 185 | ... {'name': 'lo', 'address': '127.0.0.1'}, 186 | ... ], 187 | ... }, 188 | ... }) 189 | >>> print(node.print_mem('xml', pretty=True)) 190 | 191 | 192 | eth0 193 |
1.2.3.4/24
194 |
195 | 196 | lo 197 |
127.0.0.1
198 |
199 | foobar 200 |
201 | >>> node.print_dict() 202 | {'data': {'interface': [{'name': 'eth0', 'address': '1.2.3.4/24'}, {'name': 203 | 'lo', 'address': '127.0.0.1'}], 'hostname': 'foobar'}} 204 | >>> node.free() 205 | >>> ctx.destroy() 206 | >>> 207 | 208 | See the ``tests`` folder for more examples. 209 | 210 | Contributing 211 | ============ 212 | 213 | This is an open source project and all contributions are welcome. 214 | 215 | Issues 216 | ------ 217 | 218 | Please create new issues for any bug you discover at 219 | https://github.com/CESNET/libyang-python/issues/new. It is not necessary to file 220 | a bug if you are preparing a patch. 221 | 222 | Pull Requests 223 | ------------- 224 | 225 | Here are the steps for submitting a change in the code base: 226 | 227 | #. Fork the repository: https://github.com/CESNET/libyang-python/fork 228 | 229 | #. Clone your own fork into your development machine:: 230 | 231 | git clone https://github.com//libyang-python 232 | 233 | #. Create a new branch named after what your are working on:: 234 | 235 | git checkout -b my-topic -t origin/master 236 | 237 | #. Edit the code and call ``make format`` to ensure your modifications comply 238 | with the `coding style`__. 239 | 240 | __ https://black.readthedocs.io/en/stable/the_black_code_style.html 241 | 242 | Your contribution must be licensed under the `MIT License`__ . At least one 243 | copyright notice is expected in new files. 244 | 245 | __ https://spdx.org/licenses/MIT.html 246 | 247 | #. If you are adding a new feature or fixing a bug, please consider adding or 248 | updating unit tests. 249 | 250 | #. Before creating commits, run ``make lint`` and ``make tests`` to check if 251 | your changes do not break anything. You can also run ``make`` which will run 252 | both. 253 | 254 | #. Once you are happy with your work, you can create a commit (or several 255 | commits). Follow these general rules: 256 | 257 | - Address only one issue/topic per commit. 258 | - Describe your changes in imperative mood, e.g. *"make xyzzy do frotz"* 259 | instead of *"[This patch] makes xyzzy do frotz"* or *"[I] changed xyzzy to 260 | do frotz"*, as if you are giving orders to the codebase to change its 261 | behaviour. 262 | - Limit the first line (title) of the commit message to 60 characters. 263 | - Use a short prefix for the commit title for readability with ``git log 264 | --oneline``. Do not use the `fix:` nor `feature:` prefixes. See recent 265 | commits for inspiration. 266 | - Only use lower case letters for the commit title except when quoting 267 | symbols or known acronyms. 268 | - Use the body of the commit message to actually explain what your patch 269 | does and why it is useful. Even if your patch is a one line fix, the 270 | description is not limited in length and may span over multiple 271 | paragraphs. Use proper English syntax, grammar and punctuation. 272 | - If you are fixing an issue, use appropriate ``Closes: `` or 273 | ``Fixes: `` trailers. 274 | - If you are fixing a regression introduced by another commit, add a 275 | ``Fixes: ("")`` trailer. 276 | - When in doubt, follow the format and layout of the recent existing 277 | commits. 278 | - The following trailers are accepted in commits. If you are using multiple 279 | trailers in a commit, it's preferred to also order them according to this 280 | list. 281 | 282 | * ``Closes: <URL>``: close the referenced issue or pull request. 283 | * ``Fixes: <SHA> ("<TITLE>")``: reference the commit that introduced 284 | a regression. 285 | * ``Link: <URL>``: any useful link to provide context for your commit. 286 | * ``Suggested-by`` 287 | * ``Requested-by`` 288 | * ``Reported-by`` 289 | * ``Co-authored-by`` 290 | * ``Tested-by`` 291 | * ``Reviewed-by`` 292 | * ``Acked-by`` 293 | * ``Signed-off-by``: Compulsory! 294 | 295 | There is a great reference for commit messages in the `Linux kernel 296 | documentation`__. 297 | 298 | __ https://www.kernel.org/doc/html/latest/process/submitting-patches.html#describe-your-changes 299 | 300 | IMPORTANT: you must sign-off your work using ``git commit --signoff``. Follow 301 | the `Linux kernel developer's certificate of origin`__ for more details. All 302 | contributions are made under the MIT license. If you do not want to disclose 303 | your real name, you may sign-off using a pseudonym. Here is an example:: 304 | 305 | Signed-off-by: Robin Jarry <robin@jarry.cc> 306 | 307 | __ https://www.kernel.org/doc/html/latest/process/submitting-patches.html#sign-your-work-the-developer-s-certificate-of-origin 308 | 309 | #. Push your topic branch in your forked repository:: 310 | 311 | git push origin my-topic 312 | 313 | You should get a message from Github explaining how to create a new pull 314 | request. 315 | 316 | #. Wait for a reviewer to merge your work. If minor adjustments are requested, 317 | use ``git commit --fixup $sha1`` to make it obvious what commit you are 318 | adjusting. If bigger changes are needed, make them in new separate commits. 319 | Once the reviewer is happy, please use ``git rebase --autosquash`` to amend 320 | the commits with their small fixups (if any), and ``git push --force`` on 321 | your topic branch. 322 | 323 | Thank you in advance for your contributions! 324 | -------------------------------------------------------------------------------- /libyang/diff.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2019 6WIND 2 | # SPDX-License-Identifier: MIT 3 | 4 | from typing import Any, Callable, Iterator, Optional 5 | 6 | from .context import Context 7 | from .schema import SContainer, SLeaf, SLeafList, SList, SNode, SNotif, SRpc, SRpcInOut 8 | 9 | 10 | # ------------------------------------------------------------------------------------- 11 | def schema_diff( 12 | ctx_old: Context, 13 | ctx_new: Context, 14 | exclude_node_cb: Optional[Callable[[SNode], bool]] = None, 15 | use_data_path: bool = False, 16 | ) -> Iterator["SNodeDiff"]: 17 | """ 18 | Compare two libyang Context objects, for a given set of paths and return all 19 | differences. 20 | 21 | :arg ctx_old: 22 | The first context. 23 | :arg ctx_new: 24 | The second context. 25 | :arg exclude_node_cb: 26 | Optional user callback that will be called with each node that is found in each 27 | context. If the callback returns a "trueish" value, the node will be excluded 28 | from the diff (as well as all its children). 29 | :arg use_data_path: 30 | Use data path instead of schema path to compare the nodes. Using data path 31 | ignores choices and cases. 32 | 33 | :return: 34 | An iterator that yield `SNodeDiff` objects. 35 | """ 36 | if exclude_node_cb is None: 37 | # pylint: disable=unnecessary-lambda-assignment 38 | exclude_node_cb = lambda n: False 39 | 40 | def flatten(node, dic): 41 | """ 42 | Flatten a node and all its children into a dict (indexed by their schema xpath). 43 | This function is recursive. 44 | """ 45 | if exclude_node_cb(node): 46 | return 47 | if use_data_path: 48 | path = node.data_path() 49 | else: 50 | path = node.schema_path() 51 | dic[path] = node 52 | if isinstance(node, (SContainer, SList, SNotif, SRpc, SRpcInOut)): 53 | for child in node: 54 | flatten(child, dic) 55 | 56 | old_dict = {} 57 | new_dict = {} 58 | 59 | for module in ctx_old: 60 | for node in module: 61 | flatten(node, old_dict) 62 | for module in ctx_new: 63 | for node in module: 64 | flatten(node, new_dict) 65 | 66 | diffs = {} 67 | old_paths = frozenset(old_dict.keys()) 68 | new_paths = frozenset(new_dict.keys()) 69 | for path in old_paths - new_paths: 70 | diffs[path] = [SNodeRemoved(old_dict[path])] 71 | for path in new_paths - old_paths: 72 | diffs[path] = [SNodeAdded(new_dict[path])] 73 | for path in old_paths & new_paths: 74 | old = old_dict[path] 75 | new = new_dict[path] 76 | diffs[path] = snode_changes(old, new) 77 | 78 | for path in sorted(diffs.keys()): 79 | yield from diffs[path] 80 | 81 | 82 | # ------------------------------------------------------------------------------------- 83 | class SNodeDiff: 84 | __slots__ = ("__dict__",) 85 | 86 | 87 | # ------------------------------------------------------------------------------------- 88 | class SNodeRemoved(SNodeDiff): 89 | __slots__ = ("node",) 90 | 91 | def __init__(self, node: SNode): 92 | self.node = node 93 | 94 | def __str__(self): 95 | return "-%s: removed status=%s %s" % ( 96 | self.node.schema_path(), 97 | self.node.status(), 98 | self.node.keyword(), 99 | ) 100 | 101 | 102 | # ------------------------------------------------------------------------------------- 103 | class SNodeAdded(SNodeDiff): 104 | __slots__ = ("node",) 105 | 106 | def __init__(self, node: SNode): 107 | self.node = node 108 | 109 | def __str__(self): 110 | return "+%s: added %s" % (self.node.schema_path(), self.node.keyword()) 111 | 112 | 113 | # ------------------------------------------------------------------------------------- 114 | class SNodeAttributeChanged(SNodeDiff): 115 | __slots__ = ("old", "new", "value") 116 | 117 | def __init__(self, old: SNode, new: SNode, value: Any = None): 118 | self.old = old 119 | self.new = new 120 | self.value = value 121 | 122 | def __str__(self): 123 | if self.__class__.__name__.endswith("Added"): 124 | sign = "+" 125 | else: 126 | sign = "-" 127 | s = "%s%s: %s" % (sign, self.new.schema_path(), self.__class__.__name__) 128 | if self.value is not None: 129 | str_val = str(self.value).replace('"', '\\"').replace("\n", "\\n") 130 | s += ' "%s"' % str_val 131 | return s 132 | 133 | 134 | # ------------------------------------------------------------------------------------- 135 | class BaseTypeRemoved(SNodeAttributeChanged): 136 | pass 137 | 138 | 139 | class BaseTypeAdded(SNodeAttributeChanged): 140 | pass 141 | 142 | 143 | class BitRemoved(SNodeAttributeChanged): 144 | pass 145 | 146 | 147 | class BitAdded(SNodeAttributeChanged): 148 | pass 149 | 150 | 151 | class BitStatusRemoved(SNodeAttributeChanged): 152 | pass 153 | 154 | 155 | class BitStatusAdded(SNodeAttributeChanged): 156 | pass 157 | 158 | 159 | class ConfigFalseRemoved(SNodeAttributeChanged): 160 | pass 161 | 162 | 163 | class ConfigFalseAdded(SNodeAttributeChanged): 164 | pass 165 | 166 | 167 | class DefaultRemoved(SNodeAttributeChanged): 168 | pass 169 | 170 | 171 | class DefaultAdded(SNodeAttributeChanged): 172 | pass 173 | 174 | 175 | class DescriptionRemoved(SNodeAttributeChanged): 176 | pass 177 | 178 | 179 | class DescriptionAdded(SNodeAttributeChanged): 180 | pass 181 | 182 | 183 | class EnumRemoved(SNodeAttributeChanged): 184 | pass 185 | 186 | 187 | class EnumAdded(SNodeAttributeChanged): 188 | pass 189 | 190 | 191 | class EnumStatusRemoved(SNodeAttributeChanged): 192 | pass 193 | 194 | 195 | class EnumStatusAdded(SNodeAttributeChanged): 196 | pass 197 | 198 | 199 | class ExtensionRemoved(SNodeAttributeChanged): 200 | pass 201 | 202 | 203 | class ExtensionAdded(SNodeAttributeChanged): 204 | pass 205 | 206 | 207 | class KeyRemoved(SNodeAttributeChanged): 208 | pass 209 | 210 | 211 | class KeyAdded(SNodeAttributeChanged): 212 | pass 213 | 214 | 215 | class MandatoryRemoved(SNodeAttributeChanged): 216 | pass 217 | 218 | 219 | class MandatoryAdded(SNodeAttributeChanged): 220 | pass 221 | 222 | 223 | class MustRemoved(SNodeAttributeChanged): 224 | pass 225 | 226 | 227 | class MustAdded(SNodeAttributeChanged): 228 | pass 229 | 230 | 231 | class NodeTypeAdded(SNodeAttributeChanged): 232 | pass 233 | 234 | 235 | class NodeTypeRemoved(SNodeAttributeChanged): 236 | pass 237 | 238 | 239 | class RangeRemoved(SNodeAttributeChanged): 240 | pass 241 | 242 | 243 | class RangeAdded(SNodeAttributeChanged): 244 | pass 245 | 246 | 247 | class OrderedByUserRemoved(SNodeAttributeChanged): 248 | pass 249 | 250 | 251 | class OrderedByUserAdded(SNodeAttributeChanged): 252 | pass 253 | 254 | 255 | class PresenceRemoved(SNodeAttributeChanged): 256 | pass 257 | 258 | 259 | class PresenceAdded(SNodeAttributeChanged): 260 | pass 261 | 262 | 263 | class StatusRemoved(SNodeAttributeChanged): 264 | pass 265 | 266 | 267 | class StatusAdded(SNodeAttributeChanged): 268 | pass 269 | 270 | 271 | class LengthRemoved(SNodeAttributeChanged): 272 | pass 273 | 274 | 275 | class LengthAdded(SNodeAttributeChanged): 276 | pass 277 | 278 | 279 | class PatternRemoved(SNodeAttributeChanged): 280 | pass 281 | 282 | 283 | class PatternAdded(SNodeAttributeChanged): 284 | pass 285 | 286 | 287 | class UnitsRemoved(SNodeAttributeChanged): 288 | pass 289 | 290 | 291 | class UnitsAdded(SNodeAttributeChanged): 292 | pass 293 | 294 | 295 | # ------------------------------------------------------------------------------------- 296 | def snode_changes(old: SNode, new: SNode) -> Iterator[SNodeDiff]: 297 | if old.nodetype() != new.nodetype(): 298 | yield NodeTypeRemoved(old, new, old.keyword()) 299 | yield NodeTypeAdded(old, new, new.keyword()) 300 | 301 | if old.description() != new.description(): 302 | if old.description() is not None: 303 | yield DescriptionRemoved(old, new, old.description()) 304 | if new.description() is not None: 305 | yield DescriptionAdded(old, new, new.description()) 306 | 307 | if old.mandatory() and not new.mandatory(): 308 | yield MandatoryRemoved(old, new) 309 | elif not old.mandatory() and new.mandatory(): 310 | yield MandatoryAdded(old, new) 311 | 312 | if old.status() != new.status(): 313 | yield StatusRemoved(old, new, old.status()) 314 | yield StatusAdded(old, new, new.status()) 315 | 316 | if old.config_false() and not new.config_false(): 317 | yield ConfigFalseRemoved(old, new) 318 | elif not old.config_false() and new.config_false(): 319 | yield ConfigFalseAdded(old, new) 320 | 321 | old_musts = frozenset(old.must_conditions()) 322 | new_musts = frozenset(new.must_conditions()) 323 | for m in old_musts - new_musts: 324 | yield MustRemoved(old, new, m) 325 | for m in new_musts - old_musts: 326 | yield MustAdded(old, new, m) 327 | 328 | old_exts = {(e.module().name(), e.name()): e for e in old.extensions()} 329 | new_exts = {(e.module().name(), e.name()): e for e in new.extensions()} 330 | old_ext_keys = frozenset(old_exts.keys()) 331 | new_ext_keys = frozenset(new_exts.keys()) 332 | 333 | for k in old_ext_keys - new_ext_keys: 334 | yield ExtensionRemoved(old, new, old_exts[k]) 335 | for k in new_ext_keys - old_ext_keys: 336 | yield ExtensionAdded(old, new, new_exts[k]) 337 | for k in old_ext_keys & new_ext_keys: 338 | if old_exts[k].argument() != new_exts[k].argument(): 339 | yield ExtensionRemoved(old, new, old_exts[k]) 340 | yield ExtensionAdded(old, new, new_exts[k]) 341 | 342 | if (isinstance(old, SLeaf) and isinstance(new, SLeaf)) or ( 343 | isinstance(old, SLeafList) and isinstance(new, SLeafList) 344 | ): 345 | old_bases = set(old.type().basenames()) 346 | new_bases = set(new.type().basenames()) 347 | for b in old_bases - new_bases: 348 | yield BaseTypeRemoved(old, new, b) 349 | for b in new_bases - old_bases: 350 | yield BaseTypeAdded(old, new, b) 351 | 352 | if old.units() != new.units(): 353 | if old.units() is not None: 354 | yield UnitsRemoved(old, new, old.units()) 355 | if new.units() is not None: 356 | yield UnitsAdded(old, new, new.units()) 357 | 358 | old_lengths = set(old.type().all_lengths()) 359 | new_lengths = set(new.type().all_lengths()) 360 | for l in old_lengths - new_lengths: 361 | yield LengthRemoved(old, new, l) 362 | for l in new_lengths - old_lengths: 363 | yield LengthAdded(old, new, l) 364 | 365 | # Multiple "pattern" statements on a single type are ANDed together (i.e. they 366 | # must all match). However, when a leaf type is an union of multiple string 367 | # typedefs with individual "pattern" statements, the patterns are ORed together 368 | # (i.e. one of them must match). 369 | # 370 | # This is not handled properly here as we flatten all patterns in a single set 371 | # and consider there is a difference if we remove/add one of them. 372 | # 373 | # The difference does not hold any information about which union branch it 374 | # relates to. This is way too complex. 375 | old_patterns = set(old.type().all_patterns()) 376 | new_patterns = set(new.type().all_patterns()) 377 | for p in old_patterns - new_patterns: 378 | yield PatternRemoved(old, new, p) 379 | for p in new_patterns - old_patterns: 380 | yield PatternAdded(old, new, p) 381 | 382 | old_ranges = set(old.type().all_ranges()) 383 | new_ranges = set(new.type().all_ranges()) 384 | for r in old_ranges - new_ranges: 385 | yield RangeRemoved(old, new, r) 386 | for r in new_ranges - old_ranges: 387 | yield RangeAdded(old, new, r) 388 | 389 | old_enums = {e.name(): e for e in old.type().all_enums()} 390 | new_enums = {e.name(): e for e in new.type().all_enums()} 391 | for e in old_enums.keys() - new_enums.keys(): 392 | yield EnumRemoved(old, new, old_enums[e]) 393 | for e in new_enums.keys() - old_enums.keys(): 394 | yield EnumAdded(old, new, new_enums[e]) 395 | for e in new_enums.keys() & old_enums.keys(): 396 | o = old_enums[e] 397 | n = new_enums[e] 398 | if o.status() != n.status(): 399 | yield EnumStatusRemoved(old, new, (o.name(), o.status())) 400 | yield EnumStatusAdded(old, new, (n.name(), n.status())) 401 | 402 | old_bits = {b.name(): b for b in old.type().all_bits()} 403 | new_bits = {b.name(): b for b in new.type().all_bits()} 404 | for b in old_bits.keys() - new_bits.keys(): 405 | yield BitRemoved(old, new, old_bits[b]) 406 | for b in new_bits.keys() - old_bits.keys(): 407 | yield BitAdded(old, new, new_bits[b]) 408 | for b in new_bits.keys() & old_bits.keys(): 409 | o = old_bits[b] 410 | n = new_bits[b] 411 | if o.status() != n.status(): 412 | yield BitStatusRemoved(old, new, (o.name(), o.status())) 413 | yield BitStatusAdded(old, new, (n.name(), n.status())) 414 | 415 | if isinstance(old, SLeaf) and isinstance(new, SLeaf): 416 | if old.default() != new.default(): 417 | if old.default() is not None: 418 | yield DefaultRemoved(old, new, old.default()) 419 | if new.default() is not None: 420 | yield DefaultAdded(old, new, new.default()) 421 | 422 | elif isinstance(old, SLeafList) and isinstance(new, SLeafList): 423 | old_defaults = frozenset(old.defaults()) 424 | new_defaults = frozenset(new.defaults()) 425 | for d in old_defaults - new_defaults: 426 | yield DefaultRemoved(old, new, d) 427 | for d in new_defaults - old_defaults: 428 | yield DefaultAdded(old, new, d) 429 | if old.ordered() and not new.ordered(): 430 | yield OrderedByUserRemoved(old, new) 431 | elif not old.ordered() and new.ordered(): 432 | yield OrderedByUserAdded(old, new) 433 | 434 | elif isinstance(old, SContainer) and isinstance(new, SContainer): 435 | if old.presence() != new.presence(): 436 | if old.presence() is not None: 437 | yield PresenceRemoved(old, new, old.presence()) 438 | if new.presence() is not None: 439 | yield PresenceAdded(old, new, new.presence()) 440 | 441 | elif isinstance(old, SList) and isinstance(new, SList): 442 | old_keys = frozenset(k.name() for k in old.keys()) 443 | new_keys = frozenset(k.name() for k in new.keys()) 444 | for k in old_keys - new_keys: 445 | yield KeyRemoved(old, new, k) 446 | for k in new_keys - old_keys: 447 | yield KeyAdded(old, new, k) 448 | if old.ordered() and not new.ordered(): 449 | yield OrderedByUserRemoved(old, new) 450 | elif not old.ordered() and new.ordered(): 451 | yield OrderedByUserAdded(old, new) 452 | -------------------------------------------------------------------------------- /tests/test_xpath.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 6WIND S.A. 2 | # SPDX-License-Identifier: MIT 3 | 4 | import copy 5 | import unittest 6 | 7 | import libyang as ly 8 | 9 | 10 | # ------------------------------------------------------------------------------------- 11 | class XPathTest(unittest.TestCase): 12 | def test_xpath_split(self): 13 | for xpath, split_result in XPATH_SPLIT_EXPECTED_RESULTS.items(): 14 | if split_result is ValueError: 15 | with self.assertRaises(split_result): 16 | list(ly.xpath_split(xpath)) 17 | else: 18 | self.assertEqual(list(ly.xpath_split(xpath)), split_result) 19 | 20 | def test_xpath_get(self): 21 | for xpath, value, defval, expected in XPATH_GET_EXPECTED_RESULTS: 22 | res = ly.xpath_get(DICT, xpath, defval) 23 | if expected: 24 | self.assertEqual(res, value) 25 | self.assertNotEqual(res, defval) 26 | else: 27 | self.assertNotEqual(res, value) 28 | self.assertEqual(res, defval) 29 | 30 | def test_xpath_set(self): 31 | d = copy.deepcopy(DICT) 32 | ly.xpath_set(d, "/val", 43) 33 | ly.xpath_set(d, "/cont1/leaf1", "foo") 34 | ly.xpath_set(d, "/cont1/leaf2", "bar") 35 | ly.xpath_set(d, "/iface[name='eth0']", {"name": "eth0", "up": False}) 36 | ly.xpath_set(d, "/iface[name='eth1']/ipv4/address[ip='10.0.0.2']/mtu", 1500) 37 | ly.xpath_set( 38 | d, 39 | "/iface[name='eth1']/ipv6/address[ip='3ffe::ffff'][prefixlen='100']", 40 | {"ip": "3ffe::ffff", "prefixlen": 100, "tentative": False}, 41 | ) 42 | ly.xpath_set(d, "/lstnum[.='100']", 100) 43 | ly.xpath_set(d, "/lstnum[.='1']", 1, after="") 44 | ly.xpath_set(d, "/lstnum[5]", 33, after="4") 45 | ly.xpath_set(d, "/lstnum[5]", 34, after="4") 46 | ly.xpath_set(d, "/lstnum[5]", 35, after="4") 47 | ly.xpath_set(d, "/lstnum[7]", 101, after="6") 48 | ly.xpath_set(d, "/lstnum[8]", 102, after="7") 49 | with self.assertRaises(ValueError): 50 | ly.xpath_set(d, "/lstnum[.='1000']", 1000, after="1000000") 51 | with self.assertRaises(ValueError): 52 | ly.xpath_set( 53 | d, 54 | "/iface[name='eth1']/ipv4/address[ip='10.0.0.3']", 55 | {"ip": "10.0.0.3"}, 56 | after="[ip='10.0.0.55']", 57 | ) 58 | ly.xpath_set( 59 | d, "/iface[name='eth1']/ipv4/address[ip='10.0.0.3']", {"ip": "10.0.0.3"} 60 | ) 61 | ly.xpath_set(d, "/cont2/newlist[foo='bar']", {"foo": "bar", "name": "baz"}) 62 | ly.xpath_set(d, "/cont2/newll[.='12']", 12) 63 | self.assertEqual( 64 | d, 65 | { 66 | "cont1": {"leaf1": "foo", "leaf2": "bar"}, 67 | "cont2": { 68 | "leaf2": "coucou2", 69 | "newlist": [{"foo": "bar", "name": "baz"}], 70 | "newll": [12], 71 | }, 72 | "iface": [ 73 | {"name": "eth0", "up": False}, 74 | { 75 | "name": "eth1", 76 | "ipv4": { 77 | "address": [ 78 | {"ip": "10.0.0.2", "mtu": 1500}, 79 | {"ip": "10.0.0.6"}, 80 | {"ip": "10.0.0.3"}, 81 | ] 82 | }, 83 | "ipv6": { 84 | "address": [ 85 | { 86 | "ip": "3ffe::321:8", 87 | "prefixlen": 64, 88 | "tentative": False, 89 | }, 90 | { 91 | "ip": "3ffe::ff12", 92 | "prefixlen": 96, 93 | "tentative": True, 94 | }, 95 | { 96 | "ip": "3ffe::ffff", 97 | "prefixlen": 100, 98 | "tentative": False, 99 | }, 100 | ] 101 | }, 102 | }, 103 | ], 104 | "iface2": [ 105 | {"name": "eth2", "mtu": 1500}, 106 | {"name": "eth3", "mtu": 1000}, 107 | ], 108 | "lst2": ["a", "b", "c"], 109 | "lstnum": [1, 10, 20, 30, 35, 100, 101, 102], 110 | "val": 43, 111 | }, 112 | ) 113 | 114 | def test_xpath_del(self): 115 | d = copy.deepcopy(DICT) 116 | self.assertTrue(ly.xpath_del(d, "/val")) 117 | self.assertTrue(ly.xpath_del(d, "/cont2/leaf2")) 118 | self.assertTrue(ly.xpath_del(d, "/lst2[.='b']")) 119 | self.assertTrue(ly.xpath_del(d, "/lst2[.='a']")) 120 | self.assertTrue(ly.xpath_del(d, "/lst2[.='c']")) 121 | self.assertFalse(ly.xpath_del(d, "/lst2[.='x']")) 122 | self.assertFalse(ly.xpath_del(d, "/lst2")) 123 | self.assertTrue(ly.xpath_del(d, "lstnum[.='20']")) 124 | self.assertFalse(ly.xpath_del(d, "/lstnum[.='10000']")) 125 | self.assertFalse(ly.xpath_del(d, "/foo/bar/baz")) 126 | self.assertTrue( 127 | ly.xpath_del(d, "/iface[name='eth1']/ipv4/address[ip='10.0.0.2']") 128 | ) 129 | self.assertFalse( 130 | ly.xpath_del(d, "/iface[name='eth1']/ipv4/address[ip='10.0.0.7']") 131 | ) 132 | self.assertFalse( 133 | ly.xpath_del( 134 | d, "/iface[name='eth1']/ipv6/address[ip='3ffe::ff12'][prefixlen='64']" 135 | ) 136 | ) 137 | self.assertTrue( 138 | ly.xpath_del( 139 | d, "/iface[name='eth1']/ipv6/address[ip='3ffe::ff12'][prefixlen='96']" 140 | ) 141 | ) 142 | self.assertEqual( 143 | d, 144 | { 145 | "cont1": {"leaf1": "coucou1"}, 146 | "cont2": {}, 147 | "iface": [ 148 | { 149 | "name": "eth0", 150 | "ipv4": {"address": [{"ip": "10.0.0.1"}, {"ip": "10.0.0.153"}]}, 151 | "ipv6": { 152 | "address": [{"ip": "3ffe::123:1"}, {"ip": "3ffe::c00:c00"}] 153 | }, 154 | }, 155 | { 156 | "name": "eth1", 157 | "ipv4": {"address": [{"ip": "10.0.0.6"}]}, 158 | "ipv6": { 159 | "address": [ 160 | { 161 | "ip": "3ffe::321:8", 162 | "prefixlen": 64, 163 | "tentative": False, 164 | } 165 | ] 166 | }, 167 | }, 168 | ], 169 | "iface2": [ 170 | {"name": "eth2", "mtu": 1500}, 171 | {"name": "eth3", "mtu": 1000}, 172 | ], 173 | "lstnum": [10, 30, 40], 174 | }, 175 | ) 176 | 177 | def test_xpath_move(self): 178 | d = { 179 | "regular": [ 180 | {"name": "foo", "size": 2}, 181 | {"name": "bar", "size": 1}, 182 | {"name": "baz", "size": 42}, 183 | ], 184 | "regular-multi": [ 185 | {"name": "foo", "group": "xxx", "size": 2}, 186 | {"name": "bar", "group": "yyy", "size": 1}, 187 | {"name": "baz", "group": "xxx", "size": 42}, 188 | ], 189 | "ll": ["a", "b", "c", "d"], 190 | "ll-keyed": ly.KeyedList([10, 20, 30, 40]), 191 | } 192 | with self.assertRaises(KeyError): 193 | ly.xpath_move(d, "/not/found", "") 194 | with self.assertRaises(KeyError): 195 | ly.xpath_move(d, "/ll[.='x']", "") 196 | with self.assertRaises(KeyError): 197 | ly.xpath_move(d, "/ll[.='c']", "x") 198 | with self.assertRaises(ValueError): 199 | ly.xpath_move(d, "/ll", "") 200 | ly.xpath_move(d, "/ll[.='c']", "a") 201 | ly.xpath_move(d, "/ll[.='b']", "") 202 | ly.xpath_move(d, "/ll[.='a']", None) 203 | with self.assertRaises(ValueError): 204 | ly.xpath_move(d, "/ll-keyed[.='40']", "") 205 | with self.assertRaises(ValueError): 206 | ly.xpath_move(d, "/ll-keyed[.='10']", None) 207 | with self.assertRaises(ValueError): 208 | ly.xpath_move(d, "/ll-keyed[.='30']", "40") 209 | self.assertEqual( 210 | d, 211 | { 212 | "regular": [ 213 | {"name": "foo", "size": 2}, 214 | {"name": "bar", "size": 1}, 215 | {"name": "baz", "size": 42}, 216 | ], 217 | "regular-multi": [ 218 | {"group": "xxx", "name": "foo", "size": 2}, 219 | {"group": "yyy", "name": "bar", "size": 1}, 220 | {"group": "xxx", "name": "baz", "size": 42}, 221 | ], 222 | "ll": ["b", "c", "d", "a"], 223 | "ll-keyed": [10, 20, 30, 40], 224 | }, 225 | ) 226 | 227 | def test_xpath_getall(self): 228 | for xpath, expected in XPATH_GETALL_EXPECTED_RESULTS: 229 | # result order is not guaranteed, and we cannot use a set() because 230 | # dicts are not hashable. 231 | res = list(ly.xpath_getall(DICT, xpath)) 232 | self.assertEqual(len(res), len(expected), xpath) 233 | for elt in res: 234 | self.assertIn(elt, expected, xpath) 235 | 236 | 237 | XPATH_SPLIT_EXPECTED_RESULTS = { 238 | "": ValueError, 239 | "/p:nam": [("p", "nam", [])], 240 | "/nam1/nam2": [(None, "nam1", []), (None, "nam2", [])], 241 | "/nam1/p:nam2": [(None, "nam1", []), ("p", "nam2", [])], 242 | '/p:nam/lst[k1="foo"]': [("p", "nam", []), (None, "lst", [("k1", "foo")])], 243 | '/p:nam/lst[.="foo"]': [("p", "nam", []), (None, "lst", [(".", "foo")])], 244 | '/p:nam/lst[.="foo\\bar"]': [("p", "nam", []), (None, "lst", [(".", "foo\\bar")])], 245 | "/nam/p:lst[k1='foo'][k2='bar']/x": [ 246 | (None, "nam", []), 247 | ("p", "lst", [("k1", "foo"), ("k2", "bar")]), 248 | (None, "x", []), 249 | ], 250 | "/nam/p:lst[k1='=:[/]\\'']/l2[k2=\"dead::beef/64\"]": [ 251 | (None, "nam", []), 252 | ("p", "lst", [("k1", "=:[/]'")]), 253 | (None, "l2", [("k2", "dead::beef/64")]), 254 | ], 255 | "foo": [(None, "foo", [])], 256 | "foo/bar": [(None, "foo", []), (None, "bar", [])], 257 | "//invalid/xpath": ValueError, 258 | "/xxx[abc='2']invalid:xx/xpath": ValueError, 259 | } 260 | 261 | DICT = { 262 | "cont1": { 263 | "leaf1": "coucou1", 264 | }, 265 | "cont2": { 266 | "leaf2": "coucou2", 267 | }, 268 | "iface": [ 269 | { 270 | "name": "eth0", 271 | "ipv4": { 272 | "address": [ 273 | {"ip": "10.0.0.1"}, 274 | {"ip": "10.0.0.153"}, 275 | ], 276 | }, 277 | "ipv6": { 278 | "address": [ 279 | {"ip": "3ffe::123:1"}, 280 | {"ip": "3ffe::c00:c00"}, 281 | ], 282 | }, 283 | }, 284 | { 285 | "name": "eth1", 286 | "ipv4": { 287 | "address": ly.KeyedList( 288 | [ 289 | {"ip": "10.0.0.2"}, 290 | {"ip": "10.0.0.6"}, 291 | ], 292 | key_name="ip", 293 | ), 294 | }, 295 | "ipv6": { 296 | "address": ly.KeyedList( 297 | [ 298 | {"ip": "3ffe::321:8", "prefixlen": 64, "tentative": False}, 299 | {"ip": "3ffe::ff12", "prefixlen": 96, "tentative": True}, 300 | ], 301 | key_name=("ip", "prefixlen"), 302 | ), 303 | }, 304 | }, 305 | ], 306 | "iface2": ly.KeyedList( 307 | [{"name": "eth2", "mtu": 1500}, {"name": "eth3", "mtu": 1000}], key_name="name" 308 | ), 309 | "lst2": ["a", "b", "c"], 310 | "lstnum": [10, 20, 30, 40], 311 | "val": 42, 312 | } 313 | 314 | XPATH_GET_EXPECTED_RESULTS = [ 315 | ("/val", 42, None, True), 316 | ("val", 42, None, True), 317 | ("lst2", ["a", "b", "c"], None, True), 318 | ( 319 | "iface[name='eth0']/ipv4/address", 320 | [{"ip": "10.0.0.1"}, {"ip": "10.0.0.153"}], 321 | None, 322 | True, 323 | ), 324 | ( 325 | "/iface[name='eth1']/ipv6/address[ip='3ffe::321:8'][prefixlen='64']", 326 | {"ip": "3ffe::321:8", "prefixlen": 64, "tentative": False}, 327 | None, 328 | True, 329 | ), 330 | ("cont1/leaf1", "coucou1", None, True), 331 | ("cont2/leaf2", "coucou2", None, True), 332 | ("cont1/leaf2", "not found", "fallback", False), 333 | ("cont1/leaf2", "not found", None, False), 334 | ] 335 | 336 | XPATH_GETALL_EXPECTED_RESULTS = [ 337 | ("/val", [42]), 338 | ("val", [42]), 339 | ("lst2", ["a", "b", "c"]), 340 | ("/lstnum", [10, 20, 30, 40]), 341 | ("iface/name", ["eth0", "eth1"]), 342 | ("/iface*/name", ["eth0", "eth1", "eth2", "eth3"]), 343 | ('iface[name="eth1"]/ipv4/address/ip', ["10.0.0.2", "10.0.0.6"]), 344 | ( 345 | "iface/ipv*/address/ip", 346 | [ 347 | "10.0.0.1", 348 | "10.0.0.153", 349 | "3ffe::123:1", 350 | "3ffe::c00:c00", 351 | "10.0.0.2", 352 | "10.0.0.6", 353 | "3ffe::321:8", 354 | "3ffe::ff12", 355 | ], 356 | ), 357 | ( 358 | "/iface[name='eth1']/ipv6/address[ip='3ffe::321:8'][prefixlen='64']/tentative", 359 | [False], 360 | ), 361 | ( 362 | "/iface[name='eth1']/ipv6/address[ip='3ffe::8'][prefixlen='64']/tentative", 363 | [], 364 | ), 365 | ( 366 | "/cont1[foo='bar']", 367 | [], 368 | ), 369 | ( 370 | "/iface*", 371 | [ 372 | { 373 | "name": "eth0", 374 | "ipv4": { 375 | "address": [ 376 | {"ip": "10.0.0.1"}, 377 | {"ip": "10.0.0.153"}, 378 | ], 379 | }, 380 | "ipv6": { 381 | "address": [ 382 | {"ip": "3ffe::123:1"}, 383 | {"ip": "3ffe::c00:c00"}, 384 | ], 385 | }, 386 | }, 387 | { 388 | "name": "eth1", 389 | "ipv4": { 390 | "address": [ 391 | {"ip": "10.0.0.2"}, 392 | {"ip": "10.0.0.6"}, 393 | ], 394 | }, 395 | "ipv6": { 396 | "address": [ 397 | {"ip": "3ffe::321:8", "prefixlen": 64, "tentative": False}, 398 | {"ip": "3ffe::ff12", "prefixlen": 96, "tentative": True}, 399 | ], 400 | }, 401 | }, 402 | {"name": "eth2", "mtu": 1500}, 403 | {"name": "eth3", "mtu": 1000}, 404 | ], 405 | ), 406 | ( 407 | "/iface*[name='eth0']/ipv4", 408 | [ 409 | { 410 | "address": [ 411 | {"ip": "10.0.0.1"}, 412 | {"ip": "10.0.0.153"}, 413 | ], 414 | }, 415 | ], 416 | ), 417 | ( 418 | "/iface[name='eth0']/ipv4/address[ip='10.0.0.1']", 419 | [{"ip": "10.0.0.1"}], 420 | ), 421 | ( 422 | "/iface[name='eth0']/ipv4/address[ip='10.0.0.2']", 423 | [], 424 | ), 425 | ("/iface2[name='eth3']", [{"name": "eth3", "mtu": 1000}]), 426 | ("/iface[name='eth0']/ipv4/address", [{"ip": "10.0.0.1"}, {"ip": "10.0.0.153"}]), 427 | ("*/leaf*", ["coucou1", "coucou2"]), 428 | ("cont1/not-found", []), 429 | ( 430 | "iface/ipv4", 431 | [ 432 | {"address": [{"ip": "10.0.0.1"}, {"ip": "10.0.0.153"}]}, 433 | {"address": [{"ip": "10.0.0.2"}, {"ip": "10.0.0.6"}]}, 434 | ], 435 | ), 436 | ('iface*[name="eth3"]', [{"name": "eth3", "mtu": 1000}]), 437 | ( 438 | 'iface*[name="eth0"]', 439 | [ 440 | { 441 | "name": "eth0", 442 | "ipv4": { 443 | "address": [ 444 | {"ip": "10.0.0.1"}, 445 | {"ip": "10.0.0.153"}, 446 | ], 447 | }, 448 | "ipv6": { 449 | "address": [ 450 | {"ip": "3ffe::123:1"}, 451 | {"ip": "3ffe::c00:c00"}, 452 | ], 453 | }, 454 | } 455 | ], 456 | ), 457 | ] 458 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-allow-list=_libyang 7 | 8 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 9 | # number of processors available to use. 10 | jobs=1 11 | 12 | # Control the amount of potential inferred values when inferring a single 13 | # object. This can help the performance when dealing with large functions or 14 | # complex, nested conditions. 15 | limit-inference-results=100 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Minimum Python version to use for version dependent checks. Will default to 22 | # the version used to run pylint. 23 | py-version=3.6 24 | 25 | # Pickle collected data for later comparisons. 26 | persistent=no 27 | 28 | # Specify a configuration file. 29 | #rcfile= 30 | 31 | # When enabled, pylint would attempt to guess common misconfiguration and emit 32 | # user-friendly hints instead of false-positive error messages. 33 | suggestion-mode=yes 34 | 35 | # Allow loading of arbitrary C extensions. Extensions are imported into the 36 | # active Python interpreter and may run arbitrary code. 37 | unsafe-load-any-extension=no 38 | 39 | 40 | [MESSAGES CONTROL] 41 | 42 | # Only show warnings with the listed confidence levels. Leave empty to show 43 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 44 | confidence= 45 | 46 | # Enable the message, report, category or checker with the given id(s). You can 47 | # either give multiple identifier separated by comma (,) or put this option 48 | # multiple time (only on the command line, not in the configuration file where 49 | # it should appear only once). See also the "--disable" option for examples. 50 | enable=all 51 | 52 | # Disable the message, report, category or checker with the given id(s). You 53 | # can either give multiple identifiers separated by comma (,) or put this 54 | # option multiple times (only on the command line, not in the configuration 55 | # file where it should appear only once). You can also use "--disable=all" to 56 | # disable everything first and then re-enable specific checks. For example, if 57 | # you want to run only the similarities checker, you can use "--disable=all 58 | # --enable=similarities". If you want to run only the classes checker, but have 59 | # no Warning level messages displayed, use "--disable=all --enable=classes 60 | # --disable=W". 61 | disable= 62 | broad-except, 63 | consider-using-f-string, 64 | cyclic-import, 65 | duplicate-code, 66 | file-ignored, 67 | fixme, 68 | import-outside-toplevel, 69 | line-too-long, 70 | locally-disabled, 71 | missing-docstring, 72 | raising-format-tuple, 73 | suppressed-message, 74 | too-many-branches, 75 | too-many-lines, 76 | too-many-locals, 77 | too-many-positional-arguments, 78 | too-many-return-statements, 79 | too-many-statements, 80 | unused-argument, 81 | use-implicit-booleaness-not-comparison-to-string, 82 | use-implicit-booleaness-not-comparison-to-zero, 83 | wrong-import-order, 84 | 85 | [REPORTS] 86 | 87 | # Python expression which should return a note less than 10 (10 is the highest 88 | # note). You have access to the variables errors warning, statement which 89 | # respectively contain the number of errors / warnings messages and the total 90 | # number of statements analyzed. This is used by the global evaluation report 91 | # (RP0004). 92 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 93 | 94 | # Template used to display messages. This is a python new-style format string 95 | # used to format the message information. See doc for all details. 96 | msg-template={path}:{line} {msg} [{symbol}] 97 | 98 | # Set the output format. Available formats are text, parseable, colorized, json 99 | # and msvs (visual studio). You can also give a reporter class, e.g. 100 | # mypackage.mymodule.MyReporterClass. 101 | output-format=text 102 | 103 | # Tells whether to display a full report or only the messages. 104 | reports=no 105 | 106 | # Activate the evaluation score. 107 | score=no 108 | 109 | 110 | [REFACTORING] 111 | 112 | # Maximum number of nested blocks for function / method body 113 | max-nested-blocks=5 114 | 115 | # Complete name of functions that never returns. When checking for 116 | # inconsistent-return-statements if a never returning function is called then 117 | # it will be considered as an explicit return statement and no message will be 118 | # printed. 119 | never-returning-functions=sys.exit 120 | 121 | [STRING] 122 | 123 | # This flag controls whether inconsistent-quotes generates a warning when the 124 | # character used as a quote delimiter is used inconsistently within a module. 125 | check-quote-consistency=no 126 | 127 | # This flag controls whether the implicit-str-concat should generate a warning 128 | # on implicit string concatenation in sequences defined over several lines. 129 | check-str-concat-over-line-jumps=no 130 | 131 | [TYPECHECK] 132 | 133 | # List of decorators that produce context managers, such as 134 | # contextlib.contextmanager. Add to this list to register other decorators that 135 | # produce valid context managers. 136 | contextmanager-decorators=contextlib.contextmanager 137 | 138 | # List of members which are set dynamically and missed by pylint inference 139 | # system, and so shouldn't trigger E1101 when accessed. Python regular 140 | # expressions are accepted. 141 | generated-members= 142 | 143 | # List of symbolic message names to ignore for Mixin members. 144 | ignored-checks-for-mixins=no-member, 145 | not-async-context-manager, 146 | not-context-manager, 147 | attribute-defined-outside-init 148 | 149 | # Tells whether to warn about missing members when the owner of the attribute 150 | # is inferred to be None. 151 | ignore-none=yes 152 | 153 | # This flag controls whether pylint should warn about no-member and similar 154 | # checks whenever an opaque object is returned when inferring. The inference 155 | # can return multiple potential results while evaluating a Python object, but 156 | # some branches might not be evaluated, which results in partial inference. In 157 | # that case, it might be useful to still emit no-member and other checks for 158 | # the rest of the inferred objects. 159 | ignore-on-opaque-inference=yes 160 | 161 | # List of class names for which member attributes should not be checked (useful 162 | # for classes with dynamically set attributes). This supports the use of 163 | # qualified names. 164 | ignored-classes=optparse.Values,thread._local,_thread._local 165 | 166 | # Show a hint with possible names when a member name was not found. The aspect 167 | # of finding the hint is based on edit distance. 168 | missing-member-hint=yes 169 | 170 | # The minimum edit distance a name should have in order to be considered a 171 | # similar match for a missing member name. 172 | missing-member-hint-distance=1 173 | 174 | # The total number of similar names that should be taken in consideration when 175 | # showing a hint for a missing member. 176 | missing-member-max-choices=1 177 | 178 | # Regex pattern to define which classes are considered mixins. 179 | mixin-class-rgx=.*[Mm]ixin 180 | 181 | # List of decorators that change the signature of a decorated function. 182 | signature-mutators= 183 | 184 | 185 | [VARIABLES] 186 | 187 | # List of additional names supposed to be defined in builtins. Remember that 188 | # you should avoid defining new builtins when possible. 189 | additional-builtins= 190 | 191 | # Tells whether unused global variables should be treated as a violation. 192 | allow-global-unused-variables=yes 193 | 194 | # List of names allowed to shadow builtins 195 | allowed-redefined-builtins= 196 | 197 | # List of strings which can identify a callback function by name. A callback 198 | # name must start or end with one of those strings. 199 | callbacks= 200 | cb_, 201 | _cb, 202 | callback, 203 | 204 | # A regular expression matching the name of dummy variables (i.e. expected to 205 | # not be used). 206 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 207 | 208 | # Argument names that match this expression will be ignored. Default to name 209 | # with leading underscore. 210 | ignored-argument-names=_.*|^ignored_|^unused_ 211 | 212 | # Tells whether we should check for unused import in __init__ files. 213 | init-import=no 214 | 215 | # List of qualified module names which can have objects that can redefine 216 | # builtins. 217 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 218 | 219 | 220 | [SIMILARITIES] 221 | 222 | # Comments are removed from the similarity computation 223 | ignore-comments=yes 224 | 225 | # Docstrings are removed from the similarity computation 226 | ignore-docstrings=yes 227 | 228 | # Imports are removed from the similarity computation 229 | ignore-imports=no 230 | 231 | # Signatures are removed from the similarity computation 232 | ignore-signatures=yes 233 | 234 | # Minimum lines number of a similarity. 235 | min-similarity-lines=4 236 | 237 | 238 | [LOGGING] 239 | 240 | # The type of string formatting that logging methods do. `old` means using % 241 | # formatting, `new` is for `{}` formatting. 242 | logging-format-style=old 243 | 244 | # Logging modules to check that the string format arguments are in logging 245 | # function parameter format. 246 | logging-modules=logging 247 | 248 | 249 | [FORMAT] 250 | 251 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 252 | expected-line-ending-format=LF 253 | 254 | # Regexp for a line that is allowed to be longer than the limit. 255 | ignore-long-lines=^\s*(# )?<?https?://\S+>?$ 256 | 257 | # Number of spaces of indent required inside a hanging or continued line. 258 | indent-after-paren=4 259 | 260 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 261 | # tab). 262 | indent-string=' ' 263 | 264 | # Maximum number of characters on a single line. 265 | max-line-length=88 266 | 267 | # Maximum number of lines in a module. 268 | max-module-lines=1000 269 | 270 | # Allow the body of a class to be on the same line as the declaration if body 271 | # contains single statement. 272 | single-line-class-stmt=yes 273 | 274 | # Allow the body of an if to be on the same line as the test if there is no 275 | # else. 276 | single-line-if-stmt=no 277 | 278 | 279 | [BASIC] 280 | 281 | # Naming style matching correct argument names. 282 | argument-naming-style=any 283 | 284 | # Regular expression matching correct argument names. Overrides argument- 285 | # naming-style. If left empty, argument names will be checked with the set 286 | # naming style. 287 | #argument-rgx= 288 | 289 | # Naming style matching correct attribute names. 290 | attr-naming-style=any 291 | 292 | # Regular expression matching correct attribute names. Overrides attr-naming- 293 | # style. If left empty, attribute names will be checked with the set naming 294 | # style. 295 | #attr-rgx= 296 | 297 | # Bad variable names which should always be refused, separated by a comma. 298 | bad-names= 299 | 300 | # Bad variable names regexes, separated by a comma. If names match any regex, 301 | # they will always be refused 302 | bad-names-rgxs= 303 | 304 | # Naming style matching correct class attribute names. 305 | class-attribute-naming-style=any 306 | 307 | # Regular expression matching correct class attribute names. Overrides class- 308 | # attribute-naming-style. If left empty, class attribute names will be checked 309 | # with the set naming style. 310 | #class-attribute-rgx= 311 | 312 | # Naming style matching correct class constant names. 313 | class-const-naming-style=UPPER_CASE 314 | 315 | # Regular expression matching correct class constant names. Overrides class- 316 | # const-naming-style. If left empty, class constant names will be checked with 317 | # the set naming style. 318 | #class-const-rgx= 319 | 320 | # Naming style matching correct class names. 321 | class-naming-style=PascalCase 322 | 323 | # Regular expression matching correct class names. Overrides class-naming- 324 | # style. If left empty, class names will be checked with the set naming style. 325 | #class-rgx= 326 | 327 | # Naming style matching correct constant names. 328 | const-naming-style=UPPER_CASE 329 | 330 | # Regular expression matching correct constant names. Overrides const-naming- 331 | # style. If left empty, constant names will be checked with the set naming 332 | # style. 333 | #const-rgx= 334 | 335 | # Minimum line length for functions/classes that require docstrings, shorter 336 | # ones are exempt. 337 | docstring-min-length=-1 338 | 339 | # Naming style matching correct function names. 340 | function-naming-style=snake_case 341 | 342 | # Regular expression matching correct function names. Overrides function- 343 | # naming-style. If left empty, function names will be checked with the set 344 | # naming style. 345 | #function-rgx= 346 | 347 | # Good variable names which should always be accepted, separated by a comma. 348 | good-names= 349 | 350 | # Good variable names regexes, separated by a comma. If names match any regex, 351 | # they will always be accepted 352 | good-names-rgxs= 353 | 354 | # Include a hint for the correct naming format with invalid-name. 355 | include-naming-hint=no 356 | 357 | # Naming style matching correct inline iteration names. 358 | inlinevar-naming-style=any 359 | 360 | # Regular expression matching correct inline iteration names. Overrides 361 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 362 | # with the set naming style. 363 | #inlinevar-rgx= 364 | 365 | # Naming style matching correct method names. 366 | method-naming-style=snake_case 367 | 368 | # Regular expression matching correct method names. Overrides method-naming- 369 | # style. If left empty, method names will be checked with the set naming style. 370 | #method-rgx= 371 | 372 | # Naming style matching correct module names. 373 | module-naming-style=snake_case 374 | 375 | # Regular expression matching correct module names. Overrides module-naming- 376 | # style. If left empty, module names will be checked with the set naming style. 377 | #module-rgx= 378 | 379 | # Colon-delimited sets of names that determine each other's naming style when 380 | # the name regexes allow several styles. 381 | name-group= 382 | 383 | # Regular expression which should only match function or class names that do 384 | # not require a docstring. 385 | no-docstring-rgx=^_ 386 | 387 | # List of decorators that produce properties, such as abc.abstractproperty. Add 388 | # to this list to register other decorators that produce valid properties. 389 | # These decorators are taken in consideration only for invalid-name. 390 | property-classes=abc.abstractproperty 391 | 392 | # Regular expression matching correct type variable names. If left empty, type 393 | # variable names will be checked with the set naming style. 394 | #typevar-rgx= 395 | 396 | # Naming style matching correct variable names. 397 | variable-naming-style=any 398 | 399 | # Regular expression matching correct variable names. Overrides variable- 400 | # naming-style. If left empty, variable names will be checked with the set 401 | # naming style. 402 | #variable-rgx= 403 | 404 | 405 | [MISCELLANEOUS] 406 | 407 | # List of note tags to take in consideration, separated by a comma. 408 | notes=FIXME, 409 | XXX, 410 | TODO 411 | 412 | # Regular expression of note tags to take in consideration. 413 | notes-rgx= 414 | 415 | [SPELLING] 416 | 417 | # Limits count of emitted suggestions for spelling mistakes. 418 | max-spelling-suggestions=4 419 | 420 | # Spelling dictionary name. Available dictionaries: none. To make it work, 421 | # install the 'python-enchant' package. 422 | spelling-dict= 423 | 424 | # List of comma separated words that should be considered directives if they 425 | # appear at the beginning of a comment and should not be checked. 426 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 427 | 428 | # List of comma separated words that should not be checked. 429 | spelling-ignore-words= 430 | 431 | # A path to a file that contains the private dictionary; one word per line. 432 | spelling-private-dict-file= 433 | 434 | # Tells whether to store unknown words to the private dictionary (see the 435 | # --spelling-private-dict-file option) instead of raising a message. 436 | spelling-store-unknown-words=no 437 | 438 | 439 | [IMPORTS] 440 | 441 | # Allow wildcard imports from modules that define __all__. 442 | allow-wildcard-with-all=no 443 | 444 | # List of modules that can be imported at any level, not just the top level 445 | # one. 446 | allow-any-import-level= 447 | 448 | # Deprecated modules which should not be used, separated by a comma. 449 | deprecated-modules=optparse,tkinter.tix 450 | 451 | # Output a graph (.gv or any supported image format) of external dependencies 452 | # to the given file (report RP0402 must not be disabled). 453 | ext-import-graph= 454 | 455 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 456 | # external) dependencies to the given file (report RP0402 must not be 457 | # disabled). 458 | import-graph= 459 | 460 | # Output a graph (.gv or any supported image format) of internal dependencies 461 | # to the given file (report RP0402 must not be disabled). 462 | int-import-graph= 463 | 464 | # Force import order to recognize a module as part of the standard 465 | # compatibility libraries. 466 | known-standard-library= 467 | 468 | # Force import order to recognize a module as part of a third party library. 469 | known-third-party=enchant 470 | 471 | # Couples of modules and preferred modules, separated by a comma. 472 | preferred-modules= 473 | 474 | [CLASSES] 475 | 476 | # Warn about protected attribute access inside special methods 477 | check-protected-access-in-special-methods=no 478 | 479 | # List of method names used to declare (i.e. assign) instance attributes. 480 | defining-attr-methods=__init__, 481 | __new__, 482 | setUp 483 | 484 | # List of member names, which should be excluded from the protected access 485 | # warning. 486 | exclude-protected= 487 | 488 | # List of valid names for the first argument in a class method. 489 | valid-classmethod-first-arg=cls 490 | 491 | # List of valid names for the first argument in a metaclass class method. 492 | valid-metaclass-classmethod-first-arg=mcs 493 | 494 | [DESIGN] 495 | 496 | # Maximum number of arguments for function / method. 497 | max-args=20 498 | 499 | # Maximum number of attributes for a class (see R0902). 500 | max-attributes=20 501 | 502 | # Maximum number of boolean expressions in an if statement. 503 | max-bool-expr=5 504 | 505 | # Maximum number of branch for function / method body. 506 | max-branches=20 507 | 508 | # Maximum number of locals for function / method body. 509 | max-locals=25 510 | 511 | # Maximum number of parents for a class (see R0901). 512 | max-parents=7 513 | 514 | # Maximum number of public methods for a class (see R0904). 515 | max-public-methods=100 516 | 517 | # Maximum number of return / yield for function / method body. 518 | max-returns=6 519 | 520 | # Maximum number of statements in function / method body. 521 | max-statements=80 522 | 523 | # Minimum number of public methods for a class (see R0903). 524 | min-public-methods=0 525 | 526 | 527 | [EXCEPTIONS] 528 | 529 | # Exceptions that will emit a warning when being caught. Defaults to 530 | # "Exception". 531 | overgeneral-exceptions=builtins.Exception 532 | -------------------------------------------------------------------------------- /libyang/xpath.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020 6WIND S.A. 2 | # SPDX-License-Identifier: MIT 3 | 4 | import contextlib 5 | import fnmatch 6 | import re 7 | from typing import Any, Dict, Iterator, List, Optional, Tuple, Union 8 | 9 | from .keyed_list import KeyedList, py_to_yang 10 | 11 | 12 | # ------------------------------------------------------------------------------------- 13 | XPATH_ELEMENT_RE = re.compile(r"^/(?:(?P<prefix>[-\w]+):)?(?P<name>[-\w\*]+)") 14 | 15 | 16 | def xpath_split(xpath: str) -> Iterator[Tuple[str, str, List[Tuple[str, str]]]]: 17 | """ 18 | Return an iterator that yields xpath components: 19 | 20 | (prefix, name, keys) 21 | 22 | Where: 23 | 24 | :var prefix: 25 | The YANG prefix where ``name`` is defined. May be ``None`` if no prefix is 26 | specified. 27 | :var name: 28 | The name of the YANG element (container, list, leaf, leaf-list). 29 | :var keys: 30 | A list of tuples ``("key_name", "key_value")`` parsed from Xpath key 31 | specifiers: ``[key_name="key_value"]...``. 32 | 33 | Example: 34 | 35 | >>> list(xpath_split("/p1:a/b/c[k='v']/p2:d")) 36 | [("p1", "a", []), (None, "b", []), (None, "c", [("k", "v")]), ("p2", "d", [])] 37 | """ 38 | xpath = xpath.strip() 39 | if not xpath: 40 | raise ValueError("empty xpath") 41 | if xpath[0] != "/": 42 | # support relative xpaths 43 | xpath = "/" + xpath 44 | i = 0 45 | 46 | while i < len(xpath): 47 | match = XPATH_ELEMENT_RE.search(xpath[i:]) 48 | if not match: 49 | raise ValueError( 50 | "invalid xpath: %r (expected r'%s' at index %d)" 51 | % (xpath, XPATH_ELEMENT_RE.pattern, i) 52 | ) 53 | prefix, name = match.groups() 54 | i += match.end() 55 | 56 | keys = [] 57 | while i < len(xpath) and xpath[i] == "[": 58 | i += 1 # skip opening '[' 59 | j = xpath.find("=", i) # find key name end 60 | 61 | if j != -1: # keyed specifier 62 | key_name = xpath[i:j] 63 | quote = xpath[j + 1] # record opening quote character 64 | j = i = j + 2 # skip '=' and opening quote 65 | while True: 66 | if xpath[j] == quote and xpath[j - 1] != "\\": 67 | break 68 | j += 1 69 | # replace escaped chars by their non-escape version 70 | key_value = xpath[i:j].replace(f"\\{quote}", f"{quote}") 71 | keys.append((key_name, key_value)) 72 | i = j + 2 # skip closing quote and ']' 73 | else: # index specifier 74 | j = i 75 | while True: 76 | if xpath[j] == "]": 77 | break 78 | j += 1 79 | key_value = xpath[i:j] 80 | keys.append(("", key_value)) 81 | i = j + 2 82 | 83 | yield prefix, name, keys 84 | 85 | 86 | # ------------------------------------------------------------------------------------- 87 | def _xpath_keys_to_key_name( 88 | keys: List[Tuple[str, str]], 89 | ) -> Optional[Union[str, Tuple[str, ...]]]: 90 | """ 91 | Extract key name from parsed xpath keys returned by xpath_split. The return value 92 | of this function can be used as key_name argument when creating a new KeyedList 93 | object. 94 | 95 | Examples: 96 | 97 | >>> _xpath_keys_to_key_name([('.', 'foo')]) 98 | # -> None 99 | >>> _xpath_keys_to_key_name([('name', 'baz')]) 100 | 'name' 101 | >>> _xpath_keys_to_key_name([('first-name', 'Charly'), ('last-name', 'Oleg')]) 102 | ('first-name', 'last-name') 103 | """ 104 | if keys[0][0] == ".": 105 | # leaf-list 106 | return None 107 | if len(keys) == 1: 108 | return keys[0][0] 109 | return tuple(k[0] for k in keys) 110 | 111 | 112 | # ------------------------------------------------------------------------------------- 113 | def _xpath_keys_to_key_val(keys: List[Tuple[str, str]]) -> Union[str, Tuple[str, ...]]: 114 | """ 115 | Extract key value from parsed xpath keys returned by xpath_split. The return value 116 | of this function can be used as argument to lookup elements in a KeyedList object. 117 | 118 | Examples: 119 | 120 | >>> _xpath_keys_to_key_val([('.', 'foo')]) 121 | 'foo' 122 | >>> _xpath_keys_to_key_val([('name', 'baz')]) 123 | 'baz' 124 | >>> _xpath_keys_to_key_val([('first-name', 'Charly'), ('last-name', 'Oleg')]) 125 | ('Charly', 'Oleg') 126 | """ 127 | if len(keys) == 1: 128 | return keys[0][1] 129 | return tuple(k[1] for k in keys) 130 | 131 | 132 | # ------------------------------------------------------------------------------------- 133 | def _list_find_key_index(keys: List[Tuple[str, str]], lst: List) -> int: 134 | """ 135 | Find the index of an element matching the parsed xpath keys returned by xpath_split 136 | into a native python list. 137 | 138 | :raises ValueError: 139 | If the element is not found. 140 | :raises TypeError: 141 | If the list elements are not dictionaries. 142 | """ 143 | if keys[0][0] == ".": 144 | # leaf-list 145 | for i, elem in enumerate(lst): 146 | if py_to_yang(elem) == keys[0][1]: 147 | return i 148 | 149 | elif keys[0][0] == "": 150 | # keys[0][1] is directly the index 151 | index = int(keys[0][1]) - 1 152 | if len(lst) > index: 153 | return index 154 | 155 | else: 156 | for i, elem in enumerate(lst): 157 | if not isinstance(elem, dict): 158 | raise TypeError("expected a dict") 159 | if all(k in elem and py_to_yang(elem[k]) == v for k, v in keys): 160 | return i 161 | 162 | raise ValueError("%s not found in list" % keys) 163 | 164 | 165 | # ------------------------------------------------------------------------------------- 166 | def _xpath_find(data: Dict, xparts: List, create_if_missing: bool = False) -> Any: 167 | """ 168 | Descend into a data dictionary. 169 | 170 | :arg data: 171 | The dictionary where to look for `xparts`. 172 | :arg xparts: 173 | Elements of an Xpath split with xpath_split() 174 | :arg bool create_if_missing: 175 | If elements are missing from `data`, create them. 176 | 177 | :returns: 178 | The element identified by `xparts`. 179 | :raises KeyError: 180 | If create_if_missing=False and the element is not found in `data`. 181 | :raises TypeError: 182 | If `data` does not match the expected structure conveyed by `xparts`. 183 | """ 184 | for _, name, keys in xparts: 185 | if not isinstance(data, dict): 186 | raise TypeError("expected a dict") 187 | if keys: 188 | if name not in data and create_if_missing: 189 | data[name] = KeyedList(key_name=_xpath_keys_to_key_name(keys)) 190 | lst = data[name] # may raise KeyError 191 | if isinstance(lst, KeyedList): 192 | try: 193 | data = lst[_xpath_keys_to_key_val(keys)] 194 | except KeyError: 195 | if not create_if_missing: 196 | raise 197 | data = dict(keys) 198 | lst.append(data) 199 | 200 | elif isinstance(lst, list): 201 | # regular python list, need to iterate over it 202 | try: 203 | i = _list_find_key_index(keys, lst) 204 | data = lst[i] 205 | except ValueError: 206 | # not found 207 | if not create_if_missing: 208 | raise KeyError(keys) from None 209 | data = dict(keys) 210 | lst.append(data) 211 | 212 | else: 213 | raise TypeError("expected a list") 214 | 215 | elif create_if_missing: 216 | data = data.setdefault(name, {}) 217 | 218 | else: 219 | data = data[name] # may raise KeyError 220 | 221 | return data 222 | 223 | 224 | # ------------------------------------------------------------------------------------- 225 | def xpath_get(data: Dict, xpath: str, default: Any = None) -> Any: 226 | """ 227 | Get an element from a data structure (dict) that matches the given xpath. 228 | 229 | Examples: 230 | 231 | >>> config = {'conf': {'net': [{'name': 'mgmt', 'routing': {'a': 1}}]}} 232 | >>> xpath_get(config, '/prefix:conf/net[name="mgmt"]/routing') 233 | {'a': 1} 234 | >>> xpath_get(config, '/prefix:conf/net[name="prod"]/routing') 235 | >>> xpath_get(config, '/prefix:conf/net[name="prod"]/routing', {}) 236 | {} 237 | """ 238 | try: 239 | return _xpath_find(data, xpath_split(xpath), create_if_missing=False) 240 | except KeyError: 241 | return default 242 | 243 | 244 | # ------------------------------------------------------------------------------------- 245 | def xpath_getall(data: Dict, xpath: str) -> Iterator[Any]: 246 | """ 247 | Yield all elements from a data structure (dict) that match the given 248 | xpath. Basic wildcards in the xpath are supported. 249 | 250 | IMPORTANT: the order in which the elements are yielded is not stable and 251 | you should not rely on it. 252 | 253 | Examples: 254 | 255 | >>> config = {'config': {'vrf': [{'name': 'vr0', 'routing': {'a1': [1, 8]}}, 256 | ... {'name': 'vrf1', 'routing': {'a2': 55}}, 257 | ... {'name': 'vrf2', 'snmp': {'a3': 12, 'c': 5}}]}} 258 | >>> list(xpath_getall(config, '/config/vrf/name')) 259 | ['vrf0', 'vrf1'] 260 | >>> list(xpath_getall(config, '/config/vrf/routing')) 261 | [{'a1': [1, 8]}, {'a2': 55}] 262 | >>> list(xpath_getall(config, '/config/vrf/routing/b')) 263 | [] 264 | >>> list(xpath_getall(config, '/config/vrf/*/a*')) 265 | [1, 8, 55, 12] 266 | """ 267 | parts = list(xpath_split(xpath)) 268 | 269 | def _walk_subtrees(subtrees, keys, level): 270 | next_xpath = "/" + "/".join( 271 | n + "".join('[%s="%s"]' % _k for _k in k) for _, n, k in parts[level:] 272 | ) 273 | # pylint: disable=too-many-nested-blocks 274 | for sub in subtrees: 275 | if isinstance(sub, list): 276 | if keys: 277 | try: 278 | if isinstance(sub, KeyedList): 279 | l = sub[_xpath_keys_to_key_val(keys)] 280 | else: 281 | l = sub[_list_find_key_index(keys, sub)] 282 | except (ValueError, KeyError): 283 | continue 284 | if level == len(parts): 285 | yield l 286 | elif isinstance(l, dict): 287 | yield from xpath_getall(l, next_xpath) 288 | else: 289 | if level == len(parts): 290 | yield from sub 291 | else: 292 | for l in sub: 293 | if isinstance(l, dict): 294 | yield from xpath_getall(l, next_xpath) 295 | 296 | elif isinstance(sub, dict) and not keys and level < len(parts): 297 | yield from xpath_getall(sub, next_xpath) 298 | 299 | elif level == len(parts) and not keys: 300 | yield sub 301 | 302 | for i, (_, name, keys) in enumerate(parts): 303 | if not isinstance(data, dict) or name not in data: 304 | if "*" in name and isinstance(data, dict): 305 | # Wildcard xpath element, yield from all matching subtrees 306 | subtrees = (data[n] for n in fnmatch.filter(data.keys(), name)) 307 | yield from _walk_subtrees(subtrees, keys, i + 1) 308 | return 309 | 310 | data = data[name] 311 | 312 | if keys: 313 | if isinstance(data, KeyedList): 314 | # shortcut access is possible 315 | try: 316 | data = data[_xpath_keys_to_key_val(keys)] 317 | except (KeyError, ValueError): 318 | return 319 | elif isinstance(data, list): 320 | # regular python list, need to iterate over it 321 | try: 322 | i = _list_find_key_index(keys, data) 323 | data = data[i] 324 | except ValueError: 325 | return 326 | else: 327 | return 328 | elif isinstance(data, list): 329 | # More than one element matches the xpath. 330 | yield from _walk_subtrees(data, keys, i + 1) 331 | return 332 | 333 | # trivial case, only one match 334 | yield data 335 | 336 | 337 | # ------------------------------------------------------------------------------------- 338 | def xpath_set( 339 | data: Dict, 340 | xpath: str, 341 | value: Any, 342 | *, 343 | force: bool = True, 344 | after: Optional[str] = None, 345 | ) -> Any: 346 | """ 347 | Set the value pointed by the provided xpath into the provided data structure. If 348 | force=False and the value is already set in the data structure, do not overwrite it 349 | and return the current value. 350 | 351 | :arg data: 352 | The dictionary to update. 353 | :arg xpath: 354 | The path where to insert the value. 355 | :arg value: 356 | The value to insert in data. 357 | :arg force: 358 | Overwrite if a value already exists at the given xpath. 359 | :arg after: 360 | The element after which to insert the provided value. The meaning of this 361 | argument is similar to the prev_list argument of sr_get_change_tree_next: 362 | 363 | https://github.com/sysrepo/sysrepo/blob/v1.4.66/src/sysrepo.h#L1399-L1405 364 | 365 | `after=None` 366 | Append at the end of the list if not already present. 367 | `after=""` 368 | Insert at the beginning of the list if not already present. 369 | `after="foo"` 370 | Insert after the YANG leaf-list "foo" element. If "foo" element is not 371 | found, raise a ValueError. 372 | `after="[key1='value1']..."` 373 | Insert after the YANG list element with matching keys. If such element is 374 | not found, raise a ValueError. 375 | 376 | If the value identified by `xpath` already exists in data, the `after` argument 377 | is ignored. If force=True, the existing value is replaced (at its current 378 | position in the list), otherwise the existing value is returned. 379 | 380 | :returns: 381 | The inserted value or the existing value if force=False and a value already 382 | exists a the given xpath. 383 | :raises ValueError: 384 | If `after` is specified and no matching element to insert after is found. 385 | 386 | Examples: 387 | 388 | >>> state = {'state': {'vrf': [{'name': 'vr0', 'routing': {'a': 1}}]}} 389 | >>> xpath_set(state, '/state/vrf[name="vr0"]/routing/b', 55) 390 | 55 391 | >>> state 392 | {'state': {'vrf': [{'name': 'vr0', 'routing': {'a': 1, 'b': 55}}]}} 393 | >>> xpath_set(state, '/state/vrf[name="vr0"]/routing', {'c': 8}) 394 | {'a': 1, 'b': 55} 395 | >>> state 396 | {'state': {'vrf': [{'name': 'vr0', 'routing': {'a': 1, 'b': 55}}]}} 397 | """ 398 | parts = list(xpath_split(xpath)) 399 | parent = _xpath_find(data, parts[:-1], create_if_missing=True) 400 | _, name, keys = parts[-1] 401 | 402 | if not keys: 403 | # name points to a container or leaf, trivial 404 | if force: 405 | parent[name] = value 406 | return value 407 | return parent.setdefault(name, value) 408 | 409 | # list or leaf-list 410 | if name not in parent: 411 | if after is not None: 412 | parent[name] = [] 413 | else: 414 | parent[name] = KeyedList(key_name=_xpath_keys_to_key_name(keys)) 415 | 416 | lst = parent[name] 417 | 418 | if isinstance(lst, KeyedList): 419 | # shortcut access is possible 420 | if after is not None: 421 | raise ValueError("after='...' is not supported for unordered lists") 422 | key_val = _xpath_keys_to_key_val(keys) 423 | if key_val in lst: 424 | if force: 425 | del lst[key_val] 426 | lst.append(value) 427 | else: 428 | lst.append(value) 429 | return lst[key_val] 430 | 431 | # regular python list from now 432 | if not isinstance(lst, list): 433 | raise TypeError("expected a list") 434 | 435 | with contextlib.suppress(ValueError): 436 | i = _list_find_key_index(keys, lst) 437 | # found 438 | if force: 439 | lst[i] = value 440 | return lst[i] 441 | 442 | # value not found; handle insertion based on 'after' 443 | if after is None: 444 | lst.append(value) 445 | return value 446 | 447 | if after == "": 448 | lst.insert(0, value) 449 | return value 450 | 451 | # first try to find the value in the leaf list 452 | try: 453 | _, _, after_keys = next( 454 | xpath_split(f"/*{after}" if after[0] == "[" else f"/*[.={after!r}]") 455 | ) 456 | insert_index = _list_find_key_index(after_keys, lst) + 1 457 | except ValueError: 458 | # handle 'after' as numeric index 459 | if not after.isnumeric(): 460 | raise 461 | 462 | insert_index = int(after) 463 | if insert_index > len(lst): 464 | raise 465 | 466 | if insert_index == len(lst): 467 | lst.append(value) 468 | else: 469 | lst.insert(insert_index, value) 470 | 471 | return value 472 | 473 | 474 | # ------------------------------------------------------------------------------------- 475 | def xpath_setdefault(data: Dict, xpath: str, default: Any) -> Any: 476 | """ 477 | Shortcut for xpath_set(..., force=False). 478 | """ 479 | return xpath_set(data, xpath, default, force=False) 480 | 481 | 482 | # ------------------------------------------------------------------------------------- 483 | def xpath_del(data: Dict, xpath: str) -> bool: 484 | """ 485 | Remove an element identified by an Xpath from a data structure. 486 | 487 | :arg data: 488 | The dictionary to modify. 489 | :arg xpath: 490 | The path identifying the element to remove. 491 | 492 | :returns: 493 | True if the element was removed. False if the element was not found. 494 | """ 495 | parts = list(xpath_split(xpath)) 496 | try: 497 | parent = _xpath_find(data, parts[:-1], create_if_missing=False) 498 | except KeyError: 499 | return False 500 | _, name, keys = parts[-1] 501 | if name not in parent: 502 | return False 503 | 504 | if keys: 505 | lst = parent[name] 506 | if isinstance(lst, KeyedList): 507 | # shortcut access is possible 508 | try: 509 | del lst[_xpath_keys_to_key_val(keys)] 510 | except (ValueError, KeyError): 511 | return False 512 | elif isinstance(lst, list): 513 | # regular python list, need to iterate over it 514 | try: 515 | i = _list_find_key_index(keys, lst) 516 | del lst[i] 517 | except ValueError: 518 | return False 519 | else: 520 | return False 521 | if not lst: 522 | # list is now empty 523 | del parent[name] 524 | else: 525 | del parent[name] 526 | 527 | return True 528 | 529 | 530 | # ------------------------------------------------------------------------------------- 531 | def xpath_move(data: Dict, xpath: str, after: str) -> None: 532 | """ 533 | Move a list element nested into a data dictionary. 534 | 535 | :arg data: 536 | The dictionary to modify. 537 | :arg xpath: 538 | The path identifying the element to move. 539 | :arg after: 540 | The element after which to move the element identified by xpath. Similar 541 | semantics than in xpath_set(). 542 | 543 | :raises ValueError: 544 | If `xpath` does not designate a list element or if `after` is invalid. 545 | :raises KeyError: 546 | If the element identified by `xpath` or if the element identified by `after` are 547 | not found. 548 | :raises TypeError: 549 | If `data` does not match the expected structure conveyed by `xpath`. 550 | """ 551 | parts = list(xpath_split(xpath)) 552 | parent = _xpath_find(data, parts[:-1], create_if_missing=False) 553 | _, name, keys = parts[-1] 554 | if name not in parent: 555 | raise KeyError(name) 556 | if not keys: 557 | raise ValueError("xpath does not designate a list element") 558 | 559 | lst = parent[name] 560 | if isinstance(lst, KeyedList): 561 | raise ValueError("cannot move elements in non-ordered lists") 562 | if not isinstance(lst, list): 563 | raise ValueError("expected a list") 564 | 565 | try: 566 | i = _list_find_key_index(keys, lst) 567 | except ValueError: 568 | raise KeyError(keys) from None 569 | 570 | if after is None: 571 | lst.append(lst.pop(i)) 572 | 573 | elif after == "": 574 | lst.insert(0, lst.pop(i)) 575 | 576 | else: 577 | if after[0] != "[": 578 | after = "[.=%r]" % after 579 | _, _, after_keys = next(xpath_split("/*" + after)) 580 | try: 581 | j = _list_find_key_index(after_keys, lst) 582 | except ValueError: 583 | raise KeyError(after) from None 584 | if i > j: 585 | j += 1 586 | moved = lst.pop(i) 587 | if j == len(lst): 588 | lst.append(moved) 589 | else: 590 | lst.insert(j, moved) 591 | -------------------------------------------------------------------------------- /tests/yang/ietf/ietf-netconf@2011-06-01.yang: -------------------------------------------------------------------------------- 1 | module ietf-netconf { 2 | 3 | // the namespace for NETCONF XML definitions is unchanged 4 | // from RFC 4741, which this document replaces 5 | namespace "urn:ietf:params:xml:ns:netconf:base:1.0"; 6 | 7 | prefix nc; 8 | 9 | import ietf-inet-types { 10 | prefix inet; 11 | } 12 | 13 | organization 14 | "IETF NETCONF (Network Configuration) Working Group"; 15 | 16 | contact 17 | "WG Web: <http://tools.ietf.org/wg/netconf/> 18 | WG List: <netconf@ietf.org> 19 | 20 | WG Chair: Bert Wijnen 21 | <bertietf@bwijnen.net> 22 | 23 | WG Chair: Mehmet Ersue 24 | <mehmet.ersue@nsn.com> 25 | 26 | Editor: Martin Bjorklund 27 | <mbj@tail-f.com> 28 | 29 | Editor: Juergen Schoenwaelder 30 | <j.schoenwaelder@jacobs-university.de> 31 | 32 | Editor: Andy Bierman 33 | <andy.bierman@brocade.com>"; 34 | description 35 | "NETCONF Protocol Data Types and Protocol Operations. 36 | 37 | Copyright (c) 2011 IETF Trust and the persons identified as 38 | the document authors. All rights reserved. 39 | 40 | Redistribution and use in source and binary forms, with or 41 | without modification, is permitted pursuant to, and subject 42 | to the license terms contained in, the Simplified BSD License 43 | set forth in Section 4.c of the IETF Trust's Legal Provisions 44 | Relating to IETF Documents 45 | (http://trustee.ietf.org/license-info). 46 | 47 | This version of this YANG module is part of RFC 6241; see 48 | the RFC itself for full legal notices."; 49 | revision 2011-06-01 { 50 | description 51 | "Initial revision"; 52 | reference 53 | "RFC 6241: Network Configuration Protocol"; 54 | } 55 | 56 | extension get-filter-element-attributes { 57 | description 58 | "If this extension is present within an 'anyxml' 59 | statement named 'filter', which must be conceptually 60 | defined within the RPC input section for the <get> 61 | and <get-config> protocol operations, then the 62 | following unqualified XML attribute is supported 63 | within the <filter> element, within a <get> or 64 | <get-config> protocol operation: 65 | 66 | type : optional attribute with allowed 67 | value strings 'subtree' and 'xpath'. 68 | If missing, the default value is 'subtree'. 69 | 70 | If the 'xpath' feature is supported, then the 71 | following unqualified XML attribute is 72 | also supported: 73 | 74 | select: optional attribute containing a 75 | string representing an XPath expression. 76 | The 'type' attribute must be equal to 'xpath' 77 | if this attribute is present."; 78 | } 79 | 80 | // NETCONF capabilities defined as features 81 | feature writable-running { 82 | description 83 | "NETCONF :writable-running capability; 84 | If the server advertises the :writable-running 85 | capability for a session, then this feature must 86 | also be enabled for that session. Otherwise, 87 | this feature must not be enabled."; 88 | reference "RFC 6241, Section 8.2"; 89 | } 90 | 91 | feature candidate { 92 | description 93 | "NETCONF :candidate capability; 94 | If the server advertises the :candidate 95 | capability for a session, then this feature must 96 | also be enabled for that session. Otherwise, 97 | this feature must not be enabled."; 98 | reference "RFC 6241, Section 8.3"; 99 | } 100 | 101 | feature confirmed-commit { 102 | if-feature candidate; 103 | description 104 | "NETCONF :confirmed-commit:1.1 capability; 105 | If the server advertises the :confirmed-commit:1.1 106 | capability for a session, then this feature must 107 | also be enabled for that session. Otherwise, 108 | this feature must not be enabled."; 109 | 110 | reference "RFC 6241, Section 8.4"; 111 | } 112 | 113 | feature rollback-on-error { 114 | description 115 | "NETCONF :rollback-on-error capability; 116 | If the server advertises the :rollback-on-error 117 | capability for a session, then this feature must 118 | also be enabled for that session. Otherwise, 119 | this feature must not be enabled."; 120 | reference "RFC 6241, Section 8.5"; 121 | } 122 | 123 | feature validate { 124 | description 125 | "NETCONF :validate:1.1 capability; 126 | If the server advertises the :validate:1.1 127 | capability for a session, then this feature must 128 | also be enabled for that session. Otherwise, 129 | this feature must not be enabled."; 130 | reference "RFC 6241, Section 8.6"; 131 | } 132 | 133 | feature startup { 134 | description 135 | "NETCONF :startup capability; 136 | If the server advertises the :startup 137 | capability for a session, then this feature must 138 | also be enabled for that session. Otherwise, 139 | this feature must not be enabled."; 140 | reference "RFC 6241, Section 8.7"; 141 | } 142 | 143 | feature url { 144 | description 145 | "NETCONF :url capability; 146 | If the server advertises the :url 147 | capability for a session, then this feature must 148 | also be enabled for that session. Otherwise, 149 | this feature must not be enabled."; 150 | reference "RFC 6241, Section 8.8"; 151 | } 152 | 153 | feature xpath { 154 | description 155 | "NETCONF :xpath capability; 156 | If the server advertises the :xpath 157 | capability for a session, then this feature must 158 | also be enabled for that session. Otherwise, 159 | this feature must not be enabled."; 160 | reference "RFC 6241, Section 8.9"; 161 | } 162 | 163 | // NETCONF Simple Types 164 | 165 | typedef session-id-type { 166 | type uint32 { 167 | range "1..max"; 168 | } 169 | description 170 | "NETCONF Session Id"; 171 | } 172 | 173 | typedef session-id-or-zero-type { 174 | type uint32; 175 | description 176 | "NETCONF Session Id or Zero to indicate none"; 177 | } 178 | typedef error-tag-type { 179 | type enumeration { 180 | enum in-use { 181 | description 182 | "The request requires a resource that 183 | already is in use."; 184 | } 185 | enum invalid-value { 186 | description 187 | "The request specifies an unacceptable value for one 188 | or more parameters."; 189 | } 190 | enum too-big { 191 | description 192 | "The request or response (that would be generated) is 193 | too large for the implementation to handle."; 194 | } 195 | enum missing-attribute { 196 | description 197 | "An expected attribute is missing."; 198 | } 199 | enum bad-attribute { 200 | description 201 | "An attribute value is not correct; e.g., wrong type, 202 | out of range, pattern mismatch."; 203 | } 204 | enum unknown-attribute { 205 | description 206 | "An unexpected attribute is present."; 207 | } 208 | enum missing-element { 209 | description 210 | "An expected element is missing."; 211 | } 212 | enum bad-element { 213 | description 214 | "An element value is not correct; e.g., wrong type, 215 | out of range, pattern mismatch."; 216 | } 217 | enum unknown-element { 218 | description 219 | "An unexpected element is present."; 220 | } 221 | enum unknown-namespace { 222 | description 223 | "An unexpected namespace is present."; 224 | } 225 | enum access-denied { 226 | description 227 | "Access to the requested protocol operation or 228 | data model is denied because authorization failed."; 229 | } 230 | enum lock-denied { 231 | description 232 | "Access to the requested lock is denied because the 233 | lock is currently held by another entity."; 234 | } 235 | enum resource-denied { 236 | description 237 | "Request could not be completed because of 238 | insufficient resources."; 239 | } 240 | enum rollback-failed { 241 | description 242 | "Request to roll back some configuration change (via 243 | rollback-on-error or <discard-changes> operations) 244 | was not completed for some reason."; 245 | 246 | } 247 | enum data-exists { 248 | description 249 | "Request could not be completed because the relevant 250 | data model content already exists. For example, 251 | a 'create' operation was attempted on data that 252 | already exists."; 253 | } 254 | enum data-missing { 255 | description 256 | "Request could not be completed because the relevant 257 | data model content does not exist. For example, 258 | a 'delete' operation was attempted on 259 | data that does not exist."; 260 | } 261 | enum operation-not-supported { 262 | description 263 | "Request could not be completed because the requested 264 | operation is not supported by this implementation."; 265 | } 266 | enum operation-failed { 267 | description 268 | "Request could not be completed because the requested 269 | operation failed for some reason not covered by 270 | any other error condition."; 271 | } 272 | enum partial-operation { 273 | description 274 | "This error-tag is obsolete, and SHOULD NOT be sent 275 | by servers conforming to this document."; 276 | } 277 | enum malformed-message { 278 | description 279 | "A message could not be handled because it failed to 280 | be parsed correctly. For example, the message is not 281 | well-formed XML or it uses an invalid character set."; 282 | } 283 | } 284 | description "NETCONF Error Tag"; 285 | reference "RFC 6241, Appendix A"; 286 | } 287 | 288 | typedef error-severity-type { 289 | type enumeration { 290 | enum error { 291 | description "Error severity"; 292 | } 293 | enum warning { 294 | description "Warning severity"; 295 | } 296 | } 297 | description "NETCONF Error Severity"; 298 | reference "RFC 6241, Section 4.3"; 299 | } 300 | 301 | typedef edit-operation-type { 302 | type enumeration { 303 | enum merge { 304 | description 305 | "The configuration data identified by the 306 | element containing this attribute is merged 307 | with the configuration at the corresponding 308 | level in the configuration datastore identified 309 | by the target parameter."; 310 | } 311 | enum replace { 312 | description 313 | "The configuration data identified by the element 314 | containing this attribute replaces any related 315 | configuration in the configuration datastore 316 | identified by the target parameter. If no such 317 | configuration data exists in the configuration 318 | datastore, it is created. Unlike a 319 | <copy-config> operation, which replaces the 320 | entire target configuration, only the configuration 321 | actually present in the config parameter is affected."; 322 | } 323 | enum create { 324 | description 325 | "The configuration data identified by the element 326 | containing this attribute is added to the 327 | configuration if and only if the configuration 328 | data does not already exist in the configuration 329 | datastore. If the configuration data exists, an 330 | <rpc-error> element is returned with an 331 | <error-tag> value of 'data-exists'."; 332 | } 333 | enum delete { 334 | description 335 | "The configuration data identified by the element 336 | containing this attribute is deleted from the 337 | configuration if and only if the configuration 338 | data currently exists in the configuration 339 | datastore. If the configuration data does not 340 | exist, an <rpc-error> element is returned with 341 | an <error-tag> value of 'data-missing'."; 342 | } 343 | enum remove { 344 | description 345 | "The configuration data identified by the element 346 | containing this attribute is deleted from the 347 | configuration if the configuration 348 | data currently exists in the configuration 349 | datastore. If the configuration data does not 350 | exist, the 'remove' operation is silently ignored 351 | by the server."; 352 | } 353 | } 354 | default "merge"; 355 | description "NETCONF 'operation' attribute values"; 356 | reference "RFC 6241, Section 7.2"; 357 | } 358 | 359 | // NETCONF Standard Protocol Operations 360 | 361 | rpc get-config { 362 | description 363 | "Retrieve all or part of a specified configuration."; 364 | 365 | reference "RFC 6241, Section 7.1"; 366 | 367 | input { 368 | container source { 369 | description 370 | "Particular configuration to retrieve."; 371 | 372 | choice config-source { 373 | mandatory true; 374 | description 375 | "The configuration to retrieve."; 376 | leaf candidate { 377 | if-feature candidate; 378 | type empty; 379 | description 380 | "The candidate configuration is the config source."; 381 | } 382 | leaf running { 383 | type empty; 384 | description 385 | "The running configuration is the config source."; 386 | } 387 | leaf startup { 388 | if-feature startup; 389 | type empty; 390 | description 391 | "The startup configuration is the config source. 392 | This is optional-to-implement on the server because 393 | not all servers will support filtering for this 394 | datastore."; 395 | } 396 | } 397 | } 398 | 399 | anyxml filter { 400 | description 401 | "Subtree or XPath filter to use."; 402 | nc:get-filter-element-attributes; 403 | } 404 | } 405 | 406 | output { 407 | anyxml data { 408 | description 409 | "Copy of the source datastore subset that matched 410 | the filter criteria (if any). An empty data container 411 | indicates that the request did not produce any results."; 412 | } 413 | } 414 | } 415 | 416 | rpc edit-config { 417 | description 418 | "The <edit-config> operation loads all or part of a specified 419 | configuration to the specified target configuration."; 420 | 421 | reference "RFC 6241, Section 7.2"; 422 | 423 | input { 424 | container target { 425 | description 426 | "Particular configuration to edit."; 427 | 428 | choice config-target { 429 | mandatory true; 430 | description 431 | "The configuration target."; 432 | 433 | leaf candidate { 434 | if-feature candidate; 435 | type empty; 436 | description 437 | "The candidate configuration is the config target."; 438 | } 439 | leaf running { 440 | if-feature writable-running; 441 | type empty; 442 | description 443 | "The running configuration is the config source."; 444 | } 445 | } 446 | } 447 | 448 | leaf default-operation { 449 | type enumeration { 450 | enum merge { 451 | description 452 | "The default operation is merge."; 453 | } 454 | enum replace { 455 | description 456 | "The default operation is replace."; 457 | } 458 | enum none { 459 | description 460 | "There is no default operation."; 461 | } 462 | } 463 | default "merge"; 464 | description 465 | "The default operation to use."; 466 | } 467 | 468 | leaf test-option { 469 | if-feature validate; 470 | type enumeration { 471 | enum test-then-set { 472 | description 473 | "The server will test and then set if no errors."; 474 | } 475 | enum set { 476 | description 477 | "The server will set without a test first."; 478 | } 479 | 480 | enum test-only { 481 | description 482 | "The server will only test and not set, even 483 | if there are no errors."; 484 | } 485 | } 486 | default "test-then-set"; 487 | description 488 | "The test option to use."; 489 | } 490 | 491 | leaf error-option { 492 | type enumeration { 493 | enum stop-on-error { 494 | description 495 | "The server will stop on errors."; 496 | } 497 | enum continue-on-error { 498 | description 499 | "The server may continue on errors."; 500 | } 501 | enum rollback-on-error { 502 | description 503 | "The server will roll back on errors. 504 | This value can only be used if the 'rollback-on-error' 505 | feature is supported."; 506 | } 507 | } 508 | default "stop-on-error"; 509 | description 510 | "The error option to use."; 511 | } 512 | 513 | choice edit-content { 514 | mandatory true; 515 | description 516 | "The content for the edit operation."; 517 | 518 | anyxml config { 519 | description 520 | "Inline Config content."; 521 | } 522 | leaf url { 523 | if-feature url; 524 | type inet:uri; 525 | description 526 | "URL-based config content."; 527 | } 528 | } 529 | } 530 | } 531 | 532 | rpc copy-config { 533 | description 534 | "Create or replace an entire configuration datastore with the 535 | contents of another complete configuration datastore."; 536 | 537 | reference "RFC 6241, Section 7.3"; 538 | 539 | input { 540 | container target { 541 | description 542 | "Particular configuration to copy to."; 543 | 544 | choice config-target { 545 | mandatory true; 546 | description 547 | "The configuration target of the copy operation."; 548 | 549 | leaf candidate { 550 | if-feature candidate; 551 | type empty; 552 | description 553 | "The candidate configuration is the config target."; 554 | } 555 | leaf running { 556 | if-feature writable-running; 557 | type empty; 558 | description 559 | "The running configuration is the config target. 560 | This is optional-to-implement on the server."; 561 | } 562 | leaf startup { 563 | if-feature startup; 564 | type empty; 565 | description 566 | "The startup configuration is the config target."; 567 | } 568 | leaf url { 569 | if-feature url; 570 | type inet:uri; 571 | description 572 | "The URL-based configuration is the config target."; 573 | } 574 | } 575 | } 576 | 577 | container source { 578 | description 579 | "Particular configuration to copy from."; 580 | 581 | choice config-source { 582 | mandatory true; 583 | description 584 | "The configuration source for the copy operation."; 585 | 586 | leaf candidate { 587 | if-feature candidate; 588 | type empty; 589 | description 590 | "The candidate configuration is the config source."; 591 | } 592 | leaf running { 593 | type empty; 594 | description 595 | "The running configuration is the config source."; 596 | } 597 | leaf startup { 598 | if-feature startup; 599 | type empty; 600 | description 601 | "The startup configuration is the config source."; 602 | } 603 | leaf url { 604 | if-feature url; 605 | type inet:uri; 606 | description 607 | "The URL-based configuration is the config source."; 608 | } 609 | anyxml config { 610 | description 611 | "Inline Config content: <config> element. Represents 612 | an entire configuration datastore, not 613 | a subset of the running datastore."; 614 | } 615 | } 616 | } 617 | } 618 | } 619 | 620 | rpc delete-config { 621 | description 622 | "Delete a configuration datastore."; 623 | 624 | reference "RFC 6241, Section 7.4"; 625 | 626 | input { 627 | container target { 628 | description 629 | "Particular configuration to delete."; 630 | 631 | choice config-target { 632 | mandatory true; 633 | description 634 | "The configuration target to delete."; 635 | 636 | leaf startup { 637 | if-feature startup; 638 | type empty; 639 | description 640 | "The startup configuration is the config target."; 641 | } 642 | leaf url { 643 | if-feature url; 644 | type inet:uri; 645 | description 646 | "The URL-based configuration is the config target."; 647 | } 648 | } 649 | } 650 | } 651 | } 652 | 653 | rpc lock { 654 | description 655 | "The lock operation allows the client to lock the configuration 656 | system of a device."; 657 | 658 | reference "RFC 6241, Section 7.5"; 659 | 660 | input { 661 | container target { 662 | description 663 | "Particular configuration to lock."; 664 | 665 | choice config-target { 666 | mandatory true; 667 | description 668 | "The configuration target to lock."; 669 | 670 | leaf candidate { 671 | if-feature candidate; 672 | type empty; 673 | description 674 | "The candidate configuration is the config target."; 675 | } 676 | leaf running { 677 | type empty; 678 | description 679 | "The running configuration is the config target."; 680 | } 681 | leaf startup { 682 | if-feature startup; 683 | type empty; 684 | description 685 | "The startup configuration is the config target."; 686 | } 687 | } 688 | } 689 | } 690 | } 691 | 692 | rpc unlock { 693 | description 694 | "The unlock operation is used to release a configuration lock, 695 | previously obtained with the 'lock' operation."; 696 | 697 | reference "RFC 6241, Section 7.6"; 698 | 699 | input { 700 | container target { 701 | description 702 | "Particular configuration to unlock."; 703 | 704 | choice config-target { 705 | mandatory true; 706 | description 707 | "The configuration target to unlock."; 708 | 709 | leaf candidate { 710 | if-feature candidate; 711 | type empty; 712 | description 713 | "The candidate configuration is the config target."; 714 | } 715 | leaf running { 716 | type empty; 717 | description 718 | "The running configuration is the config target."; 719 | } 720 | leaf startup { 721 | if-feature startup; 722 | type empty; 723 | description 724 | "The startup configuration is the config target."; 725 | } 726 | } 727 | } 728 | } 729 | } 730 | 731 | rpc get { 732 | description 733 | "Retrieve running configuration and device state information."; 734 | 735 | reference "RFC 6241, Section 7.7"; 736 | 737 | input { 738 | anyxml filter { 739 | description 740 | "This parameter specifies the portion of the system 741 | configuration and state data to retrieve."; 742 | nc:get-filter-element-attributes; 743 | } 744 | } 745 | 746 | output { 747 | anyxml data { 748 | description 749 | "Copy of the running datastore subset and/or state 750 | data that matched the filter criteria (if any). 751 | An empty data container indicates that the request did not 752 | produce any results."; 753 | } 754 | } 755 | } 756 | 757 | rpc close-session { 758 | description 759 | "Request graceful termination of a NETCONF session."; 760 | 761 | reference "RFC 6241, Section 7.8"; 762 | } 763 | 764 | rpc kill-session { 765 | description 766 | "Force the termination of a NETCONF session."; 767 | 768 | reference "RFC 6241, Section 7.9"; 769 | 770 | input { 771 | leaf session-id { 772 | type session-id-type; 773 | mandatory true; 774 | description 775 | "Particular session to kill."; 776 | } 777 | } 778 | } 779 | 780 | rpc commit { 781 | if-feature candidate; 782 | 783 | description 784 | "Commit the candidate configuration as the device's new 785 | current configuration."; 786 | 787 | reference "RFC 6241, Section 8.3.4.1"; 788 | 789 | input { 790 | leaf confirmed { 791 | if-feature confirmed-commit; 792 | type empty; 793 | description 794 | "Requests a confirmed commit."; 795 | reference "RFC 6241, Section 8.3.4.1"; 796 | } 797 | 798 | leaf confirm-timeout { 799 | if-feature confirmed-commit; 800 | type uint32 { 801 | range "1..max"; 802 | } 803 | units "seconds"; 804 | default "600"; // 10 minutes 805 | description 806 | "The timeout interval for a confirmed commit."; 807 | reference "RFC 6241, Section 8.3.4.1"; 808 | } 809 | 810 | leaf persist { 811 | if-feature confirmed-commit; 812 | type string; 813 | description 814 | "This parameter is used to make a confirmed commit 815 | persistent. A persistent confirmed commit is not aborted 816 | if the NETCONF session terminates. The only way to abort 817 | a persistent confirmed commit is to let the timer expire, 818 | or to use the <cancel-commit> operation. 819 | 820 | The value of this parameter is a token that must be given 821 | in the 'persist-id' parameter of <commit> or 822 | <cancel-commit> operations in order to confirm or cancel 823 | the persistent confirmed commit. 824 | 825 | The token should be a random string."; 826 | reference "RFC 6241, Section 8.3.4.1"; 827 | } 828 | 829 | leaf persist-id { 830 | if-feature confirmed-commit; 831 | type string; 832 | description 833 | "This parameter is given in order to commit a persistent 834 | confirmed commit. The value must be equal to the value 835 | given in the 'persist' parameter to the <commit> operation. 836 | If it does not match, the operation fails with an 837 | 'invalid-value' error."; 838 | reference "RFC 6241, Section 8.3.4.1"; 839 | } 840 | 841 | } 842 | } 843 | 844 | rpc discard-changes { 845 | if-feature candidate; 846 | 847 | description 848 | "Revert the candidate configuration to the current 849 | running configuration."; 850 | reference "RFC 6241, Section 8.3.4.2"; 851 | } 852 | 853 | rpc cancel-commit { 854 | if-feature confirmed-commit; 855 | description 856 | "This operation is used to cancel an ongoing confirmed commit. 857 | If the confirmed commit is persistent, the parameter 858 | 'persist-id' must be given, and it must match the value of the 859 | 'persist' parameter."; 860 | reference "RFC 6241, Section 8.4.4.1"; 861 | 862 | input { 863 | leaf persist-id { 864 | type string; 865 | description 866 | "This parameter is given in order to cancel a persistent 867 | confirmed commit. The value must be equal to the value 868 | given in the 'persist' parameter to the <commit> operation. 869 | If it does not match, the operation fails with an 870 | 'invalid-value' error."; 871 | } 872 | } 873 | } 874 | 875 | rpc validate { 876 | if-feature validate; 877 | 878 | description 879 | "Validates the contents of the specified configuration."; 880 | 881 | reference "RFC 6241, Section 8.6.4.1"; 882 | 883 | input { 884 | container source { 885 | description 886 | "Particular configuration to validate."; 887 | 888 | choice config-source { 889 | mandatory true; 890 | description 891 | "The configuration source to validate."; 892 | 893 | leaf candidate { 894 | if-feature candidate; 895 | type empty; 896 | description 897 | "The candidate configuration is the config source."; 898 | } 899 | leaf running { 900 | type empty; 901 | description 902 | "The running configuration is the config source."; 903 | } 904 | leaf startup { 905 | if-feature startup; 906 | type empty; 907 | description 908 | "The startup configuration is the config source."; 909 | } 910 | leaf url { 911 | if-feature url; 912 | type inet:uri; 913 | description 914 | "The URL-based configuration is the config source."; 915 | } 916 | anyxml config { 917 | description 918 | "Inline Config content: <config> element. Represents 919 | an entire configuration datastore, not 920 | a subset of the running datastore."; 921 | } 922 | } 923 | } 924 | } 925 | } 926 | 927 | } 928 | --------------------------------------------------------------------------------