From 161a064e3ddd14a37a2e22f7253a3ee3a68a2262 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Thu, 24 Mar 2022 03:02:45 +0200
Subject: [PATCH 01/11] pydantic models added

---
 README.MD                                     |   6 +-
 requirements.txt                              |   3 +-
 .../sandbox_rest/{sandbox_api.py => api.py}   |   0
 src/cloudshell/sandbox_rest/components.py     |   0
 src/cloudshell/sandbox_rest/exceptions.py     |  16 ++
 src/cloudshell/sandbox_rest/model.py          | 272 ++++++++++++++++++
 src/cloudshell/sandbox_rest/polling.py        | 153 ++++++++++
 .../sandbox_rest/sandbox_context.py           |   2 +
 tests/common.py                               |   2 +-
 tests/conftest.py                             |   2 +-
 tests/test_api_dut_sandbox.py                 |   2 +-
 tests/test_api_empty_sandbox.py               |   2 +-
 tests/test_api_no_sandbox.py                  |   2 +-
 13 files changed, 453 insertions(+), 9 deletions(-)
 rename src/cloudshell/sandbox_rest/{sandbox_api.py => api.py} (100%)
 create mode 100644 src/cloudshell/sandbox_rest/components.py
 create mode 100644 src/cloudshell/sandbox_rest/model.py
 create mode 100644 src/cloudshell/sandbox_rest/polling.py
 create mode 100644 src/cloudshell/sandbox_rest/sandbox_context.py

diff --git a/README.MD b/README.MD
index ee42b77..4a2f7cc 100644
--- a/README.MD
+++ b/README.MD
@@ -20,7 +20,7 @@ pip install cloudshell-sandbox-rest
 ### Basic Usage
 
 ```python
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 # pull in api user credentials
 CS_SERVER = "localhost"
@@ -44,7 +44,7 @@ print(f"total components in sandbox: {len(components_response)}")
 Using the api session with a context manager "with" statement will log out and invalidate the token for you.
 
 ```python
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 CS_SERVER = "localhost"
 CS_USER = "admin"
