Repository: egnyte/cloudimized (language: python)

Issue:
Single scan run
Implementing single scan mode - #9 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
new file mode 100644
index 0000000..9a96214
--- /dev/null
+++ b/.github/workflows/publish-to-pypi.yml
@@ -0,0 +1,37 @@
+---
+name: Publish 🐍 to PyPI
+
+on:
+  push:
+    tags:
+      - 'v*'
+      - '!v*rc*'
+
+jobs:
+  build-n-publish:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.9
+      - name: Build
+        run: |
+          pip install wheel
+          python setup.py build sdist bdist_wheel
+      - name: Publish distribution 📦 to PyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.1
+        with:
+          password: ${{ secrets.PYPI_API_TOKEN }}
+      - name: Create release in GitHub
+        id: create_release
+        uses: actions/create-release@v1
+        env:
+          # This token is provided by Actions, you do not need to create your own token
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref }}
+          release_name: ${{ github.ref }}
+          draft: false
+          prerelease: false
diff --git a/.gitignore b/.gitignore
index 75cf67a..66c9cd1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
 .idea/*
 __pycache__
+.pytest_cache
+cloudimized.egg-info
+venv
diff --git a/README.md b/README.md
index 627ef12..cb22b78 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ monitoring changes of selected resources.
 
 Cloudimized performs similar function as Oxidized, but for Cloud environment.
 
-![Example Slack notification when using all optional features, including a git diff of the change](cloudimized-example.png)
+![Example Slack notification when using all optional features, including a git diff of the change](https://raw.githubusercontent.com/egnyte/cloudimized/main/cloudimized-example.png)
 
 ## Overview
 
@@ -53,15 +53,13 @@ On each execution Cloudimized performs following actions:
 
 ## Installation
 
-1. Currently Cloudimized is installed via git clone
+1. Install Cloudimized with [pipx](https://github.com/pypa/pipx) (recommended) or plain pip.
 
 ```
-git clone https://github.com/egnyte/cloudimized.git
+pipx install cloudimized
 ```
 
-2. Install packages from requirements.txt
-
-3. Cloudimized for operation requires Git repo for storing GCP configuration files.
+2. Cloudimized for operation requires Git repo for storing GCP configuration files.
    1. Set-up empty Git repo in remote location i.e. GitHub or GitLab
 
 ## Running
@@ -72,7 +70,7 @@ After installation:
    1. This can be achieved via number of ways i.e.
       1. via cron
       2. via automation server
-   2. Execute with `python3 main.py -c <PATH_TO_FILE>/config.yaml`
+   2. Execute with `cloudimized -c <PATH_TO_FILE>/config.yaml`
 
 ## Configuration
 
@@ -296,3 +294,38 @@ change_processor:
         # Terraform workspaces list
         workspace: ["my-workspace-no1"]       
 ```
+
+## Single run mode
+
+Allows to run cloudimized only to scan given resource and dump them into text files, without performing and additional
+functions (no Git, Terraform, Slack, Jira interaction and no GCP logs lookup).
+
+### Running
+
+```
+cloudimized --singlerun/-s <RESOURCE_NAME> --output/-o {yaml, csv}
+
+i.e
+cloudimized -s addresses -o csv
+```
+
+### Configuration
+
+Resource configurations for single run (**<RESOURCE_NAME>** parameter) to be scanned are stored in **singlerunconfigs**
+directory and are selected based on filename. Resource configuration is the same as in main config file. Additional
+singe run mode configs can be added to folder as needed.
+
+Get info available configs or what will be run with <RESOURCE_NAME> with:
+
+```
+cloudimized -s --list
+cloudimized -s --describe --name <RESOURCE_NAME>
+```
+
+### Output
+
+By default script will dump results in YAML format same as in main mode. If chosen it can dump results in CSV file
+format (single file per resource).
+
+**LIMITATION:** Currently output in csv format doesn't support nested structures, it will fail if there is a list or
+dictionary in result. Therefore, selecting specific fields for each resource is needed.
diff --git a/core/__init__.py b/cloudimized/__init__.py
similarity index 100%
rename from core/__init__.py
rename to cloudimized/__init__.py
diff --git a/gcpcore/__init__.py b/cloudimized/core/__init__.py
similarity index 100%
rename from gcpcore/__init__.py
rename to cloudimized/core/__init__.py
diff --git a/core/changeprocessor.py b/cloudimized/core/changeprocessor.py
similarity index 95%
rename from core/changeprocessor.py
rename to cloudimized/core/changeprocessor.py
index 446185a..6b88ac8 100644
--- a/core/changeprocessor.py
+++ b/cloudimized/core/changeprocessor.py
@@ -1,14 +1,15 @@
 import logging
 import re
-from typing import Dict, List, Any
 from datetime import datetime
-from tfcore.query import TFQuery, TFQueryError, configure_tfquery
-from gitcore.repo import GitRepo
-from gitcore.gitchange import GitChange
-from gcpcore.gcpquery import GcpQuery
-from gcpcore.gcpchangelog import getChangeLogs
-from core.slacknotifier import SlackNotifier, SlackNotifierError, configure_slack_notifier
-from core.jiranotifier import JiraNotifier, JiraNotifierError, configure_jiranotifier
+from typing import Dict, List, Any
+
+from cloudimized.core.jiranotifier import JiraNotifier, JiraNotifierError, configure_jiranotifier
+from cloudimized.core.slacknotifier import SlackNotifier, SlackNotifierError, configure_slack_notifier
+from cloudimized.gcpcore.gcpchangelog import getChangeLogs
+from cloudimized.gcpcore.gcpquery import GcpQuery
+from cloudimized.gitcore.gitchange import GitChange
+from cloudimized.gitcore.repo import GitRepo
+from cloudimized.tfcore.query import TFQuery, TFQueryError, configure_tfquery
 
 logger = logging.getLogger(__name__)
 
@@ -22,6 +23,7 @@
 SLACK_SECTION = "slack"
 JIRA_SECTION = "jira"
 
+
 class ChangeProcessor:
     """
     Handles processing detected Git changes
@@ -62,7 +64,7 @@ def process_change(self, git_change: GitChange, change_time: datetime = None):
         :param git_change: change detected by Git
         :param change_time: time of scan start/finish
         """
-        #TODO: implement better time selection
+        # TODO: implement better time selection
         message = f"{git_change.resource_type.title()} updated in {git_change.project}"
         manual_change = False
         skip_process_ticket = False
@@ -149,7 +151,7 @@ def process_change(self, git_change: GitChange, change_time: datetime = None):
                                     logger.warning(f"Issue retrieving ticket number from "
                                                    f"run '{tf_run}'\n{e}\n{e.__cause__}")
                                     continue
-                                #TODO Parametrize string replacement
+                                # TODO Parametrize string replacement
                                 message += f"\n Related ticket {self.ticket_sys_url}/{ticket.replace('_', '-')}"
         # Add least one changer has been identified
         if changers:
diff --git a/core/core.py b/cloudimized/core/core.py
similarity index 56%
rename from core/core.py
rename to cloudimized/core/core.py
index 5d157b6..7ac0612 100644
--- a/core/core.py
+++ b/cloudimized/core/core.py
@@ -1,24 +1,26 @@
 import argparse
 import logging
 import os
-import sys
 
+import sys
 import yaml
 
-from gcpcore.gcpservicequery import configure_services, GcpServiceQueryConfigError
-from gcpcore.gcpservicequery import SERVICE_NAME, SERVICE_SECTION, VERSION, QUERIES
-from gcpcore.gcpquery import configure_queries, GcpQueryArgumentError, GcpQueryError
-from gcpcore.gcpquery import RESOURCE, GCP_API_CALL, RESULT_ITEMS_FIELD, ITEM_EXCLUDE_FILTER, GCP_LOG_RESOURCE_TYPE
-from gitcore.repo import configure_repo, GitRepoError, GitRepoConfigError, GIT_USER, GIT_PASSWORD, GIT_SECTION
-from tfcore.query import configure_tfquery, TFQueryConfigurationError, TERRAFORM_SECTION
-from core.result import set_query_results_from_configuration, QueryResultError
-from core.changeprocessor import configure_change_processor, ChangeProcessorError, CHANGE_PROCESSOR
-from core.slacknotifier import SLACK_TOKEN
-from core.jiranotifier import JIRA_USR, JIRA_PSW
+from cloudimized.core.changeprocessor import configure_change_processor, ChangeProcessorError, CHANGE_PROCESSOR
+from cloudimized.core.result import set_query_results_from_configuration, QueryResultError
+from cloudimized.core.slacknotifier import SLACK_TOKEN
+from cloudimized.gcpcore.gcpquery import RESOURCE, GCP_API_CALL, RESULT_ITEMS_FIELD, ITEM_EXCLUDE_FILTER, \
+    GCP_LOG_RESOURCE_TYPE
+from cloudimized.gcpcore.gcpquery import configure_queries, GcpQueryArgumentError, GcpQueryError
+from cloudimized.gcpcore.gcpservicequery import SERVICE_NAME, SERVICE_SECTION, VERSION, QUERIES
+from cloudimized.gcpcore.gcpservicequery import configure_services, GcpServiceQueryConfigError
+from cloudimized.gitcore.repo import configure_repo, GitRepoError, GitRepoConfigError, GIT_USER, GIT_PASSWORD, \
+    GIT_SECTION
+from cloudimized.core.jiranotifier import JIRA_USR, JIRA_PSW
 
 logger = logging.getLogger(__name__)
 
 CONFIG_FILE = "config.yaml"
+SINGLE_RUN_CONFIGS_DIR = "singlerunconfigs"
 
 # Configuration file - key names
 GCP_QUERIES = "queries"
@@ -55,7 +57,12 @@ class GcpOxidizer:
     def __init__(self):
         (
             self.config_file,
-            self.loglevel
+            self.loglevel,
+            self.arg_singlerun,
+            self.arg_output,
+            self.arg_list,
+            self.arg_describe,
+            self.arg_name
         ) = self.parse_args()
         self.gcp_services = None
         self.gcp_type_queries_map = {}
@@ -66,19 +73,66 @@ def __init__(self):
         self.projects = None
         self.run_results = None
         self.change_processor = None
-        self.parse_config_file()
+        if not self.arg_singlerun:
+            self.parse_config_file()
+        else:
+            if self.arg_list:
+                self.list_singlerun_configs()
+                sys.exit(0)
+            if self.arg_describe:
+                self.describe_singlerun_configs(self.arg_name)
+                sys.exit(0)
+            self.set_single_run(resource_name=self.arg_name)
+            self.set_logging(self.loglevel)
 
     def parse_args(self):
-        parser = argparse.ArgumentParser("Runs GCP oxidizer")
+        parser = argparse.ArgumentParser("Runs GCP oxidizer",
+                                         formatter_class=argparse.ArgumentDefaultsHelpFormatter)
         parser.add_argument("-c", "--config", default=CONFIG_FILE, help="Configuration file")
         parser.add_argument("-l", "--loglevel", default="INFO", help="Set logging level",
                             choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"])
+        singlerun_group = parser.add_argument_group('Single Run Mode')
+        singlerun_group.add_argument("-s", "--singlerun", action="store_true", help="Scan & dump files mode")
+        singlerun_group.add_argument("--list", action="store_true", help="List single run configs")
+        singlerun_group.add_argument("-d", "--describe", action="store_true", help="Print single run config")
+        singlerun_group.add_argument("-n", "--name", type=str, help="Name of single run config")
+        singlerun_group.add_argument("-o", "--output", default="yaml", choices=["yaml", "csv"],
+                                     help="Output file format")
         args = parser.parse_args()
+        # Name has to be set if running singlerun (except listing)
+        if (args.singlerun == True and args.list == False and args.name is None):
+            parser.error("--name is required in singlerun mode")
         return (
             args.config,
-            args.loglevel
+            args.loglevel,
+            args.singlerun,
+            args.output,
+            args.list,
+            args.describe,
+            args.name
         )
 
+    def list_singlerun_configs(self):
+        try:
+            script_dir = os.path.dirname(__file__)
+            configs_dir = os.path.abspath(f"{script_dir}/../{SINGLE_RUN_CONFIGS_DIR}")
+            all_files = [f.split(".")[0] for f in os.listdir(configs_dir)
+                         if os.path.isfile(os.path.join(configs_dir, f))]
+            print(f"Available single run configs:\n{all_files}")
+        except Exception as e:
+            print(f"Issue discovering singe run configs\n{str(e)}")
+
+    def describe_singlerun_configs(self, config_name: str):
+        try:
+            script_dir = os.path.dirname(__file__)
+            configs_dir = f"{script_dir}/../{SINGLE_RUN_CONFIGS_DIR}"
+            filename = os.path.abspath(f"{configs_dir}/{config_name}.yaml")
+            print(f"Config file: {filename}\n")
+            with open(filename, 'r') as fh:
+                print(fh.read())
+        except Exception as e:
+            print(f"Issue discovering singe run configs\n{str(e)}")
+
     def parse_config_file(self) -> None:
         """
         Read in and parse configuration file
@@ -134,14 +188,45 @@ def parse_config_file(self) -> None:
                                                                jira_token=os.getenv(JIRA_PSW))
         except ChangeProcessorError as e:
             raise GcpOxidizerConfigException(f"Issue with ChangeProcessor config") from e
-        #TODO Add type checking for below options
-        #TODO Add config check when discovery list is disabled and project list is not provided
+        # TODO Add type checking for below options
+        # TODO Add config check when discovery list is disabled and project list is not provided
         self.do_project_discovery = config.get(DISCOVER_PROJECTS_KEY, "False")
         self.excluded_projects = config.get(EXCLUDED_PROJECTS_KEY, [])
-        self.projects = config.get(PROJECTS_LIST_KEY, None) #TODO Add logic to detect if list is not set
-        #TODO Move logging setup at the beggining
+        self.projects = config.get(PROJECTS_LIST_KEY, None)  # TODO Add logic to detect if list is not set
+        # TODO Move logging setup at the beggining
         self.set_logging(self.loglevel)
 
+    def set_single_run(self, resource_name: str):
+        """
+        Prepare configuration for single run mode
+        :param resource_name: GCP resource name to scan
+        """
+        try:
+            script_dir = os.path.dirname(__file__)
+            filename = f"{script_dir}/../{SINGLE_RUN_CONFIGS_DIR}/{resource_name}.yaml"
+            with open(filename) as fh:
+                service = yaml.safe_load(fh)
+        except yaml.YAMLError as e:
+            raise GcpOxidizerConfigException(f"Error opening yaml file: '{filename}'") from e
+        try:
+            self.gcp_services = configure_services([service])
+        except GcpServiceQueryConfigError as e:
+            raise GcpOxidizerConfigException(f"Error in service config in file: '{filename}'") from e
+        try:
+            serviceName = service[SERVICE_NAME]
+            queries = configure_queries(service[GCP_QUERIES])
+            self.gcp_services[serviceName].queries = queries
+            self.gcp_type_queries_map.update(queries)
+        except GcpQueryArgumentError as e:
+            raise GcpOxidizerConfigException(f"Error in query config in file: '{filename}'") from e
+        try:
+            self.run_results = set_query_results_from_configuration(self.gcp_services)
+        except QueryResultError as e:
+            raise GcpOxidizerConfigException(f"Error in service/query configuration") from e
+        self.excluded_projects = []
+
+
+
     def set_logging(self, loglevel: str):
         """
         Configures logging
@@ -157,7 +242,7 @@ def discover_projects(self) -> None:
         logger.info(f"Performing GCP projects discovery")
         project_service = configure_services(PROJECTS_DISCOVERY_SERVICE_CONFIG)
         project_service[PROJECTS_DISCOVERY_SERVICE_NAME].queries = \
-                configure_queries(PROJECTS_DISCOVERY_SERVICE_CONFIG[0][GCP_QUERIES])
+            configure_queries(PROJECTS_DISCOVERY_SERVICE_CONFIG[0][GCP_QUERIES])
         project_service[PROJECTS_DISCOVERY_SERVICE_NAME].build()
         result = project_service[PROJECTS_DISCOVERY_SERVICE_NAME].queries[PROJECTS_DISCOVERY_RESOURCE_NAME] \
             .execute(project_id=None)
@@ -193,41 +278,52 @@ def run_queries(self) -> None:
                                        f"for project '{project_id}\n{e}\n{e.__cause__}")
                         #TODO: Add handling of failed queries i.e. error stats at the end
 
-    @staticmethod
-    def run():
+    def singlerun_mode(self):
+        # Discover all projects
         try:
-            # Setup and parse configuraiton
-            gcpoxidizer = GcpOxidizer()
-        except GcpOxidizerConfigException as e:
-            logger.critical(f"Error in GcpOxidizer configuration\n{e}\n{e.__cause__}")
+            self.discover_projects()
+        except Exception as e:
+            logger.critical(f"Issue during projects discovery\n{e}\n{e.__cause__}")
             sys.exit(1)
+        # Run queries
+        self.run_queries()
+        # Dump results to files
+        try:
+            if self.arg_output == "csv":
+                self.run_results.dump_results_csv(directory=".")
+            elif self.arg_output == "yaml":
+                self.run_results.dump_results(directory=".")
+        except QueryResultError as e:
+            logger.critical(f"Issue during dumping results to local files\n{e}\n{e.__cause__}")
+
+    def main_mode(self):
         try:
-            gcpoxidizer.git_repo.setup()
+            self.git_repo.setup()
         except GitRepoError as e:
-            logger.critical(f"Error setting up Git Repo: '{gcpoxidizer.git_repo.repo_url}'\n{e}\n{e.__cause__}")
-        if gcpoxidizer.do_project_discovery:
+            logger.critical(f"Error setting up Git Repo: '{self.git_repo.repo_url}'\n{e}\n{e.__cause__}")
+        if self.do_project_discovery:
             try:
-                gcpoxidizer.discover_projects()
+                self.discover_projects()
             except Exception as e:
                 logger.critical(f"Issue during projects discovery\n{e}\n{e.__cause__}")
                 sys.exit(1)
         # Clean repo
         try:
-            gcpoxidizer.git_repo.clean_repo()
+            self.git_repo.clean_repo()
             pass
         except GitRepoError as e:
             logger.critical(f"Issue during Git repo preparation\n{e}\n{e.__cause__}")
             sys.exit(1)
         # Run all queries
-        gcpoxidizer.run_queries()
+        self.run_queries()
         # Dump results to files
         try:
-            gcpoxidizer.run_results.dump_results(directory=gcpoxidizer.git_repo.directory)
+            self.run_results.dump_results(directory=self.git_repo.directory)
         except QueryResultError as e:
             logger.critical(f"Issue during dumping results to local files\n{e}\n{e.__cause__}")
         try:
             logger.info(f"Checking Git configuration files for changes")
-            git_changes = gcpoxidizer.git_repo.get_changes()
+            git_changes = self.git_repo.get_changes()
         except GitRepoError as e:
             logger.critical(f"Issue verifying changes in Git Repo\n{e}\n{e.__cause__}")
         if not git_changes:
@@ -236,12 +332,27 @@ def run():
         else:
             try:
                 logger.info(f"Processing {len(git_changes)} Git change(s)")
-                gcpoxidizer.change_processor.process(git_changes=git_changes)
+                self.change_processor.process(git_changes=git_changes)
             except ChangeProcessorError as e:
                 logger.critical(f"Issue processing Git changes\n{e}\n{e.__cause__}")
         logger.info("Run completed")
 
 
+def execute():
+    try:
+        # Setup and parse configuraiton
+        gcpoxidizer = GcpOxidizer()
+    except GcpOxidizerConfigException as e:
+        logger.critical(f"Error in GcpOxidizer configuration\n{e}\n{e.__cause__}")
+        sys.exit(1)
+    if not gcpoxidizer.arg_singlerun:
+        logger.info("Running in main mode")
+        gcpoxidizer.main_mode()
+    else:
+        logger.info("Running in single run mode")
+        gcpoxidizer.singlerun_mode()
+
+
 class GcpOxidizerException(Exception):
     pass
 
diff --git a/core/jiranotifier.py b/cloudimized/core/jiranotifier.py
similarity index 97%
rename from core/jiranotifier.py
rename to cloudimized/core/jiranotifier.py
index 8df3f81..a470e99 100644
--- a/core/jiranotifier.py
+++ b/cloudimized/core/jiranotifier.py
@@ -1,9 +1,10 @@
 import logging
 import re
 from typing import Dict, Any
+
 from jira import JIRA
 
-from gitcore.gitchange import GitChange
+from cloudimized.gitcore.gitchange import GitChange
 
 logger = logging.getLogger(__name__)
 
@@ -18,6 +19,7 @@
 FILTERSET = "filterSet"
 PROJECTIDFILTER = "projectId"
 
+
 class JiraNotifier:
     """
     Creates ticket in Jira for configuration change
@@ -49,11 +51,11 @@ def post(self, change: GitChange):
         """
         Creates Jira issue for Git Change
         """
-        #Skip for non-manual changes
+        # Skip for non-manual changes
         if not change.manual:
             logger.info("Skiping ticket creation for non-manual change")
             return
-        #Skip for non-matching projects
+        # Skip for non-matching projects
         if self.filter_set:
             if not re.match(rf"{self.filter_set.get(PROJECTIDFILTER)}", change.project):
                 logger.info("Skipping ticket creation for non-matching project id")
diff --git a/core/result.py b/cloudimized/core/result.py
similarity index 64%
rename from core/result.py
rename to cloudimized/core/result.py
index ecb45f5..af6a822 100644
--- a/core/result.py
+++ b/cloudimized/core/result.py
@@ -1,13 +1,15 @@
 import logging
 import yaml
+import csv
 from typing import List, Dict
 from pathlib import Path
 from os.path import isdir
 
-from gcpcore.gcpservicequery import GcpServiceQuery
+from cloudimized.gcpcore.gcpservicequery import GcpServiceQuery
 
 logger = logging.getLogger(__name__)
 
+PROJECTID_KEY = "projectId"
 
 class QueryResult:
     """
@@ -73,6 +75,44 @@ def dump_results(self, directory: str) -> None:
                     raise QueryResultError(f"Issue dumping results into file "
                                            f"'{directory}/{resource_name}/{project_id}.yaml")
 
+    def dump_results_csv(self, directory: str) -> None:
+        """
+        Save results in CSV files
+        :param directory: directory to which dump CSV files
+        :raises QueryResultError
+        """
+        logger.info(f"Dumping results in CSV files")
+        if not isdir(directory):
+            raise QueryResultError(f"Issue dumping results to files. Directory '{directory}' doesn't exist")
+        fieldnames_map = {}
+        # Get fieldnames
+        for resource_name, projects in self.resources.items():
+            logger.info(f"Discovering fieldnames for resource: {resource_name}")
+            fieldnames_map[resource_name] = set()
+            for project_id, result in projects.items():
+                for entry in result:
+                    try:
+                        fieldnames_map[resource_name].update(entry.keys())
+                    except Exception as e:
+                        logger.warning(f"Unable to get fieldnames for resource {resource_name} from entry {entry}")
+                        continue
+        for resource_name, projects in self.resources.items():
+            fieldnames = [PROJECTID_KEY] + sorted(list(fieldnames_map[resource_name]))
+            filename = f"{directory}/{resource_name}.csv"
+            logger.info(f"Dumping results in {filename}")
+            try:
+                with open(filename, "w", newline="") as csvfile:
+                    writer = csv.DictWriter(csvfile, fieldnames)
+                    writer.writeheader()
+                    for project_id, result in projects.items():
+                        if not result:
+                            continue
+                        for entry in result:
+                            entry[PROJECTID_KEY] = project_id
+                            writer.writerow(entry)
+            except Exception as e:
+                raise QueryResultError(f"Issue writing results to file {filename}") from e
+
 
 def set_query_results_from_configuration(gcp_services: Dict[str, GcpServiceQuery]) -> QueryResult:
     """
diff --git a/cloudimized/core/run.py b/cloudimized/core/run.py
new file mode 100644
index 0000000..004d65f
--- /dev/null
+++ b/cloudimized/core/run.py
@@ -0,0 +1,9 @@
+from cloudimized.core.core import execute
+
+
+def run():
+    execute()
+
+
+if __name__ == "__main__":
+    run()
diff --git a/core/slacknotifier.py b/cloudimized/core/slacknotifier.py
similarity index 98%
rename from core/slacknotifier.py
rename to cloudimized/core/slacknotifier.py
index 7205cb4..85f3d05 100644
--- a/core/slacknotifier.py
+++ b/cloudimized/core/slacknotifier.py
@@ -2,7 +2,7 @@
 from slack_sdk import WebClient
 from typing import Dict, Any
 
-from gitcore.gitchange import GitChange
+from cloudimized.gitcore.gitchange import GitChange
 
 logger = logging.getLogger(__name__)
 
diff --git a/gitcore/__init__.py b/cloudimized/gcpcore/__init__.py
similarity index 100%
rename from gitcore/__init__.py
rename to cloudimized/gcpcore/__init__.py
diff --git a/gcpcore/gcpchangelog.py b/cloudimized/gcpcore/gcpchangelog.py
similarity index 98%
rename from gcpcore/gcpchangelog.py
rename to cloudimized/gcpcore/gcpchangelog.py
index 3982f21..340ad7a 100644
--- a/gcpcore/gcpchangelog.py
+++ b/cloudimized/gcpcore/gcpchangelog.py
@@ -2,7 +2,7 @@
 
 from typing import List
 from datetime import datetime, timedelta
-from gcpcore.gcpquery import GcpQuery
+from cloudimized.gcpcore.gcpquery import GcpQuery
 from google.cloud import logging as gcp_logging
 
 logger = logging.getLogger(__name__)
diff --git a/gcpcore/gcpquery.py b/cloudimized/gcpcore/gcpquery.py
similarity index 100%
rename from gcpcore/gcpquery.py
rename to cloudimized/gcpcore/gcpquery.py
diff --git a/gcpcore/gcpservicequery.py b/cloudimized/gcpcore/gcpservicequery.py
similarity index 100%
rename from gcpcore/gcpservicequery.py
rename to cloudimized/gcpcore/gcpservicequery.py
diff --git a/tests/__init__.py b/cloudimized/gitcore/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to cloudimized/gitcore/__init__.py
diff --git a/gitcore/gitchange.py b/cloudimized/gitcore/gitchange.py
similarity index 100%
rename from gitcore/gitchange.py
rename to cloudimized/gitcore/gitchange.py
diff --git a/gitcore/repo.py b/cloudimized/gitcore/repo.py
similarity index 99%
rename from gitcore/repo.py
rename to cloudimized/gitcore/repo.py
index 882ac7e..718f9f1 100644
--- a/gitcore/repo.py
+++ b/cloudimized/gitcore/repo.py
@@ -5,7 +5,7 @@
 from os.path import exists, isdir, join
 from os import listdir
 from shutil import rmtree
-from gitcore.gitchange import GitChange
+from cloudimized.gitcore.gitchange import GitChange
 from os.path import basename, dirname
 
 logger = logging.getLogger(__name__)
diff --git a/cloudimized/singlerunconfigs/addresses.yaml b/cloudimized/singlerunconfigs/addresses.yaml
new file mode 100644
index 0000000..cba9abd
--- /dev/null
+++ b/cloudimized/singlerunconfigs/addresses.yaml
@@ -0,0 +1,19 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: addresses
+    gcp_api_call: addresses.aggregatedList
+    gcp_function_args:
+      project: <PROJECT_ID>
+#      filter: addressType=EXTERNAL
+    gcp_log_resource_type: None #non-applicable
+    field_include_filter:
+      - address
+      - name
+      - region
+      - status
+#    field_exclude_filter:
+#      - creationTimestamp
+#      - id
+#      - kind
+#      - selfLink
diff --git a/cloudimized/singlerunconfigs/forwardingRules.yaml b/cloudimized/singlerunconfigs/forwardingRules.yaml
new file mode 100644
index 0000000..6a67465
--- /dev/null
+++ b/cloudimized/singlerunconfigs/forwardingRules.yaml
@@ -0,0 +1,21 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: forwardingRules
+    gcp_api_call: forwardingRules.aggregatedList
+    gcp_function_args:
+      project: <PROJECT_ID>
+#      filter:
+    gcp_log_resource_type: None #non-applicable
+#    field_include_filter:
+#      - address
+#      - name
+#      - region
+#      - status
+    field_exclude_filter:
+      - creationTimestamp
+      - fingerprint
+      - id
+      - kind
+      - labelFingerprint
+      - selfLink
diff --git a/cloudimized/singlerunconfigs/globalAddresses.yaml b/cloudimized/singlerunconfigs/globalAddresses.yaml
new file mode 100644
index 0000000..ae3b597
--- /dev/null
+++ b/cloudimized/singlerunconfigs/globalAddresses.yaml
@@ -0,0 +1,15 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: globalAddresses
+    gcp_api_call: globalAddresses.list
+    gcp_function_args:
+      project: <PROJECT_ID>
+      filter: addressType=EXTERNAL
+    gcp_log_resource_type: None #non-applicable
+    field_include_filter:
+      - address
+      - name
+      - status
+#    field_exclude_filter:
+#      - id
diff --git a/cloudimized/singlerunconfigs/networks.yaml b/cloudimized/singlerunconfigs/networks.yaml
new file mode 100644
index 0000000..d17925f
--- /dev/null
+++ b/cloudimized/singlerunconfigs/networks.yaml
@@ -0,0 +1,20 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: networks
+    gcp_api_call: networks.list
+    gcp_function_args:
+      project: <PROJECT_ID>
+#      filter:
+    gcp_log_resource_type: None #non-applicable
+#    field_include_filter:
+#      - name
+    field_exclude_filter:
+      - selfLinkWithId
+      - networkFirewallPolicyEnforcementOrder
+      - peerings:
+          - stateDetails
+    item_exclude_filter:
+      - peerings:
+          name: "servicenetworking-googleapis-com"
+#      - subnetworks: ".*europe-west[89].*"
diff --git a/cloudimized/singlerunconfigs/subnetworks.yaml b/cloudimized/singlerunconfigs/subnetworks.yaml
new file mode 100644
index 0000000..b7728c7
--- /dev/null
+++ b/cloudimized/singlerunconfigs/subnetworks.yaml
@@ -0,0 +1,16 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: subnetworks
+    gcp_api_call: subnetworks.aggregatedList
+    gcp_function_args:
+      project: <PROJECT_ID>
+#      filter:
+    gcp_log_resource_type: None #non-applicable
+#    field_include_filter:
+#      - name
+    field_exclude_filter:
+      - stackType
+#    item_exclude_filter:
+#      - region: ".*(europe-west[89]|asia-south1).*"
+
diff --git a/cloudimized/singlerunconfigs/vpnTunnels.yaml b/cloudimized/singlerunconfigs/vpnTunnels.yaml
new file mode 100644
index 0000000..8d73932
--- /dev/null
+++ b/cloudimized/singlerunconfigs/vpnTunnels.yaml
@@ -0,0 +1,19 @@
+serviceName: compute
+version: v1
+queries:
+  - resource: vpnTunnels
+    gcp_api_call: vpnTunnels.aggregatedList
+    gcp_function_args:
+      project: <PROJECT_ID>
+#      filter: status!=ESTABLISHED
+    gcp_log_resource_type: None #non-applicable
+#    field_include_filter:
+#      - name
+#      - region
+#      - status
+#      - detailedStatus
+    field_exclude_filter:
+      - creationTimestamp
+      - localTrafficSelector
+      - sharedSecret
+      - sharedSecretHash
diff --git a/tfcore/__init__.py b/cloudimized/tfcore/__init__.py
similarity index 100%
rename from tfcore/__init__.py
rename to cloudimized/tfcore/__init__.py
diff --git a/tfcore/query.py b/cloudimized/tfcore/query.py
similarity index 100%
rename from tfcore/query.py
rename to cloudimized/tfcore/query.py
diff --git a/tfcore/run.py b/cloudimized/tfcore/run.py
similarity index 100%
rename from tfcore/run.py
rename to cloudimized/tfcore/run.py
diff --git a/main.py b/main.py
deleted file mode 100644
index e636fba..0000000
--- a/main.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from core.core import GcpOxidizer
-
-
-def run():
-    GcpOxidizer.run()
-
-
-if __name__ == "__main__":
-    run()
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 1e5e878..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,51 +0,0 @@
-cachetools==4.2.4
-certifi==2021.10.8
-cffi==1.15.0
-charset-normalizer==2.0.9
-cryptography==36.0.1
-defusedxml==0.7.1
-gitdb==4.0.9
-GitPython==3.1.27
-google-api-core==2.3.2
-google-api-python-client==2.33.0
-google-auth==2.3.3
-google-auth-httplib2==0.1.0
-google-cloud-appengine-logging==1.1.0
-google-cloud-audit-log==0.2.0
-google-cloud-core==2.2.1
-google-cloud-logging==2.7.0
-googleapis-common-protos==1.54.0
-grpc-google-iam-v1==0.12.3
-grpcio==1.43.0
-grpcio-status==1.43.0
-httplib2==0.20.2
-idna==3.3
-importlib-metadata==4.11.2
-jeepney==0.7.1
-jira==3.1.1
-keyring==23.5.0
-mock==4.0.3
-oauthlib==3.2.0
-packaging==21.3
-proto-plus==1.19.8
-protobuf==3.19.1
-pyasn1==0.4.8
-pyasn1-modules==0.2.8
-pycparser==2.21
-pyparsing==3.0.6
-python-dateutil==2.8.2
-PyYAML==6.0
-requests==2.26.0
-requests-oauthlib==1.3.1
-requests-toolbelt==0.9.1
-rsa==4.8
-SecretStorage==3.3.1
-six==1.16.0
-slack-sdk==3.13.0
-smmap==5.0.0
-terrasnek==0.1.7
-time-machine==2.5.0
-typing-extensions==4.0.1
-uritemplate==4.1.1
-urllib3==1.26.7
-zipp==3.7.0
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..b88034e
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[metadata]
+description-file = README.md
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..5162cb5
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,97 @@
+import codecs
+
+from setuptools import setup, find_packages
+
+with codecs.open("README.md", encoding="utf-8") as f:
+    readme = f.read()
+
+setup(
+    name="cloudimized",
+    version="1.2.0",
+    description='Google Cloud Platform (GCP) configuration scanning tool',
+    long_description=readme,
+    long_description_content_type="text/markdown",
+    url="https://github.com/egnyte/cloudimized",
+    author="Egnyte and Contributors",
+    classifiers=[
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
+        "Development Status :: 5 - Production/Stable",
+        "Intended Audience :: Information Technology",
+        "Intended Audience :: System Administrators",
+        "License :: OSI Approved :: MIT License",
+        "Operating System :: POSIX :: Linux",
+        "Operating System :: MacOS",
+    ],
+    packages=find_packages(),
+    package_data={
+        "": ["LICENSE", "*.md", "config-example.yml"],
+        "cloudimized": ["singlerunconfigs/*.yaml"]
+    },
+    include_package_data=True,
+    install_requires=[
+        'cachetools==4.2.4',
+        'certifi==2021.10.8',
+        'cffi==1.15.0',
+        'charset-normalizer==2.0.9',
+        'cryptography==36.0.1',
+        'defusedxml==0.7.1',
+        'gitdb==4.0.9',
+        'GitPython==3.1.27',
+        'google-api-core==2.3.2',
+        'google-api-python-client==2.33.0',
+        'google-auth==2.3.3',
+        'google-auth-httplib2==0.1.0',
+        'google-cloud-appengine-logging==1.1.0',
+        'google-cloud-audit-log==0.2.0',
+        'google-cloud-core==2.2.1',
+        'google-cloud-logging==2.7.0',
+        'googleapis-common-protos==1.54.0',
+        'grpc-google-iam-v1==0.12.3',
+        'grpcio==1.43.0',
+        'grpcio-status==1.43.0',
+        'httplib2==0.20.2',
+        'idna==3.3',
+        'importlib-metadata==4.11.2',
+        'jeepney==0.7.1',
+        'jira==3.1.1',
+        'keyring==23.5.0',
+        'mock==4.0.3',
+        'oauthlib==3.2.0',
+        'packaging==21.3',
+        'proto-plus==1.19.8',
+        'protobuf==3.19.1',
+        'pyasn1==0.4.8',
+        'pyasn1-modules==0.2.8',
+        'pycparser==2.21',
+        'pyparsing==3.0.6',
+        'python-dateutil==2.8.2',
+        'PyYAML==6.0',
+        'requests==2.26.0',
+        'requests-oauthlib==1.3.1',
+        'requests-toolbelt==0.9.1',
+        'rsa==4.8',
+        'SecretStorage==3.3.1',
+        'six==1.16.0',
+        'slack-sdk==3.13.0',
+        'smmap==5.0.0',
+        'terrasnek==0.1.7',
+        'time-machine==2.5.0',
+        'typing-extensions==4.0.1',
+        'uritemplate==4.1.1',
+        'urllib3==1.26.7',
+        'zipp==3.7.0',
+    ],
+    extras_require={
+        "test": [
+            'mock',
+        ],
+    },
+    entry_points={
+        "console_scripts": [
+            "cloudimized=cloudimized.core.run:run",
+        ],
+    },
+)


Test patch:
diff --git a/.github/workflows/linter-and-tests.yml b/.github/workflows/linter-and-tests.yml
new file mode 100644
index 0000000..ce255f9
--- /dev/null
+++ b/.github/workflows/linter-and-tests.yml
@@ -0,0 +1,35 @@
+name: Tests
+
+# trigger only on pushes to main OR PRs to main
+# to prevent double tests on our own
+# PRs within egnyte/cloudimized repo from a feature
+# branch to main
+on:
+  push:
+    branches:
+      - main
+      - develop
+  pull_request:
+    branches:
+      - main
+      - develop
+jobs:
+  unit-tests:
+    runs-on: ubuntu-latest
+    strategy:
+      max-parallel: 5
+      matrix:
+        python-version: [3.7, 3.8, 3.9, "3.10"]
+      fail-fast : false
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies & test dependencies
+        run: |
+          pip install -e .[test]
+      - name: Run unit tests
+        run: |
+          python -m unittest discover tests
diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml
new file mode 100644
index 0000000..25b2a8a
--- /dev/null
+++ b/.github/workflows/publish-to-test-pypi.yml
@@ -0,0 +1,26 @@
+---
+name: Publish 🐍 to Test PyPI
+
+on:
+  push:
+    tags:
+      - 'v*rc*'
+
+jobs:
+  build-n-publish:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.9
+      - name: Build
+        run: |
+          pip install wheel
+          python setup.py build sdist bdist_wheel
+      - name: Publish distribution 📦 to Test PyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.1
+        with:
+          password: ${{ secrets.TEST_PYPI_API_TOKEN }}
+          repository_url: https://test.pypi.org/legacy/
diff --git a/tests/test_changeprocessor.py b/tests/test_changeprocessor.py
index 911c8b0..ed1e71c 100644
--- a/tests/test_changeprocessor.py
+++ b/tests/test_changeprocessor.py
@@ -5,13 +5,13 @@
 
 import time_machine
 
-from core.changeprocessor import ChangeProcessor, ChangeProcessorError, configure_change_processor
-from gitcore.repo import GitRepo
-from gitcore.gitchange import GitChange
-from gcpcore.gcpquery import GcpQuery
-from gcpcore.gcpchangelog import GcpChangeLog
-from tfcore.query import TFQuery, TFQueryError
-from tfcore.run import TFRun
+from cloudimized.core.changeprocessor import ChangeProcessor, ChangeProcessorError, configure_change_processor
+from cloudimized.gitcore.repo import GitRepo
+from cloudimized.gitcore.gitchange import GitChange
+from cloudimized.gcpcore.gcpquery import GcpQuery
+from cloudimized.gcpcore.gcpchangelog import GcpChangeLog
+from cloudimized.tfcore.query import TFQuery, TFQueryError
+from cloudimized.tfcore.run import TFRun
 
 
 class ChangeProcessorTestCase(unittest.TestCase):
@@ -52,6 +52,9 @@ def setUp(self) -> None:
             "test_resource" : mock.MagicMock(spec=GcpQuery)
         }
 
+    def tearDown(self) -> None:
+        logging.disable(logging.NOTSET)
+
     def test_process_add_error(self):
         self.processor.repo.repo.git.add.side_effect = Exception("test_error")
         with self.assertRaises(ChangeProcessorError) as cm:
@@ -60,7 +63,7 @@ def test_process_add_error(self):
         self.processor.repo.repo.remotes.origin.push.assert_not_called()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_non_change(self, mock_getchangelogs):
         self.processor.repo.repo.index.diff.return_value = False
         self.processor.repo.repo.iter_commits.return_value = []
@@ -69,7 +72,7 @@ def test_process_non_change(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_not_called()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_empty_repo_case(self, mock_getchangelogs):
         self.processor.repo.repo.index.diff.side_effect = Exception("test issue")
         self.processor.repo.repo.iter_commits.side_effect = Exception("test issue #2")
@@ -84,7 +87,7 @@ def test_process_empty_repo_case(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_empty_repo_case_with_commit_count_error(self, mock_getchangelogs):
         self.processor.repo.repo.index.diff.side_effect = Exception("test issue")
         self.processor.repo.repo.iter_commits.side_effect = Exception("test issue #2")
@@ -99,7 +102,7 @@ def test_process_empty_repo_case_with_commit_count_error(self, mock_getchangelog
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_getchangelogs_error(self, mock_getchangelogs):
         mock_getchangelogs.side_effect = Exception("test error")
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
@@ -113,7 +116,7 @@ def test_process_getchangelogs_error(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_no_gcp_logs_found(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         mock_getchangelogs.return_value = []
@@ -124,7 +127,7 @@ def test_process_no_gcp_logs_found(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_missing_changer_in_gcp_log(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -140,7 +143,7 @@ def test_process_missing_changer_in_gcp_log(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_parsing_login_error(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -156,7 +159,7 @@ def test_process_parsing_login_error(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_parsing_manual_change(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -172,7 +175,7 @@ def test_process_parsing_manual_change(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_parsing_gcp_logs_with_same_changer(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_changer"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -190,7 +193,7 @@ def test_process_parsing_gcp_logs_with_same_changer(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_get_runs_error(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -208,7 +211,7 @@ def test_process_get_runs_error(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_skip_ticket_processing(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         self.processor.ticket_regex = None
@@ -231,8 +234,8 @@ def test_process_skip_ticket_processing(self, mock_getchangelogs):
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.re")
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.re")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_with_ticket_processing_error(self, mock_getchangelogs, mock_re):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -255,7 +258,7 @@ def test_process_with_ticket_processing_error(self, mock_getchangelogs, mock_re)
         self.processor.repo.repo.remotes.origin.push.assert_called_once()
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24))
-    @mock.patch("core.changeprocessor.getChangeLogs")
+    @mock.patch("cloudimized.core.changeprocessor.getChangeLogs")
     def test_process_with_ticket_processing(self, mock_getchangelogs):
         self.processor.repo.repo.iter_commits.return_value = ["test_commit_hash"]
         gcp_change_log_mock1 = mock.MagicMock(spec=GcpChangeLog)
@@ -382,7 +385,7 @@ def test_configure_repo_type_error(self):
         self.assertEqual("Incorrect type of repo parameter. Should be GitRepo, is <class 'str'>",
                          str(cm.exception))
 
-    @mock.patch("core.changeprocessor.configure_tfquery")
+    @mock.patch("cloudimized.core.changeprocessor.configure_tfquery")
     def test_configure_tfquery_type_error(self, mock_tfquery):
         mock_tfquery.return_value = "incorrect_type"
         with self.assertRaises(ChangeProcessorError) as cm:
diff --git a/tests/test_gcpchangelog.py b/tests/test_gcpchangelog.py
index ecdeac6..f4e9313 100644
--- a/tests/test_gcpchangelog.py
+++ b/tests/test_gcpchangelog.py
@@ -1,20 +1,23 @@
+import logging
 import unittest
 import mock
 import datetime as dt
 
 import time_machine
 
-from gcpcore.gcpchangelog import GcpChangeLog, getChangeLogs
+from cloudimized.gcpcore.gcpchangelog import GcpChangeLog, getChangeLogs
 
 
 class GcpChangeLogTestCase(unittest.TestCase):
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24)) #This is needed to mock datetime
     def setUp(self):
-        pass
+        logging.disable(logging.WARNING)
 
+    def tearDown(self) -> None:
+        logging.disable(logging.NOTSET)
 
     # @time_machine.travel(dt.datetime(1985, 10, 26, 1, 24)) #This is needed to mock datetime
-    @mock.patch("gcpcore.gcpchangelog.gcp_logging")
+    @mock.patch("cloudimized.gcpcore.gcpchangelog.gcp_logging")
     def testAllFields(self, mock_gcp_logging):
         # Mock GcpQuery object
         gcp_query = mock.MagicMock()
@@ -43,7 +46,7 @@ def testAllFields(self, mock_gcp_logging):
 
 
     # @time_machine.travel(dt.datetime(1985,10,26,1,24))
-    @mock.patch("gcpcore.gcpchangelog.gcp_logging")
+    @mock.patch("cloudimized.gcpcore.gcpchangelog.gcp_logging")
     def testEmptyFields(self, mock_gcp_logging):
         # Mock GcpQuery object
         gcp_query = mock.MagicMock()
diff --git a/tests/test_gcpquery.py b/tests/test_gcpquery.py
index f787166..18c3758 100644
--- a/tests/test_gcpquery.py
+++ b/tests/test_gcpquery.py
@@ -2,7 +2,7 @@
 import unittest
 import mock
 from copy import deepcopy
-from gcpcore.gcpquery import GcpQuery, GcpQueryError, GcpQueryArgumentError, configure_queries, logger
+from cloudimized.gcpcore.gcpquery import GcpQuery, GcpQueryError, GcpQueryArgumentError, configure_queries, logger
 
 
 class GcpQueryTestCase(unittest.TestCase):
@@ -18,6 +18,9 @@ def setUp(self) -> None:
         self.query_response_multiple_items = deepcopy(query_response_multiple_items_org)
         #TODO deepcopy of all test data dict
 
+    def tearDown(self) -> None:
+        logging.disable(logging.NOTSET)
+
     def testArguments(self):
         # Test argument conflict
         with self.assertRaises(GcpQueryArgumentError) as cm:
@@ -183,7 +186,7 @@ def testExecute_no_items_in_response(self):
         with self.assertLogs(logger, level="WARNING") as cm:
             result = query.execute(project_id="test_project")
             logger.warning("dummy warning")
-        self.assertEqual(["WARNING:gcpcore.gcpquery:dummy warning"], cm.output)
+        self.assertEqual(["WARNING:cloudimized.gcpcore.gcpquery:dummy warning"], cm.output)
         # self.assertEqual(cm.output, [(f"WARNING:gcpnetscanner.gcpcore.gcpquery:"
         #                               f"Skipping result sorting for API call 'firewalls.list' for project "
         #                              f"'test_project'. Missing default sort key in result 'name'")])
@@ -278,7 +281,7 @@ def testFieldAndItemFilter(self):
         result = query.execute(project_id=None)
         self.assertEqual(expected_testFieldAndItemFilter, result)
 
-    @mock.patch("gcpcore.gcpquery.GcpQuery", spec=GcpQuery)
+    @mock.patch("cloudimized.gcpcore.gcpquery.GcpQuery", spec=GcpQuery)
     def testConfigureQueries(self, mock_gcpquery):
         with self.assertRaises(GcpQueryArgumentError):
             configure_queries("incorrect type")
diff --git a/tests/test_gcpservicequery.py b/tests/test_gcpservicequery.py
index ba30d95..9e60adf 100644
--- a/tests/test_gcpservicequery.py
+++ b/tests/test_gcpservicequery.py
@@ -1,8 +1,8 @@
 import unittest
 import mock
 
-from gcpcore.gcpservicequery import GcpServiceQuery, configure_services
-from gcpcore.gcpservicequery import GcpServiceQueryError, GcpServiceQueryConfigError
+from cloudimized.gcpcore.gcpservicequery import GcpServiceQuery, configure_services
+from cloudimized.gcpcore.gcpservicequery import GcpServiceQueryError, GcpServiceQueryConfigError
 
 
 class GcpServiceQueryTestCase(unittest.TestCase):
@@ -19,9 +19,9 @@ def testBuild_no_queries(self):
             self.gcpservice.build()
         self.assertEqual(f"Queries not configured for service compute", str(cm.exception))
 
-    @mock.patch("gcpcore.gcpservicequery.google.auth")
-    @mock.patch("gcpcore.gcpservicequery.discovery")
-    @mock.patch("gcpcore.gcpservicequery.getenv")
+    @mock.patch("cloudimized.gcpcore.gcpservicequery.google.auth")
+    @mock.patch("cloudimized.gcpcore.gcpservicequery.discovery")
+    @mock.patch("cloudimized.gcpcore.gcpservicequery.getenv")
     def testBuild(self, mock_getenv, mock_discovery, mock_auth):
         self.gcpservice.queries
         mock_getenv.return_value = "/creds/secret.file"
diff --git a/tests/test_gitchange.py b/tests/test_gitchange.py
index b7a5e8b..22b7678 100644
--- a/tests/test_gitchange.py
+++ b/tests/test_gitchange.py
@@ -2,7 +2,7 @@
 
 import mock
 
-from gitcore.gitchange import GitChange
+from cloudimized.gitcore.gitchange import GitChange
 
 class MyTestCase(unittest.TestCase):
     def testGetFilename(self):
diff --git a/tests/test_gitrepo.py b/tests/test_gitrepo.py
index ff14672..63e7b7b 100644
--- a/tests/test_gitrepo.py
+++ b/tests/test_gitrepo.py
@@ -1,16 +1,16 @@
 import unittest
 import mock
 
-import gitcore.repo as repo
-from gitcore.repo import GitRepo, GitRepoError, configure_repo
-from gitcore.gitchange import GitChange
+import cloudimized.gitcore.repo as repo
+from cloudimized.gitcore.repo import GitRepo, GitRepoError, configure_repo
+from cloudimized.gitcore.gitchange import GitChange
 
 class GitRepoCase(unittest.TestCase):
     def setUp(self) -> None:
         self.gitrepo = GitRepo("user", "password", "https://example.com/test/repo.git", "localRepo")
 
-    @mock.patch("gitcore.repo.git")
-    @mock.patch("gitcore.repo.exists")
+    @mock.patch("cloudimized.gitcore.repo.git")
+    @mock.patch("cloudimized.gitcore.repo.exists")
     def test_setup_local_exists(self, mock_exists, mock_git):
         mock_exists.return_value = True
         # Fetching remote
@@ -22,16 +22,16 @@ def test_setup_local_exists(self, mock_exists, mock_git):
             mock_git.Repo().remotes.origin.fetch.side_effect = Exception()
             self.gitrepo.setup()
 
-    @mock.patch("gitcore.repo.git")
-    @mock.patch("gitcore.repo.exists")
+    @mock.patch("cloudimized.gitcore.repo.git")
+    @mock.patch("cloudimized.gitcore.repo.exists")
     def test_setup_ssh_cloning(self, mock_exists, mock_git):
         mock_exists.return_value = False
         self.gitrepo.repo_url = "git@git.example.com:test/repo.git"
         self.gitrepo.setup()
         mock_git.Repo.clone_from.assert_called_with("git@git.example.com:test/repo.git", "localRepo")
 
-    @mock.patch("gitcore.repo.git")
-    @mock.patch("gitcore.repo.exists")
+    @mock.patch("cloudimized.gitcore.repo.git")
+    @mock.patch("cloudimized.gitcore.repo.exists")
     def test_setup_https_cloning(self, mock_exists, mock_git):
         mock_exists.return_value = False
         # Test correct url with creds
@@ -112,7 +112,7 @@ def test_push_changes_success(self):
         self.gitrepo.push_changes()
         repo_mock.remotes.origin.push.called_once()
 
-    @mock.patch("gitcore.repo.GitRepo", spec=GitRepo)
+    @mock.patch("cloudimized.gitcore.repo.GitRepo", spec=GitRepo)
     def test_configure_repo(self, mock_gitrepo):
         # Missing username
         with self.assertRaises(repo.GitRepoConfigError):
@@ -134,16 +134,16 @@ def test_configure_repo(self, mock_gitrepo):
         self.assertIsInstance(result, GitRepo)
         mock_gitrepo.assert_called_with("test_user", "secret", "test_url", "/local/dir")
 
-    @mock.patch("gitcore.repo.rmtree")
-    @mock.patch("gitcore.repo.listdir")
+    @mock.patch("cloudimized.gitcore.repo.rmtree")
+    @mock.patch("cloudimized.gitcore.repo.listdir")
     def test_clean_repo_not_setup(self, mock_listdir, mock_rmtree):
         with self.assertRaises(GitRepoError) as cm:
             self.gitrepo.clean_repo()
         self.assertEqual("Repo 'https://example.com/test/repo.git' needs to be setup first",
                          str(cm.exception))
 
-    @mock.patch("gitcore.repo.rmtree")
-    @mock.patch("gitcore.repo.listdir")
+    @mock.patch("cloudimized.gitcore.repo.rmtree")
+    @mock.patch("cloudimized.gitcore.repo.listdir")
     def test_clean_repo_listing_issue(self, mock_listdir, mock_rmtree):
         self.gitrepo.repo = mock.MagicMock()
         mock_listdir.side_effect = Exception("issue")
@@ -152,9 +152,9 @@ def test_clean_repo_listing_issue(self, mock_listdir, mock_rmtree):
         self.assertEqual("Issue retrieving directories in directory 'localRepo'",
                          str(cm.exception))
 
-    @mock.patch("gitcore.repo.isdir")
-    @mock.patch("gitcore.repo.rmtree")
-    @mock.patch("gitcore.repo.listdir")
+    @mock.patch("cloudimized.gitcore.repo.isdir")
+    @mock.patch("cloudimized.gitcore.repo.rmtree")
+    @mock.patch("cloudimized.gitcore.repo.listdir")
     def test_clean_repo_remove_issue(self, mock_listdir, mock_rmtree, mock_isdir):
         self.gitrepo.repo = mock.MagicMock()
         mock_listdir.return_value = ["test_directory1", "test_directory2"]
@@ -165,9 +165,9 @@ def test_clean_repo_remove_issue(self, mock_listdir, mock_rmtree, mock_isdir):
         self.assertEqual("Issue removing directories in 'localRepo'",
                          str(cm.exception))
 
-    @mock.patch("gitcore.repo.isdir")
-    @mock.patch("gitcore.repo.rmtree")
-    @mock.patch("gitcore.repo.listdir")
+    @mock.patch("cloudimized.gitcore.repo.isdir")
+    @mock.patch("cloudimized.gitcore.repo.rmtree")
+    @mock.patch("cloudimized.gitcore.repo.listdir")
     def test_clean_repo_only_git_folder(self, mock_listdir, mock_rmtree, mock_isdir):
         self.gitrepo.repo = mock.MagicMock()
         mock_listdir.return_value = [".git"]
@@ -175,9 +175,9 @@ def test_clean_repo_only_git_folder(self, mock_listdir, mock_rmtree, mock_isdir)
         self.gitrepo.clean_repo()
         mock_rmtree.assert_not_called()
 
-    @mock.patch("gitcore.repo.isdir")
-    @mock.patch("gitcore.repo.rmtree")
-    @mock.patch("gitcore.repo.listdir")
+    @mock.patch("cloudimized.gitcore.repo.isdir")
+    @mock.patch("cloudimized.gitcore.repo.rmtree")
+    @mock.patch("cloudimized.gitcore.repo.listdir")
     def test_clean_repo_success(self, mock_listdir, mock_rmtree, mock_isdir):
         self.gitrepo.repo = mock.MagicMock()
         mock_listdir.return_value = [".git", "test_directory1", "test_directory2", "README.md"]
diff --git a/tests/test_jiranotifier.py b/tests/test_jiranotifier.py
index 6e7b9e0..4159cf9 100644
--- a/tests/test_jiranotifier.py
+++ b/tests/test_jiranotifier.py
@@ -1,8 +1,11 @@
 import unittest
+
 import mock
+from jira import JIRA
+
+from cloudimized.core.jiranotifier import configure_jiranotifier, JiraNotifier, JiraNotifierError, logger
+from cloudimized.gitcore.gitchange import GitChange
 
-from core.jiranotifier import configure_jiranotifier, JiraNotifier, JiraNotifierError, JIRA, logger
-from gitcore.gitchange import GitChange
 
 class JiraNotifierTestCase(unittest.TestCase):
     def setUp(self) -> None:
@@ -73,9 +76,9 @@ def test_configure_incorrect_projectidfilter_value(self):
                                    username="test_user",
                                    password="test_password")
         self.assertEqual((f"Incorrect Jira Notifier projectId configuration value. "
-                         f"Should be str, is <class 'list'>"), str(cm.exception))
+                          f"Should be str, is <class 'list'>"), str(cm.exception))
 
-    @mock.patch("core.jiranotifier.JiraNotifier", spec=JiraNotifier)
+    @mock.patch("cloudimized.core.jiranotifier.JiraNotifier", spec=JiraNotifier)
     def test_configure_correct_result(self, mock_jiranotifier):
         result = configure_jiranotifier(config={"url": "test_url",
                                                 "projectKey": "TEST",
@@ -93,14 +96,14 @@ def test_configure_correct_result(self, mock_jiranotifier):
                                              filter_set=None,
                                              extra="testField")
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_non_manual_change(self, mock_jira):
         self.gitchange.manual = False
         result = self.jiranotifier.post(self.gitchange)
         self.assertIsNone(result)
         mock_jira.assert_not_called()
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_non_matching_filter(self, mock_jira):
         self.gitchange.manual = True
         filter_set = {"projectId": "NO_MATCH"}
@@ -109,7 +112,7 @@ def test_post_non_matching_filter(self, mock_jira):
         self.assertIsNone(result)
         mock_jira.assert_not_called()
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_authentication_issue(self, mock_jira):
         self.gitchange.manual = True
         mock_jira.side_effect = Exception("Auth Issue")
@@ -117,7 +120,7 @@ def test_post_authentication_issue(self, mock_jira):
             self.jiranotifier.post(self.gitchange)
         self.assertEqual(f"Issue creating ticket\nAuth Issue", f"{str(cm.exception)}\n{str(cm.exception.__cause__)}")
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_creating_issue_issue(self, mock_jira):
         self.gitchange.manual = True
         mock_jira_object = mock.MagicMock()
@@ -135,7 +138,7 @@ def test_post_creating_issue_issue(self, mock_jira):
                                                          issuetype={"name": "test_type"},
                                                          test_field=[{"name": "test_value"}])
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_update_assignee_issue(self, mock_jira):
         self.gitchange.manual = True
         mock_issue_object = mock.MagicMock()
@@ -146,8 +149,8 @@ def test_post_update_assignee_issue(self, mock_jira):
         mock_jira.return_value = mock_jira_object
         with self.assertLogs(logger, level="WARNING") as cm:
             self.jiranotifier.post(self.gitchange)
-        self.assertEqual(f"WARNING:core.jiranotifier:Unable to assign ticket TEST_KEY to changer: test_changer\n"
-                         f"Update Issue",
+        self.assertEqual(f"WARNING:cloudimized.core.jiranotifier:Unable to assign ticket TEST_KEY to "
+                         f"changer: test_changer\nUpdate Issue",
                          cm.output[0])
         mock_jira_object.create_issue.assert_called_with(project={"key": "test_key"},
                                                          summary=(f"GCP manual change detected - project: "
@@ -158,7 +161,7 @@ def test_post_update_assignee_issue(self, mock_jira):
                                                          test_field=[{"name": "test_value"}])
         mock_issue_object.update.assert_called_with(assignee={"name": "test_changer"})
 
-    @mock.patch("core.jiranotifier.JIRA", spec=JIRA)
+    @mock.patch("cloudimized.core.jiranotifier.JIRA", spec=JIRA)
     def test_post_update_success(self, mock_jira):
         self.gitchange.manual = True
         filter_set = {"projectId": ".est_pro.*"}
@@ -171,7 +174,7 @@ def test_post_update_success(self, mock_jira):
         mock_jira.return_value = mock_jira_object
         with self.assertLogs(logger, level="INFO") as cm:
             self.jiranotifier.post(self.gitchange)
-        self.assertEqual(f"INFO:core.jiranotifier:Assigning issue TEST_KEY to user test_changer",
+        self.assertEqual(f"INFO:cloudimized.core.jiranotifier:Assigning issue TEST_KEY to user test_changer",
                          cm.output[-1])
         mock_jira_object.create_issue.assert_called_with(project={"key": "test_key"},
                                                          summary=(f"GCP manual change detected - project: "
diff --git a/tests/test_result.py b/tests/test_result.py
index facdd55..b83f3e0 100644
--- a/tests/test_result.py
+++ b/tests/test_result.py
@@ -1,7 +1,7 @@
 import unittest
 import mock
-from core.result import QueryResult, QueryResultError, set_query_results_from_configuration
-from gcpcore.gcpservicequery import GcpServiceQuery
+from cloudimized.core.result import QueryResult, QueryResultError, set_query_results_from_configuration
+from cloudimized.gcpcore.gcpservicequery import GcpServiceQuery
 
 
 class QueryResultTestCase(unittest.TestCase):
@@ -64,7 +64,7 @@ def test_set_query_results_from_configuration_success(self):
         projects = resources["test_resource"]
         self.assertEqual(len(projects), 0)
 
-    @mock.patch("core.result.isdir")
+    @mock.patch("cloudimized.core.result.isdir")
     def test_dump_results_not_directory(self, mock_isdir):
         mock_isdir.return_value = False
         with self.assertRaises(QueryResultError) as cm:
@@ -72,8 +72,8 @@ def test_dump_results_not_directory(self, mock_isdir):
         self.assertEqual("Issue dumping results to files. Directory 'test_directory' doesn't exist",
                          str(cm.exception))
 
-    @mock.patch("core.result.Path")
-    @mock.patch("core.result.isdir")
+    @mock.patch("cloudimized.core.result.Path")
+    @mock.patch("cloudimized.core.result.isdir")
     def test_dump_results_issue_creating_subdirectory(self, mock_isdir, mock_path):
         mock_isdir.return_value = True
         mock_mkdir = mock.MagicMock()
@@ -86,8 +86,8 @@ def test_dump_results_issue_creating_subdirectory(self, mock_isdir, mock_path):
                          str(cm.exception))
 
     @mock.patch("builtins.open")
-    @mock.patch("core.result.Path")
-    @mock.patch("core.result.isdir")
+    @mock.patch("cloudimized.core.result.Path")
+    @mock.patch("cloudimized.core.result.isdir")
     def test_dump_results_issue_creating_files(self, mock_isdir, mock_path, mock_open):
         mock_isdir.return_value = True
         mock_open.side_effect = Exception("issue opening file")
@@ -97,10 +97,10 @@ def test_dump_results_issue_creating_files(self, mock_isdir, mock_path, mock_ope
         self.assertEqual("Issue dumping results into file 'test_directory/test_resource/test_project.yaml",
                          str(cm.exception))
 
-    @mock.patch("core.result.yaml.dump")
+    @mock.patch("cloudimized.core.result.yaml.dump")
     @mock.patch("builtins.open")
-    @mock.patch("core.result.Path")
-    @mock.patch("core.result.isdir")
+    @mock.patch("cloudimized.core.result.Path")
+    @mock.patch("cloudimized.core.result.isdir")
     def test_dump_results_issue_creating_files(self, mock_isdir, mock_path, mock_b_open, mock_dump):
         mock_isdir.return_value = True
         mock_temp = mock.MagicMock()
@@ -112,10 +112,10 @@ def test_dump_results_issue_creating_files(self, mock_isdir, mock_path, mock_b_o
         mock_b_open.assert_called_with("test_directory/test_resource/test_project.yaml", "w")
         mock_dump.assert_called_with(test_result, mock_temp, default_flow_style=False)
 
-    @mock.patch("core.result.yaml.dump")
+    @mock.patch("cloudimized.core.result.yaml.dump")
     @mock.patch("builtins.open")
-    @mock.patch("core.result.Path")
-    @mock.patch("core.result.isdir")
+    @mock.patch("cloudimized.core.result.Path")
+    @mock.patch("cloudimized.core.result.isdir")
     def test_dump_results_empty_list_result(self, mock_isdir, mock_path, mock_b_open, mock_dump):
         mock_isdir.return_value = True
         mock_temp = mock.MagicMock()
@@ -127,6 +127,38 @@ def test_dump_results_empty_list_result(self, mock_isdir, mock_path, mock_b_open
         mock_b_open.assert_not_called()
         mock_dump.assert_not_called()
 
+    @mock.patch("cloudimized.core.result.isdir")
+    def test_dump_results_csv_not_directory(self, mock_isdir):
+        mock_isdir.return_value = False
+        with self.assertRaises(QueryResultError) as cm:
+            self.queryresult.dump_results_csv("test_directory")
+        self.assertEqual("Issue dumping results to files. Directory 'test_directory' doesn't exist",
+                         str(cm.exception))
+
+#TODO Test exceptions in dump_results_csv
+
+    @mock.patch("cloudimized.core.result.csv.DictWriter")
+    @mock.patch("builtins.open")
+    @mock.patch("cloudimized.core.result.isdir")
+    def test_dump_results_success(self, mock_isdir, mock_b_open, mock_dictwriter):
+        mock_isdir.return_value = True
+        mock_fh = mock.MagicMock()
+        mock_fh.__enter__.return_value = mock.MagicMock()
+        mock_b_open.return_value = mock_fh
+        mock_writer = mock.MagicMock()
+        mock_dictwriter.return_value = mock_writer
+        self.queryresult.resources = test_dump_result
+        self.queryresult.dump_results_csv("test_directory")
+        mock_isdir.assert_called_once()
+        mock_b_open.assert_called_once_with("test_directory/test_resource.csv",
+                                            "w",
+                                            newline="")
+        mock_dictwriter.assert_called_once_with(mock_fh.__enter__.return_value,
+                                                ["projectId", "id", "name", "test_field1", "test_field2",
+                                                 "test_field3", "test_field4"])
+        mock_writer.writeheader.assert_called_once()
+        #TODO finish test
+
 
 if __name__ == '__main__':
     unittest.main()
@@ -139,3 +171,16 @@ def test_dump_results_empty_list_result(self, mock_isdir, mock_path, mock_b_open
 test_gcp_services = {
     "test_serviceName": mock.MagicMock(spec=GcpServiceQuery)
 }
+
+test_dump_result = {
+    "test_resource": {
+        "test_project_1": [
+            {"name": "test_name1", "id": "test_id1", "test_field1": "test_value1"},
+            {"name": "test_name2", "id": "test_id2", "test_field2": "test_value2"}
+        ],
+        "test_project_2": [
+            {"name": "test_name1", "id": "test_id1", "test_field3": "test_value3"},
+            {"name": "test_name2", "id": "test_id2", "test_field4": "test_value2"}
+        ]
+    }
+}
diff --git a/tests/test_tfquery.py b/tests/test_tfquery.py
index e3376b1..75c14d7 100644
--- a/tests/test_tfquery.py
+++ b/tests/test_tfquery.py
@@ -4,9 +4,9 @@
 
 import time_machine
 
-from tfcore.query import TFQuery, TFQueryError, TFQueryConfigurationError, configure_tfquery
-import tfcore.query as q
-from tfcore.run import TFRun
+from cloudimized.tfcore.query import TFQuery, TFQueryError, TFQueryConfigurationError, configure_tfquery
+import cloudimized.tfcore.query as q
+from cloudimized.tfcore.run import TFRun
 
 class TFQueryTestCase(unittest.TestCase):
     def setUp(self) -> None:
@@ -19,7 +19,7 @@ def test_unknown_service_account(self):
 
     #Query resolving workspace name to id fails
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 40))
-    @mock.patch("tfcore.query.TFC")
+    @mock.patch("cloudimized.tfcore.query.TFC")
     def test_workspace_id_fail(self, mock_tfc):
         mock_tf_api = mock.MagicMock()
         mock_tf_api.workspaces.show.side_effect = Exception()
@@ -29,7 +29,7 @@ def test_workspace_id_fail(self, mock_tfc):
         mock_tf_api.workspaces.show.assert_called_with(workspace_name="test_workspace1")
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 40))
-    @mock.patch("tfcore.query.TFC")
+    @mock.patch("cloudimized.tfcore.query.TFC")
     def test_runs_list_fail(self, mock_tfc):
         mock_workspace_response = mock.MagicMock()
         mock_workspace_response.__getitem__.side_effect = test_workspace_response.__getitem__
@@ -43,8 +43,8 @@ def test_runs_list_fail(self, mock_tfc):
         mock_tf_api.runs.list.assert_called_with("id_test_workspace1", page_size=10, include=["created-by"])
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 40))
-    @mock.patch("tfcore.query.parse_tf_runs")
-    @mock.patch("tfcore.query.TFC")
+    @mock.patch("cloudimized.tfcore.query.parse_tf_runs")
+    @mock.patch("cloudimized.tfcore.query.TFC")
     def test_runs_parse_fails(self, mock_tfc, mock_parse):
         mock_workspace_response = mock.MagicMock()
         mock_workspace_response.__getitem__.side_effect = test_workspace_response.__getitem__
@@ -61,8 +61,8 @@ def test_runs_parse_fails(self, mock_tfc, mock_parse):
         mock_parse.assert_called_with(test_runs_response, "test_org1", "test_workspace1")
 
     @time_machine.travel(dt.datetime(1985, 10, 26, 1, 40))
-    @mock.patch("tfcore.query.parse_tf_runs")
-    @mock.patch("tfcore.query.TFC")
+    @mock.patch("cloudimized.tfcore.query.parse_tf_runs")
+    @mock.patch("cloudimized.tfcore.query.TFC")
     def test_runs_success(self, mock_tfc, mock_parse):
         mock_workspace_response = mock.MagicMock()
         mock_workspace_response.__getitem__.side_effect = test_workspace_response.__getitem__
@@ -100,9 +100,9 @@ def test_runs_success(self, mock_tfc, mock_parse):
         self.assertEqual(result[3].message, "test-message3")
 
 
-    @mock.patch("tfcore.query.getenv")
-    @mock.patch("tfcore.query.TFQuery", spec=TFQuery)
-    @mock.patch("tfcore.query.json.load")
+    @mock.patch("cloudimized.tfcore.query.getenv")
+    @mock.patch("cloudimized.tfcore.query.TFQuery", spec=TFQuery)
+    @mock.patch("cloudimized.tfcore.query.json.load")
     @mock.patch("builtins.open")
     def test_configure_tfquery(self, mock_open, mock_json_load, mock_tfquery, mock_getenv):
         mock_json_load.return_value = token_file_correct_data
diff --git a/tests/test_tfrun.py b/tests/test_tfrun.py
index 5c4c92d..e9784cb 100644
--- a/tests/test_tfrun.py
+++ b/tests/test_tfrun.py
@@ -3,12 +3,15 @@
 import logging
 
 from datetime import datetime
-from tfcore.run import TFRun, TFRunError, parse_tf_runs, filter_non_change_runs
+from cloudimized.tfcore.run import TFRun, TFRunError, parse_tf_runs, filter_non_change_runs
 
 class TFRunTestCase(unittest.TestCase):
     def setUp(self) -> None:
         logging.disable(logging.WARNING)
 
+    def tearDown(self) -> None:
+        logging.disable(logging.NOTSET)
+
     def test_parse_no_data(self):
         with self.assertRaises(TFRunError):
             parse_tf_runs({}, "test_org", "test_workspace")
@@ -22,7 +25,7 @@ def test_parse_non_relevat_status(self):
         result = parse_tf_runs(tf_run_status_planning, "test_org", "test_workspace")
         self.assertEqual(result, [])
 
-    @mock.patch("tfcore.run.TFRun")
+    @mock.patch("cloudimized.tfcore.run.TFRun")
     def test_parse_relevant_runs(self, mock_tfrun):
         result = parse_tf_runs(tf_run_status_relevant, "test_org", "test_workspace")
         # Assert that those two TFRun objects were created and returned in list