From 8ace891fe56274309c03c5dbdf4ea07a2cd8cf4e Mon Sep 17 00:00:00 2001 From: Carl Montanari <8515611+carlmontanari@users.noreply.github.com> Date: Fri, 30 Jul 2021 16:15:26 -0700 Subject: [PATCH] Prepare Release (#63) * 2021.07.30 release; scraplicfg support, cleanup --- .github/dependabot.yml | 4 +- .github/workflows/commit.yaml | 21 +- .github/workflows/publish.yaml | 6 + .github/workflows/weekly.yaml | 18 +- .pylintrc | 7 +- LICENSE | 2 +- Makefile | 26 +- docs/about/contributing.md | 13 +- docs/api_docs/connection.md | 397 ++++++++++++++++-- docs/api_docs/result.md | 27 +- docs/api_docs/tasks.md | 209 +++++++-- docs/generate/mkdocs_markdown.mako | 20 +- docs/htmltest.yml | 12 + docs/more_scrapli/scrapli.md | 4 +- docs/more_scrapli/scrapli_cfg.md | 7 + docs/more_scrapli/scrapli_community.md | 2 +- docs/more_scrapli/scrapli_netconf.md | 2 +- docs/more_scrapli/scrapli_replay.md | 7 + docs/more_scrapli/scrapli_stubs.md | 5 - docs/user_guide/available_functions.md | 2 +- docs/user_guide/available_tasks.md | 48 ++- docs/user_guide/project_details.md | 6 +- examples/basic_netconf_usage/demo.py | 11 +- examples/structured_data/demo.py | 2 + mkdocs.yml | 15 +- nornir_scrapli/connection.py | 137 +++++- .../functions/print_structured_result.py | 2 +- nornir_scrapli/py.typed | 0 nornir_scrapli/result.py | 11 +- nornir_scrapli/tasks/__init__.py | 50 ++- nornir_scrapli/tasks/cfg/__init__.py | 1 + nornir_scrapli/tasks/cfg/abort_config.py | 33 ++ nornir_scrapli/tasks/cfg/commit_config.py | 34 ++ nornir_scrapli/tasks/cfg/diff_config.py | 37 ++ nornir_scrapli/tasks/cfg/get_config.py | 33 ++ nornir_scrapli/tasks/cfg/get_version.py | 24 ++ nornir_scrapli/tasks/cfg/load_config.py | 42 ++ nornir_scrapli/tasks/core/__init__.py | 1 + nornir_scrapli/tasks/{ => core}/get_prompt.py | 0 .../tasks/{ => core}/send_command.py | 0 .../tasks/{ => core}/send_commands.py | 0 .../{ => core}/send_commands_from_file.py | 0 .../tasks/{ => core}/send_config.py | 0 .../tasks/{ => core}/send_configs.py | 0 .../{ => core}/send_configs_from_file.py | 0 .../tasks/{ => core}/send_interactive.py | 0 nornir_scrapli/tasks/netconf/__init__.py | 1 + .../capabilities.py} | 0 .../{netconf_commit.py => netconf/commit.py} | 0 .../delete_config.py} | 0 .../discard.py} | 0 .../edit_config.py} | 0 .../tasks/{netconf_get.py => netconf/get.py} | 0 .../get_config.py} | 0 .../{netconf_lock.py => netconf/lock.py} | 0 .../tasks/{netconf_rpc.py => netconf/rpc.py} | 0 .../{netconf_unlock.py => netconf/unlock.py} | 0 .../validate.py} | 0 noxfile.py | 106 +++-- pyproject.toml | 13 - requirements-dev.txt | 23 +- requirements-docs.txt | 8 +- requirements-genie.txt | 4 +- requirements.txt | 11 +- setup.cfg | 27 +- setup.py | 30 +- .../functions/test_print_structured_result.py | 8 +- tests/unit/tasks/cfg/__init__.py | 0 tests/unit/tasks/cfg/test_abort_config.py | 31 ++ tests/unit/tasks/cfg/test_commit_config.py | 32 ++ tests/unit/tasks/cfg/test_diff_config.py | 32 ++ tests/unit/tasks/cfg/test_get_config.py | 31 ++ tests/unit/tasks/cfg/test_get_version.py | 30 ++ tests/unit/tasks/cfg/test_load_config.py | 33 ++ 74 files changed, 1433 insertions(+), 265 deletions(-) create mode 100644 docs/htmltest.yml create mode 100644 docs/more_scrapli/scrapli_cfg.md create mode 100644 docs/more_scrapli/scrapli_replay.md delete mode 100644 docs/more_scrapli/scrapli_stubs.md create mode 100644 nornir_scrapli/py.typed create mode 100644 nornir_scrapli/tasks/cfg/__init__.py create mode 100644 nornir_scrapli/tasks/cfg/abort_config.py create mode 100644 nornir_scrapli/tasks/cfg/commit_config.py create mode 100644 nornir_scrapli/tasks/cfg/diff_config.py create mode 100644 nornir_scrapli/tasks/cfg/get_config.py create mode 100644 nornir_scrapli/tasks/cfg/get_version.py create mode 100644 nornir_scrapli/tasks/cfg/load_config.py create mode 100644 nornir_scrapli/tasks/core/__init__.py rename nornir_scrapli/tasks/{ => core}/get_prompt.py (100%) rename nornir_scrapli/tasks/{ => core}/send_command.py (100%) rename nornir_scrapli/tasks/{ => core}/send_commands.py (100%) rename nornir_scrapli/tasks/{ => core}/send_commands_from_file.py (100%) rename nornir_scrapli/tasks/{ => core}/send_config.py (100%) rename nornir_scrapli/tasks/{ => core}/send_configs.py (100%) rename nornir_scrapli/tasks/{ => core}/send_configs_from_file.py (100%) rename nornir_scrapli/tasks/{ => core}/send_interactive.py (100%) create mode 100644 nornir_scrapli/tasks/netconf/__init__.py rename nornir_scrapli/tasks/{netconf_capabilities.py => netconf/capabilities.py} (100%) rename nornir_scrapli/tasks/{netconf_commit.py => netconf/commit.py} (100%) rename nornir_scrapli/tasks/{netconf_delete_config.py => netconf/delete_config.py} (100%) rename nornir_scrapli/tasks/{netconf_discard.py => netconf/discard.py} (100%) rename nornir_scrapli/tasks/{netconf_edit_config.py => netconf/edit_config.py} (100%) rename nornir_scrapli/tasks/{netconf_get.py => netconf/get.py} (100%) rename nornir_scrapli/tasks/{netconf_get_config.py => netconf/get_config.py} (100%) rename nornir_scrapli/tasks/{netconf_lock.py => netconf/lock.py} (100%) rename nornir_scrapli/tasks/{netconf_rpc.py => netconf/rpc.py} (100%) rename nornir_scrapli/tasks/{netconf_unlock.py => netconf/unlock.py} (100%) rename nornir_scrapli/tasks/{netconf_validate.py => netconf/validate.py} (100%) create mode 100644 tests/unit/tasks/cfg/__init__.py create mode 100644 tests/unit/tasks/cfg/test_abort_config.py create mode 100644 tests/unit/tasks/cfg/test_commit_config.py create mode 100644 tests/unit/tasks/cfg/test_diff_config.py create mode 100644 tests/unit/tasks/cfg/test_get_config.py create mode 100644 tests/unit/tasks/cfg/test_get_version.py create mode 100644 tests/unit/tasks/cfg/test_load_config.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 90231c7..855d954 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,7 @@ updates: interval: "weekly" day: "sunday" timezone: "PST8PDT" - time: "07:00" + time: "03:00" target-branch: "develop" - package-ecosystem: "pip" directory: "/" @@ -14,5 +14,5 @@ updates: interval: "weekly" day: "sunday" timezone: "PST8PDT" - time: "07:00" + time: "03:00" target-branch: "develop" diff --git a/.github/workflows/commit.yaml b/.github/workflows/commit.yaml index 54c8705..cb4e1d2 100644 --- a/.github/workflows/commit.yaml +++ b/.github/workflows/commit.yaml @@ -27,10 +27,10 @@ jobs: build_posix: runs-on: ${{ matrix.os }} strategy: - max-parallel: 9 + max-parallel: 12 matrix: os: [ubuntu-latest, macos-latest] - version: [3.6, 3.7, 3.8, 3.9] + version: [3.6, 3.7, 3.8, 3.9, 3.10.0-beta.4] steps: - uses: actions/checkout@v2 - name: set up python ${{ matrix.version }} @@ -42,8 +42,8 @@ jobs: # version we are targeting with nox, while still having versions like 3.9.0a4 run: | echo "FRIENDLY_PYTHON_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" >> $GITHUB_ENV - - name: install libxml2 and libxslt seems to only be needed for 3.9 image for some reason - if: matrix.os == 'ubuntu-latest' && matrix.version == '3.9' + - name: install libxml2 and libxslt seems to only be needed for 3.9+ image for some reason + if: matrix.os == 'ubuntu-latest' && matrix.version == '3.9' || matrix.os == 'ubuntu-latest' && matrix.version == '3.10.0-beta.4' run: | sudo apt install libxml2-dev sudo apt install libxslt-dev @@ -53,4 +53,17 @@ jobs: python -m pip install setuptools python -m pip install nox - name: run nox + env: + # needed to make the terminal a tty (i think? without this system ssh is super broken) + TERM: xterm run: python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" + + docs-test: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - run: docker run -v $(pwd):/docs --entrypoint "" squidfunk/mkdocs-material:latest ash -c 'pip install mdx_gh_links && mkdocs build --clean --strict' + - name: htmltest + run: | + curl https://htmltest.wjdp.uk | bash + ./bin/htmltest -c docs/htmltest.yml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index e083476..6718604 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -26,3 +26,9 @@ jobs: run: | python setup.py sdist bdist_wheel python -m twine upload dist/* + - name: create release branch + uses: peterjgrainger/action-create-branch@v2.0.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + branch: ${{ github.event.release.tag_name }} \ No newline at end of file diff --git a/.github/workflows/weekly.yaml b/.github/workflows/weekly.yaml index f612da3..71997f6 100644 --- a/.github/workflows/weekly.yaml +++ b/.github/workflows/weekly.yaml @@ -2,8 +2,8 @@ name: Weekly Build on: schedule: - # weekly at 0700 PST/1400 UTC on Sunday - - cron: '0 14 * * 0' + # weekly at 0300 PST/1000 UTC on Sunday + - cron: '0 10 * * 0' workflow_dispatch: jobs: @@ -31,10 +31,10 @@ jobs: build_posix: runs-on: ${{ matrix.os }} strategy: - max-parallel: 9 + max-parallel: 12 matrix: os: [ubuntu-latest, macos-latest] - version: [3.6, 3.7, 3.8, 3.9] + version: [3.6, 3.7, 3.8, 3.9, 3.10.0-beta.4] steps: - uses: actions/checkout@v2 - name: set up python ${{ matrix.version }} @@ -46,8 +46,8 @@ jobs: # version we are targeting with nox, while still having versions like 3.9.0a4 run: | echo "FRIENDLY_PYTHON_VERSION=$(python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")" >> $GITHUB_ENV - - name: install libxml2 and libxslt seems to only be needed for 3.9 image for some reason - if: matrix.os == 'ubuntu-latest' && matrix.version == '3.9' + - name: install libxml2 and libxslt seems to only be needed for 3.9+ image for some reason + if: matrix.os == 'ubuntu-latest' && matrix.version == '3.9' || matrix.os == 'ubuntu-latest' && matrix.version == '3.10.0-beta4' run: | sudo apt install libxml2-dev sudo apt install libxslt-dev @@ -57,5 +57,7 @@ jobs: python -m pip install setuptools python -m pip install nox - name: run nox - run: python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" - + env: + # needed to make the terminal a tty (i think? without this system ssh is super broken) + TERM: xterm + run: python -m nox -p $FRIENDLY_PYTHON_VERSION -k "not darglint" \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 34f9ac9..162d46c 100644 --- a/.pylintrc +++ b/.pylintrc @@ -65,7 +65,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=C0103,C0115,C0330,W1202,W1203,R0902,R0913 +disable=C0103,C0115,C0330,R0901,R0902,R0913,W1202,W1203 # C0103 = constant-name (a little too aggressive for some things that aren't "really" constants") # C0115 = class docstrings (init doc strings cover this already) # C0330 = bad-continuation (hanging indent that black doesnt like) @@ -73,6 +73,7 @@ disable=C0103,C0115,C0330,W1202,W1203,R0902,R0913 # W1203 = logging-fstring-interpolation (py3.6, using f-strings so dont care) # R0902 = too-many-instance-attributes # R0913 = too-many-arguments +# R0901 = too-many-ancestors (ignore because it counts mixins and i think thats not useful here) [REPORTS] @@ -398,7 +399,7 @@ int-import-graph= known-standard-library= # Force import order to recognize a module as part of a third party library. -known-third-party=nornir,nornir_utils +known-third-party=enchant # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists @@ -410,4 +411,4 @@ analyse-fallback-blocks=no # Exceptions that will emit a warning when being caught. Defaults to # "Exception" -overgeneral-exceptions=Exception \ No newline at end of file +overgeneral-exceptions=Exception diff --git a/LICENSE b/LICENSE index e643fdf..47d60d0 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/Makefile b/Makefile index 8d697ec..59058d1 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,14 @@ lint: python -m black . python -m pylama . python -m pydocstyle . - python -m mypy nornir_scrapli/ + python -m mypy --strict nornir_scrapli/ + +darglint: + find nornir_scrapli -type f \( -iname "*.py"\ ) | xargs darglint -x + +test: + python -m pytest \ + tests/ cov: python -m pytest \ @@ -12,12 +19,25 @@ cov: --cov-report term \ tests/ -test: - python -m pytest tests/ +test_unit: + python -m pytest \ + tests/unit/ + +cov_unit: + python -m pytest \ + --cov=nornir_scrapli \ + --cov-report html \ + --cov-report term \ + tests/unit/ .PHONY: docs docs: python docs/generate/generate_docs.py +test_docs: + mkdocs build --clean --strict + htmltest -c docs/htmltest.yml -s + rm -rf tmp + deploy_docs: mkdocs gh-deploy diff --git a/docs/about/contributing.md b/docs/about/contributing.md index 8cc207a..38095ab 100644 --- a/docs/about/contributing.md +++ b/docs/about/contributing.md @@ -1,16 +1,17 @@ Contributing -======= +============ -Thanks for thinking about contributing to nornir_scrapli! Contributions are not expected, but are quite welcome. +Thanks for thinking about contributing! Contributions are not expected, but are quite welcome. Contributions of all kinds are welcomed -- typos, doc updates, adding examples, bug fixes, and feature adds. Some notes on contributing: -- Please open an issue to discuss any bug fixes, feature adds, or really any thing that could result in a pull - request. This allows us to all be on the same page, and could save everyone some extra work! -- Once we've discussed any changes, pull requests are of course welcome and very much appreciated! - - All PRs should pass tests -- checkout the Makefile for some shortcuts for linting and testing. +- Please open a GitHub discussion topic for any potential feature adds/changes to discuss them prior to opening a PR, + this way everyone has a chance to chime in and make sure we're all on the same page! +- Please open an issue to discuss any bugs/bug fixes prior to opening a PR. +- Once we all have discussed any adds/changes, pull requests are very much welcome and appreciated! + - All PRs should pass tests/CI linting -- checkout the Makefile for some shortcuts for linting and testing. - Please include tests! Even simple/basic tests are better than nothing -- it helps make sure changes in the future don't break functionality or make things act in unexpected ways! diff --git a/docs/api_docs/connection.md b/docs/api_docs/connection.md index 522ee92..71ae4d7 100644 --- a/docs/api_docs/connection.md +++ b/docs/api_docs/connection.md @@ -29,16 +29,22 @@ nornir_scrapli.connection
         
 """nornir_scrapli.connection"""
-from typing import Any, Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
 
 from scrapli import Scrapli
 from scrapli.driver import GenericDriver
 from scrapli.exceptions import ScrapliModuleNotFound
+from scrapli_cfg import ScrapliCfg
+from scrapli_cfg.platform.base.sync_platform import ScrapliCfgPlatform
 from scrapli_netconf.driver import NetconfDriver
 
 from nornir.core.configuration import Config
+from nornir.core.task import Task
 from nornir_scrapli.exceptions import NornirScrapliInvalidPlatform
 
+if TYPE_CHECKING:
+    from nornir.core.plugins.connections import ConnectionPlugin  # pylint: disable=C0412
+
 CONNECTION_NAME = "scrapli"
 
 PLATFORM_MAP = {
@@ -77,7 +83,7 @@ class ScrapliCore:
             configuration: nornir configuration
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             NornirScrapliInvalidPlatform: if no platform or an invalid scrapli/napalm platform
@@ -103,21 +109,20 @@ class ScrapliCore:
 
         if not platform:
             raise NornirScrapliInvalidPlatform(
-                f"No `platform` provided in inventory for host `{hostname}`"
+                f"'platform' not provided in inventory for host `{hostname}`"
             )
 
-        if platform in PLATFORM_MAP:
-            platform = PLATFORM_MAP.get(platform)
+        final_platform: str = PLATFORM_MAP.get(platform, platform)
 
-        if platform == "generic":
+        if final_platform == "generic":
             connection = GenericDriver(**parameters)
         else:
             try:
-                connection = Scrapli(**parameters, platform=platform)  # type: ignore
+                connection = Scrapli(**parameters, platform=final_platform)
             except ScrapliModuleNotFound as exc:
                 raise NornirScrapliInvalidPlatform(
-                    f"Provided platform `{platform}` is not a valid scrapli or napalm platform, "
-                    "or is not a valid scrapli-community platform."
+                    f"Provided platform `{final_platform}` is not a valid scrapli or napalm "
+                    "platform, or is not a valid scrapli-community platform."
                 ) from exc
 
         connection.open()
@@ -131,7 +136,7 @@ class ScrapliCore:
             N/A
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
@@ -140,6 +145,114 @@ class ScrapliCore:
         self.connection.close()
 
 
+class ScrapliConfig:
+    """Scrapli connection plugin for nornir"""
+
+    connection: ScrapliCfgPlatform
+
+    @staticmethod
+    def get_connection(task: Task) -> ScrapliCfgPlatform:
+        """
+        Try to fetch scrapli-cfg conn, create it if it doesnt exist
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliCfg
+
+        Raises:
+            N/A
+
+        """
+        connection: ScrapliCfgPlatform
+
+        try:
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+        except AttributeError:
+            task.host.connections["scrapli_cfg"] = ScrapliConfig.spawn(task=task)
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+
+        return connection
+
+    @staticmethod
+    def spawn(task: Task) -> "ConnectionPlugin":
+        """
+        Spawn a ScrapliConfig object for a nornir host
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliConfig
+
+        Raises:
+            N/A
+
+        """
+        scrapli_conn = task.host.get_connection("scrapli", task.nornir.config)
+        scrapli_cfg_parameters = task.host.get_connection_parameters(connection="scrapli_cfg")
+
+        # should always be a dict afaik, but typing doesnt appreciate the possibility it is None
+        extras = scrapli_cfg_parameters.extras or {}
+        # always overwrite `dedicated_connection` as we are *not* having a dedicated connection
+        # since we are wrapping the "normal" scrapli connection!
+        extras["dedicated_connection"] = False
+
+        final_scrapli_cfg_parameters: Dict[str, Any] = {
+            "conn": scrapli_conn,
+            **extras,
+        }
+        connection = ScrapliCfg(**final_scrapli_cfg_parameters)
+        scrapli_config_connection_obj = ScrapliConfig()
+        scrapli_config_connection_obj.connection = connection
+        scrapli_config_connection_obj.open()
+        return scrapli_config_connection_obj
+
+    def open(self, *args: Any, **kwargs: Any) -> None:
+        """
+        Override open method of normal nornir connection so we can coopt an existing conn
+
+        Args:
+            args: args for not dealing w/ overridden hings
+            kwargs: kwargs for not dealing w/ overridden hings
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+        _, _ = args, kwargs
+        self.connection.prepare()
+
+    def close(self) -> None:
+        """
+        Override close method of normal nornir connection so we never close things
+
+        Never closing allows us to not accidentally step on the underlying "normal" scrapli conn
+
+        Args:
+            N/A
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+
+
 class ScrapliNetconf:
     """Scrapli NETCONF connection plugin for nornir"""
 
@@ -168,7 +281,7 @@ class ScrapliNetconf:
             configuration: nornir configuration
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
@@ -205,7 +318,7 @@ class ScrapliNetconf:
             N/A
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
@@ -221,6 +334,233 @@ class ScrapliNetconf:
 
 ## Classes
 
+### ScrapliConfig
+
+
+```text
+Scrapli connection plugin for nornir
+```
+
+
+ + Expand source code + +
+        
+class ScrapliConfig:
+    """Scrapli connection plugin for nornir"""
+
+    connection: ScrapliCfgPlatform
+
+    @staticmethod
+    def get_connection(task: Task) -> ScrapliCfgPlatform:
+        """
+        Try to fetch scrapli-cfg conn, create it if it doesnt exist
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliCfg
+
+        Raises:
+            N/A
+
+        """
+        connection: ScrapliCfgPlatform
+
+        try:
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+        except AttributeError:
+            task.host.connections["scrapli_cfg"] = ScrapliConfig.spawn(task=task)
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+
+        return connection
+
+    @staticmethod
+    def spawn(task: Task) -> "ConnectionPlugin":
+        """
+        Spawn a ScrapliConfig object for a nornir host
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliConfig
+
+        Raises:
+            N/A
+
+        """
+        scrapli_conn = task.host.get_connection("scrapli", task.nornir.config)
+        scrapli_cfg_parameters = task.host.get_connection_parameters(connection="scrapli_cfg")
+
+        # should always be a dict afaik, but typing doesnt appreciate the possibility it is None
+        extras = scrapli_cfg_parameters.extras or {}
+        # always overwrite `dedicated_connection` as we are *not* having a dedicated connection
+        # since we are wrapping the "normal" scrapli connection!
+        extras["dedicated_connection"] = False
+
+        final_scrapli_cfg_parameters: Dict[str, Any] = {
+            "conn": scrapli_conn,
+            **extras,
+        }
+        connection = ScrapliCfg(**final_scrapli_cfg_parameters)
+        scrapli_config_connection_obj = ScrapliConfig()
+        scrapli_config_connection_obj.connection = connection
+        scrapli_config_connection_obj.open()
+        return scrapli_config_connection_obj
+
+    def open(self, *args: Any, **kwargs: Any) -> None:
+        """
+        Override open method of normal nornir connection so we can coopt an existing conn
+
+        Args:
+            args: args for not dealing w/ overridden hings
+            kwargs: kwargs for not dealing w/ overridden hings
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+        _, _ = args, kwargs
+        self.connection.prepare()
+
+    def close(self) -> None:
+        """
+        Override close method of normal nornir connection so we never close things
+
+        Never closing allows us to not accidentally step on the underlying "normal" scrapli conn
+
+        Args:
+            N/A
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+        
+    
+
+ + +#### Class variables + + +`connection: scrapli_cfg.platform.base.sync_platform.ScrapliCfgPlatform` + + + +#### Static methods + + + +#### get_connection +`get_connection(task: nornir.core.task.Task) ‑> scrapli_cfg.platform.base.sync_platform.ScrapliCfgPlatform` + +```text +Try to fetch scrapli-cfg conn, create it if it doesnt exist + +This is a little different than "normal" in that we dont have a connection and we dont +create them in the "normal" nornir way -- we actually just steal the scrapli connection and +wrap the scrapli_cfg bits around it. + +Args: + task: nornir Task object + +Returns: + ScrapliCfg + +Raises: + N/A +``` + + + + + +#### spawn +`spawn(task: nornir.core.task.Task) ‑> ConnectionPlugin` + +```text +Spawn a ScrapliConfig object for a nornir host + +This is a little different than "normal" in that we dont have a connection and we dont +create them in the "normal" nornir way -- we actually just steal the scrapli connection and +wrap the scrapli_cfg bits around it. + +Args: + task: nornir Task object + +Returns: + ScrapliConfig + +Raises: + N/A +``` + + +#### Methods + + + +##### close +`close(self) ‑> NoneType` + +```text +Override close method of normal nornir connection so we never close things + +Never closing allows us to not accidentally step on the underlying "normal" scrapli conn + +Args: + N/A + +Returns: + None + +Raises: + N/A +``` + + + + + +##### open +`open(self, *args: Any, **kwargs: Any) ‑> NoneType` + +```text +Override open method of normal nornir connection so we can coopt an existing conn + +Args: + args: args for not dealing w/ overridden hings + kwargs: kwargs for not dealing w/ overridden hings + +Returns: + None + +Raises: + N/A +``` + + + + + ### ScrapliCore @@ -261,7 +601,7 @@ class ScrapliCore: configuration: nornir configuration Returns: - N/A # noqa: DAR202 + None Raises: NornirScrapliInvalidPlatform: if no platform or an invalid scrapli/napalm platform @@ -287,21 +627,20 @@ class ScrapliCore: if not platform: raise NornirScrapliInvalidPlatform( - f"No `platform` provided in inventory for host `{hostname}`" + f"'platform' not provided in inventory for host `{hostname}`" ) - if platform in PLATFORM_MAP: - platform = PLATFORM_MAP.get(platform) + final_platform: str = PLATFORM_MAP.get(platform, platform) - if platform == "generic": + if final_platform == "generic": connection = GenericDriver(**parameters) else: try: - connection = Scrapli(**parameters, platform=platform) # type: ignore + connection = Scrapli(**parameters, platform=final_platform) except ScrapliModuleNotFound as exc: raise NornirScrapliInvalidPlatform( - f"Provided platform `{platform}` is not a valid scrapli or napalm platform, " - "or is not a valid scrapli-community platform." + f"Provided platform `{final_platform}` is not a valid scrapli or napalm " + "platform, or is not a valid scrapli-community platform." ) from exc connection.open() @@ -315,7 +654,7 @@ class ScrapliCore: N/A Returns: - N/A # noqa: DAR202 + None Raises: N/A @@ -341,7 +680,7 @@ Args: N/A Returns: - N/A # noqa: DAR202 + None Raises: N/A @@ -352,7 +691,7 @@ Raises: ##### open -`open(self, hostname: Union[str, NoneType], username: Union[str, NoneType], password: Union[str, NoneType], port: Union[int, NoneType], platform: Union[str, NoneType], extras: Union[Dict[str, Any], NoneType] = None, configuration: Union[nornir.core.configuration.Config, NoneType] = None) ‑> NoneType` +`open(self, hostname: Optional[str], username: Optional[str], password: Optional[str], port: Optional[int], platform: Optional[str], extras: Optional[Dict[str, Any]] = None, configuration: Optional[nornir.core.configuration.Config] = None) ‑> NoneType` ```text Open a scrapli connection to a device @@ -368,7 +707,7 @@ Args: configuration: nornir configuration Returns: - N/A # noqa: DAR202 + None Raises: NornirScrapliInvalidPlatform: if no platform or an invalid scrapli/napalm platform @@ -420,7 +759,7 @@ class ScrapliNetconf: configuration: nornir configuration Returns: - N/A # noqa: DAR202 + None Raises: N/A @@ -457,7 +796,7 @@ class ScrapliNetconf: N/A Returns: - N/A # noqa: DAR202 + None Raises: N/A @@ -483,7 +822,7 @@ Args: N/A Returns: - N/A # noqa: DAR202 + None Raises: N/A @@ -494,7 +833,7 @@ Raises: ##### open -`open(self, hostname: Union[str, NoneType], username: Union[str, NoneType], password: Union[str, NoneType], port: Union[int, NoneType], platform: Union[str, NoneType], extras: Union[Dict[str, Any], NoneType] = None, configuration: Union[nornir.core.configuration.Config, NoneType] = None) ‑> NoneType` +`open(self, hostname: Optional[str], username: Optional[str], password: Optional[str], port: Optional[int], platform: Optional[str], extras: Optional[Dict[str, Any]] = None, configuration: Optional[nornir.core.configuration.Config] = None) ‑> NoneType` ```text Open a scrapli connection to a device @@ -511,7 +850,7 @@ Args: configuration: nornir configuration Returns: - N/A # noqa: DAR202 + None Raises: N/A diff --git a/docs/api_docs/result.md b/docs/api_docs/result.md index 0714a86..7621f0a 100644 --- a/docs/api_docs/result.md +++ b/docs/api_docs/result.md @@ -32,6 +32,7 @@ nornir_scrapli.result from typing import TYPE_CHECKING, Any, Optional, Union from scrapli.response import MultiResponse, Response +from scrapli_cfg.response import ScrapliCfgResponse from nornir.core.task import Result @@ -88,12 +89,12 @@ def process_config_result(scrapli_response: Union[Response, MultiResponse]) -> s return full_results -class ScrapliResult(Result): # type: ignore +class ScrapliResult(Result): def __init__( self, host: "Host", result: Optional[str], - scrapli_response: Optional[Union[Response, MultiResponse]] = None, + scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]] = None, changed: bool = False, **kwargs: Any, ): @@ -124,7 +125,9 @@ class ScrapliResult(Result): # type: ignore self.scrapli_response = scrapli_response @staticmethod - def _process_failed(scrapli_response: Optional[Union[Response, MultiResponse]]) -> bool: + def _process_failed( + scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]] + ) -> bool: """ Process and return string of scrapli response(s) @@ -140,10 +143,10 @@ class ScrapliResult(Result): # type: ignore """ if scrapli_response is None: return False - if isinstance(scrapli_response, Response): + if isinstance(scrapli_response, (Response, ScrapliCfgResponse)): failed: bool = scrapli_response.failed return failed - if any([response.failed for response in scrapli_response]): + if any(response.failed for response in scrapli_response): return True return False
@@ -208,7 +211,7 @@ Arguments: changed (bool): ``True`` if the task is changing the system diff (obj): Diff between state of the system before/after running this task result (obj): Result of the task execution, see task's documentation for details - host (:obj:`nornir.core.inventory.Host`): Reference to the host that lead ot this result + host (:obj:`nornir.core.inventory.Host`): Reference to the host that lead to this result failed (bool): Whether the execution failed or not severity_level (logging.LEVEL): Severity level associated to the result of the excecution exception (Exception): uncaught exception thrown during the exection of the task (if any) @@ -247,12 +250,12 @@ Raises:
         
-class ScrapliResult(Result):  # type: ignore
+class ScrapliResult(Result):
     def __init__(
         self,
         host: "Host",
         result: Optional[str],
-        scrapli_response: Optional[Union[Response, MultiResponse]] = None,
+        scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]] = None,
         changed: bool = False,
         **kwargs: Any,
     ):
