Repository: aws-quickstart/taskcat (python)

Issue:
taskcat test run --keep-failed -> ValueError status is not a valid property of <class 'taskcat._cfn.stack.Stack'>
**Describe the bug**
Ran `taskcat test run --keep-failed` across (21) AWS Commercial Regions.  It did not delete ANY of the CloudFormation Stacks (both successful & failed).

- After `reporting on` messages come out, see below messages:
  - `[INFO   ] : deleting successful stacks`
  - `[ERROR  ] : ValueError status is not a valid property of <class 'taskcat._cfn.stack.Stack'>`

**To Reproduce**
Steps to reproduce the behavior:
1. Are you testing a *QuickStart* or *Custom template*? `No`
2. Attach or link a copy of the template if possible (**remove any sensitive info**) 
3. Provide the parameters that you passed. (**remove any sensitive info**)
4. How did you install taskcat? (docker or pip3) `pipx`
5. Are you using a *profile*, *an instance role* or  *access keys* to run taskcat? `profile`
6. Is your AWS environment configured via `aws configure`? `yes`

**Expected behavior**
- I was expecting that it would delete the stacks in all AWS regions where stack deployed successfully, and that it would not delete the stacks in the AWS regions where stack deployment failed.
- FYI, it works properly when I do `taskcat test run`, and then stacks are deleted in all AWS regions (both successful & failed stacks)

**Version (Please make sure you are running the latest version of taskcat)
 - Taskcat Version: `0.9.23`
 - Python3 Version: `3.8.7`


Pull request:
Fix: --keep-failed throws exception
## Overview

Fixes #660 

The [criteria_matches](https://github.com/aws-quickstart/taskcat/blob/2072dd5182afe7a260cb7d6e2af132ee09490fb5/taskcat/_cfn/stack.py#L28) function used `foo.__dict__` to look for instance attributes when filtering out items. This worked until the `Stack` class changed its `status` attribute to be a property instead. Turns out that properties behave like class attributes and not instance attributes???

## Testing/Steps taken to ensure quality

I wrote a test case to replicate the bug and then fixed it.

How did you validate the changes in this PR?

Other than a unit test, I didn't but I was hoping @tekdj7 would test against this branch and confirm it works. If not I might try it out locally.

### Notes

Do we have a functional test where we create two or three stacks and make one fail. Where we actually use --keep-failed and then we clean the stack that was kept our selves? I could possibly add that... maybe idk.

## Testing Instructions

 `python -m unittest tests/test_cfn_stack.py` ;)


Patch:
diff --git a/taskcat/_cfn/stack.py b/taskcat/_cfn/stack.py
index 6b50b13..10de936 100644
--- a/taskcat/_cfn/stack.py
+++ b/taskcat/_cfn/stack.py
@@ -28,7 +28,7 @@ GENERIC_ERROR_PATTERNS = [
 def criteria_matches(criteria: dict, instance):
     # fail if criteria includes an invalid property
     for k in criteria:
-        if k not in instance.__dict__:
+        if not hasattr(instance, k):
             raise ValueError(f"{k} is not a valid property of {type(instance)}")
     for k, v in criteria.items():
         # matching is AND for multiple criteria, so as soon as one fails,


Test patch:
diff --git a/tests/test_cfn_stack.py b/tests/test_cfn_stack.py
index 3d918f0..ccd6f7f 100644
--- a/tests/test_cfn_stack.py
+++ b/tests/test_cfn_stack.py
@@ -36,7 +36,7 @@ resource_template = {
 
 
 class TestCriteriaMatcher(unittest.TestCase):
-    def test_criteria_matches(self):
+    def test_dictionary(self):
         with self.assertRaises(ValueError) as cm:
             tag = Tag({"Key": "my_key", "Value": "my_value"})
             criteria_matches({"invalid": "blah"}, tag)
@@ -54,6 +54,24 @@ class TestCriteriaMatcher(unittest.TestCase):
         actual = criteria_matches({"key": "my_key", "value": "my_value"}, tag)
         self.assertEqual(actual, True)
 
+    def test_class(self):
+        class foo:
+            def __init__(self) -> None:
+                self.instance_attr = "TestInstance"
+                self._property_attr = "TestProperty"
+
+            @property
+            def property_attr(self):
+                return self._property_attr
+
+        item = foo()
+
+        actual = criteria_matches({"instance_attr": "TestInstance"}, item)
+        self.assertEqual(actual, True)
+
+        actual = criteria_matches({"property_attr": "TestProperty"}, item)
+        self.assertEqual(actual, True)
+
 
 class TestEvent(unittest.TestCase):
     def test_event(self):