@@ -75,7 +75,7 @@ Common use case is for admin to pull user token and start a session on their beh
 below.
 
 ```python
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 # admin credentials
 CS_SERVER = "localhost"
diff --git a/requirements.txt b/requirements.txt
index 471039c..da68080 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 requests>=2,<3
-abstract-http-client>=1,<2
\ No newline at end of file
+abstract-http-client>=1,<2
+pydantic
diff --git a/src/cloudshell/sandbox_rest/sandbox_api.py b/src/cloudshell/sandbox_rest/api.py
similarity index 100%
rename from src/cloudshell/sandbox_rest/sandbox_api.py
rename to src/cloudshell/sandbox_rest/api.py
diff --git a/src/cloudshell/sandbox_rest/components.py b/src/cloudshell/sandbox_rest/components.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/cloudshell/sandbox_rest/exceptions.py b/src/cloudshell/sandbox_rest/exceptions.py
index befbf51..80d9db1 100644
--- a/src/cloudshell/sandbox_rest/exceptions.py
+++ b/src/cloudshell/sandbox_rest/exceptions.py
@@ -4,3 +4,19 @@ class SandboxRestException(Exception):
 
 class SandboxRestAuthException(SandboxRestException):
     """ Failed login action """
+
+
+class OrchestrationPollingTimeout(SandboxRestException):
+    pass
+
+
+class CommandPollingTimeout(SandboxRestException):
+    pass
+
+
+class SetupFailedException(SandboxRestException):
+    pass
+
+
+class CommandExecutionFailed(SandboxRestException):
+    pass
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
new file mode 100644
index 0000000..bb25ea1
--- /dev/null
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -0,0 +1,272 @@
+"""
+decorator is added for intellisense auto completion purposes
+https://stackoverflow.com/a/71257588
+"""
+
+from enum import Enum
+from typing import TYPE_CHECKING, List, Optional
+
+from pydantic import BaseModel, Field
+
+if TYPE_CHECKING:
+    from dataclasses import dataclass as _basemodel_decorator
+else:
+
+    def _basemodel_decorator(x):
+        return x
+
+
+class SandboxApiBaseModel(BaseModel):
+    def pretty_json(self, indent=4):
+        return self.json(indent=indent)
+
+
+class BlueprintAvailabilityStates(str, Enum):
+    AVAILABLE = "Available Now"
+    NOT_AVAILABLE = "Not Available"
+
+
+class SandboxStates(str, Enum):
+    PENDING = "Pending"
+    PENDING_SETUP = "Pending Setup"
+    RUNNING_SETUP = "Setup"
+    READY = "Ready"
+    ERROR = "Error"
+    TEARDOWN = "Teardown"
+    ENDED = "Ended"
+
+    @classmethod
+    def get_post_setup_states(cls) -> List:
+        return [cls.READY, cls.ERROR, cls.TEARDOWN, cls.ENDED]
+
+    @classmethod
+    def get_active_states(cls) -> List:
+        return [cls.READY, cls.ERROR]
+
+    @classmethod
+    def get_post_active_states(cls) -> List:
+        return [cls.TEARDOWN, cls.ENDED]
+
+
+class SetupStages(str, Enum):
+    PROVISIONING = "Provisioning"
+    CONNECTIVITY = "Connectivity"
+    CONFIGURATION = "Configuration"
+
+
+class SandboxEventTypes(str, Enum):
+    SUCCESS = "success"
+    ERROR = "error"
+
+
+class CommandParameterTypes(str, Enum):
+    STRING = "String"
+    NUMERIC = "Numeric"
+    LOOKUP = "Lookup"
+
+
+class CommandExecutionStates(str, Enum):
+    PENDING = "Pending"
+    RUNNING = "Running"
+    STOPPING = "Stopping"
+    CANCELLED = "Cancelled"
+    COMPLETE = "Complete"
+    FAILED = "Failed"
+
+
+@_basemodel_decorator
+class BlueprintInput(SandboxApiBaseModel):
+    name: Optional[str]
+    type: Optional[str]
+    possible_values: Optional[List[str]]
+    default_value: Optional[str]
+
+
+@_basemodel_decorator
+class BlueprintDescription(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    categories: Optional[List[str]]
+    description: Optional[str]
+    params: Optional[List[BlueprintInput]]
+    availability: Optional[BlueprintAvailabilityStates] = Field(
+        description="The availability of blueprint: " "['Available Now', 'Not Available']"
+    )
+    estimated_setup_duration: Optional[str] = Field(
+        description="Estimated blueprint setup duration. " "Uses'ISO 8601' Standard. (e.g 'PT23H' or 'PT4H2M')"
+    )
+
+
+@_basemodel_decorator
+class SandboxComponentBasic(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    type: Optional[str]
+    component_type: Optional[str]
+    description: Optional[str]
+    address: Optional[str]
+    app_lifecycle: Optional[str]
+
+
+@_basemodel_decorator
+class SandboxDetailsBasic(SandboxApiBaseModel):
+    id: Optional[str]
+    blueprint_id: Optional[str]
+    type: Optional[str]
+    name: Optional[str]
+    permitted_users: Optional[List[str]]
+    description: Optional[str]
+    start_time: Optional[str]
+    end_time: Optional[str]
+    params: Optional[List[BlueprintInput]]
+    components: Optional[List[SandboxComponentBasic]]
+    state: Optional[SandboxStates]
+    setup_stage: Optional[SetupStages]
+
+
+@_basemodel_decorator
+class BlueprintReference(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+
+
+@_basemodel_decorator
+class SandboxDescription(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    blueprint: Optional[BlueprintReference]
+    description: Optional[str]
+    state: Optional[SandboxStates]
+
+
+@_basemodel_decorator
+class SandboxEvent(SandboxApiBaseModel):
+    id: Optional[int]
+    event_type: Optional[SandboxEventTypes]
+    event_text: Optional[str]
+    output: Optional[str]
+    time: Optional[str] = Field(description="Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z')")
+
+
+@_basemodel_decorator
+class SandboxEventsResponse(SandboxApiBaseModel):
+    num_returned_events: Optional[int]
+    more_pages: Optional[bool]
+    next_event_id: Optional[int]
+    events: Optional[List[SandboxEvent]]
+
+
+@_basemodel_decorator
+class CommandParameterDetails(SandboxApiBaseModel):
+    name: Optional[str]
+    description: Optional[str]
+    type: Optional[CommandParameterTypes]
+    possibleValues: Optional[List[str]]
+    defaultValue: Optional[str]
+    mandatory: Optional[bool]
+
+
+@_basemodel_decorator
+class CommandParameterNameValue(SandboxApiBaseModel):
+    name: Optional[str]
+    value: Optional[str]
+
+
+@_basemodel_decorator
+class CommandExecution(SandboxApiBaseModel):
+    id: Optional[str]
+    status: Optional[CommandExecutionStates]
+    supports_cancellation: Optional[bool]
+
+
+@_basemodel_decorator
+class Command(SandboxApiBaseModel):
+    name: Optional[str]
+    description: Optional[str]
+    params: Optional[List[CommandParameterDetails]]
+    executions: Optional[List[CommandExecution]]
+
+
+@_basemodel_decorator
+class CommandStartResponse(SandboxApiBaseModel):
+    executionId: Optional[str]
+    supports_cancellation: Optional[bool]
+
+
+@_basemodel_decorator
+class CommandContextDetails(SandboxApiBaseModel):
+    sandbox_id: Optional[str]
+    component_name: Optional[str]
+    component_id: Optional[str]
+    command_name: Optional[str]
+    command_params: Optional[List[CommandParameterNameValue]]
+
+
+@_basemodel_decorator
+class CommandExecutionDetails(SandboxApiBaseModel):
+    id: Optional[str]
+    status: Optional[CommandExecutionStates]
+    supports_cancellation: Optional[bool]
+    started: Optional[str]
+    ended: Optional[str]
+    output: Optional[str]
+    command_context: Optional[CommandContextDetails] = Field(
+        None, description="additional data to populate about command. " "NOTE: this does not come from api response"
+    )
+
+
+@_basemodel_decorator
+class ComponentAttribute(SandboxApiBaseModel):
+    type: Optional[str]
+    name: Optional[str]
+    value: Optional[str]
+
+
+@_basemodel_decorator
+class ConnectionInterface(SandboxApiBaseModel):
+    name: Optional[str]
+    url: Optional[str]
+
+
+@_basemodel_decorator
+class LinkMetadata(SandboxApiBaseModel):
+    href: Optional[str]
+    method: Optional[str]
+
+
+@_basemodel_decorator
+class SandboxComponentFull(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    type: Optional[str]
+    component_type: Optional[str]
+    description: Optional[str]
+    address: Optional[str]
+    app_lifecycle: Optional[str]
+    attributes: Optional[List[ComponentAttribute]]
+    connection_interfaces: Optional[List[ConnectionInterface]]
+    _links: Optional[List[LinkMetadata]]
+
+
+@_basemodel_decorator
+class ExtendResponse(SandboxApiBaseModel):
+    id: Optional[str]
+    name: Optional[str]
+    start_time: Optional[str]
+    end_time: Optional[str]
+    remaining_time: Optional[str]
+
+
+@_basemodel_decorator
+class SandboxOutputEntry(SandboxApiBaseModel):
+    id: Optional[int]
+    text: Optional[str]
+    time: Optional[str] = Field(description="Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z'")
+
+
+@_basemodel_decorator
+class SandboxOutput(SandboxApiBaseModel):
+    number_of_returned_entries: Optional[int]
+    next_entry_id: Optional[str]
+    more_pages: Optional[bool]
+    entries: Optional[List[SandboxOutputEntry]]
diff --git a/src/cloudshell/sandbox_rest/polling.py b/src/cloudshell/sandbox_rest/polling.py
new file mode 100644
index 0000000..1959e57
--- /dev/null
+++ b/src/cloudshell/sandbox_rest/polling.py
@@ -0,0 +1,153 @@
+"""
+Helper functions to poll the state of sandbox setup or the execution status of a command.
+Using 'tenacity' library to do polling:
+https://github.com/jd/tenacity
+"""
+import asyncio
+import logging
+from enum import Enum
+from time import sleep
+from typing import Callable, List
+
+from tenacity import RetryError, retry, retry_if_result, stop_after_delay, wait_fixed  # pip install tenacity
+
+import cloudshell.sandbox_rest.exceptions as exceptions
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
+
+
+class SandboxStates(Enum):
+    BEFORE_SETUP = "BeforeSetup"
+    RUNNING_SETUP = "Setup"
+    ERROR = "Error"
+    READY = "Ready"
+    TEARDOWN = "Teardown"
+    ENDED = "Ended"
+
+
+class ExecutionStatuses(Enum):
+    RUNNING = "Running"
+    PENDING = "Pending"
+    COMPLETED = "Completed"
+    FAILED = "Failed"
+
+
+SANDBOX_SETUP_STATES = [SandboxStates.BEFORE_SETUP.value, SandboxStates.RUNNING_SETUP.value]
+SANDBOX_ACTIVE_STATES = [SandboxStates.READY.value, SandboxStates.ERROR.value]
+UNFINISHED_EXECUTION_STATUSES = [ExecutionStatuses.RUNNING.value, ExecutionStatuses.PENDING.value]
+
+
+def _should_keep_polling_setup(sandbox_details: dict) -> bool:
+    """ if still in setup keep polling """
+    setup_states = [SandboxStates.BEFORE_SETUP.value, SandboxStates.RUNNING_SETUP.value]
+    if sandbox_details["state"] in setup_states:
+        return True
+    return False
+
+
+def _should_keep_polling_teardown(sandbox_details: dict) -> bool:
+    """ if in teardown keep polling """
+    if sandbox_details["state"] == SandboxStates.TEARDOWN.value:
+        return True
+    return False
+
+
+def _should_keep_polling_execution(exc_data: dict) -> bool:
+    current_exc_status = exc_data["status"]
+    if current_exc_status in UNFINISHED_EXECUTION_STATUSES:
+        return True
+    return False
+
+
+def log_orchestration_polling(orchestration_type: str, sandbox_id: str, logger: logging.Logger = None):
+    if logger:
+        polling_msg = f"Polling {orchestration_type} for sandbox '{sandbox_id}'..."
+        logger.info(polling_msg)
+
+
+def _poll_orchestration_state(
+    orchestration_type: str,
+    api: SandboxRestApiSession,
+    reservation_id: str,
+    polling_func: Callable,
+    max_polling_minutes: int,
+    polling_frequency_seconds: int,
+    logger: logging.Logger = None,
+) -> str:
+    """ Create blocking polling process """
+
+    # retry wait times are in milliseconds
+    @retry(
+        retry=retry_if_result(polling_func),
+        wait=wait_fixed(polling_frequency_seconds * 1000),
+        stop=stop_after_delay(max_polling_minutes * 60000),
+        before=log_orchestration_polling(orchestration_type, reservation_id, logger),
+    )
+    def get_sandbox_details():
+        return api.get_sandbox_details(reservation_id)
+
+    try:
+        sandbox_details = get_sandbox_details()
+    except RetryError:
+        raise exceptions.OrchestrationPollingTimeout(
+            f"Sandbox Polling timed out after configured {max_polling_minutes} minutes"
+        )
+    return sandbox_details
+
+
+def poll_sandbox_setup(
+    api: SandboxRestApiSession,
+    reservation_id: str,
+    max_polling_minutes=20,
+    polling_frequency_seconds=30,
+    logger: logging.Logger = None,
+) -> dict:
+    """ poll until completion """
+    sandbox_details = _poll_orchestration_state(
+        "Setup", api, reservation_id, _should_keep_polling_setup, max_polling_minutes, polling_frequency_seconds, logger
+    )
+    setup_state = sandbox_details["state"]
+    sandbox_id = sandbox_details["id"]
+    if setup_state == SandboxStates.ERROR.value:
+        raise exceptions.SetupFailedException(f"Sandbox setup failed for sandbox '{sandbox_id}'")
+    return sandbox_details
+
+
+def poll_sandbox_teardown(
+    api: SandboxRestApiSession,
+    reservation_id: str,
+    max_polling_minutes=20,
+    polling_frequency_seconds=30,
+    logger: logging.Logger = None,
+) -> dict:
+    """ poll until completion  """
+    sandbox_details = _poll_orchestration_state(
+        "Teardown", api, reservation_id, _should_keep_polling_teardown, max_polling_minutes, polling_frequency_seconds, logger
+    )
+    return sandbox_details
+
+
+def poll_execution(
+    sandbox_rest: SandboxRestApiSession, command_execution_id: str, max_polling_in_minutes=20, polling_frequency_in_seconds=30
+) -> str:
+    """
+    poll execution for "Completed" status, then return the execution output
+    """
+
+    @retry(
+        retry=retry_if_result(_should_keep_polling_execution),
+        wait=wait_fixed(polling_frequency_in_seconds),
+        stop=stop_after_delay(max_polling_in_minutes * 60),
+    )
+    def get_execution_response():
+        response = sandbox_rest.get_execution_details(command_execution_id)
+        return response
+
+    try:
+        execution_response = get_execution_response(sandbox_rest, command_execution_id)
+    except RetryError:
+        raise exceptions.CommandPollingTimeout(f"Execution polling timed out after max {max_polling_in_minutes} minutes")
+    execution_status = execution_response["status"]
+    execution_id = execution_response["id"]
+    if execution_status == ExecutionStatuses.FAILED.value:
+        raise exceptions.CommandExecutionFailed(f"Execution '{execution_id}' ended with failed result")
+    return execution_response["output"]
diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
new file mode 100644
index 0000000..78f89fa
--- /dev/null
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -0,0 +1,2 @@
+class SandboxControllerContext:
+    pass
diff --git a/tests/common.py b/tests/common.py
index e6d6678..0ebf1b3 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -2,7 +2,7 @@
 from random import randint
 from time import sleep
 
-from src.cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from src.cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 def pretty_print_response(dict_response):
diff --git a/tests/conftest.py b/tests/conftest.py
index 2a1dbf4..b5c892c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,7 +4,7 @@
 from constants import *
 from env_settings import *
 
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 @pytest.fixture(scope="session")
diff --git a/tests/test_api_dut_sandbox.py b/tests/test_api_dut_sandbox.py
index 9b6bc85..a0c73c6 100644
--- a/tests/test_api_dut_sandbox.py
+++ b/tests/test_api_dut_sandbox.py
@@ -9,7 +9,7 @@
 import constants
 import pytest
 
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 @pytest.fixture(scope="module")
diff --git a/tests/test_api_empty_sandbox.py b/tests/test_api_empty_sandbox.py
index e224e2b..f666f3d 100644
--- a/tests/test_api_empty_sandbox.py
+++ b/tests/test_api_empty_sandbox.py
@@ -5,7 +5,7 @@
 import common
 import pytest
 
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 @pytest.fixture(scope="module")
diff --git a/tests/test_api_no_sandbox.py b/tests/test_api_no_sandbox.py
index 521d024..844f503 100644
--- a/tests/test_api_no_sandbox.py
+++ b/tests/test_api_no_sandbox.py
@@ -7,7 +7,7 @@
 import env_settings
 import pytest
 
-from cloudshell.sandbox_rest.sandbox_api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 @pytest.fixture(scope="module")

From 6d9296eda3ed9685972d1a15d53f48dbfb81d78a Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Thu, 24 Mar 2022 03:25:17 +0200
Subject: [PATCH 02/11] patched descriptions to eliminate defaults

---
 src/cloudshell/sandbox_rest/model.py | 21 ++++++++++-----------
 1 file changed, 10 insertions(+), 11 deletions(-)

diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index bb25ea1..8f3d722 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -89,12 +89,10 @@ class BlueprintDescription(SandboxApiBaseModel):
     categories: Optional[List[str]]
     description: Optional[str]
     params: Optional[List[BlueprintInput]]
-    availability: Optional[BlueprintAvailabilityStates] = Field(
-        description="The availability of blueprint: " "['Available Now', 'Not Available']"
-    )
-    estimated_setup_duration: Optional[str] = Field(
-        description="Estimated blueprint setup duration. " "Uses'ISO 8601' Standard. (e.g 'PT23H' or 'PT4H2M')"
-    )
+    availability: Optional[BlueprintAvailabilityStates]
+    """ The availability of blueprint: ['Available Now', 'Not Available'] """
+    estimated_setup_duration: Optional[str]
+    """ Estimated blueprint setup duration. Uses'ISO 8601' Standard. (e.g 'PT23H' or 'PT4H2M') """
 
 
 @_basemodel_decorator
@@ -145,7 +143,8 @@ class SandboxEvent(SandboxApiBaseModel):
     event_type: Optional[SandboxEventTypes]
     event_text: Optional[str]
     output: Optional[str]
-    time: Optional[str] = Field(description="Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z')")
+    time: Optional[str]
+    """ Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z') """
 
 
 @_basemodel_decorator
@@ -210,9 +209,8 @@ class CommandExecutionDetails(SandboxApiBaseModel):
     started: Optional[str]
     ended: Optional[str]
     output: Optional[str]
-    command_context: Optional[CommandContextDetails] = Field(
-        None, description="additional data to populate about command. " "NOTE: this does not come from api response"
-    )
+    command_context: Optional[CommandContextDetails] = Field(description="additional data to populate about command. "
+                                                                         "NOTE: this does not come from api response")
 
 
 @_basemodel_decorator
@@ -261,7 +259,8 @@ class ExtendResponse(SandboxApiBaseModel):
 class SandboxOutputEntry(SandboxApiBaseModel):
     id: Optional[int]
     text: Optional[str]
-    time: Optional[str] = Field(description="Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z'")
+    time: Optional[str]
+    """ Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z' """
 
 
 @_basemodel_decorator

From 4b98f2ae93469e934bfc4b328263def5112406de Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Tue, 29 Mar 2022 01:15:00 +0300
Subject: [PATCH 03/11] all tests passing with type hint pydantic responses

---
 src/cloudshell/sandbox_rest/api.py   | 110 ++++++++++++++++-----------
 src/cloudshell/sandbox_rest/model.py |  47 +++++++++---
 tests/common.py                      |   4 +-
 tests/test_api_dut_sandbox.py        |  21 +++--
 tests/test_api_empty_sandbox.py      |  28 +++----
 tests/test_api_no_sandbox.py         |  16 +++-
 6 files changed, 142 insertions(+), 84 deletions(-)

diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index 63a4970..6945d72 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -6,10 +6,11 @@
 from abstract_http_client.http_clients.requests_client import RequestsClient
 
 from cloudshell.sandbox_rest.exceptions import SandboxRestAuthException, SandboxRestException
+from cloudshell.sandbox_rest import model
 
 
 @dataclass
-class InputParam:
+class CommandInputParam:
     """
     param objects passed to sandbox / component command endpoints
     sandbox global inputs, commands and resource commands all follow this generic name/value convention
@@ -116,9 +117,9 @@ def start_sandbox(
         blueprint_id: str,
         sandbox_name="",
         duration="PT2H0M",
-        bp_params: List[InputParam] = None,
+        bp_params: List[CommandInputParam] = None,
         permitted_users: List[str] = None,
-    ) -> dict:
+    ) -> model.SandboxDetails:
         """
         Create a sandbox from the provided blueprint id
         Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
@@ -134,15 +135,16 @@ def start_sandbox(
             "params": [asdict(x) for x in bp_params] if bp_params else [],
         }
 
-        return self.rest_service.request_post(uri, data).json()
+        response_dict = self.rest_service.request_post(uri, data).json()
+        return model.dict_to_model(response_dict, model.SandboxDetails)
 
     def start_persistent_sandbox(
         self,
         blueprint_id: str,
         sandbox_name="",
-        bp_params: List[InputParam] = None,
+        bp_params: List[CommandInputParam] = None,
         permitted_users: List[str] = None,
-    ) -> dict:
+    ) -> model.SandboxDetails:
         """ Create a persistent sandbox from the provided blueprint id """
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}/start-persistent"
@@ -154,40 +156,44 @@ def start_persistent_sandbox(
             "params": [asdict(x) for x in bp_params] if bp_params else [],
         }
 
-        return self.rest_service.request_post(uri, data).json()
+        response_dict = self.rest_service.request_post(uri, data).json()
+        return model.dict_to_model(response_dict, model.SandboxDetails)
+
 
     def run_sandbox_command(
         self,
         sandbox_id: str,
         command_name: str,
-        params: List[InputParam] = None,
+        params: List[CommandInputParam] = None,
         print_output=True,
-    ) -> dict:
+    ) -> model.CommandStartResponse:
         """Run a sandbox level command"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands/{command_name}/start"
         data = {"printOutput": print_output}
         params = [asdict(x) for x in params] if params else []
         data["params"] = params
-        return self.rest_service.request_post(uri, data).json()
+        response_dict = self.rest_service.request_post(uri, data).json()
+        return model.dict_to_model(response_dict, model.CommandStartResponse)
 
     def run_component_command(
         self,
         sandbox_id: str,
         component_id: str,
         command_name: str,
-        params: List[InputParam] = None,
+        params: List[CommandInputParam] = None,
         print_output: bool = True,
-    ) -> dict:
+    ) -> model.CommandStartResponse:
         """Start a command on sandbox component"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands/{command_name}/start"
         data = {"printOutput": print_output}
         params = [asdict(x) for x in params] if params else []
         data["params"] = params
-        return self.rest_service.request_post(uri, data).json()
+        response_dict = self.rest_service.request_post(uri, data).json()
+        return model.dict_to_model(response_dict, model.CommandStartResponse)
 
-    def extend_sandbox(self, sandbox_id: str, duration: str) -> dict:
+    def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse:
         """Extend the sandbox
         :param str sandbox_id: Sandbox id
         :param str duration: duration in ISO 8601 format (P1Y1M1DT1H1M1S = 1year, 1month, 1day, 1hour, 1min, 1sec)
@@ -196,27 +202,31 @@ def extend_sandbox(self, sandbox_id: str, duration: str) -> dict:
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/extend"
         data = {"extended_time": duration}
-        return self.rest_service.request_post(uri, data).json()
+        response_dict = self.rest_service.request_post(uri, data).json()
+        return model.dict_to_model(response_dict, model.ExtendResponse)
 
-    def stop_sandbox(self, sandbox_id: str) -> None:
+    def stop_sandbox(self, sandbox_id: str) -> model.StopSandboxResponse:
         """Stop the sandbox given sandbox id"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/stop"
-        return self.rest_service.request_post(uri).json()
+        response_dict = self.rest_service.request_post(uri).json()
+        return model.dict_to_model(response_dict, model.StopSandboxResponse)
 
     # SANDBOX GET REQUESTS
-    def get_sandboxes(self, show_historic=False) -> list:
+    def get_sandboxes(self, show_historic=False) -> List[model.SandboxDescriptionShort]:
         """Get list of sandboxes"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes"
         params = {"show_historic": "true" if show_historic else "false"}
-        return self.rest_service.request_get(uri, params=params).json()
+        response_list = self.rest_service.request_get(uri, params=params).json()
+        return model.list_to_model(response_list, model.SandboxDescriptionShort)
 
-    def get_sandbox_details(self, sandbox_id: str) -> dict:
+    def get_sandbox_details(self, sandbox_id: str) -> model.SandboxDetails:
         """Get details of the given sandbox id"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}"
-        return self.rest_service.request_get(uri).json()
+        response_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(response_dict, model.SandboxDetails)
 
     def get_sandbox_activity(
         self,
@@ -225,7 +235,7 @@ def get_sandbox_activity(
         since="",
         from_event_id: int = None,
         tail: int = None,
-    ) -> dict:
+    ) -> model.ActivityEventsResponse:
         """
         Get list of sandbox activity
         'since' - format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
@@ -246,43 +256,51 @@ def get_sandbox_activity(
         if tail:
             params["tail"] = tail
 
-        return self.rest_service.request_get(uri, params=params).json()
+        response_dict = self.rest_service.request_get(uri, params=params).json()
+        return model.dict_to_model(response_dict, model.ActivityEventsResponse)
 
-    def get_sandbox_commands(self, sandbox_id: str) -> list:
+    def get_sandbox_commands(self, sandbox_id: str) -> List[model.Command]:
         """Get list of sandbox commands"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands"
-        return self.rest_service.request_get(uri).json()
+        response_list = self.rest_service.request_get(uri).json()
+        return model.list_to_model(response_list, model.Command)
 
-    def get_sandbox_command_details(self, sandbox_id: str, command_name: str) -> dict:
+    def get_sandbox_command_details(self, sandbox_id: str, command_name: str) -> model.Command:
         """Get details of specific sandbox command"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands/{command_name}"
-        return self.rest_service.request_get(uri).json()
+        response_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(response_dict, model.Command)
 
-    def get_sandbox_components(self, sandbox_id: str) -> list:
+    def get_sandbox_components(self, sandbox_id: str) -> List[model.SandboxComponentFull]:
         """Get list of sandbox components"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components"
-        return self.rest_service.request_get(uri).json()
+        response_list = self.rest_service.request_get(uri).json()
+        return model.list_to_model(response_list, model.SandboxComponentFull)
 
-    def get_sandbox_component_details(self, sandbox_id: str, component_id: str) -> dict:
+    def get_sandbox_component_details(self, sandbox_id: str, component_id: str) -> model.SandboxComponentFull:
         """Get details of components in sandbox"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}"
-        return self.rest_service.request_get(uri).json()
+        response_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(response_dict, model.SandboxComponentFull)
+
 
-    def get_sandbox_component_commands(self, sandbox_id: str, component_id: str) -> list:
+    def get_sandbox_component_commands(self, sandbox_id: str, component_id: str) -> List[model.Command]:
         """Get list of commands for a particular component in sandbox"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands"
-        return self.rest_service.request_get(uri).json()
+        response_list = self.rest_service.request_get(uri).json()
+        return model.list_to_model(response_list, model.Command)
 
-    def get_sandbox_component_command_details(self, sandbox_id: str, component_id: str, command: str) -> dict:
+    def get_sandbox_component_command_details(self, sandbox_id: str, component_id: str, command: str) -> model.CommandExecutionDetails:
         """Get details of a command of sandbox component"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands/{command}"
-        return self.rest_service.request_get(uri).json()
+        response_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(response_dict, model.CommandExecutionDetails)
 
     def get_sandbox_instructions(self, sandbox_id: str) -> str:
         """ Pull the instructions text of sandbox """
@@ -296,7 +314,7 @@ def get_sandbox_output(
         tail: int = None,
         from_entry_id: int = None,
         since: str = None,
-    ) -> dict:
+    ) -> model.SandboxOutput:
         """Get list of sandbox output"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/output"
@@ -308,29 +326,33 @@ def get_sandbox_output(
         if since:
             params["since"] = since
 
-        return self.rest_service.request_get(uri, params=params).json()
+        response_dict = self.rest_service.request_get(uri, params=params).json()
+        return model.dict_to_model(response_dict, model.SandboxOutput)
 
     # BLUEPRINT GET REQUESTS
-    def get_blueprints(self) -> list:
-        """Get list of blueprints"""
+    def get_blueprints(self, raw_json=False) -> List[model.BlueprintDescription]:
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/blueprints"
-        return self.rest_service.request_get(uri).json()
+        blueprints_list = self.rest_service.request_get(uri).json()
+        return model.list_to_model(blueprints_list, model.BlueprintDescription)
 
-    def get_blueprint_details(self, blueprint_id: str) -> dict:
+    def get_blueprint_details(self, blueprint_id: str) -> model.BlueprintDescription:
         """
         Get details of a specific blueprint
         Can pass either blueprint name OR blueprint ID
         """
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}"
-        return self.rest_service.request_get(uri).json()
+        details_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(details_dict, model.BlueprintDescription)
+
 
     # EXECUTIONS
-    def get_execution_details(self, execution_id: str) -> dict:
+    def get_execution_details(self, execution_id: str) -> model.CommandExecutionDetails:
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/executions/{execution_id}"
-        return self.rest_service.request_get(uri).json()
+        details_dict = self.rest_service.request_get(uri).json()
+        return model.dict_to_model(details_dict, model.CommandExecutionDetails)
 
     def delete_execution(self, execution_id: str) -> None:
         """
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 8f3d722..5e5a711 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -2,7 +2,7 @@
 decorator is added for intellisense auto completion purposes
 https://stackoverflow.com/a/71257588
 """
-
+from __future__ import annotations
 from enum import Enum
 from typing import TYPE_CHECKING, List, Optional
 
@@ -11,14 +11,33 @@
 if TYPE_CHECKING:
     from dataclasses import dataclass as _basemodel_decorator
 else:
-
-    def _basemodel_decorator(x):
-        return x
+    def _basemodel_decorator(func):
+        return func
 
 
 class SandboxApiBaseModel(BaseModel):
-    def pretty_json(self, indent=4):
-        return self.json(indent=indent)
+    class Config:
+        use_enum_values = True
+
+    response_dict: dict = None
+    """ the original dictionary received from api response """
+
+    def pretty_json(self, indent=4, exclude_response=True):
+        excluded_key_set = set()
+        if exclude_response:
+            excluded_key_set.add("response_dict")
+        return self.json(indent=indent, exclude=excluded_key_set)
+
+
+def list_to_model(response_list: List[dict], model: SandboxApiBaseModel) -> List[SandboxApiBaseModel]:
+    return [dict_to_model(dict_item, model) for dict_item in response_list]
+
+
+def dict_to_model(response_dict: dict, model: SandboxApiBaseModel) -> SandboxApiBaseModel:
+    """ calls parse_obj, but additionally caches the response dict """
+    wrapped_item = model.parse_obj(response_dict)
+    wrapped_item.response_dict = response_dict
+    return wrapped_item
 
 
 class BlueprintAvailabilityStates(str, Enum):
@@ -28,6 +47,7 @@ class BlueprintAvailabilityStates(str, Enum):
 
 class SandboxStates(str, Enum):
     PENDING = "Pending"
+    BEFORE_SETUP = "BeforeSetup"
     PENDING_SETUP = "Pending Setup"
     RUNNING_SETUP = "Setup"
     READY = "Ready"
@@ -49,6 +69,7 @@ def get_post_active_states(cls) -> List:
 
 
 class SetupStages(str, Enum):
+    NONE = "None"
     PROVISIONING = "Provisioning"
     CONNECTIVITY = "Connectivity"
     CONFIGURATION = "Configuration"
@@ -107,7 +128,7 @@ class SandboxComponentBasic(SandboxApiBaseModel):
 
 
 @_basemodel_decorator
-class SandboxDetailsBasic(SandboxApiBaseModel):
+class SandboxDetails(SandboxApiBaseModel):
     id: Optional[str]
     blueprint_id: Optional[str]
     type: Optional[str]
@@ -129,7 +150,7 @@ class BlueprintReference(SandboxApiBaseModel):
 
 
 @_basemodel_decorator
-class SandboxDescription(SandboxApiBaseModel):
+class SandboxDescriptionShort(SandboxApiBaseModel):
     id: Optional[str]
     name: Optional[str]
     blueprint: Optional[BlueprintReference]
@@ -148,7 +169,7 @@ class SandboxEvent(SandboxApiBaseModel):
 
 
 @_basemodel_decorator
-class SandboxEventsResponse(SandboxApiBaseModel):
+class ActivityEventsResponse(SandboxApiBaseModel):
     num_returned_events: Optional[int]
     more_pages: Optional[bool]
     next_event_id: Optional[int]
@@ -257,7 +278,7 @@ class ExtendResponse(SandboxApiBaseModel):
 
 @_basemodel_decorator
 class SandboxOutputEntry(SandboxApiBaseModel):
-    id: Optional[int]
+    id: Optional[str]
     text: Optional[str]
     time: Optional[str]
     """ Event time in 'ISO 8601' Standard. (e.g '2000-12-31T23:59:60Z' """
@@ -269,3 +290,9 @@ class SandboxOutput(SandboxApiBaseModel):
     next_entry_id: Optional[str]
     more_pages: Optional[bool]
     entries: Optional[List[SandboxOutputEntry]]
+
+
+# Missing from Swagger Schema Docs
+@_basemodel_decorator
+class StopSandboxResponse(SandboxApiBaseModel):
+    result: Optional[str] = "success"
diff --git a/tests/common.py b/tests/common.py
index 0ebf1b3..1fa53aa 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -21,6 +21,6 @@ def fixed_sleep():
     sleep(3)
 
 
-def get_blueprint_id_from_name(api: SandboxRestApiSession, bp_name: str):
+def get_blueprint_id_from_name(api: SandboxRestApiSession, bp_name: str) -> str:
     res = api.get_blueprint_details(bp_name)
-    return res["id"]
+    return res.id
diff --git a/tests/test_api_dut_sandbox.py b/tests/test_api_dut_sandbox.py
index a0c73c6..4d4d834 100644
--- a/tests/test_api_dut_sandbox.py
+++ b/tests/test_api_dut_sandbox.py
@@ -9,6 +9,7 @@
 import constants
 import pytest
 
+from cloudshell.sandbox_rest import model
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
@@ -23,7 +24,7 @@ def blueprint_id(admin_session: SandboxRestApiSession, dut_blueprint):
 def sandbox_id(admin_session: SandboxRestApiSession, blueprint_id):
     # start sandbox
     start_res = admin_session.start_sandbox(blueprint_id=blueprint_id, sandbox_name="Pytest DUT blueprint test")
-    sandbox_id = start_res["id"]
+    sandbox_id = start_res.id
     print(f"Sandbox started: {sandbox_id}")
     common.fixed_sleep()
     yield sandbox_id
@@ -35,9 +36,9 @@ def sandbox_id(admin_session: SandboxRestApiSession, blueprint_id):
 def component_id(admin_session: SandboxRestApiSession, sandbox_id: str):
     components = admin_session.get_sandbox_components(sandbox_id)
     common.fixed_sleep()
-    component_filter = [x for x in components if x["component_type"] == constants.DUT_MODEL]
+    component_filter = [x for x in components if x.component_type == constants.DUT_MODEL]
     assert component_filter
-    return component_filter[0]["id"]
+    return component_filter[0].id
 
 
 @pytest.fixture(scope="module")
@@ -47,10 +48,9 @@ def execution_id(admin_session: SandboxRestApiSession, sandbox_id: str, componen
         sandbox_id=sandbox_id, component_id=component_id, command_name=constants.DUT_COMMAND
     )
     common.fixed_sleep()
-    assert isinstance(res, dict)
-    print("Started execution response")
-    common.pretty_print_response(res)
-    execution_id = res["executionId"]
+    assert isinstance(res, model.CommandStartResponse)
+    print(f"Start execution response: {res.pretty_json()}")
+    execution_id = res.executionId
     return execution_id
 
 
@@ -58,14 +58,13 @@ def execution_id(admin_session: SandboxRestApiSession, sandbox_id: str, componen
 def test_get_execution_details(admin_session, execution_id):
     res = admin_session.get_execution_details(execution_id)
     common.fixed_sleep()
-    assert isinstance(res, dict)
+    assert isinstance(res, model.CommandExecutionDetails)
     return res
 
 
 def test_delete_execution(admin_session, execution_id, test_get_execution_details):
-    print("Execution Details")
-    common.pretty_print_response(test_get_execution_details)
-    is_supports_cancellation = test_get_execution_details["supports_cancellation"]
+    print(f"Execution Details: {test_get_execution_details.pretty_json()}")
+    is_supports_cancellation = test_get_execution_details.supports_cancellation
     if not is_supports_cancellation:
         print("Can't cancel this command. Returning")
         return
diff --git a/tests/test_api_empty_sandbox.py b/tests/test_api_empty_sandbox.py
index f666f3d..e19cb44 100644
--- a/tests/test_api_empty_sandbox.py
+++ b/tests/test_api_empty_sandbox.py
@@ -6,6 +6,7 @@
 import pytest
 
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import model
 
 
 @pytest.fixture(scope="module")
@@ -19,10 +20,11 @@ def blueprint_id(admin_session: SandboxRestApiSession, empty_blueprint):
 def sandbox_id(admin_session: SandboxRestApiSession, blueprint_id):
     # start sandbox
     start_res = admin_session.start_sandbox(blueprint_id=blueprint_id, sandbox_name="Pytest empty blueprint test")
-    sandbox_id = start_res["id"]
+    sandbox_id = start_res.id
     print(f"Sandbox started: {sandbox_id}")
     yield sandbox_id
-    admin_session.stop_sandbox(sandbox_id)
+    stop_response = admin_session.stop_sandbox(sandbox_id)
+    print(stop_response)
     print(f"\nSandbox ended: {sandbox_id}")
 
 
@@ -34,8 +36,8 @@ def test_start_stop(sandbox_id):
 def test_get_sandbox_details(admin_session, sandbox_id):
     common.random_sleep()
     details_res = admin_session.get_sandbox_details(sandbox_id)
-    assert isinstance(details_res, dict)
-    sb_name = details_res["name"]
+    sb_name = details_res.name
+    assert isinstance(details_res, model.SandboxDetails)
     print(f"Pulled details for sandbox '{sb_name}'")
 
 
@@ -51,24 +53,24 @@ def test_get_sandbox_commands(admin_session, sandbox_id):
     common.random_sleep()
     commands_res = admin_session.get_sandbox_commands(sandbox_id)
     assert isinstance(commands_res, list)
-    print(f"Sandbox commands: {[x['name'] for x in commands_res]}")
-    first_sb_command = admin_session.get_sandbox_command_details(sandbox_id, commands_res[0]["name"])
-    print(f"SB command name: {first_sb_command['name']}\n" f"description: {first_sb_command['description']}")
+    print(f"Sandbox commands: {[x.name for x in commands_res]}")
+    first_sb_command_details = admin_session.get_sandbox_command_details(sandbox_id, commands_res[0].name)
+    print(f"SB command name: {first_sb_command_details.name}\n" f"description: {first_sb_command_details.description}")
 
 
 def test_get_sandbox_events(admin_session, sandbox_id):
     common.random_sleep()
     activity_res = admin_session.get_sandbox_activity(sandbox_id)
-    assert isinstance(activity_res, dict) and "events" in activity_res
-    events = activity_res["events"]
+    assert isinstance(activity_res, model.ActivityEventsResponse)
+    events = activity_res.events
     print(f"activity events count: {len(events)}")
 
 
 def test_get_console_output(admin_session, sandbox_id):
     common.random_sleep()
     output_res = admin_session.get_sandbox_output(sandbox_id)
-    assert isinstance(output_res, dict) and "entries" in output_res
-    entries = output_res["entries"]
+    assert isinstance(output_res, model.SandboxOutput)
+    entries = output_res.entries
     print(f"Sandbox output entries count: {len(entries)}")
 
 
@@ -82,5 +84,5 @@ def test_get_instructions(admin_session, sandbox_id):
 def test_extend_sandbox(admin_session, sandbox_id):
     common.random_sleep()
     extend_response = admin_session.extend_sandbox(sandbox_id, "PT0H10M")
-    assert isinstance(extend_response, dict) and "remaining_time" in extend_response
-    print(f"extended sandbox. Remaining time: {extend_response['remaining_time']}")
+    assert isinstance(extend_response, model.ExtendResponse)
+    print(f"Extended sandbox - Remaining time: {extend_response.remaining_time}")
diff --git a/tests/test_api_no_sandbox.py b/tests/test_api_no_sandbox.py
index 844f503..c1e1ed5 100644
--- a/tests/test_api_no_sandbox.py
+++ b/tests/test_api_no_sandbox.py
@@ -7,6 +7,7 @@
 import env_settings
 import pytest
 
+from cloudshell.sandbox_rest import model
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
@@ -37,12 +38,19 @@ def test_get_blueprints(admin_session: SandboxRestApiSession):
     bp_res = admin_session.get_blueprints()
     common.random_sleep()
     assert isinstance(bp_res, list)
-    print(f"Blueprint count found in system: '{len(bp_res)}'")
+    if bp_res:
+        print(f"Blueprint count found in system: '{len(bp_res)}'")
+    else:
+        print("no blueprints found in system")
+        return
+    first_bp = bp_res[0]
+    print(f"blueprint name: {first_bp.name}")
+    print(f"pretty printed:\n{first_bp.pretty_json()}")
 
 
 def test_get_default_blueprint(admin_session: SandboxRestApiSession):
     bp_res = admin_session.get_blueprint_details(constants.DEFAULT_EMPTY_BLUEPRINT)
     common.random_sleep()
-    assert isinstance(bp_res, dict)
-    bp_name = bp_res["name"]
-    print(f"Pulled details for '{bp_name}'")
+    bp_name = bp_res.name
+    print(f"Pulled details for '{bp_name}'\n{bp_res.pretty_json()}")
+    assert isinstance(bp_res, model.BlueprintDescription)

From 66816f9dc71d0708e6d399e223005df713ccfa23 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Tue, 29 Mar 2022 01:20:53 +0300
Subject: [PATCH 04/11] added module doc strings

---
 src/cloudshell/sandbox_rest/api.py        | 5 +++++
 src/cloudshell/sandbox_rest/components.py | 3 +++
 src/cloudshell/sandbox_rest/model.py      | 4 ++--
 3 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index 6945d72..e92bf95 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -1,3 +1,8 @@
+"""
+Module that handles the Sandbox Rest API session
+Handles login and wraps all api methods
+Returns Pydantic BaseModel responses of returned JSON
+"""
 import json
 import logging
 from dataclasses import asdict, dataclass
diff --git a/src/cloudshell/sandbox_rest/components.py b/src/cloudshell/sandbox_rest/components.py
index e69de29..fc7ac48 100644
--- a/src/cloudshell/sandbox_rest/components.py
+++ b/src/cloudshell/sandbox_rest/components.py
@@ -0,0 +1,3 @@
+"""
+Component helpers for filtering and sorting the sandbox components
+"""
\ No newline at end of file
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 5e5a711..728ac5b 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -1,6 +1,5 @@
 """
-decorator is added for intellisense auto completion purposes
-https://stackoverflow.com/a/71257588
+All pydantic BaseModel class representations of Sandbox API responses
 """
 from __future__ import annotations
 from enum import Enum
@@ -8,6 +7,7 @@
 
 from pydantic import BaseModel, Field
 
+# added for dev intellisense: https://stackoverflow.com/a/71257588
 if TYPE_CHECKING:
     from dataclasses import dataclass as _basemodel_decorator
 else:

From b30f6d6e8c886131a911f7891140c50866db8786 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Tue, 29 Mar 2022 01:22:45 +0300
Subject: [PATCH 05/11] sandbox context module doc string

---
 src/cloudshell/sandbox_rest/sandbox_context.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
index 78f89fa..984b909 100644
--- a/src/cloudshell/sandbox_rest/sandbox_context.py
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -1,2 +1,7 @@
+"""
+Sandbox context manager to manage the lifecyle of setup and teardown
+"""
+
+
 class SandboxControllerContext:
     pass

From b5ee5f6ae2e91e916b2dadc1ee69da6ec4396d34 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Fri, 1 Apr 2022 03:37:09 +0300
Subject: [PATCH 06/11] black formatted

---
 .gitignore                                    |   3 +-
 requirements.txt                              |   1 +
 src/cloudshell/sandbox_rest/api.py            | 309 +++++++++++++-----
 src/cloudshell/sandbox_rest/async_commands.py |   7 +
 src/cloudshell/sandbox_rest/components.py     |   6 +-
 src/cloudshell/sandbox_rest/exceptions.py     |  28 +-
 src/cloudshell/sandbox_rest/model.py          |  54 ++-
 src/cloudshell/sandbox_rest/polling.py        | 153 ---------
 .../sandbox_rest/sandbox_context.py           |  17 +-
 9 files changed, 333 insertions(+), 245 deletions(-)
 create mode 100644 src/cloudshell/sandbox_rest/async_commands.py
 delete mode 100644 src/cloudshell/sandbox_rest/polling.py

diff --git a/.gitignore b/.gitignore
index 96702e8..ebbdef4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ tests/.env
 dist/
 build/
 *.pyc
-venv
\ No newline at end of file
+venv
+debug.py
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index da68080..660db9b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
 requests>=2,<3
 abstract-http-client>=1,<2
 pydantic
+tenacity
\ No newline at end of file
diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index e92bf95..94ddba8 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -5,13 +5,14 @@
 """
 import json
 import logging
+import time
 from dataclasses import asdict, dataclass
-from typing import List
+from typing import Callable, List
 
 from abstract_http_client.http_clients.requests_client import RequestsClient
+from tenacity import RetryError, retry, retry_if_result, stop_after_delay, wait_fixed
 
-from cloudshell.sandbox_rest.exceptions import SandboxRestAuthException, SandboxRestException
-from cloudshell.sandbox_rest import model
+from cloudshell.sandbox_rest import exceptions, model
 
 
 @dataclass
@@ -86,7 +87,7 @@ def _get_token_with_credentials(self, user_name: str, password: str, domain: str
 
         login_token = response.text[1:-1]
         if not login_token:
-            raise SandboxRestAuthException(f"Invalid token. Token response {response.text}")
+            raise exceptions.SandboxRestAuthException(f"Invalid token. Token response {response.text}")
 
         return login_token
 
@@ -98,7 +99,7 @@ def _remove_auth_header_from_session(self):
 
     def _validate_auth_header(self) -> None:
         if not self.rest_service.session.headers.get("Authorization"):
-            raise SandboxRestAuthException("No Authorization header currently set for session")
+            raise exceptions.SandboxRestAuthException("No Authorization header currently set for session")
 
     def get_token_for_target_user(self, user_name: str) -> str:
         """
@@ -116,54 +117,116 @@ def delete_token(self, token_id: str) -> None:
         uri = f"{self._base_uri}/token/{token_id}"
         return self.rest_service.request_delete(uri).text
 
+    # BLUEPRINT GET REQUESTS
+    def get_blueprints(self) -> List[model.BlueprintDescription]:
+        self._validate_auth_header()
+        uri = f"{self._v2_base_uri}/blueprints"
+        blueprints_list = self.rest_service.request_get(uri).json()
+        return model.BlueprintDescription.list_to_models(blueprints_list)
+
+    def get_blueprint_details(self, blueprint_id: str) -> model.BlueprintDescription:
+        """
+        Get details of a specific blueprint
+        Can pass either blueprint name OR blueprint ID
+        """
+        self._validate_auth_header()
+        uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}"
+        details_dict = self.rest_service.request_get(uri).json()
+        return model.BlueprintDescription.dict_to_model(details_dict)
+
     # SANDBOX POST REQUESTS
-    def start_sandbox(
+    def _start_sandbox(
         self,
         blueprint_id: str,
-        sandbox_name="",
-        duration="PT2H0M",
+        sandbox_name: str,
+        duration: str = None,
         bp_params: List[CommandInputParam] = None,
         permitted_users: List[str] = None,
+        polling_setup: bool = False,
+        max_polling_minutes: int = 20,
+        polling_frequency_seconds: int = 30,
+        polling_log_level: str = logging.DEBUG,
     ) -> model.SandboxDetails:
-        """
-        Create a sandbox from the provided blueprint id
-        Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
-        """
+        """ internal implementation request to handle both regular and persistent sandbox requests """
         self._validate_auth_header()
-        uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}/start"
-        sandbox_name = sandbox_name if sandbox_name else self.get_blueprint_details(blueprint_id)["name"]
+        if duration:
+            uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}/start"
+        else:
+            uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}/start-persistent"
+
+        sandbox_name = sandbox_name if sandbox_name else self.get_blueprint_details(blueprint_id).name
 
-        data = {
-            "duration": duration,
+        payload = {
             "name": sandbox_name,
             "permitted_users": permitted_users if permitted_users else [],
             "params": [asdict(x) for x in bp_params] if bp_params else [],
         }
+        if duration:
+            payload["duration"] = duration
+
+        response_dict = self.rest_service.request_post(uri, payload).json()
+        sandbox_details = model.SandboxDetails.dict_to_model(response_dict)
+        if polling_setup:
+            return self.poll_sandbox_setup(
+                sandbox_details.id, max_polling_minutes, polling_frequency_seconds, polling_log_level
+            )
 
-        response_dict = self.rest_service.request_post(uri, data).json()
-        return model.dict_to_model(response_dict, model.SandboxDetails)
+        return sandbox_details
 
-    def start_persistent_sandbox(
+    def start_sandbox(
         self,
         blueprint_id: str,
-        sandbox_name="",
+        sandbox_name: str = "",
+        duration: str = "PT2H0M",
         bp_params: List[CommandInputParam] = None,
         permitted_users: List[str] = None,
+        polling_setup: bool = False,
+        max_polling_minutes: int = 20,
+        polling_frequency_seconds: int = 30,
+        polling_log_level: str = logging.DEBUG,
     ) -> model.SandboxDetails:
-        """ Create a persistent sandbox from the provided blueprint id """
-        self._validate_auth_header()
-        uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}/start-persistent"
-
-        sandbox_name = sandbox_name if sandbox_name else self.get_blueprint_details(blueprint_id)["name"]
-        data = {
-            "name": sandbox_name,
-            "permitted_users": permitted_users if permitted_users else [],
-            "params": [asdict(x) for x in bp_params] if bp_params else [],
-        }
-
-        response_dict = self.rest_service.request_post(uri, data).json()
-        return model.dict_to_model(response_dict, model.SandboxDetails)
+        """
+        Create a sandbox from the provided blueprint id
+        Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
+        """
+        return self._start_sandbox(
+            blueprint_id,
+            sandbox_name,
+            duration,
+            bp_params,
+            permitted_users,
+            polling_setup,
+            polling_log_level,
+            max_polling_minutes,
+            polling_frequency_seconds,
+        )
 
+    def start_persistent_sandbox(
+        self,
+        blueprint_id: str,
+        sandbox_name: str = "",
+        bp_params: List[CommandInputParam] = None,
+        permitted_users: List[str] = None,
+        polling_setup: bool = False,
+        max_polling_minutes: int = 20,
+        polling_frequency_seconds: int = 30,
+        polling_log_level: str = logging.DEBUG,
+    ) -> model.SandboxDetails:
+        """
+        Create a PERSISTENT sandbox from the provided blueprint id
+        Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
+        """
+        return self._start_sandbox(
+            blueprint_id,
+            sandbox_name,
+            None,
+            bp_params,
+            permitted_users,
+            polling_setup,
+            polling_log_level,
+            max_polling_minutes,
+            polling_frequency_seconds,
+        )
 
     def run_sandbox_command(
         self,
@@ -179,7 +242,7 @@ def run_sandbox_command(
         params = [asdict(x) for x in params] if params else []
         data["params"] = params
         response_dict = self.rest_service.request_post(uri, data).json()
-        return model.dict_to_model(response_dict, model.CommandStartResponse)
+        return model.CommandStartResponse.dict_to_model(response_dict)
 
     def run_component_command(
         self,
@@ -196,7 +259,7 @@ def run_component_command(
         params = [asdict(x) for x in params] if params else []
         data["params"] = params
         response_dict = self.rest_service.request_post(uri, data).json()
-        return model.dict_to_model(response_dict, model.CommandStartResponse)
+        return model.CommandStartResponse.dict_to_model(response_dict)
 
     def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse:
         """Extend the sandbox
@@ -208,14 +271,31 @@ def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/extend"
         data = {"extended_time": duration}
         response_dict = self.rest_service.request_post(uri, data).json()
-        return model.dict_to_model(response_dict, model.ExtendResponse)
+        return model.ExtendResponse.dict_to_model(response_dict)
 
-    def stop_sandbox(self, sandbox_id: str) -> model.StopSandboxResponse:
+    def stop_sandbox(
+        self,
+        sandbox_id: str,
+        poll_teardown=False,
+        max_polling_minutes: int = 20,
+        polling_frequency_seconds: int = 30,
+        polling_log_level: str = logging.DEBUG,
+    ) -> model.StopSandboxResponse:
         """Stop the sandbox given sandbox id"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/stop"
         response_dict = self.rest_service.request_post(uri).json()
-        return model.dict_to_model(response_dict, model.StopSandboxResponse)
+
+        stop_response = model.StopSandboxResponse.dict_to_model(response_dict)
+        if "success" not in stop_response.result:
+            raise exceptions.TeardownFailedException(f"Failed to stop sandbox. result:\n{json.dumps(response_dict)}")
+
+        if poll_teardown:
+            return self.poll_sandbox_teardown(sandbox_id, max_polling_minutes, polling_frequency_seconds, polling_log_level)
+
+        # if not polling, give teardown request chance to propagate before getting status
+        time.sleep(5)
+        return self.get_sandbox_details(sandbox_id)
 
     # SANDBOX GET REQUESTS
     def get_sandboxes(self, show_historic=False) -> List[model.SandboxDescriptionShort]:
@@ -224,14 +304,14 @@ def get_sandboxes(self, show_historic=False) -> List[model.SandboxDescriptionSho
         uri = f"{self._v2_base_uri}/sandboxes"
         params = {"show_historic": "true" if show_historic else "false"}
         response_list = self.rest_service.request_get(uri, params=params).json()
-        return model.list_to_model(response_list, model.SandboxDescriptionShort)
+        return model.SandboxDescriptionShort.list_to_models(response_list)
 
     def get_sandbox_details(self, sandbox_id: str) -> model.SandboxDetails:
         """Get details of the given sandbox id"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}"
         response_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(response_dict, model.SandboxDetails)
+        return model.SandboxDetails.dict_to_model(response_dict)
 
     def get_sandbox_activity(
         self,
@@ -253,7 +333,7 @@ def get_sandbox_activity(
         params = {}
 
         if error_only:
-            params["error_only"] = error_only
+            params["error_only"] = "true"
         if since:
             params["since"] = since
         if from_event_id:
@@ -262,56 +342,57 @@ def get_sandbox_activity(
             params["tail"] = tail
 
         response_dict = self.rest_service.request_get(uri, params=params).json()
-        return model.dict_to_model(response_dict, model.ActivityEventsResponse)
+        return model.ActivityEventsResponse.dict_to_model(response_dict)
 
     def get_sandbox_commands(self, sandbox_id: str) -> List[model.Command]:
         """Get list of sandbox commands"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands"
         response_list = self.rest_service.request_get(uri).json()
-        return model.list_to_model(response_list, model.Command)
+        return model.Command.list_to_models(response_list)
 
     def get_sandbox_command_details(self, sandbox_id: str, command_name: str) -> model.Command:
         """Get details of specific sandbox command"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands/{command_name}"
         response_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(response_dict, model.Command)
+        return model.Command.dict_to_model(response_dict)
 
     def get_sandbox_components(self, sandbox_id: str) -> List[model.SandboxComponentFull]:
         """Get list of sandbox components"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components"
         response_list = self.rest_service.request_get(uri).json()
-        return model.list_to_model(response_list, model.SandboxComponentFull)
+        return model.SandboxComponentFull.list_to_models(response_list)
 
     def get_sandbox_component_details(self, sandbox_id: str, component_id: str) -> model.SandboxComponentFull:
         """Get details of components in sandbox"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}"
         response_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(response_dict, model.SandboxComponentFull)
-
+        return model.SandboxComponentFull.dict_to_model(response_dict)
 
     def get_sandbox_component_commands(self, sandbox_id: str, component_id: str) -> List[model.Command]:
         """Get list of commands for a particular component in sandbox"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands"
         response_list = self.rest_service.request_get(uri).json()
-        return model.list_to_model(response_list, model.Command)
+        return model.Command.list_to_models(response_list)
 
-    def get_sandbox_component_command_details(self, sandbox_id: str, component_id: str, command: str) -> model.CommandExecutionDetails:
+    def get_sandbox_component_command_details(
+        self, sandbox_id: str, component_id: str, command: str
+    ) -> model.CommandExecutionDetails:
         """Get details of a command of sandbox component"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands/{command}"
         response_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(response_dict, model.CommandExecutionDetails)
+        return model.CommandExecutionDetails.dict_to_model(response_dict)
 
     def get_sandbox_instructions(self, sandbox_id: str) -> str:
         """ Pull the instructions text of sandbox """
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/instructions"
-        return self.rest_service.request_get(uri).json()
+        return self.rest_service.request_get(uri).text
 
     def get_sandbox_output(
         self,
@@ -332,34 +413,16 @@ def get_sandbox_output(
             params["since"] = since
 
         response_dict = self.rest_service.request_get(uri, params=params).json()
-        return model.dict_to_model(response_dict, model.SandboxOutput)
-
-    # BLUEPRINT GET REQUESTS
-    def get_blueprints(self, raw_json=False) -> List[model.BlueprintDescription]:
-        self._validate_auth_header()
-        uri = f"{self._v2_base_uri}/blueprints"
-        blueprints_list = self.rest_service.request_get(uri).json()
-        return model.list_to_model(blueprints_list, model.BlueprintDescription)
-
-    def get_blueprint_details(self, blueprint_id: str) -> model.BlueprintDescription:
-        """
-        Get details of a specific blueprint
-        Can pass either blueprint name OR blueprint ID
-        """
-        self._validate_auth_header()
-        uri = f"{self._v2_base_uri}/blueprints/{blueprint_id}"
-        details_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(details_dict, model.BlueprintDescription)
-
+        return model.SandboxOutput.dict_to_model(response_dict)
 
     # EXECUTIONS
     def get_execution_details(self, execution_id: str) -> model.CommandExecutionDetails:
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/executions/{execution_id}"
         details_dict = self.rest_service.request_get(uri).json()
-        return model.dict_to_model(details_dict, model.CommandExecutionDetails)
+        return model.CommandExecutionDetails.dict_to_model(details_dict)
 
-    def delete_execution(self, execution_id: str) -> None:
+    def delete_execution(self, execution_id: str) -> dict:
         """
         API returns dict with single key on successful deletion of execution
         {"result": "success"}
@@ -368,6 +431,106 @@ def delete_execution(self, execution_id: str) -> None:
         uri = f"{self._v2_base_uri}/executions/{execution_id}"
         response_dict = self.rest_service.request_delete(uri).json()
         if not response_dict["result"] == "success":
-            raise SandboxRestException(
+            raise exceptions.SandboxRestException(
                 f"Failed execution deletion of id {execution_id}\n" f"{json.dumps(response_dict, indent=4)}"
             )
+        return response_dict
+
+    # Polling
+    def _poll_orchestration_state(
+        self,
+        orchestration_type: str,
+        reservation_id: str,
+        polling_func: Callable,
+        max_polling_minutes: int,
+        polling_frequency_seconds: int,
+        log_level=logging.DEBUG,
+    ) -> model.SandboxDetails:
+        """ Create blocking polling process """
+
+        def poll_and_log(sb_details: model.SandboxDetails):
+            if self.logger:
+                polling_msg = f"Polling {orchestration_type} for sandbox '{sb_details.id}'..."
+                self.logger.log(log_level, polling_msg)
+            polling_func(sb_details)
+
+        @retry(
+            retry=retry_if_result(poll_and_log),
+            wait=wait_fixed(polling_frequency_seconds),
+            stop=stop_after_delay(max_polling_minutes * 60),
+        )
+        def retry_poll_sandbox_details():
+            return self.get_sandbox_details(reservation_id)
+
+        try:
+            sandbox_details = retry_poll_sandbox_details()
+        except RetryError:
+            raise exceptions.OrchestrationPollingTimeout(f"Sandbox Polling time out after {max_polling_minutes} minutes")
+        return sandbox_details
+
+    def poll_sandbox_setup(
+        self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level=logging.DEBUG
+    ) -> model.SandboxDetails:
+        """ poll setup until completion """
+        sandbox_details = self._poll_orchestration_state(
+            orchestration_type="Setup",
+            reservation_id=reservation_id,
+            polling_func=_should_keep_polling_setup,
+            max_polling_minutes=max_polling_minutes,
+            polling_frequency_seconds=polling_frequency_seconds,
+            log_level=log_level,
+        )
+        setup_state = sandbox_details.state
+        sandbox_id = sandbox_details.id
+        if setup_state == model.SandboxStates.ERROR:
+            error_activity = self.get_sandbox_activity(sandbox_id, error_only=True)
+            raise exceptions.SetupFailedException(
+                f"Sandbox setup failed - sandbox id: '{sandbox_id}'", error_events=error_activity.events
+            )
+        return sandbox_details
+
+    def poll_sandbox_teardown(
+        self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level=logging.DEBUG
+    ) -> model.SandboxDetails:
+        """ poll teardown until completion  """
+        latest_event_request = self.get_sandbox_activity(reservation_id, tail=1).events
+        latest_event_id = latest_event_request[0].id if latest_event_request else 0
+        sandbox_details = self._poll_orchestration_state(
+            orchestration_type="Teardown",
+            reservation_id=reservation_id,
+            polling_func=_should_keep_polling_teardown,
+            max_polling_minutes=max_polling_minutes,
+            polling_frequency_seconds=polling_frequency_seconds,
+            log_level=log_level,
+        )
+        teardown_error_events = self.get_sandbox_activity(
+            reservation_id, error_only=True, from_event_id=latest_event_id
+        ).events
+        if teardown_error_events:
+            raise exceptions.TeardownFailedException(
+                f"Failed teardown - sandbox id: '{reservation_id}'", error_events=teardown_error_events
+            )
+        return sandbox_details
+
+
+# Polling Helpers
+def _should_keep_polling_setup(sandbox_details: model.SandboxDetails) -> bool:
+    """ if still in setup keep polling """
+    post_setup_states = model.SandboxStates.get_post_setup_states()
+    if sandbox_details.state not in post_setup_states:
+        return True
+    return False
+
+
+def _should_keep_polling_teardown(sandbox_details: model.SandboxDetails) -> bool:
+    """ if in teardown keep polling """
+    if sandbox_details.state == model.SandboxStates.TEARDOWN:
+        return True
+    return False
+
+
+def _should_keep_polling_execution(exc_data: model.CommandExecutionDetails) -> bool:
+    unfinished_states = model.CommandExecutionStates.get_incomplete_execution_states()
+    if exc_data.status in unfinished_states:
+        return True
+    return False
diff --git a/src/cloudshell/sandbox_rest/async_commands.py b/src/cloudshell/sandbox_rest/async_commands.py
new file mode 100644
index 0000000..53e1673
--- /dev/null
+++ b/src/cloudshell/sandbox_rest/async_commands.py
@@ -0,0 +1,7 @@
+"""
+module to run sandbox actions in parallel using asyncio module
+"""
+
+
+class AsyncCommandExecutor:
+    pass
diff --git a/src/cloudshell/sandbox_rest/components.py b/src/cloudshell/sandbox_rest/components.py
index fc7ac48..c163830 100644
--- a/src/cloudshell/sandbox_rest/components.py
+++ b/src/cloudshell/sandbox_rest/components.py
@@ -1,3 +1,7 @@
 """
 Component helpers for filtering and sorting the sandbox components
-"""
\ No newline at end of file
+"""
+
+
+class SandboxComponents:
+    pass
diff --git a/src/cloudshell/sandbox_rest/exceptions.py b/src/cloudshell/sandbox_rest/exceptions.py
index 80d9db1..5995871 100644
--- a/src/cloudshell/sandbox_rest/exceptions.py
+++ b/src/cloudshell/sandbox_rest/exceptions.py
@@ -1,3 +1,8 @@
+from typing import List
+
+from cloudshell.sandbox_rest import model
+
+
 class SandboxRestException(Exception):
     """ Base Exception Class inside Rest client class """
 
@@ -14,8 +19,27 @@ class CommandPollingTimeout(SandboxRestException):
     pass
 
 
-class SetupFailedException(SandboxRestException):
-    pass
+class FailedOrchestrationException(SandboxRestException):
+    def __init__(self, message: str, error_events: List[model.SandboxEvent] = None):
+        self.message = message
+        self.error_events = error_events or []
+        super().__init__(message)
+
+    def events_to_json(self):
+        return model.models_to_json(self.error_events)
+
+    def __str__(self):
+        return f"{self.message}\n{self.events_to_json()}"
+
+
+class SetupFailedException(FailedOrchestrationException):
+    def __init__(self, message: str, error_events: List[model.SandboxEvent] = None):
+        super().__init__(message, error_events)
+
+
+class TeardownFailedException(SandboxRestException):
+    def __init__(self, message: str, error_events: List[model.SandboxEvent] = None):
+        super().__init__(message, error_events)
 
 
 class CommandExecutionFailed(SandboxRestException):
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 728ac5b..90e55bc 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -2,42 +2,58 @@
 All pydantic BaseModel class representations of Sandbox API responses
 """
 from __future__ import annotations
+
+import json
 from enum import Enum
 from typing import TYPE_CHECKING, List, Optional
 
 from pydantic import BaseModel, Field
 
+RESPONSE_DICT_KEY = "response_dict"
+
+
 # added for dev intellisense: https://stackoverflow.com/a/71257588
 if TYPE_CHECKING:
     from dataclasses import dataclass as _basemodel_decorator
 else:
+
     def _basemodel_decorator(func):
         return func
 
 
 class SandboxApiBaseModel(BaseModel):
+    """ Base Model for all other classes. Defines useful helper methods """
+
     class Config:
         use_enum_values = True
 
     response_dict: dict = None
-    """ the original dictionary received from api response """
+    """ this attribute will cache the original response before loading into model """
+
+    @classmethod
+    def dict_to_model(cls, response_dict: dict) -> SandboxApiBaseModel:
+        """ calls parse_obj, but additionally caches the response dict """
+        wrapped_item = cls.parse_obj(response_dict)
+        wrapped_item.response_dict = response_dict
+        return wrapped_item
+
+    @classmethod
+    def list_to_models(cls, response_list: List[dict]) -> List[SandboxApiBaseModel]:
+        return [cls.dict_to_model(dict_item) for dict_item in response_list]
 
     def pretty_json(self, indent=4, exclude_response=True):
         excluded_key_set = set()
         if exclude_response:
-            excluded_key_set.add("response_dict")
+            excluded_key_set.add(RESPONSE_DICT_KEY)
         return self.json(indent=indent, exclude=excluded_key_set)
 
 
-def list_to_model(response_list: List[dict], model: SandboxApiBaseModel) -> List[SandboxApiBaseModel]:
-    return [dict_to_model(dict_item, model) for dict_item in response_list]
-
-
-def dict_to_model(response_dict: dict, model: SandboxApiBaseModel) -> SandboxApiBaseModel:
-    """ calls parse_obj, but additionally caches the response dict """
-    wrapped_item = model.parse_obj(response_dict)
-    wrapped_item.response_dict = response_dict
-    return wrapped_item
+def models_to_json(model_list: List[SandboxApiBaseModel]) -> str:
+    """ helper method to be used to convert a list of models to a list of dicts and dump to json """
+    list_of_dicts = [x.dict() for x in model_list]
+    excluded_keys = [RESPONSE_DICT_KEY]
+    updated_list = [{k: v for k, v in curr_dict if k not in excluded_keys} for curr_dict in list_of_dicts]
+    return json.dumps(updated_list, indent=4)
 
 
 class BlueprintAvailabilityStates(str, Enum):
@@ -73,6 +89,7 @@ class SetupStages(str, Enum):
     PROVISIONING = "Provisioning"
     CONNECTIVITY = "Connectivity"
     CONFIGURATION = "Configuration"
+    ENDED = "Ended"
 
 
 class SandboxEventTypes(str, Enum):
@@ -94,6 +111,10 @@ class CommandExecutionStates(str, Enum):
     COMPLETE = "Complete"
     FAILED = "Failed"
 
+    @classmethod
+    def get_incomplete_execution_states(cls) -> List:
+        return [cls.PENDING, cls.RUNNING]
+
 
 @_basemodel_decorator
 class BlueprintInput(SandboxApiBaseModel):
@@ -142,6 +163,9 @@ class SandboxDetails(SandboxApiBaseModel):
     state: Optional[SandboxStates]
     setup_stage: Optional[SetupStages]
 
+    def components_to_json(self):
+        return models_to_json(self.components)
+
 
 @_basemodel_decorator
 class BlueprintReference(SandboxApiBaseModel):
@@ -175,6 +199,9 @@ class ActivityEventsResponse(SandboxApiBaseModel):
     next_event_id: Optional[int]
     events: Optional[List[SandboxEvent]]
 
+    def events_to_json(self) -> str:
+        return models_to_json(self.events)
+
 
 @_basemodel_decorator
 class CommandParameterDetails(SandboxApiBaseModel):
@@ -230,8 +257,9 @@ class CommandExecutionDetails(SandboxApiBaseModel):
     started: Optional[str]
     ended: Optional[str]
     output: Optional[str]
-    command_context: Optional[CommandContextDetails] = Field(description="additional data to populate about command. "
-                                                                         "NOTE: this does not come from api response")
+    command_context: Optional[CommandContextDetails] = Field(
+        description="additional data to populate about command. " "NOTE: this does not come from api response"
+    )
 
 
 @_basemodel_decorator
diff --git a/src/cloudshell/sandbox_rest/polling.py b/src/cloudshell/sandbox_rest/polling.py
deleted file mode 100644
index 1959e57..0000000
--- a/src/cloudshell/sandbox_rest/polling.py
+++ /dev/null
@@ -1,153 +0,0 @@
-"""
-Helper functions to poll the state of sandbox setup or the execution status of a command.
-Using 'tenacity' library to do polling:
-https://github.com/jd/tenacity
-"""
-import asyncio
-import logging
-from enum import Enum
-from time import sleep
-from typing import Callable, List
-
-from tenacity import RetryError, retry, retry_if_result, stop_after_delay, wait_fixed  # pip install tenacity
-
-import cloudshell.sandbox_rest.exceptions as exceptions
-from cloudshell.sandbox_rest.api import SandboxRestApiSession
-
-
-class SandboxStates(Enum):
-    BEFORE_SETUP = "BeforeSetup"
-    RUNNING_SETUP = "Setup"
-    ERROR = "Error"
-    READY = "Ready"
-    TEARDOWN = "Teardown"
-    ENDED = "Ended"
-
-
-class ExecutionStatuses(Enum):
-    RUNNING = "Running"
-    PENDING = "Pending"
-    COMPLETED = "Completed"
-    FAILED = "Failed"
-
-
-SANDBOX_SETUP_STATES = [SandboxStates.BEFORE_SETUP.value, SandboxStates.RUNNING_SETUP.value]
-SANDBOX_ACTIVE_STATES = [SandboxStates.READY.value, SandboxStates.ERROR.value]
-UNFINISHED_EXECUTION_STATUSES = [ExecutionStatuses.RUNNING.value, ExecutionStatuses.PENDING.value]
-
-
-def _should_keep_polling_setup(sandbox_details: dict) -> bool:
-    """ if still in setup keep polling """
-    setup_states = [SandboxStates.BEFORE_SETUP.value, SandboxStates.RUNNING_SETUP.value]
-    if sandbox_details["state"] in setup_states:
-        return True
-    return False
-
-
-def _should_keep_polling_teardown(sandbox_details: dict) -> bool:
-    """ if in teardown keep polling """
-    if sandbox_details["state"] == SandboxStates.TEARDOWN.value:
-        return True
-    return False
-
-
-def _should_keep_polling_execution(exc_data: dict) -> bool:
-    current_exc_status = exc_data["status"]
-    if current_exc_status in UNFINISHED_EXECUTION_STATUSES:
-        return True
-    return False
-
-
-def log_orchestration_polling(orchestration_type: str, sandbox_id: str, logger: logging.Logger = None):
-    if logger:
-        polling_msg = f"Polling {orchestration_type} for sandbox '{sandbox_id}'..."
-        logger.info(polling_msg)
-
-
-def _poll_orchestration_state(
-    orchestration_type: str,
-    api: SandboxRestApiSession,
-    reservation_id: str,
-    polling_func: Callable,
-    max_polling_minutes: int,
-    polling_frequency_seconds: int,
-    logger: logging.Logger = None,
-) -> str:
-    """ Create blocking polling process """
-
-    # retry wait times are in milliseconds
-    @retry(
-        retry=retry_if_result(polling_func),
-        wait=wait_fixed(polling_frequency_seconds * 1000),
-        stop=stop_after_delay(max_polling_minutes * 60000),
-        before=log_orchestration_polling(orchestration_type, reservation_id, logger),
-    )
-    def get_sandbox_details():
-        return api.get_sandbox_details(reservation_id)
-
-    try:
-        sandbox_details = get_sandbox_details()
-    except RetryError:
-        raise exceptions.OrchestrationPollingTimeout(
-            f"Sandbox Polling timed out after configured {max_polling_minutes} minutes"
-        )
-    return sandbox_details
-
-
-def poll_sandbox_setup(
-    api: SandboxRestApiSession,
-    reservation_id: str,
-    max_polling_minutes=20,
-    polling_frequency_seconds=30,
-    logger: logging.Logger = None,
-) -> dict:
-    """ poll until completion """
-    sandbox_details = _poll_orchestration_state(
-        "Setup", api, reservation_id, _should_keep_polling_setup, max_polling_minutes, polling_frequency_seconds, logger
-    )
-    setup_state = sandbox_details["state"]
-    sandbox_id = sandbox_details["id"]
-    if setup_state == SandboxStates.ERROR.value:
-        raise exceptions.SetupFailedException(f"Sandbox setup failed for sandbox '{sandbox_id}'")
-    return sandbox_details
-
-
-def poll_sandbox_teardown(
-    api: SandboxRestApiSession,
-    reservation_id: str,
-    max_polling_minutes=20,
-    polling_frequency_seconds=30,
-    logger: logging.Logger = None,
-) -> dict:
-    """ poll until completion  """
-    sandbox_details = _poll_orchestration_state(
-        "Teardown", api, reservation_id, _should_keep_polling_teardown, max_polling_minutes, polling_frequency_seconds, logger
-    )
-    return sandbox_details
-
-
-def poll_execution(
-    sandbox_rest: SandboxRestApiSession, command_execution_id: str, max_polling_in_minutes=20, polling_frequency_in_seconds=30
-) -> str:
-    """
-    poll execution for "Completed" status, then return the execution output
-    """
-
-    @retry(
-        retry=retry_if_result(_should_keep_polling_execution),
-        wait=wait_fixed(polling_frequency_in_seconds),
-        stop=stop_after_delay(max_polling_in_minutes * 60),
-    )
-    def get_execution_response():
-        response = sandbox_rest.get_execution_details(command_execution_id)
-        return response
-
-    try:
-        execution_response = get_execution_response(sandbox_rest, command_execution_id)
-    except RetryError:
-        raise exceptions.CommandPollingTimeout(f"Execution polling timed out after max {max_polling_in_minutes} minutes")
-    execution_status = execution_response["status"]
-    execution_id = execution_response["id"]
-    if execution_status == ExecutionStatuses.FAILED.value:
-        raise exceptions.CommandExecutionFailed(f"Execution '{execution_id}' ended with failed result")
-    return execution_response["output"]
diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
index 984b909..4daa547 100644
--- a/src/cloudshell/sandbox_rest/sandbox_context.py
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -1,7 +1,20 @@
 """
-Sandbox context manager to manage the lifecyle of setup and teardown
+Sandbox controller context manager to manage the lifecyle of setup and teardown
 """
 
 
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
+
+
 class SandboxControllerContext:
-    pass
+    def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None):
+        self.api = api
+        self.sandbox_id = sandbox_id
+
+    def __enter__(self):
+        """ start sandbox """
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        """ end sandbox """
+        return self

From 4532416d8ad69855e2b928c84127f18eedc677c0 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Fri, 8 Apr 2022 04:49:09 +0300
Subject: [PATCH 07/11] orchestration polling implemented

---
 .../setup_with_sleep/__main__.py              |  24 ++
 .../setup_with_sleep/requirements.txt         |   1 +
 .../setup_with_sleep/version.txt              |   1 +
 .../teardown_with_sleep/__main__.py           |  24 ++
 .../teardown_with_sleep/requirements.txt      |   1 +
 .../teardown_with_sleep/version.txt           |   1 +
 pytest.ini                                    |   3 +
 src/cloudshell/sandbox_rest/api.py            | 325 +++++++++++-------
 src/cloudshell/sandbox_rest/default_logger.py |  29 ++
 src/cloudshell/sandbox_rest/model.py          |  38 +-
 tests/common.py                               |   1 +
 tests/conftest.py                             |  22 +-
 tests/constants.py                            |   1 +
 tests/test_api_polling.py                     |  70 ++++
 14 files changed, 403 insertions(+), 138 deletions(-)
 create mode 100644 assets/polling-orchestrations/setup_with_sleep/__main__.py
 create mode 100644 assets/polling-orchestrations/setup_with_sleep/requirements.txt
 create mode 100644 assets/polling-orchestrations/setup_with_sleep/version.txt
 create mode 100644 assets/polling-orchestrations/teardown_with_sleep/__main__.py
 create mode 100644 assets/polling-orchestrations/teardown_with_sleep/requirements.txt
 create mode 100644 assets/polling-orchestrations/teardown_with_sleep/version.txt
 create mode 100644 pytest.ini
 create mode 100644 src/cloudshell/sandbox_rest/default_logger.py
 create mode 100644 tests/test_api_polling.py

diff --git a/assets/polling-orchestrations/setup_with_sleep/__main__.py b/assets/polling-orchestrations/setup_with_sleep/__main__.py
new file mode 100644
index 0000000..e4a4baf
--- /dev/null
+++ b/assets/polling-orchestrations/setup_with_sleep/__main__.py
@@ -0,0 +1,24 @@
+from cloudshell.workflow.orchestration.sandbox import Sandbox
+from cloudshell.workflow.orchestration.setup.default_setup_orchestrator import DefaultSetupWorkflow
+import time
+
+
+SLEEP_SECONDS = 30
+
+
+def fake_config(sandbox, components=None):
+    """
+    pretend to configure but then go to sleep
+    :param Sandbox sandbox:
+    :param components:
+    :return:
+    """
+    sandbox.automation_api.WriteMessageToReservationOutput(sandbox.id, f"starting fake config for {SLEEP_SECONDS} seconds")
+    time.sleep(SLEEP_SECONDS)
+
+
+sandbox = Sandbox()
+
+DefaultSetupWorkflow().register(sandbox)
+sandbox.workflow.add_to_configuration(fake_config)
+sandbox.execute_setup()
diff --git a/assets/polling-orchestrations/setup_with_sleep/requirements.txt b/assets/polling-orchestrations/setup_with_sleep/requirements.txt
new file mode 100644
index 0000000..9b4a25e
--- /dev/null
+++ b/assets/polling-orchestrations/setup_with_sleep/requirements.txt
@@ -0,0 +1 @@
+cloudshell-orch-core>=4.0.0.0,<4.1.0.0
\ No newline at end of file
diff --git a/assets/polling-orchestrations/setup_with_sleep/version.txt b/assets/polling-orchestrations/setup_with_sleep/version.txt
new file mode 100644
index 0000000..eb85010
--- /dev/null
+++ b/assets/polling-orchestrations/setup_with_sleep/version.txt
@@ -0,0 +1 @@
+4.0.0.250 
diff --git a/assets/polling-orchestrations/teardown_with_sleep/__main__.py b/assets/polling-orchestrations/teardown_with_sleep/__main__.py
new file mode 100644
index 0000000..3b3d05b
--- /dev/null
+++ b/assets/polling-orchestrations/teardown_with_sleep/__main__.py
@@ -0,0 +1,24 @@
+from cloudshell.workflow.orchestration.sandbox import Sandbox
+from cloudshell.workflow.orchestration.teardown.default_teardown_orchestrator import DefaultTeardownWorkflow
+
+SLEEP_SECONDS = 30
+import time
+
+
+def fake_config(sandbox, components=None):
+    """
+    pretend to configure but then go to sleep
+    :param Sandbox sandbox:
+    :param components:
+    :return:
+    """
+    sandbox.automation_api.WriteMessageToReservationOutput(sandbox.id, f"starting fake config for {SLEEP_SECONDS} seconds")
+    time.sleep(SLEEP_SECONDS)
+
+
+sandbox = Sandbox()
+
+DefaultTeardownWorkflow().register(sandbox)
+sandbox.workflow.before_teardown_started(fake_config)
+
+sandbox.execute_teardown()
diff --git a/assets/polling-orchestrations/teardown_with_sleep/requirements.txt b/assets/polling-orchestrations/teardown_with_sleep/requirements.txt
new file mode 100644
index 0000000..9b4a25e
--- /dev/null
+++ b/assets/polling-orchestrations/teardown_with_sleep/requirements.txt
@@ -0,0 +1 @@
+cloudshell-orch-core>=4.0.0.0,<4.1.0.0
\ No newline at end of file
diff --git a/assets/polling-orchestrations/teardown_with_sleep/version.txt b/assets/polling-orchestrations/teardown_with_sleep/version.txt
new file mode 100644
index 0000000..eb85010
--- /dev/null
+++ b/assets/polling-orchestrations/teardown_with_sleep/version.txt
@@ -0,0 +1 @@
+4.0.0.250 
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..0f517bc
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+log_cli = true
+log_cli_level = 10
\ No newline at end of file
diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index 94ddba8..ebe53b1 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -13,6 +13,9 @@
 from tenacity import RetryError, retry, retry_if_result, stop_after_delay, wait_fixed
 
 from cloudshell.sandbox_rest import exceptions, model
+from cloudshell.sandbox_rest.default_logger import set_up_default_logger
+from pydantic.main import ValidationError
+
 
 
 @dataclass
@@ -28,29 +31,30 @@ class CommandInputParam:
 
 class SandboxRestApiSession(RequestsClient):
     """
-    Python wrapper for CloudShell Sandbox API
-    View http://<API_SERVER>/api/v2/explore to see schemas of return json values
+    Python client for CloudShell Sandbox REST api
+    View swagger UI at http://<API_SERVER>/api/v2/explore for raw JSON response schema
     """
 
     def __init__(
-        self,
-        host: str,
-        username="",
-        password="",
-        domain="Global",
-        token="",
-        port=82,
-        logger: logging.Logger = None,
-        use_https=False,
-        ssl_verify=False,
-        proxies: dict = None,
-        show_insecure_warning=False,
+            self,
+            host: str,
+            username="",
+            password="",
+            domain="Global",
+            token="",
+            port=82,
+            logger: logging.Logger = None,
+            use_https=False,
+            ssl_verify=False,
+            proxies: dict = None,
+            show_insecure_warning=False
     ):
         """ Login to api and store headers for future requests """
+        self.logger = logger or set_up_default_logger()
         super().__init__(host, username, password, token, logger, port, use_https, ssl_verify, proxies, show_insecure_warning)
+        self.domain = domain
         self._base_uri = "/api"
         self._v2_base_uri = f"{self._base_uri}/v2"
-        self.domain = domain
         self.login()
 
     def login(self, user="", password="", token="", domain="") -> None:
@@ -87,7 +91,8 @@ def _get_token_with_credentials(self, user_name: str, password: str, domain: str
 
         login_token = response.text[1:-1]
         if not login_token:
-            raise exceptions.SandboxRestAuthException(f"Invalid token. Token response {response.text}")
+            err_msg = f"Invalid token. Token response {response.text}"
+            raise exceptions.SandboxRestAuthException(err_msg)
 
         return login_token
 
@@ -136,16 +141,16 @@ def get_blueprint_details(self, blueprint_id: str) -> model.BlueprintDescription
 
     # SANDBOX POST REQUESTS
     def _start_sandbox(
-        self,
-        blueprint_id: str,
-        sandbox_name: str,
-        duration: str = None,
-        bp_params: List[CommandInputParam] = None,
-        permitted_users: List[str] = None,
-        polling_setup: bool = False,
-        max_polling_minutes: int = 20,
-        polling_frequency_seconds: int = 30,
-        polling_log_level: str = logging.DEBUG,
+            self,
+            blueprint_id: str,
+            sandbox_name: str,
+            duration: str = None,
+            bp_params: List[CommandInputParam] = None,
+            permitted_users: List[str] = None,
+            polling_setup: bool = False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 30,
+            polling_log_level: int = logging.DEBUG,
     ) -> model.SandboxDetails:
         """ internal implementation request to handle both regular and persistent sandbox requests """
         self._validate_auth_header()
@@ -174,67 +179,71 @@ def _start_sandbox(
         return sandbox_details
 
     def start_sandbox(
-        self,
-        blueprint_id: str,
-        sandbox_name: str = "",
-        duration: str = "PT2H0M",
-        bp_params: List[CommandInputParam] = None,
-        permitted_users: List[str] = None,
-        polling_setup: bool = False,
-        max_polling_minutes: int = 20,
-        polling_frequency_seconds: int = 30,
-        polling_log_level: str = logging.DEBUG,
+            self,
+            blueprint_id: str,
+            sandbox_name: str = "",
+            duration: str = "PT2H0M",
+            bp_params: List[CommandInputParam] = None,
+            permitted_users: List[str] = None,
+            polling_setup: bool = False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 30,
+            polling_log_level: int = logging.DEBUG,
     ) -> model.SandboxDetails:
         """
         Create a sandbox from the provided blueprint id
         Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
         """
         return self._start_sandbox(
-            blueprint_id,
-            sandbox_name,
-            duration,
-            bp_params,
-            permitted_users,
-            polling_setup,
-            polling_log_level,
-            max_polling_minutes,
-            polling_frequency_seconds,
+            blueprint_id=blueprint_id,
+            sandbox_name=sandbox_name,
+            duration=duration,
+            bp_params=bp_params,
+            permitted_users=permitted_users,
+            polling_setup=polling_setup,
+            max_polling_minutes=max_polling_minutes,
+            polling_frequency_seconds=polling_frequency_seconds,
+            polling_log_level=polling_log_level
         )
 
     def start_persistent_sandbox(
-        self,
-        blueprint_id: str,
-        sandbox_name: str = "",
-        bp_params: List[CommandInputParam] = None,
-        permitted_users: List[str] = None,
-        polling_setup: bool = False,
-        max_polling_minutes: int = 20,
-        polling_frequency_seconds: int = 30,
-        polling_log_level: str = logging.DEBUG,
+            self,
+            blueprint_id: str,
+            sandbox_name: str = "",
+            bp_params: List[CommandInputParam] = None,
+            permitted_users: List[str] = None,
+            polling_setup: bool = False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 30,
+            polling_log_level: int = logging.DEBUG,
     ) -> model.SandboxDetails:
         """
         Create a PERSISTENT sandbox from the provided blueprint id
         Duration format must be a valid 'ISO 8601'. (e.g 'PT23H' or 'PT4H2M')
         """
         return self._start_sandbox(
-            blueprint_id,
-            sandbox_name,
-            None,
-            bp_params,
-            permitted_users,
-            polling_setup,
-            polling_log_level,
-            max_polling_minutes,
-            polling_frequency_seconds,
+            blueprint_id=blueprint_id,
+            sandbox_name=sandbox_name,
+            duration=None,
+            bp_params=bp_params,
+            permitted_users=permitted_users,
+            polling_setup=polling_setup,
+            max_polling_minutes=max_polling_minutes,
+            polling_frequency_seconds=polling_frequency_seconds,
+            polling_log_level=polling_log_level
         )
 
     def run_sandbox_command(
-        self,
-        sandbox_id: str,
-        command_name: str,
-        params: List[CommandInputParam] = None,
-        print_output=True,
-    ) -> model.CommandStartResponse:
+            self,
+            sandbox_id: str,
+            command_name: str,
+            params: List[CommandInputParam] = None,
+            print_output=True,
+            polling_execution: bool = False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 10,
+            polling_log_level: int = logging.DEBUG
+    ) -> model.EnvironmentCommandExecutionDetails:
         """Run a sandbox level command"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands/{command_name}/start"
@@ -242,24 +251,69 @@ def run_sandbox_command(
         params = [asdict(x) for x in params] if params else []
         data["params"] = params
         response_dict = self.rest_service.request_post(uri, data).json()
-        return model.CommandStartResponse.dict_to_model(response_dict)
+        start_response = model.CommandStartResponse.dict_to_model(response_dict)
+        model_wrapped_command_params = [model.CommandParameterNameValue(x.name, x.value) for x in params] if params else []
+        command_context = model.CommandContextDetails(sandbox_id=sandbox_id,
+                                                      command_name=command_name,
+                                                      command_params=model_wrapped_command_params)
+        if polling_execution:
+            execution_details = self.poll_command_execution(execution_id=start_response.executionId,
+                                                            max_polling_minutes=max_polling_minutes,
+                                                            polling_frequency_seconds=polling_frequency_seconds,
+                                                            log_level=polling_log_level)
+        else:
+            execution_details = self.get_execution_details(start_response.executionId)
+
+        return model.EnvironmentCommandExecutionDetails(id=start_response.executionId,
+                                                        status=execution_details.status,
+                                                        supports_cancellation=execution_details.supports_cancellation,
+                                                        started=execution_details.started,
+                                                        ended=execution_details.ended,
+                                                        output=execution_details.output,
+                                                        command_context=command_context)
 
     def run_component_command(
-        self,
-        sandbox_id: str,
-        component_id: str,
-        command_name: str,
-        params: List[CommandInputParam] = None,
-        print_output: bool = True,
-    ) -> model.CommandStartResponse:
+            self,
+            sandbox_id: str,
+            component_id: str,
+            command_name: str,
+            params: List[CommandInputParam] = None,
+            print_output: bool = True,
+            polling_execution: bool = False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 10,
+            polling_log_level: int = logging.DEBUG
+    ) -> model.ResourceCommandExecutionDetails:
         """Start a command on sandbox component"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands/{command_name}/start"
         data = {"printOutput": print_output}
-        params = [asdict(x) for x in params] if params else []
-        data["params"] = params
+        params_dicts = [asdict(x) for x in params] if params else []
+        data["params"] = params_dicts
         response_dict = self.rest_service.request_post(uri, data).json()
-        return model.CommandStartResponse.dict_to_model(response_dict)
+        start_response = model.CommandStartResponse.dict_to_model(response_dict)
+        component_details = self.get_sandbox_component_details(sandbox_id, component_id)
+        model_wrapped_command_params = [model.CommandParameterNameValue(x.name, x.value) for x in params] if params else []
+        command_context = model.ResourceCommandContextDetails(sandbox_id=sandbox_id,
+                                                              command_name=command_name,
+                                                              command_params=model_wrapped_command_params,
+                                                              component_name=component_details.name,
+                                                              component_id=component_details.id)
+        if polling_execution:
+            execution_details = self.poll_command_execution(execution_id=start_response.executionId,
+                                                            max_polling_minutes=max_polling_minutes,
+                                                            polling_frequency_seconds=polling_frequency_seconds,
+                                                            log_level=polling_log_level)
+        else:
+            execution_details = self.get_execution_details(start_response.executionId)
+
+        return model.ResourceCommandExecutionDetails(id=start_response.executionId,
+                                                     status=execution_details.status,
+                                                     supports_cancellation=execution_details.supports_cancellation,
+                                                     started=execution_details.started,
+                                                     ended=execution_details.ended,
+                                                     output=execution_details.output,
+                                                     command_context=command_context)
 
     def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse:
         """Extend the sandbox
@@ -274,13 +328,13 @@ def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse
         return model.ExtendResponse.dict_to_model(response_dict)
 
     def stop_sandbox(
-        self,
-        sandbox_id: str,
-        poll_teardown=False,
-        max_polling_minutes: int = 20,
-        polling_frequency_seconds: int = 30,
-        polling_log_level: str = logging.DEBUG,
-    ) -> model.StopSandboxResponse:
+            self,
+            sandbox_id: str,
+            poll_teardown=False,
+            max_polling_minutes: int = 20,
+            polling_frequency_seconds: int = 30,
+            polling_log_level: str = logging.DEBUG,
+    ) -> model.SandboxDetails:
         """Stop the sandbox given sandbox id"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/stop"
@@ -294,7 +348,7 @@ def stop_sandbox(
             return self.poll_sandbox_teardown(sandbox_id, max_polling_minutes, polling_frequency_seconds, polling_log_level)
 
         # if not polling, give teardown request chance to propagate before getting status
-        time.sleep(5)
+        time.sleep(3)
         return self.get_sandbox_details(sandbox_id)
 
     # SANDBOX GET REQUESTS
@@ -314,12 +368,12 @@ def get_sandbox_details(self, sandbox_id: str) -> model.SandboxDetails:
         return model.SandboxDetails.dict_to_model(response_dict)
 
     def get_sandbox_activity(
-        self,
-        sandbox_id: str,
-        error_only=False,
-        since="",
-        from_event_id: int = None,
-        tail: int = None,
+            self,
+            sandbox_id: str,
+            error_only=False,
+            since="",
+            from_event_id: int = None,
+            tail: int = None,
     ) -> model.ActivityEventsResponse:
         """
         Get list of sandbox activity
@@ -380,7 +434,7 @@ def get_sandbox_component_commands(self, sandbox_id: str, component_id: str) ->
         return model.Command.list_to_models(response_list)
 
     def get_sandbox_component_command_details(
-        self, sandbox_id: str, component_id: str, command: str
+            self, sandbox_id: str, component_id: str, command: str
     ) -> model.CommandExecutionDetails:
         """Get details of a command of sandbox component"""
         self._validate_auth_header()
@@ -395,11 +449,11 @@ def get_sandbox_instructions(self, sandbox_id: str) -> str:
         return self.rest_service.request_get(uri).text
 
     def get_sandbox_output(
-        self,
-        sandbox_id: str,
-        tail: int = None,
-        from_entry_id: int = None,
-        since: str = None,
+            self,
+            sandbox_id: str,
+            tail: int = None,
+            from_entry_id: int = None,
+            since: str = None,
     ) -> model.SandboxOutput:
         """Get list of sandbox output"""
         self._validate_auth_header()
@@ -420,7 +474,14 @@ def get_execution_details(self, execution_id: str) -> model.CommandExecutionDeta
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/executions/{execution_id}"
         details_dict = self.rest_service.request_get(uri).json()
-        return model.CommandExecutionDetails.dict_to_model(details_dict)
+        try:
+            execution_details = model.CommandExecutionDetails.dict_to_model(details_dict)
+        except ValidationError as e:
+            err_msg = f"Validation error on execution '{execution_id}. Raw JSON:\n{json.dumps(details_dict, indent=4)}"
+            if self.logger:
+                self.logger.error(err_msg)
+            raise
+        return execution_details
 
     def delete_execution(self, execution_id: str) -> dict:
         """
@@ -438,21 +499,21 @@ def delete_execution(self, execution_id: str) -> dict:
 
     # Polling
     def _poll_orchestration_state(
-        self,
-        orchestration_type: str,
-        reservation_id: str,
-        polling_func: Callable,
-        max_polling_minutes: int,
-        polling_frequency_seconds: int,
-        log_level=logging.DEBUG,
+            self,
+            orchestration_type: str,
+            reservation_id: str,
+            polling_func: Callable,
+            max_polling_minutes: int,
+            polling_frequency_seconds: int,
+            log_level=logging.DEBUG,
     ) -> model.SandboxDetails:
         """ Create blocking polling process """
 
         def poll_and_log(sb_details: model.SandboxDetails):
             if self.logger:
-                polling_msg = f"Polling {orchestration_type} for sandbox '{sb_details.id}'..."
+                polling_msg = f"Polling {orchestration_type} for sandbox '{sb_details.id}'. State: {sb_details.state}..."
                 self.logger.log(log_level, polling_msg)
-            polling_func(sb_details)
+            return polling_func(sb_details)
 
         @retry(
             retry=retry_if_result(poll_and_log),
@@ -465,15 +526,15 @@ def retry_poll_sandbox_details():
         try:
             sandbox_details = retry_poll_sandbox_details()
         except RetryError:
-            raise exceptions.OrchestrationPollingTimeout(f"Sandbox Polling time out after {max_polling_minutes} minutes")
+            raise exceptions.OrchestrationPollingTimeout(f"Sandbox Polling timed out after {max_polling_minutes} minutes")
         return sandbox_details
 
     def poll_sandbox_setup(
-        self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level=logging.DEBUG
+            self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level: int = logging.DEBUG
     ) -> model.SandboxDetails:
         """ poll setup until completion """
         sandbox_details = self._poll_orchestration_state(
-            orchestration_type="Setup",
+            orchestration_type="SETUP",
             reservation_id=reservation_id,
             polling_func=_should_keep_polling_setup,
             max_polling_minutes=max_polling_minutes,
@@ -490,13 +551,13 @@ def poll_sandbox_setup(
         return sandbox_details
 
     def poll_sandbox_teardown(
-        self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level=logging.DEBUG
+            self, reservation_id: str, max_polling_minutes=20, polling_frequency_seconds=30, log_level: int = logging.DEBUG
     ) -> model.SandboxDetails:
         """ poll teardown until completion  """
         latest_event_request = self.get_sandbox_activity(reservation_id, tail=1).events
         latest_event_id = latest_event_request[0].id if latest_event_request else 0
         sandbox_details = self._poll_orchestration_state(
-            orchestration_type="Teardown",
+            orchestration_type="TEARDOWN",
             reservation_id=reservation_id,
             polling_func=_should_keep_polling_teardown,
             max_polling_minutes=max_polling_minutes,
@@ -512,6 +573,36 @@ def poll_sandbox_teardown(
             )
         return sandbox_details
 
+    def poll_command_execution(self,
+                               execution_id: str,
+                               max_polling_minutes: int,
+                               polling_frequency_seconds: int,
+                               log_level=logging.DEBUG,
+                               ) -> model.CommandExecutionDetails:
+        """ Create blocking polling process """
+
+        def poll_and_log(execution_data: model.CommandExecutionDetails) -> bool:
+            if self.logger:
+                polling_msg = f"Polling execution '{execution_data.id}'.  Status: '{execution_data.status}'..."
+                self.logger.log(log_level, polling_msg)
+            return _should_keep_polling_execution(execution_data)
+
+        @retry(
+            retry=retry_if_result(poll_and_log),
+            wait=wait_fixed(polling_frequency_seconds),
+            stop=stop_after_delay(max_polling_minutes * 60),
+        )
+        def retry_poll_execution():
+            return self.get_execution_details(execution_id)
+
+        pre_poll_execution_details = self.get_execution_details(execution_id)
+        try:
+            execution_details = retry_poll_execution()
+        except RetryError:
+            err_msg = f"Execution '{pre_poll_execution_details.id}' timed out after {max_polling_minutes} minutes"
+            raise exceptions.CommandPollingTimeout(err_msg)
+        return execution_details
+
 
 # Polling Helpers
 def _should_keep_polling_setup(sandbox_details: model.SandboxDetails) -> bool:
@@ -529,8 +620,8 @@ def _should_keep_polling_teardown(sandbox_details: model.SandboxDetails) -> bool
     return False
 
 
-def _should_keep_polling_execution(exc_data: model.CommandExecutionDetails) -> bool:
+def _should_keep_polling_execution(execution_data: model.CommandExecutionDetails) -> bool:
     unfinished_states = model.CommandExecutionStates.get_incomplete_execution_states()
-    if exc_data.status in unfinished_states:
+    if execution_data.status in unfinished_states:
         return True
     return False
diff --git a/src/cloudshell/sandbox_rest/default_logger.py b/src/cloudshell/sandbox_rest/default_logger.py
new file mode 100644
index 0000000..59049ec
--- /dev/null
+++ b/src/cloudshell/sandbox_rest/default_logger.py
@@ -0,0 +1,29 @@
+import logging
+
+
+DEFAULT_LOGGER_NAME = "Sandbox Rest Logger"
+DEFAULT_FORMAT = '%(asctime)s:%(filename)s:%(lineno)d %(message)s'
+
+
+def set_up_default_logger(logger_name=DEFAULT_LOGGER_NAME,
+                          log_level=logging.INFO,
+                          log_to_file=False,
+                          log_file_path=".",
+                          log_format=DEFAULT_FORMAT):
+    logger = logging.getLogger(logger_name)
+    logger.setLevel(log_level)
+
+    formatter = logging.Formatter(log_format)
+
+    stream_handler = logging.StreamHandler()
+    stream_handler.setLevel(logging.INFO)
+    stream_handler.setFormatter(formatter)
+    logger.addHandler(stream_handler)
+
+    if log_to_file:
+        file_handler = logging.FileHandler(log_file_path)
+        file_handler.setLevel(logging.INFO)
+        file_handler.setFormatter(formatter)
+        logger.addHandler(file_handler)
+
+    return logger
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 90e55bc..8cadfe4 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -11,7 +11,6 @@
 
 RESPONSE_DICT_KEY = "response_dict"
 
-
 # added for dev intellisense: https://stackoverflow.com/a/71257588
 if TYPE_CHECKING:
     from dataclasses import dataclass as _basemodel_decorator
@@ -108,7 +107,7 @@ class CommandExecutionStates(str, Enum):
     RUNNING = "Running"
     STOPPING = "Stopping"
     CANCELLED = "Cancelled"
-    COMPLETE = "Complete"
+    COMPLETE = "Completed"
     FAILED = "Failed"
 
     @classmethod
@@ -240,15 +239,6 @@ class CommandStartResponse(SandboxApiBaseModel):
     supports_cancellation: Optional[bool]
 
 
-@_basemodel_decorator
-class CommandContextDetails(SandboxApiBaseModel):
-    sandbox_id: Optional[str]
-    component_name: Optional[str]
-    component_id: Optional[str]
-    command_name: Optional[str]
-    command_params: Optional[List[CommandParameterNameValue]]
-
-
 @_basemodel_decorator
 class CommandExecutionDetails(SandboxApiBaseModel):
     id: Optional[str]
@@ -257,9 +247,29 @@ class CommandExecutionDetails(SandboxApiBaseModel):
     started: Optional[str]
     ended: Optional[str]
     output: Optional[str]
-    command_context: Optional[CommandContextDetails] = Field(
-        description="additional data to populate about command. " "NOTE: this does not come from api response"
-    )
+
+
+@_basemodel_decorator
+class CommandContextDetails(SandboxApiBaseModel):
+    sandbox_id: Optional[str]
+    command_name: Optional[str]
+    command_params: Optional[List[CommandParameterNameValue]]
+
+
+@_basemodel_decorator
+class ResourceCommandContextDetails(CommandContextDetails):
+    component_name: Optional[str]
+    component_id: Optional[str]
+
+
+@_basemodel_decorator
+class EnvironmentCommandExecutionDetails(CommandExecutionDetails):
+    command_context: Optional[CommandContextDetails]
+
+
+@_basemodel_decorator
+class ResourceCommandExecutionDetails(CommandExecutionDetails):
+    command_context: Optional[ResourceCommandContextDetails]
 
 
 @_basemodel_decorator
diff --git a/tests/common.py b/tests/common.py
index 1fa53aa..4556e01 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -24,3 +24,4 @@ def fixed_sleep():
 def get_blueprint_id_from_name(api: SandboxRestApiSession, bp_name: str) -> str:
     res = api.get_blueprint_details(bp_name)
     return res.id
+
diff --git a/tests/conftest.py b/tests/conftest.py
index b5c892c..42fbd2f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,17 +1,18 @@
 import time
 
 import pytest
-from constants import *
-from env_settings import *
+import constants
+import env_settings
 
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
 
 
 @pytest.fixture(scope="session")
 def admin_session() -> SandboxRestApiSession:
-    admin_api = SandboxRestApiSession(
-        host=CLOUDSHELL_SERVER, username=CLOUDSHELL_ADMIN_USER, password=CLOUDSHELL_ADMIN_PASSWORD, domain=CLOUDSHELL_DOMAIN
-    )
+    admin_api = SandboxRestApiSession(host=env_settings.CLOUDSHELL_SERVER,
+                                      username=env_settings.CLOUDSHELL_ADMIN_USER,
+                                      password=env_settings.CLOUDSHELL_ADMIN_PASSWORD,
+                                      domain=env_settings.CLOUDSHELL_DOMAIN)
     print(f"Admin session started. Token: {admin_api.token}")
     with admin_api:
         yield admin_api
@@ -23,9 +24,16 @@ def admin_session() -> SandboxRestApiSession:
 
 @pytest.fixture(scope="session")
 def empty_blueprint():
-    return DEFAULT_EMPTY_BLUEPRINT
+    return constants.DEFAULT_EMPTY_BLUEPRINT
 
 
 @pytest.fixture(scope="session")
 def dut_blueprint():
-    return DUT_BLUEPRINT
+    return constants.DUT_BLUEPRINT
+
+
+@pytest.fixture(scope="session")
+def sleep_orch_blueprint():
+    return constants.SLEEP_ORCH_BLUEPRINT
+
+
diff --git a/tests/constants.py b/tests/constants.py
index 3d37424..2784ec9 100644
--- a/tests/constants.py
+++ b/tests/constants.py
@@ -4,6 +4,7 @@
 # this blueprint has a Putshell Resource in it
 # https://community.quali.com/repos/3318/put-shell-mock
 DUT_BLUEPRINT = "DUT Blueprint Test"
+SLEEP_ORCH_BLUEPRINT = "DUT Blueprint Sleep Orch"
 
 # DUT blueprint constants
 DUT_MODEL = "Putshell"
diff --git a/tests/test_api_polling.py b/tests/test_api_polling.py
new file mode 100644
index 0000000..903f661
--- /dev/null
+++ b/tests/test_api_polling.py
@@ -0,0 +1,70 @@
+"""
+Test the api methods against blueprint with a resource containing a command.
+
+- Putshell mock can be used - https://community.quali.com/repos/3318/put-shell-mock
+- DUT model / command can be referenced in constants.py (Putshell / health_check)
+- Assumed that only one DUT is in blueprint
+"""
+import logging
+
+import common
+import constants
+import pytest
+
+from cloudshell.sandbox_rest import model
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
+from timeit import default_timer
+
+
+@pytest.fixture(scope="module")
+def sandbox_id(admin_session: SandboxRestApiSession, sleep_orch_blueprint):
+    # start sandbox
+    start = default_timer()
+    start_res = admin_session.start_sandbox(blueprint_id=sleep_orch_blueprint,
+                                            sandbox_name="Pytest POLLING blueprint test",
+                                            polling_setup=True,
+                                            max_polling_minutes=5,
+                                            polling_frequency_seconds=10,
+                                            polling_log_level=logging.INFO)
+    sandbox_id = start_res.id
+    print(f"Sandbox started after {default_timer() - start:.2f} seconds. Sandbox id: {sandbox_id}. State: {start_res.state}")
+    yield sandbox_id
+    common.fixed_sleep()
+
+    print("cleaning up sandbox")
+    # stop sandbox
+    stop_timer = default_timer()
+    stop_res = admin_session.stop_sandbox(sandbox_id,
+                                          poll_teardown=True,
+                                          max_polling_minutes=5,
+                                          polling_frequency_seconds=10,
+                                          polling_log_level=logging.INFO)
+    print(f"\nSandbox ended after {default_timer() - stop_timer:.2f} seconds. ID: {sandbox_id}. State: {stop_res.state}")
+
+
+@pytest.fixture(scope="module")
+def component_id(admin_session: SandboxRestApiSession, sandbox_id: str):
+    components = admin_session.get_sandbox_components(sandbox_id)
+    common.fixed_sleep()
+    component_filter = [x for x in components if x.component_type == constants.DUT_MODEL]
+    assert component_filter
+    return component_filter[0].id
+
+
+def test_start_stop(sandbox_id):
+    print(f"\nstart stop test got sandbox id '{sandbox_id}'")
+    assert sandbox_id
+
+
+def test_component_command_blocking(admin_session: SandboxRestApiSession, sandbox_id: str, component_id: str):
+    print("\nStarting blocking DUT Command...")
+    start = default_timer()
+    response = admin_session.run_component_command(sandbox_id=sandbox_id,
+                                                   component_id=component_id,
+                                                   command_name=constants.DUT_COMMAND,
+                                                   polling_execution=True,
+                                                   polling_log_level=logging.INFO)
+    common.fixed_sleep()
+    assert isinstance(response, model.ResourceCommandExecutionDetails)
+    print(f"Resource command finished after {default_timer() - start:.2f} seconds.\n"
+          f"Execution response: {response.pretty_json()}")

From 9cb0cd1ce07e8ce387d81d39b1e3dae10365c82f Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Sun, 10 Apr 2022 01:33:55 +0300
Subject: [PATCH 08/11] refactored model class names

---
 .../teardown_with_sleep/__main__.py           |  2 +-
 requirements-dev.txt                          |  3 +-
 src/cloudshell/sandbox_rest/api.py            | 26 +++++-----
 src/cloudshell/sandbox_rest/model.py          | 50 +++++++++----------
 tests/test_api_polling.py                     |  2 +-
 5 files changed, 42 insertions(+), 41 deletions(-)

diff --git a/assets/polling-orchestrations/teardown_with_sleep/__main__.py b/assets/polling-orchestrations/teardown_with_sleep/__main__.py
index 3b3d05b..5d6a0c3 100644
--- a/assets/polling-orchestrations/teardown_with_sleep/__main__.py
+++ b/assets/polling-orchestrations/teardown_with_sleep/__main__.py
@@ -1,8 +1,8 @@
 from cloudshell.workflow.orchestration.sandbox import Sandbox
 from cloudshell.workflow.orchestration.teardown.default_teardown_orchestrator import DefaultTeardownWorkflow
+import time
 
 SLEEP_SECONDS = 30
-import time
 
 
 def fake_config(sandbox, components=None):
diff --git a/requirements-dev.txt b/requirements-dev.txt
index ff6f3e3..1fc7024 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,4 +2,5 @@ pytest
 python-dotenv
 flake8
 pylint
-pre-commit
\ No newline at end of file
+pre-commit
+cloudshell-orch-core
\ No newline at end of file
diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index ebe53b1..44aa248 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -283,7 +283,7 @@ def run_component_command(
             max_polling_minutes: int = 20,
             polling_frequency_seconds: int = 10,
             polling_log_level: int = logging.DEBUG
-    ) -> model.ResourceCommandExecutionDetails:
+    ) -> model.ComponentCommandExecutionDetails:
         """Start a command on sandbox component"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/components/{component_id}/commands/{command_name}/start"
@@ -294,11 +294,11 @@ def run_component_command(
         start_response = model.CommandStartResponse.dict_to_model(response_dict)
         component_details = self.get_sandbox_component_details(sandbox_id, component_id)
         model_wrapped_command_params = [model.CommandParameterNameValue(x.name, x.value) for x in params] if params else []
-        command_context = model.ResourceCommandContextDetails(sandbox_id=sandbox_id,
-                                                              command_name=command_name,
-                                                              command_params=model_wrapped_command_params,
-                                                              component_name=component_details.name,
-                                                              component_id=component_details.id)
+        command_context = model.ComponentCommandContextDetails(sandbox_id=sandbox_id,
+                                                               command_name=command_name,
+                                                               command_params=model_wrapped_command_params,
+                                                               component_name=component_details.name,
+                                                               component_id=component_details.id)
         if polling_execution:
             execution_details = self.poll_command_execution(execution_id=start_response.executionId,
                                                             max_polling_minutes=max_polling_minutes,
@@ -307,13 +307,13 @@ def run_component_command(
         else:
             execution_details = self.get_execution_details(start_response.executionId)
 
-        return model.ResourceCommandExecutionDetails(id=start_response.executionId,
-                                                     status=execution_details.status,
-                                                     supports_cancellation=execution_details.supports_cancellation,
-                                                     started=execution_details.started,
-                                                     ended=execution_details.ended,
-                                                     output=execution_details.output,
-                                                     command_context=command_context)
+        return model.ComponentCommandExecutionDetails(id=start_response.executionId,
+                                                      status=execution_details.status,
+                                                      supports_cancellation=execution_details.supports_cancellation,
+                                                      started=execution_details.started,
+                                                      ended=execution_details.ended,
+                                                      output=execution_details.output,
+                                                      command_context=command_context)
 
     def extend_sandbox(self, sandbox_id: str, duration: str) -> model.ExtendResponse:
         """Extend the sandbox
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 8cadfe4..9f36cb1 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -7,7 +7,7 @@
 from enum import Enum
 from typing import TYPE_CHECKING, List, Optional
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel
 
 RESPONSE_DICT_KEY = "response_dict"
 
@@ -249,29 +249,6 @@ class CommandExecutionDetails(SandboxApiBaseModel):
     output: Optional[str]
 
 
-@_basemodel_decorator
-class CommandContextDetails(SandboxApiBaseModel):
-    sandbox_id: Optional[str]
-    command_name: Optional[str]
-    command_params: Optional[List[CommandParameterNameValue]]
-
-
-@_basemodel_decorator
-class ResourceCommandContextDetails(CommandContextDetails):
-    component_name: Optional[str]
-    component_id: Optional[str]
-
-
-@_basemodel_decorator
-class EnvironmentCommandExecutionDetails(CommandExecutionDetails):
-    command_context: Optional[CommandContextDetails]
-
-
-@_basemodel_decorator
-class ResourceCommandExecutionDetails(CommandExecutionDetails):
-    command_context: Optional[ResourceCommandContextDetails]
-
-
 @_basemodel_decorator
 class ComponentAttribute(SandboxApiBaseModel):
     type: Optional[str]
@@ -330,7 +307,30 @@ class SandboxOutput(SandboxApiBaseModel):
     entries: Optional[List[SandboxOutputEntry]]
 
 
-# Missing from Swagger Schema Docs
+# ===== Custom Data Models NOT returned from endpoint responses
 @_basemodel_decorator
 class StopSandboxResponse(SandboxApiBaseModel):
     result: Optional[str] = "success"
+
+
+@_basemodel_decorator
+class CommandContextDetails(SandboxApiBaseModel):
+    sandbox_id: Optional[str]
+    command_name: Optional[str]
+    command_params: Optional[List[CommandParameterNameValue]]
+
+
+@_basemodel_decorator
+class ComponentCommandContextDetails(CommandContextDetails):
+    component_name: Optional[str]
+    component_id: Optional[str]
+
+
+@_basemodel_decorator
+class EnvironmentCommandExecutionDetails(CommandExecutionDetails):
+    command_context: Optional[CommandContextDetails]
+
+
+@_basemodel_decorator
+class ComponentCommandExecutionDetails(CommandExecutionDetails):
+    command_context: Optional[ComponentCommandContextDetails]
diff --git a/tests/test_api_polling.py b/tests/test_api_polling.py
index 903f661..2f803c5 100644
--- a/tests/test_api_polling.py
+++ b/tests/test_api_polling.py
@@ -65,6 +65,6 @@ def test_component_command_blocking(admin_session: SandboxRestApiSession, sandbo
                                                    polling_execution=True,
                                                    polling_log_level=logging.INFO)
     common.fixed_sleep()
-    assert isinstance(response, model.ResourceCommandExecutionDetails)
+    assert isinstance(response, model.ComponentCommandExecutionDetails)
     print(f"Resource command finished after {default_timer() - start:.2f} seconds.\n"
           f"Execution response: {response.pretty_json()}")

From 4b4236c1df83a5fea21aac183562e274a0eb9144 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Thu, 28 Apr 2022 00:40:23 +0300
Subject: [PATCH 09/11] added components module

---
 src/cloudshell/sandbox_rest/api.py            | 36 ++++-----
 src/cloudshell/sandbox_rest/components.py     | 79 ++++++++++++++++++-
 src/cloudshell/sandbox_rest/model.py          | 14 ++--
 .../sandbox_rest/sandbox_context.py           | 30 ++++++-
 .../setup_with_sleep/__main__.py              |  0
 .../setup_with_sleep/requirements.txt         |  0
 .../setup_with_sleep/version.txt              |  0
 .../teardown_with_sleep/__main__.py           |  0
 .../teardown_with_sleep/requirements.txt      |  0
 .../teardown_with_sleep/version.txt           |  0
 tests/conftest.py                             |  6 +-
 11 files changed, 133 insertions(+), 32 deletions(-)
 rename {assets => tests/assets}/polling-orchestrations/setup_with_sleep/__main__.py (100%)
 rename {assets => tests/assets}/polling-orchestrations/setup_with_sleep/requirements.txt (100%)
 rename {assets => tests/assets}/polling-orchestrations/setup_with_sleep/version.txt (100%)
 rename {assets => tests/assets}/polling-orchestrations/teardown_with_sleep/__main__.py (100%)
 rename {assets => tests/assets}/polling-orchestrations/teardown_with_sleep/requirements.txt (100%)
 rename {assets => tests/assets}/polling-orchestrations/teardown_with_sleep/version.txt (100%)

diff --git a/src/cloudshell/sandbox_rest/api.py b/src/cloudshell/sandbox_rest/api.py
index 44aa248..87af005 100644
--- a/src/cloudshell/sandbox_rest/api.py
+++ b/src/cloudshell/sandbox_rest/api.py
@@ -13,11 +13,9 @@
 from tenacity import RetryError, retry, retry_if_result, stop_after_delay, wait_fixed
 
 from cloudshell.sandbox_rest import exceptions, model
-from cloudshell.sandbox_rest.default_logger import set_up_default_logger
 from pydantic.main import ValidationError
 
 
-
 @dataclass
 class CommandInputParam:
     """
@@ -50,7 +48,7 @@ def __init__(
             show_insecure_warning=False
     ):
         """ Login to api and store headers for future requests """
-        self.logger = logger or set_up_default_logger()
+        self.logger = logger
         super().__init__(host, username, password, token, logger, port, use_https, ssl_verify, proxies, show_insecure_warning)
         self.domain = domain
         self._base_uri = "/api"
@@ -243,7 +241,7 @@ def run_sandbox_command(
             max_polling_minutes: int = 20,
             polling_frequency_seconds: int = 10,
             polling_log_level: int = logging.DEBUG
-    ) -> model.EnvironmentCommandExecutionDetails:
+    ) -> model.SandboxCommandExecutionDetails:
         """Run a sandbox level command"""
         self._validate_auth_header()
         uri = f"{self._v2_base_uri}/sandboxes/{sandbox_id}/commands/{command_name}/start"
@@ -253,7 +251,7 @@ def run_sandbox_command(
         response_dict = self.rest_service.request_post(uri, data).json()
         start_response = model.CommandStartResponse.dict_to_model(response_dict)
         model_wrapped_command_params = [model.CommandParameterNameValue(x.name, x.value) for x in params] if params else []
-        command_context = model.CommandContextDetails(sandbox_id=sandbox_id,
+        command_context = model.SandboxCommandContext(sandbox_id=sandbox_id,
                                                       command_name=command_name,
                                                       command_params=model_wrapped_command_params)
         if polling_execution:
@@ -264,13 +262,13 @@ def run_sandbox_command(
         else:
             execution_details = self.get_execution_details(start_response.executionId)
 
-        return model.EnvironmentCommandExecutionDetails(id=start_response.executionId,
-                                                        status=execution_details.status,
-                                                        supports_cancellation=execution_details.supports_cancellation,
-                                                        started=execution_details.started,
-                                                        ended=execution_details.ended,
-                                                        output=execution_details.output,
-                                                        command_context=command_context)
+        return model.SandboxCommandExecutionDetails(id=start_response.executionId,
+                                                    status=execution_details.status,
+                                                    supports_cancellation=execution_details.supports_cancellation,
+                                                    started=execution_details.started,
+                                                    ended=execution_details.ended,
+                                                    output=execution_details.output,
+                                                    command_context=command_context)
 
     def run_component_command(
             self,
@@ -294,11 +292,11 @@ def run_component_command(
         start_response = model.CommandStartResponse.dict_to_model(response_dict)
         component_details = self.get_sandbox_component_details(sandbox_id, component_id)
         model_wrapped_command_params = [model.CommandParameterNameValue(x.name, x.value) for x in params] if params else []
-        command_context = model.ComponentCommandContextDetails(sandbox_id=sandbox_id,
-                                                               command_name=command_name,
-                                                               command_params=model_wrapped_command_params,
-                                                               component_name=component_details.name,
-                                                               component_id=component_details.id)
+        command_context = model.ComponentCommandContext(sandbox_id=sandbox_id,
+                                                        command_name=command_name,
+                                                        command_params=model_wrapped_command_params,
+                                                        component_name=component_details.name,
+                                                        component_id=component_details.id)
         if polling_execution:
             execution_details = self.poll_command_execution(execution_id=start_response.executionId,
                                                             max_polling_minutes=max_polling_minutes,
@@ -575,8 +573,8 @@ def poll_sandbox_teardown(
 
     def poll_command_execution(self,
                                execution_id: str,
-                               max_polling_minutes: int,
-                               polling_frequency_seconds: int,
+                               max_polling_minutes=10,
+                               polling_frequency_seconds=30,
                                log_level=logging.DEBUG,
                                ) -> model.CommandExecutionDetails:
         """ Create blocking polling process """
diff --git a/src/cloudshell/sandbox_rest/components.py b/src/cloudshell/sandbox_rest/components.py
index c163830..6b42032 100644
--- a/src/cloudshell/sandbox_rest/components.py
+++ b/src/cloudshell/sandbox_rest/components.py
@@ -1,7 +1,84 @@
 """
 Component helpers for filtering and sorting the sandbox components
 """
+from enum import Enum
+from typing import List
+
+from cloudshell.sandbox_rest import model
+from cloudshell.sandbox_rest.api import SandboxRestApiSession
+
+
+class ComponentTypes(str, Enum):
+    app_type = "Application"
+    resource_type = "Resource"
+    service_type = "Service"
+
+
+class AppLifeCycleTypes(str, Enum):
+    deployed = "Deployed"
+    un_deployed = "Undeployed"
+
+
+class AttributeTypes(str, Enum):
+    boolean = "boolean"
+    password = "password"
+    string = "string"
+    numeric = "numeric"
 
 
 class SandboxComponents:
-    pass
+    def __init__(self, components: List[model.SandboxComponentFull] = None):
+        self.all_components = components or []
+
+    def refresh_components(self, api: SandboxRestApiSession, sandbox_id: str) -> None:
+        self.all_components = api.get_sandbox_components(sandbox_id)
+
+    def _filter_components_by_type(self, component_type: ComponentTypes) -> List[model.SandboxComponentFull]:
+        """ accepts both short info components and full info """
+        return [component for component in self.all_components if component.component_type == component_type]
+
+    def _filter_app_by_lifecycle(self, lifecycle_type: AppLifeCycleTypes) -> List[model.SandboxComponentFull]:
+        return [component for component in self.all_components if component.component_type == ComponentTypes.app_type
+                and component.app_lifecycle == lifecycle_type]
+
+    @property
+    def resources(self):
+        return self._filter_components_by_type(ComponentTypes.service_type)
+
+    @property
+    def services(self):
+        return self._filter_components_by_type(ComponentTypes.service_type)
+
+    @property
+    def deployed_apps(self):
+        return self._filter_app_by_lifecycle(AppLifeCycleTypes.deployed)
+
+    @property
+    def un_deployed_apps(self):
+        return self._filter_app_by_lifecycle(AppLifeCycleTypes.un_deployed)
+
+    def filter_by_model(self, component_model: str) -> List[dict]:
+        """
+        Component Model / Shell Template
+        ex: 'Juniper JunOS Switch Shell 2G'
+        """
+        return [component for component in self.all_components if component.component_type == component_model]
+
+    def filter_by_attr(self, attr_name: str, attr_val: str) -> List[model.SandboxComponentFull]:
+        """ attr name is shell agnostic, no namespacing """
+        components = []
+        for component in self.all_components:
+            for attr in component.attributes:
+                if attr.name.endswith(attr_name) and attr.value == attr_val:
+                    components.append(component)
+        return components
+
+    def filter_by_boolean_attr(self, attr_name: str) -> List[dict]:
+        """ attr name is shell agnostic, no namespacing """
+        components = []
+        for component in self.all_components:
+            for attr in component.attributes:
+                if attr.type == AttributeTypes.boolean and attr.name.endswith(attr_name) and attr.value == "True":
+                    components.append(component)
+        return components
+
diff --git a/src/cloudshell/sandbox_rest/model.py b/src/cloudshell/sandbox_rest/model.py
index 9f36cb1..9eaf8f3 100644
--- a/src/cloudshell/sandbox_rest/model.py
+++ b/src/cloudshell/sandbox_rest/model.py
@@ -214,8 +214,8 @@ class CommandParameterDetails(SandboxApiBaseModel):
 
 @_basemodel_decorator
 class CommandParameterNameValue(SandboxApiBaseModel):
-    name: Optional[str]
-    value: Optional[str]
+    name: str
+    value: str
 
 
 @_basemodel_decorator
@@ -314,23 +314,23 @@ class StopSandboxResponse(SandboxApiBaseModel):
 
 
 @_basemodel_decorator
-class CommandContextDetails(SandboxApiBaseModel):
+class SandboxCommandContext(SandboxApiBaseModel):
     sandbox_id: Optional[str]
     command_name: Optional[str]
     command_params: Optional[List[CommandParameterNameValue]]
 
 
 @_basemodel_decorator
-class ComponentCommandContextDetails(CommandContextDetails):
+class ComponentCommandContext(SandboxCommandContext):
     component_name: Optional[str]
     component_id: Optional[str]
 
 
 @_basemodel_decorator
-class EnvironmentCommandExecutionDetails(CommandExecutionDetails):
-    command_context: Optional[CommandContextDetails]
+class SandboxCommandExecutionDetails(CommandExecutionDetails):
+    command_context: Optional[SandboxCommandContext]
 
 
 @_basemodel_decorator
 class ComponentCommandExecutionDetails(CommandExecutionDetails):
-    command_context: Optional[ComponentCommandContextDetails]
+    command_context: Optional[ComponentCommandContext]
diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
index 4daa547..68bcc3a 100644
--- a/src/cloudshell/sandbox_rest/sandbox_context.py
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -1,15 +1,34 @@
 """
 Sandbox controller context manager to manage the lifecyle of setup and teardown
+- start sandbox on context enter, end sandbox on context exit
+- cache setup / teardown duration and errors
+- components object member
+- async commands executor member
 """
-
+import logging
 
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
+from cloudshell.sandbox_rest.async_commands import AsyncCommandExecutor
+from cloudshell.sandbox_rest.components import SandboxComponents
+
+
+class SandboxStartRequest:
+    pass
 
 
 class SandboxControllerContext:
-    def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None):
+    def __init__(self, api: SandboxRestApiSession, sandbox_request: SandboxStartRequest = None, sandbox_id: str = None,
+                 logger: logging.Logger = None):
         self.api = api
+        self.sandbox_request = sandbox_request
         self.sandbox_id = sandbox_id
+        self.logger = logger
+        self.components = SandboxComponents()
+        self.async_executor = AsyncCommandExecutor()
+        self.setup_duration_seconds: int = None
+        self.teardown_duration_seconds: int = None
+        self.setup_errors = None
+        self.teardown_errors = None
 
     def __enter__(self):
         """ start sandbox """
@@ -18,3 +37,10 @@ def __enter__(self):
     def __exit__(self, exc_type, exc_val, exc_tb):
         """ end sandbox """
         return self
+
+    def refresh_components(self):
+        self.components.refresh_components(self.api, self.sandbox_id)
+
+
+if __name__ == "__main__":
+    controller = SandboxControllerContext()
\ No newline at end of file
diff --git a/assets/polling-orchestrations/setup_with_sleep/__main__.py b/tests/assets/polling-orchestrations/setup_with_sleep/__main__.py
similarity index 100%
rename from assets/polling-orchestrations/setup_with_sleep/__main__.py
rename to tests/assets/polling-orchestrations/setup_with_sleep/__main__.py
diff --git a/assets/polling-orchestrations/setup_with_sleep/requirements.txt b/tests/assets/polling-orchestrations/setup_with_sleep/requirements.txt
similarity index 100%
rename from assets/polling-orchestrations/setup_with_sleep/requirements.txt
rename to tests/assets/polling-orchestrations/setup_with_sleep/requirements.txt
diff --git a/assets/polling-orchestrations/setup_with_sleep/version.txt b/tests/assets/polling-orchestrations/setup_with_sleep/version.txt
similarity index 100%
rename from assets/polling-orchestrations/setup_with_sleep/version.txt
rename to tests/assets/polling-orchestrations/setup_with_sleep/version.txt
diff --git a/assets/polling-orchestrations/teardown_with_sleep/__main__.py b/tests/assets/polling-orchestrations/teardown_with_sleep/__main__.py
similarity index 100%
rename from assets/polling-orchestrations/teardown_with_sleep/__main__.py
rename to tests/assets/polling-orchestrations/teardown_with_sleep/__main__.py
diff --git a/assets/polling-orchestrations/teardown_with_sleep/requirements.txt b/tests/assets/polling-orchestrations/teardown_with_sleep/requirements.txt
similarity index 100%
rename from assets/polling-orchestrations/teardown_with_sleep/requirements.txt
rename to tests/assets/polling-orchestrations/teardown_with_sleep/requirements.txt
diff --git a/assets/polling-orchestrations/teardown_with_sleep/version.txt b/tests/assets/polling-orchestrations/teardown_with_sleep/version.txt
similarity index 100%
rename from assets/polling-orchestrations/teardown_with_sleep/version.txt
rename to tests/assets/polling-orchestrations/teardown_with_sleep/version.txt
diff --git a/tests/conftest.py b/tests/conftest.py
index 42fbd2f..6f0d282 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,6 +5,7 @@
 import env_settings
 
 from cloudshell.sandbox_rest.api import SandboxRestApiSession
+from cloudshell.sandbox_rest.default_logger import set_up_default_logger
 
 
 @pytest.fixture(scope="session")
@@ -12,7 +13,8 @@ def admin_session() -> SandboxRestApiSession:
     admin_api = SandboxRestApiSession(host=env_settings.CLOUDSHELL_SERVER,
                                       username=env_settings.CLOUDSHELL_ADMIN_USER,
                                       password=env_settings.CLOUDSHELL_ADMIN_PASSWORD,
-                                      domain=env_settings.CLOUDSHELL_DOMAIN)
+                                      domain=env_settings.CLOUDSHELL_DOMAIN,
+                                      logger=set_up_default_logger())
     print(f"Admin session started. Token: {admin_api.token}")
     with admin_api:
         yield admin_api
@@ -35,5 +37,3 @@ def dut_blueprint():
 @pytest.fixture(scope="session")
 def sleep_orch_blueprint():
     return constants.SLEEP_ORCH_BLUEPRINT
-
-

From 10e533f8fdbdd51047f4fca67e6f9df0cd507135 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Thu, 28 Apr 2022 02:27:02 +0300
Subject: [PATCH 10/11] added enter / exit methods to start / stop sandbox

---
 src/cloudshell/sandbox_rest/exceptions.py     |   2 +-
 .../sandbox_rest/sandbox_context.py           | 119 ++++++++++++++++--
 2 files changed, 112 insertions(+), 9 deletions(-)

diff --git a/src/cloudshell/sandbox_rest/exceptions.py b/src/cloudshell/sandbox_rest/exceptions.py
index 5995871..443cafb 100644
--- a/src/cloudshell/sandbox_rest/exceptions.py
+++ b/src/cloudshell/sandbox_rest/exceptions.py
@@ -37,7 +37,7 @@ def __init__(self, message: str, error_events: List[model.SandboxEvent] = None):
         super().__init__(message, error_events)
 
 
-class TeardownFailedException(SandboxRestException):
+class TeardownFailedException(FailedOrchestrationException):
     def __init__(self, message: str, error_events: List[model.SandboxEvent] = None):
         super().__init__(message, error_events)
 
diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
index 68bcc3a..fa3d396 100644
--- a/src/cloudshell/sandbox_rest/sandbox_context.py
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -6,36 +6,138 @@
 - async commands executor member
 """
 import logging
+from typing import List
 
-from cloudshell.sandbox_rest.api import SandboxRestApiSession
+from cloudshell.sandbox_rest.api import SandboxRestApiSession, CommandInputParam
+from cloudshell.sandbox_rest import exceptions
+from cloudshell.sandbox_rest import model
 from cloudshell.sandbox_rest.async_commands import AsyncCommandExecutor
 from cloudshell.sandbox_rest.components import SandboxComponents
+from dataclasses import dataclass
+from timeit import default_timer
 
 
+@dataclass
 class SandboxStartRequest:
-    pass
+    blueprint_id: str
+    sandbox_name: str = ""
+    duration: str = "PT2H0M"
+    bp_params: List[CommandInputParam] = None
+    permitted_users: List[str] = None
+    max_polling_minutes: int = 20
+    polling_frequency_seconds: int = 30
+    polling_log_level: int = logging.DEBUG
+
+
+@dataclass
+class TeardownSettings:
+    max_polling_minutes: int = 20
+    polling_frequency_seconds: int = 30
+    polling_log_level: int = logging.DEBUG
 
 
 class SandboxControllerContext:
-    def __init__(self, api: SandboxRestApiSession, sandbox_request: SandboxStartRequest = None, sandbox_id: str = None,
-                 logger: logging.Logger = None):
+    def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None,sandbox_request: SandboxStartRequest = None,
+                 teardown_settings = TeardownSettings(), logger: logging.Logger = None):
         self.api = api
-        self.sandbox_request = sandbox_request
         self.sandbox_id = sandbox_id
+        self.sandbox_request = sandbox_request
+        self.teardown_settings = teardown_settings
         self.logger = logger
         self.components = SandboxComponents()
         self.async_executor = AsyncCommandExecutor()
         self.setup_duration_seconds: int = None
         self.teardown_duration_seconds: int = None
-        self.setup_errors = None
-        self.teardown_errors = None
+        self.setup_errors: List[model.SandboxEvent] = None
+        self.teardown_errors: List[model.SandboxEvent] = None
+        self._validate_init()
+
+    def _validate_init(self):
+        if not self.sandbox_request and not self.sandbox_id:
+            raise ValueError("Must supply either an existing sandbox id or a SandboxStartRequest object")
+
+    def _info_log(self, msg: str):
+        if self.logger:
+            self.logger.info(msg)
+
+    def _debug_log(self, msg: str):
+        if self.logger:
+            self.logger.debug(msg)
+
+    def _error_log(self, msg: str):
+        if self.logger:
+            self.logger.error(msg)
+
+    def launch_sandbox(self):
+        if self.sandbox_id:
+            self._debug_log(f"launch sandbox called for existing sandbox '{self.sandbox_id}'. Returning")
+            return
+
+        if not self.sandbox_request:
+            raise ValueError("No StartSandboxRequest object passed to init")
+
+        start_response = self.api.start_sandbox(blueprint_id=self.sandbox_request.blueprint_id,
+                                                sandbox_name=self.sandbox_request.sandbox_name,
+                                                duration=self.sandbox_request.duration,
+                                                bp_params=self.sandbox_request.bp_params,
+                                                permitted_users=self.sandbox_request.permitted_users)
+        self.sandbox_id = start_response.id
+        self._info_log(f"Sandbox '{self.sandbox_id}' started.")
+        self._info_log("Starting blocking sandbox and polling...")
+        start = default_timer()
+        try:
+            self.api.poll_sandbox_setup(reservation_id=self.sandbox_id,
+                                        max_polling_minutes=self.sandbox_request.max_polling_minutes,
+                                        polling_frequency_seconds=self.sandbox_request.polling_frequency_seconds,
+                                        log_level=self.sandbox_request.polling_log_level)
+        except exceptions.SetupFailedException as e:
+            self.setup_errors = e.error_events
+            setup_duration_seconds = default_timer() - start
+            self._error_log(f"Sandbox '{self.sandbox_id}' setup FAILED after {setup_duration_seconds} seconds")
+            self._error_log(str(e))
+            raise
+
+        setup_duration_seconds = default_timer() - start
+        self._info_log(f"Sandbox '{self.sandbox_id}' setup completed after {setup_duration_seconds} seconds")
+        self.setup_duration_seconds = setup_duration_seconds
+        self.refresh_components()
+
+    def teardown_sandbox(self):
+        if not self.sandbox_id:
+            self._debug_log("Trying to start teardown, but no sandbox ID found. Returning")
+            return
+
+        if self.teardown_duration_seconds:
+            self._debug_log(f"Teardown has already ran for sandbox '{self.sandbox_id}'. Returning")
+
+        self._info_log(f"starting blocking teardown of sandbox '{self.sandbox_id}'")
+        start = default_timer()
+        try:
+            self.api.stop_sandbox(sandbox_id=self.sandbox_id,
+                                  poll_teardown=True,
+                                  max_polling_minutes=self.teardown_settings.max_polling_minutes,
+                                  polling_frequency_seconds=self.teardown_settings.polling_frequency_seconds,
+                                  polling_log_level=self.teardown_settings.polling_log_level)
+        except exceptions.TeardownFailedException as e:
+            self.teardown_errors = e.error_events
+            teardown_duration_seconds = default_timer() - start
+            self._error_log(f"Sandbox '{self.sandbox_id}' setup FAILED after {teardown_duration_seconds} seconds")
+            self._error_log(str(e))
+            raise
+
+        teardown_duration_seconds = default_timer() - start
+        self._info_log(f"Sandbox '{self.sandbox_id}' teardown completed after {teardown_duration_seconds} seconds")
+        self.teardown_duration_seconds = teardown_duration_seconds
+        self.refresh_components()
 
     def __enter__(self):
         """ start sandbox """
+        self.launch_sandbox()
         return self
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         """ end sandbox """
+        self.teardown_sandbox()
         return self
 
     def refresh_components(self):
@@ -43,4 +145,5 @@ def refresh_components(self):
 
 
 if __name__ == "__main__":
-    controller = SandboxControllerContext()
\ No newline at end of file
+    controller = SandboxControllerContext()
+    controller.async_executor

From 2b44a81a9fe0077bb2f3ca68e1e225f981d22360 Mon Sep 17 00:00:00 2001
From: katzy687 <katzy687@gmail.com>
Date: Thu, 28 Apr 2022 02:51:22 +0300
Subject: [PATCH 11/11] refactored context manager flow

---
 .../sandbox_rest/sandbox_context.py           | 45 ++++++++++++-------
 1 file changed, 29 insertions(+), 16 deletions(-)

diff --git a/src/cloudshell/sandbox_rest/sandbox_context.py b/src/cloudshell/sandbox_rest/sandbox_context.py
index fa3d396..9817e1c 100644
--- a/src/cloudshell/sandbox_rest/sandbox_context.py
+++ b/src/cloudshell/sandbox_rest/sandbox_context.py
@@ -36,8 +36,16 @@ class TeardownSettings:
     polling_log_level: int = logging.DEBUG
 
 
+@dataclass
+class OrchMetadata:
+    setup_duration_seconds: int = None
+    teardown_duration_seconds: int = None
+    setup_errors: List[model.SandboxEvent] = None
+    teardown_errors: List[model.SandboxEvent] = None
+
+
 class SandboxControllerContext:
-    def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None,sandbox_request: SandboxStartRequest = None,
+    def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None, sandbox_request: SandboxStartRequest = None,
                  teardown_settings = TeardownSettings(), logger: logging.Logger = None):
         self.api = api
         self.sandbox_id = sandbox_id
@@ -46,15 +54,17 @@ def __init__(self, api: SandboxRestApiSession, sandbox_id: str = None,sandbox_re
         self.logger = logger
         self.components = SandboxComponents()
         self.async_executor = AsyncCommandExecutor()
-        self.setup_duration_seconds: int = None
-        self.teardown_duration_seconds: int = None
-        self.setup_errors: List[model.SandboxEvent] = None
-        self.teardown_errors: List[model.SandboxEvent] = None
-        self._validate_init()
+        self.orch_metadata = OrchMetadata()
+        self._handle_init()
 
-    def _validate_init(self):
+    def _handle_init(self):
         if not self.sandbox_request and not self.sandbox_id:
             raise ValueError("Must supply either an existing sandbox id or a SandboxStartRequest object")
+        if self.sandbox_id and self.sandbox_request:
+            raise ValueError("Pass either existing sandbox id, or SandboxStartRequest object to init, not both")
+        if self.sandbox_id:
+            self._info_log(f"Existing sandbox id '{self.sandbox_id}' passed. Refreshing components")
+            self.refresh_components()
 
     def _info_log(self, msg: str):
         if self.logger:
@@ -68,6 +78,9 @@ def _error_log(self, msg: str):
         if self.logger:
             self.logger.error(msg)
 
+    def refresh_components(self):
+        self.components.refresh_components(self.api, self.sandbox_id)
+
     def launch_sandbox(self):
         if self.sandbox_id:
             self._debug_log(f"launch sandbox called for existing sandbox '{self.sandbox_id}'. Returning")
@@ -82,7 +95,7 @@ def launch_sandbox(self):
                                                 bp_params=self.sandbox_request.bp_params,
                                                 permitted_users=self.sandbox_request.permitted_users)
         self.sandbox_id = start_response.id
-        self._info_log(f"Sandbox '{self.sandbox_id}' started.")
+        self._info_log(f"Sandbox '{self.sandbox_id}' LAUNCHED.")
         self._info_log("Starting blocking sandbox and polling...")
         start = default_timer()
         try:
@@ -91,7 +104,7 @@ def launch_sandbox(self):
                                         polling_frequency_seconds=self.sandbox_request.polling_frequency_seconds,
                                         log_level=self.sandbox_request.polling_log_level)
         except exceptions.SetupFailedException as e:
-            self.setup_errors = e.error_events
+            self.orch_metadata.setup_errors = e.error_events
             setup_duration_seconds = default_timer() - start
             self._error_log(f"Sandbox '{self.sandbox_id}' setup FAILED after {setup_duration_seconds} seconds")
             self._error_log(str(e))
@@ -99,7 +112,7 @@ def launch_sandbox(self):
 
         setup_duration_seconds = default_timer() - start
         self._info_log(f"Sandbox '{self.sandbox_id}' setup completed after {setup_duration_seconds} seconds")
-        self.setup_duration_seconds = setup_duration_seconds
+        self.orch_metadata.setup_duration_seconds = setup_duration_seconds
         self.refresh_components()
 
     def teardown_sandbox(self):
@@ -107,7 +120,7 @@ def teardown_sandbox(self):
             self._debug_log("Trying to start teardown, but no sandbox ID found. Returning")
             return
 
-        if self.teardown_duration_seconds:
+        if self.orch_metadata.teardown_duration_seconds:
             self._debug_log(f"Teardown has already ran for sandbox '{self.sandbox_id}'. Returning")
 
         self._info_log(f"starting blocking teardown of sandbox '{self.sandbox_id}'")
@@ -119,7 +132,7 @@ def teardown_sandbox(self):
                                   polling_frequency_seconds=self.teardown_settings.polling_frequency_seconds,
                                   polling_log_level=self.teardown_settings.polling_log_level)
         except exceptions.TeardownFailedException as e:
-            self.teardown_errors = e.error_events
+            self.orch_metadata.teardown_errors = e.error_events
             teardown_duration_seconds = default_timer() - start
             self._error_log(f"Sandbox '{self.sandbox_id}' setup FAILED after {teardown_duration_seconds} seconds")
             self._error_log(str(e))
@@ -127,12 +140,13 @@ def teardown_sandbox(self):
 
         teardown_duration_seconds = default_timer() - start
         self._info_log(f"Sandbox '{self.sandbox_id}' teardown completed after {teardown_duration_seconds} seconds")
-        self.teardown_duration_seconds = teardown_duration_seconds
+        self.orch_metadata.teardown_duration_seconds = teardown_duration_seconds
         self.refresh_components()
 
     def __enter__(self):
         """ start sandbox """
-        self.launch_sandbox()
+        if not self.sandbox_id:
+            self.launch_sandbox()
         return self
 
     def __exit__(self, exc_type, exc_val, exc_tb):
@@ -140,8 +154,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
         self.teardown_sandbox()
         return self
 
-    def refresh_components(self):
-        self.components.refresh_components(self.api, self.sandbox_id)
+
 
 
 if __name__ == "__main__":