@@ -283,7 +286,9 @@ class ScrapliResult(Result):  # type: ignore
         self.scrapli_response = scrapli_response
 
     @staticmethod
-    def _process_failed(scrapli_response: Optional[Union[Response, MultiResponse]]) -> bool:
+    def _process_failed(
+        scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]]
+    ) -> bool:
         """
         Process and return string of scrapli response(s)
 
@@ -299,10 +304,10 @@ class ScrapliResult(Result):  # type: ignore
         """
         if scrapli_response is None:
             return False
-        if isinstance(scrapli_response, Response):
+        if isinstance(scrapli_response, (Response, ScrapliCfgResponse)):
             failed: bool = scrapli_response.failed
             return failed
-        if any([response.failed for response in scrapli_response]):
+        if any(response.failed for response in scrapli_response):
             return True
         return False
         
diff --git a/docs/api_docs/tasks.md b/docs/api_docs/tasks.md
index 1bf2e42..7211e0f 100644
--- a/docs/api_docs/tasks.md
+++ b/docs/api_docs/tasks.md
@@ -29,27 +29,39 @@ nornir_scrapli.tasks
     
         
 """nornir_scrapli.tasks"""
-from nornir_scrapli.tasks.get_prompt import get_prompt
-from nornir_scrapli.tasks.netconf_capabilities import netconf_capabilities
-from nornir_scrapli.tasks.netconf_commit import netconf_commit
-from nornir_scrapli.tasks.netconf_delete_config import netconf_delete_config
-from nornir_scrapli.tasks.netconf_discard import netconf_discard
-from nornir_scrapli.tasks.netconf_edit_config import netconf_edit_config
-from nornir_scrapli.tasks.netconf_get import netconf_get
-from nornir_scrapli.tasks.netconf_get_config import netconf_get_config
-from nornir_scrapli.tasks.netconf_lock import netconf_lock
-from nornir_scrapli.tasks.netconf_rpc import netconf_rpc
-from nornir_scrapli.tasks.netconf_unlock import netconf_unlock
-from nornir_scrapli.tasks.netconf_validate import netconf_validate
-from nornir_scrapli.tasks.send_command import send_command
-from nornir_scrapli.tasks.send_commands import send_commands
-from nornir_scrapli.tasks.send_commands_from_file import send_commands_from_file
-from nornir_scrapli.tasks.send_config import send_config
-from nornir_scrapli.tasks.send_configs import send_configs
-from nornir_scrapli.tasks.send_configs_from_file import send_configs_from_file
-from nornir_scrapli.tasks.send_interactive import send_interactive
+from nornir_scrapli.tasks.cfg.abort_config import cfg_abort_config
+from nornir_scrapli.tasks.cfg.commit_config import cfg_commit_config
+from nornir_scrapli.tasks.cfg.diff_config import cfg_diff_config
+from nornir_scrapli.tasks.cfg.get_config import cfg_get_config
+from nornir_scrapli.tasks.cfg.get_version import cfg_get_version
+from nornir_scrapli.tasks.cfg.load_config import cfg_load_config
+from nornir_scrapli.tasks.core.get_prompt import get_prompt
+from nornir_scrapli.tasks.core.send_command import send_command
+from nornir_scrapli.tasks.core.send_commands import send_commands
+from nornir_scrapli.tasks.core.send_commands_from_file import send_commands_from_file
+from nornir_scrapli.tasks.core.send_config import send_config
+from nornir_scrapli.tasks.core.send_configs import send_configs
+from nornir_scrapli.tasks.core.send_configs_from_file import send_configs_from_file
+from nornir_scrapli.tasks.core.send_interactive import send_interactive
+from nornir_scrapli.tasks.netconf.capabilities import netconf_capabilities
+from nornir_scrapli.tasks.netconf.commit import netconf_commit
+from nornir_scrapli.tasks.netconf.delete_config import netconf_delete_config
+from nornir_scrapli.tasks.netconf.discard import netconf_discard
+from nornir_scrapli.tasks.netconf.edit_config import netconf_edit_config
+from nornir_scrapli.tasks.netconf.get import netconf_get
+from nornir_scrapli.tasks.netconf.get_config import netconf_get_config
+from nornir_scrapli.tasks.netconf.lock import netconf_lock
+from nornir_scrapli.tasks.netconf.rpc import netconf_rpc
+from nornir_scrapli.tasks.netconf.unlock import netconf_unlock
+from nornir_scrapli.tasks.netconf.validate import netconf_validate
 
 __all__ = (
+    "cfg_abort_config",
+    "cfg_commit_config",
+    "cfg_diff_config",
+    "cfg_get_config",
+    "cfg_get_version",
+    "cfg_load_config",
     "get_prompt",
     "netconf_capabilities",
     "netconf_commit",
@@ -80,6 +92,149 @@ __all__ = (
 
     
 
+#### cfg_abort_config
+`cfg_abort_config(task: nornir.core.task.Task) ‑> nornir.core.task.Result`
+
+```text
+Abort a device candidate config with scrapli-cfg
+
+Args:
+    task: nornir task object
+
+Returns:
+    Result: nornir result object with Result.result value set the string result of the
+        load_config operation
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
+#### cfg_commit_config
+`cfg_commit_config(task: nornir.core.task.Task, source: str = 'running') ‑> nornir.core.task.Result`
+
+```text
+Commit a device candidate config with scrapli-cfg
+
+Args:
+    task: nornir task object
+    source: name of the config source to commit against, generally running|startup
+
+Returns:
+    Result: nornir result object with Result.result value set the string result of the
+        load_config operation
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
+#### cfg_diff_config
+`cfg_diff_config(task: nornir.core.task.Task, source: str = 'running') ‑> nornir.core.task.Result`
+
+```text
+Diff a device candidate config vs a source config with scrapli-cfg
+
+The "device diff" is stored as the result. You can access the side by side or unified scrapli
+cfg diffs via the "scrapli_response" object stored in the result!
+
+Args:
+    task: nornir task object
+    source: name of the config source to commit against, generally running|startup
+
+Returns:
+    Result: nornir result object with Result.result value set the string result of the
+        load_config operation
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
+#### cfg_get_config
+`cfg_get_config(task: nornir.core.task.Task, source: str = 'running') ‑> nornir.core.task.Result`
+
+```text
+Get device config with scrapli-cfg
+
+Args:
+    task: nornir task object
+    source: config source to get
+
+Returns:
+    Result: nornir result object with Result.result value set to current prompt
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
+#### cfg_get_version
+`cfg_get_version(task: nornir.core.task.Task) ‑> nornir.core.task.Result`
+
+```text
+Get device version with scrapli-cfg
+
+Args:
+    task: nornir task object
+
+Returns:
+    Result: nornir result object with Result.result value set to current version of device
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
+#### cfg_load_config
+`cfg_load_config(task: nornir.core.task.Task, config: str, replace: bool = False, **kwargs: Any) ‑> nornir.core.task.Result`
+
+```text
+Load device config with scrapli-cfg
+
+Note that `changed` will still be `False` because this is just loading a candidate config!
+
+Args:
+    task: nornir task object
+    config: string of the configuration to load
+    replace: replace the configuration or not, if false configuration will be loaded as a
+        merge operation
+    kwargs: additional kwargs that the implementing classes may need for their platform,
+        see your specific platform for details
+
+Returns:
+    Result: nornir result object with Result.result value set the string result of the
+        load_config operation
+
+Raises:
+    N/A
+```
+
+
+
+
+    
+
 #### get_prompt
 `get_prompt(task: nornir.core.task.Task) ‑> nornir.core.task.Result`
 
@@ -191,7 +346,7 @@ Raises:
     
 
 #### netconf_edit_config
-`netconf_edit_config(task: nornir.core.task.Task, config: str, dry_run: Union[bool, NoneType] = None, diff: bool = False, target: str = 'running') ‑> nornir.core.task.Result`
+`netconf_edit_config(task: nornir.core.task.Task, config: str, dry_run: Optional[bool] = None, diff: bool = False, target: str = 'running') ‑> nornir.core.task.Result`
 
 ```text
 Edit config from the device with scrapli_netconf
@@ -362,7 +517,7 @@ Raises:
     
 
 #### send_command
-`send_command(task: nornir.core.task.Task, command: str, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_command(task: nornir.core.task.Task, command: str, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send a single command to device using scrapli
@@ -391,7 +546,7 @@ Raises:
     
 
 #### send_commands
-`send_commands(task: nornir.core.task.Task, commands: List[str], strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, eager: bool = False, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_commands(task: nornir.core.task.Task, commands: List[str], strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, eager: bool = False, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send a list of commands to device using scrapli
@@ -425,7 +580,7 @@ Raises:
     
 
 #### send_commands_from_file
-`send_commands_from_file(task: nornir.core.task.Task, file: str, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, eager: bool = False, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_commands_from_file(task: nornir.core.task.Task, file: str, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, eager: bool = False, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send a list of commands from a file to device using scrapli
@@ -458,7 +613,7 @@ Raises:
     
 
 #### send_config
-`send_config(task: nornir.core.task.Task, config: str, dry_run: Union[bool, NoneType] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_config(task: nornir.core.task.Task, config: str, dry_run: Optional[bool] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send a config to device using scrapli
@@ -501,7 +656,7 @@ Raises:
     
 
 #### send_configs
-`send_configs(task: nornir.core.task.Task, configs: List[str], dry_run: Union[bool, NoneType] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_configs(task: nornir.core.task.Task, configs: List[str], dry_run: Optional[bool] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send configs to device using scrapli
@@ -544,7 +699,7 @@ Raises:
     
 
 #### send_configs_from_file
-`send_configs_from_file(task: nornir.core.task.Task, file: str, dry_run: Union[bool, NoneType] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_configs_from_file(task: nornir.core.task.Task, file: str, dry_run: Optional[bool] = None, strip_prompt: bool = True, failed_when_contains: Union[str, List[str], NoneType] = None, stop_on_failed: bool = False, privilege_level: str = '', eager: bool = False, timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send configs from a file to device using scrapli
@@ -587,7 +742,7 @@ Raises:
     
 
 #### send_interactive
-`send_interactive(task: nornir.core.task.Task, interact_events: List[Tuple[str, str, Union[bool, NoneType]]], failed_when_contains: Union[str, List[str], NoneType] = None, privilege_level: str = '', timeout_ops: Union[float, NoneType] = None) ‑> nornir.core.task.Result`
+`send_interactive(task: nornir.core.task.Task, interact_events: List[Tuple[str, str, Optional[bool]]], failed_when_contains: Union[str, List[str], NoneType] = None, privilege_level: str = '', timeout_ops: Optional[float] = None) ‑> nornir.core.task.Result`
 
 ```text
 Send inputs in an interactive fashion using scrapli; usually used to handle prompts
diff --git a/docs/generate/mkdocs_markdown.mako b/docs/generate/mkdocs_markdown.mako
index 5dccbcc..a2deb1f 100644
--- a/docs/generate/mkdocs_markdown.mako
+++ b/docs/generate/mkdocs_markdown.mako
@@ -151,12 +151,20 @@ ${module.source}
 
 
 
-% if submodules:
-<%text>## Sub-modules
-    % for m in submodules:
-* ${m.name}
-    % endfor
-% endif
+## % if submodules:
+## <%text>## Sub-modules
+##     % for m in submodules:
+## * ${m.name}
+##     % endfor
+## % endif
+##
+## % if variables:
+## <%text>## Variables
+##     % for v in variables:
+## ${variable(v)}
+##
+##     % endfor
+## % endif
 
 % if functions:
 <%text>## Functions
diff --git a/docs/htmltest.yml b/docs/htmltest.yml
new file mode 100644
index 0000000..579c571
--- /dev/null
+++ b/docs/htmltest.yml
@@ -0,0 +1,12 @@
+# adopted from https://github.com/goreleaser/goreleaser/blob/5adf43295767b5be05fa38a01ffb3ad25bd21797/www/htmltest.yml
+# using https://github.com/wjdp/htmltest
+DirectoryPath: ./site
+IgnoreURLs:
+  - fonts.gstatic.com
+  - linkedin.com
+IgnoreDirectoryMissingTrailingSlash: true
+IgnoreAltMissing: true
+IgnoreSSLVerify: true
+IgnoreDirs:
+  - overrides
+IgnoreInternalEmptyHash: true
diff --git a/docs/more_scrapli/scrapli.md b/docs/more_scrapli/scrapli.md
index f680c9b..4de4e8b 100644
--- a/docs/more_scrapli/scrapli.md
+++ b/docs/more_scrapli/scrapli.md
@@ -1,5 +1,5 @@
-# scrapli
+# Scrapli
 
 
 [scrapli](https://github.com/carlmontanari/scrapli) ([docs](https://github.com/carlmontanari/scrapli)) is the 
-"parent" library on which scrapli_netconf is built. Check it out if you need to connect to devices with telnet or ssh!
+"parent" scrapli library. Check it out if you need to connect to devices with telnet or ssh!
diff --git a/docs/more_scrapli/scrapli_cfg.md b/docs/more_scrapli/scrapli_cfg.md
new file mode 100644
index 0000000..026143e
--- /dev/null
+++ b/docs/more_scrapli/scrapli_cfg.md
@@ -0,0 +1,7 @@
+# Scrapli Cfg
+
+
+[scrapli_cfg](https://github.com/scrapli/scrapli_cfg) ([docs](https://scrapli.github.io/scrapli_cfg/)) 
+is utility that accepts a scrapli Telnet or SSH connection and provides configuration management capabilities. 
+scrapli_cfg allows you to load candidate configurations for merge or replace operations, generate diffs of the 
+current vs candidate, and of course commit or abort the candidate configuration.
diff --git a/docs/more_scrapli/scrapli_community.md b/docs/more_scrapli/scrapli_community.md
index 6d4eab7..444a48e 100644
--- a/docs/more_scrapli/scrapli_community.md
+++ b/docs/more_scrapli/scrapli_community.md
@@ -1,4 +1,4 @@
-# scrapli Community
+# Scrapli Community
 
 
 If you would like to use scrapli, but the platform(s) that you work with are not supported in the "core" scrapli 
diff --git a/docs/more_scrapli/scrapli_netconf.md b/docs/more_scrapli/scrapli_netconf.md
index 18fa216..025a594 100644
--- a/docs/more_scrapli/scrapli_netconf.md
+++ b/docs/more_scrapli/scrapli_netconf.md
@@ -1,4 +1,4 @@
-# scrapli Netconf
+# Scrapli Netconf
 
 
 [scrapli_netconf](https://github.com/scrapli/scrapli_netconf) ([docs](https://scrapli.github.io/scrapli_netconf/)) 
diff --git a/docs/more_scrapli/scrapli_replay.md b/docs/more_scrapli/scrapli_replay.md
new file mode 100644
index 0000000..7a8aeac
--- /dev/null
+++ b/docs/more_scrapli/scrapli_replay.md
@@ -0,0 +1,7 @@
+# Scrapli Replay
+
+
+[scrapli_replay](https://github.com/scrapli/scrapli_replay) ([docs](https://scrapli.github.io/scrapli_replay/)) 
+is a set of tools used to help test scrapli programs. scrapli_replay includes a utility to capture command 
+input/output from real life servers and replay them in a semi-interactive fashion, as well as a pytest plugin that 
+patches and records and replays session data (like [vcr.py](https://github.com/kevin1024/vcrpy)) for scrapli connections. 
diff --git a/docs/more_scrapli/scrapli_stubs.md b/docs/more_scrapli/scrapli_stubs.md
deleted file mode 100644
index 5f35bce..0000000
--- a/docs/more_scrapli/scrapli_stubs.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# scrapli Stubs
-
-
-Do you __REALLY__ love typing and scrapli? Great news, type stubs for scrapli are generated periodically and updated 
-[here](https://github.com/scrapli/scrapli_stubs), enjoy!
diff --git a/docs/user_guide/available_functions.md b/docs/user_guide/available_functions.md
index 4ad864d..784c4c0 100644
--- a/docs/user_guide/available_functions.md
+++ b/docs/user_guide/available_functions.md
@@ -1,6 +1,6 @@
 # Available Functions
 
-- [print_structured_result](/nornir_scrapli/api_docs/functions/#print_structured_result) -- this function is very similar to the "normal" 
+- [print_structured_result](https://scrapli.github.io/nornir_scrapli/api_docs/functions/#print_structured_result) -- this function is very similar to the "normal" 
   `print_result` function that now ships with the `nornir_utils` library (historically with nornir "core"), except 
   it contains several  additional arguments, most importantly the `parser` argument allows you to select `textfsm` 
   or `genie` to decide which parser to use to parse the unstructured data stored in the results object. Please see the structured
diff --git a/docs/user_guide/available_tasks.md b/docs/user_guide/available_tasks.md
index 62283ff..73ad10c 100644
--- a/docs/user_guide/available_tasks.md
+++ b/docs/user_guide/available_tasks.md
@@ -8,28 +8,38 @@ All tasks presented here are methods that live in `scrapli` or `scrapli_netconf`
 
 ## Scrapli "core" Tasks
 
-- [get_prompt](/nornir_scrapli/api_docs/tasks/#get_prompt) - Get the current prompt of the device
-- [send_command](/nornir_scrapli/api_docs/tasks/#send_command) - Send a single command to the device
-- [send_commands](/nornir_scrapli/api_docs/tasks/#send_commands) - Send a list of commands to the device
-- [send_commands_from_file](/nornir_scrapli/api_docs/tasks/#send_commands_from_file) - Send a list of commands from a file to the device
-- [send_config](/nornir_scrapli/api_docs/tasks/#send_config) - Send a configuration to the device
-- [send_configs](/nornir_scrapli/api_docs/tasks/#send_configs) - Send a list of configurations to the device
-- [send_configs_from_file](/nornir_scrapli/api_docs/tasks/#send_configs_from_file) - Send a list of configurations from a file to the device
-- [send_interactive](/nornir_scrapli/api_docs/tasks/#send_interactive) -"Interact" with the device (handle prompts and inputs and things like that)
+- [get_prompt](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#get_prompt) - Get the current prompt of the device
+- [send_command](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_command) - Send a single command to the device
+- [send_commands](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_commands) - Send a list of commands to the device
+- [send_commands_from_file](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_commands_from_file) - Send a list of commands from a file to the device
+- [send_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_config) - Send a configuration to the device
+- [send_configs](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_configs) - Send a list of configurations to the device
+- [send_configs_from_file](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_configs_from_file) - Send a list of configurations from a file to the device
+- [send_interactive](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#send_interactive) -"Interact" with the device (handle prompts and inputs and things like that)
 
 
 ## Scrapli Netconf Tasks
 
 Note that not all devices will support all operations!
 
-- netconf_capabilities - Get list of capabilities as exchanged during netconf connection establishment
-- [netconf_commit](/nornir_scrapli/api_docs/tasks/#commit) - Commit the configuration on the device
-- [netconf_discard](/nornir_scrapli/api_docs/tasks/#discard) - Discard the configuration on the device
-- [netconf_edit_config](/nornir_scrapli/api_docs/tasks/#edit_config) - Edit the configuration on the device
-- [netconf_delete_config](/nornir_scrapli/api_docs/tasks/#delete_config) - Delete a given datastore on the device
-- [netconf_get](/nornir_scrapli/api_docs/tasks/#get) - Get a subtree or xpath from the device
-- [netconf_get_config](/nornir_scrapli/api_docs/tasks/#get_config) - Get the configuration from the device
-- [netconf_lock](/nornir_scrapli/api_docs/tasks/#lock) - Lock the datastore on the device
-- [netconf_unlock](/nornir_scrapli/api_docs/tasks/#unlock) - Unlock the datastore on the device
-- [netconf_rpc](/nornir_scrapli/api_docs/tasks/#rpc) - Send a "bare" RPC to the device
-- [netconf_validate](/nornir_scrapli/api_docs/tasks/#netconf_validate) - Execute the `validate` rpc against a given datastore
+- [netconf_capabilities](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#netconf_capabilities/) - Get list of capabilities as exchanged during netconf connection establishment
+- [netconf_commit](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#commit) - Commit the configuration on the device
+- [netconf_discard](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#discard) - Discard the configuration on the device
+- [netconf_edit_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#edit_config) - Edit the configuration on the device
+- [netconf_delete_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#delete_config) - Delete a given datastore on the device
+- [netconf_get](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#get) - Get a subtree or xpath from the device
+- [netconf_get_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#get_config) - Get the configuration from the device
+- [netconf_lock](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#lock) - Lock the datastore on the device
+- [netconf_unlock](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#unlock) - Unlock the datastore on the device
+- [netconf_rpc](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#rpc) - Send a "bare" RPC to the device
+- [netconf_validate](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#netconf_validate) - Execute the `validate` rpc against a given datastore
+
+
+## Scrapli Cfg Tasks
+
+- [cfg_abort_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_abort_config) - Abort a loaded candidate config
+- [cfg_commit_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_commit_config) - Commit a loaded candidate config
+- [cfg_diff_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_diff_config) - Diff a loaded candidate config
+- [cfg_get_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_get_config) - Get a target config
+- [cfg_get_version](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_get_version) - Get the device version string
+- [cfg_load_config](https://scrapli.github.io/nornir_scrapli/api_docs/tasks/#cfg_load_config) - Load a candidate config
diff --git a/docs/user_guide/project_details.md b/docs/user_guide/project_details.md
index 80ac3f2..b5b3571 100644
--- a/docs/user_guide/project_details.md
+++ b/docs/user_guide/project_details.md
@@ -36,7 +36,7 @@ This repo is the nornir plugin for scrapli, however there are other libraries/re
 
 
 - [scrapli](/more_scrapli/scrapli)
-- [scrapli_community](/more_scrapli/scrapli_community)
 - [scrapli_netconf](/more_scrapli/scrapli_netconf)
-- [scrapli_stubs](/more_scrapli/scrapli_stubs)
-
+- [scrapli_community](/more_scrapli/scrapli_community)
+- [scrapli_cfg](/more_scrapli/scrapli_cfg)
+- [scrapli_replay](/more_scrapli/scrapli_replay)
diff --git a/examples/basic_netconf_usage/demo.py b/examples/basic_netconf_usage/demo.py
index cd15fc7..85f17e1 100644
--- a/examples/basic_netconf_usage/demo.py
+++ b/examples/basic_netconf_usage/demo.py
@@ -1,3 +1,4 @@
+"""nornir_scrapli.examples.basic_netconf_usage.demo"""
 from nornir_utils.plugins.functions.print_result import print_result
 
 from nornir import InitNornir
@@ -13,6 +14,7 @@
 
 
 def main() -> None:
+    """Simple netconf demo!"""
     nr = InitNornir(config_file="nornir_data/config.yaml")
 
     capabilities_result = nr.run(task=netconf_capabilities)
@@ -21,7 +23,8 @@ def main() -> None:
     config_result = nr.run(task=netconf_get_config)
     print_result(config_result)
 
-    filter_ = """
+    filter_ = """
+    
       
         
           GigabitEthernet1
@@ -32,7 +35,8 @@ def main() -> None:
     print_result(result)
 
     print("edit-config", "*" * 50)
-    config = """
+    config = """
+    
         
             
                 GigabitEthernet1
@@ -49,7 +53,8 @@ def main() -> None:
     result = nr.run(task=netconf_unlock, target="running")
     print_result(result)
 
-    rpc = """
+    rpc = """
+    
       
         
           GigabitEthernet1
diff --git a/examples/structured_data/demo.py b/examples/structured_data/demo.py
index 9e45adb..c1cf4a1 100644
--- a/examples/structured_data/demo.py
+++ b/examples/structured_data/demo.py
@@ -1,3 +1,4 @@
+"""nornir_scrapli.examples.structured_data.demo"""
 from nornir_utils.plugins.functions import print_result
 
 from nornir import InitNornir
@@ -6,6 +7,7 @@
 
 
 def main() -> None:
+    """Simple demo for printing structured data"""
     nr = InitNornir(config_file="nornir_data/config.yaml")
     show_result = nr.run(task=send_command, command="show version")
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 97b3928..b601c48 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,8 +1,8 @@
 ---
 site_name: Nornir Scrapli
-site_url: https://github.com/scrapli/nornir_scrapli
-site_description: "Scrapli's plugin for Nornir"
+site_description: Scrapli's plugin for Nornir
 site_author: Carl Montanari
+site_url: https://scrapli.github.io/
 
 repo_name: scrapli/nornir_scrapli
 repo_url: https://github.com/scrapli/nornir_scrapli
@@ -17,7 +17,7 @@ theme:
       repo: fontawesome/brands/github-alt
 
 nav:
-  - Scrapli Nornir: index.md
+  - Nornir Scrapli: index.md
   - User Guide:
       - Quick Start Guide: user_guide/quickstart.md
       - Project Details: user_guide/project_details.md
@@ -37,7 +37,8 @@ nav:
     - Scrapli: more_scrapli/scrapli.md
     - Scrapli Netconf: more_scrapli/scrapli_netconf.md
     - Scrapli Community: more_scrapli/scrapli_community.md
-    - Scrapli Stubs: more_scrapli/scrapli_stubs.md
+    - Scrapli Cfg: more_scrapli/scrapli_cfg.md
+    - Scrapli Replay: more_scrapli/scrapli_replay.md
   - Other:
     - Contributing: about/contributing.md
     - Code of Conduct: about/code_of_conduct.md
@@ -51,11 +52,15 @@ markdown_extensions:
     - mdx_gh_links:
         user: mkdocs
         repo: mkdocs
+    - pymdownx.superfences
+    - pymdownx.highlight:
+        use_pygments: True
+        linenums: True
 
 extra:
     social:
         - icon: fontawesome/brands/github-alt
-          link: 'https://github.com/carlmontanari/scrapli'
+          link: 'https://github.com/carlmontanari/'
         - icon: fontawesome/brands/twitter
           link: 'https://twitter.com/carlrmontanari'
         - icon: fontawesome/brands/linkedin
diff --git a/nornir_scrapli/connection.py b/nornir_scrapli/connection.py
index da3fae0..446d7ba 100644
--- a/nornir_scrapli/connection.py
+++ b/nornir_scrapli/connection.py
@@ -1,14 +1,20 @@
 """nornir_scrapli.connection"""
-from typing import Any, Dict, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
 
 from scrapli import Scrapli
 from scrapli.driver import GenericDriver
 from scrapli.exceptions import ScrapliModuleNotFound
+from scrapli_cfg import ScrapliCfg
+from scrapli_cfg.platform.base.sync_platform import ScrapliCfgPlatform
 from scrapli_netconf.driver import NetconfDriver
 
 from nornir.core.configuration import Config
+from nornir.core.task import Task
 from nornir_scrapli.exceptions import NornirScrapliInvalidPlatform
 
+if TYPE_CHECKING:
+    from nornir.core.plugins.connections import ConnectionPlugin  # pylint: disable=C0412
+
 CONNECTION_NAME = "scrapli"
 
 PLATFORM_MAP = {
@@ -47,7 +53,7 @@ def open(
             configuration: nornir configuration
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             NornirScrapliInvalidPlatform: if no platform or an invalid scrapli/napalm platform
@@ -73,21 +79,20 @@ def open(
 
         if not platform:
             raise NornirScrapliInvalidPlatform(
-                f"No `platform` provided in inventory for host `{hostname}`"
+                f"'platform' not provided in inventory for host `{hostname}`"
             )
 
-        if platform in PLATFORM_MAP:
-            platform = PLATFORM_MAP.get(platform)
+        final_platform: str = PLATFORM_MAP.get(platform, platform)
 
-        if platform == "generic":
+        if final_platform == "generic":
             connection = GenericDriver(**parameters)
         else:
             try:
-                connection = Scrapli(**parameters, platform=platform)  # type: ignore
+                connection = Scrapli(**parameters, platform=final_platform)
             except ScrapliModuleNotFound as exc:
                 raise NornirScrapliInvalidPlatform(
-                    f"Provided platform `{platform}` is not a valid scrapli or napalm platform, "
-                    "or is not a valid scrapli-community platform."
+                    f"Provided platform `{final_platform}` is not a valid scrapli or napalm "
+                    "platform, or is not a valid scrapli-community platform."
                 ) from exc
 
         connection.open()
@@ -101,7 +106,7 @@ def close(self) -> None:
             N/A
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
@@ -110,6 +115,114 @@ def close(self) -> None:
         self.connection.close()
 
 
+class ScrapliConfig:
+    """Scrapli connection plugin for nornir"""
+
+    connection: ScrapliCfgPlatform
+
+    @staticmethod
+    def get_connection(task: Task) -> ScrapliCfgPlatform:
+        """
+        Try to fetch scrapli-cfg conn, create it if it doesnt exist
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliCfg
+
+        Raises:
+            N/A
+
+        """
+        connection: ScrapliCfgPlatform
+
+        try:
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+        except AttributeError:
+            task.host.connections["scrapli_cfg"] = ScrapliConfig.spawn(task=task)
+            connection = task.host.get_connection("scrapli_cfg", task.nornir.config)
+
+        return connection
+
+    @staticmethod
+    def spawn(task: Task) -> "ConnectionPlugin":
+        """
+        Spawn a ScrapliConfig object for a nornir host
+
+        This is a little different than "normal" in that we dont have a connection and we dont
+        create them in the "normal" nornir way -- we actually just steal the scrapli connection and
+        wrap the scrapli_cfg bits around it.
+
+        Args:
+            task: nornir Task object
+
+        Returns:
+            ScrapliConfig
+
+        Raises:
+            N/A
+
+        """
+        scrapli_conn = task.host.get_connection("scrapli", task.nornir.config)
+        scrapli_cfg_parameters = task.host.get_connection_parameters(connection="scrapli_cfg")
+
+        # should always be a dict afaik, but typing doesnt appreciate the possibility it is None
+        extras = scrapli_cfg_parameters.extras or {}
+        # always overwrite `dedicated_connection` as we are *not* having a dedicated connection
+        # since we are wrapping the "normal" scrapli connection!
+        extras["dedicated_connection"] = False
+
+        final_scrapli_cfg_parameters: Dict[str, Any] = {
+            "conn": scrapli_conn,
+            **extras,
+        }
+        connection = ScrapliCfg(**final_scrapli_cfg_parameters)
+        scrapli_config_connection_obj = ScrapliConfig()
+        scrapli_config_connection_obj.connection = connection
+        scrapli_config_connection_obj.open()
+        return scrapli_config_connection_obj
+
+    def open(self, *args: Any, **kwargs: Any) -> None:
+        """
+        Override open method of normal nornir connection so we can coopt an existing conn
+
+        Args:
+            args: args for not dealing w/ overridden hings
+            kwargs: kwargs for not dealing w/ overridden hings
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+        _, _ = args, kwargs
+        self.connection.prepare()
+
+    def close(self) -> None:
+        """
+        Override close method of normal nornir connection so we never close things
+
+        Never closing allows us to not accidentally step on the underlying "normal" scrapli conn
+
+        Args:
+            N/A
+
+        Returns:
+            None
+
+        Raises:
+            N/A
+
+        """
+
+
 class ScrapliNetconf:
     """Scrapli NETCONF connection plugin for nornir"""
 
@@ -138,7 +251,7 @@ def open(
             configuration: nornir configuration
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
@@ -175,7 +288,7 @@ def close(self) -> None:
             N/A
 
         Returns:
-            N/A  # noqa: DAR202
+            None
 
         Raises:
             N/A
diff --git a/nornir_scrapli/functions/print_structured_result.py b/nornir_scrapli/functions/print_structured_result.py
index 262cbf8..c2d71ff 100644
--- a/nornir_scrapli/functions/print_structured_result.py
+++ b/nornir_scrapli/functions/print_structured_result.py
@@ -69,7 +69,7 @@ def print_structured_result(
         if updated_multi_result:
             updated_agg_result[hostname] = updated_multi_result  # noqa
 
-    LOCK.acquire()
+    LOCK.acquire()  # pylint: disable=R1732
     try:
         _print_result(
             result=updated_agg_result, attrs=None, failed=failed, severity_level=severity_level
diff --git a/nornir_scrapli/py.typed b/nornir_scrapli/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/nornir_scrapli/result.py b/nornir_scrapli/result.py
index d0bb9f0..60848aa 100644
--- a/nornir_scrapli/result.py
+++ b/nornir_scrapli/result.py
@@ -2,6 +2,7 @@
 from typing import TYPE_CHECKING, Any, Optional, Union
 
 from scrapli.response import MultiResponse, Response
+from scrapli_cfg.response import ScrapliCfgResponse
 
 from nornir.core.task import Result
 
@@ -58,12 +59,12 @@ def process_config_result(scrapli_response: Union[Response, MultiResponse]) -> s
     return full_results
 
 
-class ScrapliResult(Result):  # type: ignore
+class ScrapliResult(Result):
     def __init__(
         self,
         host: "Host",
         result: Optional[str],
-        scrapli_response: Optional[Union[Response, MultiResponse]] = None,
+        scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]] = None,
         changed: bool = False,
         **kwargs: Any,
     ):
@@ -94,7 +95,9 @@ def __init__(
         self.scrapli_response = scrapli_response
 
     @staticmethod
-    def _process_failed(scrapli_response: Optional[Union[Response, MultiResponse]]) -> bool:
+    def _process_failed(
+        scrapli_response: Optional[Union[Response, MultiResponse, ScrapliCfgResponse]]
+    ) -> bool:
         """
         Process and return string of scrapli response(s)
 
@@ -110,7 +113,7 @@ def _process_failed(scrapli_response: Optional[Union[Response, MultiResponse]])
         """
         if scrapli_response is None:
             return False
-        if isinstance(scrapli_response, Response):
+        if isinstance(scrapli_response, (Response, ScrapliCfgResponse)):
             failed: bool = scrapli_response.failed
             return failed
         if any(response.failed for response in scrapli_response):
diff --git a/nornir_scrapli/tasks/__init__.py b/nornir_scrapli/tasks/__init__.py
index 45afff3..ad159bc 100644
--- a/nornir_scrapli/tasks/__init__.py
+++ b/nornir_scrapli/tasks/__init__.py
@@ -1,25 +1,37 @@
 """nornir_scrapli.tasks"""
-from nornir_scrapli.tasks.get_prompt import get_prompt
-from nornir_scrapli.tasks.netconf_capabilities import netconf_capabilities
-from nornir_scrapli.tasks.netconf_commit import netconf_commit
-from nornir_scrapli.tasks.netconf_delete_config import netconf_delete_config
-from nornir_scrapli.tasks.netconf_discard import netconf_discard
-from nornir_scrapli.tasks.netconf_edit_config import netconf_edit_config
-from nornir_scrapli.tasks.netconf_get import netconf_get
-from nornir_scrapli.tasks.netconf_get_config import netconf_get_config
-from nornir_scrapli.tasks.netconf_lock import netconf_lock
-from nornir_scrapli.tasks.netconf_rpc import netconf_rpc
-from nornir_scrapli.tasks.netconf_unlock import netconf_unlock
-from nornir_scrapli.tasks.netconf_validate import netconf_validate
-from nornir_scrapli.tasks.send_command import send_command
-from nornir_scrapli.tasks.send_commands import send_commands
-from nornir_scrapli.tasks.send_commands_from_file import send_commands_from_file
-from nornir_scrapli.tasks.send_config import send_config
-from nornir_scrapli.tasks.send_configs import send_configs
-from nornir_scrapli.tasks.send_configs_from_file import send_configs_from_file
-from nornir_scrapli.tasks.send_interactive import send_interactive
+from nornir_scrapli.tasks.cfg.abort_config import cfg_abort_config
+from nornir_scrapli.tasks.cfg.commit_config import cfg_commit_config
+from nornir_scrapli.tasks.cfg.diff_config import cfg_diff_config
+from nornir_scrapli.tasks.cfg.get_config import cfg_get_config
+from nornir_scrapli.tasks.cfg.get_version import cfg_get_version
+from nornir_scrapli.tasks.cfg.load_config import cfg_load_config
+from nornir_scrapli.tasks.core.get_prompt import get_prompt
+from nornir_scrapli.tasks.core.send_command import send_command
+from nornir_scrapli.tasks.core.send_commands import send_commands
+from nornir_scrapli.tasks.core.send_commands_from_file import send_commands_from_file
+from nornir_scrapli.tasks.core.send_config import send_config
+from nornir_scrapli.tasks.core.send_configs import send_configs
+from nornir_scrapli.tasks.core.send_configs_from_file import send_configs_from_file
+from nornir_scrapli.tasks.core.send_interactive import send_interactive
+from nornir_scrapli.tasks.netconf.capabilities import netconf_capabilities
+from nornir_scrapli.tasks.netconf.commit import netconf_commit
+from nornir_scrapli.tasks.netconf.delete_config import netconf_delete_config
+from nornir_scrapli.tasks.netconf.discard import netconf_discard
+from nornir_scrapli.tasks.netconf.edit_config import netconf_edit_config
+from nornir_scrapli.tasks.netconf.get import netconf_get
+from nornir_scrapli.tasks.netconf.get_config import netconf_get_config
+from nornir_scrapli.tasks.netconf.lock import netconf_lock
+from nornir_scrapli.tasks.netconf.rpc import netconf_rpc
+from nornir_scrapli.tasks.netconf.unlock import netconf_unlock
+from nornir_scrapli.tasks.netconf.validate import netconf_validate
 
 __all__ = (
+    "cfg_abort_config",
+    "cfg_commit_config",
+    "cfg_diff_config",
+    "cfg_get_config",
+    "cfg_get_version",
+    "cfg_load_config",
     "get_prompt",
     "netconf_capabilities",
     "netconf_commit",
diff --git a/nornir_scrapli/tasks/cfg/__init__.py b/nornir_scrapli/tasks/cfg/__init__.py
new file mode 100644
index 0000000..da0e8fb
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/__init__.py
@@ -0,0 +1 @@
+"""nornir_scrapli.tasks.cfg"""
diff --git a/nornir_scrapli/tasks/cfg/abort_config.py b/nornir_scrapli/tasks/cfg/abort_config.py
new file mode 100644
index 0000000..853e8cb
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/abort_config.py
@@ -0,0 +1,33 @@
+"""nornir_scrapli.tasks.cfg_abort_config"""
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+from nornir_scrapli.result import ScrapliResult
+
+
+def cfg_abort_config(task: Task) -> Result:
+    """
+    Abort a device candidate config with scrapli-cfg
+
+    Args:
+        task: nornir task object
+
+    Returns:
+        Result: nornir result object with Result.result value set the string result of the
+            load_config operation
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    scrapli_response = scrapli_cfg_conn.abort_config()
+
+    result = ScrapliResult(
+        host=task.host,
+        result=scrapli_response.result,
+        scrapli_response=scrapli_response,
+        changed=False,
+    )
+
+    return result
diff --git a/nornir_scrapli/tasks/cfg/commit_config.py b/nornir_scrapli/tasks/cfg/commit_config.py
new file mode 100644
index 0000000..6948e78
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/commit_config.py
@@ -0,0 +1,34 @@
+"""nornir_scrapli.tasks.cfg_commit_config"""
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+from nornir_scrapli.result import ScrapliResult
+
+
+def cfg_commit_config(task: Task, source: str = "running") -> Result:
+    """
+    Commit a device candidate config with scrapli-cfg
+
+    Args:
+        task: nornir task object
+        source: name of the config source to commit against, generally running|startup
+
+    Returns:
+        Result: nornir result object with Result.result value set the string result of the
+            load_config operation
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    scrapli_response = scrapli_cfg_conn.commit_config(source=source)
+
+    result = ScrapliResult(
+        host=task.host,
+        result=scrapli_response.result,
+        scrapli_response=scrapli_response,
+        changed=True,
+    )
+
+    return result
diff --git a/nornir_scrapli/tasks/cfg/diff_config.py b/nornir_scrapli/tasks/cfg/diff_config.py
new file mode 100644
index 0000000..274588e
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/diff_config.py
@@ -0,0 +1,37 @@
+"""nornir_scrapli.tasks.cfg_diff_config"""
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+from nornir_scrapli.result import ScrapliResult
+
+
+def cfg_diff_config(task: Task, source: str = "running") -> Result:
+    """
+    Diff a device candidate config vs a source config with scrapli-cfg
+
+    The "device diff" is stored as the result. You can access the side by side or unified scrapli
+    cfg diffs via the "scrapli_response" object stored in the result!
+
+    Args:
+        task: nornir task object
+        source: name of the config source to commit against, generally running|startup
+
+    Returns:
+        Result: nornir result object with Result.result value set the string result of the
+            load_config operation
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    scrapli_response = scrapli_cfg_conn.diff_config(source=source)
+
+    result = ScrapliResult(
+        host=task.host,
+        result=scrapli_response.device_diff,
+        scrapli_response=scrapli_response,
+        changed=False,
+    )
+
+    return result
diff --git a/nornir_scrapli/tasks/cfg/get_config.py b/nornir_scrapli/tasks/cfg/get_config.py
new file mode 100644
index 0000000..159b580
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/get_config.py
@@ -0,0 +1,33 @@
+"""nornir_scrapli.tasks.cfg_get_config"""
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+from nornir_scrapli.result import ScrapliResult
+
+
+def cfg_get_config(task: Task, source: str = "running") -> Result:
+    """
+    Get device config with scrapli-cfg
+
+    Args:
+        task: nornir task object
+        source: config source to get
+
+    Returns:
+        Result: nornir result object with Result.result value set to current prompt
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    scrapli_response = scrapli_cfg_conn.get_config(source=source)
+
+    result = ScrapliResult(
+        host=task.host,
+        result=scrapli_response.result,
+        scrapli_response=scrapli_response,
+        changed=False,
+    )
+
+    return result
diff --git a/nornir_scrapli/tasks/cfg/get_version.py b/nornir_scrapli/tasks/cfg/get_version.py
new file mode 100644
index 0000000..ef601d3
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/get_version.py
@@ -0,0 +1,24 @@
+"""nornir_scrapli.tasks.cfg.get_version"""
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+
+
+def cfg_get_version(task: Task) -> Result:
+    """
+    Get device version with scrapli-cfg
+
+    Args:
+        task: nornir task object
+
+    Returns:
+        Result: nornir result object with Result.result value set to current version of device
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    version = scrapli_cfg_conn.get_version()
+
+    return Result(host=task.host, result=version.result, failed=False, changed=False)
diff --git a/nornir_scrapli/tasks/cfg/load_config.py b/nornir_scrapli/tasks/cfg/load_config.py
new file mode 100644
index 0000000..e133688
--- /dev/null
+++ b/nornir_scrapli/tasks/cfg/load_config.py
@@ -0,0 +1,42 @@
+"""nornir_scrapli.tasks.cfg_load_config"""
+from typing import Any
+
+from nornir.core.task import Result, Task
+from nornir_scrapli.connection import ScrapliConfig
+from nornir_scrapli.result import ScrapliResult
+
+
+def cfg_load_config(task: Task, config: str, replace: bool = False, **kwargs: Any) -> Result:
+    """
+    Load device config with scrapli-cfg
+
+    Note that `changed` will still be `False` because this is just loading a candidate config!
+
+    Args:
+        task: nornir task object
+        config: string of the configuration to load
+        replace: replace the configuration or not, if false configuration will be loaded as a
+            merge operation
+        kwargs: additional kwargs that the implementing classes may need for their platform,
+            see your specific platform for details
+
+    Returns:
+        Result: nornir result object with Result.result value set the string result of the
+            load_config operation
+
+    Raises:
+        N/A
+
+    """
+    scrapli_cfg_conn = ScrapliConfig.get_connection(task=task)
+
+    scrapli_response = scrapli_cfg_conn.load_config(config=config, replace=replace, **kwargs)
+
+    result = ScrapliResult(
+        host=task.host,
+        result=scrapli_response.result,
+        scrapli_response=scrapli_response,
+        changed=False,
+    )
+
+    return result
diff --git a/nornir_scrapli/tasks/core/__init__.py b/nornir_scrapli/tasks/core/__init__.py
new file mode 100644
index 0000000..15b0c1c
--- /dev/null
+++ b/nornir_scrapli/tasks/core/__init__.py
@@ -0,0 +1 @@
+"""nornir_scrapli.tasks.core"""
diff --git a/nornir_scrapli/tasks/get_prompt.py b/nornir_scrapli/tasks/core/get_prompt.py
similarity index 100%
rename from nornir_scrapli/tasks/get_prompt.py
rename to nornir_scrapli/tasks/core/get_prompt.py
diff --git a/nornir_scrapli/tasks/send_command.py b/nornir_scrapli/tasks/core/send_command.py
similarity index 100%
rename from nornir_scrapli/tasks/send_command.py
rename to nornir_scrapli/tasks/core/send_command.py
diff --git a/nornir_scrapli/tasks/send_commands.py b/nornir_scrapli/tasks/core/send_commands.py
similarity index 100%
rename from nornir_scrapli/tasks/send_commands.py
rename to nornir_scrapli/tasks/core/send_commands.py
diff --git a/nornir_scrapli/tasks/send_commands_from_file.py b/nornir_scrapli/tasks/core/send_commands_from_file.py
similarity index 100%
rename from nornir_scrapli/tasks/send_commands_from_file.py
rename to nornir_scrapli/tasks/core/send_commands_from_file.py
diff --git a/nornir_scrapli/tasks/send_config.py b/nornir_scrapli/tasks/core/send_config.py
similarity index 100%
rename from nornir_scrapli/tasks/send_config.py
rename to nornir_scrapli/tasks/core/send_config.py
diff --git a/nornir_scrapli/tasks/send_configs.py b/nornir_scrapli/tasks/core/send_configs.py
similarity index 100%
rename from nornir_scrapli/tasks/send_configs.py
rename to nornir_scrapli/tasks/core/send_configs.py
diff --git a/nornir_scrapli/tasks/send_configs_from_file.py b/nornir_scrapli/tasks/core/send_configs_from_file.py
similarity index 100%
rename from nornir_scrapli/tasks/send_configs_from_file.py
rename to nornir_scrapli/tasks/core/send_configs_from_file.py
diff --git a/nornir_scrapli/tasks/send_interactive.py b/nornir_scrapli/tasks/core/send_interactive.py
similarity index 100%
rename from nornir_scrapli/tasks/send_interactive.py
rename to nornir_scrapli/tasks/core/send_interactive.py
diff --git a/nornir_scrapli/tasks/netconf/__init__.py b/nornir_scrapli/tasks/netconf/__init__.py
new file mode 100644
index 0000000..b2161ee
--- /dev/null
+++ b/nornir_scrapli/tasks/netconf/__init__.py
@@ -0,0 +1 @@
+"""nornir_scrapli.tasks.netconf"""
diff --git a/nornir_scrapli/tasks/netconf_capabilities.py b/nornir_scrapli/tasks/netconf/capabilities.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_capabilities.py
rename to nornir_scrapli/tasks/netconf/capabilities.py
diff --git a/nornir_scrapli/tasks/netconf_commit.py b/nornir_scrapli/tasks/netconf/commit.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_commit.py
rename to nornir_scrapli/tasks/netconf/commit.py
diff --git a/nornir_scrapli/tasks/netconf_delete_config.py b/nornir_scrapli/tasks/netconf/delete_config.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_delete_config.py
rename to nornir_scrapli/tasks/netconf/delete_config.py
diff --git a/nornir_scrapli/tasks/netconf_discard.py b/nornir_scrapli/tasks/netconf/discard.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_discard.py
rename to nornir_scrapli/tasks/netconf/discard.py
diff --git a/nornir_scrapli/tasks/netconf_edit_config.py b/nornir_scrapli/tasks/netconf/edit_config.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_edit_config.py
rename to nornir_scrapli/tasks/netconf/edit_config.py
diff --git a/nornir_scrapli/tasks/netconf_get.py b/nornir_scrapli/tasks/netconf/get.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_get.py
rename to nornir_scrapli/tasks/netconf/get.py
diff --git a/nornir_scrapli/tasks/netconf_get_config.py b/nornir_scrapli/tasks/netconf/get_config.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_get_config.py
rename to nornir_scrapli/tasks/netconf/get_config.py
diff --git a/nornir_scrapli/tasks/netconf_lock.py b/nornir_scrapli/tasks/netconf/lock.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_lock.py
rename to nornir_scrapli/tasks/netconf/lock.py
diff --git a/nornir_scrapli/tasks/netconf_rpc.py b/nornir_scrapli/tasks/netconf/rpc.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_rpc.py
rename to nornir_scrapli/tasks/netconf/rpc.py
diff --git a/nornir_scrapli/tasks/netconf_unlock.py b/nornir_scrapli/tasks/netconf/unlock.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_unlock.py
rename to nornir_scrapli/tasks/netconf/unlock.py
diff --git a/nornir_scrapli/tasks/netconf_validate.py b/nornir_scrapli/tasks/netconf/validate.py
similarity index 100%
rename from nornir_scrapli/tasks/netconf_validate.py
rename to nornir_scrapli/tasks/netconf/validate.py
diff --git a/noxfile.py b/noxfile.py
index 750831e..6ad4723 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,5 +1,6 @@
 """nornir_scrapli.noxfile"""
 import re
+import sys
 from pathlib import Path
 from typing import Dict, List
 
@@ -7,35 +8,62 @@
 
 nox.options.error_on_missing_interpreters = False
 nox.options.stop_on_first_error = False
+nox.options.default_venv_backend = "venv"
 
-DEV_REQUIREMENTS: Dict[str, str] = {}
 
-# this wouldn't work for other projects probably as its kinda hacky, but works just fine for scrapli
-with open("requirements-dev.txt") as f:
-    req_lines = f.readlines()
-    dev_requirements_lines: List[str] = [
+def parse_requirements(dev: bool = True) -> Dict[str, str]:
+    """
+    Parse requirements file
+
+    Args:
+        dev: parse dev requirements (or not)
+
+    Returns:
+        dict: dict of parsed requirements
+
+    Raises:
+        N/A
+
+    """
+    requirements = {}
+    requirements_file = "requirements.txt" if dev is False else "requirements-dev.txt"
+
+    with open(requirements_file, "r") as f:
+        requirements_file_lines = f.readlines()
+
+    requirements_lines: List[str] = [
         line
-        for line in req_lines
+        for line in requirements_file_lines
         if not line.startswith("-r") and not line.startswith("#") and not line.startswith("-e")
     ]
-    dev_editable_requirements_lines: List[str] = [
-        line for line in req_lines if line.startswith("-e")
+    editable_requirements_lines: List[str] = [
+        line for line in requirements_file_lines if line.startswith("-e")
     ]
 
-for requirement in dev_requirements_lines:
-    parsed_requirement = re.match(
-        pattern=r"^([a-z0-9\-]+)([><=]{1,2}\S*)(?:.*)$", string=requirement, flags=re.I | re.M
-    )
-    DEV_REQUIREMENTS[parsed_requirement.groups()[0]] = parsed_requirement.groups()[1]
+    for requirement in requirements_lines:
+        parsed_requirement = re.match(
+            pattern=r"^([a-z0-9\-\_\.]+)([><=]{1,2}\S*)(?:.*)$",
+            string=requirement,
+            flags=re.I | re.M,
+        )
+        requirements[parsed_requirement.groups()[0]] = parsed_requirement.groups()[1]
 
-for requirement in dev_editable_requirements_lines:
-    parsed_requirement = re.match(
-        pattern=r"^-e\s.*(?:#egg=)(\w+)$", string=requirement, flags=re.I | re.M
-    )
-    DEV_REQUIREMENTS[parsed_requirement.groups()[0]] = requirement
+    for requirement in editable_requirements_lines:
+        parsed_requirement = re.match(
+            pattern=r"^-e\s.*(?:#egg=)(\w+)$", string=requirement, flags=re.I | re.M
+        )
+        requirements[parsed_requirement.groups()[0]] = requirement
 
+    return requirements
 
-@nox.session(python=["3.6", "3.7", "3.8", "3.9"])
+
+REQUIREMENTS: Dict[str, str] = parse_requirements(dev=False)
+DEV_REQUIREMENTS: Dict[str, str] = parse_requirements(dev=True)
+PLATFORM: str = sys.platform
+SKIP_LIST: List[str] = []
+
+
+@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"])
 def unit_tests(session):
     """
     Nox run unit tests
@@ -44,19 +72,24 @@ def unit_tests(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
-    session.install("-e", ".")
+    if f"unit_tests-{PLATFORM}-{session.python}" in SKIP_LIST:
+        return
+
     session.install("-r", "requirements-dev.txt")
+    session.install(".")
     session.run(
+        "python",
+        "-m",
         "pytest",
         "--cov=nornir_scrapli",
         "--cov-report",
-        "html",
+        "xml",
         "--cov-report",
         "term",
         "tests/unit",
@@ -73,14 +106,14 @@ def isort(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
     session.install(f"isort{DEV_REQUIREMENTS['isort']}")
-    session.run("isort", "-c", ".")
+    session.run("python", "-m", "isort", "-c", ".")
 
 
 @nox.session(python=["3.9"])
@@ -92,14 +125,14 @@ def black(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
     session.install(f"black{DEV_REQUIREMENTS['black']}")
-    session.run("black", "--check", ".")
+    session.run("python", "-m", "black", "--check", ".")
 
 
 @nox.session(python=["3.9"])
@@ -111,15 +144,14 @@ def pylama(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
     session.install("-r", "requirements-dev.txt")
-    session.install("-e", ".")
-    session.run("pylama", ".")
+    session.run("python", "-m", "pylama", ".")
 
 
 @nox.session(python=["3.9"])
@@ -131,14 +163,14 @@ def pydocstyle(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
     session.install(f"pydocstyle{DEV_REQUIREMENTS['pydocstyle']}")
-    session.run("pydocstyle", ".")
+    session.run("python", "-m", "pydocstyle", ".")
 
 
 @nox.session(python=["3.9"])
@@ -150,16 +182,15 @@ def mypy(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
+    session.install(".")
     session.install(f"mypy{DEV_REQUIREMENTS['mypy']}")
-    session.install("-e", DEV_REQUIREMENTS["scrapli_stubs"].split()[1])
-    session.env["MYPYPATH"] = f"{session.virtualenv.location}/src/scrapli-stubs"
-    session.run("mypy", "--strict", "nornir_scrapli/")
+    session.run("python", "-m", "mypy", "--strict", "nornir_scrapli/")
 
 
 @nox.session(python=["3.9"])
@@ -171,13 +202,12 @@ def darglint(session):
         session: nox session
 
     Returns:
-        N/A  # noqa: DAR202
+        None
 
     Raises:
         N/A
 
     """
     session.install(f"darglint{DEV_REQUIREMENTS['darglint']}")
-    files_to_darglint = Path("nornir_scrapli").rglob("*.py")
-    for file in files_to_darglint:
+    for file in Path("nornir_scrapli").rglob("*.py"):
         session.run("darglint", f"{file.absolute()}")
diff --git a/pyproject.toml b/pyproject.toml
index 0970a31..96a9361 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,16 +1,3 @@
 [tool.black]
 line-length = 100
 target-version = ['py38']
-include = '\.pyi?$'
-exclude = '''
-(
-  /(
-      \.eggs
-    | \.git
-    | \.hg
-    | \.mypy_cache
-    | \.nox
-    | venv
-  )/
-)
-'''
\ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
index e000f98..b105bd1 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,14 +1,13 @@
-nox==2020.12.31
-black==20.8b1
-isort==5.8.0
-mypy==0.812
-pytest==6.2.2
-pytest-cov==2.11.1
-pylama==7.7.1
+black==21.7b0
+darglint==1.8.0
+isort==5.9.3
+mypy==0.910
+nox==2021.6.12
 pycodestyle==2.7.0
-pydocstyle==6.0.0
-pylint==2.7.2
-darglint==1.7.0
--e git+https://github.com/scrapli/scrapli_stubs@master#egg=scrapli_stubs
--r requirements.txt
+pydocstyle==6.1.1
+pylama==7.7.1
+pylint==2.9.6
+pytest-cov==2.12.1
+pytest==6.2.4
 -r requirements-genie.txt
+-r requirements.txt
\ No newline at end of file
diff --git a/requirements-docs.txt b/requirements-docs.txt
index 38ab1ae..183e6bb 100644
--- a/requirements-docs.txt
+++ b/requirements-docs.txt
@@ -1,5 +1,5 @@
-pdoc3==0.9.2 ; sys_platform != "win32"
-mkdocs==1.1.2
-mkdocs-material==7.0.6
+mdx-gh-links==0.2
+mkdocs==1.2.2
+mkdocs-material==7.2.1
 mkdocs-material-extensions==1.0.1
-mdx-gh-links==0.2
\ No newline at end of file
+pdoc3==0.9.2 ; sys_platform != "win32"
\ No newline at end of file
diff --git a/requirements-genie.txt b/requirements-genie.txt
index b88aee6..a4fda06 100644
--- a/requirements-genie.txt
+++ b/requirements-genie.txt
@@ -1,2 +1,2 @@
-genie>=20.2 ; sys_platform != "win32" and python_version < "3.9"
-pyats>=20.2 ; sys_platform != "win32" and python_version < "3.9"
\ No newline at end of file
+genie>=20.2 ; sys_platform != "win32" and python_version < "3.10"
+pyats>=20.2 ; sys_platform != "win32" and python_version < "3.10"
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 7fb1b9c..445cd32 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,7 +1,8 @@
-scrapli>=2021.01.30
-scrapli_netconf>=2021.01.30
+nornir>3.0.0,<4.0.0
+nornir_utils>=0.1.0
+ntc_templates>=1.1.0,<3.0.0
+scrapli>=2021.07.30
+scrapli_cfg>=2021.07.30
 scrapli_community>=2021.01.30
-nornir>=3.0.0,<4.0.0
-nornir-utils>=0.1.0
+scrapli_netconf>=2021.01.30
 textfsm>=1.1.0,<2.0.0
-ntc_templates>=1.1.0,<3.0.0
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index 5695eac..6c0b20a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,12 @@
+[coverage:run]
+source = nornir_scrapli/
+
+[coverage:report]
+sort = cover
+
 [pylama]
 linters = mccabe,pycodestyle,pylint
-skip = tests/*,.nox/*,venv/*,build/*,private/*,examples/*,docs/*,site/*
+skip = .nox/*,build/*,docs/*,private/*,site/*,tests/*,venv/*
 
 [pylama:pycodestyle]
 max_line_length = 100
@@ -9,24 +15,37 @@ max_line_length = 100
 rcfile = .pylintrc
 
 [pydocstyle]
-ignore = D101,D202,D203,D212,D400,D406,D407,D408,D409,D415
 match-dir = ^nornir_scrapli/*
+ignore = D101,D202,D203,D212,D400,D406,D407,D408,D409,D415
+# D101: missing docstring in public class
+# D202: No blank lines allowed after function docstring
+# D203: 1 blank line required before class docstring
+# D212: Multi-line docstring summary should start at the first line
+# D400: First line should end with a period
+# D406: Section name should end with a newline
+# D407: Missing dashed underline after section
+# D408: Section underline should be in the line following the sections name
+# D409: Section underline should match the length of its name
+# D415: first line should end with a period, question mark, or exclamation point
 
 [isort]
+profile = black
 line_length = 100
 multi_line_output = 3
 include_trailing_comma = True
 known_first_party = nornir
-known_third_party = scrapli,scrapli_netconf,pytest,nornir_utils
+known_third_party = nornir_utils,pytest,scrapli,scrapli_netconf
 
 [darglint]
 docstring_style = google
 strictness = full
+ignore = DAR202
 
 [mypy]
-python_version = 3.8
+python_version = 3.9
 pretty = True
 ignore_missing_imports = True
 warn_redundant_casts = True
 warn_unused_configs = True
 strict_optional = True
+
diff --git a/setup.py b/setup.py
index 5f8ef06..78da663 100644
--- a/setup.py
+++ b/setup.py
@@ -1,11 +1,11 @@
 #!/usr/bin/env python
-"""nornir_scrapli - scrapli nornir plugin"""
+"""nornir_scrapli"""
 import setuptools
 
+__version__ = "2021.07.30"
 __author__ = "Carl Montanari"
-__version__ = "2021.01.30"
 
-with open("README.md", "r") as f:
+with open("README.md", "r", encoding="utf-8") as f:
     README = f.read()
 
 with open("requirements.txt", "r") as f:
@@ -19,32 +19,48 @@
     with open(f"requirements-{extra}.txt", "r") as f:
         EXTRAS_REQUIRE[extra] = f.read().splitlines()
 
+full_requirements = [requirement for extra in EXTRAS_REQUIRE.values() for requirement in extra]
+EXTRAS_REQUIRE["full"] = full_requirements
+
+
 setuptools.setup(
     name="nornir_scrapli",
     version=__version__,
     author=__author__,
     author_email="carl.r.montanari@gmail.com",
-    description="scrapli Nornir plugin",
+    description="Scrapli's plugin for Nornir",
     long_description=README,
     long_description_content_type="text/markdown",
+    keywords="ssh telnet netconf automation network cisco iosxr iosxe nxos arista eos juniper "
+    "junos",
     url="https://github.com/scrapli/nornir_scrapli",
+    project_urls={
+        "Docs": "https://scrapli.github.io/nornir_scrapli/",
+    },
+    license="MIT",
+    package_data={"nornir_scrapli": ["py.typed"]},
     packages=setuptools.find_packages(),
     install_requires=INSTALL_REQUIRES,
+    dependency_links=[],
     extras_require=EXTRAS_REQUIRE,
     classifiers=[
         "License :: OSI Approved :: MIT License",
-        "Programming Language :: Python :: 3",
+        "Operating System :: POSIX :: Linux",
+        "Operating System :: MacOS",
+        "Programming Language :: Python",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
-        "Operating System :: POSIX :: Linux",
-        "Operating System :: MacOS",
+        "Programming Language :: Python :: 3.10",
+        "Programming Language :: Python :: 3 :: Only",
+        "Topic :: Software Development :: Libraries :: Python Modules",
     ],
     python_requires=">=3.6",
     entry_points="""
     [nornir.plugins.connections]
     scrapli=nornir_scrapli.connection:ScrapliCore
+    scrapli_cfg=nornir_scrapli.connection:ScrapliConfig
     scrapli_netconf=nornir_scrapli.connection:ScrapliNetconf
     """,
 )
diff --git a/tests/unit/functions/test_print_structured_result.py b/tests/unit/functions/test_print_structured_result.py
index 9858015..aedfae2 100644
--- a/tests/unit/functions/test_print_structured_result.py
+++ b/tests/unit/functions/test_print_structured_result.py
@@ -116,11 +116,11 @@
     [
         (
             True,
-            "\x1b[1m\x1b[36msend_commands*******************************************************************\n\x1b[1m\x1b[34m* sea-ios-1 ** changed : False *************************************************\n\x1b[1m\x1b[32mvvvv send_commands ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\n[ { 'config_register': '0x2102',\n    'hardware': ['CSR1000V'],\n    'hostname': 'csr1000v',\n    'mac': [],\n    'reload_reason': 'reload',\n    'rommon': 'IOS-XE',\n    'running_image': 'packages.conf',\n    'serial': ['9FKLJWM5EB0'],\n    'uptime': '2 hours, 43 minutes',\n    'version': '16.4.1'}]\n\x1b[1m\x1b[32m---- send_commands ** changed : False ------------------------------------------ INFO\n[ { 'distance': '',\n    'mask': '24',\n    'metric': '',\n    'network': '10.0.0.0',\n    'nexthop_if': 'GigabitEthernet1',\n    'nexthop_ip': '',\n    'protocol': 'C',\n    'type': '',\n    'uptime': ''},\n  { 'distance': '',\n    'mask': '32',\n    'metric': '',\n    'network': '10.0.0.15',\n    'nexthop_if': 'GigabitEthernet1',\n    'nexthop_ip': '',\n    'protocol': 'L',\n    'type': '',\n    'uptime': ''}]\n\x1b[1m\x1b[32m^^^^ END send_commands ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+            "\x1b[1m\x1b[36msend_commands*******************************************************************\n\x1b[1m\x1b[34m* sea-ios-1 ** changed : False *************************************************\n\x1b[1m\x1b[32mvvvv send_commands ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\n[ { 'config_register': '0x2102',\n    'hardware': ['CSR1000V'],\n    'hostname': 'csr1000v',\n    'mac': [],\n    'reload_reason': 'reload',\n    'restarted': '',\n    'rommon': 'IOS-XE',\n    'running_image': 'packages.conf',\n    'serial': ['9FKLJWM5EB0'],\n    'uptime': '2 hours, 43 minutes',\n    'version': '16.4.1'}]\n\x1b[1m\x1b[32m---- send_commands ** changed : False ------------------------------------------ INFO\n[ { 'distance': '',\n    'mask': '24',\n    'metric': '',\n    'network': '10.0.0.0',\n    'nexthop_if': 'GigabitEthernet1',\n    'nexthop_ip': '',\n    'protocol': 'C',\n    'type': '',\n    'uptime': ''},\n  { 'distance': '',\n    'mask': '32',\n    'metric': '',\n    'network': '10.0.0.15',\n    'nexthop_if': 'GigabitEthernet1',\n    'nexthop_ip': '',\n    'protocol': 'L',\n    'type': '',\n    'uptime': ''}]\n\x1b[1m\x1b[32m^^^^ END send_commands ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
         ),
         (
             False,
-            "\x1b[1m\x1b[36msend_commands*******************************************************************\n\x1b[1m\x1b[34m* sea-ios-1 ** changed : False *************************************************\n\x1b[1m\x1b[32mvvvv send_commands ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\n[ [ '16.4.1',\n    'IOS-XE',\n    'csr1000v',\n    '2 hours, 43 minutes',\n    'reload',\n    'packages.conf',\n    ['CSR1000V'],\n    ['9FKLJWM5EB0'],\n    '0x2102',\n    []]]\n\x1b[1m\x1b[32m---- send_commands ** changed : False ------------------------------------------ INFO\n[ ['C', '', '10.0.0.0', '24', '', '', '', 'GigabitEthernet1', ''],\n  ['L', '', '10.0.0.15', '32', '', '', '', 'GigabitEthernet1', '']]\n\x1b[1m\x1b[32m^^^^ END send_commands ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
+            "\x1b[1m\x1b[36msend_commands*******************************************************************\n\x1b[1m\x1b[34m* sea-ios-1 ** changed : False *************************************************\n\x1b[1m\x1b[32mvvvv send_commands ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO\n[ [ '16.4.1',\n    'IOS-XE',\n    'csr1000v',\n    '2 hours, 43 minutes',\n    'reload',\n    'packages.conf',\n    ['CSR1000V'],\n    ['9FKLJWM5EB0'],\n    '0x2102',\n    [],\n    '']]\n\x1b[1m\x1b[32m---- send_commands ** changed : False ------------------------------------------ INFO\n[ ['C', '', '10.0.0.0', '24', '', '', '', 'GigabitEthernet1', ''],\n  ['L', '', '10.0.0.15', '32', '', '', '', 'GigabitEthernet1', '']]\n\x1b[1m\x1b[32m^^^^ END send_commands ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n",
         ),
     ],
     ids=["True", "False"],
@@ -132,7 +132,7 @@ def test_print_structured_result(capsys, structured_result):
 
 
 @pytest.mark.skipif(
-    sys.version_info.minor > 8, reason="genie not currently available for python 3.9"
+    sys.version_info.minor > 9, reason="genie not currently available for python 3.10"
 )
 @pytest.mark.parametrize(
     "structured_result",
@@ -155,7 +155,7 @@ def test_print_structured_result_genie(capsys, structured_result):
 
 
 @pytest.mark.skipif(
-    sys.version_info.minor > 8, reason="genie not currently available for python 3.9"
+    sys.version_info.minor > 9, reason="genie not currently available for python 3.10"
 )
 @pytest.mark.parametrize(
     "structured_result",
diff --git a/tests/unit/tasks/cfg/__init__.py b/tests/unit/tasks/cfg/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/unit/tasks/cfg/test_abort_config.py b/tests/unit/tasks/cfg/test_abort_config.py
new file mode 100644
index 0000000..af974d9
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_abort_config.py
@@ -0,0 +1,31 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+from scrapli_cfg.response import ScrapliCfgResponse
+
+
+def test_abort_config(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_abort_config
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_abort_config(cls):
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"")
+        cfg_response = ScrapliCfgResponse(host="fake_as_heck")
+        cfg_response.record_response(scrapli_responses=[response])
+        return cfg_response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "abort_config", mock_cfg_abort_config)
+
+    result = nornir.run(task=cfg_abort_config)
+    # the result is just an empty string because there is not actual "output" from it
+    assert result["sea-ios-1"].result == ""
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is False
diff --git a/tests/unit/tasks/cfg/test_commit_config.py b/tests/unit/tasks/cfg/test_commit_config.py
new file mode 100644
index 0000000..6789468
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_commit_config.py
@@ -0,0 +1,32 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+from scrapli_cfg.response import ScrapliCfgResponse
+
+
+def test_commit_config(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_commit_config
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_commit_config(cls, source):
+        assert source == "running"
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"")
+        cfg_response = ScrapliCfgResponse(host="fake_as_heck")
+        cfg_response.record_response(scrapli_responses=[response])
+        return cfg_response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "commit_config", mock_cfg_commit_config)
+
+    result = nornir.run(task=cfg_commit_config)
+    # the result is just an empty string because there is not actual "output" from it
+    assert result["sea-ios-1"].result == ""
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is True
diff --git a/tests/unit/tasks/cfg/test_diff_config.py b/tests/unit/tasks/cfg/test_diff_config.py
new file mode 100644
index 0000000..441c02b
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_diff_config.py
@@ -0,0 +1,32 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.diff import ScrapliCfgDiffResponse
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+
+
+def test_diff_config(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_diff_config
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_diff_config(cls, source):
+        assert source == "running"
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"")
+        cfg_response = ScrapliCfgDiffResponse(host="fake_as_heck", source=source)
+        cfg_response.record_response(scrapli_responses=[response])
+        return cfg_response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "diff_config", mock_cfg_diff_config)
+
+    result = nornir.run(task=cfg_diff_config)
+    # the result is just an empty string because there is not actual "output" from it
+    assert result["sea-ios-1"].result == ""
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is False
diff --git a/tests/unit/tasks/cfg/test_get_config.py b/tests/unit/tasks/cfg/test_get_config.py
new file mode 100644
index 0000000..276614e
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_get_config.py
@@ -0,0 +1,31 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+from scrapli_cfg.response import ScrapliCfgResponse
+
+
+def test_get_config(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_get_config
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_get_config(cls, source):
+        assert source == "candidate"
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"some stuff about whatever")
+        cfg_response = ScrapliCfgResponse(host="fake_as_heck")
+        cfg_response.record_response(scrapli_responses=[response])
+        return response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "get_config", mock_cfg_get_config)
+
+    result = nornir.run(task=cfg_get_config, source="candidate")
+    assert result["sea-ios-1"].result == "some stuff about whatever"
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is False
diff --git a/tests/unit/tasks/cfg/test_get_version.py b/tests/unit/tasks/cfg/test_get_version.py
new file mode 100644
index 0000000..3c6f64e
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_get_version.py
@@ -0,0 +1,30 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+from scrapli_cfg.response import ScrapliCfgResponse
+
+
+def test_get_version(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_get_version
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_get_version(cls):
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"15.2(4)E7")
+        cfg_response = ScrapliCfgResponse(host="fake_as_heck")
+        cfg_response.record_response(scrapli_responses=[response])
+        return response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "get_version", mock_cfg_get_version)
+
+    result = nornir.run(task=cfg_get_version)
+    assert result["sea-ios-1"].result == "15.2(4)E7"
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is False
diff --git a/tests/unit/tasks/cfg/test_load_config.py b/tests/unit/tasks/cfg/test_load_config.py
new file mode 100644
index 0000000..c62811c
--- /dev/null
+++ b/tests/unit/tasks/cfg/test_load_config.py
@@ -0,0 +1,33 @@
+from scrapli.driver.core import IOSXEDriver
+from scrapli.response import Response
+from scrapli_cfg.platform.core.cisco_iosxe.sync_platform import ScrapliCfgIOSXE
+from scrapli_cfg.response import ScrapliCfgResponse
+
+
+def test_load_config(nornir, monkeypatch):
+    from nornir_scrapli.tasks import cfg_load_config
+
+    def mock_open(cls):
+        pass
+
+    def mock_cfg_prepare(cls):
+        pass
+
+    def mock_cfg_load_config(cls, config, replace, **kwargs):
+        assert config == "configtoload"
+        assert replace is True
+        response = Response(host="fake_as_heck", channel_input="blah")
+        response.record_response(b"")
+        cfg_response = ScrapliCfgResponse(host="fake_as_heck")
+        cfg_response.record_response(scrapli_responses=[response])
+        return cfg_response
+
+    monkeypatch.setattr(IOSXEDriver, "open", mock_open)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "prepare", mock_cfg_prepare)
+    monkeypatch.setattr(ScrapliCfgIOSXE, "load_config", mock_cfg_load_config)
+
+    result = nornir.run(task=cfg_load_config, config="configtoload", replace=True)
+    # the result is just an empty string because there is not actual "output" from it
+    assert result["sea-ios-1"].result == ""
+    assert result["sea-ios-1"].failed is False
+    assert result["sea-ios-1"].changed is False