Repository: microsoft/msticpy (language: python)

Issue:
Add support for contextual queries to Service Now API
To add context to data extracted from DataProviders, in addition to the existing TIProvider and GeoIpLookup, it would be nice to add contextual data from internal CMDB such as Service Now.

Most HTTP API can be queried from HttpProvider but should not be limited to IOCs supported by TIProvider.

Proposed solution:
* Define a new HttpProvider class under msticpy\common
* Create a folder _context_ under msticpy\data
* Add support for Service Now queries under msticpy\data\context
* Service Now context queries should also be registered as Pivot functions (via pivot_register)
* Service Now queries to be implemented:
    * Query host by IPv4
    * Query host by IPv6
    * Query host by host
    * Query user by email address
    * Query user by account name



Discussion / hints:
Built additional classes to clarify dependencies:
* ContextProvider -> TIProvider
* HttpContextProvider (inheriting from ContextProvider + HttpProvider) -> HttpTIProvider (inhereting from HttpContextProvider + TIProvider)

Created ContextLookup (abstracted from current TILookup) and started to implement a new version TILookup (inheriting from ContextLookup)
Dependant on #282.

Sample implementation available here > https://github.com/FlorianBracq/msticpy/tree/ServiceNow
@FlorianBracq - as part of the re-structuring in V2.0 I created a class that inherits functionality from the TI providers but without some of the restrictions. 
https://github.com/microsoft/msticpy/blob/v2.0.0/msticpy/context/tiproviders/http_lookup.py
This should be a good fit for more generic context lookups. It has an extensible pre-processing (for doing things like inferring the observable type or sanitizing the value). You can use existing ones from the TI space or add your own.
It returns data in the same format as the TI providers.

We should be able to use this to derive child classes for specific providers like Service Now.

I think we need something analogous to the TILookup class (prob derive a class from this - removing the "TI" specific aspects, parameter naming, etc.) that does the same kind of provider wrapping.

We should def revive your example Florian. Maybe we have some structure like:
- msticpy/context/context_lookup.py
- msticpy/context/context_providers/http_provider, other concrete providers
Hi @ianhelle,

I agree that it's the right time to get on it again, but I'm not sure to follow your idea?

