Repository: CiscoDevNet/sastre (language: python)

Issue:
Policy-object feature profile is now skipped from delete task as it is not allowed by vManage.



Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1bfaf0b..4b270d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,43 @@
+Sastre 1.24 [June 21, 2024]
+================================
+
+#### Improvements:
+- [#12] Starting on vManage 20.12, feature profile api get requests return parcels without data, parcels need to be retrieved individually.
+- 20.12/20.13 vManage support, included support for the following API endpoints:
+  - New SDWAN service profile parcels: routing ospfv3 ipv4/ipv6, routing eigrp, interface gre, route-policy.
+  - New SDWAN transport profile parcels: routing ospfv3 ipv4/ipv6, esimcellular-controller, esimcellular-profile, t1-e1-controller, interface serial, route-policy.
+  - New SDWAN other profile parcels: cybervision.
+  - New SDWAN system profile parcels: remote-access, ipv4-device-access-policy, ipv6-device-access-policy
+  - New SDWAN policy-object profile parcels: security-data-ip-prefix, security-fqdn, security-port, security-localapp, security-localdomain, security-ipssignature, 
+                                             security-urllist, security-protocolname, security-geolocation, security-identity, security-scalablegrouptag, security-zone, 
+                                             app-list, sla-class, as-path, class, data-ipv6-prefix, data-prefix, expanded-community, ext-community, ipv6-prefix, mirror, 
+                                             policer, prefix, standard-community, vpn-group, app-probe, tloc, color, preferred-color-group, unified/advanced-inspection-profile, 
+                                             unified/intrusion-prevention, unified/url-filtering, unified/advanced-malware-protection, unified/ssl-decryption-profile, unified/ssl-decryption
+  - SDWAN dns-security profile and dns parcel.
+  - SDWAN sig-security profile and sig parcel.
+  - SDWAN application-priority profile and qos-policy, traffic-policy parcels.
+  - SDWAN embedded-security profile and policy, unified/ngfirewall parcels.
+  - Topology groups
+  - Policy groups
+- 20.12 vManage support, updated rest api logout to use POST with 20.12+ releases.
+
+#### Changes:
+- Disabled automated tag rules processing until vManage apis for config-association via tag rules become stable and are documented.
+
+#### Known Limitations:
+- Restore of policy-object feature profile is not supported yet.
+
+
 Sastre 1.23.1 [March 22, 2024]
 ================================
 
 - Bumped up minimum Python version requirement to 3.9. As Python 3.8 is going end-of-support in October-2024.
 
+
 Sastre 1.23 [March 8, 2024]
 ================================
 
-### Improvements
+#### Improvements:
 - Handling of templates with encrypted fields (e.g. SNMP feature template community value)
   - New encrypt values and encrypt recipe tasks.
   - New transform build-recipe task.
@@ -26,14 +57,14 @@ Sastre 1.23 [March 8, 2024]
 Sastre 1.22 [November 27, 2023]
 ================================
 
-### Improvements
+#### Improvements:
 - Sastre and Sastre-Pro now contain the same tasks. There is no functional differences between the two.
 
 
 Sastre 1.21.3 [July 27, 2023]
 ================================
 
-### Fixes:
+#### Fixes:
 - Fixed pydantic.errors.PydanticUserError traceback errors found with new installations. This was due to incompatible changes on new pydantic 2.x release.
 
 
diff --git a/Dockerfile b/Dockerfile
index e36bb83..021637f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.10-alpine
+FROM python:3.12-alpine
 
 ARG http_proxy
 ARG https_proxy
diff --git a/Jenkinsfile b/Jenkinsfile
index 3110464..5c6ad9f 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,19 +1,43 @@
 pipeline {
     environment {
-        GIT_CREDS = credentials('345c79bc-9def-4981-94b5-d8190fdd2304') // as-ci-user.gen
         WEBEX_ROOM = 'Y2lzY29zcGFyazovL3VzL1JPT00vZTMxNTUzZjAtZDNiMS0xMWViLWJjNzktMTUxMzcwZjZlOTYz' // Sastre - CICD Notifications
         WEBEX_CREDS = '16fdd237-afe3-4d7f-9fe5-7bde6d1275e0'    // sastre-cicd@webex.bot
         REGISTRY = 'containers.cisco.com'
         REGISTRY_URL = "https://$REGISTRY"
-        ECH_ORG = 'aide'
+        ECH_ORG = 'maestro-org'
         ECH_REPO = 'sastre-pro'
+        GEN_USER = "cx-sastre-user.gen"
         ECH_PATH = "${REGISTRY}/${ECH_ORG}/${ECH_REPO}"
-        ECH_CREDENTIALS = '70d73668-c133-45cc-9943-cc32f1830945'
+        ECH_CREDENTIALS = 'sastre-ech-token'
     }
     agent {
-        label "AMER-REGION"
+        label "sastre-pro-node"
     }
+    options { timestamps () }
+
     stages {
+        stage('Log Build Info') {
+            steps {
+                script {
+                    def hostname = sh(script: 'hostname', returnStdout: true).trim()
+                    def ip = sh(script: 'hostname -I', returnStdout: true).trim()
+                    def buildTriggerBy = currentBuild.getBuildCauses()[0].shortDescription
+
+                    echo "Hostname: ${hostname}"
+                    echo "IP: ${ip}"
+                    echo "Build cause: ${buildTriggerBy}"
+                    echo "Build number: ${env.BUILD_NUMBER}"
+                    echo "Build URL: ${env.BUILD_URL}"
+
+                    echo "Toolchain Information:"
+                    echo "==== podman ===="
+                    echo "podman: /usr/bin/podman"
+                    sh "podman --version"
+                    sh "sha256sum /usr/bin/podman"
+                }
+            }
+        }
+
         stage("Build") {
             agent {
                 dockerfile {
@@ -33,17 +57,23 @@ pipeline {
         stage("Publish") {
             options { skipDefaultCheckout true }
             steps {
-                withDockerRegistry([ credentialsId: "$ECH_CREDENTIALS", url: "$REGISTRY_URL" ]) {
+                withCredentials([conjurSecretCredential(credentialsId: "$ECH_CREDENTIALS", variable: "ECH_TOKEN")]) {
                     sh """
+                        echo $ECH_TOKEN | docker login --username $GEN_USER --password-stdin containers.cisco.com
                         docker tag $ECH_PATH:$BRANCH_NAME $ECH_PATH:latest
                         docker push $ECH_PATH:latest
                     """
                 }
+                echo "Generated Artifact Info:"
+                echo "Image name and version: $ECH_PATH:latest"
+                sh "docker inspect --format '{{.Digest}}' $ECH_PATH:latest"
+                echo "Stored in: $REGISTRY_URL"
+                sh "docker rmi $ECH_PATH:$BRANCH_NAME $ECH_PATH:latest"
             }
             when {
                 anyOf {
                     buildingTag()
-                    branch 'master'
+                    branch pattern: "master|dev-.*", comparator: "REGEXP"
                 }
                 beforeAgent true
             }
diff --git a/README.md b/README.md
index 12071a7..6a86b69 100644
--- a/README.md
+++ b/README.md
@@ -9,14 +9,14 @@ Some use-cases include:
 - Backup, restore and delete configuration items. Tags and regular expressions can be used to select all or a subset of items.
 - Visualize operational data across multiple devices. For instance, display status of control connections from multiple devices in a single table.
 
-Sastre can also be used as an SDK to other applications, further information is available on [DevNet Sastre SDK](https://github.com/CiscoDevNet/sastre/wiki/4.-Sastre-SDK).
+Sastre can also be used as an SDK to other applications, further information is available on [Sastre SDK](https://github.com/CiscoDevNet/sastre/wiki/4.-Sastre-SDK).
 
 [Sastre-Ansible](https://github.com/CiscoDevNet/sastre-ansible) is an Ansible Collection that exposes Sastre commands to Ansible Playbooks as a set of tasks and lookup plugins. Allowing users to build-up on Sastre functionality to create larger automation workflows.
 
 Support enquires can be sent to sastre-support@cisco.com.
 
 Note on vManage release support:
-- Sastre 1.23 officially supports up to vManage 20.11. Newer vManage releases normally work without problems, just lacking support to the newer features added to that particular vManage release.
+- Sastre 1.24 officially supports up to vManage 20.13. Newer vManage releases normally work without problems, just lacking support to the newer features added to that particular vManage release.
 
 ## Sastre and Sastre-Pro
 
@@ -1314,7 +1314,7 @@ A WARNING message is displayed when this condition happens. The user may want to
 
 ## Installing
 
-Sastre requires Python 3.8 or newer. This can be verified by pasting the following to a terminal window:
+Sastre requires Python 3.9 or newer. This can be verified by pasting the following to a terminal window:
 ```
 % python3 -c "import sys;assert sys.version_info>(3,9)" && echo "ALL GOOD"
 ```
diff --git a/cisco_sdwan/__main__.py b/cisco_sdwan/__main__.py
index 47c1685..2e5456b 100644
--- a/cisco_sdwan/__main__.py
+++ b/cisco_sdwan/__main__.py
@@ -18,8 +18,8 @@
 from .__version__ import __version__ as version
 from .__version__ import __doc__ as title
 from .tasks.utils import TaskOptions, EnvVar, non_empty_type, PromptArg
-from .tasks.common import TaskException
-from .tasks.implementation import *
+from .tasks.common import Task, TaskException
+from .tasks import implementation
 
 # vManage REST API defaults
 VMANAGE_PORT = '443'
@@ -66,6 +66,48 @@
 '''
 
 
+def setup_logging(logging_config: str, is_verbose: bool = False, is_debug: bool = False) -> None:
+    logging_config_dict = json.loads(logging_config)
+    console_handler = logging_config_dict.get('handlers', {}).get('console')
+    if is_verbose and console_handler is not None:
+        console_handler['level'] = 'INFO'
+    if is_debug:
+        logging_config_dict.setdefault('loggers', {}).setdefault('urllib3.connectionpool', {})['level'] = 'DEBUG'
+
+    file_handler = logging_config_dict.get('handlers', {}).get('file')
+    if file_handler is not None:
+        file_handler['filename'] = str(Path(SASTRE_ROOT_DIR, file_handler['filename']))
+        Path(file_handler['filename']).parent.mkdir(parents=True, exist_ok=True)
+
+    logging.config.dictConfig(logging_config_dict)
+
+
+def execute_task(task_obj: Task, parsed_task_args, is_api_task: bool, base_url: str,
+                 user: str, password: str, tenant: str, timeout: int, is_verbose: bool = False) -> None:
+    try:
+        if is_api_task:
+            with Rest(base_url, user, password, tenant, timeout=timeout) as api:
+                # Dispatch to the appropriate task handler
+                task_output = task_obj.runner(parsed_task_args, api)
+        else:
+            # Dispatch to the appropriate task handler without api connection
+            task_output = task_obj.runner(parsed_task_args)
+
+        # Display task output
+        if task_output:
+            print('\n\n'.join(str(entry) for entry in task_output))
+
+        # Display dryrun report if console logging is disabled (i.e. not in verbose mode)
+        if not is_verbose and task_obj.is_dryrun:
+            print(str(task_obj.dryrun_report))
+
+        task_obj.log_info(f'Task completed {task_obj.outcome("successfully", "with caveats: {tally}")}')
+    except (RestAPIException, ConnectionError, HTTPError, FileNotFoundError, ModelException, TaskException) as ex:
+        logging.getLogger(__name__).critical(ex)
+    except KeyboardInterrupt:
+        logging.getLogger(__name__).critical('Interrupted by user')
+
+
 def main():
     # Top-level cli parser
     cli_parser = argparse.ArgumentParser(description=title)
@@ -106,20 +148,7 @@ def main():
     ])
     cli_args = cli_parser.parse_args()
 
-    # Logging setup
-    logging_config = json.loads(LOGGING_CONFIG)
-    console_handler = logging_config.get('handlers', {}).get('console')
-    if cli_args.verbose and console_handler is not None:
-        console_handler['level'] = 'INFO'
-    if cli_args.debug:
-        logging_config.setdefault('loggers',{}).setdefault('urllib3.connectionpool',{})['level'] = 'DEBUG'
-
-    file_handler = logging_config.get('handlers', {}).get('file')
-    if file_handler is not None:
-        file_handler['filename'] = str(Path(SASTRE_ROOT_DIR, file_handler['filename']))
-        Path(file_handler['filename']).parent.mkdir(parents=True, exist_ok=True)
-
-    logging.config.dictConfig(logging_config)
+    setup_logging(LOGGING_CONFIG, cli_args.verbose, cli_args.debug)
 
     # Prepare task
     task = cli_args.task()
@@ -138,34 +167,13 @@ def main():
     except KeyboardInterrupt:
         sys.exit(1)
 
-    # Dispatch task
-    try:
-        if is_api_required:
-            if target_address != cli_args.address:
-                # Target address changed, re-run parser
-                parsed_task_args = task.parser(cli_args.task_args, target_address=cli_args.address)
-
-            base_url = f'https://{cli_args.address}{"" if cli_args.port == "443" else f":{cli_args.port}"}'
-            with Rest(base_url, cli_args.user, cli_args.password, cli_args.tenant, timeout=cli_args.timeout) as api:
-                # Dispatch to the appropriate task handler
-                task_output = task.runner(parsed_task_args, api)
+    # Re-run task parser if target address changed
+    if is_api_required and target_address != cli_args.address:
+        parsed_task_args = task.parser(cli_args.task_args, target_address=cli_args.address)
 
-        else:
-            # Dispatch to the appropriate task handler without api connection
-            task_output = task.runner(parsed_task_args)
-
-        # Display task output
-        if task_output:
-            print('\n\n'.join(str(entry) for entry in task_output))
-        # Display dryrun report if console logging is disabled (i.e. not in verbose mode)
-        if not cli_args.verbose and task.is_dryrun:
-            print(str(task.dryrun_report))
-
-        task.log_info(f'Task completed {task.outcome("successfully", "with caveats: {tally}")}')
-    except (RestAPIException, ConnectionError, HTTPError, FileNotFoundError, ModelException, TaskException) as ex:
-        logging.getLogger(__name__).critical(ex)
-    except KeyboardInterrupt:
-        logging.getLogger(__name__).critical('Interrupted by user')
+    base_url = f'https://{cli_args.address}{"" if cli_args.port == "443" else f":{cli_args.port}"}'
+    execute_task(task, parsed_task_args, is_api_required, base_url, cli_args.user, cli_args.password,
+                 cli_args.tenant, cli_args.timeout, cli_args.verbose)
 
 
 if __name__ == '__main__':
diff --git a/cisco_sdwan/__version__.py b/cisco_sdwan/__version__.py
index f06eb69..c9734a5 100644
--- a/cisco_sdwan/__version__.py
+++ b/cisco_sdwan/__version__.py
@@ -3,4 +3,4 @@
 
 """
 __copyright__ = "Copyright (c) 2019-2024 Cisco Systems, Inc. and/or its affiliates"
-__version__ = "1.23.1"
+__version__ = "1.24"
diff --git a/cisco_sdwan/base/catalog.py b/cisco_sdwan/base/catalog.py
index 07bab2c..d4dfac5 100644
--- a/cisco_sdwan/base/catalog.py
+++ b/cisco_sdwan/base/catalog.py
@@ -4,7 +4,8 @@
  cisco_sdwan.base.catalog
  This module implements vManage API Catalogs
 """
-from typing import NamedTuple, Union, Optional, Iterator, Dict, Set, Tuple, Any
+from typing import NamedTuple, Union, Optional, Any
+from collections.abc import Iterator
 from enum import Enum
 from .models_base import IndexConfigItem, ConfigItem, RealtimeItem, BulkStateItem, BulkStatsItem
 from .rest_api import is_version_newer
@@ -21,7 +22,7 @@ class CatalogItem(NamedTuple):
     min_version: Union[str, None]
 
 
-_catalog: Dict[type, CatalogItem] = dict()  # {<item_cls>: (<tag>, <info>, <index_cls>, <item_cls>, <min_version>), ...}
+_catalog: dict[type, CatalogItem] = dict()  # {<item_cls>: (<tag>, <info>, <index_cls>, <item_cls>, <min_version>), ...}
 
 
 # Catalog for operational data items
@@ -52,7 +53,10 @@ class OpCatalogItem(NamedTuple):
 # their high-level dependencies.
 _tag_dependency_list = [
     'config_group',
+    'topology_group',
+    'policy_group',
     'feature_profile',
+    'policy_object',
     'template_device',
     'template_feature',
     'policy_vsmart',
@@ -128,7 +132,7 @@ def catalog_size() -> int:
     return len(_catalog)
 
 
-def catalog_iter(*tags: str, version: Optional[str] = None) -> Iterator[Tuple[str, str, Any, Any]]:
+def catalog_iter(*tags: str, version: Optional[str] = None) -> Iterator[tuple[str, str, Any, Any]]:
     """
     Return an iterator of (<tag>, <info>, <index_cls>, <item_cls>) tuples matching the specified tag(s) and supported
     by vManage version.
@@ -150,7 +154,7 @@ def match_version(clog_item):
     )
 
 
-def catalog_tags() -> Set[str]:
+def catalog_tags() -> set[str]:
     """
     Return unique tags used by items registered with the catalog
     @return: Set of unique tags
diff --git a/cisco_sdwan/base/models_base.py b/cisco_sdwan/base/models_base.py
index 25696d4..74b4a1e 100644
--- a/cisco_sdwan/base/models_base.py
+++ b/cisco_sdwan/base/models_base.py
@@ -10,13 +10,14 @@
 from pathlib import Path
 from itertools import zip_longest
 from collections import namedtuple
-from typing import Sequence, Tuple, Union, Iterator, Callable, Mapping, Any, Optional, Dict, List, Generator, NamedTuple
+from typing import Union, Any, Optional, NamedTuple
+from collections.abc import Mapping, Sequence, Iterator, Generator, Callable
 from operator import attrgetter
 from datetime import datetime, timezone
 from urllib.parse import quote_plus
-from pydantic import ConfigDict, BaseModel
+from pydantic import ConfigDict, BaseModel, Field
 from requests.exceptions import Timeout
-from .rest_api import RestAPIException, Rest
+from .rest_api import RestAPIException, Rest, is_version_newer
 
 # Top-level directory for local data store
 SASTRE_ROOT_DIR = Path(environ.get('SASTRE_ROOT_DIR', Path.cwd()))
@@ -162,7 +163,7 @@ def __init__(self, path_map: Mapping[str, ApiPath],
         self._parent_types = {path_key.parent_parcel_type
                               for path_key in self._parcel_ref_map if path_key.parent_parcel_type is not None}
 
-    def api_path(self, key: PathKey) -> Tuple[Union[ApiPath, None], bool]:
+    def api_path(self, key: PathKey) -> tuple[Union[ApiPath, None], bool]:
         """
         Returns the api path associated with the provided key, along with whether this is a parcel reference or an
         actual parcel.
@@ -217,7 +218,7 @@ def __init__(self, payload: Mapping[str, Any]) -> None:
             field['title'] = title_dict.get(field_property, field['property'].replace('_', ' ').title())
 
     @property
-    def field_names(self) -> Tuple[str, ...]:
+    def field_names(self) -> tuple[str, ...]:
         return tuple(self._meta.keys())
 
     def field_info(self, *field_names: str, info: str = 'title', default: Union[None, str] = 'N/A') -> tuple:
@@ -466,7 +467,7 @@ def __init__(self, payload: Mapping[str, Any]) -> None:
         self._page_info = payload['pageInfo']
 
     @staticmethod
-    def query(start_time: datetime, end_time: datetime, size: int) -> Dict[str, Any]:
+    def query(start_time: datetime, end_time: datetime, size: int) -> dict[str, Any]:
         """
         @param start_time: Starting date time for the query, i.e. oldest.
         @param end_time: End date time for the query, i.e. newest.
@@ -627,7 +628,7 @@ class ConfigItem(ApiItem):
     ConfigItem is an ApiItem that can be backed up and restored
     """
     store_path = None
-    store_file = None
+    store_file = '{item_name}.json'
     root_dir = DATA_DIR
     factory_default_tag = 'factoryDefault'
     readonly_tag = 'readOnly'
@@ -638,7 +639,7 @@ class ConfigItem(ApiItem):
     skip_cmp_tag_set = set()
     name_check_regex = re.compile(r'(?=^.{1,128}$)[^&<>! "]+$')
 
-    def is_equal(self, other_payload: Dict[str, Any]) -> bool:
+    def is_equal(self, other_payload: Mapping[str, Any]) -> bool:
         exclude_set = self.skip_cmp_tag_set | {self.id_tag}
 
         local_cmp_dict = {k: v for k, v in self.data.items() if k not in exclude_set}
@@ -721,13 +722,13 @@ def save(self, node_dir, ext_name=False, item_name=None, item_id=None):
 
         return True
 
-    def post_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
+    def post_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
         """
         Build payload to be used for POST requests against this config item. From "self.data", perform item id
         replacements defined in id_mapping_dict, also remove item id and rename item with new_name (if provided).
         @param id_mapping_dict: {<old item id>: <new item id>} dict. If provided, <old item id> matches are replaced
                                 with <new item id>
-        @return: Dict containing payload for POST requests
+        @return: dict containing payload for POST requests
         """
         # Delete keys that shouldn't be on post requests
         filtered_keys = {
@@ -753,13 +754,13 @@ def post_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict
 
         return update_ids(id_mapping_dict, post_dict)
 
-    def put_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
+    def put_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
         """
         Build payload to be used for PUT requests against this config item. From "self.data", perform item id
         replacements defined in id_mapping_dict.
         @param id_mapping_dict: {<old item id>: <new item id>} dict. If provided, <old item id> matches are replaced
                                 with <new item id>
-        @return: Dict containing payload for PUT requests
+        @return: dict containing payload for PUT requests
         """
         filtered_keys = {
             '@rid',
@@ -831,7 +832,6 @@ class IndexConfigItem(ConfigItem):
     """
     IndexConfigItem is an index-type ConfigItem that can be iterated over, returning iter_fields
     """
-
     def __init__(self, data):
         """
         @param data: dict containing the information to be associated with this configuration item.
@@ -855,6 +855,7 @@ def __init__(self, data):
     extended_iter_fields = None
 
     store_path = ('inventory',)
+    store_file = None
 
     @classmethod
     def create(cls, item_list: Sequence[ConfigItem], id_hint_dict: Mapping[str, str]):
@@ -908,14 +909,14 @@ def __init__(self, **kwargs):
 class ProfileParcelPayloadModel(ConfigRequestModel):
     name: str
     description: str = ''
-    data: Dict[str, Any]
+    data: Optional[dict[str, Any]] = None
 
 
 class ProfileParcelModel(ConfigRequestModel):
     parcelId: str
     parcelType: str
     payload: ProfileParcelPayloadModel
-    subparcels: List['ProfileParcelModel']
+    subparcels: list['ProfileParcelModel'] = Field(default_factory=list)
 
 
 class ProfileParcelReferenceModel(ConfigRequestModel):
@@ -931,64 +932,64 @@ class Config2Item(ConfigItem):
     put_model: Optional[Callable[..., ConfigRequestModel]] = None
     delete_model: Optional[Callable[..., ConfigRequestModel]] = None
 
-    def is_equal(self, other: Dict[str, Any]) -> bool:
+    def is_equal(self, other: Mapping[str, Any]) -> bool:
         exclude_set = self.skip_cmp_tag_set | {self.id_tag}
         put_model = self.put_model or self.post_model
 
-        local_cmp_dict = put_model(**self.data).model_dump(by_alias=True, exclude=exclude_set)
+        local_cmp_dict = put_model(**self.data).model_dump(by_alias=True, exclude=exclude_set, exclude_defaults=True)
         other_cmp_dict = {k: v for k, v in other.items() if k not in exclude_set}
 
         return sorted(json.dumps(local_cmp_dict)) == sorted(json.dumps(other_cmp_dict))
 
-    def post_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
+    def post_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
         """
         Build payload to be used for POST requests against this config item. From "self.data", perform item id
         replacements defined in id_mapping_dict, also remove item id and rename item with new_name (if provided).
         @param id_mapping_dict: {<old item id>: <new item id>} dict. If provided, <old item id> matches are replaced
                                 with <new item id>
-        @return: Dict containing payload for POST requests
+        @return: dict containing payload for POST requests
         """
         return self._op_data(self.post_model, id_mapping_dict)
 
-    def put_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
+    def put_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
         """
         Build payload to be used for PUT requests against this config item. From "self.data", perform item id
         replacements defined in id_mapping_dict.
         @param id_mapping_dict: {<old item id>: <new item id>} dict. If provided, <old item id> matches are replaced
                                 with <new item id>
-        @return: Dict containing payload for PUT requests
+        @return: dict containing payload for PUT requests
         """
         put_model = self.put_model or self.post_model
         return self._op_data(put_model, id_mapping_dict)
 
-    def delete_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
+    def delete_data(self, id_mapping_dict: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
         """
         Build payload to be used for DELETE requests against this config item. From "self.data", perform item id
         replacements defined in id_mapping_dict.
         @param id_mapping_dict: {<old item id>: <new item id>} dict. If provided, <old item id> matches are replaced
                                 with <new item id>
-        @return: Dict containing payload for DELETE requests
+        @return: dict containing payload for DELETE requests
         """
         delete_model = self.delete_model or self.put_model or self.post_model
         return self._op_data(delete_model, id_mapping_dict)
 
     def _op_data(self, op_model: Callable[..., ConfigRequestModel],
-                 id_mapping_dict: Optional[Mapping[str, str]]) -> Dict[str, Any]:
+                 id_mapping_dict: Optional[Mapping[str, str]]) -> dict[str, Any]:
         payload = op_model(**self.data)
 
         if id_mapping_dict is None:
-            return payload.model_dump(by_alias=True)
+            return payload.model_dump(by_alias=True, exclude_defaults=True)
 
-        return update_ids(id_mapping_dict, payload.model_dump(by_alias=True))
+        return update_ids(id_mapping_dict, payload.model_dump(by_alias=True, exclude_defaults=True))
 
 
 class FeatureProfile(Config2Item):
-    store_file = '{item_name}.json'
     id_tag = 'profileId'
     name_tag = 'profileName'
     type_tag = 'profileType'
     parcels_tag = 'associatedProfileParcels'
-    parcel_api_paths: ApiPathGroup = None
+    created_by_tag = 'createdBy'
+    parcel_api_paths: Optional[ApiPathGroup] = None
 
     post_model = FeatureProfileModel
 
@@ -996,9 +997,44 @@ def __init__(self, data):
         super().__init__(data)
 
         # {<old parcel id>: <new parcel id>} map used to update parcel references with the new parcel ids
-        self.id_mapping: Dict[str, str] = {}
+        self._id_mapping: dict[str, str] = {}
+
+    @property
+    def is_system(self):
+        return super().is_system or self.data.get(self.created_by_tag, '') == 'system'
+
+    def parcel_id_mapping(self) -> Iterator[tuple[str, str]]:
+        return ((old_parcel_id, new_parcel_id) for old_parcel_id, new_parcel_id in self._id_mapping.items())
+
+    def update_parcels_data(self, api: Rest, profile_id: str) -> None:
+        def eval_parcel(parcel: ProfileParcelModel, *element_ids: str, parent_parcel_type: Optional[str] = None):
+            api_path, _ = self.parcel_api_paths.api_path(PathKey(parcel.parcelType, parent_parcel_type))
+            if api_path is None:
+                raise ModelException(f"Parcel type {parcel.parcelType} is not supported")
+            if api_path is ...:
+                # This is a parcel reference no further processing is needed
+                return
+
+            if parcel.payload.data is None:
+                raw_single_parcel = api.get(api_path.resolve(*element_ids).get, parcel.parcelId)
+                parcel.payload.data = ProfileParcelModel(**raw_single_parcel).payload.data
+
+            new_element_ids = element_ids + (parcel.parcelId,)
+
+            for sub_parcel in parcel.subparcels:
+                eval_parcel(sub_parcel, *new_element_ids, parent_parcel_type=parcel.parcelType)
+
+        def eval_root_parcel(raw_parcel: Mapping[str, Any]) -> dict[str, Any]:
+            root_parcel = ProfileParcelModel(**raw_parcel)
+            eval_parcel(root_parcel, profile_id)
+
+            return root_parcel.model_dump(by_alias=True, exclude_defaults=True)
 
-    def associated_parcels(self, new_profile_id: str) -> Generator[Tuple[ApiPath, str, Dict[str, Any]], str, None]:
+        self.data[self.parcels_tag] = [
+            eval_root_parcel(raw_parcel) for raw_parcel in self.data.get(self.parcels_tag, [])
+        ]
+
+    def associated_parcels(self, new_profile_id: str) -> Generator[tuple[ApiPath, str, dict[str, Any]], str, None]:
         def parcel_ordering(parcel_obj):
             if self.parcel_api_paths.is_referenced_type(parcel_obj.parcelType):
                 return 0 if not self.parcel_api_paths.is_parent_type(parcel_obj.parcelType) else 1
@@ -1012,7 +1048,7 @@ def parcel_ordering(parcel_obj):
 
     def profile_parcel_coro(
             self, parcel: ProfileParcelModel, *element_ids: str,
-            parent_parcel_type: Optional[str] = None) -> Generator[Tuple[ApiPath, str, Dict[str, Any]], str, None]:
+            parent_parcel_type: Optional[str] = None) -> Generator[tuple[ApiPath, str, dict[str, Any]], str, None]:
         """
         Iterate over Config 2.0 feature profile parcels, starting with the provided parcel and recursively checking
         sub-parcels it may contain.
@@ -1033,7 +1069,7 @@ def profile_parcel_coro(
         if is_reference:
             parcel_info = f'{parcel.payload.name} (parcel reference)'
 
-            new_parcel_id = self.id_mapping.get(parcel.parcelId)
+            new_parcel_id = self._id_mapping.get(parcel.parcelId)
             if new_parcel_id is None:
                 raise ModelException(f"{parcel_info}: Referenced parcel ID not found")
 
@@ -1042,16 +1078,36 @@ def profile_parcel_coro(
             parcel_info = parcel.payload.name
             parcel_payload = parcel.payload
 
-        new_element_id = yield (api_path.resolve(*element_ids),
-                                parcel_info,
-                                update_ids(self.id_mapping, parcel_payload.model_dump(by_alias=True)))
+        new_element_id = yield (
+            api_path.resolve(*element_ids),
+            parcel_info,
+            update_ids(self._id_mapping, parcel_payload.model_dump(by_alias=True, exclude_defaults=True))
+        )
 
-        self.id_mapping[parcel.parcelId] = new_element_id
+        self._id_mapping[parcel.parcelId] = new_element_id
         new_element_ids = element_ids + (new_element_id,)
 
         for sub_parcel in parcel.subparcels:
             yield from self.profile_parcel_coro(sub_parcel, *new_element_ids, parent_parcel_type=parcel.parcelType)
 
+    @classmethod
+    def get_raise(cls, api: Rest, *args, **kwargs):
+        # Extract path vars from kwargs, what is left becomes query vars
+        path_vars_map = {}
+        if cls.api_path.path_vars is not None:
+            for path_var in cls.api_path.path_vars:
+                path_var_value = kwargs.pop(path_var, None)
+                if path_var_value is not None:
+                    path_vars_map[path_var] = path_var_value
+
+        fp_obj = cls(api.get(cls.api_path.resolve(**path_vars_map).get, *args, **kwargs))
+
+        # Special case for FeatureProfiles on 20.12 or newer, need to retrieve parcel data fields
+        if is_version_newer('20.11', api.server_version):
+            fp_obj.update_parcels_data(api, fp_obj.uuid)
+
+        return fp_obj
+
 
 class FeatureProfileIndex(IndexConfigItem):
     iter_fields = IdName('profileId', 'profileName')
@@ -1141,7 +1197,7 @@ def filename_safe(name: str, lower: bool = False) -> str:
     return cleaned.lower() if lower else cleaned
 
 
-def update_ids(id_map: Mapping[str, str], item_data: Dict[str, Any]) -> Dict[str, Any]:
+def update_ids(id_map: Mapping[str, str], item_data: Mapping[str, Any]) -> dict[str, Any]:
     def replace_id(match):
         matched_id = match.group(0)
         return id_map.get(matched_id, matched_id)
@@ -1151,7 +1207,7 @@ def replace_id(match):
     return json.loads(dict_json)
 
 
-def update_crypts(crypt_map: Mapping[str, str], item_data: Dict[str, Any]) -> Dict[str, Any]:
+def update_crypts(crypt_map: Mapping[str, str], item_data: Mapping[str, Any]) -> dict[str, Any]:
     def replace_crypt(match):
         matched_crypt = match.group(0)
         return crypt_map.get(matched_crypt, matched_crypt)
diff --git a/cisco_sdwan/base/models_vmanage.py b/cisco_sdwan/base/models_vmanage.py
index 708786e..c8a0151 100644
--- a/cisco_sdwan/base/models_vmanage.py
+++ b/cisco_sdwan/base/models_vmanage.py
@@ -5,7 +5,8 @@
  This module implements vManage API models
 """
 import re
-from typing import Iterable, Set, Optional, Sequence, Mapping, List, Any, Callable, Tuple, Dict, Union
+from typing import Optional, Any, Union
+from collections.abc import Mapping, Sequence, Callable, Iterable
 from pathlib import Path
 from collections import namedtuple
 from copy import deepcopy
@@ -433,7 +434,6 @@ class DeviceTemplateAttached(IndexConfigItem):
 class DeviceTemplateValues(ConfigItem):
     api_path = ApiPath(None, 'template/device/config/input', None, None)
     store_path = ('device_templates', 'values')
-    store_file = '{item_name}.json'
 
     @staticmethod
     def api_params(template_id: str, device_uuids: Iterable[str]) -> dict:
@@ -490,7 +490,6 @@ class DeviceTemplate(ConfigItem):
         ApiPath('template/device/object', 'template/device/cli', 'template/device')
     )
     store_path = ('device_templates', 'template')
-    store_file = '{item_name}.json'
     name_tag = 'templateName'
     id_tag = 'templateId'
     post_filtered_tags = ('feature',)
@@ -521,7 +520,7 @@ def contains_template(self, template_type: str) -> bool:
         return template_type in self.find_key('templateType')
 
     @property
-    def feature_templates(self) -> Set[str]:
+    def feature_templates(self) -> set[str]:
         return set(self.find_key('templateId', from_key='generalTemplates'))
 
 
@@ -549,7 +548,7 @@ def is_attached(iterator_entry: FilteredIterEntry) -> bool:
     def is_cedge(iterator_entry: FilteredIterEntry) -> bool:
         return iterator_entry.device_type is not None and iterator_entry.device_type in CEDGE_SET
 
-    def filtered_iter(self, *filter_fns: Callable) -> Iterable[Tuple[str, str]]:
+    def filtered_iter(self, *filter_fns: Callable) -> Iterable[tuple[str, str]]:
         # The contract for filtered_iter is that it should return an iterable of iter_fields tuples.
         # If no filter_fns is provided, no filtering is done and iterate over all entries
         return (
@@ -582,7 +581,6 @@ def index_entry_dict(item_obj: DeviceTemplate):
 class FeatureTemplate(ConfigItem):
     api_path = ApiPath('template/feature/object', 'template/feature')
     store_path = ('feature_templates',)
-    store_file = '{item_name}.json'
     id_tag = 'templateId'
     name_tag = 'templateName'
     type_tag = 'templateType'
@@ -592,7 +590,7 @@ class FeatureTemplate(ConfigItem):
                         'devicesAttached', 'attachedMastersCount', 'gTemplateClass'}
 
     @property
-    def device_types(self) -> Set[str]:
+    def device_types(self) -> set[str]:
         return set(self.data.get('deviceType', []))
 
     @device_types.setter
@@ -633,13 +631,13 @@ class ConfigGroupModel(ConfigRequestModel):
     name: str
     description: str
     solution: str
-    profiles: Optional[List[ConfigGroupProfileModel]] = None
+    profiles: Optional[list[ConfigGroupProfileModel]] = None
+    topology: Optional[dict[str, Any]] = None
 
 
 class ConfigGroup(Config2Item):
     api_path = ApiPath('v1/config-group')
     store_path = ('config_groups', 'group')
-    store_file = '{item_name}.json'
     id_tag = 'id'
     name_tag = 'name'
 
@@ -664,12 +662,12 @@ class NameValuePair(ConfigRequestModel):
 
 class DeviceValuesModel(ConfigRequestModel):
     device_id: str = Field(..., alias='device-id')
-    variables: List[NameValuePair]
+    variables: list[NameValuePair]
 
 
 class ConfigGroupValuesModel(ConfigRequestModel):
     solution: str
-    devices: List[DeviceValuesModel]
+    devices: list[DeviceValuesModel]
 
     # In 20.8.1 get values contains 'family' key while put request requires 'solution' instead
     def __init__(self, **kwargs):
@@ -683,7 +681,6 @@ class ConfigGroupValues(Config2Item):
     api_path = ApiPath('v1/config-group/{configGroupId}/device/variables', None,
                        'v1/config-group/{configGroupId}/device/variables', None)
     store_path = ('config_groups', 'values')
-    store_file = '{item_name}.json'
 
     post_model = ConfigGroupValuesModel
 
@@ -695,7 +692,7 @@ def uuids(self) -> Iterable[str]:
     def is_empty(self) -> bool:
         return self.data is None or len(self.data.get('devices', [])) == 0
 
-    def filter(self, allowed_uuid_set: Set[str]) -> 'ConfigGroupValues':
+    def filter(self, allowed_uuid_set: set[str]) -> 'ConfigGroupValues':
         """
         Return a new instance of ConfigGroupValues containing only device entries with an id that is present in
         allowed_uuid_set.
@@ -720,13 +717,12 @@ class AssociatedDeviceModel(ConfigRequestModel):
 
 
 class ConfigGroupAssociatedModel(ConfigRequestModel):
-    devices: List[AssociatedDeviceModel]
+    devices: list[AssociatedDeviceModel]
 
 
 class ConfigGroupAssociated(Config2Item):
     api_path = ApiPath('v1/config-group/{configGroupId}/device/associate')
     store_path = ('config_groups', 'associated')
-    store_file = '{item_name}.json'
 
     post_model = ConfigGroupAssociatedModel
 
@@ -740,7 +736,7 @@ def uuids(self) -> Iterable[str]:
     def is_empty(self) -> bool:
         return self.data is None or len(self.data.get('devices', [])) == 0
 
-    def filter(self, allowed_uuid_set: Optional[Set[str]] = None, not_by_rule: bool = False) -> 'ConfigGroupAssociated':
+    def filter(self, allowed_uuid_set: Optional[set[str]] = None, not_by_rule: bool = False) -> 'ConfigGroupAssociated':
         """
         Return a new instance of ConfigGroupAssociated containing only device entries with an id that is present in
         allowed_uuid_set.
@@ -761,8 +757,6 @@ def filter(self, allowed_uuid_set: Optional[Set[str]] = None, not_by_rule: bool
     def put_raise(self, api: Rest, **path_vars: str) -> None:
         api.put(self.put_data(), ConfigGroupAssociated.api_path.resolve(**path_vars).put)
 
-        return
-
     @staticmethod
     def delete_raise(api: Rest, uuids: Iterable[str], **path_vars: str) -> ActionWorker:
         payload = {
@@ -776,7 +770,9 @@ def delete_raise(api: Rest, uuids: Iterable[str], **path_vars: str) -> ActionWor
 
 
 class ConfigGroupRules(IndexConfigItem):
-    api_path = ApiPath('tag/tagRules/{configGroupId}', 'tag/tagRules')
+    # api_path = ApiPath('tag/tagRules/{configGroupId}', 'tag/tagRules')
+    # TODO: Review post 20.13
+    api_path = ApiPath('v1/config-group/{configGroupId}/rules')
     store_path = ('config_groups', 'tag_rules')
     store_file = '{item_name}.json'
     id_tag = 'tagId'
@@ -788,7 +784,7 @@ def delete_raise(api: Rest, config_group_id: str, rule_id: str) -> None:
         api.delete(ConfigGroupRules.api_path.resolve(configGroupId=config_group_id).delete, rule_id,
                    configGroupId=config_group_id)
 
-    def post_raise(self, api: Rest, config_group_id: str) -> List[str]:
+    def post_raise(self, api: Rest, config_group_id: str) -> list[str]:
         filtered_keys = {
             self.id_tag,
         }
@@ -809,7 +805,7 @@ class ConfigGroupDeploy(ApiItem):
     id_tag = 'parentTaskId'
 
     @staticmethod
-    def api_params(uuids: Iterable[str]) -> Dict[str, Any]:
+    def api_params(uuids: Iterable[str]) -> dict[str, Any]:
         """
         Build dictionary used to provide input parameters for api POST call
         @param uuids: An iterable of device UUIDs to deploy
@@ -826,22 +822,40 @@ def api_params(uuids: Iterable[str]) -> Dict[str, Any]:
 # Feature Profiles
 #
 
+class ProfileSdwanPolicy(FeatureProfile):
+    api_path = ApiPath('v1/feature-profile/sdwan/policy-object')
+    store_path = ('feature_profiles', 'sdwan', 'policy_object')
+
+    parcel_names = (
+        "app-list", "app-probe", "as-path", "class", "color", "data-prefix", "data-ipv6-prefix", "ext-community",
+        "standard-community", "expanded-community", "ipv6-prefix", "mirror", "policer", "preferred-color-group",
+        "prefix", "security-zone", "security-scalablegrouptag", "security-identity", "security-geolocation",
+        "security-protocolname", "security-urllist", "security-ipssignature", "security-localdomain",
+        "security-localapp", "security-port", "security-fqdn", "security-data-ip-prefix", "sla-class", "tloc",
+        "vpn-group", "unified/advanced-inspection-profile", "unified/intrusion-prevention", "unified/url-filtering",
+        "unified/advanced-malware-protection", "unified/ssl-decryption-profile", "unified/ssl-decryption"
+    )
+    parcel_api_paths = ApiPathGroup({
+        name: ApiPath(f"v1/feature-profile/sdwan/policy-object/{{policyId}}/{name}") for name in parcel_names
+    })
+
+
+@register('policy_object', 'SDWAN policy object', ProfileSdwanPolicy, min_version='20.10')
+class ProfileSdwanPolicyIndex(FeatureProfileIndex):
+    api_path = ApiPath('v1/feature-profile/sdwan/policy-object', None, None, None)
+    store_file = 'feature_profiles_sdwan_policy_object.json'
+
+
 class ProfileSdwanSystem(FeatureProfile):
     api_path = ApiPath('v1/feature-profile/sdwan/system')
     store_path = ('feature_profiles', 'sdwan', 'system')
+
+    parcel_names = (
+        "aaa", "global", "banner", "basic", "bfd", "logging", "ntp", "omp", "snmp", "perfmonitor", "mrf", "security",
+        "remote-access", "ipv4-device-access-policy", "ipv6-device-access-policy"
+    )
     parcel_api_paths = ApiPathGroup({
-        "aaa": ApiPath("v1/feature-profile/sdwan/system/{systemId}/aaa"),
-        "global": ApiPath("v1/feature-profile/sdwan/system/{systemId}/global"),
-        "banner": ApiPath("v1/feature-profile/sdwan/system/{systemId}/banner"),
-        "basic": ApiPath("v1/feature-profile/sdwan/system/{systemId}/basic"),
-        "bfd": ApiPath("v1/feature-profile/sdwan/system/{systemId}/bfd"),
-        "logging": ApiPath("v1/feature-profile/sdwan/system/{systemId}/logging"),
-        "ntp": ApiPath("v1/feature-profile/sdwan/system/{systemId}/ntp"),
-        "omp": ApiPath("v1/feature-profile/sdwan/system/{systemId}/omp"),
-        "snmp": ApiPath("v1/feature-profile/sdwan/system/{systemId}/snmp"),
-        "perfmonitor": ApiPath("v1/feature-profile/sdwan/system/{systemId}/perfmonitor"),
-        "mrf": ApiPath("v1/feature-profile/sdwan/system/{systemId}/mrf"),
-        "security": ApiPath("v1/feature-profile/sdwan/system/{systemId}/security")
+        name: ApiPath(f"v1/feature-profile/sdwan/system/{{systemId}}/{name}") for name in parcel_names
     })
 
 
@@ -858,6 +872,9 @@ class ProfileSdwanService(FeatureProfile):
         "dhcp-server": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/dhcp-server"),
         "routing/bgp": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/bgp"),
         "routing/ospf": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/ospf"),
+        "routing/eigrp": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/eigrp"),
+        "routing/ospfv3/ipv4": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/ospfv3/ipv4"),
+        "routing/ospfv3/ipv6": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/ospfv3/ipv6"),
         "routing/multicast": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/routing/multicast"),
         "tracker": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/tracker"),
         "trackergroup": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/trackergroup"),
@@ -867,9 +884,11 @@ class ProfileSdwanService(FeatureProfile):
         "lan/vpn/interface/svi": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/svi"),
         "lan/vpn/interface/ipsec": ApiPath(
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/ipsec"),
+        "lan/vpn/interface/gre": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/gre"),
         "switchport": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/switchport"),
         "wirelesslan": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/wirelesslan"),
-        "appqoe": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/appqoe")
+        "appqoe": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/appqoe"),
+        "route-policy": ApiPath("v1/feature-profile/sdwan/service/{serviceId}/route-policy")
     }, parcel_reference_path_map={
         PathKey("dhcp-server", "lan/vpn/interface/ethernet"): ApiPath(
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/ethernet/{ethId}/dhcp-server"),
@@ -881,6 +900,12 @@ class ProfileSdwanService(FeatureProfile):
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/bgp"),
         PathKey("routing/ospf", "lan/vpn"): ApiPath(
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/ospf"),
+        PathKey("routing/eigrp", "lan/vpn"): ApiPath(
+            "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/eigrp"),
+        PathKey("routing/ospfv3/ipv4", "lan/vpn"): ApiPath(
+            "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/ospfv3/ipv4"),
+        PathKey("routing/ospfv3/ipv6", "lan/vpn"): ApiPath(
+            "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/ospfv3/ipv6"),
         PathKey("routing/multicast", "lan/vpn"): ApiPath(
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/routing/multicast"),
         PathKey("tracker", "trackergroup"): ...,
@@ -889,8 +914,14 @@ class ProfileSdwanService(FeatureProfile):
             "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/ethernet/{ethId}/tracker"),
         PathKey("trackergroup", "lan/vpn"): ...,
         PathKey("trackergroup", "lan/vpn/interface/ethernet"): ApiPath(
-            "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/ethernet/{ethId}/trackergroup")
-    })
+            "v1/feature-profile/sdwan/service/{serviceId}/lan/vpn/{vpnId}/interface/ethernet/{ethId}/trackergroup"),
+        PathKey("route-policy", "routing/bgp"): ...,
+        PathKey("route-policy", "routing/ospf"): ...,
+        PathKey("route-policy", "routing/eigrp"): ...,
+        PathKey("route-policy", "routing/ospfv3/ipv4"): ...,
+        PathKey("route-policy", "routing/ospfv3/ipv6"): ...,
+        PathKey("route-policy", "lan/vpn"): ...,
+    } | {PathKey(policy_obj_parcel, "route-policy"): ... for policy_obj_parcel in ProfileSdwanPolicy.parcel_names})
 
 
 @register('feature_profile', 'SDWAN service profile', ProfileSdwanService, min_version='20.8')
@@ -905,11 +936,14 @@ class ProfileSdwanTransport(FeatureProfile):
     parcel_api_paths = ApiPathGroup({
         "routing/bgp": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/routing/bgp"),
         "routing/ospf": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/routing/ospf"),
+        "routing/ospfv3/ipv4": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/routing/ospfv3/ipv4"),
+        "routing/ospfv3/ipv6": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/routing/ospfv3/ipv6"),
         "tracker": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/tracker"),
         "trackergroup": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/trackergroup"),
         "ipv6-tracker": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/ipv6-tracker"),
         "ipv6-trackergroup": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/ipv6-trackergroup"),
         "cellular-profile": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/cellular-profile"),
+        "esimcellular-profile": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/esimcellular-profile"),
         "wan/vpn": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/wan/vpn"),
         "wan/vpn/interface/ethernet": ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/interface/ethernet"),
@@ -919,16 +953,25 @@ class ProfileSdwanTransport(FeatureProfile):
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/interface/gre"),
         "wan/vpn/interface/cellular": ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/interface/cellular"),
+        "wan/vpn/interface/serial": ApiPath(
+            "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/interface/serial"),
         "management/vpn": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/management/vpn"),
         "management/vpn/interface/ethernet": ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/management/vpn/{vpnId}/interface/ethernet"),
         "cellular-controller": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/cellular-controller"),
-        "gps": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/gps")
+        "esimcellular-controller": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/esimcellular-controller"),
+        "t1-e1-controller": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/t1-e1-controller"),
+        "gps": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/gps"),
+        "route-policy": ApiPath("v1/feature-profile/sdwan/transport/{transportId}/route-policy")
     }, parcel_reference_path_map={
         PathKey("routing/bgp", "wan/vpn"): ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/routing/bgp"),
         PathKey("routing/ospf", "wan/vpn"): ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/routing/ospf"),
+        PathKey("routing/ospfv3/ipv4", "wan/vpn"): ApiPath(
+            "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/routing/ospfv3/ipv4"),
+        PathKey("routing/ospfv3/ipv6", "wan/vpn"): ApiPath(
+            "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/routing/ospfv3/ipv6"),
         PathKey("tracker", "trackergroup"): ...,
         PathKey("tracker", "wan/vpn/interface/ethernet"): ApiPath(
             "v1/feature-profile/sdwan/transport/{transportId}/wan/vpn/{vpnId}/interface/ethernet/{ethernetId}/tracker"),
@@ -958,8 +1001,13 @@ class ProfileSdwanTransport(FeatureProfile):
             "v1/feature-profile/sdwan/transport/{transportId}/cellular-controller/"
             "{cellularControllerId}/cellular-profile"),
         PathKey("gps", "cellular-controller"): ApiPath(
-            "v1/feature-profile/sdwan/transport/{transportId}/cellular-controller/{cellularControllerId}/gps")
-    })
+            "v1/feature-profile/sdwan/transport/{transportId}/cellular-controller/{cellularControllerId}/gps"),
+        PathKey("route-policy", "routing/bgp"): ...,
+        PathKey("route-policy", "routing/ospf"): ...,
+        PathKey("route-policy", "routing/ospfv3/ipv4"): ...,
+        PathKey("route-policy", "routing/ospfv3/ipv6"): ...,
+        PathKey("route-policy", "wan/vpn"): ...,
+    } | {PathKey(policy_obj_parcel, "route-policy"): ... for policy_obj_parcel in ProfileSdwanPolicy.parcel_names})
 
 
 @register('feature_profile', 'SDWAN transport profile', ProfileSdwanTransport, min_version='20.8')
@@ -987,7 +1035,8 @@ class ProfileSdwanOther(FeatureProfile):
     store_path = ('feature_profiles', 'sdwan', 'other')
     parcel_api_paths = ApiPathGroup({
         "thousandeyes": ApiPath("v1/feature-profile/sdwan/other/{otherId}/thousandeyes"),
-        "ucse": ApiPath("v1/feature-profile/sdwan/other/{otherId}/ucse")
+        "ucse": ApiPath("v1/feature-profile/sdwan/other/{otherId}/ucse"),
+        "cybervision": ApiPath("v1/feature-profile/sdwan/other/{otherId}/cybervision")
     })
 
 
@@ -997,60 +1046,137 @@ class ProfileSdwanOtherIndex(FeatureProfileIndex):
     store_file = 'feature_profiles_sdwan_other.json'
 
 
-# Policy-object profiles show up in 20.10, but there is no documentation in the apidocs
-class ProfileSdwanPolicy(FeatureProfile):
-    api_path = ApiPath('v1/feature-profile/sdwan/policy-object')
-    store_path = ('feature_profiles', 'sdwan', 'policy_object')
+class ProfileSdwanDnsSecurity(FeatureProfile):
+    api_path = ApiPath('v1/feature-profile/sdwan/dns-security')
+    store_path = ('feature_profiles', 'sdwan', 'dns_security')
     parcel_api_paths = ApiPathGroup({
-        "as-path": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/as-path"),
-        "class": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/class"),
-        "standard-community": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/standard-community"),
-        "expanded-community": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/expanded-community"),
-        "data-prefix": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/data-prefix"),
-        "data-ipv6-prefix": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/data-ipv6-prefix"),
-        "ipv6-prefix": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/ipv6-prefix"),
-        "prefix": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/prefix"),
-        "ext-community": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/ext-community"),
-        "mirror": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/mirror"),
-        "policer": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/policer"),
-        "vpn-group": ApiPath("v1/feature-profile/sdwan/policy-object/{policyId}/vpn-group")
+        "dns": ApiPath("v1/feature-profile/sdwan/dns-security/{dnsSecurityId}/dns")
     })
 
 
-@register('feature_profile', 'SDWAN policy object', ProfileSdwanPolicy, min_version='20.10')
-class ProfileSdwanPolicyIndex(FeatureProfileIndex):
-    api_path = ApiPath('v1/feature-profile/sdwan/policy-object', None, None, None)
-    store_file = 'feature_profiles_sdwan_policy_object.json'
+@register('feature_profile', 'SDWAN dns-security profile', ProfileSdwanDnsSecurity, min_version='20.12')
+class ProfileSdwanDnsSecurityIndex(FeatureProfileIndex):
+    api_path = ApiPath('v1/feature-profile/sdwan/dns-security', None, None, None)
+    store_file = 'feature_profiles_sdwan_dns_security.json'
+
+
+class ProfileSdwanSigSecurity(FeatureProfile):
+    api_path = ApiPath('v1/feature-profile/sdwan/sig-security')
+    store_path = ('feature_profiles', 'sdwan', 'sig_security')
+    parcel_api_paths = ApiPathGroup({
+        "sig": ApiPath("v1/feature-profile/sdwan/sig-security/{sigSecurityId}/sig")
+    })
+
+
+@register('feature_profile', 'SDWAN sig-security profile', ProfileSdwanSigSecurity, min_version='20.12')
+class ProfileSdwanSigSecurityIndex(FeatureProfileIndex):
+    api_path = ApiPath('v1/feature-profile/sdwan/sig-security', None, None, None)
+    store_file = 'feature_profiles_sdwan_sig_security.json'
+
+
+class ProfileSdwanApplicationPriority(FeatureProfile):
+    api_path = ApiPath('v1/feature-profile/sdwan/application-priority')
+    store_path = ('feature_profiles', 'sdwan', 'application_priority')
+    parcel_api_paths = ApiPathGroup({
+        "qos-policy": ApiPath("v1/feature-profile/sdwan/application-priority/{appPriorityId}/qos-policy"),
+        "traffic-policy": ApiPath("v1/feature-profile/sdwan/application-priority/{appPriorityId}/traffic-policy"),
+        "policy-settings": ApiPath("v1/feature-profile/sdwan/application-priority/{appPriorityId}/policy-settings")
+    }, parcel_reference_path_map={
+        PathKey(policy_object_parcel, parcel): ...
+        for parcel in ('qos-policy', 'traffic-policy') for policy_object_parcel in ProfileSdwanPolicy.parcel_names
+    })
+
+
+@register('feature_profile', 'SDWAN application-priority profile', ProfileSdwanApplicationPriority, min_version='20.12')
+class ProfileSdwanApplicationPriorityIndex(FeatureProfileIndex):
+    api_path = ApiPath('v1/feature-profile/sdwan/application-priority', None, None, None)
+    store_file = 'feature_profiles_sdwan_application_priority.json'
+
+
+class ProfileSdwanEmbeddedSecurity(FeatureProfile):
+    api_path = ApiPath('v1/feature-profile/sdwan/embedded-security')
+    store_path = ('feature_profiles', 'sdwan', 'embedded_security')
+    parcel_api_paths = ApiPathGroup({
+        "policy": ApiPath("v1/feature-profile/sdwan/embedded-security/{securityId}/policy"),
+        "unified/ngfirewall": ApiPath("v1/feature-profile/sdwan/embedded-security/{securityId}/unified/ngfirewall"),
+    }, parcel_reference_path_map={
+        PathKey("ngfirewall", "policy"): ...,
+        # TODO: Validate referenced items
+        PathKey("unified/advanced-inspection-profile", "unified/ngfirewall"): ...,
+        PathKey("unified/url-filtering", "unified/advanced-inspection-profile"): ...,
+        PathKey("unified/intrusion-prevention", "unified/ngfirewall"): ...,
+        PathKey("security-zone", "policy"): ...,
+        PathKey("unified/ngfirewall", "policy"): ...,
+        PathKey("unified/intrusion-prevention", "unified/advanced-inspection-profile"): ...,
+    })
+
+
+@register('feature_profile', 'SDWAN embedded-security profile', ProfileSdwanEmbeddedSecurity, min_version='20.12')
+class ProfileSdwanEmbeddedSecurityIndex(FeatureProfileIndex):
+    api_path = ApiPath('v1/feature-profile/sdwan/embedded-security', None, None, None)
+    store_file = 'feature_profiles_sdwan_embedded_security.json'
 
 
-# Those profiles show in 20.10 GUI but don't have any entry in apidocs. DE indicated to hold off on those as they'll
-# have major changes. Plan to include in 20.12
-# class ProfileSdwanSecurity(FeatureProfile):
-#     api_path = ApiPath('v1/feature-profile/sdwan/security')
-#     store_path = ('feature_profiles', 'sdwan', 'security')
-#     parcel_api_paths = ApiPathGroup({
-#         "sig": ApiPath("v1/feature-profile/sdwan/security/{securityId}/sig")
-#     })
-#
-#
-# @register('feature_profile', 'SDWAN security profile', ProfileSdwanSecurity, min_version='20.10')
-# class ProfileSdwanSecurityIndex(FeatureProfileIndex):
-#     api_path = ApiPath('v1/feature-profile/sdwan/security', None, None, None)
-#     store_file = 'feature_profiles_sdwan_security.json'
 #
+# Topology Groups
 #
-# class ProfileSdwanPolicy(FeatureProfile):
-#     api_path = ApiPath('v1/feature-profile/sdwan/policy-object')
-#     store_path = ('feature_profiles', 'sdwan', 'policy')
-#     parcel_api_paths = ApiPathGroup({
-#         "sig": ApiPath("v1/feature-profile/sdwan/security/{securityId}/sig")
-#     })
+
+class TopologyGroupProfileModel(ConfigRequestModel):
+    id: str
+
+
+class TopologyGroupModel(ConfigRequestModel):
+    name: str
+    description: str
+    solution: str
+    profiles: Optional[list[TopologyGroupProfileModel]] = None
+
+
+class TopologyGroup(Config2Item):
+    api_path = ApiPath('v1/topology-group')
+    store_path = ('topology_groups', 'group')
+    id_tag = 'id'
+    name_tag = 'name'
+
+    post_model = TopologyGroupModel
+
+
+@register('topology_group', 'topology group', TopologyGroup, min_version='20.12')
+class TopologyGroupIndex(IndexConfigItem):
+    api_path = ApiPath('v1/topology-group', None, None, None)
+    store_file = 'topology_groups.json'
+    iter_fields = IdName('id', 'name')
+
+
 #
+# Policy Groups
 #
-# @register('feature_profile', 'SDWAN policy profile', ProfileSdwanPolicy, min_version='20.10')
-# class ProfileSdwanPolicyIndex(FeatureProfileIndex):
-#     api_path = ApiPath('v1/feature-profile/sdwan/policy-object', None, None, None)
-#     store_file = 'feature_profiles_sdwan_policy.json'
+
+class PolicyGroupProfileModel(ConfigRequestModel):
+    id: str
+
+
+class PolicyGroupModel(ConfigRequestModel):
+    name: str
+    description: str
+    solution: str
+    profiles: Optional[list[PolicyGroupProfileModel]] = None
+
+
+class PolicyGroup(Config2Item):
+    api_path = ApiPath('v1/policy-group')
+    store_path = ('policy_groups', 'group')
+    id_tag = 'id'
+    name_tag = 'name'
+
+    post_model = PolicyGroupModel
+
+
+@register('policy_group', 'policy group', PolicyGroup, min_version='20.12')
+class PolicyGroupIndex(IndexConfigItem):
+    api_path = ApiPath('v1/policy-group', None, None, None)
+    store_file = 'policy_groups.json'
+    iter_fields = IdName('id', 'name')
 
 
 #
@@ -1060,7 +1186,6 @@ class ProfileSdwanPolicyIndex(FeatureProfileIndex):
 class PolicyVsmart(ConfigItem):
     api_path = ApiPath('template/policy/vsmart/definition', 'template/policy/vsmart')
     store_path = ('policy_templates', 'vSmart')
-    store_file = '{item_name}.json'
     name_tag = 'policyName'
     type_tag = 'policyType'
     skip_cmp_tag_set = {'isPolicyActivated', }
@@ -1091,7 +1216,6 @@ def active_policy(self):
 class PolicyVedge(ConfigItem):
     api_path = ApiPath('template/policy/vedge/definition', 'template/policy/vedge')
     store_path = ('policy_templates', 'vEdge')
-    store_file = '{item_name}.json'
     name_tag = 'policyName'
     type_tag = 'policyType'
 
@@ -1110,7 +1234,6 @@ class PolicyVedgeIndex(IndexConfigItem):
 class PolicySecurity(ConfigItem):
     api_path = ApiPath('template/policy/security/definition', 'template/policy/security')
     store_path = ('policy_templates', 'Security')
-    store_file = '{item_name}.json'
     name_tag = 'policyName'
     type_tag = 'policyType'
     # policyUseCase, policyMode are new tags in 20.x+, adding to skip diff to not trigger updates when restore --update
@@ -1132,7 +1255,6 @@ class PolicySecurityIndex(IndexConfigItem):
 class PolicyVoice(ConfigItem):
     api_path = ApiPath('template/policy/voice/definition', 'template/policy/voice')
     store_path = ('policy_templates', 'Voice')
-    store_file = '{item_name}.json'
     name_tag = 'policyName'
     type_tag = 'policyType'
 
@@ -1151,7 +1273,6 @@ class PolicyVoiceIndex(IndexConfigItem):
 class PolicyCustomApp(ConfigItem):
     api_path = ApiPath('template/policy/customapp')
     store_path = ('policy_templates', 'CustomApp')
-    store_file = '{item_name}.json'
     name_tag = 'appName'
     id_tag = 'appId'
     skip_cmp_tag_set = {'lastUpdated', 'referenceCount', 'references', 'activatedId', 'isActivatedByVsmart', 'owner'}
@@ -1180,7 +1301,6 @@ class PolicyCustomAppIndex(IndexConfigItem):
 
 # Policy definition base class
 class PolicyDef(ConfigItem):
-    store_file = '{item_name}.json'
     id_tag = 'definitionId'
     name_tag = 'name'
     type_tag = 'type'
@@ -1544,7 +1664,6 @@ class SecurityGroupIndex(PolicyDefIndex):
 
 # Policy list base class
 class PolicyList(ConfigItem):
-    store_file = '{item_name}.json'
     id_tag = 'listId'
     name_tag = 'name'
     type_tag = 'type'
diff --git a/cisco_sdwan/base/rest_api.py b/cisco_sdwan/base/rest_api.py
index 45ff007..61cd2e3 100644
--- a/cisco_sdwan/base/rest_api.py
+++ b/cisco_sdwan/base/rest_api.py
@@ -11,7 +11,8 @@
 from urllib3 import disable_warnings
 from urllib3.exceptions import InsecureRequestWarning
 from time import time, sleep
-from typing import Optional, Dict, Sequence, Any, Union
+from typing import Optional, Any, Union
+from collections.abc import Mapping
 from random import uniform
 
 
@@ -39,8 +40,8 @@ def retry_fn(*args, **kwargs):
                 wait_secs = backoff_wait_secs(retry)
                 logging.getLogger(__name__).debug(f'{ex}: Retry {retry+1}/{MAX_RETRIES}, backoff {wait_secs:.3}s')
                 sleep(wait_secs)
-        else:
-            raise RestAPIException(f'Maximum retries exceeded ({MAX_RETRIES})')
+
+        raise RestAPIException(f'Maximum retries exceeded ({MAX_RETRIES})')
 
     return retry_fn
 
@@ -66,8 +67,6 @@ def __enter__(self):
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         if self.session is not None:
-            # In 19.3, logging out actually de-authorize all sessions a user may have from the same IP address. For
-            # instance browser windows open from the same laptop. This is fixed in 20.1.
             self.logout()
             self.session.close()
 
@@ -121,8 +120,13 @@ def login(self, username: str, password: str, tenant_name: Optional[str]) -> boo
         return True
 
     def logout(self) -> bool:
-        response = self.session.get(f'{self.base_url}/logout', params={'nocache': str(int(time()))})
-        return response.status_code == requests.codes.ok
+        if is_version_newer('20.11', self.server_version):
+            response = self.session.post(f'{self.base_url}/logout', verify=self.verify, allow_redirects=False)
+        else:
+            response = self.session.get(f'{self.base_url}/logout', params={'nocache': str(int(time()))},
+                                        verify=self.verify, allow_redirects=False)
+
+        return response.status_code == requests.codes.found
 
     @property
     def server_version(self) -> str:
@@ -137,7 +141,7 @@ def is_provider(self) -> bool:
         return self.server_facts.get('userMode', '') == 'provider'
 
     @backoff_retry
-    def get(self, *path_entries: str, **params: Union[str, int]) -> Dict[str, Any]:
+    def get(self, *path_entries: str, **params: Union[str, int]) -> dict[str, Any]:
         response = self.session.get(self._url(*path_entries),
                                     params=params if params else None,
                                     timeout=self.timeout, verify=self.verify)
@@ -145,7 +149,7 @@ def get(self, *path_entries: str, **params: Union[str, int]) -> Dict[str, Any]:
         return response.json()
 
     @backoff_retry
-    def post(self, input_data: Dict[str, Any], *path_entries: str) -> Union[Dict[str, Any], None]:
+    def post(self, input_data: Mapping[str, Any], *path_entries: str) -> Union[dict[str, Any], None]:
         # With large input_data, vManage fails the post request if payload is encoded in compact form. Thus encoding
         # with indent=1.
         response = self.session.post(self._url(*path_entries), data=json.dumps(input_data, indent=1),
@@ -156,7 +160,7 @@ def post(self, input_data: Dict[str, Any], *path_entries: str) -> Union[Dict[str
         return response.json() if response.text else None
 
     @backoff_retry
-    def put(self, input_data: Dict[str, Any], *path_entries: str) -> Union[Dict[str, Any], None]:
+    def put(self, input_data: Mapping[str, Any], *path_entries: str) -> Union[dict[str, Any], None]:
         # With large input_data, vManage fails the put request if payload is encoded in compact form. Thus encoding
         # with indent=1.
         response = self.session.put(self._url(*path_entries), data=json.dumps(input_data, indent=1),
@@ -167,8 +171,8 @@ def put(self, input_data: Dict[str, Any], *path_entries: str) -> Union[Dict[str,
         return response.json() if response.text else None
 
     @backoff_retry
-    def delete(self, *path_entries: str, input_data: Optional[Dict[str, Any]] = None,
-               **params: str) -> Union[Dict[str, Any], None]:
+    def delete(self, *path_entries: str, input_data: Optional[Mapping[str, Any]] = None,
+               **params: str) -> Union[dict[str, Any], None]:
         response = self.session.delete(self._url(*path_entries),
                                        data=json.dumps(input_data, indent=1) if input_data is not None else None,
                                        params=params if params else None,
@@ -212,14 +216,14 @@ def is_version_newer(version_1: str, version_2: str) -> bool:
     @param version_2: String containing second version
     @return: True if version_2 is newer than version_1.
     """
-    def parse(version_string: str) -> Sequence[int]:
+    def parse(version_string: str) -> list[int]:
         # Development versions may follow this format: '20.1.999-98' or '20.9.0.02-li'
         return [int(v) for v in f"{version_string}.0".split('.')[:2]]
 
     return parse(version_2) > parse(version_1)
 
 
-def response_id(response_payload: Dict[str, str]) -> str:
+def response_id(response_payload: Mapping[str, str]) -> str:
     """
     Extracts the first value in a response payload. Assumes that this first value contains the ID of the object
     just created by the post request.
diff --git a/cisco_sdwan/tasks/common.py b/cisco_sdwan/tasks/common.py
index fa1eda2..ce75f06 100644
--- a/cisco_sdwan/tasks/common.py
+++ b/cisco_sdwan/tasks/common.py
@@ -12,7 +12,8 @@
 from pathlib import Path
 from shutil import rmtree
 from collections import namedtuple
-from typing import List, Tuple, Iterator, Union, Optional, Any, Iterable, Type, TypeVar, Sequence, Mapping
+from typing import Union, Optional, Any, TypeVar
+from collections.abc import Sequence, Mapping, Iterator, Iterable
 from zipfile import ZipFile, ZIP_DEFLATED
 from pydantic import ValidationError
 from cisco_sdwan.base.rest_api import Rest, RestAPIException
@@ -21,8 +22,7 @@
                                              DeviceTemplateAttach, DeviceTemplateCLIAttach, DeviceModeCli,
                                              ActionStatus, PolicyVsmartStatus, PolicyVsmartStatusException,
                                              PolicyVsmartActivate, PolicyVsmartIndex, PolicyVsmartDeactivate,
-                                             Device, ConfigGroupDeploy, ConfigGroupAssociated, ConfigGroupValues,
-                                             ConfigGroupRules)
+                                             Device, ConfigGroupDeploy, ConfigGroupAssociated, ConfigGroupValues)
 
 T = TypeVar('T')
 
@@ -217,7 +217,7 @@ def filtered_tables(tables: Sequence[Table], *filter_fns: TableFilter) -> Sequen
 
 class DryRunReport:
     def __init__(self):
-        self._entries: List[str] = []
+        self._entries: list[str] = []
 
     def add(self, entry: str) -> None:
         self._entries.append(entry)
@@ -335,7 +335,7 @@ def load_index(index_cls, info):
         return ((tag, info, index, item_cls) for tag, info, index, item_cls in all_index_iter if index is not None)
 
     @staticmethod
-    def item_get(item_cls: Type[T], backend: Union[Rest, str],
+    def item_get(item_cls: type[T], backend: Union[Rest, str],
                  item_id: str, item_name: str, ext_name: bool) -> Union[T, None]:
         if isinstance(backend, Rest):
             return item_cls.get(backend, item_id)
@@ -343,11 +343,11 @@ def item_get(item_cls: Type[T], backend: Union[Rest, str],
             return item_cls.load(backend, ext_name, item_name, item_id)
 
     @staticmethod
-    def index_get(index_cls: Type[T], backend: Union[Rest, str]) -> Union[T, None]:
+    def index_get(index_cls: type[T], backend: Union[Rest, str]) -> Union[T, None]:
         return index_cls.get(backend) if isinstance(backend, Rest) else index_cls.load(backend)
 
     def template_attach_data(self, api: Rest, workdir: str, ext_name: bool, templates_iter: Iterable[tuple],
-                             target_uuid_set: Optional[set] = None) -> Tuple[list, bool]:
+                             target_uuid_set: Optional[set] = None) -> tuple[list, bool]:
         """
         Prepare data for template attach considering local backup as the source of truth (i.e. where input values are)
         @param api: Instance of Rest API
@@ -402,7 +402,7 @@ def is_template_cli(template_name: str, saved_id: str) -> bool:
         return template_input_list, target_uuid_set is None
 
     @staticmethod
-    def template_reattach_data(api: Rest, templates_iter: Iterable[tuple]) -> Tuple[list, bool]:
+    def template_reattach_data(api: Rest, templates_iter: Iterable[tuple]) -> tuple[list, bool]:
         """
         Prepare data for template reattach considering vManage as the source of truth (i.e. where input values are)
         @param api: Instance of Rest API
@@ -490,8 +490,8 @@ def feeder(attach_cls, attach_data_iter):
         return len(feature_based_reqs + cli_based_reqs)
 
     def cfg_group_deploy_data(self, api: Rest, workdir: str, ext_name: bool,
-                              cfg_group_iter: Iterable[Tuple[str, str, Union[str, None]]],
-                              devices_map: Mapping[str, str]) -> Sequence[Tuple[str, str, Sequence]]:
+                              cfg_group_iter: Iterable[tuple[str, str, Union[str, None]]],
+                              devices_map: Mapping[str, str]) -> Sequence[tuple[str, str, Sequence]]:
         """
         Prepare data for config-group deploy assuming local backup as source of truth. Associate devices and
         pushing values as needed in preparation for the deployment.
@@ -527,21 +527,24 @@ def associate_devices(config_grp_name: str, config_grp_saved_id: str, config_grp
             return True
 
         def restore_rules(config_grp_name: str, config_grp_saved_id: str, config_grp_target_id: str) -> bool:
-            saved_rules = ConfigGroupRules.load(workdir, ext_name, config_grp_name, config_grp_saved_id)
-            if saved_rules is None:
-                self.log_debug(f"Skip config-group {config_grp_name} restore rules, no ConfigGroupRules file")
-                return False
-
-            if self.is_dryrun:
-                self.log_info(f"Config-group {config_grp_name}, associate (via automated rules): "
-                              "device list is unknown during dry-run")
-                return True
-
-            matched_uuids = saved_rules.post_raise(api, config_grp_target_id)
-            self.log_info(f"Config-group {config_grp_name}, associate (via automated rules): "
-                          f"{', '.join(devices_map.get(uuid) or uuid for uuid in matched_uuids)}")
-
-            return len(matched_uuids) > 0
+            # saved_rules = ConfigGroupRules.load(workdir, ext_name, config_grp_name, config_grp_saved_id)
+            # if saved_rules is None:
+            #     self.log_debug(f"Skip config-group {config_grp_name} restore rules, no ConfigGroupRules file")
+            #     return False
+            #
+            # if self.is_dryrun:
+            #     self.log_info(f"Config-group {config_grp_name}, associate (via automated rules): "
+            #                   "device list is unknown during dry-run")
+            #     return True
+            #
+            # matched_uuids = saved_rules.post_raise(api, config_grp_target_id)
+            # self.log_info(f"Config-group {config_grp_name}, associate (via automated rules): "
+            #               f"{', '.join(devices_map.get(uuid) or uuid for uuid in matched_uuids)}")
+            #
+            # return len(matched_uuids) > 0
+
+            # TODO: Review post 20.13
+            return False
 
         def restore_values(config_grp_name: str, config_grp_saved_id: str, config_grp_target_id: str):
             saved_values = ConfigGroupValues.load(workdir, ext_name, config_grp_name, config_grp_saved_id)
@@ -591,7 +594,7 @@ def restore_values(config_grp_name: str, config_grp_saved_id: str, config_grp_ta
 
         return deploy_data
 
-    def cfg_group_deploy(self, api: Rest, deploy_data: Sequence[Tuple[str, str, Sequence]],
+    def cfg_group_deploy(self, api: Rest, deploy_data: Sequence[tuple[str, str, Sequence]],
                          devices_map: Mapping[str, str], *, chunk_size: int = 200, log_context: str,
                          raise_on_failure: bool = True) -> int:
         """
@@ -641,7 +644,7 @@ def grouper(request_list):
 
         return len(deploy_reqs)
 
-    def template_detach(self, api: Rest, template_iter: Iterable[Tuple[str, str]],
+    def template_detach(self, api: Rest, template_iter: Iterable[tuple[str, str]],
                         devices_map: Optional[Mapping[str, str]] = None, *,
                         chunk_size: int = 200, log_context: str, raise_on_failure: bool = True) -> int:
         """
@@ -699,7 +702,7 @@ def grouper(request_list):
 
         return len(detach_reqs)
 
-    def cfg_group_dissociate(self, api: Rest, cfg_group_iter: Iterable[Tuple[str, str]],
+    def cfg_group_dissociate(self, api: Rest, cfg_group_iter: Iterable[tuple[str, str]],
                              devices_map: Optional[Mapping[str, str]] = None, *,
                              chunk_size: int = 200, log_context: str, raise_on_failure: bool = True) -> int:
         """
@@ -755,7 +758,7 @@ def grouper(request_list):
 
         return len(dissociate_reqs)
 
-    def cfg_group_rules_delete(self, api: Rest, cfg_group_iter: Iterable[Tuple[str, str]]) -> int:
+    def cfg_group_rules_delete(self, api: Rest, cfg_group_iter: Iterable[tuple[str, str]]) -> int:
         """
         Delete config-group device association automated rules
         @param api: Instance of Rest API
@@ -763,23 +766,24 @@ def cfg_group_rules_delete(self, api: Rest, cfg_group_iter: Iterable[Tuple[str,
         @return: Number of automated rules delete requests processed
         """
         delete_req_count = 0
-        for config_grp_id, config_grp_name in cfg_group_iter:
-            rules = ConfigGroupRules.get(api, configGroupId=config_grp_id)
-            if rules is None:
-                self.log_warning(f'Failed to retrieve {config_grp_name} automated rules from vManage')
-                continue
-            for rule_id in rules:
-                delete_req_count += 1
-                self.log_info(f'Config-group {config_grp_name} delete automated rule: {rule_id}')
-
-                if self.is_dryrun:
-                    continue
-
-                try:
-                    ConfigGroupRules.delete_raise(api, config_grp_id, rule_id)
-                except RestAPIException as ex:
-                    self.log_error(f'Failed to delete config-group {config_grp_name} automated rule {rule_id}: {ex}')
-
+        # for config_grp_id, config_grp_name in cfg_group_iter:
+        #     rules = ConfigGroupRules.get(api, configGroupId=config_grp_id)
+        #     if rules is None:
+        #         self.log_warning(f'Failed to retrieve {config_grp_name} automated rules from vManage')
+        #         continue
+        #     for rule_id in rules:
+        #         delete_req_count += 1
+        #         self.log_info(f'Config-group {config_grp_name} delete automated rule: {rule_id}')
+        #
+        #         if self.is_dryrun:
+        #             continue
+        #
+        #         try:
+        #             ConfigGroupRules.delete_raise(api, config_grp_id, rule_id)
+        #         except RestAPIException as ex:
+        #             self.log_error(f'Failed to delete config-group {config_grp_name} automated rule {rule_id}: {ex}')
+
+        # TODO: Review post 20.13
         return delete_req_count
 
     def policy_activate(self, api: Rest, policy_id: Optional[str], policy_name: Optional[str], *,
@@ -840,7 +844,7 @@ def policy_deactivate(self, api: Rest, *, log_context: str, raise_on_failure: bo
 
         return len(deactivate_reqs)
 
-    def wait_actions(self, api: Rest, action_list: List[tuple], log_context: str, raise_on_failure: bool) -> bool:
+    def wait_actions(self, api: Rest, action_list: list[tuple], log_context: str, raise_on_failure: bool) -> bool:
         """
         Wait for actions in action_list to complete
         @param api: Instance of Rest API
@@ -929,7 +933,7 @@ def device_iter(api: Rest,
                 match_reachable: bool = False,
                 match_site_id: Optional[str] = None,
                 match_system_ip: Optional[str] = None,
-                default: Any = '-') -> Iterator[Tuple[str, str]]:
+                default: Any = '-') -> Iterator[tuple[str, str]]:
     """
     Return an iterator over device inventory, filtered by optional conditions.
     @param api: Instance of Rest API
@@ -998,8 +1002,6 @@ def archive_create(archive_filename: str, workdir: str) -> None:
         for member_path in source_dir.rglob("*"):
             archive_file.write(member_path, arcname=member_path.relative_to(source_dir))
 
-    return
-
 
 def archive_extract(archive_filename: str, workdir: str) -> None:
     """
@@ -1010,5 +1012,3 @@ def archive_extract(archive_filename: str, workdir: str) -> None:
     destination_dir = Path(DATA_DIR, workdir)
     with ZipFile(archive_filename, mode='r') as archive_file:
         archive_file.extractall(destination_dir)
-
-    return
diff --git a/cisco_sdwan/tasks/implementation/_attach_detach.py b/cisco_sdwan/tasks/implementation/_attach_detach.py
index 7478faa..511600a 100644
--- a/cisco_sdwan/tasks/implementation/_attach_detach.py
+++ b/cisco_sdwan/tasks/implementation/_attach_detach.py
@@ -1,6 +1,7 @@
 import argparse
 from functools import partial
-from typing import Union, Optional, Callable, Tuple, Set, Mapping, Iterable
+from typing import Union, Optional
+from collections.abc import Mapping, Callable, Iterable
 from pydantic import Field, field_validator
 from typing_extensions import Annotated
 from cisco_sdwan.__version__ import __doc__ as title
@@ -19,9 +20,9 @@
 DEFAULT_BATCH_SIZE = 200
 
 
-def build_device_maps(selected_devices_iter: Iterable[Tuple[str, str]],
-                      template_ops_uuid_set: Set[str],
-                      cfg_group_ops_uuid_set: Set[str]) -> Tuple[Mapping[str, str], Mapping[str, str]]:
+def build_device_maps(selected_devices_iter: Iterable[tuple[str, str]],
+                      template_ops_uuid_set: set[str],
+                      cfg_group_ops_uuid_set: set[str]) -> tuple[Mapping[str, str], Mapping[str, str]]:
     selected_devices = [(uuid, name) for uuid, name in selected_devices_iter]
     return (
         {uuid: name for uuid, name in selected_devices if uuid in template_ops_uuid_set},
@@ -76,7 +77,7 @@ def parser(task_args, target_address=None):
         return task_parser.parse_args(task_args)
 
     @staticmethod
-    def edge_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
+    def edge_sets(api: Rest) -> tuple[set[str], set[str]]:
         inventory = EdgeInventory.get_raise(api)
         attach_set = {
             entry.uuid for entry in inventory.filtered_iter(EdgeInventory.is_available)
@@ -87,7 +88,7 @@ def edge_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
         return attach_set, deploy_set
 
     @staticmethod
-    def vsmart_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
+    def vsmart_sets(api: Rest) -> tuple[set[str], set[str]]:
         inventory = ControlInventory.get_raise(api)
         attach_set = {
             entry.uuid for entry in inventory.filtered_iter(ControlInventory.is_available, ControlInventory.is_vsmart)
@@ -235,7 +236,7 @@ def parser(task_args, target_address=None):
         return task_parser.parse_args(task_args)
 
     @staticmethod
-    def edge_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
+    def edge_sets(api: Rest) -> tuple[set[str], set[str]]:
         inventory = EdgeInventory.get_raise(api)
         attached_set = {
             entry.uuid for entry in inventory.filtered_iter(EdgeInventory.is_attached)
@@ -246,7 +247,7 @@ def edge_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
         return attached_set, associated_set
 
     @staticmethod
-    def vsmart_sets(api: Rest) -> Tuple[Set[str], Set[str]]:
+    def vsmart_sets(api: Rest) -> tuple[set[str], set[str]]:
         inventory = ControlInventory.get_raise(api)
         attached_set = {
             entry.uuid for entry in inventory.filtered_iter(ControlInventory.is_attached, ControlInventory.is_vsmart)
diff --git a/cisco_sdwan/tasks/implementation/_backup.py b/cisco_sdwan/tasks/implementation/_backup.py
index d171125..e3c24fa 100644
--- a/cisco_sdwan/tasks/implementation/_backup.py
+++ b/cisco_sdwan/tasks/implementation/_backup.py
@@ -1,14 +1,14 @@
 import argparse
-from typing import Union, Optional, List
+from typing import Union, Optional
 from pydantic import model_validator, field_validator
 from uuid import uuid4
 from cisco_sdwan.__version__ import __doc__ as title
 from cisco_sdwan.base.rest_api import Rest, RestAPIException
 from cisco_sdwan.base.catalog import catalog_iter, CATALOG_TAG_ALL
-from cisco_sdwan.base.models_base import ServerInfo
+from cisco_sdwan.base.models_base import ServerInfo, ModelException
 from cisco_sdwan.base.models_vmanage import (DeviceConfig, DeviceConfigRFS, DeviceTemplate, DeviceTemplateAttached,
                                              DeviceTemplateValues, EdgeInventory, ControlInventory, EdgeCertificate,
-                                             ConfigGroup, ConfigGroupValues, ConfigGroupAssociated, ConfigGroupRules)
+                                             ConfigGroup, ConfigGroupValues, ConfigGroupAssociated)
 from cisco_sdwan.tasks.utils import TaskOptions, TagOptions, filename_type, regex_type, default_workdir
 from cisco_sdwan.tasks.common import regex_search, clean_dir, Task, archive_create
 from cisco_sdwan.tasks.models import TaskArgs, CatalogTag
@@ -91,12 +91,13 @@ def runner(self, parsed_args, api: Optional[Rest] = None) -> Union[None, list]:
                 if regex is None or regex_search(regex, item_name, inverse=parsed_args.regex is None)
             )
             for item_id, item_name in matched_item_iter:
-                item = item_cls.get(api, item_id)
-                if item is None:
-                    self.log_error(f'Failed backup {info} {item_name}')
+                try:
+                    item = item_cls.get_raise(api, item_id)
+                    if item.save(parsed_args.workdir, item_index.need_extended_name, item_name, item_id):
+                        self.log_info(f'Done {info} {item_name}')
+                except (RestAPIException, ModelException, ValueError) as ex:
+                    self.log_error(f'Failed backup {info} {item_name}: {ex}')
                     continue
-                if item.save(parsed_args.workdir, item_index.need_extended_name, item_name, item_id):
-                    self.log_info(f'Done {info} {item_name}')
 
                 # Special case for DeviceTemplate, handle DeviceTemplateAttached and DeviceTemplateValues
                 if isinstance(item, DeviceTemplate):
@@ -120,9 +121,10 @@ def runner(self, parsed_args, api: Optional[Rest] = None) -> Union[None, list]:
                         self.log_error(f'Failed backup {info} {item_name} values: {ex}')
 
                 # Special case for ConfigGroup, handle ConfigGroupAssociated, ConfigGroupValues, ConfigGroupRules
+                # TODO: Review post 20.13
                 if isinstance(item, ConfigGroup) and item.devices_associated:
                     for sub_item_info, sub_item_cls in (('associated devices', ConfigGroupAssociated),
-                                                        ('automated rules', ConfigGroupRules),
+                                                        # ('automated rules', ConfigGroupRules),
                                                         ('values', ConfigGroupValues)):
                         sub_item = sub_item_cls.get(api, configGroupId=item_id)
                         if sub_item is None:
@@ -170,7 +172,7 @@ class BackupArgs(TaskArgs):
     not_regex: Optional[str] = None
     no_rollover: bool = False
     save_running: bool = False
-    tags: List[CatalogTag]
+    tags: list[CatalogTag]
 
     # Validators
     _validate_filename = field_validator('workdir', 'archive')(validate_filename)
diff --git a/cisco_sdwan/tasks/implementation/_certificate.py b/cisco_sdwan/tasks/implementation/_certificate.py
index d8eef8e..2bdd741 100644
--- a/cisco_sdwan/tasks/implementation/_certificate.py
+++ b/cisco_sdwan/tasks/implementation/_certificate.py
@@ -1,5 +1,6 @@
 import argparse
-from typing import Union, Optional, Callable
+from typing import Union, Optional
+from collections.abc import Callable
 from pydantic import field_validator, model_validator
 from cisco_sdwan.__version__ import __doc__ as title
 from cisco_sdwan.base.rest_api import Rest, RestAPIException
diff --git a/cisco_sdwan/tasks/implementation/_encrypt.py b/cisco_sdwan/tasks/implementation/_encrypt.py
index dbe36b3..c4da249 100644
--- a/cisco_sdwan/tasks/implementation/_encrypt.py
+++ b/cisco_sdwan/tasks/implementation/_encrypt.py
@@ -1,6 +1,7 @@
 import argparse
 from getpass import getpass
-from typing import Union, Optional, List, Callable
+from typing import Union, Optional
+from collections.abc import Callable
 from pydantic import field_validator, ValidationError
 import yaml
 from cisco_sdwan.__version__ import __doc__ as title
@@ -138,12 +139,12 @@ def encrypt_text(self, input_value: str, api: Rest) -> Union[None, str]:
 class EncryptArgs(TaskArgs):
     # Only have TaskEncrypt.values subtask because interactive mode should not be called programmatically
     subtask_handler: const(Callable, TaskEncrypt.values)
-    values: List[str]
+    values: list[str]
 
     # Validators
     @field_validator('values')
     @classmethod
-    def validate_cmd(cls, values: List[str]) -> List[str]:
+    def validate_cmd(cls, values: list[str]) -> list[str]:
         # Zero length values indicate interactive mode, which is not allowed when encrypt is called programmatically
         if len(values) == 0:
             raise ValueError("Values must not be empty")
diff --git a/cisco_sdwan/tasks/implementation/_list.py b/cisco_sdwan/tasks/implementation/_list.py
index 0088bec..053840e 100644
--- a/cisco_sdwan/tasks/implementation/_list.py
+++ b/cisco_sdwan/tasks/implementation/_list.py
@@ -1,5 +1,6 @@
 import argparse
-from typing import Union, Optional, List, Callable
+from typing import Union, Optional
+from collections.abc import Callable
 from operator import itemgetter
 from pydantic import model_validator, field_validator
 from cisco_sdwan.__version__ import __doc__ as title
@@ -171,7 +172,7 @@ class ListArgs(TableTaskArgs):
 class ListConfigArgs(ListArgs):
     subtask_info: const(str, 'configuration')
     subtask_handler: const(Callable, TaskList.config_table)
-    tags: List[CatalogTag]
+    tags: list[CatalogTag]
 
 
 class ListCertificateArgs(ListArgs):
@@ -182,7 +183,7 @@ class ListCertificateArgs(ListArgs):
 class ListTransformArgs(ListArgs):
     subtask_info: const(str, 'transform')
     subtask_handler: const(Callable, TaskList.xform_table)
-    tags: List[CatalogTag]
+    tags: list[CatalogTag]
     regex: Optional[str] = None
     not_regex: Optional[str] = None
     name_regex: str
diff --git a/cisco_sdwan/tasks/implementation/_report.py b/cisco_sdwan/tasks/implementation/_report.py
index 4e445da..99b2125 100644
--- a/cisco_sdwan/tasks/implementation/_report.py
+++ b/cisco_sdwan/tasks/implementation/_report.py
@@ -4,7 +4,8 @@
 import yaml
 from datetime import date
 from difflib import unified_diff, HtmlDiff
-from typing import Union, Optional, Iterator, List, Dict, Any, NamedTuple, Type, Tuple, Callable, Set
+from typing import Union, Optional, Any, NamedTuple
+from collections.abc import Iterator, Callable
 from typing_extensions import Annotated
 from pydantic import field_validator, model_validator, BaseModel, ValidationError, Field, ConfigDict
 from cisco_sdwan.__version__ import __doc__ as title
@@ -25,7 +26,7 @@ class SectionModel(BaseModel):
 
     name: str
     task: str
-    args: Optional[Dict[str, Any]] = None
+    args: Optional[dict[str, Any]] = None
     inherit_globals: bool = True
     skip_diff: bool = False
 
@@ -37,21 +38,21 @@ def validate_task(cls, v: str) -> str:
         return v
 
     @property
-    def task_label(self) -> Tuple[str, ...]:
+    def task_label(self) -> tuple[str, ...]:
         return tuple(self.task.split())
 
 
 class ReportContentModel(BaseModel):
-    globals: Optional[Dict[str, Any]] = None
-    sections: List[SectionModel]
+    globals: Optional[dict[str, Any]] = None
+    sections: list[SectionModel]
 
     @property
-    def skip_section_set(self) -> Set[str]:
+    def skip_section_set(self) -> set[str]:
         return {section.name for section in self.sections if section.skip_diff}
 
 
 # Report specification used as default if user did not provide a custom one
-DEFAULT_SECTIONS_1: List[Dict[str, Any]] = [
+DEFAULT_SECTIONS_1: list[dict[str, Any]] = [
     {'name': f'List configuration {tag}', 'task': 'list configuration', 'args': {'tags': [tag]}}
     for tag in ordered_tags(CATALOG_TAG_ALL)
 ]
@@ -68,11 +69,11 @@ def skip_section_set(self) -> Set[str]:
 
 # Metadata about tasks that can be included in a report
 class TaskMeta(NamedTuple):
-    task_cls: Type[Task]
-    task_args_cls: Type[TaskArgs]
+    task_cls: type[Task]
+    task_args_cls: type[TaskArgs]
 
 
-section_catalog: Dict[Tuple[str, ...], TaskMeta] = {
+section_catalog: dict[tuple[str, ...], TaskMeta] = {
     ('show', 'devices'): TaskMeta(TaskShow, ShowDevicesArgs),
     ('show', 'realtime'): TaskMeta(TaskShow, ShowRealtimeArgs),
     ('show', 'state'): TaskMeta(TaskShow, ShowStateArgs),
@@ -304,7 +305,7 @@ def subtask_diff(self, parsed_args, api: Optional[Rest]) -> Union[None, list]:
 
     def section_iter(self, report_spec: ReportContentModel,
                      has_api_session: bool,
-                     workdir: Optional[str]) -> Iterator[Tuple[str, Type[Task], TaskArgs]]:
+                     workdir: Optional[str]) -> Iterator[tuple[str, type[Task], TaskArgs]]:
         """
         An iterator over the different sections of the report, including task and arguments for the task.
         @param report_spec: report specification to use for generating this report
@@ -360,7 +361,7 @@ def spaced_line(line: str) -> Iterator[str]:
         yield ""
         yield line
 
-    def subsection_lines(subsection_iter: Iterator[str]) -> List[str]:
+    def subsection_lines(subsection_iter: Iterator[str]) -> list[str]:
         lines = []
         for subsection in subsection_iter:
             lines.extend(subsection.splitlines())
diff --git a/cisco_sdwan/tasks/implementation/_restore.py b/cisco_sdwan/tasks/implementation/_restore.py
index b0c2fab..bc7d6f1 100644
--- a/cisco_sdwan/tasks/implementation/_restore.py
+++ b/cisco_sdwan/tasks/implementation/_restore.py
@@ -1,7 +1,9 @@
 import argparse
-from typing import Union, Optional, Sequence, Dict, Set
+from typing import Union, Optional
+from collections.abc import Sequence
 from pydantic import model_validator, field_validator
 from uuid import uuid4
+from contextlib import suppress
 from cisco_sdwan.__version__ import __doc__ as title
 from cisco_sdwan.base.rest_api import Rest, RestAPIException, is_version_newer, response_id
 from cisco_sdwan.base.catalog import catalog_iter, CATALOG_TAG_ALL, ordered_tags, is_index_supported
@@ -175,12 +177,13 @@ def is_vbond_configured(self, api: Rest) -> bool:
 
         return check_vbond.is_configured
 
-    def restore_config_items(self, api: Rest, restore_list: Sequence[tuple], id_mapping: Dict[str, str],
-                             dependency_set: Set[str], match_set: Set[str]) -> None:
+    def restore_config_items(self, api: Rest, restore_list: Sequence[tuple], id_mapping: dict[str, str],
+                             dependency_set: set[str], match_set: set[str]) -> None:
         # Items were added to restore_list following ordered_tags() order (i.e. higher level items before lower
         # level items). The reverse order needs to be followed on restore.
         for info, index, restore_item_list in reversed(restore_list):
             pushed_item_dict = {}
+            parcel_id_mapping = {}
             for item_id, item, target_id in restore_item_list:
                 op_info = 'Create' if target_id is None else 'Update'
                 reason = ' (dependency)' if item_id in dependency_set - match_set else ''
@@ -202,7 +205,7 @@ def restore_config_items(self, api: Rest, restore_list: Sequence[tuple], id_mapp
                         # Special case for FeatureProfiles, creating linked parcels
                         if isinstance(item, FeatureProfile):
                             parcel_coro = item.associated_parcels(response_id(response))
-                            try:
+                            with suppress(StopIteration):
                                 new_parcel_id = None
                                 while True:
                                     try:
@@ -216,9 +219,10 @@ def restore_config_items(self, api: Rest, restore_list: Sequence[tuple], id_mapp
                                         self.log_error(f'Failed: {op_info} {info} {item.name} parcel{reason}: {ex}')
                                     else:
                                         self.log_info(f'Done: {op_info} {info} {item.name} parcel {p_info}{reason}')
-                            except StopIteration:
-                                pass
 
+                            # Retrieve id mapping for parcels in this feature profile
+                            # noinspection PyUnreachableCode
+                            parcel_id_mapping.update(item.parcel_id_mapping())
                     else:
                         # Update existing item
                         if item.is_readonly:
@@ -272,6 +276,8 @@ def restore_config_items(self, api: Rest, restore_list: Sequence[tuple], id_mapp
             except RestAPIException as ex:
                 self.log_critical(f'Failed retrieving {info}: {ex}')
                 break
+            else:
+                id_mapping.update(parcel_id_mapping)
 
     def restore_deployments(self, api: Rest, workdir: str) -> None:
         saved_groups_index = ConfigGroupIndex.load(workdir)
diff --git a/cisco_sdwan/tasks/implementation/_show.py b/cisco_sdwan/tasks/implementation/_show.py
index 25fa8a3..cb18ccd 100644
--- a/cisco_sdwan/tasks/implementation/_show.py
+++ b/cisco_sdwan/tasks/implementation/_show.py
@@ -1,5 +1,6 @@
 import argparse
-from typing import Tuple, List, Any, NamedTuple, Type, Union, Optional, Sequence, Callable
+from typing import Any, NamedTuple, Union, Optional
+from collections.abc import Callable, Sequence
 from typing_extensions import Annotated
 from pathlib import Path
 from functools import partial
@@ -32,11 +33,11 @@ class DeviceInfo(NamedTuple):
     model: str
 
 
-def retrieve_rt_task(api_obj: Rest, rt_cls: Type[RealtimeItem], device: DeviceInfo) -> Tuple[DeviceInfo, Any]:
+def retrieve_rt_task(api_obj: Rest, rt_cls: type[RealtimeItem], device: DeviceInfo) -> tuple[DeviceInfo, Any]:
     return device, rt_cls.get(api_obj, device.system_ip)
 
 
-def table_fields(op_cls: Type[OperationalItem], detail: bool, simple: bool) -> tuple:
+def table_fields(op_cls: type[OperationalItem], detail: bool, simple: bool) -> tuple:
     if detail and op_cls.fields_ext is not None:
         return op_cls.fields_std + op_cls.fields_ext
 
@@ -163,7 +164,7 @@ def runner(self, parsed_args, api: Optional[Rest] = None) -> Union[None, list]:
 
         return result_tables if (parsed_args.save_csv is None and parsed_args.save_json is None) else None
 
-    def realtime(self, parsed_args, api: Rest) -> List[Table]:
+    def realtime(self, parsed_args, api: Rest) -> list[Table]:
         devices = self.selected_devices(parsed_args, api)
         pool_size = max(min(len(devices), THREAD_POOL_SIZE), 1)
 
@@ -199,7 +200,7 @@ def realtime(self, parsed_args, api: Rest) -> List[Table]:
 
         return result_tables
 
-    def bulk_state(self, parsed_args, api: Rest) -> List[Table]:
+    def bulk_state(self, parsed_args, api: Rest) -> list[Table]:
         devices = self.selected_devices(parsed_args, api)
 
         result_tables = []
@@ -223,7 +224,7 @@ def bulk_state(self, parsed_args, api: Rest) -> List[Table]:
 
         return result_tables
 
-    def bulk_stats(self, parsed_args, api: Rest) -> List[Table]:
+    def bulk_stats(self, parsed_args, api: Rest) -> list[Table]:
         devices = self.selected_devices(parsed_args, api)
         end_time = datetime.now(tz=timezone.utc) - timedelta(days=parsed_args.days, hours=parsed_args.hours)
         start_time = end_time - timedelta(minutes=self.STATS_QUERY_RANGE_MINS)
@@ -256,7 +257,7 @@ def bulk_stats(self, parsed_args, api: Rest) -> List[Table]:
 
         return result_tables
 
-    def devices(self, parsed_args, api: Rest) -> List[Table]:
+    def devices(self, parsed_args, api: Rest) -> list[Table]:
         devices = self.selected_devices(parsed_args, api)
 
         result_tables = []
@@ -267,7 +268,7 @@ def devices(self, parsed_args, api: Rest) -> List[Table]:
 
         return result_tables
 
-    def selected_devices(self, parsed_args, api: Rest) -> List[DeviceInfo]:
+    def selected_devices(self, parsed_args, api: Rest) -> list[DeviceInfo]:
         regex = parsed_args.regex or parsed_args.not_regex
         matched_items = [
             DeviceInfo(name, system_ip, site_id, state, d_type, model)
@@ -299,7 +300,7 @@ def build_table(self, name: str, headers: Sequence[str], devices: Sequence[Devic
 
         return table
 
-    def records(self, parsed_args, api: Rest) -> List[Table]:
+    def records(self, parsed_args, api: Rest) -> list[Table]:
         device_map = {system_ip: name for _, name, system_ip, *_ in Device.get_raise(api).extended_iter(default='-')}
 
         def device_names(devices_field):
@@ -360,35 +361,35 @@ class ShowDevicesArgs(ShowArgs):
 class ShowRealtimeArgs(ShowArgs):
     subtask_info: const(str, 'realtime')
     subtask_handler: const(Callable, TaskShow.realtime)
-    cmd: List[str]
+    cmd: list[str]
     detail: bool = False
     simple: bool = False
 
     # Validators
     @field_validator('cmd')
     @classmethod
-    def validate_cmd(cls, cmd_list: List[str]) -> List[str]:
+    def validate_cmd(cls, cmd_list: list[str]) -> list[str]:
         return validate_op_cmd(OpType.RT, cmd_list)
 
 
 class ShowStateArgs(ShowArgs):
     subtask_info: const(str, 'state')
     subtask_handler: const(Callable, TaskShow.bulk_state)
-    cmd: List[str]
+    cmd: list[str]
     detail: bool = False
     simple: bool = False
 
     # Validators
     @field_validator('cmd')
     @classmethod
-    def validate_cmd(cls, cmd_list: List[str]) -> List[str]:
+    def validate_cmd(cls, cmd_list: list[str]) -> list[str]:
         return validate_op_cmd(OpType.STATE, cmd_list)
 
 
 class ShowStatisticsArgs(ShowArgs):
     subtask_info: const(str, 'statistics')
     subtask_handler: const(Callable, TaskShow.bulk_stats)
-    cmd: List[str]
+    cmd: list[str]
     detail: bool = False
     simple: bool = False
     days: Annotated[int, Field(ge=0, lt=10000)] = 0
@@ -397,7 +398,7 @@ class ShowStatisticsArgs(ShowArgs):
     # Validators
     @field_validator('cmd')
     @classmethod
-    def validate_cmd(cls, cmd_list: List[str]) -> List[str]:
+    def validate_cmd(cls, cmd_list: list[str]) -> list[str]:
         return validate_op_cmd(OpType.STATS, cmd_list)
 
 
diff --git a/cisco_sdwan/tasks/implementation/_show_template.py b/cisco_sdwan/tasks/implementation/_show_template.py
index 95b6d72..09e84c1 100644
--- a/cisco_sdwan/tasks/implementation/_show_template.py
+++ b/cisco_sdwan/tasks/implementation/_show_template.py
@@ -1,7 +1,8 @@
 import argparse
 from pathlib import Path
 from collections import namedtuple
-from typing import List, Union, Optional, Callable
+from typing import Union, Optional
+from collections.abc import Callable
 from operator import itemgetter, attrgetter
 from pydantic import field_validator
 from cisco_sdwan.__version__ import __doc__ as title
@@ -85,7 +86,7 @@ def runner(self, parsed_args, api: Optional[Rest] = None) -> Union[None, list]:
 
         return result_tables if (parsed_args.save_csv is None and parsed_args.save_json is None) else None
 
-    def values_table(self, parsed_args, api: Optional[Rest]) -> List[Table]:
+    def values_table(self, parsed_args, api: Optional[Rest]) -> list[Table]:
         def template_values(ext_name: bool, template_name: str, template_id: str) -> Union[DeviceTemplateValues, None]:
             if api is None:
                 # Load from local backup
@@ -141,7 +142,7 @@ def template_values(ext_name: bool, template_name: str, template_id: str) -> Uni
 
         return result_tables
 
-    def references_table(self, parsed_args, api: Optional[Rest]) -> List[Table]:
+    def references_table(self, parsed_args, api: Optional[Rest]) -> list[Table]:
         FeatureInfo = namedtuple('FeatureInfo', ['name', 'type', 'attached', 'device_templates'])
 
         backend = api or parsed_args.workdir
diff --git a/cisco_sdwan/tasks/implementation/_transform.py b/cisco_sdwan/tasks/implementation/_transform.py
index 286a2e6..0d79885 100644
--- a/cisco_sdwan/tasks/implementation/_transform.py
+++ b/cisco_sdwan/tasks/implementation/_transform.py
@@ -2,7 +2,8 @@
 from uuid import uuid4
 from copy import deepcopy
 from contextlib import suppress
-from typing import Union, Optional, Tuple, List, Dict, Type, Callable, NamedTuple
+from typing import Union, Optional, NamedTuple
+from collections.abc import Callable
 from typing_extensions import Annotated
 from pydantic import model_validator, BaseModel, field_validator, ValidationError, Field, ValidationInfo, ConfigDict
 import yaml
@@ -46,7 +47,7 @@ class ValueMap(BaseModel):
 
 class CryptResourceUpdate(BaseModel):
     resource_name: str
-    replacements: List[ValueMap]
+    replacements: list[ValueMap]
 
 
 class TransformRecipe(BaseModel):
@@ -54,8 +55,8 @@ class TransformRecipe(BaseModel):
 
     tag: CatalogTag
     name_template: Optional[TransformRecipeNameTemplate] = None
-    name_map: Annotated[Optional[Dict[str, str]], Field(validate_default=True)] = None
-    crypt_updates:  Optional[List[CryptResourceUpdate]] = None
+    name_map: Annotated[Optional[dict[str, str]], Field(validate_default=True)] = None
+    crypt_updates:  Optional[list[CryptResourceUpdate]] = None
     replace_source: bool = True
 
     # Validators
@@ -119,9 +120,9 @@ def match(self, name: str, tag: str) -> ProcessorMatch:
 
         return ProcessorMatch(False)
 
-    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> Tuple[dict, List[str]]:
+    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> tuple[dict, list[str]]:
         new_payload = deepcopy(config_obj.data)
-        trace_log: List[str] = []
+        trace_log: list[str] = []
 
         if config_obj.name_tag:
             new_payload[config_obj.name_tag] = new_name
@@ -150,17 +151,17 @@ def replace_source(self):
 
 
 class AttachedProcessor(Processor):
-    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> Tuple[dict, List[str]]:
+    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> tuple[dict, list[str]]:
         new_payload = deepcopy(config_obj.data)
-        trace_log: List[str] = []
+        trace_log: list[str] = []
 
         return new_payload, trace_log
 
 
 class ValuesProcessor(Processor):
-    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> Tuple[dict, List[str]]:
+    def eval(self, config_obj: ConfigItem, new_name: str, new_id: str) -> tuple[dict, list[str]]:
         new_payload = deepcopy(config_obj.data)
-        trace_log: List[str] = []
+        trace_log: list[str] = []
 
         return new_payload, trace_log
 
@@ -306,7 +307,7 @@ def transform(self, parsed_args, backend: Union[Rest, str], server_version: Opti
                 self.log_info('Saved vManage server information')
 
         # Process items
-        id_mapping: Dict[str, str] = {}  # {<old_id>: <new_id>}
+        id_mapping: dict[str, str] = {}  # {<old_id>: <new_id>}
         try:
             for tag in ordered_tags(CATALOG_TAG_ALL, reverse=True):
                 self.log_info(f'Inspecting {tag} items')
@@ -462,7 +463,7 @@ def build_recipe(self, parsed_args, backend: Union[Rest, str], server_version: O
 
         return
 
-    def retrieve(self, item_cls: Type[ConfigItem], backend: Union[Rest, str],
+    def retrieve(self, item_cls: type[ConfigItem], backend: Union[Rest, str],
                  item_id: str, item_name: str, ext_name: bool) -> Union[ConfigItem, None]:
 
         item = self.item_get(item_cls, backend, item_id, item_name, ext_name)
diff --git a/cisco_sdwan/tasks/models.py b/cisco_sdwan/tasks/models.py
index 4eaf543..519f696 100644
--- a/cisco_sdwan/tasks/models.py
+++ b/cisco_sdwan/tasks/models.py
@@ -1,4 +1,4 @@
-from typing import Optional, List, Any, Dict
+from typing import Optional, Any
 from typing_extensions import Annotated
 from pydantic import BaseModel, field_validator, Field, AfterValidator, ConfigDict, ValidationInfo
 from cisco_sdwan.base.catalog import OpType, CATALOG_TAG_ALL, op_catalog_tags, op_catalog_commands, catalog_tags
@@ -7,7 +7,7 @@
 
 
 # Model field validators
-def validate_op_cmd(op_type: OpType, cmd_list: List[str]) -> List[str]:
+def validate_op_cmd(op_type: OpType, cmd_list: list[str]) -> list[str]:
     full_command = ' '.join(cmd_list)
     pass_options = [
         len(cmd_list) == 1 and CATALOG_TAG_ALL in cmd_list,
diff --git a/cisco_sdwan/tasks/utils.py b/cisco_sdwan/tasks/utils.py
index 1c1c53f..2b5a1a5 100644
--- a/cisco_sdwan/tasks/utils.py
+++ b/cisco_sdwan/tasks/utils.py
@@ -9,7 +9,7 @@
 import argparse
 from datetime import date
 from getpass import getpass
-from typing import Callable
+from typing import Callable, Optional
 from cisco_sdwan.base.catalog import catalog_tags, op_catalog_tags, op_catalog_commands, CATALOG_TAG_ALL, OpType
 from cisco_sdwan.tasks.common import Task
 from cisco_sdwan.tasks.validators import (validate_workdir, validate_regex, validate_existing_file, validate_zip_file,
@@ -91,7 +91,7 @@ def commands(cls, op_type: OpType) -> str:
 class OpCmdSemantics(argparse.Action):
     # Using an action as opposed to a type check so that it can evaluate the full command line passed as opposed to
     # individual tokens.
-    op_type: OpType = None
+    op_type: Optional[OpType] = None
 
     def __call__(self, parser, namespace, values, option_string=None):
         full_command = ' '.join(values)
diff --git a/cisco_sdwan/tasks/validators.py b/cisco_sdwan/tasks/validators.py
index 5b17362..6d0f2c1 100644
--- a/cisco_sdwan/tasks/validators.py
+++ b/cisco_sdwan/tasks/validators.py
@@ -30,7 +30,7 @@ def validate_filename(filename: str) -> str:
         raise ValueError(f'Directory for "{filename}" does not exist')
 
     # Also allow . on filename, on top of what's allowed by filename_safe
-    if re.sub(r'\.', '_', file_path.name) != filename_safe(file_path.name):
+    if file_path.name.replace('.', '_') != filename_safe(file_path.name):
         raise ValueError(
             f'Invalid name "{file_path.name}". Only alphanumeric characters, "-", "_", and "." are allowed.'
         )
diff --git a/installer/mac/darwin/Resources/conclusion.html b/installer/mac/darwin/Resources/conclusion.html
index 52f81f3..b9d128a 100644
--- a/installer/mac/darwin/Resources/conclusion.html
+++ b/installer/mac/darwin/Resources/conclusion.html
@@ -1,30 +1,38 @@
-
 <!DOCTYPE html>
 <html lang="en">
     <head>
+        <title>Conclusion</title>
         <meta charset="utf-8" />
+        <style>
+            .align-left {
+                text-align: left;
+            }
+            body {
+                font-family: Helvetica;
+                padding-left: 10px;
+            }
+        </style>
     </head>
     <body>
-        <div style="font-family: Helvetica; padding-left: 10px;" align="left">
+        <div class="align-left">
             <h3>__PRODUCT__</h3>
-                <p style="color: #020202; font-size: 11px;">Thank you for installing __PRODUCT__ version __VERSION__</p>
+            <p style="color: #020202; font-size: 11px;">Thank you for installing __PRODUCT__ version __VERSION__</p>
             <h5>Run __PRODUCT__</h5>
-                <p style="color: #020202; font-size: 11px;">Open a new terminal and run the following commands to get started and verify installed version is matching:</p>
-                <code style="color: #c6a72b; font-size: 11px;">&nbsp; % <span style="color: #abb0b0">cd ~/sastre-pro/</span></code><br> 
-                <code style="color: #c6a72b; font-size: 11px;">&nbsp; % <span style="color: #abb0b0">./sastre-pro.sh</span></code>
-                <p></p>
-                <p style="color: #020202; font-size: 11px;">By default logs and data files generated by __PRODUCT__ are stored at "~/sastre-pro/sastre-volume" path in your host machine.</p>
+            <p style="color: #020202; font-size: 11px;">Open a new terminal and run the following commands to get started and verify installed version is matching:</p>
+            <code style="color: #c6a72b; font-size: 11px;">&nbsp; % <span style="color: #abb0b0">cd ~/sastre-pro/</span></code><br> 
+            <code style="color: #c6a72b; font-size: 11px;">&nbsp; % <span style="color: #abb0b0">./sastre-pro.sh</span></code>
+            <p></p>
+            <p style="color: #020202; font-size: 11px;">By default logs and data files generated by __PRODUCT__ are stored at "~/sastre-pro/sastre-volume" path in your host machine.</p>
         </div>
-        <div style="font-family: Helvetica; padding-left: 10px;" align="left">
+        <div class="align-left">
             <br/>
             <h5>Uninstall __PRODUCT__</h5>
-                <p style="color: #020202; font-size: 11px;">Go to this folder ~/sastre-pro/ in Finder app and double click on "uninstall.app" application file to uninstall  __PRODUCT__<br />
-                </p>
-                <p style="color: #020202; font-size: 11px;">You can also uninstall __PRODUCT__ from terminal by running below command <br />
-                    <code style="color: #c6a72b; font-size: 11px;"><br />&nbsp; % <span style="color: #abb0b0">sudo bash ~/sastre-pro/uninstall.sh</span></code>
-                  </p>
+            <p style="color: #020202; font-size: 11px;">Go to this folder ~/sastre-pro/ in Finder app and double click on "uninstall.app" application file to uninstall  __PRODUCT__<br /></p>
+            <p style="color: #020202; font-size: 11px;">You can also uninstall __PRODUCT__ from terminal by running below command <br />
+                <code style="color: #c6a72b; font-size: 11px;"><br />&nbsp; % <span style="color: #abb0b0">sudo bash ~/sastre-pro/uninstall.sh</span></code>
+            </p>
         </div>
-        <div style="font-family: Helvetica; padding-left: 10px;" align="left"><br />
+        <div class="align-left"><br />
             <p style="color: #abb0b0; font-size: 10px;">Copyright © 2024 Cisco Inc. All rights reserved. __PRODUCT__ and its use subject to a license agreement and are also subject to copyright, trademark patent and/or other laws.</p>
         </div>
     </body>
diff --git a/installer/mac/darwin/Resources/welcome.html b/installer/mac/darwin/Resources/welcome.html
index 4ad407d..532441d 100644
--- a/installer/mac/darwin/Resources/welcome.html
+++ b/installer/mac/darwin/Resources/welcome.html
@@ -1,11 +1,21 @@
 <!DOCTYPE html>
 <html lang="en">
 <head>
+    <title>Welcome</title>
     <meta charset="utf-8" />
+    <style>
+        .align-left {
+            text-align: left;
+        }
+        .container {
+            font-family: Helvetica;
+            padding-left: 10px;
+        }
+    </style>
 </head>
 <body>
-    <div align="left" style="font-family: Helvetica; padding-left: 10px;">
-    	<br/>
+    <div class="align-left container">
+        <br/>
         <p style="color: #020202; font-size: 12px;">This will install <span style="color: #46b9d6; font-size: 12px;">__PRODUCT__  __VERSION__</span> on your computer.</p>
         <br/>
         <p style="color: #abb0b0; font-size: 12px;">Click <span style="color: #626666">“Continue"</span> to continue the setup</p>
diff --git a/requirements.txt b/requirements.txt
index 1035915..b8d5579 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
-pydantic>=2.6
+pydantic==2.7.4
 PyYAML==6.0.1
-requests==2.31.0
-urllib3==2.1.0
+requests==2.32.3
+urllib3==2.2.2
+


Test patch:
diff --git a/tests/unittests/task_tests.py b/tests/unittests/task_tests.py
deleted file mode 100644
index 6ecd19f..0000000
--- a/tests/unittests/task_tests.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import unittest
-from cisco_sdwan.base.rest_api import Rest
-
-
-VMANAGE_INFO = ("https:/198.18.1.10:443", "admin", "admin")
-
-
-class TestTasks(unittest.TestCase):
-    api: Rest = None
-
-    @classmethod
-    def setUpClass(cls) -> None:
-        cls.api = Rest(*VMANAGE_INFO, timeout=120)
-
-    def test_task_show_rt(self) -> None:
-        ...
-
-    @classmethod
-    def tearDownClass(cls) -> None:
-        if cls.api is not None:
-            cls.api.logout()
-            cls.api.session.close()
-
-
-if __name__ == '__main__':
-    unittest.main()