├── scrapli_cfg ├── py.typed ├── platform │ ├── __init__.py │ ├── base │ │ └── __init__.py │ └── core │ │ ├── __init__.py │ │ ├── arista_eos │ │ ├── __init__.py │ │ └── patterns.py │ │ ├── cisco_nxos │ │ ├── __init__.py │ │ └── patterns.py │ │ ├── cisco_iosxe │ │ ├── __init__.py │ │ └── patterns.py │ │ ├── cisco_iosxr │ │ ├── __init__.py │ │ ├── patterns.py │ │ └── base_platform.py │ │ └── juniper_junos │ │ ├── __init__.py │ │ ├── patterns.py │ │ └── base_platform.py ├── __init__.py ├── helper.py ├── logging.py ├── exceptions.py └── response.py ├── docs ├── reference │ ├── diff.md │ ├── factory.md │ ├── helper.md │ ├── logging.md │ ├── response.md │ ├── exceptions.md │ ├── platform │ │ ├── index.md │ │ ├── base │ │ │ ├── index.md │ │ │ ├── async_platform.md │ │ │ ├── base_platform.md │ │ │ └── sync_platform.md │ │ └── core │ │ │ ├── index.md │ │ │ ├── arista_eos │ │ │ ├── index.md │ │ │ ├── patterns.md │ │ │ ├── base_platform.md │ │ │ ├── sync_platform.md │ │ │ └── async_platform.md │ │ │ ├── cisco_iosxe │ │ │ ├── index.md │ │ │ ├── patterns.md │ │ │ ├── async_platform.md │ │ │ ├── base_platform.md │ │ │ └── sync_platform.md │ │ │ ├── cisco_iosxr │ │ │ ├── index.md │ │ │ ├── patterns.md │ │ │ ├── async_platform.md │ │ │ ├── base_platform.md │ │ │ └── sync_platform.md │ │ │ ├── cisco_nxos │ │ │ ├── index.md │ │ │ ├── patterns.md │ │ │ ├── base_platform.md │ │ │ ├── sync_platform.md │ │ │ └── async_platform.md │ │ │ └── juniper_junos │ │ │ ├── index.md │ │ │ ├── patterns.md │ │ │ ├── async_platform.md │ │ │ ├── base_platform.md │ │ │ └── sync_platform.md │ └── SUMMARY.md ├── about │ ├── code_of_conduct.md │ └── contributing.md ├── more_scrapli │ ├── scrapli.md │ ├── nornir_scrapli.md │ ├── scrapli_community.md │ ├── scrapli_replay.md │ └── scrapli_netconf.md ├── index.md ├── htmltest.yml ├── changelog.md ├── generate.py ├── user_guide │ ├── quickstart.md │ ├── installation.md │ ├── versioning.md │ └── project_details.md └── api_docs │ ├── platform │ └── core │ │ ├── juniper_junos │ │ └── patterns.md │ │ ├── cisco_iosxe │ │ ├── patterns.md │ │ └── types.md │ │ ├── cisco_nxos │ │ └── patterns.md │ │ ├── arista_eos │ │ └── patterns.md │ │ └── cisco_iosxr │ │ └── patterns.md │ └── logging.md ├── MANIFEST.in ├── requirements.txt ├── requirements-asyncssh.txt ├── requirements-paramiko.txt ├── requirements-ssh2.txt ├── setup.cfg ├── requirements-docs.txt ├── requirements-dev.txt ├── .github ├── dependabot.yml ├── CONTRIBUTING.md ├── workflows │ ├── publish.yaml │ ├── pre_release.yaml │ ├── commit.yaml │ └── weekly.yaml └── ISSUE_TEMPLATE │ └── bug_report.md ├── Makefile ├── tests ├── unit │ ├── test_response.py │ ├── test_logging.py │ ├── test_factory.py │ ├── platform │ │ ├── core │ │ │ ├── juniper_junos │ │ │ │ └── test_juniper_junos_base_platform.py │ │ │ ├── cisco_iosxr │ │ │ │ └── test_cisco_iosxr_base_platform.py │ │ │ └── arista_eos │ │ │ │ └── test_arista_eos_base_platform.py │ │ └── base │ │ │ ├── test_sync_platform.py │ │ │ └── test_async_platform.py │ ├── conftest.py │ └── test_diff.py ├── conftest.py ├── integration │ ├── platform │ │ ├── test_core_platforms.py │ │ ├── test_core_platforms_async.py │ │ └── scrapli_replay_sessions │ │ │ ├── test_get_config[juniper_junos-ssh2].yaml │ │ │ ├── test_get_config[juniper_junos-asyncssh].yaml │ │ │ ├── test_get_config[juniper_junos-paramiko].yaml │ │ │ ├── test_get_config[juniper_junos-system].yaml │ │ │ ├── test_get_config[juniper_junos-telnet].yaml │ │ │ └── test_get_config[juniper_junos-asynctelnet].yaml │ └── conftest.py ├── devices.py ├── test_data │ └── expected │ │ ├── juniper_junos │ │ ├── cisco_iosxe │ │ └── cisco_nxos └── helper.py ├── LICENSE ├── .gitignore ├── examples ├── basic_usage │ └── config_replace.py └── selective_config_replace │ ├── eos_selective_config_replace.py │ └── config ├── README.md ├── pyproject.toml ├── mkdocs.yml └── noxfile.py /scrapli_cfg/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/reference/diff.md: -------------------------------------------------------------------------------- 1 | ::: diff -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements*.txt -------------------------------------------------------------------------------- /docs/reference/factory.md: -------------------------------------------------------------------------------- 1 | ::: factory -------------------------------------------------------------------------------- /docs/reference/helper.md: -------------------------------------------------------------------------------- 1 | ::: helper -------------------------------------------------------------------------------- /docs/reference/logging.md: -------------------------------------------------------------------------------- 1 | ::: logging -------------------------------------------------------------------------------- /docs/reference/response.md: -------------------------------------------------------------------------------- 1 | ::: response -------------------------------------------------------------------------------- /docs/reference/exceptions.md: -------------------------------------------------------------------------------- 1 | ::: exceptions -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | scrapli>=2022.01.30a2 2 | -------------------------------------------------------------------------------- /docs/reference/platform/index.md: -------------------------------------------------------------------------------- 1 | ::: platform -------------------------------------------------------------------------------- /requirements-asyncssh.txt: -------------------------------------------------------------------------------- 1 | asyncssh>=2.2.1,<3.0.0 -------------------------------------------------------------------------------- /requirements-paramiko.txt: -------------------------------------------------------------------------------- 1 | paramiko>=2.6.0,<4.0.0 -------------------------------------------------------------------------------- /docs/reference/platform/base/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.base -------------------------------------------------------------------------------- /docs/reference/platform/core/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core -------------------------------------------------------------------------------- /scrapli_cfg/platform/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform""" 2 | -------------------------------------------------------------------------------- /requirements-ssh2.txt: -------------------------------------------------------------------------------- 1 | ssh2-python>=0.23.0,<2.0.0 ; python_version < "3.12" -------------------------------------------------------------------------------- /scrapli_cfg/platform/base/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.base""" 2 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core""" 2 | -------------------------------------------------------------------------------- /docs/reference/platform/base/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.base.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/base/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.base.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/base/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.base.sync_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/arista_eos/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.arista_eos -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxe/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxe -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxr/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxr -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_nxos/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_nxos -------------------------------------------------------------------------------- /docs/reference/platform/core/juniper_junos/index.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.juniper_junos -------------------------------------------------------------------------------- /docs/about/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Be excellent to each other! 4 | -------------------------------------------------------------------------------- /docs/reference/platform/core/arista_eos/patterns.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.arista_eos.patterns -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_nxos/patterns.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_nxos.patterns -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxe/patterns.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxe.patterns -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxr/patterns.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxr.patterns -------------------------------------------------------------------------------- /docs/reference/platform/core/arista_eos/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.arista_eos.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/arista_eos/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.arista_eos.sync_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_nxos/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_nxos.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_nxos/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_nxos.sync_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/juniper_junos/patterns.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.juniper_junos.patterns -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [darglint] 2 | docstring_style = google 3 | strictness = full 4 | ignore = DAR202 5 | -------------------------------------------------------------------------------- /docs/reference/platform/core/arista_eos/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.arista_eos.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxe/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxe.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxe/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxe.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxe/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxe.sync_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxr/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxr.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxr/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxr.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_iosxr/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_iosxr.sync_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/cisco_nxos/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.cisco_nxos.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/juniper_junos/async_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.juniper_junos.async_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/juniper_junos/base_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.juniper_junos.base_platform -------------------------------------------------------------------------------- /docs/reference/platform/core/juniper_junos/sync_platform.md: -------------------------------------------------------------------------------- 1 | ::: platform.core.juniper_junos.sync_platform -------------------------------------------------------------------------------- /scrapli_cfg/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg""" 2 | 3 | from scrapli_cfg.factory import AsyncScrapliCfg, ScrapliCfg 4 | 5 | __version__ = "2025.01.30" 6 | 7 | __all__ = ( 8 | "AsyncScrapliCfg", 9 | "ScrapliCfg", 10 | ) 11 | -------------------------------------------------------------------------------- /docs/more_scrapli/scrapli.md: -------------------------------------------------------------------------------- 1 | # Scrapli 2 | 3 | 4 | [scrapli](https://github.com/carlmontanari/scrapli) ([docs](https://github.com/carlmontanari/scrapli)) is the 5 | "parent" scrapli library. Check it out if you need to connect to devices with telnet or ssh! 6 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mdx-gh-links>=0.2,<1.0 2 | mkdocs>=1.2.3,<2.0.0 3 | mkdocs-gen-files>=0.4.0,<1.0.0 4 | mkdocs-literate-nav>=0.5.0,<1.0.0 5 | mkdocs-material>=8.1.6,<10.0.0 6 | mkdocs-material-extensions>=1.0.3,<2.0.0 7 | mkdocs-section-index>=0.3.4,<1.0.0 8 | mkdocstrings[python]>=0.19.0,<1.0.0 -------------------------------------------------------------------------------- /docs/more_scrapli/nornir_scrapli.md: -------------------------------------------------------------------------------- 1 | # Nornir scrapli 2 | 3 | 4 | If you want to use scrapli, but don't want to deal with handling concurrency yourself, there is great news! The 5 | [nornir_scrapli](https://github.com/scrapli/nornir_scrapli) plugin allows you to use scrapli (and scrapli netconf 6 | and scrapli cfg) within the Nornir framework! 7 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/arista_eos/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.arista_eos""" 2 | 3 | from scrapli_cfg.platform.core.arista_eos.async_platform import AsyncScrapliCfgEOS 4 | from scrapli_cfg.platform.core.arista_eos.sync_platform import ScrapliCfgEOS 5 | 6 | __all__ = ( 7 | "AsyncScrapliCfgEOS", 8 | "ScrapliCfgEOS", 9 | ) 10 | -------------------------------------------------------------------------------- /docs/more_scrapli/scrapli_community.md: -------------------------------------------------------------------------------- 1 | # Scrapli Community 2 | 3 | 4 | If you would like to use scrapli, but the platform(s) that you work with are not supported in the "core" scrapli 5 | platforms, you should check out [scrapli_community](https://github.com/scrapli/scrapli_community)! This is the place 6 | for users to share "non-core" scrapli platforms. 7 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_nxos/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_nxos""" 2 | 3 | from scrapli_cfg.platform.core.cisco_nxos.async_platform import AsyncScrapliCfgNXOS 4 | from scrapli_cfg.platform.core.cisco_nxos.sync_platform import ScrapliCfgNXOS 5 | 6 | __all__ = ( 7 | "AsyncScrapliCfgNXOS", 8 | "ScrapliCfgNXOS", 9 | ) 10 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black>=23.3.0,<25.0.0 2 | darglint>=1.8.1,<2.0.0 3 | isort>=5.10.1,<6.0.0 4 | mypy>=1.4.1,<2.0.0 5 | nox==2024.4.15 6 | pydocstyle>=6.1.1,<7.0.0 7 | pyfakefs>=5.4.1,<6.0.0 8 | pylint>=3.0.0,<4.0.0 9 | pytest-asyncio>=0.17.0,<1.0.0 10 | pytest-cov>=3.0.0,<5.0.0 11 | pytest>=7.0.0,<8.0.0 12 | scrapli-replay==2023.7.30 13 | toml>=0.10.2,<1.0.0 -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_iosxe/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_iosxe""" 2 | 3 | from scrapli_cfg.platform.core.cisco_iosxe.async_platform import AsyncScrapliCfgIOSXE 4 | from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE 5 | 6 | __all__ = ( 7 | "AsyncScrapliCfgIOSXE", 8 | "ScrapliCfgIOSXE", 9 | ) 10 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_iosxr/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_iosxr""" 2 | 3 | from scrapli_cfg.platform.core.cisco_iosxr.async_platform import AsyncScrapliCfgIOSXR 4 | from scrapli_cfg.platform.core.cisco_iosxr.sync_platform import ScrapliCfgIOSXR 5 | 6 | __all__ = ( 7 | "AsyncScrapliCfgIOSXR", 8 | "ScrapliCfgIOSXR", 9 | ) 10 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/juniper_junos/__init__.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.juniper_junos""" 2 | 3 | from scrapli_cfg.platform.core.juniper_junos.async_platform import AsyncScrapliCfgJunos 4 | from scrapli_cfg.platform.core.juniper_junos.sync_platform import ScrapliCfgJunos 5 | 6 | __all__ = ( 7 | "AsyncScrapliCfgJunos", 8 | "ScrapliCfgJunos", 9 | ) 10 | -------------------------------------------------------------------------------- /scrapli_cfg/helper.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.helper""" 2 | 3 | 4 | def strip_blank_lines(config: str) -> str: 5 | """ 6 | Strip blank lines out of a config 7 | 8 | Args: 9 | config: config to normalize 10 | 11 | Returns: 12 | str: normalized config 13 | 14 | Raises: 15 | N/A 16 | 17 | """ 18 | return "\n".join(line for line in config.splitlines() if line) 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | timezone: "PST8PDT" 8 | time: "03:00" 9 | target-branch: "main" 10 | - package-ecosystem: "pip" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" 14 | timezone: "PST8PDT" 15 | time: "03:00" 16 | target-branch: "main" 17 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/juniper_junos/patterns.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.juniper_junos.patterns""" 2 | 3 | import re 4 | 5 | VERSION_PATTERN = re.compile( 6 | # should match at least versions looking like: 7 | # 17.3R2.10 8 | # 18.1R3-S2.5 9 | pattern=r"\d+\.[\w-]+\.\w+", 10 | ) 11 | OUTPUT_HEADER_PATTERN = re.compile(pattern=r"^## last commit.*$\nversion.*$", flags=re.M | re.I) 12 | EDIT_PATTERN = re.compile(pattern=r"^\[edit\]$", flags=re.M) 13 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # scrapli_cfg 2 | 3 | scrapli_cfg makes merging or replacing device configurations over Telnet or SSH easy. Why over Telnet or SSH? 4 | Because you pretty much will always have one of these options available to you, whereas you may not have eAPI or 5 | NETCONF ready and enabled (think day zero provisioning, or crazy security requirements locking down ports). 6 | 7 | scrapli_cfg is built on top of scrapli "core" and continues the "look and feel" of scrapli into the config 8 | management side of things. 9 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_iosxe/patterns.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_iosxe.patterns""" 2 | 3 | import re 4 | 5 | VERSION_PATTERN = re.compile(pattern=r"\d+\.[a-z0-9\(\)\.]+", flags=re.I) 6 | BYTES_FREE = re.compile(pattern=r"(?P\d+)(?: bytes free)", flags=re.I) 7 | FILE_PROMPT_MODE = re.compile(pattern=r"(?:file prompt )(?P\w+)", flags=re.I) 8 | 9 | OUTPUT_HEADER_PATTERN = re.compile( 10 | pattern=r".*(?=(version \d+\.\d+))", 11 | flags=re.I | re.S, 12 | ) 13 | -------------------------------------------------------------------------------- /docs/more_scrapli/scrapli_replay.md: -------------------------------------------------------------------------------- 1 | # Scrapli Replay 2 | 3 | 4 | [scrapli_replay](https://github.com/scrapli/scrapli_replay) ([docs](https://scrapli.github.io/scrapli_replay/)) 5 | is a set of tools used to help test scrapli programs. scrapli_replay includes a utility to capture command 6 | input/output from real life servers and replay them in a semi-interactive fashion, as well as a pytest plugin that 7 | patches and records and replays session data (like [vcr.py](https://github.com/kevin1024/vcrpy)) for scrapli connections. 8 | -------------------------------------------------------------------------------- /docs/more_scrapli/scrapli_netconf.md: -------------------------------------------------------------------------------- 1 | # Scrapli Netconf 2 | 3 | 4 | [scrapli_netconf](https://github.com/scrapli/scrapli_netconf) ([docs](https://scrapli.github.io/scrapli_netconf/)) 5 | is a netconf driver built on top of scrapli. The purpose of scrapli_netconf is to provide a fast, flexible, 6 | thoroughly tested, well typed, well documented, simple API that supports both synchronous and asynchronous usage. 7 | Working together scrapli and scrapli_netconf aim to provide a consistent (as is practical) look and feel when 8 | automating devices over telnet, SSH, or netconf (over SSH). 9 | -------------------------------------------------------------------------------- /docs/htmltest.yml: -------------------------------------------------------------------------------- 1 | # adopted from https://github.com/goreleaser/goreleaser/blob/5adf43295767b5be05fa38a01ffb3ad25bd21797/www/htmltest.yml 2 | # using https://github.com/wjdp/htmltest 3 | DirectoryPath: ./site 4 | IgnoreURLs: 5 | - fonts.gstatic.com 6 | - linkedin.com 7 | # ignoring due to too many 429 even w/ concurrency set to 1 8 | - github.com 9 | IgnoreDirectoryMissingTrailingSlash: true 10 | IgnoreAltMissing: true 11 | IgnoreSSLVerify: true 12 | IgnoreDirs: 13 | - overrides 14 | IgnoreInternalEmptyHash: true 15 | ExternalTimeout: 60 16 | HTTPHeaders: 17 | "Range": "bytes=0-10" 18 | "Accept": "*/*" 19 | CacheExpires: "96h" 20 | HTTPConcurrencyLimit: 1 -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## 2022.07.30 5 | 6 | - Fix from @WillIrvine to sort a very bad (carl's fault) regex overly aggressive matching -- see #41 7 | 8 | 9 | ## 2022.01.30 10 | 11 | - Revised juniper abort config to remove candidate config file *after* rollback 0 to avoid issues where junos would 12 | prompt for confirmation when exiting config mode to go delete the candidate config file prompting timeouts. 13 | - Dropped Python3.6 support as it is now EOL! Of course, scrapli probably still works just fine with 3.6 (if you 14 | install the old 3.6 requirements), but we won't test/support it anymore. 15 | - Wow, pretty empty here... guess that's a good sign things have been working :p 16 | 17 | 18 | ## 2021.07.30 19 | 20 | - Initial release! 21 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_nxos/patterns.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platforms.cisco_nxos.patterns""" 2 | 3 | import re 4 | 5 | VERSION_PATTERN = re.compile(pattern=r"\d+\.[a-z0-9\(\)\.]+", flags=re.I) 6 | BYTES_FREE = re.compile(pattern=r"(?P\d+)(?: bytes free)", flags=re.I) 7 | 8 | BUILD_CONFIG_PATTERN = re.compile(r"(^!command:.*$)", flags=re.I | re.M) 9 | CONFIG_VERSION_PATTERN = re.compile(r"(^!running configuration last done.*$)", flags=re.I | re.M) 10 | CONFIG_CHANGE_PATTERN = re.compile(r"(^!time.*$)", flags=re.I | re.M) 11 | OUTPUT_HEADER_PATTERN = re.compile( 12 | pattern=rf"{BUILD_CONFIG_PATTERN.pattern}|" 13 | rf"{CONFIG_VERSION_PATTERN.pattern}|" 14 | rf"{CONFIG_CHANGE_PATTERN.pattern}", 15 | flags=re.I | re.M, 16 | ) 17 | 18 | CHECKPOINT_LINE = re.compile(pattern=r"^\s*!#.*$", flags=re.M) 19 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/arista_eos/patterns.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.arista_eos.patterns""" 2 | 3 | import re 4 | 5 | VERSION_PATTERN = re.compile(pattern=r"\d+\.\d+\.[a-z0-9\-]+(\.\d+[a-z]{0,1})?", flags=re.I) 6 | GLOBAL_COMMENT_LINE_PATTERN = re.compile(pattern=r"^\! .*$", flags=re.I | re.M) 7 | BANNER_PATTERN = re.compile(pattern=r"^banner.*EOF$", flags=re.I | re.M | re.S) 8 | END_PATTERN = re.compile(pattern="end$") 9 | 10 | # pre-canned config section grabber patterns 11 | 12 | # match all ethernet interfaces w/ or w/out config items below them 13 | ETHERNET_INTERFACES = re.compile( 14 | pattern=r"(^interface ethernet\d+$(?:\n^\s{3}.*$)*\n!\n)+", flags=re.I | re.M 15 | ) 16 | # match management1 interface and config items below it 17 | MANAGEMENT_ONE_INTERFACE = re.compile( 18 | pattern=r"^interface management1$(?:\n^\s{3}.*$)*\n!", flags=re.I | re.M 19 | ) 20 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ======= 3 | 4 | Thanks for thinking about contributing to scrapli_cfg! Contributions are not expected, but are quite welcome. 5 | 6 | Contributions of all kinds are welcomed -- typos, doc updates, adding examples, bug fixes, and feature adds. 7 | 8 | 9 | Some notes on contributing: 10 | 11 | - Please open an issue to discuss any bug fixes, feature adds, or really any thing that could result in a pull 12 | request. This allows us to all be on the same page, and could save everyone some extra work! 13 | - Once we've discussed any changes, pull requests are of course welcome and very much appreciated! 14 | - All PRs should pass tests -- checkout the Makefile for some shortcuts for linting and testing. 15 | - Please include tests! Even simple/basic tests are better than nothing -- it helps make sure changes in the future 16 | don't break functionality or make things act in unexpected ways! 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: set up python 3.11 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: "3.11" 16 | - name: setup publish env 17 | run: | 18 | python -m pip install --upgrade pip 19 | python -m pip install setuptools wheel build twine 20 | - name: build and publish 21 | env: 22 | TWINE_USERNAME: __token__ 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PROJECT_TOKEN }} 24 | run: | 25 | python -m build 26 | python -m twine upload dist/* 27 | - name: create release branch 28 | uses: peterjgrainger/action-create-branch@v2.4.0 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | branch: ${{ github.event.release.tag_name }} -------------------------------------------------------------------------------- /docs/about/contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Thanks for thinking about contributing! Contributions are not expected, but are quite welcome. 5 | 6 | Contributions of all kinds are welcomed -- typos, doc updates, adding examples, bug fixes, and feature adds. 7 | 8 | 9 | Some notes on contributing: 10 | 11 | - Please open a GitHub discussion topic for any potential feature adds/changes to discuss them prior to opening a PR, 12 | this way everyone has a chance to chime in and make sure we're all on the same page! 13 | - Please open an issue to discuss any bugs/bug fixes prior to opening a PR. 14 | - Once we all have discussed any adds/changes, pull requests are very much welcome and appreciated! 15 | - All PRs should pass tests/CI linting -- checkout the Makefile for some shortcuts for linting and testing. 16 | - Please include tests! Even simple/basic tests are better than nothing -- it helps make sure changes in the future 17 | don't break functionality or make things act in unexpected ways! 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lint: 2 | python -m isort . 3 | python -m black . 4 | python -m pylint scrapli_cfg/ 5 | python -m pydocstyle . 6 | python -m mypy --strict scrapli_cfg/ 7 | 8 | darglint: 9 | find scrapli_cfg -type f \( -iname "*.py"\ ) | xargs darglint -x 10 | 11 | test: 12 | python -m pytest \ 13 | tests/ 14 | 15 | cov: 16 | python -m pytest \ 17 | --cov=scrapli_cfg \ 18 | --cov-report html \ 19 | --cov-report term \ 20 | tests/ 21 | 22 | test_unit: 23 | python -m pytest \ 24 | tests/unit/ 25 | 26 | cov_unit: 27 | python -m pytest \ 28 | --cov=scrapli_cfg \ 29 | --cov-report html \ 30 | --cov-report term \ 31 | tests/unit/ 32 | 33 | test_integration: 34 | python -m pytest \ 35 | tests/integration/ 36 | 37 | cov_integration: 38 | python -m pytest \ 39 | --cov=scrapli_cfg \ 40 | --cov-report html \ 41 | --cov-report term \ 42 | tests/integration/ 43 | 44 | .PHONY: docs 45 | docs: 46 | python docs/generate.py 47 | 48 | test_docs: 49 | mkdocs build --clean --strict 50 | htmltest -c docs/htmltest.yml -s 51 | rm -rf tmp 52 | 53 | deploy_docs: 54 | mkdocs gh-deploy 55 | -------------------------------------------------------------------------------- /tests/unit/test_response.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli_cfg.exceptions import ScrapliCfgException, TemplateError 4 | 5 | 6 | def test_response_obj_bool(response_obj): 7 | assert bool(response_obj) is True 8 | response_obj.failed = False 9 | assert bool(response_obj) is False 10 | 11 | 12 | def test_response_obj_repr(response_obj): 13 | assert repr(response_obj) == "ScrapliCfgResponse " 14 | response_obj.failed = False 15 | assert repr(response_obj) == "ScrapliCfgResponse " 16 | 17 | 18 | def test_response_obj_str(response_obj): 19 | assert str(response_obj) == "ScrapliCfgResponse " 20 | response_obj.failed = False 21 | assert str(response_obj) == "ScrapliCfgResponse " 22 | 23 | 24 | def test_response_obj_raise_for_status(response_obj): 25 | with pytest.raises(ScrapliCfgException): 26 | response_obj.raise_for_status() 27 | 28 | response_obj.raise_for_status_exception = TemplateError 29 | 30 | with pytest.raises(TemplateError): 31 | response_obj.raise_for_status() 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Carl Montanari 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 | -------------------------------------------------------------------------------- /docs/generate.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation.""" 2 | 3 | from pathlib import Path 4 | 5 | import mkdocs_gen_files 6 | 7 | nav = mkdocs_gen_files.Nav() 8 | 9 | for path in sorted(Path("scrapli_cfg").rglob("*.py")): 10 | module_path = path.relative_to("scrapli_cfg").with_suffix("") 11 | doc_path = path.relative_to("scrapli_cfg").with_suffix(".md") 12 | full_doc_path = Path("reference", doc_path) 13 | 14 | parts = tuple(module_path.parts) 15 | 16 | if parts[-1] == "__init__": 17 | parts = parts[:-1] 18 | doc_path = doc_path.with_name("index.md") 19 | full_doc_path = full_doc_path.with_name("index.md") 20 | elif parts[-1] == "__main__": 21 | continue 22 | 23 | if not parts: 24 | continue 25 | 26 | nav[parts] = doc_path.as_posix() 27 | 28 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 29 | ident = ".".join(parts) 30 | fd.write(f"::: {ident}") 31 | 32 | mkdocs_gen_files.set_edit_path(full_doc_path, path) 33 | 34 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 35 | nav_file.writelines(nav.build_literate_nav()) 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Your script 16 | 2. What you're connecting to (vendor, platform, version) 17 | 3. Anything else relevant 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Stack Trace** 23 | Copy of your stack trace here, please format it properly using triple back ticks (top left key on US keyboards!) 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem, but do note that formatted text is much preferred over 27 | screenshots! 28 | 29 | **OS (please complete the following information):** 30 | - OS: [e.g. Ubuntu, MacOS, etc. - Note scrapli is *not* thoroughly tested on Windows and some/many things will not 31 | be supported] 32 | - scrapli version 33 | - scrapli_cfg version 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /docs/user_guide/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | 4 | ## Installation 5 | 6 | In most cases installation via pip is the simplest and best way to install scrapli_cfg. 7 | See [here](/user_guide/installation) for advanced installation details. 8 | 9 | ``` 10 | pip install scrapli-cfg 11 | ``` 12 | 13 | 14 | ## A Simple Example 15 | 16 | ```python 17 | from scrapli import Scrapli 18 | from scrapli_cfg import ScrapliCfg 19 | 20 | device = { 21 | "host": "172.18.0.11", 22 | "auth_username": "vrnetlab", 23 | "auth_password": "VR-netlab9", 24 | "auth_strict_key": False, 25 | "platform": "cisco_iosxe" 26 | } 27 | 28 | with open("myconfig", "r") as f: 29 | my_config = f.read() 30 | 31 | with Scrapli(**device) as conn: 32 | cfg_conn = ScrapliCfg(conn=conn) 33 | cfg_conn.prepare() 34 | cfg_conn.load_config(config=my_config, replace=True) 35 | diff = cfg_conn.diff_config() 36 | print(diff.side_by_side_diff) 37 | cfg_conn.commit_config() 38 | 39 | ``` 40 | 41 | 42 | ## More Examples 43 | 44 | - [Basic Usage](https://github.com/scrapli/scrapli_cfg/tree/main/examples/basic_usage) 45 | - [Selective Configuration Replace](https://github.com/scrapli/scrapli_cfg/tree/main/examples/selective_config_replace) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Environments 54 | .env 55 | .venv 56 | env/ 57 | venv/ 58 | ENV/ 59 | env.bak/ 60 | venv.bak/ 61 | 62 | # pycharm 63 | .idea 64 | 65 | # vscode 66 | .vscode/ 67 | 68 | # mypy 69 | .mypy_cache/ 70 | .dmypy.json 71 | dmypy.json 72 | 73 | # swap files 74 | *.swp 75 | 76 | # macos stuff 77 | .DS_Store 78 | */.DS_Store 79 | 80 | # private dir for notes and such 81 | private/ 82 | .private/ 83 | 84 | # log output 85 | *.log 86 | 87 | # mkdocs site 88 | site/* -------------------------------------------------------------------------------- /docs/user_guide/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | 4 | ## Standard Installation 5 | 6 | As outlined in the quick start, you should be able to pip install scrapli "normally": 7 | 8 | ``` 9 | pip install scrapli-cfg 10 | ``` 11 | 12 | 13 | ## Installing current master branch 14 | 15 | To install from the source repositories master branch: 16 | 17 | ``` 18 | pip install git+https://github.com/scrapli/scrapli_cfg 19 | ``` 20 | 21 | 22 | ## Installing current develop branch 23 | 24 | To install from the source repositories develop branch: 25 | 26 | ``` 27 | pip install -e git+https://github.com/scrapli/scrapli_cfg.git@develop#egg=scrapli_cfg 28 | ``` 29 | 30 | 31 | ## Installation from Source 32 | 33 | To install from source: 34 | 35 | ``` 36 | git clone https://github.com/scrapli/scrapli_cfg 37 | cd scrapli_cfg 38 | python setup.py install 39 | ``` 40 | 41 | 42 | ## Supported Platforms 43 | 44 | As for platforms to *run* scrapli on -- it has and will be tested on MacOS and Ubuntu regularly and should work on any 45 | POSIX system. Windows at one point was being tested very minimally via GitHub Actions builds, however this is no 46 | longer the case as it is just not worth the effort. While scrapli should work on Windows when using the paramiko or 47 | ssh2-python transport drivers, it is not "officially" supported. It is *strongly* recommended/preferred for folks 48 | to use WSL/Cygwin instead of Windows. 49 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from devices import CONFIG_REPLACER, DEVICES 5 | 6 | import scrapli_cfg 7 | 8 | TEST_DATA_PATH = f"{Path(scrapli_cfg.__file__).parents[1]}/tests/test_data" 9 | 10 | 11 | @pytest.fixture(scope="session") 12 | def test_data_path(): 13 | """Fixture to provide path to test data files""" 14 | return TEST_DATA_PATH 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def test_devices_dict(): 19 | """Fixture to return test devices dict""" 20 | return DEVICES 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def expected_configs(): 25 | """Fixture to provide expected configs""" 26 | return { 27 | "arista_eos": open(f"{TEST_DATA_PATH}/expected/arista_eos").read(), 28 | "cisco_iosxe": open(f"{TEST_DATA_PATH}/expected/cisco_iosxe").read(), 29 | "cisco_nxos": open(f"{TEST_DATA_PATH}/expected/cisco_nxos").read(), 30 | "cisco_iosxr": open(f"{TEST_DATA_PATH}/expected/cisco_iosxr").read(), 31 | "juniper_junos": open(f"{TEST_DATA_PATH}/expected/juniper_junos").read(), 32 | } 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | def test_devices_dict(): 37 | """Fixture to return test devices dict""" 38 | return DEVICES 39 | 40 | 41 | @pytest.fixture(scope="session") 42 | def config_replacer_dict(): 43 | """Fixture to return dict of config replacer helper functions""" 44 | return CONFIG_REPLACER 45 | -------------------------------------------------------------------------------- /docs/user_guide/versioning.md: -------------------------------------------------------------------------------- 1 | # Versioning 2 | 3 | Just like scrapli, scrapli_cfg uses the [CalVer](https://calver.org) versioning standard. All release versions 4 | follow the format `YYYY.MM.DD`, however PyPi will shorten/standardize this to remove leading zeros. 5 | 6 | The reason for choosing CalVer is simply to make it very clear how old a given release of scrapli is. While there are 7 | clearly some potential challenges around indicating when a "breaking" change occurs due to there not being the 8 | concept of a "major" version, this is hopefully not too big a deal for scrapli, and thus far the "core" API has 9 | been very stable -- there are only so many things you can/need to do over SSH after all! 10 | 11 | Please also note that the [CHANGELOG](/changelog) contains notes about each version (and is updated in develop branch 12 | while updates are happening). Releases will be made semi-yearly; if you need a feature between releases, there will 13 | be periodic pre-releases cut so that folks can easily pip install the prerelease versions for testing and getting 14 | any new features/fixes. 15 | 16 | A final note regarding versioning: scrapli updates are released as often as necessary/there are things to update 17 | . This means you should ALWAYS PIN YOUR REQUIREMENTS when using scrapli!! As stated, the "core" API has been very 18 | stable, but things will change over time -- always pin your requirements, and keep an eye on the changelog/api docs 19 | -- you can "watch" this repository to ensure you are notified of any releases. 20 | -------------------------------------------------------------------------------- /tests/integration/platform/test_core_platforms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.scrapli_replay 5 | def test_get_config(cfg_conn): 6 | cfg_conn.prepare() 7 | config = cfg_conn.get_config() 8 | assert config.failed is False 9 | # expected config is loaded from disk and set as an attribute in the fixture to make life easy 10 | assert cfg_conn._config_cleaner(config.result) == cfg_conn._config_cleaner( 11 | cfg_conn._expected_config 12 | ) 13 | 14 | 15 | @pytest.mark.scrapli_replay 16 | def test_load_config_merge_diff_and_abort(cfg_conn): 17 | cfg_conn.prepare() 18 | load_config = cfg_conn.load_config(config=cfg_conn._load_config, replace=False) 19 | assert load_config.failed is False 20 | diff_config = cfg_conn.diff_config() 21 | assert diff_config.failed is False 22 | abort_config = cfg_conn.abort_config() 23 | assert abort_config.failed is False 24 | # dont bother with checking the diff itself, we'll do that in unit tests much more thoroughly 25 | 26 | 27 | @pytest.mark.scrapli_replay 28 | def test_load_config_replace_diff_and_commit(cfg_conn): 29 | cfg_conn.prepare() 30 | load_config = cfg_conn.load_config(config=cfg_conn._expected_config, replace=True) 31 | assert load_config.failed is False 32 | diff_config = cfg_conn.diff_config() 33 | assert diff_config.failed is False 34 | commit_config = cfg_conn.commit_config() 35 | assert commit_config.failed is False 36 | # dont bother with checking the diff itself, we'll do that in unit tests much more thoroughly 37 | -------------------------------------------------------------------------------- /tests/unit/test_logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | 4 | from scrapli_cfg.logging import ScrapliFileHandler, ScrapliFormatter, enable_basic_logging, logger 5 | 6 | 7 | def test_enable_basic_logging(fs): 8 | assert Path("scrapli_cfg.log").is_file() is False 9 | enable_basic_logging(file=True, level="debug") 10 | scrapli_logger = logging.getLogger("scrapli_cfg") 11 | 12 | assert scrapli_logger.level == 10 13 | assert isinstance(scrapli_logger.handlers[1], ScrapliFileHandler) 14 | assert isinstance(scrapli_logger.handlers[1].formatter, ScrapliFormatter) 15 | assert scrapli_logger.propagate is False 16 | 17 | assert Path("scrapli_cfg.log").is_file() is True 18 | 19 | # reset the main logger to propagate and delete the file handler so caplog works! 20 | logger.propagate = True 21 | del logger.handlers[1] 22 | 23 | 24 | def test_enable_basic_logging_no_buffer(fs): 25 | assert Path("mylog.log").is_file() is False 26 | 27 | enable_basic_logging(file="mylog.log", level="debug", buffer_log=False, caller_info=True) 28 | scrapli_logger = logging.getLogger("scrapli_cfg") 29 | 30 | assert scrapli_logger.level == 10 31 | assert isinstance(scrapli_logger.handlers[1], logging.FileHandler) 32 | assert isinstance(scrapli_logger.handlers[1].formatter, ScrapliFormatter) 33 | assert scrapli_logger.propagate is False 34 | 35 | assert Path("mylog.log").is_file() is True 36 | 37 | # reset the main logger to propagate and delete the file handler so caplog works! 38 | logger.propagate = True 39 | del logger.handlers[1] 40 | -------------------------------------------------------------------------------- /scrapli_cfg/logging.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.logging""" 2 | 3 | from logging import FileHandler, NullHandler, getLogger 4 | from typing import Union 5 | 6 | from scrapli.logging import ScrapliFileHandler, ScrapliFormatter 7 | 8 | 9 | def enable_basic_logging( 10 | file: Union[str, bool] = False, 11 | level: str = "info", 12 | caller_info: bool = False, 13 | buffer_log: bool = True, 14 | ) -> None: 15 | """ 16 | Enable opinionated logging for scrapli_cfg 17 | 18 | Uses scrapli "core" formatter/file handler 19 | 20 | Args: 21 | file: True to output to default log path ("scrapli.log"), otherwise string path to write log 22 | file to 23 | level: string name of logging level to use, i.e. "info", "debug", etc. 24 | caller_info: add info about module/function/line in the log entry 25 | buffer_log: buffer log read outputs 26 | 27 | Returns: 28 | None 29 | 30 | Raises: 31 | N/A 32 | 33 | """ 34 | logger.propagate = False 35 | logger.setLevel(level=level.upper()) 36 | 37 | scrapli_formatter = ScrapliFormatter(caller_info=caller_info) 38 | 39 | if file: 40 | if isinstance(file, bool): 41 | filename = "scrapli_cfg.log" 42 | else: 43 | filename = file 44 | 45 | if not buffer_log: 46 | fh = FileHandler(filename=filename, mode="w") 47 | else: 48 | fh = ScrapliFileHandler(filename=filename, mode="w") 49 | 50 | fh.setFormatter(scrapli_formatter) 51 | 52 | logger.addHandler(fh) 53 | 54 | 55 | logger = getLogger("scrapli_cfg") 56 | logger.addHandler(NullHandler()) 57 | -------------------------------------------------------------------------------- /docs/api_docs/platform/core/juniper_junos/patterns.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.juniper_junos.patterns 22 | 23 | scrapli_cfg.platform.core.juniper_junos.patterns 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platform.core.juniper_junos.patterns"""
32 | import re
33 | 
34 | VERSION_PATTERN = re.compile(
35 |     # should match at least versions looking like:
36 |     # 17.3R2.10
37 |     # 18.1R3-S2.5
38 |     pattern=r"\d+\.[\w-]+\.\w+",
39 | )
40 | OUTPUT_HEADER_PATTERN = re.compile(pattern=r"^## last commit.*$\nversion.*$", flags=re.M | re.I)
41 | EDIT_PATTERN = re.compile(pattern=r"^\[edit\]$", flags=re.M)
42 |         
43 |     
44 |
-------------------------------------------------------------------------------- /docs/api_docs/platform/core/cisco_iosxe/patterns.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.cisco_iosxe.patterns 22 | 23 | scrapli_cfg.platform.core.cisco_iosxe.patterns 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platform.core.cisco_iosxe.patterns"""
32 | import re
33 | 
34 | VERSION_PATTERN = re.compile(pattern=r"\d+\.[a-z0-9\(\)\.]+", flags=re.I)
35 | BYTES_FREE = re.compile(pattern=r"(?P\d+)(?: bytes free)", flags=re.I)
36 | FILE_PROMPT_MODE = re.compile(pattern=r"(?:file prompt )(?P\w+)", flags=re.I)
37 | 
38 | OUTPUT_HEADER_PATTERN = re.compile(
39 |     pattern=r".*(?=(version \d+\.\d+))",
40 |     flags=re.I | re.S,
41 | )
42 |         
43 |     
44 |
-------------------------------------------------------------------------------- /tests/integration/platform/test_core_platforms_async.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.scrapli_replay 5 | async def test_get_config(async_cfg_conn): 6 | await async_cfg_conn.prepare() 7 | config = await async_cfg_conn.get_config() 8 | assert config.failed is False 9 | # expected config is loaded from disk and set as an attribute in the fixture to make life easy 10 | assert async_cfg_conn._config_cleaner(config.result) == async_cfg_conn._config_cleaner( 11 | async_cfg_conn._expected_config 12 | ) 13 | 14 | 15 | @pytest.mark.scrapli_replay 16 | async def test_load_config_merge_diff_and_abort(async_cfg_conn): 17 | await async_cfg_conn.prepare() 18 | load_config = await async_cfg_conn.load_config( 19 | config=async_cfg_conn._load_config, replace=False 20 | ) 21 | assert load_config.failed is False 22 | diff_config = await async_cfg_conn.diff_config() 23 | assert diff_config.failed is False 24 | abort_config = await async_cfg_conn.abort_config() 25 | assert abort_config.failed is False 26 | # dont bother with checking the diff itself, we'll do that in unit tests much more thoroughly 27 | 28 | 29 | @pytest.mark.scrapli_replay 30 | async def test_load_config_merge_diff_and_commit(async_cfg_conn): 31 | await async_cfg_conn.prepare() 32 | load_config = await async_cfg_conn.load_config( 33 | config=async_cfg_conn._expected_config, replace=True 34 | ) 35 | assert load_config.failed is False 36 | diff_config = await async_cfg_conn.diff_config() 37 | assert diff_config.failed is False 38 | commit_config = await async_cfg_conn.commit_config() 39 | assert commit_config.failed is False 40 | # dont bother with checking the diff itself, we'll do that in unit tests much more thoroughly 41 | -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_iosxr/patterns.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_iosxr.patterns""" 2 | 3 | import re 4 | 5 | VERSION_PATTERN = re.compile(pattern=r"\d+\.\d+\.\d+", flags=re.I) 6 | BANNER_PATTERN = re.compile( 7 | pattern=r"(^banner\s(?:exec|incoming|login|motd|prompt-timeout|slip-ppp)\s" 8 | r"(?P.{1}).*(?P=delim)$)", 9 | flags=re.I | re.M | re.S, 10 | ) 11 | 12 | TIMESTAMP_PATTERN = datetime_pattern = re.compile( 13 | r"^(mon|tue|wed|thu|fri|sat|sun)\s+" 14 | r"(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+" 15 | r"\d+\s+\d+:\d+:\d+((\.\d+\s\w+)|\s\d+)$", 16 | flags=re.M | re.I, 17 | ) 18 | BUILD_CONFIG_PATTERN = re.compile(r"(^building configuration\.\.\.$)", flags=re.I | re.M) 19 | CONFIG_VERSION_PATTERN = re.compile(r"(^!! ios xr.*$)", flags=re.I | re.M) 20 | CONFIG_CHANGE_PATTERN = re.compile(r"(^!! last config.*$)", flags=re.I | re.M) 21 | OUTPUT_HEADER_PATTERN = re.compile( 22 | pattern=rf"{TIMESTAMP_PATTERN.pattern}|" 23 | rf"{BUILD_CONFIG_PATTERN.pattern}|" 24 | rf"{CONFIG_VERSION_PATTERN.pattern}|" 25 | rf"{CONFIG_CHANGE_PATTERN.pattern}", 26 | flags=re.I | re.M, 27 | ) 28 | 29 | END_PATTERN = re.compile(pattern="end$") 30 | 31 | # pre-canned config section grabber patterns 32 | 33 | # match all ethernet interfaces w/ or w/out config items below them 34 | IOSXR_INTERFACES_PATTERN = r"(?:Ethernet|GigabitEthernet|TenGigE|HundredGigE)" 35 | ETHERNET_INTERFACES = re.compile( 36 | pattern=rf"(^interface {IOSXR_INTERFACES_PATTERN}(?:\d|\/)+$(?:\n^\s{1}.*$)*\n!\n)+", 37 | flags=re.I | re.M, 38 | ) 39 | # match mgmteth[numbers, letters, forward slashes] interface and config items below it 40 | MANAGEMENT_ONE_INTERFACE = re.compile( 41 | pattern=r"^^interface mgmteth(?:[a-z0-9\/]+)(?:\n^\s.*$)*\n!", flags=re.I | re.M 42 | ) 43 | -------------------------------------------------------------------------------- /tests/unit/test_factory.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli.driver.core import ( 4 | AsyncEOSDriver, 5 | AsyncIOSXEDriver, 6 | AsyncIOSXRDriver, 7 | AsyncNXOSDriver, 8 | EOSDriver, 9 | IOSXEDriver, 10 | IOSXRDriver, 11 | NXOSDriver, 12 | ) 13 | from scrapli_cfg import AsyncScrapliCfg, ScrapliCfg 14 | from scrapli_cfg.exceptions import ScrapliCfgException 15 | from scrapli_cfg.platform.core.arista_eos import AsyncScrapliCfgEOS, ScrapliCfgEOS 16 | from scrapli_cfg.platform.core.cisco_iosxe import AsyncScrapliCfgIOSXE, ScrapliCfgIOSXE 17 | from scrapli_cfg.platform.core.cisco_iosxr import AsyncScrapliCfgIOSXR, ScrapliCfgIOSXR 18 | from scrapli_cfg.platform.core.cisco_nxos import AsyncScrapliCfgNXOS, ScrapliCfgNXOS 19 | 20 | ASYNC_CORE_PLATFORM_MAP = { 21 | AsyncEOSDriver: AsyncScrapliCfgEOS, 22 | AsyncIOSXEDriver: AsyncScrapliCfgIOSXE, 23 | AsyncIOSXRDriver: AsyncScrapliCfgIOSXR, 24 | AsyncNXOSDriver: AsyncScrapliCfgNXOS, 25 | } 26 | SYNC_CORE_PLATFORM_MAP = { 27 | EOSDriver: ScrapliCfgEOS, 28 | IOSXEDriver: ScrapliCfgIOSXE, 29 | IOSXRDriver: ScrapliCfgIOSXR, 30 | NXOSDriver: ScrapliCfgNXOS, 31 | } 32 | 33 | 34 | def test_sync_factory(sync_scrapli_conn): 35 | scrapli_cfg_obj = ScrapliCfg(conn=sync_scrapli_conn) 36 | assert isinstance(scrapli_cfg_obj, SYNC_CORE_PLATFORM_MAP.get(type(sync_scrapli_conn))) 37 | 38 | 39 | def test_sync_factory_exception(): 40 | with pytest.raises(ScrapliCfgException): 41 | ScrapliCfg(conn=True) 42 | 43 | 44 | def test_async_factory(async_scrapli_conn): 45 | scrapli_cfg_obj = AsyncScrapliCfg(conn=async_scrapli_conn) 46 | assert isinstance(scrapli_cfg_obj, ASYNC_CORE_PLATFORM_MAP.get(type(async_scrapli_conn))) 47 | 48 | 49 | def test_async_factory_exception(): 50 | with pytest.raises(ScrapliCfgException): 51 | AsyncScrapliCfg(conn=True) 52 | -------------------------------------------------------------------------------- /.github/workflows/pre_release.yaml: -------------------------------------------------------------------------------- 1 | name: Weekly Build (dev) 2 | 3 | on: 4 | schedule: 5 | # weekly at 0300 PST/1000 UTC on Sunday 6 | - cron: '0 10 * * 0' 7 | workflow_dispatch: {} 8 | 9 | jobs: 10 | build_posix: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | max-parallel: 2 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | version: ["3.14-dev"] 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: set up python ${{ matrix.version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.version }} 23 | - name: get friendly (for nox) python version 24 | # not super friendly looking, but easy way to get major.minor version so we can easily exec only the specific 25 | # version we are targeting with nox, while still having versions like 3.9.0a4 26 | run: | 27 | echo "FRIENDLY_PYTHON_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" >> $GITHUB_ENV 28 | - name: setup test env 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install --upgrade setuptools wheel 32 | python -m pip install nox 33 | - name: run nox 34 | # TERM is needed to make the terminal a tty (i think? without this system ssh is super broken) 35 | # libssh2/ssh2-python were getting libssh2 linked incorrectly/weirdly and libraries were trying to be loaded 36 | # from the temp dir that pip used for installs. setting the DYLD_LIBRARY_PATH envvar seems to solve this -- note 37 | # that if brew macos packages get updated on runners this may break again :) 38 | run: TERM=xterm DYLD_LIBRARY_PATH=/opt/homebrew/Cellar/libssh2/1.11.0_1/lib PRE_RELEASE=1 python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" -------------------------------------------------------------------------------- /docs/api_docs/platform/core/cisco_nxos/patterns.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.cisco_nxos.patterns 22 | 23 | scrapli_cfg.platforms.cisco_nxos.patterns 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platforms.cisco_nxos.patterns"""
32 | import re
33 | 
34 | VERSION_PATTERN = re.compile(pattern=r"\d+\.[a-z0-9\(\)\.]+", flags=re.I)
35 | BYTES_FREE = re.compile(pattern=r"(?P\d+)(?: bytes free)", flags=re.I)
36 | 
37 | BUILD_CONFIG_PATTERN = re.compile(r"(^!command:.*$)", flags=re.I | re.M)
38 | CONFIG_VERSION_PATTERN = re.compile(r"(^!running configuration last done.*$)", flags=re.I | re.M)
39 | CONFIG_CHANGE_PATTERN = re.compile(r"(^!time.*$)", flags=re.I | re.M)
40 | OUTPUT_HEADER_PATTERN = re.compile(
41 |     pattern=rf"{BUILD_CONFIG_PATTERN.pattern}|"
42 |     rf"{CONFIG_VERSION_PATTERN.pattern}|"
43 |     rf"{CONFIG_CHANGE_PATTERN.pattern}",
44 |     flags=re.I | re.M,
45 | )
46 | 
47 | CHECKPOINT_LINE = re.compile(pattern=r"^\s*!#.*$", flags=re.M)
48 |         
49 |     
50 |
-------------------------------------------------------------------------------- /docs/api_docs/platform/core/arista_eos/patterns.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.arista_eos.patterns 22 | 23 | scrapli_cfg.platform.core.arista_eos.patterns 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platform.core.arista_eos.patterns"""
32 | import re
33 | 
34 | VERSION_PATTERN = re.compile(pattern=r"\d+\.\d+\.[a-z0-9\-]+(\.\d+[a-z]{0,1})?", flags=re.I)
35 | GLOBAL_COMMENT_LINE_PATTERN = re.compile(pattern=r"^\! .*$", flags=re.I | re.M)
36 | BANNER_PATTERN = re.compile(pattern=r"^banner.*EOF$", flags=re.I | re.M | re.S)
37 | END_PATTERN = re.compile(pattern="end$")
38 | 
39 | # pre-canned config section grabber patterns
40 | 
41 | # match all ethernet interfaces w/ or w/out config items below them
42 | ETHERNET_INTERFACES = re.compile(
43 |     pattern=r"(^interface ethernet\d+$(?:\n^\s{3}.*$)*\n!\n)+", flags=re.I | re.M
44 | )
45 | # match management1 interface and config items below it
46 | MANAGEMENT_ONE_INTERFACE = re.compile(
47 |     pattern=r"^interface management1$(?:\n^\s{3}.*$)*\n!", flags=re.I | re.M
48 | )
49 |         
50 |     
51 |
-------------------------------------------------------------------------------- /scrapli_cfg/exceptions.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.exceptions""" 2 | 3 | from scrapli.exceptions import ScrapliException 4 | 5 | 6 | class ScrapliCfgException(ScrapliException): 7 | """Base scrapli_cfg exception""" 8 | 9 | 10 | class PrepareNotCalled(ScrapliCfgException): 11 | """ 12 | Raised when the `prepare` method has not been called 13 | 14 | This will only be raised in two scenarios: 15 | 1) an `on_prepare` callable has been provided, yet `prepare` was not called 16 | 2) `ignore_version` is False and `prepare` was not called 17 | 18 | If using a context manager this should never be raised as the enter method will handle things 19 | for you 20 | """ 21 | 22 | 23 | class TemplateError(ScrapliCfgException): 24 | """For errors relating to configuration templates""" 25 | 26 | 27 | class FailedToDetermineDeviceState(ScrapliCfgException): 28 | """For issues determining device state (i.e. what mode is file prompt in, etc.)""" 29 | 30 | 31 | class VersionError(ScrapliCfgException): 32 | """For errors related to getting/parsing/invalid versions""" 33 | 34 | 35 | class ConfigError(ScrapliCfgException): 36 | """For configuration operation related errors""" 37 | 38 | 39 | class InvalidConfigTarget(ConfigError): 40 | """User has provided an invalid configuration target""" 41 | 42 | 43 | class FailedToFetchSpaceAvailable(ConfigError): 44 | """Unable to determine space available on filesystem""" 45 | 46 | 47 | class InsufficientSpaceAvailable(ConfigError): 48 | """If space available on filesystem is insufficient""" 49 | 50 | 51 | class GetConfigError(ConfigError): 52 | """For errors getting configuration from a device""" 53 | 54 | 55 | class LoadConfigError(ConfigError): 56 | """For errors loading a configuration""" 57 | 58 | 59 | class DiffConfigError(ConfigError): 60 | """For errors diffing a configuration""" 61 | 62 | 63 | class AbortConfigError(ConfigError): 64 | """For errors aborting a configuration""" 65 | 66 | 67 | class CommitConfigError(ConfigError): 68 | """For errors committing a configuration""" 69 | 70 | 71 | class CleanupError(ScrapliCfgException): 72 | """For errors during cleanup (i.e. removing candidate config, etc.)""" 73 | -------------------------------------------------------------------------------- /docs/api_docs/platform/core/cisco_iosxe/types.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.cisco_iosxe.types 22 | 23 | scrapli_cfg.platform.core.cisco_iosxe.types 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platform.core.cisco_iosxe.types"""
32 | from enum import Enum
33 | 
34 | 
35 | class FilePromptMode(Enum):
36 |     """Enum representing file prompt modes"""
37 | 
38 |     NOISY = "noisy"
39 |     ALERT = "alert"
40 |     QUIET = "quiet"
41 |         
42 |     
43 |
44 | 45 | 46 | 47 | 48 | ## Classes 49 | 50 | ### FilePromptMode 51 | 52 | 53 | ```text 54 | Enum representing file prompt modes 55 | ``` 56 | 57 |
58 | 59 | Expand source code 60 | 61 |
62 |         
63 | class FilePromptMode(Enum):
64 |     """Enum representing file prompt modes"""
65 | 
66 |     NOISY = "noisy"
67 |     ALERT = "alert"
68 |     QUIET = "quiet"
69 |         
70 |     
71 |
72 | 73 | 74 | #### Ancestors (in MRO) 75 | - enum.Enum 76 | #### Class variables 77 | 78 | 79 | `ALERT` 80 | 81 | 82 | 83 | 84 | 85 | `NOISY` 86 | 87 | 88 | 89 | 90 | 91 | `QUIET` -------------------------------------------------------------------------------- /docs/reference/SUMMARY.md: -------------------------------------------------------------------------------- 1 | * [diff](diff.md) 2 | * [exceptions](exceptions.md) 3 | * [factory](factory.md) 4 | * [helper](helper.md) 5 | * [logging](logging.md) 6 | * [platform](platform/index.md) 7 | * [base](platform/base/index.md) 8 | * [async_platform](platform/base/async_platform.md) 9 | * [base_platform](platform/base/base_platform.md) 10 | * [sync_platform](platform/base/sync_platform.md) 11 | * [core](platform/core/index.md) 12 | * [arista_eos](platform/core/arista_eos/index.md) 13 | * [async_platform](platform/core/arista_eos/async_platform.md) 14 | * [base_platform](platform/core/arista_eos/base_platform.md) 15 | * [patterns](platform/core/arista_eos/patterns.md) 16 | * [sync_platform](platform/core/arista_eos/sync_platform.md) 17 | * [cisco_iosxe](platform/core/cisco_iosxe/index.md) 18 | * [async_platform](platform/core/cisco_iosxe/async_platform.md) 19 | * [base_platform](platform/core/cisco_iosxe/base_platform.md) 20 | * [patterns](platform/core/cisco_iosxe/patterns.md) 21 | * [sync_platform](platform/core/cisco_iosxe/sync_platform.md) 22 | * [cisco_iosxr](platform/core/cisco_iosxr/index.md) 23 | * [async_platform](platform/core/cisco_iosxr/async_platform.md) 24 | * [base_platform](platform/core/cisco_iosxr/base_platform.md) 25 | * [patterns](platform/core/cisco_iosxr/patterns.md) 26 | * [sync_platform](platform/core/cisco_iosxr/sync_platform.md) 27 | * [cisco_nxos](platform/core/cisco_nxos/index.md) 28 | * [async_platform](platform/core/cisco_nxos/async_platform.md) 29 | * [base_platform](platform/core/cisco_nxos/base_platform.md) 30 | * [patterns](platform/core/cisco_nxos/patterns.md) 31 | * [sync_platform](platform/core/cisco_nxos/sync_platform.md) 32 | * [juniper_junos](platform/core/juniper_junos/index.md) 33 | * [async_platform](platform/core/juniper_junos/async_platform.md) 34 | * [base_platform](platform/core/juniper_junos/base_platform.md) 35 | * [patterns](platform/core/juniper_junos/patterns.md) 36 | * [sync_platform](platform/core/juniper_junos/sync_platform.md) 37 | * [response](response.md) 38 | -------------------------------------------------------------------------------- /examples/basic_usage/config_replace.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.examples.basic_usage.config_replace""" 2 | 3 | from scrapli import Scrapli 4 | from scrapli_cfg import ScrapliCfg 5 | 6 | DEVICE = { 7 | "host": "localhost", 8 | "port": 21022, 9 | "auth_username": "boxen", 10 | "auth_password": "b0x3N-b0x3N", 11 | "auth_strict_key": False, 12 | "platform": "cisco_iosxe", 13 | } 14 | 15 | 16 | def main(): 17 | """Demo basic config replace functionality""" 18 | 19 | # load up a config to use for the candidate config 20 | with open("config", "r", encoding="utf-8") as f: 21 | my_config = f.read() 22 | 23 | # open the "normal" scrapli connection 24 | with Scrapli(**DEVICE) as conn: 25 | # create the scrapli cfg object, passing in the scrapli connection, we are also using the 26 | # scrapli_cfg factory, so we can just pass the connection object and it will automatically 27 | # find and return the IOSXE (in this case) scrapli-cfg object 28 | cfg_conn = ScrapliCfg(conn=conn) 29 | 30 | # prepare the scrapli cfg object; this is where we'll fetch the device version and run the 31 | # `on_prepare` function if provided (to disable logging console or stuff like that) 32 | cfg_conn.prepare() 33 | 34 | # load up the new candidate config, set replace to True 35 | cfg_conn.load_config(config=my_config, replace=True) 36 | 37 | # get a diff from the device 38 | diff = cfg_conn.diff_config() 39 | 40 | # print out the different types of diffs; sometimes the "side by side" and "unified" diffs 41 | # can be dumb as they only know what you are sending and what is on the device, but have no 42 | # context about what is getting added or removed. in theory the "device_diff" is smarter 43 | # because it is what the actual device can give us, though this varies from vendor to vendor 44 | print(diff.device_diff) 45 | print(diff.side_by_side_diff) 46 | print(diff.side_by_side_diff) 47 | 48 | # if you are happy you can commit the config, or if not you can use `abort_config` to abort 49 | cfg_conn.commit_config() 50 | 51 | # optionally clean it all down when done! this is unnecessary if you are not going to re-use 52 | # the scrapli cfg object later, but its probably a good habit! 53 | cfg_conn.cleanup() 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /.github/workflows/commit.yaml: -------------------------------------------------------------------------------- 1 | name: Commit 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | darglint: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | max-parallel: 1 10 | matrix: 11 | os: [ubuntu-latest] 12 | version: ["3.13"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: set up python ${{ matrix.version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.version }} 19 | - name: setup test env 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install setuptools 23 | python -m pip install nox 24 | - name: run nox darglint 25 | run: python -m nox -s darglint 26 | 27 | build_posix: 28 | runs-on: ${{ matrix.os }} 29 | strategy: 30 | max-parallel: 10 31 | matrix: 32 | os: [ubuntu-latest, macos-latest] 33 | version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: set up python ${{ matrix.version }} 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: ${{ matrix.version }} 40 | - name: get friendly (for nox) python version 41 | # not super friendly looking, but easy way to get major.minor version so we can easily exec only the specific 42 | # version we are targeting with nox, while still having versions like 3.9.0a4 43 | run: | 44 | echo "FRIENDLY_PYTHON_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" >> $GITHUB_ENV 45 | - name: setup test env 46 | run: | 47 | python -m pip install --upgrade pip 48 | python -m pip install --upgrade setuptools wheel 49 | python -m pip install nox 50 | - name: run nox 51 | # TERM is needed needed to make the terminal a tty (i think? without this system ssh is super broken) 52 | # libssh2/ssh2-python were getting libssh2 linked incorrectly/weirdly and libraries were trying to be loaded 53 | # from the temp dir that pip used for installs. setting the DYLD_LIBRARY_PATH envvar seems to solve this -- note 54 | # that if brew macos packages get updated on runners this may break again :) 55 | run: TERM=xterm DYLD_LIBRARY_PATH=/opt/homebrew/Cellar/libssh2/1.11.0_1/lib python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/weekly.yaml: -------------------------------------------------------------------------------- 1 | name: Weekly Build 2 | 3 | on: 4 | schedule: 5 | # weekly at 0300 PST/1000 UTC on Sunday 6 | - cron: '0 10 * * 0' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | darglint: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | max-parallel: 1 14 | matrix: 15 | os: [ubuntu-latest] 16 | version: ["3.13"] 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | ref: main 21 | - name: set up python ${{ matrix.version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.version }} 25 | - name: setup test env 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install --upgrade setuptools wheel 29 | python -m pip install nox 30 | - name: run nox darglint 31 | run: python -m nox -s darglint 32 | 33 | build_posix: 34 | runs-on: ${{ matrix.os }} 35 | strategy: 36 | max-parallel: 10 37 | matrix: 38 | os: [ubuntu-latest, macos-latest] 39 | version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: set up python ${{ matrix.version }} 43 | uses: actions/setup-python@v4 44 | with: 45 | python-version: ${{ matrix.version }} 46 | - name: get friendly (for nox) python version 47 | # not super friendly looking, but easy way to get major.minor version so we can easily exec only the specific 48 | # version we are targeting with nox, while still having versions like 3.9.0a4 49 | run: | 50 | echo "FRIENDLY_PYTHON_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" >> $GITHUB_ENV 51 | - name: setup test env 52 | run: | 53 | python -m pip install --upgrade pip 54 | python -m pip install --upgrade setuptools wheel 55 | python -m pip install nox 56 | - name: run nox 57 | # TERM is needed needed to make the terminal a tty (i think? without this system ssh is super broken) 58 | # libssh2/ssh2-python were getting libssh2 linked incorrectly/weirdly and libraries were trying to be loaded 59 | # from the temp dir that pip used for installs. setting the DYLD_LIBRARY_PATH envvar seems to solve this -- note 60 | # that if brew macos packages get updated on runners this may break again :) 61 | run: TERM=xterm DYLD_LIBRARY_PATH=/opt/homebrew/Cellar/libssh2/1.11.0_1/lib python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" -------------------------------------------------------------------------------- /tests/devices.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from helper import ( 4 | arista_eos_clean_response, 5 | cisco_iosxe_clean_response, 6 | cisco_iosxr_clean_response, 7 | cisco_nxos_clean_response, 8 | juniper_junos_clean_response, 9 | ) 10 | 11 | from scrapli.driver.core import ( 12 | AsyncEOSDriver, 13 | AsyncIOSXEDriver, 14 | AsyncIOSXRDriver, 15 | AsyncJunosDriver, 16 | AsyncNXOSDriver, 17 | EOSDriver, 18 | IOSXEDriver, 19 | IOSXRDriver, 20 | JunosDriver, 21 | NXOSDriver, 22 | ) 23 | 24 | VRNETLAB_MODE = bool(os.environ.get("SCRAPLI_VRNETLAB", False)) 25 | USERNAME = "boxen" if not VRNETLAB_MODE else "vrnetlab" 26 | PASSWORD = "b0x3N-b0x3N" if not VRNETLAB_MODE else "VR-netlab9" 27 | 28 | DEVICES = { 29 | "cisco_iosxe": { 30 | "driver": IOSXEDriver, 31 | "async_driver": AsyncIOSXEDriver, 32 | "auth_username": USERNAME, 33 | "auth_password": PASSWORD, 34 | "auth_secondary": PASSWORD, 35 | "auth_strict_key": False, 36 | "host": "localhost" if not VRNETLAB_MODE else "172.18.0.11", 37 | "port": 21022 if not VRNETLAB_MODE else 22, 38 | }, 39 | "cisco_nxos": { 40 | "driver": NXOSDriver, 41 | "async_driver": AsyncNXOSDriver, 42 | "auth_username": USERNAME, 43 | "auth_password": PASSWORD, 44 | "auth_secondary": PASSWORD, 45 | "auth_strict_key": False, 46 | "host": "localhost" if not VRNETLAB_MODE else "172.18.0.12", 47 | "port": 22022 if not VRNETLAB_MODE else 22, 48 | }, 49 | "cisco_iosxr": { 50 | "driver": IOSXRDriver, 51 | "async_driver": AsyncIOSXRDriver, 52 | "auth_username": USERNAME, 53 | "auth_password": PASSWORD, 54 | "auth_secondary": PASSWORD, 55 | "auth_strict_key": False, 56 | "host": "localhost" if not VRNETLAB_MODE else "172.18.0.13", 57 | "port": 23022 if not VRNETLAB_MODE else 22, 58 | }, 59 | "arista_eos": { 60 | "driver": EOSDriver, 61 | "async_driver": AsyncEOSDriver, 62 | "auth_username": USERNAME, 63 | "auth_password": PASSWORD, 64 | "auth_secondary": PASSWORD, 65 | "auth_strict_key": False, 66 | "host": "localhost" if not VRNETLAB_MODE else "172.18.0.14", 67 | "port": 24022 if not VRNETLAB_MODE else 22, 68 | }, 69 | "juniper_junos": { 70 | "driver": JunosDriver, 71 | "async_driver": AsyncJunosDriver, 72 | "auth_username": USERNAME, 73 | "auth_password": PASSWORD, 74 | "auth_secondary": PASSWORD, 75 | "auth_strict_key": False, 76 | "host": "localhost" if not VRNETLAB_MODE else "172.18.0.15", 77 | "port": 25022 if not VRNETLAB_MODE else 22, 78 | }, 79 | } 80 | 81 | CONFIG_REPLACER = { 82 | "cisco_iosxe": cisco_iosxe_clean_response, 83 | "cisco_nxos": cisco_nxos_clean_response, 84 | "cisco_iosxr": cisco_iosxr_clean_response, 85 | "arista_eos": arista_eos_clean_response, 86 | "juniper_junos": juniper_junos_clean_response, 87 | } 88 | -------------------------------------------------------------------------------- /docs/api_docs/platform/core/cisco_iosxr/patterns.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.platform.core.cisco_iosxr.patterns 22 | 23 | scrapli_cfg.platform.core.cisco_iosxr.patterns 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
30 |         
31 | """scrapli_cfg.platform.core.cisco_iosxr.patterns"""
32 | import re
33 | 
34 | VERSION_PATTERN = re.compile(pattern=r"\d+\.\d+\.\d+", flags=re.I)
35 | BANNER_PATTERN = re.compile(
36 |     pattern=r"(^banner\s(?:exec|incoming|login|motd|prompt-timeout|slip-ppp)\s"
37 |     r"(?P.{1}).*(?P=delim)$)",
38 |     flags=re.I | re.M | re.S,
39 | )
40 | 
41 | TIMESTAMP_PATTERN = datetime_pattern = re.compile(
42 |     r"^(mon|tue|wed|thu|fri|sat|sun)\s+"
43 |     r"(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+"
44 |     r"\d+\s+\d+:\d+:\d+((\.\d+\s\w+)|\s\d+)$",
45 |     flags=re.M | re.I,
46 | )
47 | BUILD_CONFIG_PATTERN = re.compile(r"(^building configuration\.\.\.$)", flags=re.I | re.M)
48 | CONFIG_VERSION_PATTERN = re.compile(r"(^!! ios xr.*$)", flags=re.I | re.M)
49 | CONFIG_CHANGE_PATTERN = re.compile(r"(^!! last config.*$)", flags=re.I | re.M)
50 | OUTPUT_HEADER_PATTERN = re.compile(
51 |     pattern=rf"{TIMESTAMP_PATTERN.pattern}|"
52 |     rf"{BUILD_CONFIG_PATTERN.pattern}|"
53 |     rf"{CONFIG_VERSION_PATTERN.pattern}|"
54 |     rf"{CONFIG_CHANGE_PATTERN.pattern}",
55 |     flags=re.I | re.M,
56 | )
57 | 
58 | END_PATTERN = re.compile(pattern="end$")
59 | 
60 | # pre-canned config section grabber patterns
61 | 
62 | # match all ethernet interfaces w/ or w/out config items below them
63 | IOSXR_INTERFACES_PATTERN = r"(?:Ethernet|GigabitEthernet|TenGigE|HundredGigE)"
64 | ETHERNET_INTERFACES = re.compile(
65 |     pattern=rf"(^interface {IOSXR_INTERFACES_PATTERN}(?:\d|\/)+$(?:\n^\s{1}.*$)*\n!\n)+",
66 |     flags=re.I | re.M,
67 | )
68 | # match mgmteth[numbers, letters, forward slashes] interface and config items below it
69 | MANAGEMENT_ONE_INTERFACE = re.compile(
70 |     pattern=r"^^interface mgmteth(?:[a-z0-9\/]+)(?:\n^\s.*$)*\n!", flags=re.I | re.M
71 | )
72 |         
73 |     
74 |
-------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Supported Versions](https://img.shields.io/pypi/pyversions/scrapli_cfg.svg)](https://pypi.org/project/scrapli_cfg) 2 | [![PyPI version](https://badge.fury.io/py/scrapli-cfg.svg)](https://badge.fury.io/py/scrapli-cfg) 3 | [![Weekly Build](https://github.com/scrapli/scrapli_cfg/workflows/Weekly%20Build/badge.svg)](https://github.com/scrapli/scrapli_cfg/actions?query=workflow%3A%22Weekly+Build%22) 4 | [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://opensource.org/licenses/MIT) 6 | 7 | scrapli_cfg 8 | =========== 9 | 10 | --- 11 | 12 | **Documentation**: https://scrapli.github.io/scrapli_cfg 13 | 14 | **Source Code**: https://github.com/scrapli/scrapli_cfg 15 | 16 | **Examples**: https://github.com/scrapli/scrapli_cfg/tree/master/examples 17 | 18 | --- 19 | 20 | scrapli_cfg makes merging or replacing device configurations over Telnet or SSH easy, all while giving you the 21 | scrapli behaviour you know and love. 22 | 23 | 24 | #### Key Features: 25 | 26 | - __Easy__: It's easy to get going with scrapli and scrapli-cfg -- check out the documentation and example links above, 27 | and you'll be managing device configurations in no time. 28 | - __Fast__: Do you like to go fast? Of course you do! All of scrapli is built with speed in mind, but if you really 29 | feel the need for speed, check out the `ssh2` transport plugin to take it to the next level! All the "normal" 30 | scrapli transport plugin goodness exists here in scrapli-cfg too! 31 | - __Great Developer Experience__: scrapli_cfg has great editor support thanks to being fully typed; that plus 32 | thorough docs make developing with scrapli a breeze. 33 | 34 | 35 | ## Requirements 36 | 37 | MacOS or \*nix1, Python 3.7+ 38 | 39 | scrapli_cfg's only requirements is `scrapli`. 40 | 41 | 1 Although many parts of scrapli *do* run on Windows, Windows is not officially supported 42 | 43 | 44 | ## Installation 45 | 46 | ``` 47 | pip install scrapli-cfg 48 | ``` 49 | 50 | See the [docs](https://scrapli.github.io/scrapli_cfg/user_guide/installation) for other installation methods/details. 51 | 52 | 53 | 54 | ## A simple Example 55 | 56 | ```python 57 | from scrapli import Scrapli 58 | from scrapli_cfg import ScrapliCfg 59 | 60 | device = { 61 | "host": "172.18.0.11", 62 | "auth_username": "scrapli", 63 | "auth_password": "scrapli", 64 | "auth_strict_key": False, 65 | "platform": "cisco_iosxe" 66 | } 67 | 68 | with open("myconfig", "r") as f: 69 | my_config = f.read() 70 | 71 | with Scrapli(**device) as conn: 72 | cfg_conn = ScrapliCfg(conn=conn) 73 | cfg_conn.prepare() 74 | cfg_conn.load_config(config=my_config, replace=True) 75 | diff = cfg_conn.diff_config() 76 | print(diff.side_by_side_diff) 77 | cfg_conn.commit_config() 78 | cfg_conn.cleanup() 79 | 80 | ``` 81 | -------------------------------------------------------------------------------- /examples/selective_config_replace/eos_selective_config_replace.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.examples.basic_usage.eos_selective_config_replace""" 2 | 3 | from scrapli import Scrapli 4 | from scrapli_cfg.platform.core.arista_eos import ScrapliCfgEOS 5 | from scrapli_cfg.platform.core.arista_eos.patterns import ETHERNET_INTERFACES 6 | 7 | DEVICE = { 8 | "host": "172.18.0.14", 9 | "auth_username": "vrnetlab", 10 | "auth_password": "VR-netlab9", 11 | "auth_secondary": "VR-netlab9", 12 | "auth_strict_key": False, 13 | "platform": "arista_eos", 14 | } 15 | 16 | 17 | def main(): 18 | """Demo basic selective config replace functionality""" 19 | 20 | # load up a config to use for the candidate config; this config is a bit different though! take 21 | # a look and you'll see that there are no ethernet interfaces! how can we do a config replace 22 | # if we have no configuration on any of our ethernet ports?!?! Easy! We can just drop a flag 23 | # in the configuration that looks an awful lot like a jinja2 variable, in this case: 24 | # "{{ ethernet_interfaces }}" -- all we need to do is tell scrapli_cfg how to match (from the 25 | # actual running config) what *should* go in this section (see the next comment section!) 26 | with open("config", "r", encoding="utf-8") as f: 27 | my_config = f.read() 28 | 29 | # in this example we have a `dedicated_connection` which means that the scrapli connection is 30 | # just for scrapli-cfg, when setting `dedicated_connection` to True scrapli cfg will auto open 31 | # and close the scrapli connection when using a context manager -- or when calling `prepare` 32 | # and `cleanup`. 33 | with ScrapliCfgEOS(conn=Scrapli(**DEVICE), dedicated_connection=True) as cfg_conn: 34 | # the scrapli cfg `render_substituted_config` method accepts a template config, and a list 35 | # of "substitutes" -- these substitutes are a tuple of the "tag" that needs to be replaced 36 | # with output from the real device, and a regex pattern that "pulls" this section from the 37 | # actual device itself. In other words; the "{{ ethernet_interfaces }}" section will be 38 | # replaced with whatever the provided pattern finds from the real device. clearly the trick 39 | # here is being good with regex so you can properly snag the section from the real device. 40 | # In this case there are a handful of already built "patterns" in scrapli cfg you can use, 41 | # like this ETHERNET_INTERFACES pattern that matches all ethernet interfaces on a device 42 | # (on a veos at least!) NOTE: this will likely have some changes soon, this is the alpha 43 | # testing of this! 44 | rendered_config = cfg_conn.render_substituted_config( 45 | config_template=my_config, substitutes=[("ethernet_interfaces", ETHERNET_INTERFACES)] 46 | ) 47 | cfg_conn.load_config(config=rendered_config, replace=True) 48 | 49 | diff = cfg_conn.diff_config() 50 | print(diff.device_diff) 51 | print(diff.side_by_side_diff) 52 | print(diff.side_by_side_diff) 53 | 54 | cfg_conn.commit_config() 55 | 56 | 57 | if __name__ == "__main__": 58 | main() 59 | -------------------------------------------------------------------------------- /tests/unit/platform/core/juniper_junos/test_juniper_junos_base_platform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli_cfg.exceptions import FailedToFetchSpaceAvailable, InsufficientSpaceAvailable 4 | from scrapli_cfg.response import ScrapliCfgResponse 5 | 6 | CONFIG_PAYLOAD = """## Last commit: 2021-03-07 18:30:28 UTC by vrnetlab 7 | version 17.3R2.10; 8 | system { 9 | """ 10 | 11 | FLASH_BYTES_OUTPUT = " 1950670848 bytes free" 12 | JUNOS_SHOW_VERSION_OUTPUT = """Junos: 17.3R2.10 13 | """ 14 | 15 | 16 | def test_parse_version_success(junos_base_cfg_object): 17 | actual_version_string = junos_base_cfg_object._parse_version( 18 | device_output=JUNOS_SHOW_VERSION_OUTPUT 19 | ) 20 | assert actual_version_string == "17.3R2.10" 21 | 22 | 23 | def test_parse_version_no_match(junos_base_cfg_object): 24 | actual_version_string = junos_base_cfg_object._parse_version(device_output="blah") 25 | assert actual_version_string == "" 26 | 27 | 28 | def test_reset_config_session(junos_base_cfg_object, dummy_logger): 29 | junos_base_cfg_object.logger = dummy_logger 30 | junos_base_cfg_object.candidate_config_filename = "BLAH" 31 | junos_base_cfg_object.candidate_config = "SOMECONFIG" 32 | junos_base_cfg_object._in_configuration_session = True 33 | junos_base_cfg_object._set = True 34 | 35 | junos_base_cfg_object._reset_config_session() 36 | 37 | assert junos_base_cfg_object.candidate_config_filename == "" 38 | assert junos_base_cfg_object.candidate_config == "" 39 | assert junos_base_cfg_object._in_configuration_session is False 40 | assert junos_base_cfg_object._set is False 41 | 42 | 43 | def test_prepare_config_payloads(junos_base_cfg_object): 44 | junos_base_cfg_object.filesystem = "/config/" 45 | junos_base_cfg_object.candidate_config_filename = "scrapli_cfg_candidate" 46 | actual_config = junos_base_cfg_object._prepare_config_payloads( 47 | config="interface fxp0\n description tacocat" 48 | ) 49 | assert ( 50 | actual_config 51 | == """echo >> /config/scrapli_cfg_candidate 'interface fxp0'\necho >> /config/scrapli_cfg_candidate ' description tacocat'""" 52 | ) 53 | 54 | 55 | def test_prepare_load_config(junos_base_cfg_object, dummy_logger): 56 | junos_base_cfg_object.logger = dummy_logger 57 | junos_base_cfg_object.candidate_config_filename = "" 58 | junos_base_cfg_object.filesystem = "/config/" 59 | actual_config = junos_base_cfg_object._prepare_load_config( 60 | config="interface fxp0\n description tacocat", replace=True 61 | ) 62 | assert junos_base_cfg_object.candidate_config == "interface fxp0\n description tacocat" 63 | assert junos_base_cfg_object._replace is True 64 | # dont wanna deal w/ finding the timestamp stuff, so we'll just make sure the rest of the actual 65 | # config is what we think it shoudl be 66 | assert actual_config.startswith("""echo >> /config/scrapli_cfg_""") 67 | assert actual_config.endswith("""description tacocat'""") 68 | 69 | 70 | def test_clean_config(junos_base_cfg_object, dummy_logger): 71 | junos_base_cfg_object.logger = dummy_logger 72 | junos_base_cfg_object.candidate_config = CONFIG_PAYLOAD 73 | 74 | actual_config = junos_base_cfg_object.clean_config(config=CONFIG_PAYLOAD) 75 | 76 | assert actual_config == "system {" 77 | -------------------------------------------------------------------------------- /tests/test_data/expected/juniper_junos: -------------------------------------------------------------------------------- 1 | ## Last commit: 2021-03-07 19:15:24 UTC by boxen 2 | version 17.3R2.10; 3 | system { 4 | root-authentication { 5 | encrypted-password "$6$Adf5A57s$5Iu/Tdzi/qQuSA.9tV.przrz51avKm.6TvJMBF42zODEM/d7.cvk2Y/kIeLinV5R.HGEEOOD6wane6Xs6rbfT0"; ## SECRET-DATA 6 | } 7 | login { 8 | user boxen { 9 | uid 2000; 10 | class super-user; 11 | authentication { 12 | encrypted-password "$6$SDiXXE9D$KTq4es.BGkdh5IlSxbDhCFn8yWkaFxsjEhmbi4cd53hc9aNfOVmtL1iJiQKEUkgQOdYRvyJetjzygVsVR3CCf."; ## SECRET-DATA 13 | } 14 | } 15 | } 16 | services { 17 | ssh { 18 | protocol-version v2; 19 | } 20 | telnet; 21 | netconf { 22 | ssh; 23 | } 24 | web-management { 25 | http { 26 | interface fxp0.0; 27 | } 28 | } 29 | } 30 | syslog { 31 | user * { 32 | any emergency; 33 | } 34 | file messages { 35 | any any; 36 | authorization info; 37 | } 38 | file interactive-commands { 39 | interactive-commands any; 40 | } 41 | } 42 | license { 43 | autoupdate { 44 | url https://ae1.juniper.net/junos/key_retrieval; 45 | } 46 | } 47 | } 48 | security { 49 | screen { 50 | ids-option untrust-screen { 51 | icmp { 52 | ping-death; 53 | } 54 | ip { 55 | source-route-option; 56 | tear-drop; 57 | } 58 | tcp { 59 | syn-flood { 60 | alarm-threshold 1024; 61 | attack-threshold 200; 62 | source-threshold 1024; 63 | destination-threshold 2048; 64 | queue-size 2000; ## Warning: 'queue-size' is deprecated 65 | timeout 20; 66 | } 67 | land; 68 | } 69 | } 70 | } 71 | policies { 72 | from-zone trust to-zone trust { 73 | policy default-permit { 74 | match { 75 | source-address any; 76 | destination-address any; 77 | application any; 78 | } 79 | then { 80 | permit; 81 | } 82 | } 83 | } 84 | from-zone trust to-zone untrust { 85 | policy default-permit { 86 | match { 87 | source-address any; 88 | destination-address any; 89 | application any; 90 | } 91 | then { 92 | permit; 93 | } 94 | } 95 | } 96 | } 97 | zones { 98 | security-zone trust { 99 | tcp-rst; 100 | } 101 | security-zone untrust { 102 | screen untrust-screen; 103 | } 104 | } 105 | } 106 | interfaces { 107 | fxp0 { 108 | unit 0 { 109 | family inet { 110 | address 10.0.0.15/24; 111 | } 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /docs/api_docs/logging.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | #Module scrapli_cfg.logging 22 | 23 | scrapli_cfg.logging 24 | 25 |
26 | 27 | Expand source code 28 | 29 |
 30 |         
 31 | """scrapli_cfg.logging"""
 32 | from logging import FileHandler, NullHandler, getLogger
 33 | from typing import Union
 34 | 
 35 | from scrapli.logging import ScrapliFileHandler, ScrapliFormatter
 36 | 
 37 | 
 38 | def enable_basic_logging(
 39 |     file: Union[str, bool] = False,
 40 |     level: str = "info",
 41 |     caller_info: bool = False,
 42 |     buffer_log: bool = True,
 43 | ) -> None:
 44 |     """
 45 |     Enable opinionated logging for scrapli_cfg
 46 | 
 47 |     Uses scrapli "core" formatter/file handler
 48 | 
 49 |     Args:
 50 |         file: True to output to default log path ("scrapli.log"), otherwise string path to write log
 51 |             file to
 52 |         level: string name of logging level to use, i.e. "info", "debug", etc.
 53 |         caller_info: add info about module/function/line in the log entry
 54 |         buffer_log: buffer log read outputs
 55 | 
 56 |     Returns:
 57 |         None
 58 | 
 59 |     Raises:
 60 |         N/A
 61 | 
 62 |     """
 63 |     logger.propagate = False
 64 |     logger.setLevel(level=level.upper())
 65 | 
 66 |     scrapli_formatter = ScrapliFormatter(caller_info=caller_info)
 67 | 
 68 |     if file:
 69 |         if isinstance(file, bool):
 70 |             filename = "scrapli_cfg.log"
 71 |         else:
 72 |             filename = file
 73 | 
 74 |         if not buffer_log:
 75 |             fh = FileHandler(filename=filename, mode="w")
 76 |         else:
 77 |             fh = ScrapliFileHandler(filename=filename, mode="w")
 78 | 
 79 |         fh.setFormatter(scrapli_formatter)
 80 | 
 81 |         logger.addHandler(fh)
 82 | 
 83 | 
 84 | logger = getLogger("scrapli_cfg")
 85 | logger.addHandler(NullHandler())
 86 |         
 87 |     
88 |
89 | 90 | 91 | 92 | ## Functions 93 | 94 | 95 | 96 | #### enable_basic_logging 97 | `enable_basic_logging(file: Union[str, bool] = False, level: str = 'info', caller_info: bool = False, buffer_log: bool = True) ‑> None` 98 | 99 | ```text 100 | Enable opinionated logging for scrapli_cfg 101 | 102 | Uses scrapli "core" formatter/file handler 103 | 104 | Args: 105 | file: True to output to default log path ("scrapli.log"), otherwise string path to write log 106 | file to 107 | level: string name of logging level to use, i.e. "info", "debug", etc. 108 | caller_info: add info about module/function/line in the log entry 109 | buffer_log: buffer log read outputs 110 | 111 | Returns: 112 | None 113 | 114 | Raises: 115 | N/A 116 | ``` -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli import AsyncScrapli, Scrapli 4 | from scrapli_cfg import AsyncScrapliCfg, ScrapliCfg 5 | from scrapli_cfg.diff import ScrapliCfgDiffResponse 6 | from scrapli_cfg.platform.base.sync_platform import ScrapliCfgBase 7 | from scrapli_cfg.platform.core.arista_eos.base_platform import ScrapliCfgEOSBase 8 | from scrapli_cfg.platform.core.cisco_iosxe.base_platform import ScrapliCfgIOSXEBase 9 | from scrapli_cfg.platform.core.cisco_iosxr.base_platform import ScrapliCfgIOSXRBase 10 | from scrapli_cfg.platform.core.cisco_nxos.base_platform import ScrapliCfgNXOSBase 11 | from scrapli_cfg.platform.core.juniper_junos.base_platform import ScrapliCfgJunosBase 12 | from scrapli_cfg.response import ScrapliCfgResponse 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def diff_obj(): 17 | # setting width to 118 as thats what my console was for during testing, but of course if the 18 | # width is different for github actions or whoever this could change 19 | return ScrapliCfgDiffResponse(host="localhost", source="running", side_by_side_diff_width=118) 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | def response_obj(): 24 | return ScrapliCfgResponse(host="localhost") 25 | 26 | 27 | @pytest.fixture(scope="session", params=["cisco_iosxe", "cisco_iosxr", "cisco_nxos", "arista_eos"]) 28 | def sync_scrapli_conn(request): 29 | return Scrapli(host="localhost", platform=request.param) 30 | 31 | 32 | @pytest.fixture(scope="session", params=["cisco_iosxe", "cisco_iosxr", "cisco_nxos", "arista_eos"]) 33 | def async_scrapli_conn(request): 34 | return AsyncScrapli(host="localhost", platform=request.param, transport="asyncssh") 35 | 36 | 37 | @pytest.fixture(scope="function") 38 | def base_cfg_object(): 39 | base = ScrapliCfgBase 40 | base.conn = Scrapli(host="localhost", platform="cisco_iosxe") 41 | cfg_conn = ScrapliCfgBase(config_sources=["running", "startup"]) 42 | cfg_conn.on_prepare = None 43 | cfg_conn.ignore_version = True 44 | return cfg_conn 45 | 46 | 47 | @pytest.fixture(scope="function") 48 | def sync_cfg_object(): 49 | scrapli_conn = Scrapli(host="localhost", platform="cisco_iosxe") 50 | sync_cfg_conn = ScrapliCfg(conn=scrapli_conn, config_sources=["running", "startup"]) 51 | return sync_cfg_conn 52 | 53 | 54 | @pytest.fixture(scope="function") 55 | def async_cfg_object(): 56 | scrapli_conn = AsyncScrapli(host="localhost", platform="cisco_iosxe", transport="asynctelnet") 57 | async_cfg_conn = AsyncScrapliCfg(conn=scrapli_conn, config_sources=["running", "startup"]) 58 | return async_cfg_conn 59 | 60 | 61 | @pytest.fixture(scope="function") 62 | def eos_base_cfg_object(): 63 | cfg_conn = ScrapliCfgEOSBase() 64 | return cfg_conn 65 | 66 | 67 | @pytest.fixture(scope="function") 68 | def iosxe_base_cfg_object(): 69 | cfg_conn = ScrapliCfgIOSXEBase() 70 | return cfg_conn 71 | 72 | 73 | @pytest.fixture(scope="function") 74 | def iosxr_base_cfg_object(): 75 | cfg_conn = ScrapliCfgIOSXRBase() 76 | return cfg_conn 77 | 78 | 79 | @pytest.fixture(scope="function") 80 | def nxos_base_cfg_object(): 81 | cfg_conn = ScrapliCfgNXOSBase() 82 | return cfg_conn 83 | 84 | 85 | @pytest.fixture(scope="function") 86 | def junos_base_cfg_object(): 87 | cfg_conn = ScrapliCfgJunosBase() 88 | return cfg_conn 89 | 90 | 91 | @pytest.fixture(scope="function") 92 | def dummy_logger(): 93 | class Logger: 94 | def info(self, msg): 95 | pass 96 | 97 | def debug(self, msg): 98 | pass 99 | 100 | def critical(self, msg): 101 | pass 102 | 103 | return Logger() 104 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli_cfg import AsyncScrapliCfg, ScrapliCfg 4 | 5 | TIMEOUT_SOCKET = 60 6 | TIMEOUT_TRANSPORT = 60 7 | TIMEOUT_OPS = 60 8 | 9 | TELNET_TRANSPORTS = ( 10 | "telnet", 11 | "asynctelnet", 12 | ) 13 | 14 | 15 | @pytest.fixture( 16 | scope="function", 17 | params=["cisco_iosxe", "cisco_nxos", "cisco_iosxr", "arista_eos", "juniper_junos"], 18 | ) 19 | def device_type(request): 20 | yield request.param 21 | 22 | 23 | @pytest.fixture(scope="function", params=["system", "paramiko", "telnet"]) 24 | def transport(request): 25 | yield request.param 26 | 27 | 28 | @pytest.fixture(scope="function", params=["asyncssh", "asynctelnet"]) 29 | def async_transport(request): 30 | yield request.param 31 | 32 | 33 | @pytest.fixture(scope="function") 34 | def conn(test_devices_dict, device_type, transport): 35 | device = test_devices_dict[device_type].copy() 36 | driver = device.pop("driver") 37 | device.pop("async_driver") 38 | 39 | port = device.pop("port") 40 | if transport in TELNET_TRANSPORTS: 41 | port = port + 1 42 | 43 | conn = driver( 44 | **device, 45 | port=port, 46 | transport=transport, 47 | timeout_socket=TIMEOUT_SOCKET, 48 | timeout_transport=TIMEOUT_TRANSPORT, 49 | timeout_ops=TIMEOUT_OPS, 50 | ) 51 | return conn, device_type 52 | 53 | 54 | @pytest.fixture(scope="function") 55 | def cfg_conn(config_replacer_dict, conn, expected_configs): 56 | scrapli_conn, device_type = conn 57 | cfg_conn = ScrapliCfg(conn=scrapli_conn, dedicated_connection=True) 58 | 59 | cfg_conn._expected_config = expected_configs[device_type] 60 | cfg_conn._config_cleaner = config_replacer_dict[device_type] 61 | cfg_conn._load_config = "interface loopback1\ndescription tacocat" 62 | 63 | if device_type == "juniper_junos": 64 | cfg_conn._load_config = """interfaces { 65 | fxp0 { 66 | unit 0 { 67 | description RACECAR; 68 | } 69 | } 70 | }""" 71 | 72 | yield cfg_conn 73 | if cfg_conn.conn.isalive(): 74 | cfg_conn.cleanup() 75 | 76 | 77 | # scoping to function is probably dumb but dont have to screw around with which event loop is what this way 78 | @pytest.fixture(scope="function") 79 | async def async_conn(test_devices_dict, device_type, async_transport): 80 | device = test_devices_dict[device_type].copy() 81 | driver = device.pop("async_driver") 82 | device.pop("driver") 83 | 84 | port = device.pop("port") 85 | if async_transport in TELNET_TRANSPORTS: 86 | port = port + 1 87 | 88 | async_conn = driver( 89 | **device, 90 | port=port, 91 | transport=async_transport, 92 | timeout_socket=TIMEOUT_SOCKET, 93 | timeout_transport=TIMEOUT_TRANSPORT, 94 | timeout_ops=TIMEOUT_OPS, 95 | ) 96 | return async_conn, device_type 97 | 98 | 99 | @pytest.fixture(scope="function") 100 | async def async_cfg_conn(config_replacer_dict, async_conn, expected_configs): 101 | scrapli_conn, device_type = async_conn 102 | async_cfg_conn = AsyncScrapliCfg(conn=scrapli_conn, dedicated_connection=True) 103 | 104 | async_cfg_conn._expected_config = expected_configs[device_type] 105 | async_cfg_conn._config_cleaner = config_replacer_dict[device_type] 106 | async_cfg_conn._load_config = "interface loopback1\ndescription tacocat" 107 | 108 | if device_type == "juniper_junos": 109 | async_cfg_conn._load_config = """interfaces { 110 | fxp0 { 111 | unit 0 { 112 | description RACECAR; 113 | } 114 | } 115 | }""" 116 | 117 | yield async_cfg_conn 118 | if async_cfg_conn.conn.isalive(): 119 | await async_cfg_conn.cleanup() 120 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools", 5 | "wheel", 6 | ] 7 | 8 | [project] 9 | name = "scrapli_cfg" 10 | description = "Network device configuration management with scrapli" 11 | readme = "README.md" 12 | keywords = [ 13 | "arista", 14 | "automation", 15 | "cisco", 16 | "eos", 17 | "iosxe", 18 | "iosxr", 19 | "juniper", 20 | "junos", 21 | "netconf", 22 | "network", 23 | "nxos", 24 | "ssh", 25 | "telnet", 26 | ] 27 | license = { file = "LICENSE" } 28 | authors = [ 29 | { name = "Carl Montanari", email = "carl.r.montanari@gmail.com" }, 30 | ] 31 | requires-python = ">=3.9" 32 | classifiers = [ 33 | "License :: OSI Approved :: MIT License", 34 | "Operating System :: POSIX :: Linux", 35 | "Operating System :: MacOS", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.9", 38 | "Programming Language :: Python :: 3.10", 39 | "Programming Language :: Python :: 3.11", 40 | "Programming Language :: Python :: 3.12", 41 | "Programming Language :: Python :: 3.13", 42 | "Programming Language :: Python :: 3 :: Only", 43 | "Topic :: Software Development :: Libraries :: Python Modules", 44 | ] 45 | dynamic = [ 46 | "dependencies", 47 | "optional-dependencies", 48 | "version", 49 | ] 50 | [project.urls] 51 | Changelog = "https://scrapli.github.io/scrapli_cfg/changelog/" 52 | Docs = "https://scrapli.github.io/scrapli_cfg/" 53 | Homepage = "https://github.com/scrapli/scrapli_cfg" 54 | 55 | [tool.setuptools.dynamic] 56 | version = { attr = "scrapli_cfg.__version__" } 57 | dependencies = { file = "requirements.txt" } 58 | # dev-darwin is same as dev but excludes ssh2-python 59 | optional-dependencies.dev-darwin = { file = [ 60 | "requirements-dev.txt", 61 | "requirements-textfsm.txt", 62 | "requirements-genie.txt", 63 | "requirements-ttp.txt", 64 | "requirements-paramiko.txt", 65 | "requirements-asyncssh.txt", 66 | "requirements-community.txt", 67 | ] } 68 | optional-dependencies.dev = { file = [ 69 | "requirements-dev.txt", 70 | "requirements-paramiko.txt", 71 | "requirements-ssh2.txt", 72 | "requirements-asyncssh.txt", 73 | ] } 74 | optional-dependencies.docs = { file = "requirements-docs.txt" } 75 | optional-dependencies.paramiko = { file = "requirements-paramiko.txt" } 76 | optional-dependencies.ssh2 = { file = "requirements-ssh2.txt" } 77 | optional-dependencies.asyncssh = { file = "requirements-asyncssh.txt" } 78 | 79 | [tool.setuptools.package-data] 80 | scrapli_cfg = [ 81 | "py.typed" 82 | ] 83 | 84 | [tool.black] 85 | line-length = 100 86 | target-version = [ 87 | "py311", 88 | "py313", 89 | ] 90 | 91 | [tool.isort] 92 | profile = "black" 93 | line_length = 100 94 | multi_line_output = 3 95 | include_trailing_comma = true 96 | known_first_party = "scrapli" 97 | known_third_party = "asyncssh,pytest" 98 | [tool.pytest.ini_options] 99 | asyncio_mode = "auto" 100 | 101 | [tool.coverage.run] 102 | source = [ 103 | "scrapli_cfg/" 104 | ] 105 | 106 | [tool.coverage.report] 107 | sort = "cover" 108 | 109 | [tool.mypy] 110 | python_version = "3.10" 111 | pretty = true 112 | ignore_missing_imports = true 113 | warn_redundant_casts = true 114 | warn_unused_configs = true 115 | strict_optional = true 116 | 117 | 118 | [tool.pydocstyle] 119 | match-dir = "^scrapli_cfg/*" 120 | ignore = "D101,D202,D203,D212,D400,D406,D407,D408,D409,D415" 121 | # D101: missing docstring in public class 122 | # D202: No blank lines allowed after function docstring 123 | # D203: 1 blank line required before class docstring 124 | # D212: Multi-line docstring summary should start at the first line 125 | # D400: First line should end with a period 126 | # D406: Section name should end with a newline 127 | # D407: Missing dashed underline after section 128 | # D408: Section underline should be in the line following the sections name 129 | # D409: Section underline should match the length of its name 130 | # D415: first line should end with a period, question mark, or exclamation point 131 | -------------------------------------------------------------------------------- /docs/user_guide/project_details.md: -------------------------------------------------------------------------------- 1 | # Project Details 2 | 3 | 4 | ## What is scrapli_cfg 5 | 6 | scrapli_cfg is a library that sits "on top" of [scrapli "core"](https://github.com/carlmontanari/scrapli) and 7 | makes merging or replacing device configurations over Telnet or SSH easy. Why over Telnet or SSH? Because you pretty 8 | much will always have one of these options available to you, whereas you may not have eAPI or NETCONF ready and 9 | enabled (think day zero provisioning, or crazy security requirements locking down ports). 10 | 11 | 12 | ### So its like NAPALM? 13 | 14 | If you are familiar with the configuration management abilities of the excellent 15 | [NAPALM library](https://github.com/napalm-automation/napalm) then you are already generally familiar with what 16 | scrapli_cfg is capable of. The primary differences between scrapli_cfg and NAPALM are as follows: 17 | 18 | 1. scrapli_cfg has, and never will (unless I change my mind), have "getters" outside the "get_config" and 19 | "get_version" getters. This means there will not be anything like "get_interfaces" in scrapli_cfg. 20 | 2. scrapli_cfg has no dependency on any APIs being available -- configurations are all handled via Telnet or SSH. 21 | This may sound "bad" because the cli is so "bad", but it means that there are no requirements for additional 22 | ports to be opened or services to be enabled (i.e. eAPI or NETCONF), it even means (with a bit of work to handle 23 | initially logging into a terminal server and getting to the right port) you could use scrapli_cfg to fully manage 24 | device configuration over terminal server connections. 25 | 3. scrapli_cfg has no Python dependencies other than scrapli -- this means there are no vendor libraries necessary, 26 | no eznc, no pyeapi, and no pyiosxr. Fewer dependencies isn't a *huge* deal, but it does mean that the scrapli 27 | community is fully "in control" of all requirements which is pretty nice! 28 | 4. scrapli_cfg, just like "normal" scrapli provides supports both synchronous and asynchronous code with the same API 29 | 5. scrapli_cfg, provides a `render_substituted_config` method that helps you easily merge templated configurations 30 | with real device configuration -- so you can do "full config replace" without having to template out every last 31 | line of config! 32 | 33 | 34 | ## Supported Platforms 35 | 36 | Just like scrapli "core", scrapli_cfg covers the "core" NAPALM platforms -- Cisco IOS-XE, IOS-XR, NX-OS, 37 | Arista EOS, and Juniper JunOS (eventually, no JunOS support just yet). Below are the core driver platforms and 38 | regularly tested version. 39 | 40 | Cisco IOS-XE (tested on: 16.12.03) 41 | Cisco NX-OS (tested on: 9.2.4) 42 | Juniper JunOS (tested on: 17.3R2.10) 43 | Cisco IOS-XR (tested on: 6.5.3) 44 | Arista EOS (tested on: 4.22.1F) 45 | 46 | Specific platform support requirements are listed below. 47 | 48 | 49 | ### Arista EOS 50 | 51 | scrapli_cfg uses configuration sessions in EOS, this feature was added somewhere around the 4.14 release. Early 52 | versions of EOS that support configuration sessions did not allow configuration sessions to be aborted from 53 | privilege exec, the `clear_config_sessions` will not work on these versions, however all other scrapli_cfg features 54 | should work. 55 | 56 | 57 | ### Cisco IOSXE 58 | 59 | IOSXE behavior is very similar to NAPALM, using the archive feature to help with config management and diffs, as 60 | such scrapli_cfg requires IOS versions > 12.4(20)T -- all IOSXE versions *should* be supported (please open an issue 61 | or find me on Slack/Twitter if this is incorrect!). 62 | 63 | 64 | ### Cisco IOSXR 65 | 66 | scrapli_cfg has worked on every IOSXR version that it has been tested on -- due to IOSXR natively supporting 67 | configuration merging/replacing this *should* work on most IOSXR devices. 68 | 69 | 70 | ### Cisco NXOS 71 | 72 | scrapli_cfg *should* work on most versions of NXOS, there is no requirement for NX-API, instead scrapli_cfg simply 73 | relies on the tclsh. 74 | 75 | 76 | ### Juniper JunOS 77 | 78 | scrapli_cfg should work on JunOS too -- though JunOS has likely received the least amount of testing at this point. 79 | -------------------------------------------------------------------------------- /tests/unit/platform/core/cisco_iosxr/test_cisco_iosxr_base_platform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | IOSXE_SHOW_VERSION_OUTPUT = """Sat Mar 6 21:35:16.805 UTC 4 | Cisco IOS XR Software, Version 6.5.3 5 | Copyright (c) 2013-2019 by Cisco Systems, Inc. 6 | 7 | Build Information: 8 | Built By : ahoang 9 | Built On : Tue Mar 26 06:52:25 PDT 2019 10 | Built Host : iox-ucs-019 11 | Workspace : /auto/srcarchive13/prod/6.5.3/xrv9k/ws 12 | Version : 6.5.3 13 | Location : /opt/cisco/XR/packages/ 14 | 15 | cisco IOS-XRv 9000 () processor 16 | System uptime is 5 hours 46 minutes""" 17 | 18 | CONFIG_PAYLOAD = """Sat Mar 6 21:36:58.107 UTC 19 | Building configuration... 20 | !! IOS XR Configuration version = 6.5.3 21 | !! Last configuration change at Thu Mar 4 01:30:30 2021 by vrnetlab 22 | ! 23 | telnet vrf default ipv4 server max-servers 10 24 | banner motd ^ 25 | something in a banner 26 | ^ 27 | end""" 28 | 29 | 30 | def test_parse_version_success(iosxr_base_cfg_object): 31 | actual_version_string = iosxr_base_cfg_object._parse_version( 32 | device_output=IOSXE_SHOW_VERSION_OUTPUT 33 | ) 34 | assert actual_version_string == "6.5.3" 35 | 36 | 37 | def test_parse_version_no_match(iosxr_base_cfg_object): 38 | actual_version_string = iosxr_base_cfg_object._parse_version(device_output="blah") 39 | assert actual_version_string == "" 40 | 41 | 42 | def test_prepare_config_payloads(iosxr_base_cfg_object): 43 | actual_config, actual_eager_config = iosxr_base_cfg_object._prepare_config_payloads( 44 | config=CONFIG_PAYLOAD 45 | ) 46 | assert actual_config == "!\n!\n!\n!\n!\ntelnet vrf default ipv4 server max-servers 10\n!\n!" 47 | assert actual_eager_config == "banner motd ^\nsomething in a banner\n^" 48 | 49 | 50 | def test_prepare_load_config_session_and_payload(iosxr_base_cfg_object): 51 | ( 52 | actual_config, 53 | actual_eager_config, 54 | ) = iosxr_base_cfg_object._prepare_load_config_session_and_payload( 55 | config=CONFIG_PAYLOAD, 56 | replace=True, 57 | exclusive=False, 58 | ) 59 | assert iosxr_base_cfg_object._in_configuration_session is True 60 | assert iosxr_base_cfg_object._config_privilege_level == "configuration" 61 | assert iosxr_base_cfg_object.candidate_config == CONFIG_PAYLOAD 62 | assert actual_config == "!\n!\n!\n!\n!\ntelnet vrf default ipv4 server max-servers 10\n!\n!" 63 | assert actual_eager_config == "banner motd ^\nsomething in a banner\n^" 64 | 65 | 66 | def test_reset_config_session(iosxr_base_cfg_object, dummy_logger): 67 | iosxr_base_cfg_object.logger = dummy_logger 68 | iosxr_base_cfg_object._in_configuration_session = True 69 | iosxr_base_cfg_object._config_privilege_level = "BLAH" 70 | iosxr_base_cfg_object.candidate_config = "SOMECONFIG" 71 | 72 | iosxr_base_cfg_object._reset_config_session() 73 | 74 | iosxr_base_cfg_object._in_configuration_session = False 75 | iosxr_base_cfg_object._config_privilege_level = "" 76 | iosxr_base_cfg_object.candidate_config = "" 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "test_data", 81 | ( 82 | ( 83 | True, 84 | "show configuration changes diff", 85 | ), 86 | ( 87 | False, 88 | "show commit changes diff", 89 | ), 90 | ), 91 | ids=( 92 | "replace", 93 | "merge", 94 | ), 95 | ) 96 | def test_get_diff_command(iosxr_base_cfg_object, test_data): 97 | replace, expected_command = test_data 98 | iosxr_base_cfg_object._replace = replace 99 | assert iosxr_base_cfg_object._get_diff_command() == expected_command 100 | 101 | 102 | def test_clean_config(iosxr_base_cfg_object, dummy_logger): 103 | iosxr_base_cfg_object.logger = dummy_logger 104 | 105 | iosxr_base_cfg_object.candidate_config = CONFIG_PAYLOAD 106 | actual_config = iosxr_base_cfg_object.clean_config(config=CONFIG_PAYLOAD) 107 | 108 | assert ( 109 | actual_config 110 | == "!\ntelnet vrf default ipv4 server max-servers 10\nbanner motd ^\nsomething in a banner\n^\nend" 111 | ) 112 | -------------------------------------------------------------------------------- /tests/unit/platform/base/test_sync_platform.py: -------------------------------------------------------------------------------- 1 | from scrapli_cfg.response import ScrapliCfgResponse 2 | 3 | 4 | def test_open(sync_cfg_object, monkeypatch): 5 | open_called = False 6 | get_version_called = False 7 | validate_and_set_version_called = False 8 | on_prepare_called = False 9 | 10 | # just going to mock things so we know that when open is called these functions get executed, 11 | # this test isn't for testing those other functions, we'll do that elsewhere 12 | 13 | def _open(cls): 14 | nonlocal open_called 15 | open_called = True 16 | 17 | def _get_version(cls): 18 | nonlocal get_version_called 19 | get_version_called = True 20 | 21 | def _validate_and_set_version(cls, version_response): 22 | nonlocal validate_and_set_version_called 23 | validate_and_set_version_called = True 24 | 25 | def _on_prepare(cls): 26 | nonlocal on_prepare_called 27 | on_prepare_called = True 28 | 29 | monkeypatch.setattr("scrapli.driver.base.sync_driver.Driver.open", _open) 30 | monkeypatch.setattr( 31 | "scrapli_cfg.platform.core.cisco_iosxe.sync_platform.ScrapliCfgIOSXE.get_version", 32 | _get_version, 33 | ) 34 | monkeypatch.setattr( 35 | "scrapli_cfg.platform.core.cisco_iosxe.sync_platform.ScrapliCfgIOSXE._validate_and_set_version", 36 | _validate_and_set_version, 37 | ) 38 | 39 | sync_cfg_object.dedicated_connection = True 40 | sync_cfg_object.on_prepare = _on_prepare 41 | sync_cfg_object.prepare() 42 | 43 | assert open_called is True 44 | assert get_version_called is True 45 | assert validate_and_set_version_called is True 46 | assert on_prepare_called is True 47 | 48 | 49 | def test_close(sync_cfg_object, monkeypatch): 50 | close_called = False 51 | 52 | # just going to mock things so we know that when open is called these functions get executed, 53 | # this test isn't for testing those other functions, we'll do that elsewhere 54 | 55 | def _close(cls): 56 | nonlocal close_called 57 | close_called = True 58 | 59 | monkeypatch.setattr("scrapli.driver.base.sync_driver.Driver.close", _close) 60 | # lie and pretend its alive so we actually run close 61 | monkeypatch.setattr("scrapli.driver.base.sync_driver.Driver.isalive", lambda cls: True) 62 | 63 | sync_cfg_object.dedicated_connection = True 64 | sync_cfg_object.cleanup() 65 | 66 | assert close_called is True 67 | 68 | 69 | def test_context_manager(monkeypatch, sync_cfg_object): 70 | """Asserts context manager properly opens/closes""" 71 | open_called = False 72 | close_called = False 73 | 74 | def _prepare(cls): 75 | nonlocal open_called 76 | open_called = True 77 | 78 | def _cleanup(cls): 79 | nonlocal close_called 80 | close_called = True 81 | 82 | monkeypatch.setattr( 83 | "scrapli_cfg.platform.base.sync_platform.ScrapliCfgPlatform.prepare", _prepare 84 | ) 85 | monkeypatch.setattr( 86 | "scrapli_cfg.platform.base.sync_platform.ScrapliCfgPlatform.cleanup", _cleanup 87 | ) 88 | 89 | with sync_cfg_object: 90 | pass 91 | 92 | assert open_called is True 93 | assert close_called is True 94 | 95 | 96 | def test_render_substituted_config(monkeypatch, sync_cfg_object): 97 | """Asserts context manager properly opens/closes""" 98 | get_config_called = False 99 | 100 | def _get_config(cls, source): 101 | nonlocal get_config_called 102 | get_config_called = True 103 | response = ScrapliCfgResponse(host="localhost") 104 | response.result = "blah\nmatchthisline\nanotherblah" 105 | return response 106 | 107 | monkeypatch.setattr( 108 | "scrapli_cfg.platform.core.cisco_iosxe.sync_platform.ScrapliCfgIOSXE.get_config", 109 | _get_config, 110 | ) 111 | 112 | rendered_config = sync_cfg_object.render_substituted_config( 113 | config_template="something\n{{ taco }}\nsomethingelse", 114 | substitutes=[("taco", "matchthisline")], 115 | ) 116 | assert rendered_config == "something\nmatchthisline\nsomethingelse" 117 | -------------------------------------------------------------------------------- /examples/selective_config_replace/config: -------------------------------------------------------------------------------- 1 | ! Command: show running-config 2 | ! device: localhost (vEOS, EOS-4.22.1F) 3 | ! 4 | ! boot system flash:/vEOS-lab.swi 5 | ! 6 | switchport default mode routed 7 | ! 8 | transceiver qsfp default-mode 4x10G 9 | ! 10 | logging console informational 11 | ! 12 | logging level AAA errors 13 | logging level ACCOUNTING errors 14 | logging level ACL errors 15 | logging level AGENT errors 16 | logging level ALE errors 17 | logging level ARP errors 18 | logging level BFD errors 19 | logging level BGP errors 20 | logging level BMP errors 21 | logging level CAPACITY errors 22 | logging level CAPI errors 23 | logging level CLEAR errors 24 | logging level CVX errors 25 | logging level DATAPLANE errors 26 | logging level DHCP errors 27 | logging level DOT1X errors 28 | logging level DSCP errors 29 | logging level ENVMON errors 30 | logging level ETH errors 31 | logging level EVENTMON errors 32 | logging level EXTENSION errors 33 | logging level FHRP errors 34 | logging level FLOW errors 35 | logging level FORWARDING errors 36 | logging level FRU errors 37 | logging level FWK errors 38 | logging level GMP errors 39 | logging level HARDWARE errors 40 | logging level HEALTH errors 41 | logging level HTTPSERVICE errors 42 | logging level IGMP errors 43 | logging level IGMPSNOOPING errors 44 | logging level INT errors 45 | logging level INTF errors 46 | logging level IP6ROUTING errors 47 | logging level IPRIB errors 48 | logging level IRA errors 49 | logging level ISIS errors 50 | logging level KERNELFIB errors 51 | logging level LACP errors 52 | logging level LAG errors 53 | logging level LAUNCHER errors 54 | logging level LDP errors 55 | logging level LICENSE errors 56 | logging level LINEPROTO errors 57 | logging level LLDP errors 58 | logging level LOGMGR errors 59 | logging level LOOPBACK errors 60 | logging level LOOPPROTECT errors 61 | logging level MAPREDUCEMONITOR errors 62 | logging level MIRRORING errors 63 | logging level MKA errors 64 | logging level MLAG errors 65 | logging level MMODE errors 66 | logging level MROUTE errors 67 | logging level MRP errors 68 | logging level MSDP errors 69 | logging level MSRP errors 70 | logging level MSSPOLICYMONITOR errors 71 | logging level MVRP errors 72 | logging level NAT errors 73 | logging level OPENCONFIG errors 74 | logging level OPENFLOW errors 75 | logging level OSPF errors 76 | logging level OSPF3 errors 77 | logging level PACKAGE errors 78 | logging level PFC errors 79 | logging level PIMBSR errors 80 | logging level PORTSECURITY errors 81 | logging level PTP errors 82 | logging level PWRMGMT errors 83 | logging level QOS errors 84 | logging level QUEUEMONITOR errors 85 | logging level REDUNDANCY errors 86 | logging level RIB errors 87 | logging level ROUTING errors 88 | logging level SECURITY errors 89 | logging level SERVERMONITOR errors 90 | logging level SPANTREE errors 91 | logging level SSO errors 92 | logging level STAGEMGR errors 93 | logging level SYS errors 94 | logging level SYSDB errors 95 | logging level TAPAGG errors 96 | logging level TCP errors 97 | logging level TRANSCEIVER errors 98 | logging level TUNNEL errors 99 | logging level TUNNELINTF errors 100 | logging level VMTRACERSESS errors 101 | logging level VMWAREVI errors 102 | logging level VMWAREVS errors 103 | logging level VRF errors 104 | logging level VRRP errors 105 | logging level VXLAN errors 106 | logging level XMPP errors 107 | logging level ZTP informational 108 | ! 109 | spanning-tree mode mstp 110 | ! 111 | enable secret sha512 $6$P1M9SV2bLTmQJpwW$KVoaaIa7i34uTFp7JRRp.hqL55nr7jSJJiDA.9CHTCW7q4GDIwyceMMSp6TavgYiAokjobyBYCO70L7FxpZon1 112 | no aaa root 113 | ! 114 | username vrnetlab role network-admin secret sha512 $6$8zrJ4ESW2fqG2QqH$9u768TvLXXDeUJmG2Std71EX1ip6q4MoJrMwDng1cmpuSYc9ECWytRjvXpMH7C3dzSdoEv0MxAUiAZeeTre3h. 115 | ! 116 | {{ ethernet_interfaces }} 117 | interface Management1 118 | ip address 10.0.0.15/24 119 | ipv6 enable 120 | ipv6 address auto-config 121 | ipv6 nd ra rx accept default-route 122 | ! 123 | no ip routing 124 | ! 125 | control-plane 126 | no service-policy input copp-system-policy 127 | ! 128 | banner login 129 | new banner because why not! 130 | EOF 131 | ! 132 | management api http-commands 133 | protocol unix-socket 134 | no shutdown 135 | ! 136 | management telnet 137 | no shutdown 138 | ! 139 | end -------------------------------------------------------------------------------- /scrapli_cfg/response.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.response""" 2 | 3 | from datetime import datetime 4 | from typing import Iterable, List, Optional, Type, Union 5 | 6 | from scrapli.response import MultiResponse, Response 7 | from scrapli_cfg.exceptions import ScrapliCfgException 8 | 9 | 10 | class ScrapliCfgResponse: 11 | def __init__( 12 | self, host: str, raise_for_status_exception: Type[Exception] = ScrapliCfgException 13 | ) -> None: 14 | """ 15 | Scrapli CFG Response object 16 | 17 | Args: 18 | host: host that was operated on 19 | raise_for_status_exception: exception to raise if response is failed and user calls 20 | `raise_for_status` 21 | 22 | Returns: 23 | N/A 24 | 25 | Raises: 26 | N/A 27 | 28 | """ 29 | self.host = host 30 | self.start_time = datetime.now() 31 | self.finish_time: Optional[datetime] = None 32 | self.elapsed_time: Optional[float] = None 33 | 34 | # scrapli_responses is a "flattened" list of responses from all operations that were 35 | # performed; meaning that if we used any plural operations like send_commands we'll flatten 36 | # the MultiResponse bits into a list of singular response objects and store them here 37 | self.scrapli_responses: List[Response] = [] 38 | self.result: str = "" 39 | 40 | self.raise_for_status_exception = raise_for_status_exception 41 | self.failed = True 42 | 43 | def __bool__(self) -> bool: 44 | """ 45 | Magic bool method based on operation being failed or not 46 | 47 | Args: 48 | N/A 49 | 50 | Returns: 51 | bool: True/False if channel_input failed 52 | 53 | Raises: 54 | N/A 55 | 56 | """ 57 | return self.failed 58 | 59 | def __repr__(self) -> str: 60 | """ 61 | Magic repr method for ScrapliCfgResponse class 62 | 63 | Args: 64 | N/A 65 | 66 | Returns: 67 | str: repr for class object 68 | 69 | Raises: 70 | N/A 71 | 72 | """ 73 | return f"ScrapliCfgResponse " 74 | 75 | def __str__(self) -> str: 76 | """ 77 | Magic str method for ScrapliCfgResponse class 78 | 79 | Args: 80 | N/A 81 | 82 | Returns: 83 | str: str for class object 84 | 85 | Raises: 86 | N/A 87 | 88 | """ 89 | return f"ScrapliCfgResponse " 90 | 91 | def record_response( 92 | self, scrapli_responses: Iterable[Union[Response, MultiResponse]], result: str = "" 93 | ) -> None: 94 | """ 95 | Record channel_input results and elapsed time of channel input/reading output 96 | 97 | Args: 98 | scrapli_responses: list of scrapli response/multiresponse objects 99 | result: string to assign to final result for the scrapli cfg response object 100 | 101 | Returns: 102 | None 103 | 104 | Raises: 105 | N/A 106 | 107 | """ 108 | self.finish_time = datetime.now() 109 | self.elapsed_time = (self.finish_time - self.start_time).total_seconds() 110 | 111 | for response in scrapli_responses: 112 | if isinstance(response, Response): 113 | self.scrapli_responses.append(response) 114 | elif isinstance(response, MultiResponse): 115 | for sub_response in response: 116 | self.scrapli_responses.append(sub_response) 117 | 118 | self.result = result 119 | 120 | if not any(response.failed for response in self.scrapli_responses): 121 | self.failed = False 122 | 123 | def raise_for_status(self) -> None: 124 | """ 125 | Raise a `ScrapliCommandFailure` if command/config failed 126 | 127 | Args: 128 | N/A 129 | 130 | Returns: 131 | None 132 | 133 | Raises: 134 | raise_for_status_exception: exception raised is dependent on the type of response object 135 | 136 | """ 137 | if self.failed: 138 | raise self.raise_for_status_exception() 139 | -------------------------------------------------------------------------------- /tests/unit/platform/base/test_async_platform.py: -------------------------------------------------------------------------------- 1 | from scrapli_cfg.response import ScrapliCfgResponse 2 | 3 | 4 | async def test_open(async_cfg_object, monkeypatch): 5 | open_called = False 6 | get_version_called = False 7 | validate_and_set_version_called = False 8 | on_prepare_called = False 9 | 10 | # just going to mock things so we know that when open is called these functions get executed, 11 | # this test isn't for testing those other functions, we'll do that elsewhere 12 | 13 | async def _open(cls): 14 | nonlocal open_called 15 | open_called = True 16 | 17 | async def _get_version(cls): 18 | nonlocal get_version_called 19 | get_version_called = True 20 | 21 | def _validate_and_set_version(cls, version_response): 22 | nonlocal validate_and_set_version_called 23 | validate_and_set_version_called = True 24 | 25 | async def _on_prepare(cls): 26 | nonlocal on_prepare_called 27 | on_prepare_called = True 28 | 29 | monkeypatch.setattr("scrapli.driver.base.async_driver.AsyncDriver.open", _open) 30 | monkeypatch.setattr( 31 | "scrapli_cfg.platform.core.cisco_iosxe.async_platform.AsyncScrapliCfgIOSXE.get_version", 32 | _get_version, 33 | ) 34 | monkeypatch.setattr( 35 | "scrapli_cfg.platform.core.cisco_iosxe.async_platform.AsyncScrapliCfgIOSXE._validate_and_set_version", 36 | _validate_and_set_version, 37 | ) 38 | 39 | async_cfg_object.dedicated_connection = True 40 | async_cfg_object.on_prepare = _on_prepare 41 | await async_cfg_object.prepare() 42 | 43 | assert open_called is True 44 | assert get_version_called is True 45 | assert validate_and_set_version_called is True 46 | assert on_prepare_called is True 47 | 48 | 49 | async def test_close(async_cfg_object, monkeypatch): 50 | close_called = False 51 | 52 | # just going to mock things so we know that when open is called these functions get executed, 53 | # this test isn't for testing those other functions, we'll do that elsewhere 54 | 55 | async def _close(cls): 56 | nonlocal close_called 57 | close_called = True 58 | 59 | monkeypatch.setattr("scrapli.driver.base.async_driver.AsyncDriver.close", _close) 60 | # lie and pretend its alive so we actually run close 61 | monkeypatch.setattr("scrapli.driver.base.async_driver.AsyncDriver.isalive", lambda cls: True) 62 | 63 | async_cfg_object.dedicated_connection = True 64 | await async_cfg_object.cleanup() 65 | 66 | assert close_called is True 67 | 68 | 69 | async def test_context_manager(monkeypatch, async_cfg_object): 70 | """Asserts context manager properly opens/closes""" 71 | open_called = False 72 | close_called = False 73 | 74 | async def _prepare(cls): 75 | nonlocal open_called 76 | open_called = True 77 | 78 | async def _cleanup(cls): 79 | nonlocal close_called 80 | close_called = True 81 | 82 | monkeypatch.setattr( 83 | "scrapli_cfg.platform.base.async_platform.AsyncScrapliCfgPlatform.prepare", _prepare 84 | ) 85 | monkeypatch.setattr( 86 | "scrapli_cfg.platform.base.async_platform.AsyncScrapliCfgPlatform.cleanup", _cleanup 87 | ) 88 | 89 | async with async_cfg_object: 90 | pass 91 | 92 | assert open_called is True 93 | assert close_called is True 94 | 95 | 96 | async def test_render_substituted_config(monkeypatch, async_cfg_object): 97 | """Asserts context manager properly opens/closes""" 98 | get_config_called = False 99 | 100 | async def _get_config(cls, source): 101 | nonlocal get_config_called 102 | get_config_called = True 103 | response = ScrapliCfgResponse(host="localhost") 104 | response.result = "blah\nmatchthisline\nanotherblah" 105 | return response 106 | 107 | monkeypatch.setattr( 108 | "scrapli_cfg.platform.core.cisco_iosxe.async_platform.AsyncScrapliCfgIOSXE.get_config", 109 | _get_config, 110 | ) 111 | 112 | rendered_config = await async_cfg_object.render_substituted_config( 113 | config_template="something\n{{ taco }}\nsomethingelse", 114 | substitutes=[("taco", "matchthisline")], 115 | ) 116 | assert rendered_config == "something\nmatchthisline\nsomethingelse" 117 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: Scrapli Cfg 3 | site_description: Network device configuration management with scrapli 4 | site_author: Carl Montanari 5 | site_url: https://scrapli.github.io/ 6 | 7 | repo_name: scrapli/scrapli_cfg 8 | repo_url: https://github.com/scrapli/scrapli_cfg 9 | edit_uri: '' 10 | 11 | theme: 12 | name: material 13 | palette: 14 | primary: 'black' 15 | accent: 'teal' 16 | icon: 17 | repo: fontawesome/brands/github-alt 18 | 19 | nav: 20 | - Scrapli Cfg: index.md 21 | - User Guide: 22 | - Quick Start Guide: user_guide/quickstart.md 23 | - Project Details: user_guide/project_details.md 24 | - Versioning: user_guide/versioning.md 25 | - Installation: user_guide/installation.md 26 | - Basic Usage: user_guide/basic_usage.md 27 | - API Docs: 28 | - Platforms: 29 | - Base: 30 | - Base Platform: api_docs/platform/base/async_platform.md 31 | - Async Platform: api_docs/platform/base/async_platform.md 32 | - Sync Platform: api_docs/platform/base/sync_platform.md 33 | - Core: 34 | - Arista EOS: 35 | - Base Platform: api_docs/platform/core/arista_eos/async_platform.md 36 | - Async Platform: api_docs/platform/core/arista_eos/async_platform.md 37 | - Sync Platform: api_docs/platform/core/arista_eos/sync_platform.md 38 | - Patterns: api_docs/platform/core/arista_eos/patterns.md 39 | - Cisco IOSXE: 40 | - Base Platform: api_docs/platform/core/cisco_iosxe/async_platform.md 41 | - Async Platform: api_docs/platform/core/cisco_iosxe/async_platform.md 42 | - Sync Platform: api_docs/platform/core/cisco_iosxe/sync_platform.md 43 | - Patterns: api_docs/platform/core/cisco_iosxe/patterns.md 44 | - Cisco IOSXR: 45 | - Base Platform: api_docs/platform/core/cisco_iosxr/async_platform.md 46 | - Async Platform: api_docs/platform/core/cisco_iosxr/async_platform.md 47 | - Sync Platform: api_docs/platform/core/cisco_iosxr/sync_platform.md 48 | - Patterns: api_docs/platform/core/cisco_iosxr/patterns.md 49 | - Cisco NXOS: 50 | - Base Platform: api_docs/platform/core/cisco_nxos/async_platform.md 51 | - Async Platform: api_docs/platform/core/cisco_nxos/async_platform.md 52 | - Sync Platform: api_docs/platform/core/cisco_nxos/sync_platform.md 53 | - Patterns: api_docs/platform/core/cisco_nxos/patterns.md 54 | - Juniper JUNOS: 55 | - Base Platform: api_docs/platform/core/juniper_junos/async_platform.md 56 | - Async Platform: api_docs/platform/core/juniper_junos/async_platform.md 57 | - Sync Platform: api_docs/platform/core/juniper_junos/sync_platform.md 58 | - Patterns: api_docs/platform/core/juniper_junos/patterns.md 59 | - Diff: api_docs/diff.md 60 | - Exceptions: api_docs/exceptions.md 61 | - Factory: api_docs/factory.md 62 | - Logging: api_docs/logging.md 63 | - Response: api_docs/response.md 64 | - Changelog: changelog.md 65 | - More Scrapli: 66 | - Scrapli: more_scrapli/scrapli.md 67 | - Scrapli Netconf: more_scrapli/scrapli_netconf.md 68 | - Scrapli Community: more_scrapli/scrapli_community.md 69 | - Scrapli Replay: more_scrapli/scrapli_replay.md 70 | - Nornir Scrapli: more_scrapli/nornir_scrapli.md 71 | - Scrapli SCP: more_scrapli/scrapli_scp.md 72 | - Other: 73 | - Contributing: about/contributing.md 74 | - Code of Conduct: about/code_of_conduct.md 75 | 76 | markdown_extensions: 77 | - toc: 78 | permalink: True 79 | - admonition 80 | - codehilite 81 | - extra 82 | - mdx_gh_links: 83 | user: scrapli 84 | repo: scrapli_cfg 85 | - pymdownx.superfences 86 | - pymdownx.highlight: 87 | use_pygments: True 88 | linenums: True 89 | 90 | extra: 91 | social: 92 | - icon: fontawesome/brands/github-alt 93 | link: 'https://github.com/carlmontanari/' 94 | - icon: fontawesome/brands/twitter 95 | link: 'https://twitter.com/carlrmontanari' 96 | - icon: fontawesome/brands/linkedin 97 | link: 'https://www.linkedin.com/in/carl-montanari-47888931/' 98 | - icon: fontawesome/solid/globe 99 | link: 'https://montanari.io' 100 | 101 | plugins: 102 | - search 103 | - gen-files: 104 | scripts: 105 | - docs/generate.py 106 | - mkdocstrings: 107 | handlers: 108 | python: 109 | paths: [ scrapli_cfg ] 110 | options: 111 | show_signature_annotations: true 112 | - section-index 113 | - literate-nav 114 | 115 | watch: 116 | - scrapli_cfg -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/juniper_junos/base_platform.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.juniper_junos.base_platform""" 2 | 3 | import re 4 | from datetime import datetime 5 | from logging import Logger, LoggerAdapter 6 | from typing import TYPE_CHECKING 7 | 8 | from scrapli_cfg.helper import strip_blank_lines 9 | from scrapli_cfg.platform.core.juniper_junos.patterns import ( 10 | EDIT_PATTERN, 11 | OUTPUT_HEADER_PATTERN, 12 | VERSION_PATTERN, 13 | ) 14 | 15 | if TYPE_CHECKING: 16 | LoggerAdapterT = LoggerAdapter[Logger] # pylint:disable=E1136 17 | else: 18 | LoggerAdapterT = LoggerAdapter 19 | 20 | 21 | CONFIG_SOURCES = [ 22 | "running", 23 | ] 24 | 25 | 26 | class ScrapliCfgJunosBase: 27 | logger: LoggerAdapterT 28 | candidate_config: str 29 | candidate_config_filename: str 30 | _in_configuration_session: bool 31 | _replace: bool 32 | _set: bool 33 | filesystem: str 34 | 35 | @staticmethod 36 | def _parse_version(device_output: str) -> str: 37 | """ 38 | Parse version string out of device output 39 | 40 | Args: 41 | device_output: output from show version command 42 | 43 | Returns: 44 | str: device version string 45 | 46 | Raises: 47 | N/A 48 | 49 | """ 50 | version_string_search = re.search(pattern=VERSION_PATTERN, string=device_output) 51 | 52 | if not version_string_search: 53 | return "" 54 | 55 | version_string = version_string_search.group(0) or "" 56 | return version_string 57 | 58 | def _reset_config_session(self) -> None: 59 | """ 60 | Reset config session info 61 | 62 | Resets the candidate config and config session name attributes -- when these are "empty" we 63 | know there is no current config session 64 | 65 | Args: 66 | N/A 67 | 68 | Returns: 69 | None 70 | 71 | Raises: 72 | N/A 73 | 74 | """ 75 | self.logger.debug("resetting candidate config and candidate config file name") 76 | self.candidate_config = "" 77 | self.candidate_config_filename = "" 78 | self._in_configuration_session = False 79 | self._set = False 80 | 81 | def _prepare_config_payloads(self, config: str) -> str: 82 | """ 83 | Prepare a configuration so it can be nicely sent to the device via scrapli 84 | 85 | Args: 86 | config: configuration to prep 87 | 88 | Returns: 89 | str: string of config lines to write to candidate config file 90 | 91 | Raises: 92 | N/A 93 | 94 | """ 95 | final_config_list = [] 96 | for config_line in config.splitlines(): 97 | final_config_list.append( 98 | f"echo >> {self.filesystem}{self.candidate_config_filename} '{config_line}'" 99 | ) 100 | 101 | final_config = "\n".join(final_config_list) 102 | 103 | return final_config 104 | 105 | def _prepare_load_config(self, config: str, replace: bool) -> str: 106 | """ 107 | Handle pre "load_config" operations for parity between sync and async 108 | 109 | Args: 110 | config: candidate config to load 111 | replace: True/False replace the configuration; passed here so it can be set at the class 112 | level as we need to stay in config mode and we need to know if we are doing a merge 113 | or a replace when we go to diff things 114 | 115 | Returns: 116 | str: string of config to write to candidate config file 117 | 118 | Raises: 119 | N/A 120 | 121 | """ 122 | self.candidate_config = config 123 | 124 | if not self.candidate_config_filename: 125 | self.candidate_config_filename = f"scrapli_cfg_{round(datetime.now().timestamp())}" 126 | self.logger.debug( 127 | f"candidate config file name will be '{self.candidate_config_filename}'" 128 | ) 129 | 130 | config = self._prepare_config_payloads(config=config) 131 | self._replace = replace 132 | 133 | return config 134 | 135 | def clean_config(self, config: str) -> str: 136 | """ 137 | Clean a configuration file of unwanted lines 138 | 139 | Args: 140 | config: configuration string to "clean" 141 | 142 | Returns: 143 | str: cleaned configuration string 144 | 145 | Raises: 146 | N/A 147 | 148 | """ 149 | self.logger.debug("cleaning config file") 150 | 151 | config = re.sub(pattern=OUTPUT_HEADER_PATTERN, string=config, repl="") 152 | config = re.sub(pattern=EDIT_PATTERN, string=config, repl="") 153 | return strip_blank_lines(config=config) 154 | -------------------------------------------------------------------------------- /tests/unit/test_diff.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli_cfg.diff import END_COLOR, GREEN, RED, YELLOW 4 | 5 | DUMMY_SOURCE_CONFIG = """! 6 | interface loopback123 7 | description tacocat 8 | ! 9 | """ 10 | 11 | DUMMY_CANDIDATE_CONFIG = """! 12 | interface loopback456 13 | description racecar 14 | ! 15 | """ 16 | 17 | DUMMY_DEVICE_DIFF = """!List of Commands: 18 | interface loopback456 19 | description racecar 20 | end""" 21 | 22 | COLORIZED_SIDE_BY_SIDE_DIFF = "! !\n\x1b[91minterface loopback123 \x1b[0m\n\x1b[93m ^^^ \x1b[0m\x1b[93m ^^^\x1b[0m\n \x1b[92minterface loopback456\x1b[0m\n\x1b[93m ^^^ \x1b[0m\x1b[93m ^^^\x1b[0m\n\x1b[91m description tacocat \x1b[0m\n\x1b[93m ^ ^ ^ \x1b[0m\x1b[93m ^ ^ ^\x1b[0m\n \x1b[92m description racecar\x1b[0m\n\x1b[93m ^ ^ ^ \x1b[0m\x1b[93m ^ ^ ^\x1b[0m\n! !" 23 | SIDE_BY_SIDE_DIFF = "! !\n- interface loopback123 \n? ^^^ ? ^^^\n + interface loopback456\n? ^^^ ? ^^^\n- description tacocat \n? ^ ^ ^ ? ^ ^ ^\n + description racecar\n? ^ ^ ^ ? ^ ^ ^\n! !" 24 | COLORIZED_UNIFIED_DIFF = "!\n\x1b[91minterface loopback123\n\x1b[0m\x1b[93m ^^^\n\x1b[0m\x1b[92minterface loopback456\n\x1b[0m\x1b[93m ^^^\n\x1b[0m\x1b[91m description tacocat\n\x1b[0m\x1b[93m ^ ^ ^\n\x1b[0m\x1b[92m description racecar\n\x1b[0m\x1b[93m ^ ^ ^\n\x1b[0m!\n" 25 | UNIFIED_DIFF = "!\n- interface loopback123\n? ^^^\n+ interface loopback456\n? ^^^\n- description tacocat\n? ^ ^ ^\n+ description racecar\n? ^ ^ ^\n!\n" 26 | 27 | 28 | def test_record_diff_response(diff_obj): 29 | assert diff_obj.colorize is True 30 | assert diff_obj.side_by_side_diff_width == 118 31 | assert diff_obj.source == "running" 32 | assert diff_obj.source_config == "" 33 | assert diff_obj.candidate_config == "" 34 | assert diff_obj.device_diff == "" 35 | 36 | diff_obj.record_diff_response( 37 | source_config=DUMMY_SOURCE_CONFIG, 38 | candidate_config=DUMMY_CANDIDATE_CONFIG, 39 | device_diff=DUMMY_DEVICE_DIFF, 40 | ) 41 | 42 | assert diff_obj._difflines == [ 43 | " !\n", 44 | "- interface loopback123\n", 45 | "? ^^^\n", 46 | "+ interface loopback456\n", 47 | "? ^^^\n", 48 | "- description tacocat\n", 49 | "? ^ ^ ^\n", 50 | "+ description racecar\n", 51 | "? ^ ^ ^\n", 52 | " !\n", 53 | ] 54 | assert diff_obj.subtractions == "interface loopback123\n description tacocat\n" 55 | assert diff_obj.additions == "interface loopback456\n description racecar\n" 56 | assert diff_obj.device_diff == DUMMY_DEVICE_DIFF 57 | 58 | 59 | @pytest.mark.parametrize( 60 | "colorize", 61 | ( 62 | True, 63 | False, 64 | ), 65 | ids=("color", "no color"), 66 | ) 67 | def test_generate_colors(diff_obj, colorize): 68 | diff_obj.colorize = colorize 69 | colors = diff_obj._generate_colors() 70 | 71 | if colorize is True: 72 | assert colors == (YELLOW, RED, GREEN, END_COLOR) 73 | else: 74 | assert colors == ("? ", "- ", "+ ", "") 75 | 76 | 77 | @pytest.mark.parametrize( 78 | "colorize", 79 | ( 80 | True, 81 | False, 82 | ), 83 | ids=("color", "no color"), 84 | ) 85 | def test_side_by_side_diff(diff_obj, colorize): 86 | diff_obj.colorize = colorize 87 | assert diff_obj.side_by_side_diff == "" 88 | 89 | diff_obj.record_diff_response( 90 | source_config=DUMMY_SOURCE_CONFIG, 91 | candidate_config=DUMMY_CANDIDATE_CONFIG, 92 | device_diff=DUMMY_DEVICE_DIFF, 93 | ) 94 | 95 | if colorize is True: 96 | assert diff_obj.side_by_side_diff == COLORIZED_SIDE_BY_SIDE_DIFF 97 | else: 98 | assert diff_obj.side_by_side_diff == SIDE_BY_SIDE_DIFF 99 | 100 | 101 | @pytest.mark.parametrize( 102 | "colorize", 103 | ( 104 | True, 105 | False, 106 | ), 107 | ids=("color", "no color"), 108 | ) 109 | def test_unified_diff(diff_obj, colorize): 110 | diff_obj.colorize = colorize 111 | assert diff_obj.side_by_side_diff == "" 112 | 113 | diff_obj.record_diff_response( 114 | source_config=DUMMY_SOURCE_CONFIG, 115 | candidate_config=DUMMY_CANDIDATE_CONFIG, 116 | device_diff=DUMMY_DEVICE_DIFF, 117 | ) 118 | 119 | if colorize is True: 120 | assert diff_obj.unified_diff == COLORIZED_UNIFIED_DIFF 121 | else: 122 | assert diff_obj.unified_diff == UNIFIED_DIFF 123 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-ssh2].yaml: -------------------------------------------------------------------------------- 1 | localhost:25022:Ssh2Transport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25022 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: ssh2 11 | auth_secondary: true 12 | interactions: 13 | - channel_output: '' 14 | expected_channel_input: "\n" 15 | expected_channel_input_redacted: false 16 | - channel_output: "\n--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\nboxen> " 17 | expected_channel_input: set cli screen-length 0 18 | expected_channel_input_redacted: false 19 | - channel_output: "\n\nboxen> set cli screen-length 0" 20 | expected_channel_input: "\n" 21 | expected_channel_input_redacted: false 22 | - channel_output: " \nScreen length set to 0\n\nboxen> " 23 | expected_channel_input: set cli screen-width 511 24 | expected_channel_input_redacted: false 25 | - channel_output: set cli screen-width 511 26 | expected_channel_input: "\n" 27 | expected_channel_input_redacted: false 28 | - channel_output: " \nScreen width set to 511\n\nboxen> " 29 | expected_channel_input: set cli complete-on-space off 30 | expected_channel_input_redacted: false 31 | - channel_output: set cli complete-on-space off 32 | expected_channel_input: "\n" 33 | expected_channel_input_redacted: false 34 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 35 | expected_channel_input: 'show version | grep junos:' 36 | expected_channel_input_redacted: false 37 | - channel_output: 'show version | grep junos:' 38 | expected_channel_input: "\n" 39 | expected_channel_input_redacted: false 40 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 41 | expected_channel_input: show configuration 42 | expected_channel_input_redacted: false 43 | - channel_output: show configuration 44 | expected_channel_input: "\n" 45 | expected_channel_input_redacted: false 46 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 47 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 48 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 49 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 50 | \ 2000;\n class super-user;\n authentication {\n \ 51 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 52 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 53 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 54 | \ netconf {\n ssh;\n }\n web-management {\n\ 55 | \ http {\n interface fxp0.0;\n }\n \ 56 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 57 | \ }\n file messages {\n any any;\n authorization\ 58 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 59 | \ any;\n }\n }\n license {\n autoupdate {\n \ 60 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 61 | security {\n screen {\n ids-option untrust-screen {\n \ 62 | \ icmp {\n ping-death;\n }\n ip {\n\ 63 | \ source-route-option;\n tear-drop;\n \ 64 | \ }\n tcp {\n syn-flood {\n \ 65 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 66 | \ source-threshold 1024;\n destination-threshold\ 67 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 68 | \ deprecated\n timeout 20;\n }\n \ 69 | \ land;\n }\n }\n }\n policies {\n \ 70 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 71 | \ match {\n source-address any;\n \ 72 | \ destination-address any;\n application any;\n\ 73 | \ }\n then {\n permit;\n\ 74 | \ }\n }\n }\n from-zone trust to-zone\ 75 | \ untrust {\n policy default-permit {\n match {\n\ 76 | \ source-address any;\n destination-address\ 77 | \ any;\n application any;\n }\n \ 78 | \ then {\n permit;\n }\n \ 79 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 80 | \ tcp-rst;\n }\n security-zone untrust {\n \ 81 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 82 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 83 | \ }\n }\n }\n}\n\nboxen> " 84 | expected_channel_input: "\n" 85 | expected_channel_input_redacted: false 86 | - channel_output: "\n\nboxen> " 87 | expected_channel_input: exit 88 | expected_channel_input_redacted: false 89 | - channel_output: '' 90 | expected_channel_input: "\n" 91 | expected_channel_input_redacted: false 92 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-asyncssh].yaml: -------------------------------------------------------------------------------- 1 | localhost:25022:AsyncsshTransport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25022 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: asyncssh 11 | auth_secondary: false 12 | interactions: 13 | - channel_output: '' 14 | expected_channel_input: "\n" 15 | expected_channel_input_redacted: false 16 | - channel_output: "--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\n\nboxen> " 17 | expected_channel_input: set cli screen-length 0 18 | expected_channel_input_redacted: false 19 | - channel_output: "\n\nboxen> set cli screen-length 0" 20 | expected_channel_input: "\n" 21 | expected_channel_input_redacted: false 22 | - channel_output: " \nScreen length set to 0\n\nboxen> " 23 | expected_channel_input: set cli screen-width 511 24 | expected_channel_input_redacted: false 25 | - channel_output: set cli screen-width 511 26 | expected_channel_input: "\n" 27 | expected_channel_input_redacted: false 28 | - channel_output: " \nScreen width set to 511\n\nboxen> " 29 | expected_channel_input: set cli complete-on-space off 30 | expected_channel_input_redacted: false 31 | - channel_output: set cli complete-on-space off 32 | expected_channel_input: "\n" 33 | expected_channel_input_redacted: false 34 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 35 | expected_channel_input: 'show version | grep junos:' 36 | expected_channel_input_redacted: false 37 | - channel_output: 'show version | grep junos:' 38 | expected_channel_input: "\n" 39 | expected_channel_input_redacted: false 40 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 41 | expected_channel_input: show configuration 42 | expected_channel_input_redacted: false 43 | - channel_output: show configuration 44 | expected_channel_input: "\n" 45 | expected_channel_input_redacted: false 46 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 47 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 48 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 49 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 50 | \ 2000;\n class super-user;\n authentication {\n \ 51 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 52 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 53 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 54 | \ netconf {\n ssh;\n }\n web-management {\n\ 55 | \ http {\n interface fxp0.0;\n }\n \ 56 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 57 | \ }\n file messages {\n any any;\n authorization\ 58 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 59 | \ any;\n }\n }\n license {\n autoupdate {\n \ 60 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 61 | security {\n screen {\n ids-option untrust-screen {\n \ 62 | \ icmp {\n ping-death;\n }\n ip {\n\ 63 | \ source-route-option;\n tear-drop;\n \ 64 | \ }\n tcp {\n syn-flood {\n \ 65 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 66 | \ source-threshold 1024;\n destination-threshold\ 67 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 68 | \ deprecated\n timeout 20;\n }\n \ 69 | \ land;\n }\n }\n }\n policies {\n \ 70 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 71 | \ match {\n source-address any;\n \ 72 | \ destination-address any;\n application any;\n\ 73 | \ }\n then {\n permit;\n\ 74 | \ }\n }\n }\n from-zone trust to-zone\ 75 | \ untrust {\n policy default-permit {\n match {\n\ 76 | \ source-address any;\n destination-address\ 77 | \ any;\n application any;\n }\n \ 78 | \ then {\n permit;\n }\n \ 79 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 80 | \ tcp-rst;\n }\n security-zone untrust {\n \ 81 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 82 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 83 | \ }\n }\n }\n}\n\nboxen> " 84 | expected_channel_input: "\n" 85 | expected_channel_input_redacted: false 86 | - channel_output: "\n\nboxen> " 87 | expected_channel_input: exit 88 | expected_channel_input_redacted: false 89 | - channel_output: '' 90 | expected_channel_input: "\n" 91 | expected_channel_input_redacted: false 92 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-paramiko].yaml: -------------------------------------------------------------------------------- 1 | localhost:25022:ParamikoTransport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25022 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: paramiko 11 | auth_secondary: true 12 | interactions: 13 | - channel_output: '' 14 | expected_channel_input: "\n" 15 | expected_channel_input_redacted: false 16 | - channel_output: "\n--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\nboxen> " 17 | expected_channel_input: set cli screen-length 0 18 | expected_channel_input_redacted: false 19 | - channel_output: "\n\nboxen> set cli screen-length 0" 20 | expected_channel_input: "\n" 21 | expected_channel_input_redacted: false 22 | - channel_output: " \nScreen length set to 0\n\nboxen> " 23 | expected_channel_input: set cli screen-width 511 24 | expected_channel_input_redacted: false 25 | - channel_output: set cli screen-width 511 26 | expected_channel_input: "\n" 27 | expected_channel_input_redacted: false 28 | - channel_output: " \nScreen width set to 511\n\nboxen> " 29 | expected_channel_input: set cli complete-on-space off 30 | expected_channel_input_redacted: false 31 | - channel_output: set cli complete-on-space off 32 | expected_channel_input: "\n" 33 | expected_channel_input_redacted: false 34 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 35 | expected_channel_input: 'show version | grep junos:' 36 | expected_channel_input_redacted: false 37 | - channel_output: 'show version | grep junos:' 38 | expected_channel_input: "\n" 39 | expected_channel_input_redacted: false 40 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 41 | expected_channel_input: show configuration 42 | expected_channel_input_redacted: false 43 | - channel_output: show configuration 44 | expected_channel_input: "\n" 45 | expected_channel_input_redacted: false 46 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 47 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 48 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 49 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 50 | \ 2000;\n class super-user;\n authentication {\n \ 51 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 52 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 53 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 54 | \ netconf {\n ssh;\n }\n web-management {\n\ 55 | \ http {\n interface fxp0.0;\n }\n \ 56 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 57 | \ }\n file messages {\n any any;\n authorization\ 58 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 59 | \ any;\n }\n }\n license {\n autoupdate {\n \ 60 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 61 | security {\n screen {\n ids-option untrust-screen {\n \ 62 | \ icmp {\n ping-death;\n }\n ip {\n\ 63 | \ source-route-option;\n tear-drop;\n \ 64 | \ }\n tcp {\n syn-flood {\n \ 65 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 66 | \ source-threshold 1024;\n destination-threshold\ 67 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 68 | \ deprecated\n timeout 20;\n }\n \ 69 | \ land;\n }\n }\n }\n policies {\n \ 70 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 71 | \ match {\n source-address any;\n \ 72 | \ destination-address any;\n application any;\n\ 73 | \ }\n then {\n permit;\n\ 74 | \ }\n }\n }\n from-zone trust to-zone\ 75 | \ untrust {\n policy default-permit {\n match {\n\ 76 | \ source-address any;\n destination-address\ 77 | \ any;\n application any;\n }\n \ 78 | \ then {\n permit;\n }\n \ 79 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 80 | \ tcp-rst;\n }\n security-zone untrust {\n \ 81 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 82 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 83 | \ }\n }\n }\n}\n\nboxen> " 84 | expected_channel_input: "\n" 85 | expected_channel_input_redacted: false 86 | - channel_output: "\n\nboxen> " 87 | expected_channel_input: exit 88 | expected_channel_input_redacted: false 89 | - channel_output: '' 90 | expected_channel_input: "\n" 91 | expected_channel_input_redacted: false 92 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-system].yaml: -------------------------------------------------------------------------------- 1 | localhost:25022:SystemTransport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25022 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: system 11 | auth_secondary: true 12 | interactions: 13 | - channel_output: "Warning: Permanently added '[localhost]:25022' (ECDSA) to the\ 14 | \ list of known hosts.\nPassword:" 15 | expected_channel_input: REDACTED 16 | expected_channel_input_redacted: true 17 | - channel_output: '' 18 | expected_channel_input: "\n" 19 | expected_channel_input_redacted: false 20 | - channel_output: "\n--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\nboxen> " 21 | expected_channel_input: "\n" 22 | expected_channel_input_redacted: false 23 | - channel_output: "\n\nboxen> " 24 | expected_channel_input: set cli screen-length 0 25 | expected_channel_input_redacted: false 26 | - channel_output: set cli screen-length 0 27 | expected_channel_input: "\n" 28 | expected_channel_input_redacted: false 29 | - channel_output: " \nScreen length set to 0\n\nboxen> " 30 | expected_channel_input: set cli screen-width 511 31 | expected_channel_input_redacted: false 32 | - channel_output: set cli screen-width 511 33 | expected_channel_input: "\n" 34 | expected_channel_input_redacted: false 35 | - channel_output: " \nScreen width set to 511\n\nboxen> " 36 | expected_channel_input: set cli complete-on-space off 37 | expected_channel_input_redacted: false 38 | - channel_output: set cli complete-on-space off 39 | expected_channel_input: "\n" 40 | expected_channel_input_redacted: false 41 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 42 | expected_channel_input: 'show version | grep junos:' 43 | expected_channel_input_redacted: false 44 | - channel_output: 'show version | grep junos:' 45 | expected_channel_input: "\n" 46 | expected_channel_input_redacted: false 47 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 48 | expected_channel_input: show configuration 49 | expected_channel_input_redacted: false 50 | - channel_output: show configuration 51 | expected_channel_input: "\n" 52 | expected_channel_input_redacted: false 53 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 54 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 55 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 56 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 57 | \ 2000;\n class super-user;\n authentication {\n \ 58 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 59 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 60 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 61 | \ netconf {\n ssh;\n }\n web-management {\n\ 62 | \ http {\n interface fxp0.0;\n }\n \ 63 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 64 | \ }\n file messages {\n any any;\n authorization\ 65 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 66 | \ any;\n }\n }\n license {\n autoupdate {\n \ 67 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 68 | security {\n screen {\n ids-option untrust-screen {\n \ 69 | \ icmp {\n ping-death;\n }\n ip {\n\ 70 | \ source-route-option;\n tear-drop;\n \ 71 | \ }\n tcp {\n syn-flood {\n \ 72 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 73 | \ source-threshold 1024;\n destination-threshold\ 74 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 75 | \ deprecated\n timeout 20;\n }\n \ 76 | \ land;\n }\n }\n }\n policies {\n \ 77 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 78 | \ match {\n source-address any;\n \ 79 | \ destination-address any;\n application any;\n\ 80 | \ }\n then {\n permit;\n\ 81 | \ }\n }\n }\n from-zone trust to-zone\ 82 | \ untrust {\n policy default-permit {\n match {\n\ 83 | \ source-address any;\n destination-address\ 84 | \ any;\n application any;\n }\n \ 85 | \ then {\n permit;\n }\n \ 86 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 87 | \ tcp-rst;\n }\n security-zone untrust {\n \ 88 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 89 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 90 | \ }\n }\n }\n}\n\nboxen> " 91 | expected_channel_input: "\n" 92 | expected_channel_input_redacted: false 93 | - channel_output: "\n\nboxen> " 94 | expected_channel_input: exit 95 | expected_channel_input_redacted: false 96 | - channel_output: '' 97 | expected_channel_input: "\n" 98 | expected_channel_input_redacted: false 99 | -------------------------------------------------------------------------------- /tests/test_data/expected/cisco_iosxe: -------------------------------------------------------------------------------- 1 | Building configuration... 2 | 3 | Current configuration : 4844 bytes 4 | ! 5 | ! Last configuration change at 20:27:31 UTC Tue Mar 2 2021 by vrnetlab 6 | ! 7 | version 16.12 8 | service timestamps debug datetime msec 9 | service timestamps log datetime msec 10 | service call-home 11 | platform qfp utilization monitor load 80 12 | platform punt-keepalive disable-kernel-core 13 | platform console serial 14 | ! 15 | hostname csr1000v 16 | ! 17 | boot-start-marker 18 | boot-end-marker 19 | ! 20 | ! 21 | enable secret 9 $9$xvWnx8Fe35f8xE$E9ijp7GM/V48P5y1Uz3IEPtotXgwkJKYJmN0q3q2E92 22 | ! 23 | no aaa new-model 24 | call-home 25 | ! If contact email address in call-home is configured as sch-smart-licensing@cisco.com 26 | ! the email address configured in Cisco Smart License Portal will be used as contact email address to send SCH notifications. 27 | contact-email-addr sch-smart-licensing@cisco.com 28 | profile "CiscoTAC-1" 29 | active 30 | destination transport-method http 31 | no destination transport-method email 32 | ! 33 | ! 34 | ! 35 | ! 36 | ! 37 | ! 38 | ! 39 | ip domain name example.com 40 | ! 41 | ! 42 | ! 43 | login on-success log 44 | ! 45 | ! 46 | ! 47 | ! 48 | ! 49 | ! 50 | ! 51 | subscriber templating 52 | ! 53 | ! 54 | ! 55 | ! 56 | ! 57 | ! 58 | multilink bundle-name authenticated 59 | ! 60 | ! 61 | ! 62 | ! 63 | ! 64 | ! 65 | ! 66 | ! 67 | ! 68 | ! 69 | ! 70 | ! 71 | ! 72 | ! 73 | ! 74 | crypto pki trustpoint SLA-TrustPoint 75 | enrollment pkcs12 76 | revocation-check crl 77 | ! 78 | ! 79 | crypto pki certificate chain SLA-TrustPoint 80 | certificate ca 01 81 | 30820321 30820209 A0030201 02020101 300D0609 2A864886 F70D0101 0B050030 82 | 32310E30 0C060355 040A1305 43697363 6F312030 1E060355 04031317 43697363 83 | 6F204C69 63656E73 696E6720 526F6F74 20434130 1E170D31 33303533 30313934 84 | 3834375A 170D3338 30353330 31393438 34375A30 32310E30 0C060355 040A1305 85 | 43697363 6F312030 1E060355 04031317 43697363 6F204C69 63656E73 696E6720 86 | 526F6F74 20434130 82012230 0D06092A 864886F7 0D010101 05000382 010F0030 87 | 82010A02 82010100 A6BCBD96 131E05F7 145EA72C 2CD686E6 17222EA1 F1EFF64D 88 | CBB4C798 212AA147 C655D8D7 9471380D 8711441E 1AAF071A 9CAE6388 8A38E520 89 | 1C394D78 462EF239 C659F715 B98C0A59 5BBB5CBD 0CFEBEA3 700A8BF7 D8F256EE 90 | 4AA4E80D DB6FD1C9 60B1FD18 FFC69C96 6FA68957 A2617DE7 104FDC5F EA2956AC 91 | 7390A3EB 2B5436AD C847A2C5 DAB553EB 69A9A535 58E9F3E3 C0BD23CF 58BD7188 92 | 68E69491 20F320E7 948E71D7 AE3BCC84 F10684C7 4BC8E00F 539BA42B 42C68BB7 93 | C7479096 B4CB2D62 EA2F505D C7B062A4 6811D95B E8250FC4 5D5D5FB8 8F27D191 94 | C55F0D76 61F9A4CD 3D992327 A8BB03BD 4E6D7069 7CBADF8B DF5F4368 95135E44 95 | DFC7C6CF 04DD7FD1 02030100 01A34230 40300E06 03551D0F 0101FF04 04030201 96 | 06300F06 03551D13 0101FF04 05300301 01FF301D 0603551D 0E041604 1449DC85 97 | 4B3D31E5 1B3E6A17 606AF333 3D3B4C73 E8300D06 092A8648 86F70D01 010B0500 98 | 03820101 00507F24 D3932A66 86025D9F E838AE5C 6D4DF6B0 49631C78 240DA905 99 | 604EDCDE FF4FED2B 77FC460E CD636FDB DD44681E 3A5673AB 9093D3B1 6C9E3D8B 100 | D98987BF E40CBD9E 1AECA0C2 2189BB5C 8FA85686 CD98B646 5575B146 8DFC66A8 101 | 467A3DF4 4D565700 6ADF0F0D CF835015 3C04FF7C 21E878AC 11BA9CD2 55A9232C 102 | 7CA7B7E6 C1AF74F6 152E99B7 B1FCF9BB E973DE7F 5BDDEB86 C71E3B49 1765308B 103 | 5FB0DA06 B92AFE7F 494E8A9E 07B85737 F3A58BE1 1A48A229 C37C1E69 39F08678 104 | 80DDCD16 D6BACECA EEBC7CF9 8428787B 35202CDC 60E4616A B623CDBD 230E3AFB 105 | 418616A9 4093E049 4D10AB75 27E86F73 932E35B5 8862FDAE 0275156F 719BB2F0 106 | D697DF7F 28 107 | quit 108 | ! 109 | license udi pid CSR1000V sn 9MVVU09YZFH 110 | diagnostic bootup level minimal 111 | archive 112 | log config 113 | logging enable 114 | path bootflash: 115 | memory free low-watermark processor 72329 116 | ! 117 | ! 118 | spanning-tree extend system-id 119 | ! 120 | username boxen privilege 15 password 0 b0x3N-b0x3N 121 | ! 122 | redundancy 123 | ! 124 | ! 125 | ! 126 | ! 127 | ! 128 | ! 129 | ! 130 | ! 131 | ! 132 | ! 133 | ! 134 | ! 135 | ! 136 | ! 137 | ! 138 | ! 139 | ! 140 | ! 141 | ! 142 | ! 143 | ! 144 | ! 145 | ! 146 | interface GigabitEthernet1 147 | ip address 10.0.0.15 255.255.255.0 148 | negotiation auto 149 | no mop enabled 150 | no mop sysid 151 | ! 152 | interface GigabitEthernet2 153 | no ip address 154 | shutdown 155 | negotiation auto 156 | no mop enabled 157 | no mop sysid 158 | ! 159 | interface GigabitEthernet3 160 | no ip address 161 | shutdown 162 | negotiation auto 163 | no mop enabled 164 | no mop sysid 165 | ! 166 | interface GigabitEthernet4 167 | no ip address 168 | shutdown 169 | negotiation auto 170 | no mop enabled 171 | no mop sysid 172 | ! 173 | interface GigabitEthernet5 174 | no ip address 175 | shutdown 176 | negotiation auto 177 | no mop enabled 178 | no mop sysid 179 | ! 180 | interface GigabitEthernet6 181 | no ip address 182 | shutdown 183 | negotiation auto 184 | no mop enabled 185 | no mop sysid 186 | ! 187 | interface GigabitEthernet7 188 | no ip address 189 | shutdown 190 | negotiation auto 191 | no mop enabled 192 | no mop sysid 193 | ! 194 | interface GigabitEthernet8 195 | no ip address 196 | shutdown 197 | negotiation auto 198 | no mop enabled 199 | no mop sysid 200 | ! 201 | interface GigabitEthernet9 202 | no ip address 203 | shutdown 204 | negotiation auto 205 | no mop enabled 206 | no mop sysid 207 | ! 208 | interface GigabitEthernet10 209 | no ip address 210 | shutdown 211 | negotiation auto 212 | no mop enabled 213 | no mop sysid 214 | ! 215 | ! 216 | virtual-service csr_mgmt 217 | ! 218 | ip forward-protocol nd 219 | no ip http server 220 | no ip http secure-server 221 | ! 222 | ip ssh pubkey-chain 223 | username boxen 224 | key-hash ssh-rsa 5CC74A68B18B026A1709FB09D1F44E2F 225 | ip scp server enable 226 | ! 227 | ! 228 | ! 229 | ! 230 | ! 231 | ! 232 | ! 233 | control-plane 234 | ! 235 | ! 236 | ! 237 | ! 238 | ! 239 | ! 240 | line con 0 241 | stopbits 1 242 | line vty 0 4 243 | login local 244 | transport input all 245 | line vty 5 15 246 | login local 247 | transport input all 248 | ! 249 | netconf ssh 250 | ! 251 | ! 252 | ! 253 | ! 254 | ! 255 | netconf-yang 256 | end -------------------------------------------------------------------------------- /tests/unit/platform/core/arista_eos/test_arista_eos_base_platform.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from scrapli import Scrapli 4 | from scrapli.response import Response 5 | from scrapli_cfg.response import ScrapliCfgResponse 6 | 7 | EOS_SHOW_VERSION_OUTPUT = """ vEOS 8 | Hardware version: 9 | Serial number: 10 | System MAC address: 5254.001f.e379 11 | 12 | Software image version: 4.22.1F 13 | Architecture: i686 14 | Internal build version: 4.22.1F-13062802.4221F 15 | Internal build ID: bb097f5d-d38c-4c32-898b-c20f6e18b00a 16 | 17 | Uptime: 0 weeks, 0 days, 4 hours and 52 minutes 18 | Total memory: 4008840 kB 19 | Free memory: 3257488 kB""" 20 | 21 | EOS_CONFIG_SESSION_OUTPUT = """{ 22 | "maxSavedSessions": 1, 23 | "maxOpenSessions": 5, 24 | "sessions": { 25 | "racecar": { 26 | "description": "", 27 | "state": "pending", 28 | "instances": {} 29 | }, 30 | "tacocat": { 31 | "description": "", 32 | "state": "pending", 33 | "instances": {} 34 | } 35 | } 36 | } 37 | """ 38 | 39 | CONFIG_PAYLOAD = """! Command: show running-config 40 | ! device: localhost (vEOS, EOS-4.22.1F) 41 | ! 42 | ! boot system flash:/vEOS-lab.swi 43 | ! 44 | switchport default mode routed 45 | ! 46 | transceiver qsfp default-mode 4x10G 47 | ! 48 | banner login 49 | No startup-config was found. 50 | EOF 51 | ! 52 | end""" 53 | 54 | 55 | def test_parse_version_success(eos_base_cfg_object): 56 | actual_version_string = eos_base_cfg_object._parse_version( 57 | device_output=EOS_SHOW_VERSION_OUTPUT 58 | ) 59 | assert actual_version_string == "4.22.1F" 60 | 61 | 62 | def test_parse_version_no_match(eos_base_cfg_object): 63 | actual_version_string = eos_base_cfg_object._parse_version(device_output="blah") 64 | assert actual_version_string == "" 65 | 66 | 67 | def test_config_sessions(eos_base_cfg_object): 68 | actual_config_session_list = eos_base_cfg_object._parse_config_sessions( 69 | device_output=EOS_CONFIG_SESSION_OUTPUT 70 | ) 71 | assert actual_config_session_list == ["racecar", "tacocat"] 72 | 73 | 74 | def test_config_sessions_no_match(eos_base_cfg_object): 75 | actual_config_session_list = eos_base_cfg_object._parse_config_sessions(device_output="blah") 76 | assert actual_config_session_list == [] 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "test_data", 81 | ( 82 | ("running", "show running-config"), 83 | ( 84 | "startup", 85 | "show startup-config", 86 | ), 87 | ), 88 | ids=( 89 | "running", 90 | "startup", 91 | ), 92 | ) 93 | def test_get_config_command(eos_base_cfg_object, test_data): 94 | source, expected_command = test_data 95 | assert eos_base_cfg_object._get_config_command(source=source) == expected_command 96 | 97 | 98 | def test_prepare_config_payloads(eos_base_cfg_object): 99 | actual_regular_config, actual_eager_config = eos_base_cfg_object._prepare_config_payloads( 100 | config=CONFIG_PAYLOAD 101 | ) 102 | assert ( 103 | actual_regular_config 104 | == "!\n!\n!\n!\n!\nswitchport default mode routed\n!\ntransceiver qsfp default-mode 4x10G\n!\n!\n!\n!" 105 | ) 106 | assert actual_eager_config == "banner login\nNo startup-config was found.\nEOF" 107 | 108 | 109 | def test_prepare_load_config_session_and_payloads(eos_base_cfg_object, dummy_logger): 110 | eos_base_cfg_object.logger = dummy_logger 111 | eos_base_cfg_object.config_session_name = "" 112 | ( 113 | actual_regular_config, 114 | actual_eager_config, 115 | actual_register_config_session, 116 | ) = eos_base_cfg_object._prepare_load_config_session_and_payload(config=CONFIG_PAYLOAD) 117 | assert ( 118 | actual_regular_config 119 | == "!\n!\n!\n!\n!\nswitchport default mode routed\n!\ntransceiver qsfp default-mode 4x10G\n!\n!\n!\n!" 120 | ) 121 | assert actual_eager_config == "banner login\nNo startup-config was found.\nEOF" 122 | assert actual_register_config_session is True 123 | 124 | 125 | def test_reset_config_session(eos_base_cfg_object, dummy_logger): 126 | eos_base_cfg_object.logger = dummy_logger 127 | eos_base_cfg_object.config_session_name = "BLAH" 128 | eos_base_cfg_object.candidate_config = "SOMECONFIG" 129 | 130 | eos_base_cfg_object._reset_config_session() 131 | 132 | assert eos_base_cfg_object.config_session_name == "" 133 | assert eos_base_cfg_object.candidate_config == "" 134 | 135 | 136 | def test_clean_config(eos_base_cfg_object, dummy_logger): 137 | eos_base_cfg_object.logger = dummy_logger 138 | eos_base_cfg_object.candidate_config = CONFIG_PAYLOAD 139 | 140 | actual_config = eos_base_cfg_object.clean_config(config=CONFIG_PAYLOAD) 141 | 142 | # checking that this removes the comment banner basically, in the future it may have to "clean" 143 | # more things too 144 | assert ( 145 | actual_config 146 | == "!\n!\nswitchport default mode routed\n!\ntransceiver qsfp default-mode 4x10G\n!\nbanner login\nNo startup-config was found.\nEOF\n!\nend" 147 | ) 148 | 149 | 150 | def test_pre_and_post_clear_config_sessions(eos_base_cfg_object, dummy_logger): 151 | eos_base_cfg_object.logger = dummy_logger 152 | eos_base_cfg_object.conn = Scrapli(host="localhost", platform="arista_eos") 153 | pre_response = eos_base_cfg_object._pre_clear_config_sessions() 154 | assert isinstance(pre_response, ScrapliCfgResponse) 155 | 156 | scrapli_response = Response(host="localhost", channel_input="diff a config") 157 | post_response = eos_base_cfg_object._post_clear_config_sessions( 158 | response=pre_response, scrapli_responses=[scrapli_response] 159 | ) 160 | assert post_response.result == "failed to clear device configuration session(s)" 161 | 162 | scrapli_response.failed = False 163 | post_response = eos_base_cfg_object._post_clear_config_sessions( 164 | response=pre_response, scrapli_responses=[scrapli_response] 165 | ) 166 | assert post_response.result == "configuration session(s) cleared" 167 | -------------------------------------------------------------------------------- /tests/test_data/expected/cisco_nxos: -------------------------------------------------------------------------------- 1 | !Command: show running-config 2 | !Running configuration last done at: Thu Mar 4 00:23:17 2021 3 | !Time: Thu Mar 4 01:17:44 2021 4 | 5 | version 9.2(4) Bios:version 6 | vdc switch id 1 7 | limit-resource vlan minimum 16 maximum 4094 8 | limit-resource vrf minimum 2 maximum 4096 9 | limit-resource port-channel minimum 0 maximum 511 10 | limit-resource u4route-mem minimum 128 maximum 128 11 | limit-resource u6route-mem minimum 96 maximum 96 12 | limit-resource m4route-mem minimum 58 maximum 58 13 | limit-resource m6route-mem minimum 8 maximum 8 14 | feature telnet 15 | feature nxapi 16 | feature scp-server 17 | 18 | no password strength-check 19 | username admin password 5 $5$LOIMHI$hIaO64VM40/x.MTQoeWg8/IAn2iBY5jv4WZyzQbb5q9 role network-admin 20 | username boxen password 5 $5$AT5s3bhE$4/A..pCU3QK/YfesFHYPgbStJuRKK2JoYO7dEOGN2n3 role network-admin 21 | username boxen passphrase lifetime 99999 warntime 14 gracetime 3 22 | ip domain-lookup 23 | copp profile strict 24 | snmp-server user admin network-admin auth md5 0xd42fc9f6e153a348e1ab40f0f5b84589 priv 0xd42fc9f6e153a348e1ab40f0f5b84589 localizedkey 25 | snmp-server user boxen network-admin auth md5 0x1a3abb28531cf988a22cc61af30861a7 priv 0x1a3abb28531cf988a22cc61af30861a7 localizedkey 26 | rmon event 1 description FATAL(1) owner PMON@FATAL 27 | rmon event 2 description CRITICAL(2) owner PMON@CRITICAL 28 | rmon event 3 description ERROR(3) owner PMON@ERROR 29 | rmon event 4 description WARNING(4) owner PMON@WARNING 30 | rmon event 5 description INFORMATION(5) owner PMON@INFO 31 | 32 | vlan 1 33 | 34 | vrf context management 35 | 36 | interface Ethernet1/1 37 | 38 | interface Ethernet1/2 39 | 40 | interface Ethernet1/3 41 | 42 | interface Ethernet1/4 43 | 44 | interface Ethernet1/5 45 | 46 | interface Ethernet1/6 47 | 48 | interface Ethernet1/7 49 | 50 | interface Ethernet1/8 51 | 52 | interface Ethernet1/9 53 | 54 | interface Ethernet1/10 55 | 56 | interface Ethernet1/11 57 | 58 | interface Ethernet1/12 59 | 60 | interface Ethernet1/13 61 | 62 | interface Ethernet1/14 63 | 64 | interface Ethernet1/15 65 | 66 | interface Ethernet1/16 67 | 68 | interface Ethernet1/17 69 | 70 | interface Ethernet1/18 71 | 72 | interface Ethernet1/19 73 | 74 | interface Ethernet1/20 75 | 76 | interface Ethernet1/21 77 | 78 | interface Ethernet1/22 79 | 80 | interface Ethernet1/23 81 | 82 | interface Ethernet1/24 83 | 84 | interface Ethernet1/25 85 | 86 | interface Ethernet1/26 87 | 88 | interface Ethernet1/27 89 | 90 | interface Ethernet1/28 91 | 92 | interface Ethernet1/29 93 | 94 | interface Ethernet1/30 95 | 96 | interface Ethernet1/31 97 | 98 | interface Ethernet1/32 99 | 100 | interface Ethernet1/33 101 | 102 | interface Ethernet1/34 103 | 104 | interface Ethernet1/35 105 | 106 | interface Ethernet1/36 107 | 108 | interface Ethernet1/37 109 | 110 | interface Ethernet1/38 111 | 112 | interface Ethernet1/39 113 | 114 | interface Ethernet1/40 115 | 116 | interface Ethernet1/41 117 | 118 | interface Ethernet1/42 119 | 120 | interface Ethernet1/43 121 | 122 | interface Ethernet1/44 123 | 124 | interface Ethernet1/45 125 | 126 | interface Ethernet1/46 127 | 128 | interface Ethernet1/47 129 | 130 | interface Ethernet1/48 131 | 132 | interface Ethernet1/49 133 | 134 | interface Ethernet1/50 135 | 136 | interface Ethernet1/51 137 | 138 | interface Ethernet1/52 139 | 140 | interface Ethernet1/53 141 | 142 | interface Ethernet1/54 143 | 144 | interface Ethernet1/55 145 | 146 | interface Ethernet1/56 147 | 148 | interface Ethernet1/57 149 | 150 | interface Ethernet1/58 151 | 152 | interface Ethernet1/59 153 | 154 | interface Ethernet1/60 155 | 156 | interface Ethernet1/61 157 | 158 | interface Ethernet1/62 159 | 160 | interface Ethernet1/63 161 | 162 | interface Ethernet1/64 163 | 164 | interface Ethernet1/65 165 | 166 | interface Ethernet1/66 167 | 168 | interface Ethernet1/67 169 | 170 | interface Ethernet1/68 171 | 172 | interface Ethernet1/69 173 | 174 | interface Ethernet1/70 175 | 176 | interface Ethernet1/71 177 | 178 | interface Ethernet1/72 179 | 180 | interface Ethernet1/73 181 | 182 | interface Ethernet1/74 183 | 184 | interface Ethernet1/75 185 | 186 | interface Ethernet1/76 187 | 188 | interface Ethernet1/77 189 | 190 | interface Ethernet1/78 191 | 192 | interface Ethernet1/79 193 | 194 | interface Ethernet1/80 195 | 196 | interface Ethernet1/81 197 | 198 | interface Ethernet1/82 199 | 200 | interface Ethernet1/83 201 | 202 | interface Ethernet1/84 203 | 204 | interface Ethernet1/85 205 | 206 | interface Ethernet1/86 207 | 208 | interface Ethernet1/87 209 | 210 | interface Ethernet1/88 211 | 212 | interface Ethernet1/89 213 | 214 | interface Ethernet1/90 215 | 216 | interface Ethernet1/91 217 | 218 | interface Ethernet1/92 219 | 220 | interface Ethernet1/93 221 | 222 | interface Ethernet1/94 223 | 224 | interface Ethernet1/95 225 | 226 | interface Ethernet1/96 227 | 228 | interface Ethernet1/97 229 | 230 | interface Ethernet1/98 231 | 232 | interface Ethernet1/99 233 | 234 | interface Ethernet1/100 235 | 236 | interface Ethernet1/101 237 | 238 | interface Ethernet1/102 239 | 240 | interface Ethernet1/103 241 | 242 | interface Ethernet1/104 243 | 244 | interface Ethernet1/105 245 | 246 | interface Ethernet1/106 247 | 248 | interface Ethernet1/107 249 | 250 | interface Ethernet1/108 251 | 252 | interface Ethernet1/109 253 | 254 | interface Ethernet1/110 255 | 256 | interface Ethernet1/111 257 | 258 | interface Ethernet1/112 259 | 260 | interface Ethernet1/113 261 | 262 | interface Ethernet1/114 263 | 264 | interface Ethernet1/115 265 | 266 | interface Ethernet1/116 267 | 268 | interface Ethernet1/117 269 | 270 | interface Ethernet1/118 271 | 272 | interface Ethernet1/119 273 | 274 | interface Ethernet1/120 275 | 276 | interface Ethernet1/121 277 | 278 | interface Ethernet1/122 279 | 280 | interface Ethernet1/123 281 | 282 | interface Ethernet1/124 283 | 284 | interface Ethernet1/125 285 | 286 | interface Ethernet1/126 287 | 288 | interface Ethernet1/127 289 | 290 | interface Ethernet1/128 291 | 292 | interface mgmt0 293 | vrf member management 294 | ip address 10.0.0.15/24 295 | line console 296 | line vty 297 | boot nxos bootflash:/nxos.9.2.4.bin -------------------------------------------------------------------------------- /scrapli_cfg/platform/core/cisco_iosxr/base_platform.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.platform.core.cisco_iosxr.base_platform""" 2 | 3 | import re 4 | from logging import Logger, LoggerAdapter 5 | from typing import TYPE_CHECKING, Tuple 6 | 7 | from scrapli_cfg.helper import strip_blank_lines 8 | from scrapli_cfg.platform.core.cisco_iosxr.patterns import ( 9 | BANNER_PATTERN, 10 | END_PATTERN, 11 | OUTPUT_HEADER_PATTERN, 12 | VERSION_PATTERN, 13 | ) 14 | 15 | if TYPE_CHECKING: 16 | LoggerAdapterT = LoggerAdapter[Logger] # pylint:disable=E1136 17 | else: 18 | LoggerAdapterT = LoggerAdapter 19 | 20 | 21 | CONFIG_SOURCES = [ 22 | "running", 23 | ] 24 | 25 | 26 | class ScrapliCfgIOSXRBase: 27 | logger: LoggerAdapterT 28 | _in_configuration_session: bool 29 | _config_privilege_level: str 30 | _replace: bool 31 | candidate_config: str 32 | 33 | @staticmethod 34 | def _parse_version(device_output: str) -> str: 35 | """ 36 | Parse version string out of device output 37 | 38 | Args: 39 | device_output: output from show version command 40 | 41 | Returns: 42 | str: device version string 43 | 44 | Raises: 45 | N/A 46 | 47 | """ 48 | version_string_search = re.search(pattern=VERSION_PATTERN, string=device_output) 49 | 50 | if not version_string_search: 51 | return "" 52 | 53 | version_string = version_string_search.group(0) or "" 54 | return version_string 55 | 56 | @staticmethod 57 | def _prepare_config_payloads(config: str) -> Tuple[str, str]: 58 | """ 59 | Prepare a configuration so it can be nicely sent to the device via scrapli 60 | 61 | Args: 62 | config: configuration to prep 63 | 64 | Returns: 65 | tuple: tuple of "normal" config lines and "eager" config lines 66 | 67 | Raises: 68 | N/A 69 | 70 | """ 71 | # remove building config lines 72 | config = re.sub(pattern=OUTPUT_HEADER_PATTERN, repl="!", string=config) 73 | 74 | # remove "end" at the end of config if present - if its present it will drop scrapli out 75 | # of the config session which we do not want 76 | config = re.sub(pattern=END_PATTERN, repl="!", string=config) 77 | 78 | # find all sections that need to be "eagerly" sent 79 | eager_config = re.findall(pattern=BANNER_PATTERN, string=config) 80 | 81 | for eager_section in eager_config: 82 | # afaik cant backreference a non capturing group so we have an extra group per match 83 | # that we ignore here (element 1) 84 | config = config.replace(eager_section[0], "!") 85 | 86 | joined_eager_config = "\n".join(captured_section[0] for captured_section in eager_config) 87 | 88 | return config, joined_eager_config 89 | 90 | def _prepare_load_config_session_and_payload( 91 | self, config: str, replace: bool, exclusive: bool 92 | ) -> Tuple[str, str]: 93 | """ 94 | Handle pre "load_config" operations for parity between sync and async 95 | 96 | Args: 97 | config: candidate config to load 98 | replace: True/False replace the configuration; passed here so it can be set at the class 99 | level as we need to stay in config mode and we need to know if we are doing a merge 100 | or a replace when we go to diff things 101 | exclusive: True/False use exclusive config mode 102 | 103 | Returns: 104 | tuple: tuple containing "normal" config elements to send to the device and "eager" mode 105 | config elements to send to the device (things like banners/macro that require 106 | scrapli "eager=True") 107 | 108 | Raises: 109 | N/A 110 | 111 | """ 112 | self.candidate_config = config 113 | config, eager_config = self._prepare_config_payloads(config=config) 114 | 115 | self._in_configuration_session = True 116 | self._config_privilege_level = "configuration_exclusive" if exclusive else "configuration" 117 | self._replace = replace 118 | 119 | return config, eager_config 120 | 121 | def _reset_config_session(self) -> None: 122 | """ 123 | Reset config session info 124 | 125 | Resets the candidate config and config session name attributes -- when these are "empty" we 126 | know there is no current config session 127 | 128 | Args: 129 | N/A 130 | 131 | Returns: 132 | None 133 | 134 | Raises: 135 | N/A 136 | 137 | """ 138 | self.logger.debug("resetting candidate config and config session name") 139 | self.candidate_config = "" 140 | self._in_configuration_session = False 141 | self._config_privilege_level = "configuration" 142 | 143 | def _get_diff_command(self) -> str: 144 | """ 145 | Generate diff command based on source to diff and filesystem/candidate config name 146 | 147 | Args: 148 | N/A 149 | 150 | Returns: 151 | str: command to use to diff the configuration 152 | 153 | Raises: 154 | N/A 155 | 156 | """ 157 | if self._replace: 158 | return "show configuration changes diff" 159 | return "show commit changes diff" 160 | 161 | def clean_config(self, config: str) -> str: 162 | """ 163 | Clean a configuration file of unwanted lines 164 | 165 | Args: 166 | config: configuration string to "clean"; cleaning removes leading timestamp/building 167 | config/xr version/last change lines. 168 | 169 | Returns: 170 | str: cleaned configuration string 171 | 172 | Raises: 173 | N/A 174 | 175 | """ 176 | self.logger.debug("cleaning config file") 177 | 178 | # remove any of the leading timestamp/building config/xr version/last change lines in 179 | # both the source and candidate configs so they dont need to be compared 180 | return strip_blank_lines( 181 | config=re.sub(pattern=OUTPUT_HEADER_PATTERN, string=config, repl="") 182 | ) 183 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-telnet].yaml: -------------------------------------------------------------------------------- 1 | localhost:25023:TelnetTransport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25023 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: telnet 11 | auth_secondary: true 12 | interactions: 13 | - channel_output: 'login: ' 14 | expected_channel_input: boxen 15 | expected_channel_input_redacted: false 16 | - channel_output: '' 17 | expected_channel_input: "\n" 18 | expected_channel_input_redacted: false 19 | - channel_output: "boxen\nPassword:" 20 | expected_channel_input: REDACTED 21 | expected_channel_input_redacted: true 22 | - channel_output: '' 23 | expected_channel_input: "\n" 24 | expected_channel_input_redacted: false 25 | - channel_output: "\n\n--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\nboxen> " 26 | expected_channel_input: "\n" 27 | expected_channel_input_redacted: false 28 | - channel_output: "\n\nboxen> " 29 | expected_channel_input: set cli screen-length 0 30 | expected_channel_input_redacted: false 31 | - channel_output: set cli screen-length 0 32 | expected_channel_input: "\n" 33 | expected_channel_input_redacted: false 34 | - channel_output: " \nScreen length set to 0\n\nboxen> " 35 | expected_channel_input: set cli screen-width 511 36 | expected_channel_input_redacted: false 37 | - channel_output: set cli screen-width 511 38 | expected_channel_input: "\n" 39 | expected_channel_input_redacted: false 40 | - channel_output: " \nScreen width set to 511\n\nboxen> " 41 | expected_channel_input: set cli complete-on-space off 42 | expected_channel_input_redacted: false 43 | - channel_output: set cli complete-on-space off 44 | expected_channel_input: "\n" 45 | expected_channel_input_redacted: false 46 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 47 | expected_channel_input: 'show version | grep junos:' 48 | expected_channel_input_redacted: false 49 | - channel_output: 'show version | grep junos:' 50 | expected_channel_input: "\n" 51 | expected_channel_input_redacted: false 52 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 53 | expected_channel_input: show configuration 54 | expected_channel_input_redacted: false 55 | - channel_output: show configuration 56 | expected_channel_input: "\n" 57 | expected_channel_input_redacted: false 58 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 59 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 60 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 61 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 62 | \ 2000;\n class super-user;\n authentication {\n \ 63 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 64 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 65 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 66 | \ netconf {\n ssh;\n }\n web-management {\n\ 67 | \ http {\n interface fxp0.0;\n }\n \ 68 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 69 | \ }\n file messages {\n any any;\n authorization\ 70 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 71 | \ any;\n }\n }\n license {\n autoupdate {\n \ 72 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 73 | security {\n screen {\n ids-option untrust-screen {\n \ 74 | \ icmp {\n ping-death;\n }\n ip {\n\ 75 | \ source-route-option;\n tear-drop;\n \ 76 | \ }\n tcp {\n syn-flood {\n \ 77 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 78 | \ source-threshold 1024;\n destination-threshold\ 79 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 80 | \ deprecated\n timeout 20;\n }\n \ 81 | \ land;\n }\n }\n }\n policies {\n \ 82 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 83 | \ match {\n source-address any;\n \ 84 | \ destination-address any;\n application any;\n\ 85 | \ }\n then {\n permit;\n\ 86 | \ }\n }\n }\n from-zone trust to-zone\ 87 | \ untrust {\n policy default-permit {\n match {\n\ 88 | \ source-address any;\n destination-address\ 89 | \ any;\n application any;\n }\n \ 90 | \ then {\n permit;\n }\n \ 91 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 92 | \ tcp-rst;\n }\n security-zone untrust {\n \ 93 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 94 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 95 | \ }\n }\n }\n}\n\nboxen> " 96 | expected_channel_input: "\n" 97 | expected_channel_input_redacted: false 98 | - channel_output: "\n\nboxen> " 99 | expected_channel_input: exit 100 | expected_channel_input_redacted: false 101 | - channel_output: '' 102 | expected_channel_input: "\n" 103 | expected_channel_input_redacted: false 104 | -------------------------------------------------------------------------------- /tests/integration/platform/scrapli_replay_sessions/test_get_config[juniper_junos-asynctelnet].yaml: -------------------------------------------------------------------------------- 1 | localhost:25023:AsynctelnetTransport::0: 2 | connection_profile: 3 | host: localhost 4 | port: 25023 5 | auth_username: boxen 6 | auth_password: true 7 | auth_private_key: '' 8 | auth_private_key_passphrase: false 9 | auth_bypass: false 10 | transport: asynctelnet 11 | auth_secondary: false 12 | interactions: 13 | - channel_output: 'login: ' 14 | expected_channel_input: boxen 15 | expected_channel_input_redacted: false 16 | - channel_output: '' 17 | expected_channel_input: "\n" 18 | expected_channel_input_redacted: false 19 | - channel_output: 'Password:' 20 | expected_channel_input: REDACTED 21 | expected_channel_input_redacted: true 22 | - channel_output: '' 23 | expected_channel_input: "\n" 24 | expected_channel_input_redacted: false 25 | - channel_output: "\n\n--- JUNOS 17.3R2.10 built 2018-02-08 02:19:07 UTC\nboxen> " 26 | expected_channel_input: "\n" 27 | expected_channel_input_redacted: false 28 | - channel_output: "\n\nboxen> " 29 | expected_channel_input: set cli screen-length 0 30 | expected_channel_input_redacted: false 31 | - channel_output: set cli screen-length 0 32 | expected_channel_input: "\n" 33 | expected_channel_input_redacted: false 34 | - channel_output: " \nScreen length set to 0\n\nboxen> " 35 | expected_channel_input: set cli screen-width 511 36 | expected_channel_input_redacted: false 37 | - channel_output: set cli screen-width 511 38 | expected_channel_input: "\n" 39 | expected_channel_input_redacted: false 40 | - channel_output: " \nScreen width set to 511\n\nboxen> " 41 | expected_channel_input: set cli complete-on-space off 42 | expected_channel_input_redacted: false 43 | - channel_output: set cli complete-on-space off 44 | expected_channel_input: "\n" 45 | expected_channel_input_redacted: false 46 | - channel_output: " \nDisabling complete-on-space\n\nboxen> " 47 | expected_channel_input: 'show version | grep junos:' 48 | expected_channel_input_redacted: false 49 | - channel_output: 'show version | grep junos:' 50 | expected_channel_input: "\n" 51 | expected_channel_input_redacted: false 52 | - channel_output: " \nJunos: 17.3R2.10\n\nboxen> " 53 | expected_channel_input: show configuration 54 | expected_channel_input_redacted: false 55 | - channel_output: show configuration 56 | expected_channel_input: "\n" 57 | expected_channel_input_redacted: false 58 | - channel_output: " \n## Last commit: 2021-05-29 12:46:18 UTC by boxen\nversion\ 59 | \ 17.3R2.10;\nsystem {\n root-authentication {\n encrypted-password\ 60 | \ \"$6$RhR81Jm4$DEXKIbZNGjv.agJvM.FlIZWtFqX/966PZk0r4/Ps3LlS.OQZn9fHoVGuYJ7Q.hj2OQLyPJO6Mq7aQ3xLQiNrx/\"\ 61 | ; ## SECRET-DATA\n }\n login {\n user boxen {\n uid\ 62 | \ 2000;\n class super-user;\n authentication {\n \ 63 | \ encrypted-password \"$6$iYt26fU9$gkt6bgxPs.VqHgCoLuSD6Kxv1JUHJLQzXJgzAEUIxobvxWwRErtpaOFvBOjIHr3KMI7sEo.V/7xLXzr0Ok20h0\"\ 64 | ; ## SECRET-DATA\n }\n }\n }\n services {\n \ 65 | \ ssh {\n protocol-version v2;\n }\n telnet;\n \ 66 | \ netconf {\n ssh;\n }\n web-management {\n\ 67 | \ http {\n interface fxp0.0;\n }\n \ 68 | \ }\n }\n syslog {\n user * {\n any emergency;\n\ 69 | \ }\n file messages {\n any any;\n authorization\ 70 | \ info;\n }\n file interactive-commands {\n interactive-commands\ 71 | \ any;\n }\n }\n license {\n autoupdate {\n \ 72 | \ url https://ae1.juniper.net/junos/key_retrieval;\n }\n }\n}\n\ 73 | security {\n screen {\n ids-option untrust-screen {\n \ 74 | \ icmp {\n ping-death;\n }\n ip {\n\ 75 | \ source-route-option;\n tear-drop;\n \ 76 | \ }\n tcp {\n syn-flood {\n \ 77 | \ alarm-threshold 1024;\n attack-threshold 200;\n \ 78 | \ source-threshold 1024;\n destination-threshold\ 79 | \ 2048;\n queue-size 2000; ## Warning: 'queue-size' is\ 80 | \ deprecated\n timeout 20;\n }\n \ 81 | \ land;\n }\n }\n }\n policies {\n \ 82 | \ from-zone trust to-zone trust {\n policy default-permit {\n \ 83 | \ match {\n source-address any;\n \ 84 | \ destination-address any;\n application any;\n\ 85 | \ }\n then {\n permit;\n\ 86 | \ }\n }\n }\n from-zone trust to-zone\ 87 | \ untrust {\n policy default-permit {\n match {\n\ 88 | \ source-address any;\n destination-address\ 89 | \ any;\n application any;\n }\n \ 90 | \ then {\n permit;\n }\n \ 91 | \ }\n }\n }\n zones {\n security-zone trust {\n \ 92 | \ tcp-rst;\n }\n security-zone untrust {\n \ 93 | \ screen untrust-screen;\n }\n }\n}\ninterfaces {\n fxp0 {\n\ 94 | \ unit 0 {\n family inet {\n address 10.0.0.15/24;\n\ 95 | \ }\n }\n }\n}\n\nboxen> " 96 | expected_channel_input: "\n" 97 | expected_channel_input_redacted: false 98 | - channel_output: "\n\nboxen> " 99 | expected_channel_input: exit 100 | expected_channel_input_redacted: false 101 | - channel_output: '' 102 | expected_channel_input: "\n" 103 | expected_channel_input_redacted: false 104 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """scrapli_cfg.noxfile""" 2 | 3 | import os 4 | import re 5 | import sys 6 | from pathlib import Path 7 | from typing import Dict, List 8 | 9 | import nox 10 | 11 | nox.options.error_on_missing_interpreters = False 12 | nox.options.stop_on_first_error = False 13 | nox.options.default_venv_backend = "venv" 14 | 15 | PRE = bool(os.environ.get("PRE_RELEASE")) 16 | 17 | 18 | def parse_requirements(dev: bool = True) -> Dict[str, str]: 19 | """ 20 | Parse requirements file 21 | 22 | Args: 23 | dev: parse dev requirements (or not) 24 | 25 | Returns: 26 | dict: dict of parsed requirements 27 | 28 | Raises: 29 | N/A 30 | 31 | """ 32 | requirements = {} 33 | requirements_file = "requirements.txt" if not dev else "requirements-dev.txt" 34 | 35 | with open(requirements_file, "r", encoding="utf-8") as f: 36 | requirements_file_lines = f.readlines() 37 | 38 | requirements_lines: List[str] = [ 39 | line 40 | for line in requirements_file_lines 41 | if not line.startswith("-r") and not line.startswith("#") and not line.startswith("-e") 42 | ] 43 | editable_requirements_lines: List[str] = [ 44 | line for line in requirements_file_lines if line.startswith("-e") 45 | ] 46 | 47 | for requirement in requirements_lines: 48 | parsed_requirement = re.match( 49 | pattern=r"^([a-z0-9\-\_\.\[\]]+)([><=]{1,2}\S*)(?:.*)$", 50 | string=requirement, 51 | flags=re.I | re.M, 52 | ) 53 | requirements[parsed_requirement.groups()[0]] = parsed_requirement.groups()[1] 54 | 55 | for requirement in editable_requirements_lines: 56 | parsed_requirement = re.match( 57 | pattern=r"^-e\s.*(?:#egg=)(\w+)$", string=requirement, flags=re.I | re.M 58 | ) 59 | requirements[parsed_requirement.groups()[0]] = requirement 60 | 61 | return requirements 62 | 63 | 64 | REQUIREMENTS: Dict[str, str] = parse_requirements(dev=False) 65 | DEV_REQUIREMENTS: Dict[str, str] = parse_requirements(dev=True) 66 | PLATFORM: str = sys.platform 67 | SKIP_LIST: List[str] = [ 68 | "unit_tests-darwin-3.11", 69 | "integration_tests-darwin-3.11", 70 | ] 71 | 72 | 73 | def _get_install_test_args() -> List[str]: 74 | args = [".[dev]"] 75 | 76 | if sys.platform == "darwin": 77 | args = [".[dev-darwin]"] 78 | 79 | if PRE: 80 | args.append("--pre") 81 | 82 | return args 83 | 84 | 85 | @nox.session(python=["3.9", "3.10", "3.11", "3.12", "3.13"]) 86 | def unit_tests(session): 87 | """ 88 | Nox run unit tests 89 | 90 | Args: 91 | session: nox session 92 | 93 | Returns: 94 | None 95 | 96 | Raises: 97 | N/A 98 | 99 | """ 100 | if f"unit_tests-{PLATFORM}-{session.python}" in SKIP_LIST: 101 | return 102 | 103 | session.install("-U", "setuptools", "wheel", "pip") 104 | session.install(*_get_install_test_args()) 105 | session.run( 106 | "python", 107 | "-m", 108 | "pytest", 109 | "--cov=scrapli_cfg", 110 | "--cov-report", 111 | "xml", 112 | "--cov-report", 113 | "term", 114 | "tests/unit", 115 | "-v", 116 | ) 117 | 118 | 119 | @nox.session(python=["3.13"]) 120 | def integration_tests(session): 121 | """ 122 | Nox run integration tests 123 | 124 | Args: 125 | session: nox session 126 | 127 | Returns: 128 | None 129 | 130 | Raises: 131 | N/A 132 | 133 | """ 134 | if f"integration_tests-{PLATFORM}-{session.python}" in SKIP_LIST: 135 | return 136 | 137 | session.install("-U", "setuptools", "wheel", "pip") 138 | session.install(*_get_install_test_args()) 139 | # setting scrapli boxen -> 1 so that the saved scrapli replay sessions are "correctly" 140 | # pointing to the boxen dev env (i.e. port 21022 instead of 22 for iosxe, etc.) 141 | session.run( 142 | "python", 143 | "-m", 144 | "pytest", 145 | "--cov=scrapli_cfg", 146 | "--cov-report", 147 | "xml", 148 | "--cov-report", 149 | "term", 150 | "tests/integration", 151 | "-v", 152 | env={"SCRAPLI_BOXEN": "1"}, 153 | ) 154 | 155 | 156 | @nox.session(python=["3.13"]) 157 | def isort(session): 158 | """ 159 | Nox run isort 160 | 161 | Args: 162 | session: nox session 163 | 164 | Returns: 165 | None 166 | 167 | Raises: 168 | N/A 169 | 170 | """ 171 | session.install(f"toml{DEV_REQUIREMENTS['toml']}") 172 | session.install(f"isort{DEV_REQUIREMENTS['isort']}") 173 | session.run("python", "-m", "isort", "-c", ".") 174 | 175 | 176 | @nox.session(python=["3.13"]) 177 | def black(session): 178 | """ 179 | Nox run black 180 | 181 | Args: 182 | session: nox session 183 | 184 | Returns: 185 | None 186 | 187 | Raises: 188 | N/A 189 | 190 | """ 191 | session.install(f"toml{DEV_REQUIREMENTS['toml']}") 192 | session.install(f"black{DEV_REQUIREMENTS['black']}") 193 | session.run("python", "-m", "black", "--check", ".") 194 | 195 | 196 | @nox.session(python=["3.13"]) 197 | def pylint(session): 198 | """ 199 | Nox run pylint 200 | 201 | Args: 202 | session: nox session 203 | 204 | Returns: 205 | None 206 | 207 | Raises: 208 | N/A 209 | 210 | """ 211 | session.install(*_get_install_test_args()) 212 | session.run("python", "-m", "pylint", "scrapli_cfg/") 213 | 214 | 215 | @nox.session(python=["3.13"]) 216 | def pydocstyle(session): 217 | """ 218 | Nox run pydocstyle 219 | 220 | Args: 221 | session: nox session 222 | 223 | Returns: 224 | None 225 | 226 | Raises: 227 | N/A 228 | 229 | """ 230 | session.install(f"toml{DEV_REQUIREMENTS['toml']}") 231 | session.install(f"pydocstyle{DEV_REQUIREMENTS['pydocstyle']}") 232 | session.run("python", "-m", "pydocstyle", ".") 233 | 234 | 235 | @nox.session(python=["3.13"]) 236 | def mypy(session): 237 | """ 238 | Nox run mypy 239 | 240 | Args: 241 | session: nox session 242 | 243 | Returns: 244 | None 245 | 246 | Raises: 247 | N/A 248 | 249 | """ 250 | session.install(".") 251 | session.install(f"toml{DEV_REQUIREMENTS['toml']}") 252 | session.install(f"mypy{DEV_REQUIREMENTS['mypy']}") 253 | session.run("python", "-m", "mypy", "--strict", "scrapli_cfg/") 254 | 255 | 256 | @nox.session(python=["3.13"]) 257 | def darglint(session): 258 | """ 259 | Nox run darglint 260 | 261 | Args: 262 | session: nox session 263 | 264 | Returns: 265 | None 266 | 267 | Raises: 268 | N/A 269 | 270 | """ 271 | session.install(f"darglint{DEV_REQUIREMENTS['darglint']}") 272 | for file in Path("scrapli_cfg").rglob("*.py"): 273 | session.run("darglint", f"{file.absolute()}") 274 | -------------------------------------------------------------------------------- /tests/helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def cisco_iosxe_clean_response(response): 5 | def _replace_config_bytes(response): 6 | config_bytes_pattern = re.compile(r"^Current configuration : \d+ bytes$", flags=re.M | re.I) 7 | response = re.sub(config_bytes_pattern, "Current configuration : CONFIG_BYTES", response) 8 | return response 9 | 10 | def _replace_timestamps(response): 11 | datetime_pattern = re.compile( 12 | r"\d+:\d+:\d+\d+\s+[a-z]{3}\s+(mon|tue|wed|thu|fri|sat|sun)\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d+\s+\d+", 13 | flags=re.M | re.I, 14 | ) 15 | response = re.sub(datetime_pattern, "TIME_STAMP_REPLACED", response) 16 | return response 17 | 18 | def _replace_configured_by(response): 19 | configured_by_pattern = re.compile( 20 | r"^! Last configuration change at TIME_STAMP_REPLACED by (\w+)$", flags=re.M | re.I 21 | ) 22 | response = re.sub( 23 | configured_by_pattern, "! Last configuration change at TIME_STAMP_REPLACED", response 24 | ) 25 | return response 26 | 27 | def _replace_hashed_passwords(response): 28 | crypto_pattern = re.compile(r"^enable secret 5 (.*$)", flags=re.M | re.I) 29 | response = re.sub(crypto_pattern, "enable secret 5 HASHED_PASSWORD", response) 30 | return response 31 | 32 | def _replace_call_home_comment(response): 33 | # vrnetlab router seems to get this comment string but vrouter one does not. unclear why, 34 | # but we'll just remove it just in case 35 | crypto_pattern = re.compile( 36 | r"(^.*$)\n^! Call-home is enabled by Smart-Licensing.$(\n^.*$)", flags=re.M | re.I 37 | ) 38 | response = re.sub(crypto_pattern, r"\1\2", response) 39 | return response 40 | 41 | def _replace_certificates_and_license(response): 42 | # replace pki/certificate stuff and license all in one go -- this is always lumped together 43 | # but in vrnetlab vs vrouter things are sometimes in different order (trustpoints are 44 | # switched for example) so comparing strings obviously fails even though content is correct 45 | crypto_pattern = re.compile( 46 | r"^crypto pki .*\nlicense udi pid CSR1000V sn \w+$", flags=re.M | re.I | re.S 47 | ) 48 | response = re.sub(crypto_pattern, "CERTIFICATES AND LICENSE", response) 49 | return response 50 | 51 | response = _replace_config_bytes(response) 52 | response = _replace_timestamps(response) 53 | response = _replace_configured_by(response) 54 | response = _replace_hashed_passwords(response) 55 | response = _replace_call_home_comment(response) 56 | response = _replace_certificates_and_license(response) 57 | return response 58 | 59 | 60 | def cisco_iosxr_clean_response(response): 61 | def _replace_timestamps(response): 62 | datetime_pattern = re.compile( 63 | r"(mon|tue|wed|thu|fri|sat|sun)\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d+\s+\d+:\d+:\d+((\.\d+\s\w+)|\s\d+)", 64 | flags=re.M | re.I, 65 | ) 66 | response = re.sub(datetime_pattern, "TIME_STAMP_REPLACED", response) 67 | return response 68 | 69 | def _replace_configured_by(response): 70 | configured_by_pattern = re.compile( 71 | r"^!! Last configuration change at TIME_STAMP_REPLACED by (\w+)$", flags=re.M | re.I 72 | ) 73 | response = re.sub( 74 | configured_by_pattern, "!! Last configuration change at TIME_STAMP_REPLACED", response 75 | ) 76 | return response 77 | 78 | def _replace_crypto_strings(response): 79 | crypto_pattern = re.compile(r"^\ssecret\s5\s[\w$\.\/]+$", flags=re.M | re.I) 80 | response = re.sub(crypto_pattern, "CRYPTO_REPLACED", response) 81 | return response 82 | 83 | def _replace_commit_in_progress(response): 84 | commit_in_progress_pattern = re.compile(r"System configuration.*", flags=re.M | re.I | re.S) 85 | response = re.sub(commit_in_progress_pattern, "", response) 86 | return response.rstrip() 87 | 88 | response = _replace_timestamps(response) 89 | response = _replace_configured_by(response) 90 | response = _replace_crypto_strings(response) 91 | response = _replace_commit_in_progress(response) 92 | return response 93 | 94 | 95 | def cisco_nxos_clean_response(response): 96 | def _replace_timestamps(response): 97 | datetime_pattern = re.compile( 98 | r"(mon|tue|wed|thu|fri|sat|sun)\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d+\s+\d+:\d+:\d+\s\d+", 99 | flags=re.M | re.I, 100 | ) 101 | response = re.sub(datetime_pattern, "TIME_STAMP_REPLACED", response) 102 | return response 103 | 104 | def _replace_crypto_strings(response): 105 | crypto_pattern = re.compile(r"^(.*?\s(?:5|md5)\s)[\w$\.\/]+.*$", flags=re.M | re.I) 106 | response = re.sub(crypto_pattern, "CRYPTO_REPLACED", response) 107 | return response 108 | 109 | def _replace_resource_limits(response): 110 | crypto_pattern = re.compile( 111 | r"^(\s+limit-resource\s[a-z0-9\-]+\sminimum\s)\d+\smaximum\s\d+$", flags=re.M | re.I 112 | ) 113 | response = re.sub(crypto_pattern, r"\1N maximum N", response) 114 | return response 115 | 116 | response = _replace_timestamps(response) 117 | response = _replace_crypto_strings(response) 118 | response = _replace_resource_limits(response) 119 | return response 120 | 121 | 122 | def arista_eos_clean_response(response): 123 | def _replace_timestamps(response): 124 | datetime_pattern = re.compile( 125 | r"(mon|tue|wed|thu|fri|sat|sun)\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d+\s+\d+:\d+:\d+\s+\d+$", 126 | flags=re.M | re.I, 127 | ) 128 | response = re.sub(datetime_pattern, "TIME_STAMP_REPLACED", response) 129 | return response 130 | 131 | def _replace_crypto_strings(response): 132 | crypto_pattern = re.compile(r"secret\ssha512\s[\w$\.\/]+$", flags=re.M | re.I) 133 | response = re.sub(crypto_pattern, "CRYPTO_REPLACED", response) 134 | return response 135 | 136 | response = _replace_timestamps(response) 137 | response = _replace_crypto_strings(response) 138 | return response 139 | 140 | 141 | def juniper_junos_clean_response(response): 142 | def _replace_timestamps(response): 143 | datetime_pattern = re.compile( 144 | r"^## Last commit: \d+-\d+-\d+\s\d+:\d+:\d+\s\w+.*$", flags=re.M | re.I 145 | ) 146 | response = re.sub(datetime_pattern, "TIME_STAMP_REPLACED", response) 147 | return response 148 | 149 | def _replace_crypto_strings(response): 150 | crypto_pattern = re.compile( 151 | r'^\s+encrypted-password\s"[\w$\.\/]+";\s.*$', flags=re.M | re.I 152 | ) 153 | response = re.sub(crypto_pattern, "CRYPTO_REPLACED", response) 154 | return response 155 | 156 | response = _replace_timestamps(response) 157 | response = _replace_crypto_strings(response) 158 | return response 159 | --------------------------------------------------------------------------------