The way I see it, [http_lookup.py](https://github.com/microsoft/msticpy/blob/v2.0.0/msticpy/context/tiproviders/http_lookup.py) being generic/extensible would be more aligned with a parent class (and the class HttpTIProvider should inherit from it, rather than HttpLookupProvider inheriting from HttpTIProvider.

I would see the mid term solution as:
* msticpy/context/
  * base_provider
  * base_http
  * TI_Lookup
  * context_lookup
  * contextproviders/
    * contextprovider (inheriting from base_provider)
    * http_context_provider (inheriting from base_http and contextprovider)
  * tiproviders/
    * tiprovider (inheriting from base_provider)
    * http_context_provider (inheriting from base_http and tiprovider)

and the long term solution would be for base_http to move to common in order for data_providers too to rely on the same code base?

What do you think?

Fix patch:
diff --git a/docs/source/api/msticpy.context.contextlookup.rst b/docs/source/api/msticpy.context.contextlookup.rst
new file mode 100644
index 000000000..cd4969d25
--- /dev/null
+++ b/docs/source/api/msticpy.context.contextlookup.rst
@@ -0,0 +1,7 @@
+msticpy.context.contextlookup module
+====================================
+
+.. automodule:: msticpy.context.contextlookup
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.contextproviders.context_provider_base.rst b/docs/source/api/msticpy.context.contextproviders.context_provider_base.rst
new file mode 100644
index 000000000..56db520a2
--- /dev/null
+++ b/docs/source/api/msticpy.context.contextproviders.context_provider_base.rst
@@ -0,0 +1,7 @@
+msticpy.context.contextproviders.context\_provider\_base module
+===============================================================
+
+.. automodule:: msticpy.context.contextproviders.context_provider_base
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.contextproviders.http_context_provider.rst b/docs/source/api/msticpy.context.contextproviders.http_context_provider.rst
new file mode 100644
index 000000000..e3a6251eb
--- /dev/null
+++ b/docs/source/api/msticpy.context.contextproviders.http_context_provider.rst
@@ -0,0 +1,7 @@
+msticpy.context.contextproviders.http\_context\_provider module
+===============================================================
+
+.. automodule:: msticpy.context.contextproviders.http_context_provider
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.contextproviders.rst b/docs/source/api/msticpy.context.contextproviders.rst
new file mode 100644
index 000000000..df915ae30
--- /dev/null
+++ b/docs/source/api/msticpy.context.contextproviders.rst
@@ -0,0 +1,16 @@
+msticpy.context.contextproviders package
+========================================
+
+.. automodule:: msticpy.context.contextproviders
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Submodules
+----------
+
+.. toctree::
+   :maxdepth: 4
+
+   msticpy.context.contextproviders.context_provider_base
+   msticpy.context.contextproviders.http_context_provider
diff --git a/docs/source/api/msticpy.context.http_lookup.rst b/docs/source/api/msticpy.context.http_lookup.rst
new file mode 100644
index 000000000..8c46a85c0
--- /dev/null
+++ b/docs/source/api/msticpy.context.http_lookup.rst
@@ -0,0 +1,7 @@
+msticpy.context.http\_lookup module
+===================================
+
+.. automodule:: msticpy.context.http_lookup
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.lookup.rst b/docs/source/api/msticpy.context.lookup.rst
new file mode 100644
index 000000000..2126c58af
--- /dev/null
+++ b/docs/source/api/msticpy.context.lookup.rst
@@ -0,0 +1,7 @@
+msticpy.context.lookup module
+=============================
+
+.. automodule:: msticpy.context.lookup
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.lookup_result.rst b/docs/source/api/msticpy.context.lookup_result.rst
new file mode 100644
index 000000000..e62cb05d2
--- /dev/null
+++ b/docs/source/api/msticpy.context.lookup_result.rst
@@ -0,0 +1,7 @@
+msticpy.context.lookup\_result module
+=====================================
+
+.. automodule:: msticpy.context.lookup_result
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.preprocess_observable.rst b/docs/source/api/msticpy.context.preprocess_observable.rst
new file mode 100644
index 000000000..d9ad5d8f4
--- /dev/null
+++ b/docs/source/api/msticpy.context.preprocess_observable.rst
@@ -0,0 +1,7 @@
+msticpy.context.preprocess\_observable module
+=============================================
+
+.. automodule:: msticpy.context.preprocess_observable
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.provider_base.rst b/docs/source/api/msticpy.context.provider_base.rst
new file mode 100644
index 000000000..0594a6591
--- /dev/null
+++ b/docs/source/api/msticpy.context.provider_base.rst
@@ -0,0 +1,7 @@
+msticpy.context.provider\_base module
+=====================================
+
+.. automodule:: msticpy.context.provider_base
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.rst b/docs/source/api/msticpy.context.rst
index f83aee6ac..4b9e13b71 100644
--- a/docs/source/api/msticpy.context.rst
+++ b/docs/source/api/msticpy.context.rst
@@ -13,6 +13,7 @@ Subpackages
    :maxdepth: 4
 
    msticpy.context.azure
+   msticpy.context.contextproviders
    msticpy.context.tiproviders
    msticpy.context.vtlookupv3
 
@@ -22,7 +23,13 @@ Submodules
 .. toctree::
    :maxdepth: 4
 
+   msticpy.context.contextlookup
    msticpy.context.domain_utils
    msticpy.context.geoip
+   msticpy.context.http_lookup
    msticpy.context.ip_utils
+   msticpy.context.lookup
+   msticpy.context.lookup_result
+   msticpy.context.preprocess_observable
+   msticpy.context.provider_base
    msticpy.context.tilookup
diff --git a/docs/source/api/msticpy.context.tiproviders.http_lookup.rst b/docs/source/api/msticpy.context.tiproviders.http_lookup.rst
deleted file mode 100644
index fd3c4125f..000000000
--- a/docs/source/api/msticpy.context.tiproviders.http_lookup.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-msticpy.context.tiproviders.http\_lookup module
-===============================================
-
-.. automodule:: msticpy.context.tiproviders.http_lookup
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.tiproviders.http_provider.rst b/docs/source/api/msticpy.context.tiproviders.http_provider.rst
deleted file mode 100644
index 7f633e1bb..000000000
--- a/docs/source/api/msticpy.context.tiproviders.http_provider.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-msticpy.context.tiproviders.http\_provider module
-=================================================
-
-.. automodule:: msticpy.context.tiproviders.http_provider
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.tiproviders.lookup_result.rst b/docs/source/api/msticpy.context.tiproviders.lookup_result.rst
deleted file mode 100644
index 81a6f3d5f..000000000
--- a/docs/source/api/msticpy.context.tiproviders.lookup_result.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-msticpy.context.tiproviders.lookup\_result module
-=================================================
-
-.. automodule:: msticpy.context.tiproviders.lookup_result
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.tiproviders.preprocess_observable.rst b/docs/source/api/msticpy.context.tiproviders.preprocess_observable.rst
deleted file mode 100644
index e52442441..000000000
--- a/docs/source/api/msticpy.context.tiproviders.preprocess_observable.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-msticpy.context.tiproviders.preprocess\_observable module
-=========================================================
-
-.. automodule:: msticpy.context.tiproviders.preprocess_observable
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/docs/source/api/msticpy.context.tiproviders.rst b/docs/source/api/msticpy.context.tiproviders.rst
index db2738fa5..2886627ed 100644
--- a/docs/source/api/msticpy.context.tiproviders.rst
+++ b/docs/source/api/msticpy.context.tiproviders.rst
@@ -14,18 +14,16 @@ Submodules
 
    msticpy.context.tiproviders.alienvault_otx
    msticpy.context.tiproviders.azure_sent_byoti
+   msticpy.context.tiproviders.dynamic_provider-old
    msticpy.context.tiproviders.greynoise
-   msticpy.context.tiproviders.http_lookup
-   msticpy.context.tiproviders.http_provider
    msticpy.context.tiproviders.ibm_xforce
    msticpy.context.tiproviders.intsights
    msticpy.context.tiproviders.kql_base
-   msticpy.context.tiproviders.lookup_result
    msticpy.context.tiproviders.mblookup
    msticpy.context.tiproviders.open_page_rank
-   msticpy.context.tiproviders.preprocess_observable
    msticpy.context.tiproviders.result_severity
    msticpy.context.tiproviders.riskiq
+   msticpy.context.tiproviders.ti_http_provider
    msticpy.context.tiproviders.ti_provider_base
    msticpy.context.tiproviders.tor_exit_nodes
    msticpy.context.tiproviders.virustotal
diff --git a/docs/source/api/msticpy.context.tiproviders.ti_http_provider.rst b/docs/source/api/msticpy.context.tiproviders.ti_http_provider.rst
new file mode 100644
index 000000000..b3ef6fc8a
--- /dev/null
+++ b/docs/source/api/msticpy.context.tiproviders.ti_http_provider.rst
@@ -0,0 +1,7 @@
+msticpy.context.tiproviders.ti\_http\_provider module
+=====================================================
+
+.. automodule:: msticpy.context.tiproviders.ti_http_provider
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/msticpy/__init__.py b/msticpy/__init__.py
index c4c044c57..40f07dcc1 100644
--- a/msticpy/__init__.py
+++ b/msticpy/__init__.py
@@ -81,6 +81,7 @@
 _DEFAULT_IMPORTS = {
     "az_connect": "msticpy.auth.azure_auth",
     "current_providers": "msticpy.init.nbinit",
+    "ContextLookup": "msticpy.context.contextlookup",
     "GeoLiteLookup": "msticpy.context.geoip",
     "init_notebook": "msticpy.init.nbinit",
     "IPStackLookup": "msticpy.context.geoip",
diff --git a/msticpy/context/contextlookup.py b/msticpy/context/contextlookup.py
new file mode 100644
index 000000000..8049c9e0f
--- /dev/null
+++ b/msticpy/context/contextlookup.py
@@ -0,0 +1,242 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+Module for ContextLookup classes.
+
+Input can be a single Observable observable or a pandas DataFrame containing
+multiple observables. Processing may require a an API key and
+processing performance may be limited to a specific number of
+requests per minute for the account type that you have.
+
+"""
+from typing import Iterable, List, Mapping, Optional, Union
+
+
+import pandas as pd
+
+from .._version import VERSION
+from ..common.utility import export
+
+from .lookup import Lookup
+from .provider_base import _make_sync
+
+# used in dynamic instantiation of providers
+from .contextproviders import CONTEXT_PROVIDERS
+
+__version__ = VERSION
+__author__ = "Ian Hellen"
+
+
+@export
+class ContextLookup(Lookup):
+    """Observable lookup from providers."""
+
+    _NO_PROVIDERS_MSG = """
+    No Context Providers are loaded - please check that
+    you have correctly configured your msticpyconfig.yaml settings.
+    """
+
+    _HELP_URI = (
+        "https://msticpy.readthedocs.io/en/latest/data_acquisition/"
+        "ContextProviders.html#configuration-file"
+    )
+
+    PACKAGE = "contextproviders"
+
+    PROVIDERS = CONTEXT_PROVIDERS
+
+    # pylint: disable=too-many-arguments
+    def lookup_observable(
+        self,
+        observable: str,
+        observable_type: Optional[str] = None,
+        query_type: Optional[str] = None,
+        providers: Optional[List[str]] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup single observable in active providers.
+
+        Parameters
+        ----------
+        observable : str
+            observable
+        observable_type : str, optional
+            One of IoCExtract.IOCType, by default None
+            If none, the Observable type will be inferred
+        query_type: str, optional
+            The observable query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]] = None,
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            The result returned as a DataFrame:
+            bool indicates whether a TI record was found in any provider
+            list has an entry for each provider result
+
+        """
+        return self.lookup_item(
+            item=observable,
+            item_type=observable_type,
+            query_type=query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
+        )
+
+    def lookup_observables(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup a collection of Observables.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `obs_col` parameter)
+            2. Mapping (e.g. a dict) of [observable, ObservableType]
+            3. Iterable of observables - ObservableTypes will be inferred
+        obs_col : str, optional
+            DataFrame column to use for observables, by default None
+            ("col" and "column" are also aliases for this parameter)
+        obs_type_col : str, optional
+            DataFrame column to use for ObservableTypes, by default None
+        query_type: str, optional
+            The observable query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]], optional
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results
+
+        """
+        return _make_sync(
+            self._lookup_observables_async(
+                data=data,
+                obs_col=obs_col,
+                obs_type_col=obs_type_col,
+                query_type=query_type,
+                providers=providers,
+                default_providers=default_providers,
+                prov_scope=prov_scope,
+                **kwargs,
+            )
+        )
+
+    # pylint: disable=too-many-locals
+    async def _lookup_observables_async(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """Lookup items async."""
+        return await self._lookup_items_async(
+            data,
+            item_col=obs_col,
+            item_type_col=obs_type_col,
+            query_type=query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
+        )
+
+    def lookup_observables_sync(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup a collection of IoCs.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `obs_col` parameter)
+            2. Mapping (e.g. a dict) of [observable, IoCType]
+            3. Iterable of observables - IoCTypes will be inferred
+        obs_col : str, optional
+            DataFrame column to use for observables, by default None
+            ("col" and "column" are also aliases for this parameter)
+        obs_type_col : str, optional
+            DataFrame column to use for IoCTypes, by default None
+        query_type: str, optional
+            The ioc query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]], optional
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results
+
+        """
+        return self.lookup_items_sync(
+            data,
+            item_col=obs_col,
+            item_type_col=obs_type_col,
+            query_type=query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
+        )
+
+    def _load_providers(self, **kwargs):
+        """Load provider classes based on config."""
+        return super()._load_providers(providers="ContextProviders", **kwargs)
diff --git a/msticpy/context/contextproviders/__init__.py b/msticpy/context/contextproviders/__init__.py
new file mode 100644
index 000000000..6f9bdda7a
--- /dev/null
+++ b/msticpy/context/contextproviders/__init__.py
@@ -0,0 +1,7 @@
+"""Context Providers sub-package."""
+from typing import Dict, Tuple
+from ..._version import VERSION
+
+__version__ = VERSION
+
+CONTEXT_PROVIDERS: Dict[str, Tuple[str, str]] = {}
diff --git a/msticpy/context/contextproviders/context_provider_base.py b/msticpy/context/contextproviders/context_provider_base.py
new file mode 100644
index 000000000..438ec6b61
--- /dev/null
+++ b/msticpy/context/contextproviders/context_provider_base.py
@@ -0,0 +1,293 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+Module for ContextProvider classes.
+
+Input can be a single observable or a pandas DataFrame containing
+multiple observables. Processing may require a an API key and
+processing performance may be limited to a specific number of
+requests per minute for the account type that you have.
+
+"""
+from abc import abstractmethod
+from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
+
+import pandas as pd
+
+from ..._version import VERSION
+from ...common.utility import export
+from ..provider_base import Provider
+
+__version__ = VERSION
+__author__ = "Ian Hellen"
+
+
+@export
+class ContextProvider(Provider):
+    """Abstract base class for Context providers."""
+
+    _REQUIRED_PARAMS: List[str] = []
+
+    def lookup_item(
+        self,
+        item: str,
+        item_type: Optional[str] = None,
+        query_type: Optional[str] = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup from a value.
+
+        Parameters
+        ----------
+        item : str
+            item to lookup
+        item_type : str, optional
+            The Type of the item to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item_value
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            The lookup result:
+            result - Positive/Negative,
+            details - Lookup Details (or status if failure),
+            raw_result - Raw Response
+            reference - URL of the item
+
+        Raises
+        ------
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
+
+        Notes
+        -----
+        Note: this method uses memoization (lru_cache) to cache results
+        for a particular observable to try avoid repeated network calls for
+        the same item.
+
+        """
+        return self.lookup_observable(
+            observable=item,
+            observable_type=item_type,
+            query_type=query_type,
+            **kwargs,
+        )
+
+    @abstractmethod
+    def lookup_observable(  # type: ignore
+        self,
+        observable: str,
+        observable_type: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup a single observable.
+
+        Parameters
+        ----------
+        observable : str
+            Observable value to lookup
+        observable_type : str, optional
+            The Type of the value to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item_value
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            The context lookup result:
+            result - Positive/Negative,
+            details - Lookup Details (or status if failure),
+            raw_result - Raw Response
+            reference - URL of the item
+
+        """
+
+    def _check_item_type(
+        self, item: str, item_type: str = None, query_subtype: str = None
+    ) -> Dict:
+        """
+        Check Item Type and cleans up item.
+
+        Parameters
+        ----------
+        item : str
+            item
+        item_type : str, optional
+            item type, by default None
+        query_subtype : str, optional
+            Query sub-type, if any, by default None
+
+        Returns
+        -------
+        Dict
+            Dict result with resolved type and pre-processed item.
+            Status is none-zero on failure.
+
+        """
+        return self._check_observable_type(
+            item,
+            item_type,
+            query_subtype,
+        )
+
+    def _check_observable_type(
+        self, obs: str, obs_type: str = None, query_subtype: str = None
+    ) -> Dict:
+        """
+        Check Observable Type and cleans up observable.
+
+        Parameters
+        ----------
+        obs : str
+            Observable
+        obs_type : str, optional
+            Observable type, by default None
+        query_subtype : str, optional
+            Query sub-type, if any, by default None
+
+        Returns
+        -------
+        Dict
+            Dict result with resolved type and pre-processed Observable.
+            Status is none-zero on failure.
+
+        """
+        result = super()._check_item_type(
+            item=obs, item_type=obs_type, query_subtype=query_subtype
+        )
+        result["Observable"] = result.pop("Item")
+        result["ObservableType"] = result.pop("ItemType")
+        result["SafeObservable"] = result.pop("SanitizedValue")
+        return result
+
+    @abstractmethod
+    def parse_results(self, response: Dict) -> Tuple[bool, Any]:
+        """
+        Return the details of the response.
+
+        Parameters
+        ----------
+        response : Dict
+            The returned data response
+
+        Returns
+        -------
+        Tuple[bool, ResultSeverity, Any]
+            bool = positive or negative hit
+            ResultSeverity = enumeration of severity
+            Object with match details
+
+        """
+
+    def lookup_observables(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup collection of observables.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `obs_col` parameter)
+            2. Dict of observables
+            3. Iterable of observables
+        obs_col : str, optional
+            DataFrame column to use for observables, by default None
+        obs_type_col : str, optional
+            DataFrame column to use for observables types, by default None
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the type
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        """
+        return self.lookup_items(
+            data,
+            item_col=obs_col,
+            item_type_col=obs_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
+
+    async def lookup_observables_async(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """Call base async wrapper."""
+        return await self._lookup_items_async_wrapper(
+            data,
+            item_col=obs_col,
+            item_type_col=obs_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
+
+    async def _lookup_observables_async_wrapper(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        obs_col: str = None,
+        obs_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Async wrapper for providers that do not implement lookup_iocs_async.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `obs_col` parameter)
+            2. Dict of observable, Observables Types
+            3. Iterable of observables - Observables Types will be inferred
+        obs_col : str, optional
+            DataFrame column to use for observables, by default None
+        obs_type_col : str, optional
+            DataFrame column to use for Observables Types, by default None
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the IoC type
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        """
+        return await self._lookup_items_async_wrapper(
+            data,
+            item_col=obs_col,
+            item_type_col=obs_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
diff --git a/msticpy/context/contextproviders/http_context_provider.py b/msticpy/context/contextproviders/http_context_provider.py
new file mode 100644
index 000000000..bfe85e17b
--- /dev/null
+++ b/msticpy/context/contextproviders/http_context_provider.py
@@ -0,0 +1,127 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+HTTP Context Provider base.
+
+HTTP implementation of a ContextProvider.
+It is used to interface with HTTP API providing additional contexts.
+It inherits from ContextProvider and HttpProvider
+
+"""
+from typing import Dict, Any
+from json import JSONDecodeError
+from functools import lru_cache
+import pandas as pd
+
+
+from ..._version import VERSION
+from ...common.pkg_config import get_http_timeout
+from .context_provider_base import ContextProvider
+from ..lookup_result import LookupStatus
+from ..http_provider import HttpProvider, APILookupParams
+
+__version__ = VERSION
+__author__ = "Florian Bracq"
+
+
+# Create alias for simplicity
+ContextLookupParams = APILookupParams
+
+
+class HttpContextProvider(ContextProvider, HttpProvider):
+    """HTTP Context Provider base class."""
+
+    @lru_cache(maxsize=256)
+    def lookup_observable(
+        self,
+        observable: str,
+        observable_type: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup from a value.
+
+        Parameters
+        ----------
+        observable : str
+            observable to lookup
+        observable_type : str, optional
+            The Type of the observable to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item_value
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            The lookup result:
+            result - Positive/Negative,
+            details - Lookup Details (or status if failure),
+            raw_result - Raw Response
+            reference - URL of the item
+
+        Raises
+        ------
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
+
+        Notes
+        -----
+        Note: this method uses memoization (lru_cache) to cache results
+        for a particular item to try avoid repeated network calls for
+        the same item.
+
+        """
+        result = self._check_observable_type(
+            observable, observable_type, query_subtype=query_type
+        )
+
+        result["Provider"] = kwargs.get("provider_name", self.__class__.__name__)
+
+        req_params: Dict[str, Any] = {}
+        try:
+            verb, req_params = self._substitute_parms(
+                result["SafeObservable"], result["ObservableType"], query_type
+            )
+            if verb == "GET":
+                response = self._httpx_client.get(
+                    **req_params, timeout=get_http_timeout(**kwargs)
+                )
+            else:
+                raise NotImplementedError(f"Unsupported verb {verb}")
+
+            result["Status"] = response.status_code
+            result["Reference"] = req_params["url"]
+            if result["Status"] == 200:
+                try:
+                    result["RawResult"] = response.json()
+                    result["Result"], result["Details"] = self.parse_results(result)
+                except JSONDecodeError:
+                    result[
+                        "RawResult"
+                    ] = f"""There was a problem parsing results from this lookup:
+                                        {response.text}"""
+                    result["Result"] = False
+                    result["Details"] = {}
+                result["Status"] = LookupStatus.OK.value
+            else:
+                result["RawResult"] = str(response.text)
+                result["Result"] = False
+                result["Details"] = self._response_message(result["Status"])
+        except (
+            LookupError,
+            JSONDecodeError,
+            NotImplementedError,
+            ConnectionError,
+        ) as err:
+            self._err_to_results(result, err)
+            if not isinstance(err, LookupError):
+                url = req_params.get("url", None) if req_params else None
+                result["Reference"] = url
+        return pd.DataFrame([result])
diff --git a/msticpy/context/tiproviders/http_provider.py b/msticpy/context/http_provider.py
similarity index 55%
rename from msticpy/context/tiproviders/http_provider.py
rename to msticpy/context/http_provider.py
index f7c89e937..939631ab3 100644
--- a/msticpy/context/tiproviders/http_provider.py
+++ b/msticpy/context/http_provider.py
@@ -4,32 +4,29 @@
 # license information.
 # --------------------------------------------------------------------------
 """
-HTTP TI Provider base.
+HTTP Lookup base class.
 
-Input can be a single IoC observable or a pandas DataFrame containing
-multiple observables. Processing may require a an API key and
+Input can be a single item or a pandas DataFrame containing
+multiple items. Processing may require a an API key and
 processing performance may be limited to a specific number of
 requests per minute for the account type that you have.
 
 """
-import abc
+from abc import abstractmethod
+from typing import Dict, List, Tuple, Any, Union
 import traceback
-from functools import lru_cache
-from http import client
-from json import JSONDecodeError
-from typing import Any, Dict, List, Tuple
 
 import attr
 import httpx
 from attr import Factory
+import pandas as pd
 
-from ..._version import VERSION
-from ...common.exceptions import MsticpyConfigException
-from ...common.pkg_config import get_http_timeout
-from ...common.utility import export, mp_ua_header
-from .lookup_result import LookupResult, LookupStatus
-from .result_severity import ResultSeverity
-from .ti_provider_base import TIProvider
+from .._version import VERSION
+from .lookup_result import LookupStatus
+from ..common.utility import mp_ua_header
+from ..common.pkg_config import get_http_timeout
+from ..common.exceptions import MsticpyConfigException
+from .provider_base import Provider
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -37,34 +34,99 @@
 
 # pylint: disable=too-few-public-methods
 @attr.s(auto_attribs=True)
-class IoCLookupParams:
-    """IoC HTTP Lookup Params definition."""
+class APILookupParams:
+    """HTTP Lookup Params definition."""
 
     path: str = ""
     verb: str = "GET"
     full_url: bool = False
     headers: Dict[str, str] = Factory(dict)
-    params: Dict[str, str] = Factory(dict)
+    params: Dict[str, Union[str, int, float]] = Factory(dict)
     data: Dict[str, str] = Factory(dict)
     auth_type: str = ""
     auth_str: List[str] = Factory(list)
     sub_type: str = ""
 
 
-@export
-class HttpTIProvider(TIProvider, abc.ABC):
-    """HTTP API Lookup provider base class."""
+class HttpProvider(Provider):
+    """
+    HTTP Generic lookup provider base class.
 
+    For subclasses:
+
+    Define Base URL of the service
+
+    .. code:: python
+
+        _BASE_URL = "https://my.api.org/"
+
+    Define query parameters for different item types (keys)
+
+    ..code:: python
+
+        _QUERIES: Dict[str, APILookupParams] = {}
+
+    For example:
+
+    .. code:: python
+
+        _QUERIES = {
+        # Community API
+        "ipv4": APILookupParams(
+            path="/v3/community/{observable}",
+            headers={"key": "{API_KEY}"},
+        ),
+        # Enterprise API Quick Lookup
+        "ipv4-quick": APILookupParams(
+            ...
+
+
+    Define list of required __init__ params
+
+    .. code:: python
+
+        _REQUIRED_PARAMS: List[str] = []
+
+    For example:
+
+    .. code:: python
+
+        _REQUIRED_PARAMS = ["API_KEY"]
+
+    In __init__
+
+    Be sure to call
+
+    .. code::
+
+        super().__init__(**kwargs)``
+
+    Supply any additional checkers/pre-processors
+    with
+
+    .. code: python
+
+        self._preprocessors.add_check(type, check_func)
+
+    See Also
+    --------
+    PreProcessor
+    HttpTIProvider
+
+    """
+
+    # Base URL of the service
     _BASE_URL = ""
 
-    _IOC_QUERIES: Dict[str, IoCLookupParams] = {}
+    # Define query parameters for different item types (keys)
+    _QUERIES: Dict[str, APILookupParams] = {}
 
+    # List of required __init__ params
     _REQUIRED_PARAMS: List[str] = []
 
     def __init__(self, **kwargs):
-        """Initialize a new instance of the class."""
+        """Initialize the class."""
         super().__init__(**kwargs)
-
         self._httpx_client = httpx.Client(timeout=get_http_timeout(**kwargs))
         self._request_params = {}
         if "ApiID" in kwargs:
@@ -73,12 +135,18 @@ def __init__(self, **kwargs):
         if "AuthKey" in kwargs:
             auth_key = kwargs.pop("AuthKey")
             self._request_params["API_KEY"] = auth_key.strip() if auth_key else None
+        if "Instance" in kwargs:
+            auth_key = kwargs.pop("Instance")
+            self._request_params["INSTANCE"] = auth_key.strip() if auth_key else None
 
         missing_params = [
             param
             for param in self._REQUIRED_PARAMS
             if param not in self._request_params
         ]
+
+        missing_params = []
+
         if missing_params:
             param_list = ", ".join(f"'{param}'" for param in missing_params)
             raise MsticpyConfigException(
@@ -86,19 +154,26 @@ def __init__(self, **kwargs):
                 f"Missing parameters are: {param_list}",
             )
 
-    @lru_cache(maxsize=256)
-    def lookup_ioc(  # type: ignore
-        self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
+        # In __init__ you might want to
+        # supply additional checkers/preprocessors
+        # with
+        # self._preprocessors.add_check(type, check_func)
+        # or replace the default PreProcessors
+        # self._preprocessors = MyPreProcessor()
+
+    @abstractmethod
+    def lookup_item(
+        self, item: str, item_type: str = None, query_type: str = None, **kwargs
+    ) -> pd.DataFrame:
         """
-        Lookup a single item.
+        Lookup from an item value.
 
         Parameters
         ----------
-        ioc : str
-            Item value to lookup
-        ioc_type : str, optional
-            The Type of the value to lookup, by default None (type will be inferred)
+        item : str
+            item to lookup
+        item_type : str, optional
+            The Type of the item to lookup, by default None (type will be inferred)
         query_type : str, optional
             Specify the data subtype to be queried, by default None.
             If not specified the default record type for the item_value
@@ -106,7 +181,7 @@ def lookup_ioc(  # type: ignore
 
         Returns
         -------
-        LookupResult
+        pd.DataFrame
             The lookup result:
             result - Positive/Negative,
             details - Lookup Details (or status if failure),
@@ -122,59 +197,10 @@ def lookup_ioc(  # type: ignore
         Notes
         -----
         Note: this method uses memoization (lru_cache) to cache results
-        for a particular observable to try avoid repeated network calls for
+        for a particular item to try avoid repeated network calls for
         the same item.
 
         """
-        result = self._check_ioc_type(
-            ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
-        )
-
-        result.provider = kwargs.get("provider_name", self.__class__.__name__)
-        if result.status != LookupStatus.OK.value:
-            return result
-
-        req_params: Dict[str, Any] = {}
-        try:
-            verb, req_params = self._substitute_parms(
-                result.safe_ioc, result.ioc_type, query_type
-            )
-            if verb == "GET":
-                response = self._httpx_client.get(
-                    **req_params, timeout=get_http_timeout(**kwargs)
-                )
-            else:
-                raise NotImplementedError(f"Unsupported verb {verb}")
-            result.status = response.status_code
-            result.reference = req_params["url"]
-            if result.status == 200:
-                try:
-                    result.raw_result = response.json()
-                    result.result, severity, result.details = self.parse_results(result)
-                except JSONDecodeError:
-                    result.raw_result = f"""There was a problem parsing results from this lookup:
-                                        {response.text}"""
-                    result.result = False
-                    severity = ResultSeverity.information
-                    result.details = {}
-                result.set_severity(severity)
-                result.status = LookupStatus.OK.value
-            else:
-                result.raw_result = str(response)
-                result.result = False
-                result.details = self._response_message(result.status)
-            return result
-        except (
-            LookupError,
-            JSONDecodeError,
-            NotImplementedError,
-            ConnectionError,
-        ) as err:
-            self._err_to_results(result, err)
-            if not isinstance(err, LookupError):
-                url = req_params.get("url", None) if req_params else None
-                result.reference = url
-            return result
 
     # pylint: enable=duplicate-code
     def _substitute_parms(
@@ -203,7 +229,7 @@ def _substitute_parms(
         req_params = {"observable": value}
         req_params.update(self._request_params)
         value_key = f"{value_type}-{query_type}" if query_type else value_type
-        src = self.ioc_query_defs.get(value_key, None)
+        src = self.item_query_defs.get(value_key, None)
         if not src:
             raise LookupError(f"Provider does not support this type {value_key}.")
 
@@ -211,9 +237,9 @@ def _substitute_parms(
         # substitute any parameter value from our req_params dict
         req_dict: Dict[str, Any] = {
             "headers": {},
-            "url": src.path.format(observable=value)
+            "url": src.path.format(**req_params)
             if src.full_url
-            else self._BASE_URL + src.path.format(**req_params),
+            else (self._BASE_URL + src.path).format(**req_params),
         }
 
         if src.headers:
@@ -225,7 +251,8 @@ def _substitute_parms(
             req_dict["headers"].update(mp_ua_header())
         if src.params:
             q_params: Dict[str, Any] = {
-                key: val.format(**req_params) for key, val in src.params.items()
+                key: val.format(**req_params) if isinstance(val, str) else val
+                for key, val in src.params.items()
             }
             req_dict["params"] = q_params
         if src.data:
@@ -241,33 +268,14 @@ def _substitute_parms(
                 raise NotImplementedError(f"Unknown auth type {src.auth_type}")
         return src.verb, req_dict
 
-    @abc.abstractmethod
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
-        """
-        Return the details of the response.
-
-        Parameters
-        ----------
-        response : LookupResult
-            The returned data response
-
-        Returns
-        -------
-        Tuple[bool, ResultSeverity, Any]
-            bool = positive or negative hit
-            ResultSeverity = enumeration of severity
-            Object with match details
-
-        """
-
     @staticmethod
-    def _failed_response(response: LookupResult) -> bool:
+    def _failed_response(response: Dict) -> bool:
         """
         Return True if negative response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -277,15 +285,15 @@ def _failed_response(response: LookupResult) -> bool:
 
         """
         return (
-            response.status != 200
-            or not response.raw_result
-            or not isinstance(response.raw_result, dict)
+            response["Status"] not in (200, LookupStatus.OK.value)
+            or not response["RawResult"]
+            or not isinstance(response["RawResult"], dict)
         )
 
     @staticmethod
-    def _err_to_results(result: LookupResult, err: Exception):
-        result.details = err.args
-        result.raw_result = (
+    def _err_to_results(result: Dict, err: Exception):
+        result["Details"] = err.args
+        result["RawResult"] = (
             type(err).__name__ + "\n" + str(err) + "\n" + traceback.format_exc()
         )
 
@@ -297,4 +305,4 @@ def _response_message(status_code):
             return "Authorization failed. Check account and key details."
         if status_code == 403:
             return "Request forbidden. Allowed query rate may have been exceeded."
-        return client.responses.get(status_code, "Unknown HTTP status code.")
+        return httpx.codes.get_reason_phrase(status_code) or "Unknown HTTP status code."
diff --git a/msticpy/context/lookup.py b/msticpy/context/lookup.py
new file mode 100644
index 000000000..7eaab5816
--- /dev/null
+++ b/msticpy/context/lookup.py
@@ -0,0 +1,765 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+Generic Module for Lookup classes.
+
+Input can be a single item or a pandas DataFrame containing
+multiple items. Processing may require a an API key and
+processing performance may be limited to a specific number of
+requests per minute for the account type that you have.
+
+"""
+import asyncio
+import importlib
+import warnings
+from collections import ChainMap
+from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
+
+import nest_asyncio
+import pandas as pd
+from tqdm.auto import tqdm
+
+from .._version import VERSION
+from ..common.exceptions import MsticpyConfigException, MsticpyUserConfigError
+from ..common.provider_settings import get_provider_settings, reload_settings
+from ..common.utility import export, is_ipython
+from ..vis.ti_browser import browse_results
+from .lookup_result import LookupStatus
+
+# used in dynamic instantiation of providers
+from .provider_base import Provider, _make_sync
+
+__version__ = VERSION
+__author__ = "Florian Bracq"
+
+
+class ProgressCounter:
+    """Progress counter for async tasks."""
+
+    def __init__(self, total: int):
+        """Initialize the class."""
+        self.total = total
+        self._lock: asyncio.Condition = asyncio.Condition()
+        self._remaining: int = total
+
+    async def decrement(self, increment: int = 1):
+        """Decrement the counter."""
+        if self._remaining == 0:
+            return
+        async with self._lock:
+            self._remaining -= increment
+
+    async def get_remaining(self) -> int:
+        """Get the current remaining count."""
+        async with self._lock:
+            return self._remaining
+
+
+@export
+class Lookup:
+    """Item lookup from providers."""
+
+    _NO_PROVIDERS_MSG = """
+    No Providers are loaded - please check that
+    you have correctly configured your msticpyconfig.yaml settings.
+    """
+
+    _HELP_URI = "https://msticpy.readthedocs.io/en/latest/DataEnrichment.html"
+
+    PROVIDERS: Dict[str, Tuple[str, str]] = {}
+
+    PACKAGE: str = ""
+
+    def __init__(self, providers: Optional[List[str]] = None, **kwargs):
+        """
+        Initialize TILookup instance.
+
+        Parameters
+        ----------
+        primary_providers : Optional[List[Provider]], optional
+            Primary Providers, by default None
+        secondary_providers : Optional[List[Provider]], optional
+            Secondary Providers, by default None
+        providers: Optional[List[str]], optional
+            List of provider names to load, by default all available
+            providers are loaded. To see the list of available providers
+            call `TILookup.list_available_providers()`.
+            Note: if primary_provides or secondary_providers is specified
+            This will override the providers list.
+
+        """
+        self._providers: Dict[str, Provider] = {}
+        self._secondary_providers: Dict[str, Provider] = {}
+        self._providers_to_load = providers
+
+        primary_providers = kwargs.pop("primary_providers", None)
+        if primary_providers:
+
+            for prov in primary_providers:
+                self.add_provider(prov, primary=True)
+        secondary_providers = kwargs.pop("secondary_providers", None)
+        if secondary_providers:
+            warnings.warn(
+                "'secondary_providers' is a deprecated parameter", DeprecationWarning
+            )
+            for prov in secondary_providers:
+                self.add_provider(prov, primary=False)
+        if not (primary_providers or secondary_providers):
+            self._load_providers()
+
+        self._all_providers = ChainMap(self._secondary_providers, self._providers)
+        if is_ipython():
+            nest_asyncio.apply()
+
+    @property
+    def loaded_providers(self) -> Dict[str, Provider]:
+        """
+        Return dictionary of loaded providers.
+
+        Returns
+        -------
+        Dict[str, TIProvider]
+            [description]
+
+        """
+        return dict(self._all_providers)
+
+    @property
+    def provider_status(self) -> Iterable[str]:
+        """
+        Return loaded provider status.
+
+        Returns
+        -------
+        Iterable[str]
+            List of providers and descriptions.
+
+        """
+        prim = [
+            f"{prov_name} - {prov.description} (primary)"
+            for prov_name, prov in self._providers.items()
+        ]
+        sec = [
+            f"{prov_name} - {prov.description} (secondary)"
+            for prov_name, prov in self._secondary_providers.items()
+        ]
+        return prim + sec
+
+    @property
+    def configured_providers(self) -> List[str]:
+        """
+        Return a list of available providers that have configuration details present.
+
+        Returns
+        -------
+        List[str]
+            List of TI Provider classes.
+
+        """
+        prim_conf = list(self._providers.keys())
+        sec_conf = list(self._secondary_providers.keys())
+
+        return prim_conf + sec_conf
+
+    def enable_provider(self, providers: Union[str, Iterable[str]]):
+        """
+        Set the provider(s) as primary (used by default).
+
+        Parameters
+        ----------
+        providers : Union[str, Iterable[str]
+            Provider name or list of names.
+            Use `list_available_providers()` to see the list of loaded providers.
+
+        Raises
+        ------
+        ValueError
+            If the provider name is not recognized.
+
+        """
+        provs_to_enable = [providers] if isinstance(providers, str) else providers
+        for provider in provs_to_enable:
+            if provider in self._secondary_providers:
+                self._providers[provider] = self._secondary_providers[provider]
+                del self._secondary_providers[provider]
+            elif provider not in self._providers:
+                raise ValueError(
+                    f"Unknown provider '{provider}'. Available providers:",
+                    ", ".join(self.list_available_providers(as_list=True)),  # type: ignore
+                )
+
+    def disable_provider(self, providers: Union[str, Iterable[str]]):
+        """
+        Set the provider as secondary (not used by default).
+
+        Parameters
+        ----------
+        providers : Union[str, Iterable[str]
+            Provider name or list of names.
+            Use `list_available_providers()` to see the list of loaded providers.
+
+        Raises
+        ------
+        ValueError
+            If the provider name is not recognized.
+
+        """
+        provs_to_disable = [providers] if isinstance(providers, str) else providers
+        for provider in provs_to_disable:
+            if provider in self._providers:
+                self._secondary_providers[provider] = self._providers[provider]
+                del self._providers[provider]
+            elif provider not in self._secondary_providers:
+                raise ValueError(
+                    f"Unknown provider '{provider}'. Available providers:",
+                    ", ".join(self.list_available_providers(as_list=True)),  # type: ignore
+                )
+
+    def set_provider_state(self, prov_dict: Dict[str, bool]):
+        """
+        Set a dict of providers to primary/secondary.
+
+        Parameters
+        ----------
+        prov_dict : Dict[str, bool]
+            Dictionary of provider name and bool - True if enabled/primary,
+            False if disabled/secondary.
+
+        """
+        for prov, state in prov_dict.items():
+            if state:
+                self.enable_provider(prov)
+            else:
+                self.disable_provider(prov)
+
+    @classmethod
+    def browse_results(
+        cls, data: pd.DataFrame, severities: Optional[List[str]] = None, **kwargs
+    ):
+        """
+        Return TI Results list browser.
+
+        Parameters
+        ----------
+        data : pd.DataFrame
+            TI Results data from TIProviders
+        severities : Optional[List[str]], optional
+            A list of the severity classes to show.
+            By default these are ['warning', 'high'].
+            Pass ['information', 'warning', 'high'] to see all
+            results.
+
+        Other Parameters
+        ----------------
+        kwargs :
+            passed to SelectItem constructor.
+
+        Returns
+        -------
+        SelectItem
+            SelectItem browser for TI Data.
+
+        """
+        if not isinstance(data, pd.DataFrame):
+            print("Input data is in an unexpected format.")
+            return None
+        return browse_results(data=data, severities=severities, **kwargs)
+
+    browse = browse_results
+
+    def provider_usage(self):
+        """Print usage of loaded providers."""
+        print("Primary providers")
+        print("-----------------")
+        if self._providers:
+            for prov_name, prov in self._providers.items():
+                print(f"\nProvider class: {prov_name}")
+                prov.usage()
+        else:
+            print("none")
+        print("\nSecondary providers")
+        print("-------------------")
+        if self._secondary_providers:
+            for prov_name, prov in self._secondary_providers.items():
+                print(f"\nProvider class: {prov_name}")
+                prov.usage()
+        else:
+            print("none")
+
+    @classmethod
+    def reload_provider_settings(cls):
+        """Reload provider settings from config."""
+        reload_settings()
+        print(
+            "Settings reloaded. Use reload_providers to update settings",
+            "for loaded providers.",
+        )
+
+    def reload_providers(self):
+        """Reload settings and provider classes."""
+        reload_settings()
+        self._load_providers()
+
+    def add_provider(self, provider: Provider, name: str = None, primary: bool = True):
+        """
+        Add a provider to the current collection.
+
+        Parameters
+        ----------
+        provider : TIProvider
+            Provider instance
+        name : str, optional
+            The name to use for the provider (overrides the class name
+            of `provider`)
+        primary : bool, optional
+            "primary" or "secondary" if False, by default "primary"
+
+        """
+        if not name:
+            name = provider.__class__.__name__
+        if primary:
+            self._providers[name] = provider
+        else:
+            self._secondary_providers[name] = provider
+
+    # pylint: disable=too-many-locals
+    # pylint: disable=too-many-arguments
+    def lookup_item(
+        self,
+        item: str,
+        item_type: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup single item in active providers.
+
+        Parameters
+        ----------
+        item : str
+            item to lookup
+        item_type : str, optional
+            One of ItemExtract.ItemType, by default None
+            If none, the Item type will be inferred
+        query_type: str, optional
+            The query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]], optional
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results
+
+        """
+        return self.lookup_items(
+            data={item: item_type},
+            query_type=query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
+        )
+
+    def lookup_items(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup a collection of items.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `item_col` parameter)
+            2. Mapping (e.g. a dict) of [item, ItemType]
+            3. Iterable of items - ItemTypes will be inferred
+        item_col : str, optional
+            DataFrame column to use for items, by default None
+            ("col" and "column" are also aliases for this parameter)
+        item_type_col : str, optional
+            DataFrame column to use for ItemTypes, by default None
+        query_type: str, optional
+            The item query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]], optional
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results
+
+        """
+        return _make_sync(
+            self._lookup_items_async(
+                data=data,
+                item_col=item_col,
+                item_type_col=item_type_col,
+                query_type=query_type,
+                providers=providers,
+                default_providers=default_providers,
+                prov_scope=prov_scope,
+                **kwargs,
+            )
+        )
+
+    @staticmethod
+    def result_to_df(item_lookup: pd.DataFrame) -> pd.DataFrame:
+        """
+        Return DataFrame representation of Lookup response.
+
+        Parameters
+        ----------
+        item_lookup : pd.DataFrame
+            Output from `lookup_item`
+
+        Returns
+        -------
+        pd.DataFrame
+            The response as a DataFrame with a row for each
+            provider response.
+
+        """
+        if isinstance(item_lookup, pd.DataFrame):
+            return item_lookup
+        raise ValueError(f"DataFrame was expected, but {type(item_lookup)} received.")
+
+    # pylint: disable=too-many-locals
+    async def _lookup_items_async(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """Lookup items async."""
+        item_col = item_col or kwargs.pop("col", kwargs.pop("column", None))
+
+        selected_providers = self._select_providers(
+            providers or default_providers, prov_scope
+        )
+        if not selected_providers:
+            raise MsticpyUserConfigError(
+                self._NO_PROVIDERS_MSG,
+                title="No Threat Intel Provider configuration found.",
+                help_uri=self._HELP_URI,
+            )
+
+        event_loop = asyncio.get_event_loop()
+        result_futures: List[Any] = []
+        provider_names: List[str] = []
+        prog_counter = ProgressCounter(
+            total=len(data) * len(selected_providers)  # type: ignore
+        )
+        # create a list of futures/tasks to await
+        for prov_name, provider in selected_providers.items():
+            provider_names.append(prov_name)
+            result_futures.append(
+                provider.lookup_items_async(
+                    data=data,
+                    item_col=item_col,
+                    item_type_col=item_type_col,
+                    query_type=query_type,
+                    prog_counter=prog_counter,
+                    **kwargs,
+                )
+            )
+        # Create a task for tqdm
+        prog_task = event_loop.create_task(self._track_completion(prog_counter))
+        # collect the return values of the tasks
+        results = await asyncio.gather(*result_futures)
+        # cancel the progress task if results have completed.
+        prog_task.cancel()
+        return self._combine_results(results, provider_names, **kwargs)
+
+    def lookup_items_sync(
+        self,
+        data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        providers: List[str] = None,
+        default_providers: Optional[List[str]] = None,
+        prov_scope: str = "primary",
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup a collection of items.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `item_col` parameter)
+            2. Mapping (e.g. a dict) of [item, ItemType]
+            3. Iterable of items - ItemTypes will be inferred
+        item_col : str, optional
+            DataFrame column to use for items, by default None
+            ("col" and "column" are also aliases for this parameter)
+        item_type_col : str, optional
+            DataFrame column to use for ItemTypes, by default None
+        query_type: str, optional
+            The item query type (e.g. rep, info, malware)
+        providers: List[str]
+            Explicit list of providers to use
+        default_providers: Optional[List[str]], optional
+            Used by pivot functions as a fallback to `providers`. If
+            `providers` is specified, it will override this parameter.
+        prov_scope : str, optional
+            Use "primary", "secondary" or "all" providers, by default "primary"
+        kwargs :
+            Additional arguments passed to the underlying provider(s)
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results
+
+        """
+        item_col = item_col or kwargs.pop("col", kwargs.pop("column", None))
+
+        selected_providers = self._select_providers(
+            providers or default_providers, prov_scope
+        )
+        if not selected_providers:
+            raise MsticpyUserConfigError(
+                self._NO_PROVIDERS_MSG,
+                title="No Threat Intel Provider configuration found.",
+                help_uri=self._HELP_URI,
+            )
+
+        results: List[Any] = []
+        provider_names: List[str] = []
+        for prov_name, provider in selected_providers.items():
+            provider_names.append(prov_name)
+            results.append(
+                provider.lookup_items(
+                    data=data,
+                    item_col=item_col,
+                    item_type_col=item_type_col,
+                    query_type=query_type,
+                    **kwargs,
+                )
+            )
+        return self._combine_results(results, provider_names, **kwargs)
+
+    @staticmethod
+    async def _track_completion(prog_counter):
+        total = await prog_counter.get_remaining()
+        with tqdm(total=total, unit="obs", desc="Observables processed") as prog_bar:
+            try:
+                last_remaining = total
+                while last_remaining:
+                    new_remaining = await prog_counter.get_remaining()
+                    incr = last_remaining - new_remaining
+                    if incr:
+                        prog_bar.update(incr)
+                    last_remaining = new_remaining
+                    # print(f"progress: incr {incr}, last: {last_remaining}")
+                    await asyncio.sleep(0)
+            except asyncio.CancelledError:
+                # make progress bar get to 100% on cancel
+                final_remaining = await prog_counter.get_remaining()
+                if final_remaining:
+                    prog_bar.update(total - final_remaining)
+
+    @property
+    def available_providers(self) -> List[str]:
+        """
+        Return a list of builtin providers.
+
+        Returns
+        -------
+        List[str]
+            List of TI Provider classes.
+
+        """
+        return list(self.PROVIDERS)
+
+    @classmethod
+    def list_available_providers(
+        cls, show_query_types=False, as_list: bool = False
+    ) -> Optional[List[str]]:  # type: ignore
+        """
+        Print a list of builtin providers with optional usage.
+
+        Parameters
+        ----------
+        show_query_types : bool, optional
+            Show query types supported by providers, by default False
+        as_list : bool, optional
+            Return list of providers instead of printing to stdout.
+            Note: if you specify `show_query_types` this will be printed
+            irrespective of this parameter setting.
+
+        Returns
+        -------
+        Optional[List[str]]
+            A list of provider names (if `return_list=True`)
+
+        """
+        providers = []
+        for provider_name in cls.PROVIDERS:
+            provider_class = cls.import_provider(provider_name)
+            if not as_list:
+                print(provider_name)
+            providers.append(provider_name)
+            if show_query_types and provider_class:
+                provider_class.usage()
+
+        if as_list:
+            return providers
+        return None
+
+    @classmethod
+    def import_provider(cls, provider: str) -> Provider:
+        """Import provider class."""
+        mod_name, cls_name = cls.PROVIDERS.get(provider, (None, None))
+
+        if not (mod_name and cls_name):
+            raise LookupError(
+                f"No driver available for environment {provider}.",
+                "Possible values are:",
+                ", ".join(list(cls.PROVIDERS)),
+            )
+
+        imp_module = importlib.import_module(
+            f"msticpy.context.{cls.PACKAGE}.{mod_name}", package="msticpy"
+        )
+        return getattr(imp_module, cls_name)
+
+    def _load_providers(self, **kwargs):
+        """Load provider classes based on config."""
+        prov_type = kwargs.get("providers", "Providers")
+        prov_settings = get_provider_settings(prov_type)
+
+        for provider_entry, settings in prov_settings.items():
+            # Allow overriding provider name to use another class
+            provider_name = settings.provider or provider_entry
+            if (
+                self._providers_to_load is not None
+                and provider_name not in self._providers_to_load
+            ) or provider_name == "--no-load--":
+                continue
+            try:
+                provider_class: Provider = self.import_provider(provider_name)
+            except LookupError:
+                warnings.warn(
+                    f"Could not find provider class for {provider_name} "
+                    f"in config section {provider_entry}"
+                )
+                continue
+
+            # instantiate class sending args from settings to init
+            try:
+                provider_instance = provider_class(**(settings.args))
+            except MsticpyConfigException as mp_ex:
+                # If the TI Provider didn't load, raise an exception
+                raise MsticpyUserConfigError(
+                    f"Could not load Provider {provider_name}",
+                    *mp_ex.args,
+                    "To avoid loading this provider please use the 'providers' parameter"
+                    + " to specify which providers to load.",
+                    title="Provider configuration error",
+                    help_uri=self._HELP_URI,
+                ) from mp_ex
+
+            # set the description from settings, if one is provided, otherwise
+            # use class docstring.
+            provider_instance.description = (
+                settings.description or provider_instance.__doc__
+            )
+
+            self.add_provider(
+                provider=provider_instance, name=provider_name, primary=settings.primary
+            )
+
+    def _select_providers(
+        self, providers: List[str] = None, prov_scope: str = "primary"
+    ) -> Dict[str, Provider]:
+        """
+        Return required subset of providers.
+
+        Parameters
+        ----------
+        providers : List[str], optional
+            Explicit list of provider names, by default None
+        prov_scope : str, optional
+            Provider scope, by default "primary"
+            Other values are "all" and "secondary"
+
+        Returns
+        -------
+        Dict[str, TIProvider]
+            Dictionary of provider names and instances.
+
+        """
+        if providers:
+            return {
+                prov_name: prov
+                for prov_name, prov in self._all_providers.items()
+                if prov_name in providers
+            }
+        if prov_scope == "all":
+            return dict(self._all_providers)
+        if prov_scope == "primary":
+            return self._providers
+        return self._secondary_providers
+
+    @staticmethod
+    def _combine_results(
+        results: Iterable[pd.DataFrame], provider_names: List[str], **kwargs
+    ):
+        """Combine dataframe results into single DF."""
+        result_list: List[pd.DataFrame] = []
+        for prov_name, provider_result in zip(provider_names, results):
+            if provider_result is None or provider_result.empty:
+                continue
+            if not kwargs.get("show_not_supported", False):
+                provider_result = provider_result[
+                    provider_result["Status"] != LookupStatus.NOT_SUPPORTED.value
+                ]
+            if not kwargs.get("show_bad_item", False):
+                provider_result = provider_result[
+                    provider_result["Status"] != LookupStatus.BAD_FORMAT.value
+                ]
+            provider_result["Provider"] = prov_name
+            result_list.append(provider_result)
+
+        if not result_list:
+            print("No Item matches")
+        return pd.concat(result_list, sort=False) if result_list else None
diff --git a/msticpy/context/lookup_result.py b/msticpy/context/lookup_result.py
new file mode 100644
index 000000000..d21f8c859
--- /dev/null
+++ b/msticpy/context/lookup_result.py
@@ -0,0 +1,27 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""Lookup Status class."""
+from collections import namedtuple
+from enum import Enum
+
+from .._version import VERSION
+
+__version__ = VERSION
+__author__ = "Ian Hellen"
+
+
+SanitizedObservable = namedtuple("SanitizedObservable", ["observable", "status"])
+
+
+class LookupStatus(Enum):
+    """Threat intelligence lookup status."""
+
+    OK = 0
+    NOT_SUPPORTED = 1
+    BAD_FORMAT = 2
+    QUERY_FAILED = 3
+    NO_DATA = 4
+    OTHER = 10
diff --git a/msticpy/context/tiproviders/preprocess_observable.py b/msticpy/context/preprocess_observable.py
similarity index 99%
rename from msticpy/context/tiproviders/preprocess_observable.py
rename to msticpy/context/preprocess_observable.py
index b6ca1e543..0678bce9a 100644
--- a/msticpy/context/tiproviders/preprocess_observable.py
+++ b/msticpy/context/preprocess_observable.py
@@ -24,8 +24,8 @@
 from urllib3.exceptions import LocationParseError
 from urllib3.util import parse_url
 
-from ..._version import VERSION
-from ...transform.iocextract import IoCExtract
+from .._version import VERSION
+from ..transform.iocextract import IoCExtract
 from .lookup_result import SanitizedObservable
 
 __version__ = VERSION
diff --git a/msticpy/context/provider_base.py b/msticpy/context/provider_base.py
new file mode 100644
index 000000000..742396639
--- /dev/null
+++ b/msticpy/context/provider_base.py
@@ -0,0 +1,477 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+Base class for Provider classes.
+
+Input can be a single item or a pandas DataFrame containing
+multiple items. Processing may require a an API key and
+processing performance may be limited to a specific number of
+requests per minute for the account type that you have.
+
+"""
+
+import asyncio
+import collections
+from abc import ABC, abstractmethod
+from asyncio import get_event_loop
+from functools import lru_cache, partial, singledispatch
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
+
+import pandas as pd
+
+from .._version import VERSION
+from ..common.utility import export
+from ..transform.iocextract import IoCExtract as ItemExtract
+from ..transform.iocextract import IoCType as Type
+from .lookup_result import LookupStatus
+from .preprocess_observable import PreProcessor
+
+__version__ = VERSION
+__author__ = "Ian Hellen"
+
+_ITEM_EXTRACT = ItemExtract()
+
+
+@export
+class Provider(ABC):
+    """Abstract base class for Providers."""
+
+    _QUERIES: Dict[str, Any] = {}
+
+    @abstractmethod
+    def lookup_item(
+        self, item: str, item_type: str = None, query_type: str = None, **kwargs
+    ) -> pd.DataFrame:
+        """
+        Lookup from a value.
+
+        Parameters
+        ----------
+        item : str
+            item to lookup
+        item_type : str, optional
+            The Type of the item to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item_value
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        Raises
+        ------
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
+
+        Notes
+        -----
+        Note: this method uses memoization (lru_cache) to cache results
+        for a particular item to try avoid repeated network calls for
+        the same item.
+
+        """
+
+    def _check_item_type(
+        self, item: str, item_type: str = None, query_subtype: str = None
+    ) -> Dict:
+        """
+        Check Item Type and cleans up item.
+
+        Parameters
+        ----------
+        item : str
+            item
+        item_type : str, optional
+            item type, by default None
+        query_subtype : str, optional
+            Query sub-type, if any, by default None
+
+        Returns
+        -------
+        Dict
+            Dict result with resolved type and pre-processed
+            item.
+            Status is none-zero on failure.
+
+        """
+        result: Dict[str, Any] = {
+            "Item": item,
+            "SanitizedValue": item,
+            "ItemType": item_type or self.resolve_item_type(item_type),
+            "QuerySubtype": query_subtype,
+            "Result": False,
+            "Details": "",
+            "RawResult": None,
+            "Reference": None,
+            "Status": LookupStatus.OK.value,
+        }
+
+        if not self.is_supported_type(result["ItemType"]):
+            result["Details"] = f"Type {result['ItemType']} not supported."
+            result["Status"] = LookupStatus.NOT_SUPPORTED.value
+            return result
+
+        clean_item = self._preprocessors.check(
+            item,
+            item_type,
+            require_url_encoding=self.require_url_encoding,
+        )
+
+        result["SanitizedValue"] = clean_item.observable
+
+        if clean_item.status != "ok":
+            result["Details"] = clean_item.status
+            result["Status"] = LookupStatus.BAD_FORMAT.value
+
+        return result
+
+    # pylint: disable=unused-argument
+    def __init__(self, **kwargs):
+        """Initialize the provider."""
+        self.description: Optional[str] = None
+        self._supported_types: Set[Type] = set()
+        self._supported_types = {
+            Type.parse(type.split("-")[0]) for type in self._QUERIES
+        }
+        if Type.unknown in self._supported_types:
+            self._supported_types.remove(Type.unknown)
+
+        self.require_url_encoding = False
+        self._preprocessors = PreProcessor()
+
+    @property
+    def name(self) -> str:
+        """Return the name of the provider."""
+        return self.__class__.__name__
+
+    def lookup_items(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup collection of items.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `item_col` parameter)
+            2. Dict of items
+            3. Iterable of items
+        item_col : str, optional
+            DataFrame column to use for items, by default None
+        item_type_col : str, optional
+            DataFrame column to use for types, by default None
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the type
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        """
+        results = []
+        for item, item_type in generate_items(data, item_col, item_type_col):
+            if not item:
+                continue
+            item_result = self.lookup_item(
+                item,
+                item_type,
+                query_type,
+                **kwargs,
+            )
+            results.append(item_result)
+
+        return pd.concat(results)
+
+    async def lookup_items_async(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Lookup collection of items.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `item_col` parameter)
+            2. Dict of items, Type
+            3. Iterable of items - Types will be inferred
+        item_col : str, optional
+            DataFrame column to use for items, by default None
+        item_type_col : str, optional
+            DataFrame column to use for Types, by default None
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        """
+        event_loop = get_event_loop()
+        results = []
+        prog_counter = kwargs.pop("prog_counter", None)
+        for item, item_type in generate_items(data, item_col, item_type_col):
+            item_type = kwargs.pop("item_type", item_type)
+            if not item:
+                continue
+            get_item = partial(
+                self.lookup_item,
+                item=item,
+                item_type=item_type,
+                query_type=query_type,
+                **kwargs,
+            )
+            item_result: pd.DataFrame = await event_loop.run_in_executor(None, get_item)
+            if prog_counter:
+                await prog_counter.decrement()
+            results.append(item_result)
+
+        return pd.concat(results)
+
+    @property
+    def item_query_defs(self) -> Dict[str, Any]:
+        """
+        Return current dictionary of IoC query/request definitions.
+
+        Returns
+        -------
+        Dict[str, Any]
+            IoC query/request definitions keyed by IoCType
+
+        """
+        return self._QUERIES
+
+    @classmethod
+    def is_known_type(cls, item_type: str) -> bool:
+        """
+        Return True if this a known IoC Type.
+
+        Parameters
+        ----------
+        item_type : str
+            IoCType string to test
+
+        Returns
+        -------
+        bool
+            True if known type.
+
+        """
+        return item_type in Type.__members__ and item_type != "unknown"
+
+    @property
+    def supported_types(self) -> List[str]:
+        """
+        Return list of supported types for this provider.
+
+        Returns
+        -------
+        List[str]
+            List of supported type names
+
+        """
+        return [item.name for item in self._supported_types]
+
+    @classmethod
+    def usage(cls):
+        """Print usage of provider."""
+        print(f"{cls.__doc__} Supported query types:")
+        for key in sorted(cls._QUERIES):
+            elements = key.split("-", maxsplit=1)
+            if len(elements) == 1:
+                print(f"\titem_type={elements[0]}")
+            if len(elements) == 2:
+                print(f"\titem_type={elements[0]}, query_type={elements[1]}")
+
+    def is_supported_type(self, item_type: Union[str, Type]) -> bool:
+        """
+        Return True if the passed type is supported.
+
+        Parameters
+        ----------
+        item_type : Union[str, Type]
+            type name or instance
+
+        Returns
+        -------
+        bool
+            True if supported.
+
+        """
+        if isinstance(item_type, str):
+            item_type = Type.parse(item_type)
+        return item_type.name in self.supported_types
+
+    @staticmethod
+    @lru_cache(maxsize=1024)
+    def resolve_item_type(item: str) -> str:
+        """
+        Return Type determined by ItemExtract.
+
+        Parameters
+        ----------
+        item : str
+            Item string
+
+        Returns
+        -------
+        str
+            Type (or unknown if type could not be determined)
+
+        """
+        return _ITEM_EXTRACT.get_ioc_type(item)
+
+    async def _lookup_items_async_wrapper(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        item_col: str = None,
+        item_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """
+        Async wrapper for providers that do not implement lookup_items_async.
+
+        Parameters
+        ----------
+        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
+            Data input in one of three formats:
+            1. Pandas dataframe (you must supply the column name in
+            `item_col` parameter)
+            2. Dict of items, Type
+            3. Iterable of items - Type will be inferred
+        item_col : str, optional
+            DataFrame column to use for items, by default None
+        item_type_col : str, optional
+            DataFrame column to use for Type, by default None
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the IoitemC type
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            DataFrame of results.
+
+        """
+        event_loop = get_event_loop()
+        prog_counter = kwargs.pop("prog_counter", None)
+        get_items = partial(
+            self.lookup_items,
+            data,
+            item_col,
+            item_type_col,
+            query_type,
+            **kwargs,
+        )
+        result = await event_loop.run_in_executor(None, get_items)
+        if prog_counter:
+            await prog_counter.decrement(len(data))  # type: ignore
+        return result
+
+
+class PivotProvider(ABC):
+    """A class which provides pivot functions and a means of registering them."""
+
+    @abstractmethod
+    def register_pivots(
+        self,
+        pivot_reg: "PivotRegistration",  # type: ignore # noqa: F821
+        pivot: "Pivot",  # type: ignore # noqa: F821
+    ):
+        """
+        Register pivot functions for a Provider.
+
+        Parameters
+        ----------
+        pivot_reg : PivotRegistration
+            Pivot registration settings.
+        pivot : Pivot
+            Pivot library instance
+
+        """
+
+
+@singledispatch
+def generate_items(
+    data: Any, item_col: Optional[str] = None, item_type_col: Optional[str] = None
+) -> Iterable[Tuple[Optional[str], Optional[str]]]:
+    """
+    Generate item pairs from different input types.
+
+    Parameters
+    ----------
+    data : Any
+        DataFrame, dictionary or iterable
+    item_col : Optional[str]
+        If `data` is a DataFrame, the column containing the item value.
+    item_type_col : Optional[str]
+        If `data` is a DataFrame, the column containing the item type.
+
+    Returns
+    -------
+    Iterable[Tuple[Optional[str], Optional[str]]]] - a tuple of Observable/Type.
+
+    """
+    del item_col, item_type_col
+
+    if isinstance(data, collections.abc.Iterable):
+        for item in data:
+            yield item, Provider.resolve_item_type(item)
+    else:
+        yield None, None
+
+
+@generate_items.register(pd.DataFrame)
+def _(data: pd.DataFrame, item_col: str, item_type_col: Optional[str] = None):
+    for _, row in data.iterrows():
+        if item_type_col is None:
+            yield row[item_col], Provider.resolve_item_type(row[item_col])
+        else:
+            yield row[item_col], row[item_type_col]
+
+
+@generate_items.register(dict)  # type: ignore
+def _(data: dict, item_col: Optional[str] = None, item_type_col: Optional[str] = None):
+    for item, item_type in data.items():
+        if not item_type:
+            item_type = Provider.resolve_item_type(item)
+        yield item, item_type
+
+
+def _make_sync(future):
+    """Wait for an async call, making it sync."""
+    try:
+        event_loop = asyncio.get_event_loop()
+    except RuntimeError:
+        # Generate an event loop if there isn't any.
+        event_loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(event_loop)
+    return event_loop.run_until_complete(future)
diff --git a/msticpy/context/tilookup.py b/msticpy/context/tilookup.py
index 5fb3122a3..621735e57 100644
--- a/msticpy/context/tilookup.py
+++ b/msticpy/context/tilookup.py
@@ -13,402 +13,57 @@
 
 """
 
-import asyncio
-import warnings
-from collections import ChainMap
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union
+from typing import Iterable, List, Mapping, Optional, Union
 
-import attr
-import nest_asyncio
 import pandas as pd
-from tqdm.auto import tqdm
 
 from .._version import VERSION
-from ..common.exceptions import MsticpyConfigException, MsticpyUserConfigError
-from ..common.provider_settings import get_provider_settings, reload_settings
-from ..common.utility import export, is_ipython
-from ..vis.ti_browser import browse_results
+from ..common.utility import export
+
+from .provider_base import _make_sync
+from .lookup import Lookup
 
 # used in dynamic instantiation of providers
-from .tiproviders import TI_PROVIDERS, import_provider
-from .tiproviders.ti_provider_base import (  # noqa:F401
-    LookupResult,
-    LookupStatus,
-    TIProvider,
-)
+from .tiproviders import TI_PROVIDERS
+
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
 
 
-_NO_PROVIDERS_MSSG = """
-No TI Providers are loaded - please check that
-you have correctly configured your msticpyconfig.yaml settings.
-"""
-_TI_HELP_URI = (
-    "https://msticpy.readthedocs.io/en/latest/data_acquisition/"
-    "TIProviders.html#configuration-file"
-)
-
-
-class ProgressCounter:
-    """Progress counter for async tasks."""
-
-    def __init__(self, total: int):
-        """Initialize the class."""
-        self.total = total
-        self._lock: asyncio.Condition = asyncio.Condition()
-        self._remaining: int = total
-
-    async def decrement(self, increment: int = 1):
-        """Decrement the counter."""
-        if self._remaining == 0:
-            return
-        async with self._lock:
-            self._remaining -= increment
-
-    async def get_remaining(self) -> int:
-        """Get the current remaining count."""
-        async with self._lock:
-            return self._remaining
-
-
 @export
-class TILookup:
+class TILookup(Lookup):
     """Threat Intel observable lookup from providers."""
 
-    def __init__(self, providers: Optional[List[str]] = None, **kwargs):
-        """
-        Initialize TILookup instance.
-
-        Parameters
-        ----------
-        primary_providers : Optional[List[TIProvider]], optional
-            Primary TI Providers, by default None
-        secondary_providers : Optional[List[TIProvider]], optional
-            Secondary TI Providers, by default None
-        providers: Optional[List[str]], optional
-            List of provider names to load, by default all available
-            providers are loaded. To see the list of available providers
-            call `TILookup.list_available_providers()`.
-            Note: if primary_provides or secondary_providers is specified
-            This will override the providers list.
-
-        """
-        self._providers: Dict[str, TIProvider] = {}
-        self._secondary_providers: Dict[str, TIProvider] = {}
-        self._providers_to_load = providers
-
-        primary_providers = kwargs.pop("primary_providers", None)
-        if primary_providers:
-
-            for prov in primary_providers:
-                self.add_provider(prov, primary=True)
-        secondary_providers = kwargs.pop("secondary_providers", None)
-        if secondary_providers:
-            warnings.warn(
-                "'secondary_providers' is a deprecated parameter", DeprecationWarning
-            )
-            for prov in secondary_providers:
-                self.add_provider(prov, primary=False)
-        if not (primary_providers or secondary_providers):
-            self._load_providers()
-
-        self._all_providers = ChainMap(self._secondary_providers, self._providers)
-        if is_ipython():
-            nest_asyncio.apply()
-
-    @property
-    def loaded_providers(self) -> Dict[str, TIProvider]:
-        """
-        Return dictionary of loaded providers.
-
-        Returns
-        -------
-        Dict[str, TIProvider]
-            [description]
-
-        """
-        return dict(self._all_providers)
-
-    @property
-    def provider_status(self) -> Iterable[str]:
-        """
-        Return loaded provider status.
-
-        Returns
-        -------
-        Iterable[str]
-            List of providers and descriptions.
-
-        """
-        prim = [
-            f"{prov_name} - {prov.description} (primary)"
-            for prov_name, prov in self._providers.items()
-        ]
-        sec = [
-            f"{prov_name} - {prov.description} (secondary)"
-            for prov_name, prov in self._secondary_providers.items()
-        ]
-        return prim + sec
-
-    @property
-    def configured_providers(self) -> List[str]:
-        """
-        Return a list of avaliable providers that have configuration details present.
-
-        Returns
-        -------
-        List[str]
-            List of TI Provider classes.
-
-        """
-        prim_conf = list(self._providers.keys())
-        sec_conf = list(self._secondary_providers.keys())
-
-        return prim_conf + sec_conf
-
-    @property
-    def available_providers(self) -> List[str]:
-        """
-        Return a list of builtin providers.
-
-        Returns
-        -------
-        List[str]
-            List of TI Provider classes.
-
-        """
-        return list(TI_PROVIDERS)
-
-    def enable_provider(self, providers: Union[str, Iterable[str]]):
-        """
-        Set the provider(s) as primary (used by default).
-
-        Parameters
-        ----------
-        providers : Union[str, Iterable[str]
-            Provider name or list of names.
-            Use `list_available_providers()` to see the list of loaded providers.
-
-        Raises
-        ------
-        ValueError
-            If the provider name is not recognized.
-
-        """
-        provs_to_enable = [providers] if isinstance(providers, str) else providers
-        for provider in provs_to_enable:
-            if provider in self._secondary_providers:
-                self._providers[provider] = self._secondary_providers[provider]
-                del self._secondary_providers[provider]
-            elif provider not in self._providers:
-                raise ValueError(
-                    f"Unknown provider '{provider}'. Available providers:",
-                    ", ".join(self.list_available_providers(as_list=True)),  # type: ignore
-                )
-
-    def disable_provider(self, providers: Union[str, Iterable[str]]):
-        """
-        Set the provider as secondary (not used by default).
-
-        Parameters
-        ----------
-        providers : Union[str, Iterable[str]
-            Provider name or list of names.
-            Use `list_available_providers()` to see the list of loaded providers.
-
-        Raises
-        ------
-        ValueError
-            If the provider name is not recognized.
-
-        """
-        provs_to_disable = [providers] if isinstance(providers, str) else providers
-        for provider in provs_to_disable:
-            if provider in self._providers:
-                self._secondary_providers[provider] = self._providers[provider]
-                del self._providers[provider]
-            elif provider not in self._secondary_providers:
-                raise ValueError(
-                    f"Unknown provider '{provider}'. Available providers:",
-                    ", ".join(self.list_available_providers(as_list=True)),  # type: ignore
-                )
-
-    def set_provider_state(self, prov_dict: Dict[str, bool]):
-        """
-        Set a dict of providers to primary/secondary.
-
-        Parameters
-        ----------
-        prov_dict : Dict[str, bool]
-            Dictionary of provider name and bool - True if enabled/primary,
-            False if disabled/secondary.
-
-        """
-        for prov, state in prov_dict.items():
-            if state:
-                self.enable_provider(prov)
-            else:
-                self.disable_provider(prov)
-
-    @classmethod
-    def list_available_providers(
-        cls, show_query_types=False, as_list: bool = False
-    ) -> Optional[List[str]]:  # type: ignore
-        """
-        Print a list of builtin providers with optional usage.
-
-        Parameters
-        ----------
-        show_query_types : bool, optional
-            Show query types supported by providers, by default False
-        as_list : bool, optional
-            Return list of providers instead of printing to stdout.
-            Note: if you specify `show_query_types` this will be printed
-            irrespective of this parameter setting.
-
-        Returns
-        -------
-        Optional[List[str]]
-            A list of provider names (if `return_list=True`)
-
-        """
-        providers = []
-        for provider_name in TI_PROVIDERS:
-            provider_class = import_provider(provider_name)
-            if not as_list:
-                print(provider_name)
-            providers.append(provider_name)
-            if show_query_types and provider_class:
-                provider_class.usage()
-
-        if as_list:
-            return providers
-        return None
-
-    @classmethod
-    def browse_results(
-        cls, data: pd.DataFrame, severities: Optional[List[str]] = None, **kwargs
-    ):
-        """
-        Return TI Results list browser.
-
-        Parameters
-        ----------
-        data : pd.DataFrame
-            TI Results data from TIProviders
-        severities : Optional[List[str]], optional
-            A list of the severity classes to show.
-            By default these are ['warning', 'high'].
-            Pass ['information', 'warning', 'high'] to see all
-            results.
-
-        Other Parameters
-        ----------------
-        kwargs :
-            passed to SelectItem constructor.
-
-        Returns
-        -------
-        SelectItem
-            SelectItem browser for TI Data.
-
-        """
-        if not isinstance(data, pd.DataFrame):
-            try:
-                data = cls.result_to_df(data)
-            # pylint: disable=broad-except
-            except Exception:
-                print("Input data is in an unexpected format.")
-                return None
-            # pylint: enable=broad-except
-        return browse_results(data=data, severities=severities, **kwargs)
-
-    browse = browse_results
-
-    def provider_usage(self):
-        """Print usage of loaded providers."""
-        print("Primary providers")
-        print("-----------------")
-        if self._providers:
-            for prov_name, prov in self._providers.items():
-                print(f"\nProvider class: {prov_name}")
-                prov.usage()
-        else:
-            print("none")
-        print("\nSecondary providers")
-        print("-------------------")
-        if self._secondary_providers:
-            for prov_name, prov in self._secondary_providers.items():
-                print(f"\nProvider class: {prov_name}")
-                prov.usage()
-        else:
-            print("none")
-
-    @classmethod
-    def reload_provider_settings(cls):
-        """Reload provider settings from config."""
-        reload_settings()
-        print(
-            "Settings reloaded. Use reload_providers to update settings",
-            "for loaded providers.",
-        )
-
-    def reload_providers(self):
-        """Reload settings and provider classes."""
-        reload_settings()
-        self._load_providers()
+    _NO_PROVIDERS_MSG = """
+    No TI Providers are loaded - please check that
+    you have correctly configured your msticpyconfig.yaml settings.
+    """
+    _HELP_URI = (
+        "https://msticpy.readthedocs.io/en/latest/data_acquisition/"
+        "TIProviders.html#configuration-file"
+    )
 
-    def add_provider(
-        self, provider: TIProvider, name: str = None, primary: bool = True
-    ):
-        """
-        Add a TI provider to the current collection.
-
-        Parameters
-        ----------
-        provider : TIProvider
-            Provider instance
-        name : str, optional
-            The name to use for the provider (overrides the class name
-            of `provider`)
-        primary : bool, optional
-            "primary" or "secondary" if False, by default "primary"
-
-        """
-        if not name:
-            name = provider.__class__.__name__
-        if primary:
-            self._providers[name] = provider
-        else:
-            self._secondary_providers[name] = provider
-
-    @property
-    def available_query_types(self) -> List[str]:
-        """Return query types available from loaded providers."""
-        query_types: Set[str] = set()
-        for provider in self._providers.values():
-            query_types.update(provider.ioc_query_defs.keys())
-        return sorted(query_types)
+    PROVIDERS = TI_PROVIDERS
+    PACKAGE = "tiproviders"
 
     # pylint: disable=too-many-arguments
     def lookup_ioc(
         self,
-        observable: str = None,
-        ioc_type: str = None,
-        ioc_query_type: str = None,
-        providers: List[str] = None,
+        ioc: str,
+        ioc_type: Optional[str] = None,
+        ioc_query_type: Optional[str] = None,
+        providers: Optional[List[str]] = None,
         default_providers: Optional[List[str]] = None,
         prov_scope: str = "primary",
         **kwargs,
-    ) -> Tuple[bool, List[Tuple[str, LookupResult]]]:
+    ) -> pd.DataFrame:
         """
         Lookup single IoC in active providers.
 
         Parameters
         ----------
-        observable : str
+        ioc : str
             IoC observable
             (`ioc` is also an alias for observable)
         ioc_type : str, optional
@@ -428,41 +83,29 @@ def lookup_ioc(
 
         Returns
         -------
-        Tuple[bool, List[Tuple[str, LookupResult]]]
+        pd.DataFrame
             The result returned as a tuple(bool, list):
             bool indicates whether a TI record was found in any provider
             list has an entry for each provider result
 
         """
-        if not observable and "ioc" in kwargs:
-            observable = kwargs["ioc"]
-        if not observable:
-            raise ValueError("observable or ioc parameter must be supplied.")
-
-        result_list: List[Tuple[str, LookupResult]] = []
-        selected_providers = self._select_providers(
-            providers or default_providers, prov_scope
+        ioc = ioc or kwargs.pop("observable", None)
+        if ioc is None:
+            raise ValueError("No value supplied for 'ioc' parameter")
+        return self.lookup_item(
+            item=ioc,
+            item_type=ioc_type,
+            query_type=ioc_query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
         )
-        if not selected_providers:
-            raise MsticpyUserConfigError(
-                _NO_PROVIDERS_MSSG,
-                title="No Threat Intel Provider configuration found.",
-                help_uri=_TI_HELP_URI,
-            )
-
-        ioc_type = ioc_type or TIProvider.resolve_ioc_type(observable)
-        for prov_name, provider in selected_providers.items():
-            provider_result: LookupResult = provider.lookup_ioc(
-                ioc=observable, ioc_type=ioc_type, query_type=ioc_query_type, **kwargs
-            )
-            result_list.append((prov_name, provider_result))
-        overall_result = any(res.result for _, res in result_list)
-        return overall_result, result_list
 
     def lookup_iocs(
         self,
         data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         ioc_query_type: str = None,
         providers: List[str] = None,
@@ -478,10 +121,10 @@ def lookup_iocs(
         data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
             Data input in one of three formats:
             1. Pandas dataframe (you must supply the column name in
-            `obs_col` parameter)
+            `ioc_col` parameter)
             2. Mapping (e.g. a dict) of [observable, IoCType]
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
             ("col" and "column" are also aliases for this parameter)
         ioc_type_col : str, optional
@@ -507,7 +150,7 @@ def lookup_iocs(
         return _make_sync(
             self._lookup_iocs_async(
                 data=data,
-                obs_col=obs_col,
+                ioc_col=ioc_col,
                 ioc_type_col=ioc_type_col,
                 ioc_query_type=ioc_query_type,
                 providers=providers,
@@ -521,7 +164,7 @@ def lookup_iocs(
     async def _lookup_iocs_async(
         self,
         data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         ioc_query_type: str = None,
         providers: List[str] = None,
@@ -530,49 +173,21 @@ async def _lookup_iocs_async(
         **kwargs,
     ) -> pd.DataFrame:
         """Lookup IoCs async."""
-        obs_col = obs_col or kwargs.pop("col", kwargs.pop("column", None))
-
-        selected_providers = self._select_providers(
-            providers or default_providers, prov_scope
+        return await self._lookup_items_async(
+            data,
+            item_col=ioc_col,
+            item_type_col=ioc_type_col,
+            ioc_query_type=ioc_query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
         )
-        if not selected_providers:
-            raise MsticpyUserConfigError(
-                _NO_PROVIDERS_MSSG,
-                title="No Threat Intel Provider configuration found.",
-                help_uri=_TI_HELP_URI,
-            )
-
-        event_loop = asyncio.get_event_loop()
-        result_futures: List[Any] = []
-        provider_names: List[str] = []
-        prog_counter = ProgressCounter(
-            total=len(data) * len(selected_providers)  # type: ignore
-        )
-        # create a list of futures/tasks to await
-        for prov_name, provider in selected_providers.items():
-            provider_names.append(prov_name)
-            result_futures.append(
-                provider.lookup_iocs_async(
-                    data=data,
-                    obs_col=obs_col,
-                    ioc_type_col=ioc_type_col,
-                    query_type=ioc_query_type,
-                    prog_counter=prog_counter,
-                    **kwargs,
-                )
-            )
-        # Create a task for tqdm
-        prog_task = event_loop.create_task(self._track_completion(prog_counter))
-        # collect the return values of the tasks
-        results = await asyncio.gather(*result_futures)
-        # cancel the progress task if results have completed.
-        prog_task.cancel()
-        return self._combine_results(results, provider_names, kwargs)
 
     def lookup_iocs_sync(
         self,
         data: Union[pd.DataFrame, Mapping[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         ioc_query_type: str = None,
         providers: List[str] = None,
@@ -588,10 +203,10 @@ def lookup_iocs_sync(
         data : Union[pd.DataFrame, Mapping[str, str], Iterable[str]]
             Data input in one of three formats:
             1. Pandas dataframe (you must supply the column name in
-            `obs_col` parameter)
+            `ioc_col` parameter)
             2. Mapping (e.g. a dict) of [observable, IoCType]
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
             ("col" and "column" are also aliases for this parameter)
         ioc_type_col : str, optional
@@ -614,194 +229,17 @@ def lookup_iocs_sync(
             DataFrame of results
 
         """
-        obs_col = obs_col or kwargs.pop("col", kwargs.pop("column", None))
-
-        selected_providers = self._select_providers(
-            providers or default_providers, prov_scope
-        )
-        if not selected_providers:
-            raise MsticpyUserConfigError(
-                _NO_PROVIDERS_MSSG,
-                title="No Threat Intel Provider configuration found.",
-                help_uri=_TI_HELP_URI,
-            )
-
-        results: List[Any] = []
-        provider_names: List[str] = []
-        for prov_name, provider in selected_providers.items():
-            provider_names.append(prov_name)
-            results.append(
-                provider.lookup_iocs(
-                    data=data,
-                    obs_col=obs_col,
-                    ioc_type_col=ioc_type_col,
-                    query_type=ioc_query_type,
-                    **kwargs,
-                )
-            )
-        return self._combine_results(results, provider_names, kwargs)
-
-    @staticmethod
-    def result_to_df(
-        ioc_lookup: Tuple[bool, List[Tuple[str, LookupResult]]]
-    ) -> pd.DataFrame:
-        """
-        Return DataFrame representation of IoC Lookup response.
-
-        Parameters
-        ----------
-        ioc_lookup : Tuple[bool, List[Tuple[str, LookupResult]]]
-            Output from `lookup_ioc`
-
-        Returns
-        -------
-        pd.DataFrame
-            The response as a DataFrame with a row for each
-            provider response.
-
-        """
-        return (
-            pd.DataFrame(
-                {
-                    r_item[0]: pd.Series(attr.asdict(r_item[1]))
-                    for r_item in ioc_lookup[1]
-                }
-            )
-            .T.rename(columns=LookupResult.column_map())
-            .drop("SanitizedValue", errors="ignore", axis=1)
+        return self.lookup_items_sync(
+            data,
+            item_col=ioc_col,
+            item_type_col=ioc_type_col,
+            query_type=ioc_query_type,
+            providers=providers,
+            default_providers=default_providers,
+            prov_scope=prov_scope,
+            **kwargs,
         )
 
-    @staticmethod
-    async def _track_completion(prog_counter):
-        total = await prog_counter.get_remaining()
-        with tqdm(total=total, unit="obs", desc="Observables processed") as prog_bar:
-            try:
-                last_remaining = total
-                while last_remaining:
-                    new_remaining = await prog_counter.get_remaining()
-                    incr = last_remaining - new_remaining
-                    if incr:
-                        prog_bar.update(incr)
-                    last_remaining = new_remaining
-                    # print(f"progress: incr {incr}, last: {last_remaining}")
-                    await asyncio.sleep(0)
-            except asyncio.CancelledError:
-                # make progress bar get to 100% on cancel
-                final_remaining = await prog_counter.get_remaining()
-                if final_remaining:
-                    prog_bar.update(total - final_remaining)
-
-    def _load_providers(self):
+    def _load_providers(self, **kwargs):
         """Load provider classes based on config."""
-        prov_settings = get_provider_settings("TIProviders")
-
-        for provider_entry, settings in prov_settings.items():
-            # Allow overriding provider name to use another class
-            provider_name = settings.provider or provider_entry
-            if (
-                self._providers_to_load is not None
-                and provider_name not in self._providers_to_load
-            ) or provider_name == "--no-load--":
-                continue
-            try:
-                provider_class: TIProvider = import_provider(provider_name)
-            except LookupError:
-                warnings.warn(
-                    f"Could not find provider class for {provider_name} "
-                    f"in config section {provider_entry}"
-                )
-                continue
-
-            # instantiate class sending args from settings to init
-            try:
-                provider_instance = provider_class(**(settings.args))
-            except MsticpyConfigException as mp_ex:
-                # If the TI Provider didn't load, raise an exception
-                raise MsticpyUserConfigError(
-                    f"Could not load TI Provider {provider_name}",
-                    *mp_ex.args,
-                    "To avoid loading this provider please use the 'providers' parameter"
-                    + " to TILookup() to specify which providers to load.",
-                    title="TIProvider configuration error",
-                    help_uri=(
-                        "https://msticpy.readthedocs.io/en/latest/data_acquisition/TIProviders.html"
-                        + "#configuration-file"
-                    ),
-                ) from mp_ex
-
-            # set the description from settings, if one is provided, otherwise
-            # use class docstring.
-            provider_instance.description = (
-                settings.description or provider_instance.__doc__
-            )
-
-            self.add_provider(
-                provider=provider_instance, name=provider_name, primary=settings.primary
-            )
-
-    def _select_providers(
-        self, providers: List[str] = None, prov_scope: str = "primary"
-    ) -> Dict[str, TIProvider]:
-        """
-        Return required subset of providers.
-
-        Parameters
-        ----------
-        providers : List[str], optional
-            Explicit list of provider names, by default None
-        prov_scope : str, optional
-            Provider scope, by default "primary"
-            Other values are "all" and "secondary"
-
-        Returns
-        -------
-        Dict[str, TIProvider]
-            Dictionary of provider names and instances.
-
-        """
-        if providers:
-            return {
-                prov_name: prov
-                for prov_name, prov in self._all_providers.items()
-                if prov_name in providers
-            }
-        if prov_scope == "all":
-            return dict(self._all_providers)
-        if prov_scope == "primary":
-            return self._providers
-        return self._secondary_providers
-
-    @staticmethod
-    def _combine_results(
-        results: Iterable[pd.DataFrame], provider_names: List[str], kwargs
-    ):
-        """Combine dataframe results into single DF."""
-        result_list: List[pd.DataFrame] = []
-        for prov_name, provider_result in zip(provider_names, results):
-            if provider_result is None or provider_result.empty:
-                continue
-            if not kwargs.get("show_not_supported", False):
-                provider_result = provider_result[
-                    provider_result["Status"] != LookupStatus.NOT_SUPPORTED.value
-                ]
-            if not kwargs.get("show_bad_ioc", False):
-                provider_result = provider_result[
-                    provider_result["Status"] != LookupStatus.BAD_FORMAT.value
-                ]
-            provider_result["Provider"] = prov_name
-            result_list.append(provider_result)
-
-        if not result_list:
-            print("No IoC matches")
-        return pd.concat(result_list, sort=False) if result_list else None
-
-
-def _make_sync(future):
-    """Wait for an async call, making it sync."""
-    try:
-        event_loop = asyncio.get_event_loop()
-    except RuntimeError:
-        # Generate an event loop if there isn't any.
-        event_loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(event_loop)
-    return event_loop.run_until_complete(future)
+        return super()._load_providers(providers="TIProviders", **kwargs)
diff --git a/msticpy/context/tiproviders/__init__.py b/msticpy/context/tiproviders/__init__.py
index 6c0dbc8ac..7356ce2a8 100644
--- a/msticpy/context/tiproviders/__init__.py
+++ b/msticpy/context/tiproviders/__init__.py
@@ -4,17 +4,15 @@
 # license information.
 # --------------------------------------------------------------------------
 """TI Providers sub-package."""
-import importlib
-
+from typing import Dict, Tuple
 from ..._version import VERSION
-from .http_provider import HttpTIProvider  # noqa:F401
-from .lookup_result import LookupResult  # noqa:F401
-from .preprocess_observable import preprocess_observable  # noqa:F401
 from .ti_provider_base import TIProvider  # noqa:F401
+from .ti_http_provider import HttpTIProvider  # noqa:F401
+from ..preprocess_observable import preprocess_observable  # noqa:F401
 
 __version__ = VERSION
 
-TI_PROVIDERS = {
+TI_PROVIDERS: Dict[str, Tuple[str, str]] = {
     "OTX": ("alienvault_otx", "OTX"),
     "AzSTI": ("azure_sent_byoti", "AzSTI"),
     "GreyNoise": ("greynoise", "GreyNoise"),
@@ -25,20 +23,3 @@
     "VirusTotal": ("virustotal", "VirusTotal"),
     "RiskIQ": ("riskiq", "RiskIQ"),
 }
-
-
-def import_provider(provider: str) -> TIProvider:
-    """Import provider class."""
-    mod_name, cls_name = TI_PROVIDERS.get(provider, (None, None))
-
-    if not (mod_name and cls_name):
-        raise LookupError(
-            f"No driver available for environment {provider}.",
-            "Possible values are:",
-            ", ".join(list(TI_PROVIDERS)),
-        )
-
-    imp_module = importlib.import_module(
-        f"msticpy.context.tiproviders.{mod_name}", package="msticpy"
-    )
-    return getattr(imp_module, cls_name)
diff --git a/msticpy/context/tiproviders/alienvault_otx.py b/msticpy/context/tiproviders/alienvault_otx.py
index a65eba45a..d71d34bf2 100644
--- a/msticpy/context/tiproviders/alienvault_otx.py
+++ b/msticpy/context/tiproviders/alienvault_otx.py
@@ -12,14 +12,16 @@
 requests per minute for the account type that you have.
 
 """
-from typing import Any, Tuple
+from typing import Any, Tuple, Dict
 
 import attr
 
 from ..._version import VERSION
 from ...common.utility import export
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .ti_provider_base import ResultSeverity
+
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -27,8 +29,8 @@
 
 # pylint: disable=too-few-public-methods
 @attr.s
-class _OTXParams(IoCLookupParams):
-    # override IoCLookupParams to set common defaults
+class _OTXParams(APILookupParams):
+    # override APILookupParams to set common defaults
     def __attrs_post_init__(self):
         # pylint: disable=
         self.headers = {"X-OTX-API-KEY": "{API_KEY}"}
@@ -40,7 +42,7 @@ class OTX(HttpTIProvider):
 
     _BASE_URL = "https://otx.alienvault.com"
 
-    _IOC_QUERIES = {
+    _QUERIES = {
         "ipv4": _OTXParams(path="/api/v1/indicators/IPv4/{observable}/general"),
         "ipv6": _OTXParams(path="/api/v1/indicators/IPv6/{observable}/general"),
         "ipv4-passivedns": _OTXParams(
@@ -62,9 +64,9 @@ class OTX(HttpTIProvider):
     }
 
     # aliases
-    _IOC_QUERIES["md5_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha1_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha256_hash"] = _IOC_QUERIES["file_hash"]
+    _QUERIES["md5_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha1_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha256_hash"] = _QUERIES["file_hash"]
 
     _REQUIRED_PARAMS = ["API_KEY"]
 
@@ -73,13 +75,13 @@ def __init__(self, **kwargs):
         super().__init__(**kwargs)
         self.require_url_encoding = True
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -90,10 +92,12 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, ResultSeverity.information, "Not found."
-        if "pulse_info" in response.raw_result:
-            pulses = response.raw_result["pulse_info"].get("pulses", {})
+        if "pulse_info" in response["RawResult"]:
+            pulses = response["RawResult"]["pulse_info"].get("pulses", {})
             pulse_count = len(pulses)
             if pulse_count == 0:
                 severity = ResultSeverity.information
@@ -102,7 +106,7 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
                     severity,
                     {
                         "pulse_count": pulse_count,
-                        "sections_available": response.raw_result["sections"],
+                        "sections_available": response["RawResult"]["sections"],
                     },
                 )
             severity = (
diff --git a/msticpy/context/tiproviders/azure_sent_byoti.py b/msticpy/context/tiproviders/azure_sent_byoti.py
index dda5645aa..83f351f2e 100644
--- a/msticpy/context/tiproviders/azure_sent_byoti.py
+++ b/msticpy/context/tiproviders/azure_sent_byoti.py
@@ -19,7 +19,7 @@
 from ..._version import VERSION
 from ...common.utility import export
 from .kql_base import KqlTIProvider
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_provider_base import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -29,7 +29,7 @@
 class AzSTI(KqlTIProvider):
     """Microsoft Sentinel TI provider class."""
 
-    _IOC_QUERIES: Dict[str, tuple] = {
+    _QUERIES: Dict[str, tuple] = {
         "ipv4": ("ThreatIntelligence.list_indicators_by_ip", {"ioc": "observables"}),
         "file_hash": (
             "ThreatIntelligence.list_indicators_by_hash",
@@ -44,22 +44,22 @@ class AzSTI(KqlTIProvider):
     }
 
     # aliases
-    _IOC_QUERIES["ipv6"] = _IOC_QUERIES["ipv4"]
-    _IOC_QUERIES["md5_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha1_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha256_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["linux_path"] = _IOC_QUERIES["windows_path"]
-    _IOC_QUERIES["hostname"] = _IOC_QUERIES["dns"]
+    _QUERIES["ipv6"] = _QUERIES["ipv4"]
+    _QUERIES["md5_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha1_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha256_hash"] = _QUERIES["file_hash"]
+    _QUERIES["linux_path"] = _QUERIES["windows_path"]
+    _QUERIES["hostname"] = _QUERIES["dns"]
 
     _REQUIRED_TABLES = ["ThreatIntelligenceIndicator"]
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -70,13 +70,13 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if response.raw_result is None:
+        if response["RawResult"] is None:
             return False, ResultSeverity.information, "No data"
 
         severity = ResultSeverity.warning
         # if this is a series (single row) return a dictionary
-        if isinstance(response.raw_result, pd.Series):
-            extracted_data = response.raw_result[
+        if isinstance(response["RawResult"], pd.Series):
+            extracted_data = response["RawResult"][
                 ["Action", "ThreatType", "ThreatSeverity", "Active", "ConfidenceScore"]
             ].to_dict()
             if extracted_data["Action"].lower() in ["alert", "block"]:
@@ -85,8 +85,8 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
         # if this is a dataframe (multiple rows)
         # concatenate the values for each column/record into a list
         # and return as a dictionary
-        if isinstance(response.raw_result, pd.DataFrame):
-            d_frame = response.raw_result
+        if isinstance(response["RawResult"], pd.DataFrame):
+            d_frame = response["RawResult"]
             if d_frame["Action"].str.lower().isin(["alert", "block"]).any():
                 severity = ResultSeverity.high
 
@@ -124,8 +124,8 @@ def _get_detail_summary(data_result: pd.DataFrame) -> pd.Series:
     def _get_severity(data_result: pd.DataFrame) -> pd.Series:
         # For the input frame return severity in a series
         return data_result.apply(
-            lambda x: ResultSeverity.high.value
+            lambda x: ResultSeverity.high.name
             if x.Action.lower() in ["alert", "block"]
-            else ResultSeverity.warning.value,
+            else ResultSeverity.warning.name,
             axis=1,
         )
diff --git a/msticpy/context/tiproviders/greynoise.py b/msticpy/context/tiproviders/greynoise.py
index 37158171a..99f74c8ef 100644
--- a/msticpy/context/tiproviders/greynoise.py
+++ b/msticpy/context/tiproviders/greynoise.py
@@ -12,11 +12,12 @@
 requests per minute for the account type that you have.
 
 """
-from typing import Any, Tuple
+from typing import Any, Tuple, Dict
 
 from ..._version import VERSION
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .ti_provider_base import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Pete Bryan"
@@ -27,31 +28,31 @@ class GreyNoise(HttpTIProvider):
 
     _BASE_URL = "https://api.greynoise.io"
 
-    _IOC_QUERIES = {
+    _QUERIES = {
         # Community API
-        "ipv4": IoCLookupParams(
+        "ipv4": APILookupParams(
             path="/v3/community/{observable}",
             headers={"key": "{API_KEY}"},
         ),
         # Enterprise API Quick Lookup
-        "ipv4-quick": IoCLookupParams(
+        "ipv4-quick": APILookupParams(
             path="/v2/noise/quick/{observable}",
             headers={"key": "{API_KEY}"},
         ),
         # Enterprise API Full Lookup
-        "ipv4-full": IoCLookupParams(
+        "ipv4-full": APILookupParams(
             path="/v2/noise/context/{observable}",
             headers={"key": "{API_KEY}"},
         ),
     }
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -62,40 +63,42 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, ResultSeverity.information, "Not found."
         result = True
         result_dict = {}
         # If community API response extract key elements
-        if "riot" in response.raw_result:
+        if "riot" in response["RawResult"]:
             result_dict.update(
                 {
-                    "Classification": response.raw_result.get("classification"),
-                    "Name": response.raw_result.get("name"),
-                    "Last Seen": response.raw_result.get("last_seen"),
-                    "Message": response.raw_result.get("message"),
-                    "Noise": response.raw_result.get("noise"),
-                    "RIOT": response.raw_result.get("riot"),
+                    "Classification": response["RawResult"].get("classification"),
+                    "Name": response["RawResult"].get("name"),
+                    "Last Seen": response["RawResult"].get("last_seen"),
+                    "Message": response["RawResult"].get("message"),
+                    "Noise": response["RawResult"].get("noise"),
+                    "RIOT": response["RawResult"].get("riot"),
                 }
             )
         # If enterprise full lookup response extract key elements
-        if "actor" in response.raw_result:
+        if "actor" in response["RawResult"]:
             result_dict.update(
                 {
-                    "Classification": response.raw_result.get("classification"),
-                    "First Seen": response.raw_result.get("first_seen"),
-                    "Last Seen": response.raw_result.get("last_seen"),
-                    "Actor": response.raw_result.get("actor"),
-                    "Tags": response.raw_result.get("tags"),
-                    "VPN": response.raw_result.get("vpn_service", False),
-                    "Metadata": response.raw_result.get("metadata"),
+                    "Classification": response["RawResult"].get("classification"),
+                    "First Seen": response["RawResult"].get("first_seen"),
+                    "Last Seen": response["RawResult"].get("last_seen"),
+                    "Actor": response["RawResult"].get("actor"),
+                    "Tags": response["RawResult"].get("tags"),
+                    "VPN": response["RawResult"].get("vpn_service", False),
+                    "Metadata": response["RawResult"].get("metadata"),
                 }
             )
         # If enterprise quick lookup just return raw data is its so small
-        if "code" in response.raw_result:
-            result_dict = response.raw_result
+        if "code" in response["RawResult"]:
+            result_dict = response["RawResult"]
 
         severity = ResultSeverity.information
-        if response.raw_result["classification"] == "malicious":
+        if response["RawResult"]["classification"] == "malicious":
             severity = ResultSeverity.high
         return result, severity, result_dict
diff --git a/msticpy/context/tiproviders/http_lookup.py b/msticpy/context/tiproviders/http_lookup.py
deleted file mode 100644
index 5ac7149c6..000000000
--- a/msticpy/context/tiproviders/http_lookup.py
+++ /dev/null
@@ -1,217 +0,0 @@
-# -------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for
-# license information.
-# --------------------------------------------------------------------------
-"""
-HTTP TI Provider base.
-
-Input can be a single IoC observable or a pandas DataFrame containing
-multiple observables. Processing may require a an API key and
-processing performance may be limited to a specific number of
-requests per minute for the account type that you have.
-
-"""
-import abc
-from typing import Dict, Iterable, List, Union
-
-import attr
-import pandas as pd
-from attr import Factory
-
-from ..._version import VERSION
-from .http_provider import HttpTIProvider
-from .ti_provider_base import LookupResult
-
-__version__ = VERSION
-__author__ = "Ian Hellen"
-
-
-# pylint: disable=too-few-public-methods
-@attr.s(auto_attribs=True)
-class APILookupParams:
-    """IoC HTTP Lookup Params definition."""
-
-    path: str = ""
-    verb: str = "GET"
-    full_url: bool = False
-    headers: Dict[str, str] = Factory(dict)
-    params: Dict[str, str] = Factory(dict)
-    data: Dict[str, str] = Factory(dict)
-    auth_type: str = ""
-    auth_str: List[str] = Factory(list)
-    sub_type: str = ""
-
-
-class HttpLookupProvider(HttpTIProvider, abc.ABC):
-    """
-    HTTP Generic lookup provider base class.
-
-    For subclasses:
-
-    Define Base URL of the service
-
-    .. code:: python
-
-        _BASE_URL = "https://my.api.org/"
-
-    Define query parameters for different item types (keys)
-
-    ..code:: python
-
-        _API_QUERIES: Dict[str, APILookupParams] = {}
-
-    For example:
-
-    .. code:: python
-
-        _IOC_QUERIES = {
-        # Community API
-        "ipv4": IoCLookupParams(
-            path="/v3/community/{observable}",
-            headers={"key": "{API_KEY}"},
-        ),
-        # Enterprise API Quick Lookup
-        "ipv4-quick": IoCLookupParams(
-            ...
-
-
-    Define list of required __init__ params
-
-    .. code:: python
-
-        _REQUIRED_PARAMS: List[str] = []
-
-    For example:
-
-    .. code:: python
-
-        _REQUIRED_PARAMS = ["API_KEY"]
-
-    In __init__
-
-    Be sure to call
-
-    .. code::
-
-        super().__init__(**kwargs)``
-
-    Supply any additional checkers/pre-processors
-    with
-
-    .. code: python
-
-        self._preprocessors.add_check(type, check_func)
-
-    See Also
-    --------
-    PreProcessor
-    HttpTIProvider
-
-    """
-
-    # Base URL of the service
-    _BASE_URL = ""
-
-    # Define query parameters for different item types (keys)
-    _API_QUERIES: Dict[str, APILookupParams] = {}
-
-    # List of required __init__ params
-    _REQUIRED_PARAMS: List[str] = []
-
-    def __init__(self, **kwargs):
-        """Initialize the class."""
-        super().__init__(**kwargs)
-        self.__class__._IOC_QUERIES = self._API_QUERIES
-
-        # In __init__ you might want to
-        # supply additional checkers/preprocessors
-        # with
-        # self._preprocessors.add_check(type, check_func)
-        # or replace the default PreProcessors
-        # self._preprocessors = MyPreProcessor()
-
-    def lookup_item(  # type: ignore
-        self, value: str, item_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
-        """
-        Lookup a single item.
-
-        Parameters
-        ----------
-        value : str
-            Item value to lookup
-        item_type : str, optional
-            The Type of the value to lookup, by default None (type will be inferred)
-        query_type : str, optional
-            Specify the data subtype to be queried, by default None.
-            If not specified the default record type for the item_value
-            will be returned.
-
-        Returns
-        -------
-        LookupResult
-            The lookup result:
-            result - Positive/Negative,
-            details - Lookup Details (or status if failure),
-            raw_result - Raw Response
-            reference - URL of the item
-
-        Raises
-        ------
-        NotImplementedError
-            If attempting to use an HTTP method or authentication
-            protocol that is not supported.
-
-        Notes
-        -----
-        Note: this method uses memoization (lru_cache) to cache results
-        for a particular observable to try avoid repeated network calls for
-        the same item.
-
-        """
-        return self.lookup_ioc(
-            ioc=value, ioc_type=item_type, query_type=query_type, **kwargs
-        )
-
-    def lookup_items(
-        self,
-        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
-        item_type_col: str = None,
-        query_type: str = None,
-        **kwargs,
-    ) -> pd.DataFrame:
-        """
-        Lookup collection of IoC observables.
-
-        Parameters
-        ----------
-        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
-            Data input in one of three formats:
-            1. Pandas dataframe (you must supply the column name in
-            `obs_col` parameter)
-            2. Dict of observable, IoCType
-            3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
-            DataFrame column to use for observables, by default None
-        item_type_col : str, optional
-            DataFrame column to use for IoCTypes, by default None
-        query_type : str, optional
-            Specify the data subtype to be queried, by default None.
-            If not specified the default record type for the IoC type
-            will be returned.
-
-        Returns
-        -------
-        pd.DataFrame
-            DataFrame of results.
-
-        """
-        return self.lookup_iocs(
-            data=data,
-            obs_col=obs_col,
-            ioc_type_col=item_type_col,
-            query_type=query_type,
-            **kwargs,
-        )
diff --git a/msticpy/context/tiproviders/ibm_xforce.py b/msticpy/context/tiproviders/ibm_xforce.py
index 01f78f0b1..6f4db7fb9 100644
--- a/msticpy/context/tiproviders/ibm_xforce.py
+++ b/msticpy/context/tiproviders/ibm_xforce.py
@@ -12,14 +12,15 @@
 requests per minute for the account type that you have.
 
 """
-from typing import Any, Tuple
+from typing import Any, Tuple, Dict
 
 import attr
 
 from ..._version import VERSION
 from ...common.utility import export
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .ti_provider_base import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -27,8 +28,8 @@
 
 # pylint: disable=too-few-public-methods
 @attr.s
-class _XForceParams(IoCLookupParams):
-    # override IoCLookupParams to set common defaults
+class _XForceParams(APILookupParams):
+    # override APILookupParams to set common defaults
     def __attrs_post_init__(self):
         self.auth_str = ["{API_ID}", "{API_KEY}"]
         self.auth_type = "HTTPBasic"
@@ -40,7 +41,7 @@ class XForce(HttpTIProvider):
 
     _BASE_URL = "https://api.xforce.ibmcloud.com"
 
-    _IOC_QUERIES = {
+    _QUERIES = {
         "ipv4": _XForceParams(path="/ipr/{observable}"),
         "ipv4-rep": _XForceParams(path="/ipr/history/{observable}"),
         "ipv4-malware": _XForceParams(path="/ipr/malware/{observable}"),
@@ -53,29 +54,29 @@ class XForce(HttpTIProvider):
     }
 
     # aliases
-    _IOC_QUERIES["ipv6"] = _IOC_QUERIES["ipv4"]
-    _IOC_QUERIES["ipv6-rep"] = _IOC_QUERIES["ipv4-rep"]
-    _IOC_QUERIES["ipv6-malware"] = _IOC_QUERIES["ipv4-malware"]
-    _IOC_QUERIES["ipv6-whois"] = _IOC_QUERIES["ipv4-whois"]
-    _IOC_QUERIES["md5_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha1_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha256_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["dns"] = _IOC_QUERIES["url"]
-    _IOC_QUERIES["dns-malware"] = _IOC_QUERIES["url-malware"]
-    _IOC_QUERIES["ipv4-passivedns"] = _IOC_QUERIES["dns-passivedns"]
-    _IOC_QUERIES["ipv6-passivedns"] = _IOC_QUERIES["dns-passivedns"]
-    _IOC_QUERIES["hostname-whois"] = _IOC_QUERIES["ipv4-whois"]
-    _IOC_QUERIES["dns-whois"] = _IOC_QUERIES["ipv4-whois"]
+    _QUERIES["ipv6"] = _QUERIES["ipv4"]
+    _QUERIES["ipv6-rep"] = _QUERIES["ipv4-rep"]
+    _QUERIES["ipv6-malware"] = _QUERIES["ipv4-malware"]
+    _QUERIES["ipv6-whois"] = _QUERIES["ipv4-whois"]
+    _QUERIES["md5_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha1_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha256_hash"] = _QUERIES["file_hash"]
+    _QUERIES["dns"] = _QUERIES["url"]
+    _QUERIES["dns-malware"] = _QUERIES["url-malware"]
+    _QUERIES["ipv4-passivedns"] = _QUERIES["dns-passivedns"]
+    _QUERIES["ipv6-passivedns"] = _QUERIES["dns-passivedns"]
+    _QUERIES["hostname-whois"] = _QUERIES["ipv4-whois"]
+    _QUERIES["dns-whois"] = _QUERIES["ipv4-whois"]
 
     _REQUIRED_PARAMS = ["API_ID", "API_KEY"]
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -87,27 +88,29 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
 
         """
         severity = ResultSeverity.information
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, severity, "Not found."
         result = True
         result_dict = {}
         if (
-            response.ioc_type in ["ipv4", "ipv6", "url", "dns"]
-            and not response.query_subtype
+            response["IocType"] in ["ipv4", "ipv6", "url", "dns"]
+            and not response["QuerySubtype"]
         ):
-            score = response.raw_result.get("score", 0)
+            score = response["RawResult"].get("score", 0)
             result_dict.update(
                 {
-                    "score": response.raw_result.get("score", 0),
-                    "cats": response.raw_result.get("cats"),
-                    "categoryDescriptions": response.raw_result.get(
+                    "score": response["RawResult"].get("score", 0),
+                    "cats": response["RawResult"].get("cats"),
+                    "categoryDescriptions": response["RawResult"].get(
                         "categoryDescriptions"
                     ),
-                    "reason": response.raw_result.get("reason"),
-                    "reasonDescription": response.raw_result.get(
+                    "reason": response["RawResult"].get("reason"),
+                    "reasonDescription": response["RawResult"].get(
                         "reasonDescription", 0
                     ),
-                    "tags": response.raw_result.get("tags", 0),
+                    "tags": response["RawResult"].get("tags", 0),
                 }
             )
             severity = (
@@ -118,29 +121,26 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
                 else ResultSeverity.high
             )
         if (
-            response.ioc_type in ["file_hash", "md5_hash", "sha1_hash", "sha256_hash"]
-            or response.query_subtype == "malware"
+            response["IocType"] in ["file_hash", "md5_hash", "sha1_hash", "sha256_hash"]
+            or response["QuerySubtype"] == "malware"
         ):
-            malware = response.raw_result.get("malware")
+            malware = response["RawResult"].get("malware")
             if malware:
                 result_dict.update(
                     {
                         "risk": malware.get("risk"),
                         "family": malware.get("family"),
-                        "reasonDescription": response.raw_result.get(
+                        "reasonDescription": response["RawResult"].get(
                             "reasonDescription", 0
                         ),
                     }
                 )
                 severity = ResultSeverity.high
-        if response.ioc_type in [
-            "dns",
-            "ipv4",
-            "ipv6",
-            "hostname",
-        ] and response.query_subtype in ["info", "passivedns", "whois"]:
-            records = response.raw_result.get("total_rows", 0)
-            contact = response.raw_result.get("contact", 0)
+        if response["IocType"] in ["dns", "ipv4", "ipv6", "hostname"] and response[
+            "QuerySubtype"
+        ] in ["info", "passivedns", "whois"]:
+            records = response["RawResult"].get("total_rows", 0)
+            contact = response["RawResult"].get("contact", 0)
             if records:
                 result_dict["records"] = records
             elif contact:
diff --git a/msticpy/context/tiproviders/intsights.py b/msticpy/context/tiproviders/intsights.py
index 73b2b0eca..c55cb1f44 100644
--- a/msticpy/context/tiproviders/intsights.py
+++ b/msticpy/context/tiproviders/intsights.py
@@ -12,14 +12,15 @@
 requests per minute for the account type that you have.
 """
 import datetime as dt
-from typing import Any, Tuple
+from typing import Any, Tuple, Dict
 
 import attr
 
 from ..._version import VERSION
 from ...common.utility import export
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .result_severity import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Florian Bracq"
@@ -30,8 +31,8 @@
 
 # pylint: disable=too-few-public-methods
 @attr.s
-class _IntSightsParams(IoCLookupParams):
-    # override IoCLookupParams to set common defaults
+class _IntSightsParams(APILookupParams):
+    # override APILookupParams to set common defaults
     def __attrs_post_init__(self):
         self.auth_str = ["{API_ID}", "{API_KEY}"]
         self.auth_type = "HTTPBasic"
@@ -43,7 +44,7 @@ class IntSights(HttpTIProvider):
 
     _BASE_URL = "https://api.intsights.com"
 
-    _IOC_QUERIES = {
+    _QUERIES = {
         "ipv4": _IntSightsParams(
             path="/public/v2/iocs/ioc-by-value",
             params={"iocValue": "{observable}"},
@@ -88,13 +89,13 @@ class IntSights(HttpTIProvider):
 
     _REQUIRED_PARAMS = ["API_ID", "API_KEY"]
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -105,30 +106,32 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, ResultSeverity.information, "Not found."
 
-        if response.raw_result["Whitelist"] == "True":
+        if response["RawResult"]["Whitelist"] == "True":
             return False, ResultSeverity.information, "Whitelisted."
 
-        sev = response.raw_result["Severity"]
+        sev = response["RawResult"]["Severity"]
         result_dict = {
-            "threat_actors": response.raw_result["RelatedThreatActors"],
-            "geolocation": response.raw_result.get("Geolocation", ""),
-            "response_code": response.status,
-            "tags": response.raw_result["Tags"] + response.raw_result["SystemTags"],
-            "malware": response.raw_result["RelatedMalware"],
-            "campaigns": response.raw_result["RelatedCampaigns"],
-            "sources": response.raw_result["Sources"],
-            "score": response.raw_result["Score"],
+            "threat_actors": response["RawResult"]["RelatedThreatActors"],
+            "geolocation": response["RawResult"].get("Geolocation", ""),
+            "response_code": response["Status"],
+            "tags": response["RawResult"]["Tags"] + response["RawResult"]["SystemTags"],
+            "malware": response["RawResult"]["RelatedMalware"],
+            "campaigns": response["RawResult"]["RelatedCampaigns"],
+            "sources": response["RawResult"]["Sources"],
+            "score": response["RawResult"]["Score"],
             "first_seen": dt.datetime.strptime(
-                response.raw_result["FirstSeen"], "%Y-%m-%dT%H:%M:%S.%fZ"
+                response["RawResult"]["FirstSeen"], "%Y-%m-%dT%H:%M:%S.%fZ"
             ),
             "last_seen": dt.datetime.strptime(
-                response.raw_result["LastSeen"], "%Y-%m-%dT%H:%M:%S.%fZ"
+                response["RawResult"]["LastSeen"], "%Y-%m-%dT%H:%M:%S.%fZ"
             ),
             "last_update": dt.datetime.strptime(
-                response.raw_result["LastUpdate"], "%Y-%m-%dT%H:%M:%S.%fZ"
+                response["RawResult"]["LastUpdate"], "%Y-%m-%dT%H:%M:%S.%fZ"
             ),
         }
 
diff --git a/msticpy/context/tiproviders/kql_base.py b/msticpy/context/tiproviders/kql_base.py
index 6bc5f0507..9728e0c92 100644
--- a/msticpy/context/tiproviders/kql_base.py
+++ b/msticpy/context/tiproviders/kql_base.py
@@ -27,13 +27,9 @@
 from ...common.utility import export
 from ...common.wsconfig import WorkspaceConfig
 from ...data import QueryProvider
-from .ti_provider_base import (
-    LookupResult,
-    LookupStatus,
-    ResultSeverity,
-    TIProvider,
-    generate_items,
-)
+from ..lookup_result import LookupStatus
+from ..provider_base import generate_items
+from .ti_provider_base import ResultSeverity, TIProvider
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -43,7 +39,7 @@
 class KqlTIProvider(TIProvider):
     """KQL TI provider base class."""
 
-    _IOC_QUERIES: Dict[str, tuple] = {}
+    _QUERIES: Dict[str, tuple] = {}
 
     _CONNECT_STR = (
         "loganalytics://code().tenant('{TENANT_ID}').workspace('{WORKSPACE_ID}')"
@@ -75,92 +71,50 @@ def _connected(self):
     @lru_cache(maxsize=256)
     def lookup_ioc(  # type: ignore
         self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
+    ) -> pd.DataFrame:
         """
-        Lookup a single IoC observable.
+        Lookup from a value.
 
         Parameters
         ----------
         ioc : str
-            IoC observable
+            item to lookup
         ioc_type : str, optional
-            IocType, by default None (type will be inferred)
+            The Type of the item to lookup, by default None (type will be inferred)
         query_type : str, optional
             Specify the data subtype to be queried, by default None.
-            If not specified the default record type for the IoC type
+            If not specified the default record type for the item_value
             will be returned.
 
         Returns
         -------
-        LookupResult
-            The lookup result:
-            result - Positive/Negative,
-            details - Lookup Details (or status if failure),
-            raw_result - Raw Response
-            reference - URL of IoC
+        pd.DataFrame
+            DataFrame of results.
 
         Raises
         ------
-        LookupError
-            If a query could not be found for the ioc_type.
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
 
         Notes
         -----
         Note: this method uses memoization (lru_cache) to cache results
-        for a particular observable to try avoid repeated network calls for
+        for a particular item to try avoid repeated network calls for
         the same item.
 
         """
-        if not self._connected:
-            self._connect()
-
-        if any(
-            table not in self._query_provider.schema for table in self._REQUIRED_TABLES
-        ):
-            return LookupResult(
-                ioc=ioc, ioc_type=ioc_type or "", status=LookupStatus.NO_DATA.value
-            )
-        # check and lookup (if needed) ioc_type
-        result = self._check_ioc_type(
-            ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
+        return self.lookup_iocs(
+            data={ioc: ioc_type},
+            query_type=query_type,
+            **kwargs,
         )
-        result.provider = kwargs.get("provider_name", self.__class__.__name__)
-        if result.status:
-            return result
-
-        try:
-            query_obj, query_params = self._get_query_and_params(
-                ioc=ioc, ioc_type=result.ioc_type, query_type=query_type, **kwargs
-            )
-        except LookupError as err:
-            result.details = err.args
-            result.raw_result = type(err).__name__ + "\n" + str(err) + "\n"
-            return result
-
-        if not query_obj:
-            raise LookupError(
-                f"Could not find query for {ioc} ({ioc_type}, {query_type})"
-            )
-        data_result = query_obj(**query_params)
-        if not isinstance(data_result, pd.DataFrame):
-            result.status = LookupStatus.QUERY_FAILED.value
-        elif data_result.empty:
-            result.details = "Not found."
-            result.status = LookupStatus.OK.value
-            return result
-
-        result.raw_result = data_result
-        result.result, severity, result.details = self.parse_results(result)
-        result.set_severity(severity)
-        # Save the query that was used.
-        result.reference = query_obj("print_query", **query_params)
-        return result
 
     # pylint: disable=too-many-locals
     def lookup_iocs(
         self,
         data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         query_type: str = None,
         **kwargs,
@@ -176,7 +130,7 @@ def lookup_iocs(
             `obs_col` parameter)
             2. Dict of observable, IoCType
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
         ioc_type_col : str, optional
             DataFrame column to use for IoCTypes, by default None
@@ -200,15 +154,13 @@ def lookup_iocs(
 
         # We need to partition the IoC types to invoke separate queries
         ioc_groups: DefaultDict[str, Set[str]] = defaultdict(set)
-        for ioc, ioc_type in generate_items(data, obs_col, ioc_type_col):
+        for ioc, ioc_type in generate_items(data, ioc_col, ioc_type_col):
             if not ioc:
                 continue
-            result = self._check_ioc_type(
-                ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
-            )
+            result = self._check_ioc_type(ioc, ioc_type, query_type)
 
-            if result.status != LookupStatus.NOT_SUPPORTED.value:
-                ioc_groups[result.ioc_type].add(result.ioc)
+            if result["Status"] != LookupStatus.NOT_SUPPORTED.value:
+                ioc_groups[result["IocType"]].add(result["Ioc"])
 
         all_results = []
         for ioc_type, obs_set in ioc_groups.items():
@@ -268,7 +220,7 @@ def _add_failure_status(src_ioc_frame: pd.DataFrame, lookup_status: LookupStatus
             else "Not found"
         )
         src_ioc_frame["Status"] = lookup_status.value
-        src_ioc_frame["Severity"] = ResultSeverity.information.value
+        src_ioc_frame["Severity"] = ResultSeverity.information.name
 
     @staticmethod
     def _check_result_status(data_result):
@@ -291,27 +243,14 @@ def _check_result_status(data_result):
             print(f"Unknown response from provider: {str(data_result)}")
         return LookupStatus.QUERY_FAILED
 
-    async def lookup_iocs_async(
-        self,
-        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
-        ioc_type_col: str = None,
-        query_type: str = None,
-        **kwargs,
-    ) -> pd.DataFrame:
-        """Call base async wrapper."""
-        return await self._lookup_iocs_async_wrapper(
-            data, obs_col, ioc_type_col, query_type, **kwargs
-        )
-
     @abc.abstractmethod
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -383,7 +322,7 @@ def _get_query_and_params(
     ) -> Tuple[Callable, Dict[str, Any]]:
 
         ioc_key = f"{ioc_type}-{query_type}" if query_type else ioc_type
-        query_def = self._IOC_QUERIES.get(ioc_key, None)
+        query_def = self._QUERIES.get(ioc_key, None)
         if not query_def:
             raise LookupError(f"Provider does not support IoC type {ioc_key}.")
 
diff --git a/msticpy/context/tiproviders/lookup_result.py b/msticpy/context/tiproviders/lookup_result.py
deleted file mode 100644
index 5d11ea7f5..000000000
--- a/msticpy/context/tiproviders/lookup_result.py
+++ /dev/null
@@ -1,136 +0,0 @@
-# -------------------------------------------------------------------------
-# Copyright (c) Microsoft Corporation. All rights reserved.
-# Licensed under the MIT License. See License.txt in the project root for
-# license information.
-# --------------------------------------------------------------------------
-"""Lookup Result and Status classes."""
-import pprint
-from collections import namedtuple
-from enum import Enum
-from typing import Any, Optional, Union
-
-import attr
-
-from ..._version import VERSION
-from .result_severity import ResultSeverity
-
-__version__ = VERSION
-__author__ = "Ian Hellen"
-
-
-SanitizedObservable = namedtuple("SanitizedObservable", ["observable", "status"])
-
-
-class LookupStatus(Enum):
-    """Threat intelligence lookup status."""
-
-    OK = 0
-    NOT_SUPPORTED = 1
-    BAD_FORMAT = 2
-    QUERY_FAILED = 3
-    NO_DATA = 4
-    OTHER = 10
-
-
-# pylint: enable=comparison-with-callable
-@attr.s(auto_attribs=True)
-class LookupResult:
-    """Lookup result for IoCs."""
-
-    ioc: str
-    ioc_type: str
-    sanitized_value: str = ""
-    query_subtype: Optional[str] = None
-    provider: Optional[str] = None
-    result: bool = False
-    severity: int = attr.ib(default=0)
-    details: Any = None
-    raw_result: Optional[Union[str, dict]] = None
-    reference: Optional[str] = None
-    status: int = LookupStatus.OK.value
-
-    @severity.validator
-    def _check_severity(self, attribute, value):
-        del attribute
-        if isinstance(value, ResultSeverity):
-            self.severity = value.name
-            return
-        self.severity = ResultSeverity.parse(value).name
-
-    @property
-    def summary(self):
-        """Print a summary of the Lookup Result."""
-        p_pr = pprint.PrettyPrinter(indent=4)
-        print("value:", self.ioc, "(", self.ioc_type, ")")
-        print("result:", self.result)
-        # print("severity:", self.severity)
-        p_pr.pprint(self.details)
-        print("reference: ", self.reference)
-
-    @property
-    def raw_result_fmtd(self):
-        """Print raw results of the Lookup Result."""
-        p_pr = pprint.PrettyPrinter(indent=4)
-        p_pr.pprint(self.raw_result)
-
-    @property
-    def value(self) -> str:
-        """Return lookup value."""
-        return self.ioc
-
-    @value.setter
-    def value(self, value: str):
-        """Set lookup value."""
-        self.ioc = value
-
-    @property
-    def value_type(self) -> str:
-        """Return lookup value type."""
-        return self.ioc_type
-
-    @value_type.setter
-    def value_type(self, value: str):
-        """Set lookup value type."""
-        self.ioc_type = value
-
-    @property
-    def safe_ioc(self) -> str:
-        """Return sanitized value."""
-        return self.sanitized_value
-
-    @property
-    def severity_name(self) -> str:
-        """
-        Return text description of severity score.
-
-        Returns
-        -------
-        str
-            Severity description.
-
-        """
-        try:
-            return ResultSeverity(self.severity).name
-        except ValueError:
-            return ResultSeverity.unknown.name
-
-    def set_severity(self, value: Any):
-        """
-        Set the severity from enum, int or string.
-
-        Parameters
-        ----------
-        value : Any
-            The severity value to set
-
-        """
-        self._check_severity(None, value)
-
-    @classmethod
-    def column_map(cls):
-        """Return a dictionary that maps fields to DF Names."""
-        col_mapping = {}
-        for name in attr.fields_dict(cls):
-            out_name = "".join(part.capitalize() for part in name.split("_"))
-            col_mapping[name] = out_name
-        return col_mapping
diff --git a/msticpy/context/tiproviders/open_page_rank.py b/msticpy/context/tiproviders/open_page_rank.py
index 598d88b9c..0a8a9b656 100644
--- a/msticpy/context/tiproviders/open_page_rank.py
+++ b/msticpy/context/tiproviders/open_page_rank.py
@@ -15,13 +15,15 @@
 from json import JSONDecodeError
 from typing import Any, Dict, Iterable, List, Tuple, Union
 
-import attr
 import pandas as pd
 
 from ..._version import VERSION
 from ...common.utility import export
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, LookupStatus, ResultSeverity, generate_items
+from ..lookup_result import LookupStatus
+from ..provider_base import generate_items
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .ti_provider_base import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -33,8 +35,8 @@ class OPR(HttpTIProvider):
 
     _BASE_URL = "https://openpagerank.com"
 
-    _IOC_QUERIES = {
-        "dns": IoCLookupParams(
+    _QUERIES = {
+        "dns": APILookupParams(
             path="/api/v1.0/getPageRank",
             params={"domains[0]": "{observable}"},
             headers={"API-OPR": "{API_KEY}"},
@@ -56,20 +58,20 @@ def __init__(self, **kwargs):
     async def lookup_iocs_async(
         self,
         data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         query_type: str = None,
         **kwargs,
     ) -> pd.DataFrame:
         """Call base async wrapper."""
         return await self._lookup_iocs_async_wrapper(
-            data, obs_col, ioc_type_col, query_type, **kwargs
+            data, ioc_col, ioc_type_col, query_type, **kwargs
         )
 
     def lookup_iocs(
         self,
         data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         query_type: str = None,
         **kwargs,
@@ -85,7 +87,7 @@ def lookup_iocs(
             `obs_col` parameter)
             2. Dict of observable, IoCType
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
         ioc_type_col : str, optional
             DataFrame column to use for IoCTypes, by default None
@@ -104,36 +106,33 @@ def lookup_iocs(
 
         domain_list = set()
         bad_requests: List[pd.Series] = []
-        for ioc, ioc_type in generate_items(data, obs_col, ioc_type_col):
+        for ioc, ioc_type in generate_items(data, ioc_col, ioc_type_col):
             if not ioc:
                 continue
             result = self._check_ioc_type(
                 ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
             )
 
-            if result.status == LookupStatus.OK.value:
-                domain_list.add(result.ioc)
+            if result["Status"] == LookupStatus.OK.value:
+                domain_list.add(result["Ioc"])
             else:
-                bad_requests.append(pd.Series(attr.asdict(result)))
+                bad_requests.append(pd.Series(result))
 
         results: List[pd.Series] = []
         if not domain_list:
-            return pd.DataFrame(columns=LookupResult.column_map())
-        results.extend(
-            pd.Series(attr.asdict(item_result))
-            for item_result in self._lookup_bulk_request(domain_list)
-        )
+            return pd.DataFrame()
+        results.extend(self._lookup_bulk_request(domain_list))
 
         all_results = results + bad_requests
-        return pd.DataFrame(data=all_results).rename(columns=LookupResult.column_map())
+        return pd.DataFrame(all_results)
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -144,41 +143,45 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, ResultSeverity.information, "Not found."
 
         severity = ResultSeverity.information
-        if "response" in response.raw_result:
-            dom_records = response.raw_result["response"]
+        if "response" in response["RawResult"]:
+            dom_records = response["RawResult"]["response"]
             dom_record = dom_records[0]
             return self._parse_one_record(dom_record)
         return True, severity, {}
 
-    def _parse_multi_results(self, response: LookupResult) -> Iterable[LookupResult]:
+    def _parse_multi_results(self, response: Dict) -> Iterable[Dict]:
         """Parse details of batch response."""
-        if not isinstance(response.raw_result, dict):
-            yield LookupResult(
-                **attr.asdict(response),
-                result=False,
-                severity=ResultSeverity.information.value,
-                details="Not found",
+        if not isinstance(response["RawResult"], dict):
+            response.update(
+                {
+                    "Result": False,
+                    "Severity": ResultSeverity.information.value,
+                    "Details": "Not found",
+                }
             )
+            yield response
 
-        elif "response" in response.raw_result:
-            dom_records = response.raw_result["response"]
+        elif "response" in response["RawResult"]:
+            dom_records = response["RawResult"]["response"]
             for dom_record in dom_records:
                 result, sev, details = self._parse_one_record(dom_record)
                 domain_name = dom_record["domain"]
-                yield LookupResult(
-                    ioc=domain_name,
-                    ioc_type="dns",
-                    provider=self._provider_name,
-                    result=result,
-                    severity=sev.value,
-                    details=details,
-                    raw_result=dom_record,
-                    reference=f"{response.reference}?domains[0]={domain_name}",
-                )
+                yield {
+                    "Ioc": domain_name,
+                    "IocType": "dns",
+                    "Provider": self._provider_name,
+                    "Result": result,
+                    "Severity": sev.name,
+                    "Details": details,
+                    "RawResult": dom_record,
+                    "Reference": f"{response['Reference']}?domains[0]={domain_name}",
+                }
 
     @staticmethod
     def _parse_one_record(dom_record: dict):
@@ -205,7 +208,7 @@ def _parse_one_record(dom_record: dict):
             )
         return False, ResultSeverity.information, {}
 
-    def _lookup_bulk_request(self, ioc_list: Iterable[str]) -> Iterable[LookupResult]:
+    def _lookup_bulk_request(self, ioc_list: Iterable[str]) -> Iterable[Dict]:
         ioc_list = list(ioc_list)
         batch_size = 100
 
@@ -214,14 +217,14 @@ def _lookup_bulk_request(self, ioc_list: Iterable[str]) -> Iterable[LookupResult
             batch_list = ioc_list[step : (step + batch_size)]  # noqa: E203
             yield from self._lookup_batch(batch_list)
 
-    def _lookup_batch(self, ioc_list: list) -> Iterable[LookupResult]:
+    def _lookup_batch(self, ioc_list: list) -> Iterable[Dict]:
         # build the query string manually - of the form domains[N]=domN&domains[N+1]...
         qry_elements = [
             f"domains[{idx}]={dom}" for idx, dom in zip(range(len(ioc_list)), ioc_list)
         ]
 
         qry_str = "&".join(qry_elements)
-        path = self._IOC_QUERIES["dns"].path
+        path = self._QUERIES["dns"].path
         req_url = f"{self._BASE_URL}{path}?{qry_str}"
 
         try:
@@ -230,25 +233,25 @@ def _lookup_batch(self, ioc_list: list) -> Iterable[LookupResult]:
                 url=req_url, headers=req_params["headers"]
             )
             if response.status_code == 200:
-                result = LookupResult(
-                    ioc=",".join(ioc_list),
-                    ioc_type="dns",
-                    status=LookupStatus.OK.value,
-                    reference=f"{self._BASE_URL}{path}",
-                    raw_result=response.json(),
-                )
+                result = {
+                    "Ioc": ",".join(ioc_list),
+                    "IocType": "dns",
+                    "Status": LookupStatus.OK.value,
+                    "Reference": f"{self._BASE_URL}{path}",
+                    "RawResult": response.json(),
+                }
 
                 yield from self._parse_multi_results(result)
             else:
-                yield LookupResult(
-                    ioc=",".join(ioc_list),
-                    ioc_type="dns",
-                    status=response.status_code,
-                    reference=req_url,
-                    raw_result=str(response),
-                    result=False,
-                    details="No response from provider.",
-                )
+                yield {
+                    "Ioc": ",".join(ioc_list),
+                    "IocType": "dns",
+                    "Status": response.status_code,
+                    "Reference": req_url,
+                    "RawResult": str(response),
+                    "Result": False,
+                    "Details": "No response from provider.",
+                }
         except (
             LookupError,
             JSONDecodeError,
@@ -256,10 +259,10 @@ def _lookup_batch(self, ioc_list: list) -> Iterable[LookupResult]:
             ConnectionError,
         ) as err:
             result_dict = {
-                "ioc": ",".join(ioc_list),
-                "ioc_type": "dns",
-                "details": "\n".join(err.args),
+                "Ioc": ",".join(ioc_list),
+                "IocType": "dns",
+                "Details": "\n".join(err.args),
             }
             if isinstance(err, LookupError):
-                result_dict["reference"] = req_url
-            yield LookupResult(**result_dict)  # type: ignore
+                result_dict["Reference"] = req_url
+            yield result_dict  # type: ignore
diff --git a/msticpy/context/tiproviders/riskiq.py b/msticpy/context/tiproviders/riskiq.py
index ed01bd503..9aee34424 100644
--- a/msticpy/context/tiproviders/riskiq.py
+++ b/msticpy/context/tiproviders/riskiq.py
@@ -14,14 +14,15 @@
 """
 from datetime import datetime
 from functools import partial
-from typing import Any, Optional, Tuple, Union
+from typing import Any, Optional, Tuple, Union, Dict
+
+import pandas as pd
 
 from ..._version import VERSION
 from ...common.exceptions import MsticpyImportExtraError, MsticpyUserError
 from ...common.utility import export
+from ..lookup_result import LookupStatus
 from .ti_provider_base import (
-    LookupResult,
-    LookupStatus,
     ResultSeverity,
     TIPivotProvider,
     TIProvider,
@@ -45,7 +46,7 @@
 class RiskIQ(TIProvider, TIPivotProvider):
     """RiskIQ Threat Intelligence Lookup."""
 
-    _IOC_QUERIES: dict = {
+    _QUERIES: Dict[str, str] = {
         "ipv4": "ALL",
         "ipv4-articles": "articles",
         "ipv4-artifacts": "artifacts",
@@ -80,21 +81,21 @@ class RiskIQ(TIProvider, TIPivotProvider):
     }
 
     # Aliases
-    _IOC_QUERIES["dns"] = _IOC_QUERIES["hostname"]
-    _IOC_QUERIES["dns-articles"] = _IOC_QUERIES["hostname-articles"]
-    _IOC_QUERIES["dns-artifacts"] = _IOC_QUERIES["hostname-artifacts"]
-    _IOC_QUERIES["dns-certificates"] = _IOC_QUERIES["hostname-certificates"]
-    _IOC_QUERIES["dns-components"] = _IOC_QUERIES["hostname-components"]
-    _IOC_QUERIES["dns-cookies"] = _IOC_QUERIES["hostname-cookies"]
-    _IOC_QUERIES["dns-hostpairchildren"] = _IOC_QUERIES["hostname-hostpairchildren"]
-    _IOC_QUERIES["dns-hostpairparents"] = _IOC_QUERIES["hostname-hostpairparents"]
-    _IOC_QUERIES["dns-passivedns"] = _IOC_QUERIES["hostname-passivedns"]
-    _IOC_QUERIES["dns-projects"] = _IOC_QUERIES["hostname-projects"]
-    _IOC_QUERIES["dns-malware"] = _IOC_QUERIES["hostname-malware"]
-    _IOC_QUERIES["dns-rep"] = _IOC_QUERIES["hostname-rep"]
-    _IOC_QUERIES["dns-summary"] = _IOC_QUERIES["hostname-summary"]
-    _IOC_QUERIES["dns-trackers"] = _IOC_QUERIES["hostname-trackers"]
-    _IOC_QUERIES["dns-whois"] = _IOC_QUERIES["hostname-whois"]
+    _QUERIES["dns"] = _QUERIES["hostname"]
+    _QUERIES["dns-articles"] = _QUERIES["hostname-articles"]
+    _QUERIES["dns-artifacts"] = _QUERIES["hostname-artifacts"]
+    _QUERIES["dns-certificates"] = _QUERIES["hostname-certificates"]
+    _QUERIES["dns-components"] = _QUERIES["hostname-components"]
+    _QUERIES["dns-cookies"] = _QUERIES["hostname-cookies"]
+    _QUERIES["dns-hostpairchildren"] = _QUERIES["hostname-hostpairchildren"]
+    _QUERIES["dns-hostpairparents"] = _QUERIES["hostname-hostpairparents"]
+    _QUERIES["dns-passivedns"] = _QUERIES["hostname-passivedns"]
+    _QUERIES["dns-projects"] = _QUERIES["hostname-projects"]
+    _QUERIES["dns-malware"] = _QUERIES["hostname-malware"]
+    _QUERIES["dns-rep"] = _QUERIES["hostname-rep"]
+    _QUERIES["dns-summary"] = _QUERIES["hostname-summary"]
+    _QUERIES["dns-trackers"] = _QUERIES["hostname-trackers"]
+    _QUERIES["dns-whois"] = _QUERIES["hostname-whois"]
 
     _PIVOT_ENTITIES = {
         prop: {"Dns": "DomainName", "IpAddress": "Address", "Host": "fqdn"}
@@ -151,7 +152,7 @@ def _severity_rep(classification):
 
     def lookup_ioc(
         self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
+    ) -> pd.DataFrame:
         """
         Lookup a single IoC observable.
 
@@ -168,7 +169,7 @@ def lookup_ioc(
 
         Returns
         -------
-        LookupResult
+        pd.DataFrame
             The returned results.
 
         """
@@ -176,23 +177,21 @@ def lookup_ioc(
             ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
         )
 
-        if result.status:
-            return result
+        if result["Status"]:
+            return pd.DataFrame([result])
 
-        result.provider = kwargs.get("provider_name", self.__class__.__name__)
-        result.reference = self._REFERENCE
+        result["Provider"] = kwargs.get("provider_name", self.__class__.__name__)
+        result["Reference"] = self._REFERENCE
 
         if query_type is None:
             prop = "ALL"
-        elif query_type not in [
-            q.split("-", maxsplit=1)[-1] for q in self._IOC_QUERIES
-        ]:
-            result.result = False
-            result.status = LookupStatus.QUERY_FAILED.value
-            result.details = f"ERROR: unsupported query type {query_type}"
-            return result
+        elif query_type not in [q.split("-", maxsplit=1)[-1] for q in self._QUERIES]:
+            result["Result"] = False
+            result["Status"] = LookupStatus.QUERY_FAILED.value
+            result["Details"] = f"ERROR: unsupported query type {query_type}"
+            return pd.DataFrame([result])
         else:
-            prop = self._IOC_QUERIES.get(f"{result.ioc_type}-{query_type}", "ALL")
+            prop = self._QUERIES.get(f"{result['IocType']}-{query_type}", "ALL")
 
         try:
             ptanalyzer.set_context("msticpy", "ti", VERSION, prop)
@@ -202,56 +201,56 @@ def lookup_ioc(
             else:
                 result = self._parse_result_prop(pt_obj, prop, result)
         except ptanalyzer.AnalyzerError as err:
-            result.result = False
-            result.status = LookupStatus.QUERY_FAILED.value
-            result.details = f"ERROR: {err}"
-            result.raw_result = err
-            result.set_severity(ResultSeverity.unknown)
+            result["Result"] = False
+            result["Status"] = LookupStatus.QUERY_FAILED.value
+            result["Details"] = f"ERROR: {err}"
+            result["Raw_result"] = err
+            result["Severity"] = ResultSeverity.unknown.name
 
-        return result
+        return pd.DataFrame([result])
 
     def _parse_result_all_props(self, pt_result, ti_result):
         """Parse results for ALL properties."""
-        ti_result.details = {
+        ti_result["Details"] = {
             "summary": pt_result.summary.as_dict,
             "reputation": pt_result.reputation.as_dict,
         }
-        ti_result.raw_result = ti_result.details
-        ti_result.result = (
+        ti_result["RawResult"] = ti_result["Details"]
+        ti_result["Result"] = (
             pt_result.summary.total != 0 or pt_result.reputation.score != 0
         )
 
         rep_severity = self._severity_rep(pt_result.reputation.classification)
-        ti_result.set_severity(rep_severity)
+        ti_result["Severity"] = rep_severity.name
         if (
             "malware_hashes" in pt_result.summary.available
             or "articles" in pt_result.summary.available
         ):
-            ti_result.set_severity(max(rep_severity, ResultSeverity.high))
+            ti_result["Severity"] = (max(rep_severity, ResultSeverity.high)).name
         elif "projects" in pt_result.summary.available:
-            ti_result.set_severity(max(rep_severity, ResultSeverity.warning))
+            ti_result["Severity"] = (max(rep_severity, ResultSeverity.warning)).name
         return ti_result
 
     def _parse_result_prop(self, pt_result, pt_prop, ti_result):
         """Parse result for a specific property."""
         attr = getattr(pt_result, pt_prop)
         if pt_prop == "reputation":
-            ti_result.set_severity(self._severity_rep(attr.classification))
+            ti_result["Severity"] = self._severity_rep(attr.classification).name
         elif pt_prop == "malware_hashes" and len(attr) > 0:
-            ti_result.set_severity(ResultSeverity.high)
+            ti_result["Severity"] = ResultSeverity.high.name
         else:
-            ti_result.set_severity(ResultSeverity.information)
-        ti_result.details = ti_result.raw_result = attr.as_dict
-        ti_result.result = True
+            ti_result["Severity"] = ResultSeverity.information.name
+        ti_result["Details"] = ti_result["RawResult"] = attr.as_dict
+        ti_result["Result"] = True
         return ti_result
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
diff --git a/msticpy/context/tiproviders/ti_http_provider.py b/msticpy/context/tiproviders/ti_http_provider.py
new file mode 100644
index 000000000..856123753
--- /dev/null
+++ b/msticpy/context/tiproviders/ti_http_provider.py
@@ -0,0 +1,127 @@
+# -------------------------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License. See License.txt in the project root for
+# license information.
+# --------------------------------------------------------------------------
+"""
+HTTP TI Provider base.
+
+Input can be a single IoC observable or a pandas DataFrame containing
+multiple observables. Processing may require a an API key and
+processing performance may be limited to a specific number of
+requests per minute for the account type that you have.
+
+"""
+from typing import Dict, Any
+from json import JSONDecodeError
+
+from functools import lru_cache
+import pandas as pd
+
+from ..._version import VERSION
+from .ti_provider_base import TIProvider
+from ...common.pkg_config import get_http_timeout
+from ..http_provider import HttpProvider
+from ..lookup_result import LookupStatus
+from ...common.utility import export
+from .result_severity import ResultSeverity
+
+__version__ = VERSION
+__author__ = "Ian Hellen"
+
+
+@export
+class HttpTIProvider(TIProvider, HttpProvider):
+    """HTTP API Lookup provider base class."""
+
+    @lru_cache(maxsize=256)
+    def lookup_ioc(
+        self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
+    ) -> pd.DataFrame:
+        """
+        Lookup from a value.
+
+        Parameters
+        ----------
+        ioc : str
+            ioc to lookup
+        ioc_type : str, optional
+            The Type of the ioc to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the ioc
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            The lookup result:
+            result - Positive/Negative,
+            details - Lookup Details (or status if failure),
+            raw_result - Raw Response
+            reference - URL of the item
+
+        Raises
+        ------
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
+
+        Notes
+        -----
+        Note: this method uses memoization (lru_cache) to cache results
+        for a particular item to try avoid repeated network calls for
+        the same item.
+
+        """
+        result = self._check_ioc_type(ioc, ioc_type, query_subtype=query_type)
+
+        result["Provider"] = kwargs.get("provider_name", self.__class__.__name__)
+
+        req_params: Dict[str, Any] = {}
+        try:
+            verb, req_params = self._substitute_parms(
+                result["SafeIoc"], result["IocType"], query_type
+            )
+            if verb == "GET":
+                response = self._httpx_client.get(
+                    **req_params, timeout=get_http_timeout(**kwargs)
+                )
+            else:
+                raise NotImplementedError(f"Unsupported verb {verb}")
+
+            result["Status"] = response.status_code
+            result["Reference"] = req_params["url"]
+            if result["Status"] == 200:
+                try:
+                    result["RawResult"] = response.json()
+                    result["Result"], severity, result["Details"] = self.parse_results(
+                        result
+                    )
+                except JSONDecodeError:
+                    result[
+                        "RawResult"
+                    ] = f"""There was a problem parsing results from this lookup:
+                                        {response.text}"""
+                    result["Result"] = False
+                    severity = ResultSeverity.information
+                    result["Details"] = {}
+                if isinstance(severity, ResultSeverity):
+                    result["Severity"] = severity.name
+                result["Severity"] = ResultSeverity.parse(severity).name
+                result["Status"] = LookupStatus.OK.value
+            else:
+                result["RawResult"] = str(response)
+                result["Result"] = False
+                result["Details"] = self._response_message(result["Status"])
+        except (
+            LookupError,
+            JSONDecodeError,
+            NotImplementedError,
+            ConnectionError,
+        ) as err:
+            self._err_to_results(result, err)
+            if not isinstance(err, LookupError):
+                url = req_params.get("url", None) if req_params else None
+                result["Reference"] = url
+        return pd.DataFrame([result])
diff --git a/msticpy/context/tiproviders/ti_provider_base.py b/msticpy/context/tiproviders/ti_provider_base.py
index f371c5aa8..89f2cb203 100644
--- a/msticpy/context/tiproviders/ti_provider_base.py
+++ b/msticpy/context/tiproviders/ti_provider_base.py
@@ -13,61 +13,160 @@
 
 """
 
-import abc
-import collections
-from abc import ABC, abstractmethod
-from asyncio import get_event_loop
-from functools import lru_cache, partial, singledispatch
-from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
-
-import attr
+from abc import abstractmethod
+from typing import Any, Dict, Iterable, Tuple, Union
+
 import pandas as pd
 
 from ..._version import VERSION
 from ...common.utility import export
-from ...transform.iocextract import IoCExtract, IoCType
-from .lookup_result import LookupResult, LookupStatus
-from .preprocess_observable import PreProcessor
+from ..provider_base import PivotProvider, Provider
 from .result_severity import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
 
 
-_IOC_EXTRACT = IoCExtract()
-
-
 @export
-class TIProvider(ABC):
+class TIProvider(Provider):
     """Abstract base class for Threat Intel providers."""
 
-    _IOC_QUERIES: Dict[str, Any] = {}
+    _QUERIES: Dict[str, Any] = {}
 
-    # pylint: disable=unused-argument
-    def __init__(self, **kwargs):
-        """Initialize the provider."""
-        self._supported_types: Set[IoCType] = set()
-        self.description: Optional[str] = None
+    def _check_item_type(
+        self, item: str, item_type: str = None, query_subtype: str = None
+    ) -> Dict:
+        """
+        Check Item Type and cleans up item.
 
-        self._supported_types = {
-            IoCType.parse(ioc_type.split("-")[0]) for ioc_type in self._IOC_QUERIES
-        }
-        if IoCType.unknown in self._supported_types:
-            self._supported_types.remove(IoCType.unknown)
+        Parameters
+        ----------
+        item : str
+            item
+        item_type : str, optional
+            item type, by default None
+        query_subtype : str, optional
+            Query sub-type, if any, by default None
 
-        self.require_url_encoding = False
+        Returns
+        -------
+        Dict
+            Dict result with resolved type and pre-processed
+            item.
+            Status is none-zero on failure.
 
-        self._preprocessors = PreProcessor()
+        """
+        return self._check_ioc_type(
+            item,
+            item_type,
+            query_subtype,
+        )
 
-    @property
-    def name(self) -> str:
-        """Return the name of the provider."""
-        return self.__class__.__name__
+    def _check_ioc_type(
+        self, ioc: str, ioc_type: str = None, query_subtype: str = None
+    ) -> Dict:
+        """
+        Check Ioc Type and cleans up ioc.
 
-    @abc.abstractmethod
+        Parameters
+        ----------
+        ioc : str
+            IoC
+        ioc_type : str, optional
+            IoC type, by default None
+        query_subtype : str, optional
+            Query sub-type, if any, by default None
+
+        Returns
+        -------
+        Dict
+            Dict result with resolved type and pre-processed
+            Ioc.
+            Status is none-zero on failure.
+
+        """
+        result = super()._check_item_type(
+            item=ioc, item_type=ioc_type, query_subtype=query_subtype
+        )
+        result["Ioc"] = result.pop("Item")
+        result["IocType"] = result.pop("ItemType")
+        result["SafeIoc"] = result.pop("SanitizedValue")
+        result["Severity"] = ResultSeverity.information.name
+        return result
+
+    def lookup_item(
+        self, item: str, item_type: str = None, query_type: str = None, **kwargs
+    ) -> pd.DataFrame:
+        """
+        Lookup a single item.
+
+        Parameters
+        ----------
+        item : str
+            Item value to lookup
+        item_type : str, optional
+            The Type of the value to lookup, by default None (type will be inferred)
+        query_type : str, optional
+            Specify the data subtype to be queried, by default None.
+            If not specified the default record type for the item_value
+            will be returned.
+
+        Returns
+        -------
+        pd.DataFrame
+            The lookup result:
+            result - Positive/Negative,
+            details - Lookup Details (or status if failure),
+            raw_result - Raw Response
+            reference - URL of the item
+
+        Raises
+        ------
+        NotImplementedError
+            If attempting to use an HTTP method or authentication
+            protocol that is not supported.
+
+        Notes
+        -----
+        Note: this method uses memoization (lru_cache) to cache results
+        for a particular observable to try avoid repeated network calls for
+        the same item.
+
+        """
+        return self.lookup_ioc(
+            ioc=item,
+            ioc_type=item_type,
+            query_type=query_type,
+            **kwargs,
+        )
+
+    @abstractmethod
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
+        """
+        Return the details of the response.
+
+        Parameters
+        ----------
+        response : Dict
+            The returned data response
+
+        Returns
+        -------
+        Tuple[bool, ResultSeverity, Any]
+            bool = positive or negative hit
+            ResultSeverity = enumeration of severity
+            Object with match details
+
+        """
+
+    @abstractmethod
     def lookup_ioc(
-        self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
+        self,
+        ioc: str,
+        ioc_type: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
         """
         Lookup a single IoC observable.
 
@@ -84,15 +183,15 @@ def lookup_ioc(
 
         Returns
         -------
-        LookupResult
-            The returned results.
+        pd.DataFrame
+            DataFrame of results.
 
         """
 
     def lookup_iocs(
         self,
         data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
         ioc_type_col: str = None,
         query_type: str = None,
         **kwargs,
@@ -105,10 +204,10 @@ def lookup_iocs(
         data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
             Data input in one of three formats:
             1. Pandas dataframe (you must supply the column name in
-            `obs_col` parameter)
+            `ioc_col` parameter)
             2. Dict of observable, IoCType
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
         ioc_type_col : str, optional
             DataFrame column to use for IoCTypes, by default None
@@ -123,27 +222,41 @@ def lookup_iocs(
             DataFrame of results.
 
         """
-        results = []
-        for observable, ioc_type in generate_items(data, obs_col, ioc_type_col):
-            if not observable:
-                continue
-            item_result = self.lookup_ioc(
-                ioc=observable, ioc_type=ioc_type, query_type=query_type
-            )
-            results.append(pd.Series(attr.asdict(item_result)))
-
-        return pd.DataFrame(data=results).rename(columns=LookupResult.column_map())
+        return self.lookup_items(
+            data,
+            item_col=ioc_col,
+            item_type_col=ioc_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
 
     async def lookup_iocs_async(
         self,
         data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
+        ioc_col: str = None,
+        ioc_type_col: str = None,
+        query_type: str = None,
+        **kwargs,
+    ) -> pd.DataFrame:
+        """Call base async wrapper."""
+        return await self._lookup_items_async_wrapper(
+            data,
+            item_col=ioc_col,
+            item_type_col=ioc_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
+
+    async def _lookup_iocs_async_wrapper(
+        self,
+        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
+        ioc_col: str = None,
         ioc_type_col: str = None,
         query_type: str = None,
         **kwargs,
     ) -> pd.DataFrame:
         """
-        Lookup collection of IoC observables.
+        Async wrapper for providers that do not implement lookup_iocs_async.
 
         Parameters
         ----------
@@ -153,7 +266,7 @@ async def lookup_iocs_async(
             `obs_col` parameter)
             2. Dict of observable, IoCType
             3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
+        ioc_col : str, optional
             DataFrame column to use for observables, by default None
         ioc_type_col : str, optional
             DataFrame column to use for IoCTypes, by default None
@@ -168,59 +281,13 @@ async def lookup_iocs_async(
             DataFrame of results.
 
         """
-        event_loop = get_event_loop()
-        results = []
-        prog_counter = kwargs.pop("prog_counter", None)
-        for observable, ioc_type in generate_items(data, obs_col, ioc_type_col):
-            ioc_type = kwargs.pop("ioc_type", ioc_type)
-            if not observable:
-                continue
-            get_ioc = partial(
-                self.lookup_ioc,
-                ioc=observable,
-                ioc_type=ioc_type,
-                query_type=query_type,
-                **kwargs,
-            )
-            item_result: LookupResult = await event_loop.run_in_executor(None, get_ioc)
-            if prog_counter:
-                await prog_counter.decrement()
-            results.append(pd.Series(attr.asdict(item_result)))
-
-        return pd.DataFrame(data=results).rename(columns=LookupResult.column_map())
-        # return self.lookup_iocs(data, obs_col, ioc_type_col, query_type, status_queue, **kwargs)
-
-    @abc.abstractmethod
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
-        """
-        Return the details of the response.
-
-        Parameters
-        ----------
-        response : LookupResult
-            The returned data response
-
-        Returns
-        -------
-        Tuple[bool, ResultSeverity, Any]
-            bool = positive or negative hit
-            ResultSeverity = enumeration of severity
-            Object with match details
-
-        """
-
-    @property
-    def supported_types(self) -> List[str]:
-        """
-        Return list of supported IoC types for this provider.
-
-        Returns
-        -------
-        List[str]
-            List of supported type names
-
-        """
-        return [ioc.name for ioc in self._supported_types]
+        return await self._lookup_items_async_wrapper(
+            data,
+            item_col=ioc_col,
+            item_type_col=ioc_type_col,
+            query_type=query_type,
+            **kwargs,
+        )
 
     @property
     def ioc_query_defs(self) -> Dict[str, Any]:
@@ -233,60 +300,20 @@ def ioc_query_defs(self) -> Dict[str, Any]:
             IoC query/request definitions keyed by IoCType
 
         """
-        return self._IOC_QUERIES
-
-    @classmethod
-    def is_known_type(cls, ioc_type: str) -> bool:
-        """
-        Return True if this a known IoC Type.
-
-        Parameters
-        ----------
-        ioc_type : str
-            IoCType string to test
-
-        Returns
-        -------
-        bool
-            True if known type.
-
-        """
-        return ioc_type in IoCType.__members__ and ioc_type != "unknown"
+        return self._QUERIES
 
     @classmethod
     def usage(cls):
         """Print usage of provider."""
         print(f"{cls.__doc__} Supported query types:")
-        for ioc_key in sorted(cls._IOC_QUERIES):
+        for ioc_key in sorted(cls._QUERIES):
             ioc_key_elems = ioc_key.split("-", maxsplit=1)
             if len(ioc_key_elems) == 1:
                 print(f"\tioc_type={ioc_key_elems[0]}")
             if len(ioc_key_elems) == 2:
-                print(
-                    f"\tioc_type={ioc_key_elems[0]}, ioc_query_type={ioc_key_elems[1]}"
-                )
-
-    def is_supported_type(self, ioc_type: Union[str, IoCType]) -> bool:
-        """
-        Return True if the passed type is supported.
-
-        Parameters
-        ----------
-        ioc_type : Union[str, IoCType]
-            IoC type name or instance
-
-        Returns
-        -------
-        bool
-            True if supported.
-
-        """
-        if isinstance(ioc_type, str):
-            ioc_type = IoCType.parse(ioc_type)
-        return ioc_type.name in self.supported_types
+                print(f"\tioc_type={ioc_key_elems[0]}, query_type={ioc_key_elems[1]}")
 
     @staticmethod
-    @lru_cache(maxsize=1024)
     def resolve_ioc_type(observable: str) -> str:
         """
         Return IoCType determined by IoCExtract.
@@ -302,111 +329,11 @@ def resolve_ioc_type(observable: str) -> str:
             IoC Type (or unknown if type could not be determined)
 
         """
-        return _IOC_EXTRACT.get_ioc_type(observable)
+        return TIProvider.resolve_item_type(observable)
 
-    def _check_ioc_type(
-        self, ioc: str, ioc_type: str = None, query_subtype: str = None
-    ) -> LookupResult:
-        """
-        Check IoC Type and cleans up observable.
-
-        Parameters
-        ----------
-        ioc : str
-            IoC observable
-        ioc_type : str, optional
-            IoC type, by default None
-        query_subtype : str, optional
-            Query sub-type, if any, by default None
 
-        Returns
-        -------
-        LookupResult
-            Lookup result with resolved ioc_type and pre-processed
-            observable.
-            LookupResult.status is none-zero on failure.
-
-        """
-        result = LookupResult(
-            ioc=ioc,
-            sanitized_value=ioc,
-            ioc_type=ioc_type or self.resolve_ioc_type(ioc),
-            query_subtype=query_subtype,
-            result=False,
-            details="",
-            raw_result=None,
-            reference=None,
-        )
-
-        if not self.is_supported_type(result.ioc_type):
-            result.details = f"IoC type {result.ioc_type} not supported."
-            result.status = LookupStatus.NOT_SUPPORTED.value
-            return result
-
-        clean_ioc = self._preprocessors.check(
-            ioc, result.ioc_type, require_url_encoding=self.require_url_encoding
-        )
-
-        result.sanitized_value = clean_ioc.observable
-
-        if clean_ioc.status != "ok":
-            result.details = clean_ioc.status
-            result.status = LookupStatus.BAD_FORMAT.value
-
-        return result
-
-    async def _lookup_iocs_async_wrapper(
-        self,
-        data: Union[pd.DataFrame, Dict[str, str], Iterable[str]],
-        obs_col: str = None,
-        ioc_type_col: str = None,
-        query_type: str = None,
-        **kwargs,
-    ) -> pd.DataFrame:
-        """
-        Async wrapper for providers that do not implement lookup_iocs_async.
-
-        Parameters
-        ----------
-        data : Union[pd.DataFrame, Dict[str, str], Iterable[str]]
-            Data input in one of three formats:
-            1. Pandas dataframe (you must supply the column name in
-            `obs_col` parameter)
-            2. Dict of observable, IoCType
-            3. Iterable of observables - IoCTypes will be inferred
-        obs_col : str, optional
-            DataFrame column to use for observables, by default None
-        ioc_type_col : str, optional
-            DataFrame column to use for IoCTypes, by default None
-        query_type : str, optional
-            Specify the data subtype to be queried, by default None.
-            If not specified the default record type for the IoC type
-            will be returned.
-
-        Returns
-        -------
-        pd.DataFrame
-            DataFrame of results.
-
-        """
-        event_loop = get_event_loop()
-        prog_counter = kwargs.pop("prog_counter", None)
-        get_iocs = partial(
-            self.lookup_iocs,
-            data=data,
-            obs_col=obs_col,
-            ioc_type_col=ioc_type_col,
-            query_type=query_type,
-            **kwargs,
-        )
-        result = await event_loop.run_in_executor(None, get_iocs)
-        if prog_counter:
-            await prog_counter.decrement(len(data))  # type: ignore
-        return result
-
-
-class TIPivotProvider(ABC):
-    """A class which provides pivot functions and a means of registering them."""
+class TIPivotProvider(PivotProvider):
+    """A class which provides TI pivot functions and a means of registering them."""
 
     @abstractmethod
     def register_pivots(
@@ -425,50 +352,3 @@ def register_pivots(
             Pivot library instance
 
         """
-
-
-@singledispatch
-def generate_items(
-    data: Any, obs_col: Optional[str] = None, ioc_type_col: Optional[str] = None
-) -> Iterable[Tuple[Optional[str], Optional[str]]]:
-    """
-    Generate item pairs from different input types.
-
-    Parameters
-    ----------
-    data : Any
-        DataFrame, dictionary or iterable
-    obs_col : Optional[str]
-        If `data` is a DataFrame, the column containing the observable value.
-    ioc_type_col : Optional[str]
-        If `data` is a DataFrame, the column containing the observable type.
-
-    Returns
-    -------
-    Iterable[Tuple[Optional[str], Optional[str]]]] - a tuple of Observable/Type.
-
-    """
-    del obs_col, ioc_type_col
-
-    if isinstance(data, collections.abc.Iterable):
-        for item in data:
-            yield item, TIProvider.resolve_ioc_type(item)
-    else:
-        yield None, None
-
-
-@generate_items.register(pd.DataFrame)
-def _(data: pd.DataFrame, obs_col: str, ioc_type_col: Optional[str] = None):
-    for _, row in data.iterrows():
-        if ioc_type_col is None:
-            yield row[obs_col], TIProvider.resolve_ioc_type(row[obs_col])
-        else:
-            yield row[obs_col], row[ioc_type_col]
-
-
-@generate_items.register(dict)  # type: ignore
-def _(data: dict, obs_col: Optional[str] = None, ioc_type_col: Optional[str] = None):
-    for obs, ioc_type in data.items():
-        if not ioc_type:
-            ioc_type = TIProvider.resolve_ioc_type(obs)
-        yield obs, ioc_type
diff --git a/msticpy/context/tiproviders/tor_exit_nodes.py b/msticpy/context/tiproviders/tor_exit_nodes.py
index 8a2cb1ead..6a3f7444b 100644
--- a/msticpy/context/tiproviders/tor_exit_nodes.py
+++ b/msticpy/context/tiproviders/tor_exit_nodes.py
@@ -20,11 +20,13 @@
 from typing import Any, Dict, Iterable, Tuple
 
 import httpx
+import pandas as pd
 
 from ..._version import VERSION
 from ...common.pkg_config import get_http_timeout
 from ...common.utility import export, mp_ua_header
-from .ti_provider_base import LookupResult, LookupStatus, ResultSeverity, TIProvider
+from ..lookup_result import LookupStatus
+from .ti_provider_base import ResultSeverity, TIProvider
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -40,7 +42,7 @@ class Tor(TIProvider):
         "/master/tor-exit-nodes.lst"
     )
 
-    _IOC_QUERIES: dict = {"ipv4": None, "ipv6": None}
+    _QUERIES: Dict[str, Any] = {"ipv4": None, "ipv6": None}
     _nodelist: Dict[str, Dict[str, str]] = {}
     _last_cached = datetime.min
     _cache_lock = Lock()
@@ -90,7 +92,7 @@ def _tor_splitter(node_list) -> Iterable[Tuple[str, Dict[str, str]]]:
 
     def lookup_ioc(
         self, ioc: str, ioc_type: str = None, query_type: str = None, **kwargs
-    ) -> LookupResult:
+    ) -> pd.DataFrame:
         """
         Lookup a single IoC observable.
 
@@ -107,7 +109,7 @@ def lookup_ioc(
 
         Returns
         -------
-        LookupResult
+        pd.DataFrame
             The returned results.
 
         """
@@ -117,36 +119,36 @@ def lookup_ioc(
             ioc=ioc, ioc_type=ioc_type, query_subtype=query_type
         )
 
-        result.provider = kwargs.get("provider_name", self.__class__.__name__)
-        result.result = bool(self._nodelist)
-        result.reference = self._BASE_URL
+        result["Provider"] = kwargs.get("provider_name", self.__class__.__name__)
+        result["Result"] = bool(self._nodelist)
+        result["Reference"] = self._BASE_URL
 
-        if result.status and not bool(self._nodelist):
-            result.status = LookupStatus.QUERY_FAILED.value
+        if result["Status"] and not bool(self._nodelist):
+            result["Status"] = LookupStatus.QUERY_FAILED.value
 
-        if result.status:
-            return result
+        if result["Status"]:
+            return pd.DataFrame([result])
 
         tor_node = self._nodelist.get(ioc)
 
         if tor_node:
-            result.set_severity(ResultSeverity.warning)
-            result.details = {
+            result["Severity"] = ResultSeverity.warning.name
+            result["Details"] = {
                 "NodeID": tor_node["ExitNode"],
                 "LastStatus": tor_node["LastStatus"],
             }
-            result.raw_result = tor_node
+            result["RawResult"] = tor_node
         else:
-            result.details = "Not found."
-        return result
+            result["Details"] = "Not found."
+        return pd.DataFrame([result])
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
diff --git a/msticpy/context/tiproviders/virustotal.py b/msticpy/context/tiproviders/virustotal.py
index af6a83f0f..f71ce8ee6 100644
--- a/msticpy/context/tiproviders/virustotal.py
+++ b/msticpy/context/tiproviders/virustotal.py
@@ -17,8 +17,9 @@
 
 from ..._version import VERSION
 from ...common.utility import export
-from .http_provider import HttpTIProvider, IoCLookupParams
-from .ti_provider_base import LookupResult, ResultSeverity
+from .ti_http_provider import HttpTIProvider
+from ..http_provider import APILookupParams
+from .result_severity import ResultSeverity
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
@@ -35,23 +36,23 @@ class VirusTotal(HttpTIProvider):
     _BASE_URL = "https://www.virustotal.com/"
 
     _PARAMS = {"apikey": "{API_KEY}"}
-    _IOC_QUERIES = {
-        "ipv4": IoCLookupParams(
+    _QUERIES = {
+        "ipv4": APILookupParams(
             path="vtapi/v2/ip-address/report",
             params={**_PARAMS, "ip": "{observable}"},
             headers=_DEF_HEADERS,
         ),
-        "dns": IoCLookupParams(
+        "dns": APILookupParams(
             path="vtapi/v2/domain/report",
             params={**_PARAMS, "domain": "{observable}"},
             headers=_DEF_HEADERS,
         ),
-        "file_hash": IoCLookupParams(
+        "file_hash": APILookupParams(
             path="vtapi/v2/file/report",
             params={**_PARAMS, "resource": "{observable}"},
             headers={**_DEF_HEADERS, **_GZIP_HEADERS},
         ),
-        "url": IoCLookupParams(
+        "url": APILookupParams(
             path="vtapi/v2/url/report",
             params={**_PARAMS, "resource": "{observable}"},
             headers={**_DEF_HEADERS, **_GZIP_HEADERS},
@@ -59,9 +60,9 @@ class VirusTotal(HttpTIProvider):
     }
 
     # aliases
-    _IOC_QUERIES["md5_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha1_hash"] = _IOC_QUERIES["file_hash"]
-    _IOC_QUERIES["sha256_hash"] = _IOC_QUERIES["file_hash"]
+    _QUERIES["md5_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha1_hash"] = _QUERIES["file_hash"]
+    _QUERIES["sha256_hash"] = _QUERIES["file_hash"]
 
     _REQUIRED_PARAMS = ["API_KEY"]
 
@@ -71,13 +72,13 @@ class VirusTotal(HttpTIProvider):
         "detected_communicating_samples": ("sha256", "date"),
     }
 
-    def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, Any]:
+    def parse_results(self, response: Dict) -> Tuple[bool, ResultSeverity, Any]:
         """
         Return the details of the response.
 
         Parameters
         ----------
-        response : LookupResult
+        response : Dict
             The returned data response
 
         Returns
@@ -88,29 +89,31 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
             Object with match details
 
         """
-        if self._failed_response(response) or not isinstance(response.raw_result, dict):
+        if self._failed_response(response) or not isinstance(
+            response["RawResult"], dict
+        ):
             return False, ResultSeverity.information, "Not found."
 
         result_dict = {
-            "verbose_msg": response.raw_result.get("verbose_msg", None),
-            "response_code": response.raw_result.get("response_code", None),
+            "verbose_msg": response["RawResult"].get("verbose_msg", None),
+            "response_code": response["RawResult"].get("response_code", None),
             "positives": 0,
         }
 
-        if response.ioc_type in [
+        if response["IocType"] in [
             "url",
             "md5_hash",
             "sha1_hash",
             "sha256_hash",
             "file_hash",
         ]:
-            result_dict["resource"] = response.raw_result.get("resource", None)
-            result_dict["permalink"] = response.raw_result.get("permalink", None)
-            result_dict["positives"] = response.raw_result.get("positives", 0)
+            result_dict["resource"] = response["RawResult"].get("resource", None)
+            result_dict["permalink"] = response["RawResult"].get("permalink", None)
+            result_dict["positives"] = response["RawResult"].get("positives", 0)
 
         else:
             for hit_type, params in self._VT_DETECT_RESULTS.items():
-                if hit_type in response.raw_result:
+                if hit_type in response["RawResult"]:
                     self._extract_url_results(
                         response=response,
                         result_dict=result_dict,
@@ -135,18 +138,18 @@ def parse_results(self, response: LookupResult) -> Tuple[bool, ResultSeverity, A
 
     @staticmethod
     def _extract_url_results(
-        response: LookupResult,
+        response: Dict,
         result_dict: Dict[str, Any],
         hit_type: str,
         item_type: str,
         date_name: str,
     ):
-        if not isinstance(response.raw_result, dict):
+        if not isinstance(response["RawResult"], dict):
             return
         time_scope = dt.datetime.now() - dt.timedelta(days=30)
         result_dict[hit_type] = [
             item[item_type]
-            for item in response.raw_result[hit_type]
+            for item in response["RawResult"][hit_type]
             if item_type in item
             and dt.datetime.strptime(item[date_name], "%Y-%m-%d %H:%M:%S") > time_scope
         ]
@@ -154,7 +157,7 @@ def _extract_url_results(
         # pull those our and sum them.
         positives = sum(
             item["positives"]
-            for item in response.raw_result[hit_type]
+            for item in response["RawResult"][hit_type]
             if "positives" in item
             and dt.datetime.strptime(item[date_name], "%Y-%m-%d %H:%M:%S") > time_scope
         )
diff --git a/msticpy/context/vtlookupv3/vtlookup.py b/msticpy/context/vtlookupv3/vtlookup.py
index 6b06a5bee..ca73cb502 100644
--- a/msticpy/context/vtlookupv3/vtlookup.py
+++ b/msticpy/context/vtlookupv3/vtlookup.py
@@ -30,8 +30,8 @@
 from ..._version import VERSION
 from ...common.pkg_config import get_http_timeout
 from ...common.utility import export, mp_ua_header
-from ..tiproviders.lookup_result import SanitizedObservable
-from ..tiproviders.preprocess_observable import preprocess_observable
+from ..lookup_result import SanitizedObservable
+from ..preprocess_observable import preprocess_observable
 
 __version__ = VERSION
 __author__ = "Ian Hellen"
diff --git a/msticpy/init/pivot_init/pivot_ti_provider.py b/msticpy/init/pivot_init/pivot_ti_provider.py
index d5cd30ca5..e80cc210f 100644
--- a/msticpy/init/pivot_init/pivot_ti_provider.py
+++ b/msticpy/init/pivot_init/pivot_ti_provider.py
@@ -100,7 +100,7 @@ def _create_lookup_func(
     suffix = f"_{ioc_name}"
     short_func_name = f"lookup{suffix}"
     func_name = f"{short_func_name}_{ioc_name}"
-    params_dict = {"default_providers": providers, "ioc_type": ioc}
+    params_dict = {"default_providers": providers}
 
     # use IoC name if ioc_type is None
     entity_cls, entity_attr = TI_ENTITY_ATTRIBS[ioc or ioc_name]
@@ -110,7 +110,7 @@ def _create_lookup_func(
         input_type="dataframe",
         entity_map={entity_cls.__name__: entity_attr},
         func_df_param_name="data",
-        func_df_col_param_name="obs_col",
+        func_df_col_param_name="ioc_col",
         func_out_column_name="Ioc",
         func_static_params=params_dict,
     )


Test patch:
diff --git a/tests/context/test_tiprovider_kql.py b/tests/context/test_tiprovider_kql.py
index a6a1dbb03..778582fdb 100644
--- a/tests/context/test_tiprovider_kql.py
+++ b/tests/context/test_tiprovider_kql.py
@@ -13,9 +13,9 @@
 import pytest_check as check
 
 from msticpy.common.provider_settings import get_provider_settings
+from msticpy.context.lookup_result import LookupStatus
 from msticpy.context.tilookup import TILookup
 from msticpy.context.tiproviders.azure_sent_byoti import AzSTI
-from msticpy.context.tiproviders.lookup_result import LookupStatus
 from msticpy.data import QueryProvider
 from msticpy.data.core.data_providers import DriverBase
 
@@ -195,39 +195,39 @@ def test_sentinel_ti_provider(ti_lookup):
     end = datetime(2019, 8, 5, 22, 59, 59, 809000)
     start = datetime(2019, 8, 5, 22, 16, 16, 574000)
 
-    result = ti_lookup.lookup_ioc(observable=_IOC_URL, start=start, end=end)
+    result = ti_lookup.lookup_ioc(ioc=_IOC_URL, start=start, end=end)
     check.is_not_none(result)
-    ioc_lookups = result[1]
+    ioc_lookups = result.to_dict(orient="records")
 
     check.greater_equal(1, len(ioc_lookups))
-    check.equal(ioc_lookups[0][0], "AzSTI")
-    azs_result = ioc_lookups[0][1]
-    check.equal(azs_result.ioc.lower(), _IOC_URL.lower())
-    check.equal(azs_result.ioc_type, "url")
-    check.is_in("alert", azs_result.details["Action"])
-    check.is_in(True, azs_result.details["Active"])
-    check.is_in(100, azs_result.details["ConfidenceScore"])
-    check.is_in("Malware", azs_result.details["ThreatType"])
-
-    res_df = azs_result.raw_result
-    check.is_instance(res_df, pd.DataFrame)
-    check.is_instance(azs_result.reference, str)
-    check.is_true("ThreatIntelligenceIndicator  | where" in azs_result.reference)
+    check.equal(ioc_lookups[0]["Provider"], "AzSTI")
+    azs_result = ioc_lookups[0]
+    check.equal(azs_result["Ioc"].lower(), _IOC_URL.lower())
+    check.equal(azs_result["IocType"], "url")
+    check.is_in("alert", azs_result["Details"]["Action"])
+    check.equal(True, azs_result["Details"]["Active"])
+    check.equal(100, azs_result["Details"]["ConfidenceScore"])
+    check.is_in("Malware", azs_result["Details"]["ThreatType"])
+
+    res_df = azs_result["RawResult"]
+    check.is_instance(res_df, Dict)
+    check.is_instance(azs_result["Reference"], str)
+    check.is_true("ThreatIntelligenceIndicator  | where" in azs_result["Reference"])
 
     # IP Lookups
-    result = ti_lookup.lookup_ioc(observable=_IOC_IP, start=start, end=end)
+    result = ti_lookup.lookup_ioc(ioc=_IOC_IP, start=start, end=end)
     check.is_not_none(result)
-    ioc_lookups = result[1]
+    ioc_lookups = result.to_dict(orient="records")
 
     check.greater_equal(1, len(ioc_lookups))
-    check.equal(ioc_lookups[0][0], "AzSTI")
-    azs_result = ioc_lookups[0][1]
-    check.equal(azs_result.ioc, _IOC_IP)
-    check.equal(azs_result.ioc_type, "ipv4")
-    check.is_in("alert", azs_result.details["Action"])
-    check.is_in(True, azs_result.details["Active"])
-    check.is_in(70, azs_result.details["ConfidenceScore"])
-    check.is_in("Malware", azs_result.details["ThreatType"])
+    check.equal(ioc_lookups[0]["Provider"], "AzSTI")
+    azs_result = ioc_lookups[0]
+    check.equal(azs_result["Ioc"], _IOC_IP)
+    check.equal(azs_result["IocType"], "ipv4")
+    check.is_in("alert", azs_result["Details"]["Action"])
+    check.equal(True, azs_result["Details"]["Active"])
+    check.equal(70, azs_result["Details"]["ConfidenceScore"])
+    check.is_in("Malware", azs_result["Details"]["ThreatType"])
 
 
 _TEST_MULTI = [
diff --git a/tests/context/test_tiproviders.py b/tests/context/test_tiproviders.py
index c2eefcd78..f85480863 100644
--- a/tests/context/test_tiproviders.py
+++ b/tests/context/test_tiproviders.py
@@ -20,11 +20,12 @@
 from msticpy.common import pkg_config
 from msticpy.common.provider_settings import get_provider_settings
 from msticpy.context.tilookup import TILookup
-from msticpy.context.tiproviders.preprocess_observable import (
+from msticpy.context.preprocess_observable import (
     _clean_url,
     preprocess_observable,
 )
-from msticpy.context.tiproviders.ti_provider_base import ResultSeverity, generate_items
+from msticpy.context.provider_base import generate_items
+from msticpy.context.tiproviders.result_severity import ResultSeverity
 from msticpy.context.tiproviders.tor_exit_nodes import Tor
 
 from ..unit_test_lib import custom_mp_config, get_test_data_path
@@ -172,10 +173,23 @@ def test_ti_provider(ti_lookup, provider_name):
     # Lookup multiple IoCs
     for ioc, ioc_params in _TEST_IOCS.items():
         result = ti_lookup.lookup_ioc(
+            ioc=ioc,
+            ioc_type=ioc_params[0],
+            ioc_query_type=ioc_params[1],
+            providers=[provider_name],
+            show_not_supported=True,
+        )
+        verify_result(result, ti_lookup)
+
+    # Check if lookup works with observable parameter
+    for ioc, ioc_params in _TEST_IOCS.items():
+        result = ti_lookup.lookup_ioc(
+            ioc=None,
             observable=ioc,
             ioc_type=ioc_params[0],
             ioc_query_type=ioc_params[1],
             providers=[provider_name],
+            show_not_supported=True,
         )
         verify_result(result, ti_lookup)
 
@@ -201,35 +215,32 @@ def test_ti_provider(ti_lookup, provider_name):
 def verify_result(result, ti_lookup):
     """Verify return results."""
     check.is_not_none(result)
-    for prov, lu_result in result[1]:
+    check.is_instance(result, pd.DataFrame)
+    check.is_false(result.empty)
+    check.equal(1, len(result))
+    for lu_result in result.to_dict(orient="records"):
         check.is_in(
-            prov,
+            lu_result["Provider"],
             ["OTX", "XForce", "VirusTotal", "GreyNoise", "RiskIQ", "IntSights"],
         )
-        check.is_not_none(lu_result.ioc)
-        check.is_not_none(lu_result.ioc_type)
-        if lu_result.result:
-            check.is_not_none(lu_result.details)
-            check.is_not_none(lu_result.raw_result)
-            check.is_not_none(lu_result.reference)
+        check.is_not_none(lu_result["Ioc"])
+        check.is_not_none(lu_result["IocType"])
+        if lu_result["Result"]:
+            check.is_not_none(lu_result["Details"])
+            check.is_not_none(lu_result["RawResult"])
+            check.is_not_none(lu_result["Reference"])
             # exercise summary functions of Lookup class
-            output = io.StringIO()
-            with redirect_stdout(output):
-                lu_result.summary
-            check.is_not_none(output.getvalue())
-            output = io.StringIO()
-            with redirect_stdout(output):
-                lu_result.raw_result_fmtd
-            check.is_not_none(output.getvalue())
 
     # test browser with raw result
     # note something wrong with RiskIQ raw output.
-    if result[1][0][0] != "RiskIQ":
-        ti_lookup.browse(result)
-    # test convert to DF
-    result_df = ti_lookup.result_to_df(result)
-    check.is_instance(result_df, pd.DataFrame)
-    check.is_false(result_df.empty)
+    riskiq_result = result[
+        (result.Provider == "RiskIQ") & (result.Severity != ResultSeverity.unknown.name)
+    ]
+    if not riskiq_result.empty:
+        ti_lookup.browse(riskiq_result)
+        # test convert to DF
+        result_df = ti_lookup.result_to_df(riskiq_result)
+        check.is_instance(result_df, pd.DataFrame)
 
 
 @pytest.mark.filterwarnings("ignore::UserWarning")
@@ -298,33 +309,35 @@ def test_opr_single_result(ti_lookup):
     # Lookup multiple IoCs
     for ioc, ioc_params in iocs.items():
         result = ti_lookup.lookup_ioc(
-            observable=ioc,
+            ioc=ioc,
             ioc_type=ioc_params[0],
             ioc_query_type=ioc_params[1],
             providers=["OPR"],
+            show_not_supported=True,
         )
         check.is_not_none(result)
-        for _, lu_result in result[1]:
-            check.is_not_none(lu_result.ioc)
-            check.is_not_none(lu_result.ioc_type)
-            if lu_result.severity in ["warning", "high"]:
+        for lu_result in result.to_dict(orient="records"):
+            check.is_not_none(lu_result["Ioc"])
+            check.is_not_none(lu_result["IocType"])
+            if lu_result["Severity"] in ["warning", "high"]:
                 check.is_true(
-                    "rank" in lu_result.details and lu_result.details["rank"] is None
+                    "rank" in lu_result["Details"]
+                    and lu_result["Details"]["rank"] is None
                 )
                 check.is_true(
-                    "error" in lu_result.details
-                    and lu_result.details["error"] == "Domain not found"
+                    "error" in lu_result["Details"]
+                    and lu_result["Details"]["error"] == "Domain not found"
                 )
             else:
                 check.is_true(
-                    "rank" in lu_result.details
-                    and lu_result.details["rank"].isdigit()
-                    and int(lu_result.details["rank"]) > 0
+                    "rank" in lu_result["Details"]
+                    and lu_result["Details"]["rank"].isdigit()
+                    and int(lu_result["Details"]["rank"]) > 0
                 )
                 check.is_true(
-                    "response" in lu_result.raw_result
-                    and lu_result.raw_result["response"][0]
-                    and lu_result.raw_result["response"][0]["domain"] == ioc
+                    "response" in lu_result["RawResult"]
+                    and lu_result["RawResult"]["response"][0]
+                    and lu_result["RawResult"]["response"][0]["domain"] == ioc
                 )
 
 
@@ -342,7 +355,7 @@ def test_opr_multi_result(ti_lookup):
     )
 
     results_df = ti_lookup.lookup_iocs(
-        data=gen_doms, obs_col="domain", ioc_type_col="ioc_type", providers=["OPR"]
+        data=gen_doms, ioc_col="domain", ioc_type_col="ioc_type", providers=["OPR"]
     )
     check.equal(n_requests, len(results_df))
     check.greater_equal(
@@ -393,13 +406,17 @@ def test_tor_exit_nodes(ti_lookup, monkeypatch):
     pos_results = []
     neg_results = []
     for ioc in tor_nodes + other_ips:
-        result = ti_lookup.lookup_ioc(observable=ioc, providers=["Tor"])
-        lu_result = result[1][0][1]
-        check.is_true(lu_result.result)
-        check.is_true(bool(lu_result.reference))
-        if lu_result.severity in ["warning", "high"]:
-            check.is_true(bool(lu_result.details))
-            check.is_true(bool(lu_result.raw_result))
+        result = ti_lookup.lookup_ioc(
+            ioc=ioc,
+            providers=["Tor"],
+            show_not_supported=True,
+        )
+        lu_result = result.to_dict(orient="records")[0]
+        check.is_true(lu_result["Result"])
+        check.is_true(bool(lu_result["Reference"]))
+        if lu_result["Severity"] in ["warning", "high"]:
+            check.is_true(bool(lu_result["Details"]))
+            check.is_true(bool(lu_result["RawResult"]))
             pos_results.append(lu_result)
         else:
             neg_results.append(lu_result)
@@ -420,15 +437,15 @@ def test_check_ioc_type(ti_lookup):
     """Check IOC types."""
     provider = ti_lookup.loaded_providers["OTX"]
     lu_result = provider._check_ioc_type(ioc="a.b.c.d", ioc_type="ipv4")
-    check.equal(lu_result.status, 2)
+    check.equal(lu_result["Status"], 2)
     lu_result = provider._check_ioc_type(ioc="a.b.c.d", ioc_type="ipv6")
-    check.equal(lu_result.status, 2)
+    check.equal(lu_result["Status"], 2)
     lu_result = provider._check_ioc_type(ioc="url", ioc_type="ipv4")
-    check.equal(lu_result.status, 2)
+    check.equal(lu_result["Status"], 2)
     lu_result = provider._check_ioc_type(ioc="123", ioc_type="dns")
-    check.equal(lu_result.status, 2)
+    check.equal(lu_result["Status"], 2)
     lu_result = provider._check_ioc_type(ioc="424246", ioc_type="file_hash")
-    check.equal(lu_result.status, 2)
+    check.equal(lu_result["Status"], 2)
 
 
 def test_result_severity():
@@ -509,11 +526,11 @@ def test_iterable_generator():
     test_df = pd.DataFrame({"col1": _IOC_IPS, "col2": _IOC_IPS})
 
     # DataFrames
-    for ioc, _ in generate_items(test_df, obs_col="col1", ioc_type_col="col2"):
+    for ioc, _ in generate_items(test_df, item_col="col1", item_type_col="col2"):
         check.is_in(ioc, _IOC_IPS)
 
     # Iterables
-    for ioc, ioc_type in generate_items(test_df[["col1"]], obs_col="col1"):
+    for ioc, ioc_type in generate_items(test_df[["col1"]], item_col="col1"):
         check.is_in(ioc, _IOC_IPS)
         check.equal(ioc_type, "ipv4")
 
diff --git a/tests/context/test_vtlookup.py b/tests/context/test_vtlookup.py
index 76c96a604..18f9612d5 100644
--- a/tests/context/test_vtlookup.py
+++ b/tests/context/test_vtlookup.py
@@ -7,7 +7,7 @@
 import unittest
 from os import path
 
-from msticpy.context.tiproviders import preprocess_observable
+from msticpy.context.preprocess_observable import preprocess_observable
 from msticpy.context.vtlookupv3.vtlookup import VTLookup
 
 from ..unit_test_lib import TEST_DATA_PATH
@@ -80,7 +80,7 @@ def test_parse_file_results(self):
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
 
         FILE_NAME = path.join(TEST_DATA_PATH, "fileresponse.json")
-        with open(FILE_NAME, "r") as file_handle:
+        with open(FILE_NAME, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["file"]
@@ -97,7 +97,7 @@ def test_parse_file_results(self):
 
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
         FILE_NAME2 = path.join(TEST_DATA_PATH, "file-multi_pos.json")
-        with open(FILE_NAME2, "r") as file_handle:
+        with open(FILE_NAME2, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["file"]
@@ -116,7 +116,7 @@ def test_parse_url_results(self):
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
 
         FILE_NAME = path.join(TEST_DATA_PATH, "url_pos.json")
-        with open(FILE_NAME, "r") as file_handle:
+        with open(FILE_NAME, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["url"]
@@ -132,7 +132,7 @@ def test_parse_url_results(self):
 
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
         FILE_NAME2 = path.join(TEST_DATA_PATH, "url_neg.json")
-        with open(FILE_NAME2, "r") as file_handle:
+        with open(FILE_NAME2, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["url"]
@@ -150,7 +150,7 @@ def test_parse_domain_results(self):
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
 
         FILE_NAME = path.join(TEST_DATA_PATH, "domain_pos.json")
-        with open(FILE_NAME, "r") as file_handle:
+        with open(FILE_NAME, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["domain"]
@@ -171,7 +171,7 @@ def test_parse_domain_results(self):
 
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
         FILE_NAME2 = path.join(TEST_DATA_PATH, "domain_neg.json")
-        with open(FILE_NAME2, "r") as file_handle:
+        with open(FILE_NAME2, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["domain"]
@@ -191,7 +191,7 @@ def test_parse_ip_results(self):
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
 
         FILE_NAME = path.join(TEST_DATA_PATH, "ip-address_pos.json")
-        with open(FILE_NAME, "r") as file_handle:
+        with open(FILE_NAME, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["ip-address"]
@@ -210,7 +210,7 @@ def test_parse_ip_results(self):
 
         vtlookup = VTLookup(vtkey="fake", verbosity=2)
         FILE_NAME2 = path.join(TEST_DATA_PATH, "ip-address_neg.json")
-        with open(FILE_NAME2, "r") as file_handle:
+        with open(FILE_NAME2, "r", encoding="utf8") as file_handle:
             txt = file_handle.read()
 
         vt_params = vtlookup._VT_API_TYPES["ip-address"]