Repository: dbt-labs/dbt-core

Issue:
[CT-1999] Unify ExposureOwner dataclass with group Owner
* Rename `ExposureOwner` to `Owner`
* Relax the `email` attribute to be optional
* Require at least one of `email` or `name` to be present in `Owner`
* Accept arbitrary additional attributes, such as `slack` or `github`


Discussion:

Resolving patch:
diff --git a/core/dbt/contracts/graph/nodes.py b/core/dbt/contracts/graph/nodes.py
--- a/core/dbt/contracts/graph/nodes.py
+++ b/core/dbt/contracts/graph/nodes.py
@@ -28,7 +28,7 @@
     UnparsedSourceTableDefinition,
     UnparsedColumn,
     TestDef,
-    ExposureOwner,
+    Owner,
     ExposureType,
     MaturityType,
     MetricFilter,
@@ -892,7 +892,7 @@ def search_name(self):
 @dataclass
 class Exposure(GraphNode):
     type: ExposureType
-    owner: ExposureOwner
+    owner: Owner
     resource_type: NodeType = field(metadata={"restrict": [NodeType.Exposure]})
     description: str = ""
     label: Optional[str] = None
diff --git a/core/dbt/contracts/graph/unparsed.py b/core/dbt/contracts/graph/unparsed.py
--- a/core/dbt/contracts/graph/unparsed.py
+++ b/core/dbt/contracts/graph/unparsed.py
@@ -424,8 +424,8 @@ class MaturityType(StrEnum):
 
 
 @dataclass
-class ExposureOwner(dbtClassMixin, Replaceable):
-    email: str
+class Owner(AdditionalPropertiesAllowed, Replaceable):
+    email: Optional[str] = None
     name: Optional[str] = None
 
 
@@ -433,7 +433,7 @@ class ExposureOwner(dbtClassMixin, Replaceable):
 class UnparsedExposure(dbtClassMixin, Replaceable):
     name: str
     type: ExposureType
-    owner: ExposureOwner
+    owner: Owner
     description: str = ""
     label: Optional[str] = None
     maturity: Optional[MaturityType] = None
@@ -451,6 +451,9 @@ def validate(cls, data):
             if not (re.match(r"[\w-]+$", data["name"])):
                 deprecations.warn("exposure-name", exposure=data["name"])
 
+        if data["owner"].get("name") is None and data["owner"].get("email") is None:
+            raise ValidationError("Exposure owner must have at least one of 'name' or 'email'.")
+
 
 @dataclass
 class MetricFilter(dbtClassMixin, Replaceable):


Test patch:
diff --git a/test/unit/test_contracts_graph_parsed.py b/test/unit/test_contracts_graph_parsed.py
--- a/test/unit/test_contracts_graph_parsed.py
+++ b/test/unit/test_contracts_graph_parsed.py
@@ -30,7 +30,7 @@
     SourceDefinition,
     Documentation,
     HookNode,
-    ExposureOwner,
+    Owner,
     TestMetadata,
 )
 from dbt.contracts.graph.unparsed import (
@@ -2142,7 +2142,7 @@ def basic_parsed_exposure_object():
         package_name='test',
         path='models/something.yml',
         original_file_path='models/something.yml',
-        owner=ExposureOwner(email='test@example.com'),
+        owner=Owner(email='test@example.com'),
         description='',
         meta={},
         tags=[],
@@ -2195,7 +2195,7 @@ def complex_parsed_exposure_object():
         name='my_exposure',
         resource_type=NodeType.Exposure,
         type=ExposureType.Analysis,
-        owner=ExposureOwner(email='test@example.com', name='A Name'),
+        owner=Owner(email='test@example.com', name='A Name'),
         maturity=MaturityType.Low,
         url='https://example.com/analyses/1',
         description='my description',
diff --git a/test/unit/test_contracts_graph_unparsed.py b/test/unit/test_contracts_graph_unparsed.py
--- a/test/unit/test_contracts_graph_unparsed.py
+++ b/test/unit/test_contracts_graph_unparsed.py
@@ -6,7 +6,7 @@
     UnparsedNode, UnparsedRunHook, UnparsedMacro, Time, TimePeriod,
     FreshnessThreshold, Quoting, UnparsedSourceDefinition,
     UnparsedSourceTableDefinition, UnparsedDocumentationFile, UnparsedColumn,
-    UnparsedNodeUpdate, Docs, UnparsedExposure, MaturityType, ExposureOwner,
+    UnparsedNodeUpdate, Docs, UnparsedExposure, MaturityType, Owner,
     ExposureType, UnparsedMetric, MetricFilter, MetricTime, MetricTimePeriod
 )
 from dbt.contracts.results import FreshnessStatus
@@ -584,7 +584,9 @@ def get_ok_dict(self):
             'name': 'my_exposure',
             'type': 'dashboard',
             'owner': {
+                'name': 'example',
                 'email': 'name@example.com',
+                'slack': '#channel'
             },
             'maturity': 'medium',
             'meta': {'tool': 'my_tool'},
@@ -602,7 +604,7 @@ def test_ok(self):
         exposure = self.ContractType(
             name='my_exposure',
             type=ExposureType.Dashboard,
-            owner=ExposureOwner(email='name@example.com'),
+            owner=Owner(name='example', email='name@example.com', _extra={'slack': '#channel'}),
             maturity=MaturityType.Medium,
             url='https://example.com/dashboards/1',
             description='A exposure',
@@ -652,6 +654,7 @@ def test_bad_maturity(self):
     def test_bad_owner_missing_things(self):
         tst = self.get_ok_dict()
         del tst['owner']['email']
+        del tst['owner']['name']
         self.assert_fails_validation(tst)
 
         del tst['owner']
diff --git a/test/unit/test_graph_selector_methods.py b/test/unit/test_graph_selector_methods.py
--- a/test/unit/test_graph_selector_methods.py
+++ b/test/unit/test_graph_selector_methods.py
@@ -23,7 +23,7 @@
     ColumnInfo,
 )
 from dbt.contracts.graph.manifest import Manifest
-from dbt.contracts.graph.unparsed import ExposureType, ExposureOwner, MetricFilter,MetricTime
+from dbt.contracts.graph.unparsed import ExposureType, Owner, MetricFilter,MetricTime
 from dbt.contracts.state import PreviousState
 from dbt.node_types import NodeType
 from dbt.graph.selector_methods import (
@@ -330,7 +330,7 @@ def make_exposure(pkg, name, path=None, fqn_extras=None, owner=None):
         fqn_extras = []
 
     if owner is None:
-        owner = ExposureOwner(email='test@example.com')
+        owner = Owner(email='test@example.com')
 
     fqn = [pkg, 'exposures'] + fqn_extras + [name]
     return Exposure(
diff --git a/test/unit/test_manifest.py b/test/unit/test_manifest.py
--- a/test/unit/test_manifest.py
+++ b/test/unit/test_manifest.py
@@ -27,7 +27,7 @@
 
 from dbt.contracts.graph.unparsed import (
     ExposureType,
-    ExposureOwner,
+    Owner,
     MaturityType,
     MetricFilter,
     MetricTime
@@ -83,7 +83,7 @@ def setUp(self):
             'exposure.root.my_exposure': Exposure(
                 name='my_exposure',
                 type=ExposureType.Dashboard,
-                owner=ExposureOwner(email='some@email.com'),
+                owner=Owner(email='some@email.com'),
                 resource_type=NodeType.Exposure,
                 description='Test description',
                 maturity=MaturityType.High,