dbt-bouncer 1.31.2rc3__py3-none-any.whl → 2.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. dbt_bouncer/artifact_parsers/dbt_cloud/catalog_latest.py +21 -21
  2. dbt_bouncer/artifact_parsers/dbt_cloud/manifest_latest.py +1745 -1745
  3. dbt_bouncer/artifact_parsers/dbt_cloud/run_results_latest.py +22 -22
  4. dbt_bouncer/artifact_parsers/parsers_catalog.py +26 -24
  5. dbt_bouncer/artifact_parsers/parsers_common.py +57 -36
  6. dbt_bouncer/artifact_parsers/parsers_manifest.py +98 -69
  7. dbt_bouncer/artifact_parsers/parsers_run_results.py +32 -19
  8. dbt_bouncer/check_base.py +22 -11
  9. dbt_bouncer/checks/catalog/check_catalog_sources.py +22 -12
  10. dbt_bouncer/checks/catalog/check_columns.py +175 -105
  11. dbt_bouncer/checks/common.py +24 -3
  12. dbt_bouncer/checks/manifest/check_exposures.py +79 -52
  13. dbt_bouncer/checks/manifest/check_lineage.py +69 -40
  14. dbt_bouncer/checks/manifest/check_macros.py +177 -104
  15. dbt_bouncer/checks/manifest/check_metadata.py +28 -18
  16. dbt_bouncer/checks/manifest/check_models.py +842 -496
  17. dbt_bouncer/checks/manifest/check_seeds.py +63 -0
  18. dbt_bouncer/checks/manifest/check_semantic_models.py +28 -20
  19. dbt_bouncer/checks/manifest/check_snapshots.py +57 -33
  20. dbt_bouncer/checks/manifest/check_sources.py +246 -137
  21. dbt_bouncer/checks/manifest/check_unit_tests.py +97 -54
  22. dbt_bouncer/checks/run_results/check_run_results.py +34 -20
  23. dbt_bouncer/config_file_parser.py +47 -28
  24. dbt_bouncer/config_file_validator.py +11 -8
  25. dbt_bouncer/global_context.py +31 -0
  26. dbt_bouncer/main.py +128 -67
  27. dbt_bouncer/runner.py +61 -31
  28. dbt_bouncer/utils.py +146 -50
  29. dbt_bouncer/version.py +1 -1
  30. {dbt_bouncer-1.31.2rc3.dist-info → dbt_bouncer-2.0.0rc1.dist-info}/METADATA +15 -15
  31. dbt_bouncer-2.0.0rc1.dist-info/RECORD +37 -0
  32. dbt_bouncer-1.31.2rc3.dist-info/RECORD +0 -35
  33. {dbt_bouncer-1.31.2rc3.dist-info → dbt_bouncer-2.0.0rc1.dist-info}/WHEEL +0 -0
  34. {dbt_bouncer-1.31.2rc3.dist-info → dbt_bouncer-2.0.0rc1.dist-info}/entry_points.txt +0 -0
  35. {dbt_bouncer-1.31.2rc3.dist-info → dbt_bouncer-2.0.0rc1.dist-info}/licenses/LICENSE +0 -0
  36. {dbt_bouncer-1.31.2rc3.dist-info → dbt_bouncer-2.0.0rc1.dist-info}/top_level.txt +0 -0
@@ -1,34 +1,33 @@
1
- # mypy: disable-error-code="union-attr"
2
-
3
-
4
- from typing import TYPE_CHECKING, List, Literal
1
+ from typing import TYPE_CHECKING, Literal
5
2
 
6
3
  from pydantic import Field
7
4
 
8
5
  from dbt_bouncer.check_base import BaseCheck
9
6
 
10
7
  if TYPE_CHECKING:
11
- from dbt_bouncer.artifact_parsers.parsers_common import (
8
+ from dbt_bouncer.artifact_parsers.parsers_manifest import (
12
9
  DbtBouncerExposureBase,
13
10
  DbtBouncerModelBase,
14
11
  )
15
12
 
13
+ from dbt_bouncer.checks.common import DbtBouncerFailedCheckError
14
+
16
15
 
17
16
  class CheckExposureOnModel(BaseCheck):
18
17
  """Exposures should depend on a model.
19
18
 
20
19
  Parameters:
21
- maximum_number_of_models (Optional[int]): The maximum number of models an exposure can depend on, defaults to 100.
22
- minimum_number_of_models (Optional[int]): The minimum number of models an exposure can depend on, defaults to 1.
20
+ maximum_number_of_models (int | None): The maximum number of models an exposure can depend on, defaults to 100.
21
+ minimum_number_of_models (int | None): The minimum number of models an exposure can depend on, defaults to 1.
23
22
 
24
23
  Receives:
25
24
  exposure (DbtBouncerExposureBase): The DbtBouncerExposureBase object to check.
26
25
 
27
26
  Other Parameters:
28
- description (Optional[str]): Description of what the check does and why it is implemented.
29
- exclude (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
30
- include (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
31
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
27
+ description (str | None): Description of what the check does and why it is implemented.
28
+ exclude (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
29
+ include (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
30
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
32
31
 
33
32
  Example(s):
34
33
  ```yaml
@@ -44,35 +43,47 @@ class CheckExposureOnModel(BaseCheck):
44
43
 
45
44
  """
46
45
 
47
- exposure: "DbtBouncerExposureBase" = Field(default=None)
46
+ exposure: "DbtBouncerExposureBase | None" = Field(default=None)
48
47
  maximum_number_of_models: int = Field(default=100)
49
48
  minimum_number_of_models: int = Field(default=1)
50
49
  name: Literal["check_exposure_based_on_model"]
51
50
 
52
51
  def execute(self) -> None:
53
- """Execute the check."""
54
- number_of_upstream_models = len(self.exposure.depends_on.nodes)
52
+ """Execute the check.
55
53
 
56
- assert self.minimum_number_of_models <= number_of_upstream_models, (
57
- f"`{self.exposure.name}` is based on less models ({number_of_upstream_models}) than the minimum permitted ({self.minimum_number_of_models})."
58
- )
59
- assert number_of_upstream_models <= self.maximum_number_of_models, (
60
- f"`{self.exposure.name}` is based on more models ({number_of_upstream_models}) than the maximum permitted ({self.maximum_number_of_models})."
54
+ Raises:
55
+ DbtBouncerFailedCheckError: If upstream models number is not within limits.
56
+
57
+ """
58
+ if self.exposure is None:
59
+ raise DbtBouncerFailedCheckError("self.exposure is None")
60
+ depends_on = self.exposure.depends_on
61
+ number_of_upstream_models = (
62
+ len(getattr(depends_on, "nodes", []) or []) if depends_on else 0
61
63
  )
62
64
 
65
+ if number_of_upstream_models < self.minimum_number_of_models:
66
+ raise DbtBouncerFailedCheckError(
67
+ f"`{self.exposure.name}` is based on less models ({number_of_upstream_models}) than the minimum permitted ({self.minimum_number_of_models})."
68
+ )
69
+ if number_of_upstream_models > self.maximum_number_of_models:
70
+ raise DbtBouncerFailedCheckError(
71
+ f"`{self.exposure.name}` is based on more models ({number_of_upstream_models}) than the maximum permitted ({self.maximum_number_of_models})."
72
+ )
73
+
63
74
 
64
75
  class CheckExposureOnNonPublicModels(BaseCheck):
65
76
  """Exposures should be based on public models only.
66
77
 
67
78
  Receives:
68
79
  exposure (DbtBouncerExposureBase): The DbtBouncerExposureBase object to check.
69
- models (List[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
80
+ models (list[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
70
81
 
71
82
  Other Parameters:
72
- description (Optional[str]): Description of what the check does and why it is implemented.
73
- exclude (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
74
- include (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
75
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
83
+ description (str | None): Description of what the check does and why it is implemented.
84
+ exclude (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
85
+ include (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
86
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
76
87
 
77
88
  Example(s):
78
89
  ```yaml
@@ -82,44 +93,52 @@ class CheckExposureOnNonPublicModels(BaseCheck):
82
93
 
83
94
  """
84
95
 
85
- exposure: "DbtBouncerExposureBase" = Field(default=None)
86
- models: List["DbtBouncerModelBase"] = Field(default=[])
96
+ exposure: "DbtBouncerExposureBase | None" = Field(default=None)
97
+ models: list["DbtBouncerModelBase"] = Field(default=[])
87
98
  name: Literal["check_exposure_based_on_non_public_models"]
88
99
 
89
100
  def execute(self) -> None:
90
- """Execute the check."""
101
+ """Execute the check.
102
+
103
+ Raises:
104
+ DbtBouncerFailedCheckError: If exposure is based on non-public models.
105
+
106
+ """
107
+ if self.exposure is None:
108
+ raise DbtBouncerFailedCheckError("self.exposure is None")
91
109
  non_public_upstream_dependencies = []
92
- for model in self.exposure.depends_on.nodes:
110
+ for model in getattr(self.exposure.depends_on, "nodes", []) or []:
93
111
  if (
94
112
  next(m for m in self.models if m.unique_id == model).resource_type
95
113
  == "model"
96
114
  and next(m for m in self.models if m.unique_id == model).package_name
97
115
  == self.exposure.package_name
98
116
  ):
99
- model = next(m for m in self.models if m.unique_id == model)
100
- if model.access.value != "public":
101
- non_public_upstream_dependencies.append(model.name)
117
+ model_obj = next(m for m in self.models if m.unique_id == model)
118
+ if model_obj.access and model_obj.access.value != "public":
119
+ non_public_upstream_dependencies.append(model_obj.name)
102
120
 
103
- assert not non_public_upstream_dependencies, (
104
- f"`{self.exposure.name}` is based on a model(s) that is not public: {non_public_upstream_dependencies}."
105
- )
121
+ if non_public_upstream_dependencies:
122
+ raise DbtBouncerFailedCheckError(
123
+ f"`{self.exposure.name}` is based on a model(s) that is not public: {non_public_upstream_dependencies}."
124
+ )
106
125
 
107
126
 
108
127
  class CheckExposureOnView(BaseCheck):
109
128
  """Exposures should not be based on views.
110
129
 
111
130
  Parameters:
112
- materializations_to_include (Optional[List[str]]): List of materializations to include in the check.
131
+ materializations_to_include (list[str] | None): List of materializations to include in the check.
113
132
 
114
133
  Receives:
115
134
  exposure (DbtBouncerExposureBase): The DbtBouncerExposureBase object to check.
116
- models (List[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
135
+ models (list[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
117
136
 
118
137
  Other Parameters:
119
- description (Optional[str]): Description of what the check does and why it is implemented.
120
- exclude (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
121
- include (Optional[str]): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
122
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
138
+ description (str | None): Description of what the check does and why it is implemented.
139
+ exclude (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Exposure paths that match the pattern will not be checked.
140
+ include (str | None): Regex pattern to match the exposure path (i.e the .yml file where the exposure is configured). Only exposure paths that match the pattern will be checked.
141
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
123
142
 
124
143
  Example(s):
125
144
  ```yaml
@@ -137,27 +156,35 @@ class CheckExposureOnView(BaseCheck):
137
156
 
138
157
  """
139
158
 
140
- exposure: "DbtBouncerExposureBase" = Field(default=None)
141
- materializations_to_include: List[str] = Field(
159
+ exposure: "DbtBouncerExposureBase | None" = Field(default=None)
160
+ materializations_to_include: list[str] = Field(
142
161
  default=["ephemeral", "view"],
143
162
  )
144
- models: List["DbtBouncerModelBase"] = Field(default=[])
163
+ models: list["DbtBouncerModelBase"] = Field(default=[])
145
164
  name: Literal["check_exposure_based_on_view"]
146
165
 
147
166
  def execute(self) -> None:
148
- """Execute the check."""
167
+ """Execute the check.
168
+
169
+ Raises:
170
+ DbtBouncerFailedCheckError: If exposure is based on a model that is not a table.
171
+
172
+ """
173
+ if self.exposure is None:
174
+ raise DbtBouncerFailedCheckError("self.exposure is None")
149
175
  non_table_upstream_dependencies = []
150
- for model in self.exposure.depends_on.nodes:
176
+ for model in getattr(self.exposure.depends_on, "nodes", []) or []:
151
177
  if (
152
178
  next(m for m in self.models if m.unique_id == model).resource_type
153
179
  == "model"
154
180
  and next(m for m in self.models if m.unique_id == model).package_name
155
181
  == self.exposure.package_name
156
182
  ):
157
- model = next(m for m in self.models if m.unique_id == model)
158
- if model.config.materialized in self.materializations_to_include:
159
- non_table_upstream_dependencies.append(model.name)
160
-
161
- assert not non_table_upstream_dependencies, (
162
- f"`{self.exposure.name}` is based on a model that is not a table: {non_table_upstream_dependencies}."
163
- )
183
+ model_obj = next(m for m in self.models if m.unique_id == model)
184
+ if model_obj.config.materialized in self.materializations_to_include:
185
+ non_table_upstream_dependencies.append(model_obj.name)
186
+
187
+ if non_table_upstream_dependencies:
188
+ raise DbtBouncerFailedCheckError(
189
+ f"`{self.exposure.name}` is based on a model that is not a table: {non_table_upstream_dependencies}."
190
+ )
@@ -1,18 +1,17 @@
1
- # mypy: disable-error-code="union-attr"
2
-
3
1
  import re
4
- from typing import TYPE_CHECKING, List, Literal, Optional
2
+ from typing import TYPE_CHECKING, Literal
5
3
 
6
4
  from dbt_bouncer.check_base import BaseCheck
7
5
 
8
6
  if TYPE_CHECKING:
9
- from dbt_bouncer.artifact_parsers.parsers_common import (
7
+ from dbt_bouncer.artifact_parsers.parsers_manifest import (
10
8
  DbtBouncerManifest,
11
9
  DbtBouncerModelBase,
12
10
  )
13
11
 
14
12
  from pydantic import Field
15
13
 
14
+ from dbt_bouncer.checks.common import DbtBouncerFailedCheckError
16
15
  from dbt_bouncer.utils import clean_path_str, get_clean_model_name
17
16
 
18
17
 
@@ -25,13 +24,13 @@ class CheckLineagePermittedUpstreamModels(BaseCheck):
25
24
  Receives:
26
25
  manifest_obj (DbtBouncerManifest): The manifest object.
27
26
  model (DbtBouncerModelBase): The DbtBouncerModelBase object to check.
28
- models (List[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
27
+ models (list[DbtBouncerModelBase]): List of DbtBouncerModelBase objects parsed from `manifest.json`.
29
28
 
30
29
  Other Parameters:
31
- description (Optional[str]): Description of what the check does and why it is implemented.
32
- exclude (Optional[str]): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
33
- include (Optional[str]): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
34
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
30
+ description (str | None): Description of what the check does and why it is implemented.
31
+ exclude (str | None): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
32
+ include (str | None): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
33
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
35
34
 
36
35
  Example(s):
37
36
  ```yaml
@@ -49,18 +48,27 @@ class CheckLineagePermittedUpstreamModels(BaseCheck):
49
48
 
50
49
  """
51
50
 
52
- manifest_obj: "DbtBouncerManifest" = Field(default=None)
53
- model: "DbtBouncerModelBase" = Field(default=None)
54
- models: List["DbtBouncerModelBase"] = Field(default=[])
51
+ manifest_obj: "DbtBouncerManifest | None" = Field(default=None)
52
+ model: "DbtBouncerModelBase | None" = Field(default=None)
53
+ models: list["DbtBouncerModelBase"] = Field(default=[])
55
54
  name: Literal["check_lineage_permitted_upstream_models"]
56
- package_name: Optional[str] = Field(default=None)
55
+ package_name: str | None = Field(default=None)
57
56
  upstream_path_pattern: str
58
57
 
59
58
  def execute(self) -> None:
60
- """Execute the check."""
59
+ """Execute the check.
60
+
61
+ Raises:
62
+ DbtBouncerFailedCheckError: If upstream models are not permitted.
63
+
64
+ """
65
+ if self.model is None:
66
+ raise DbtBouncerFailedCheckError("self.model is None")
67
+ if self.manifest_obj is None:
68
+ raise DbtBouncerFailedCheckError("self.manifest_obj is None")
61
69
  upstream_models = [
62
70
  x
63
- for x in self.model.depends_on.nodes
71
+ for x in getattr(self.model.depends_on, "nodes", []) or []
64
72
  if x.split(".")[0] == "model"
65
73
  and x.split(".")[1]
66
74
  == (self.package_name or self.manifest_obj.manifest.metadata.project_name)
@@ -77,9 +85,10 @@ class CheckLineagePermittedUpstreamModels(BaseCheck):
77
85
  )
78
86
  is None
79
87
  ]
80
- assert not not_permitted_upstream_models, (
81
- f"`{get_clean_model_name(self.model.unique_id)}` references upstream models that are not permitted: {[m.split('.')[-1] for m in not_permitted_upstream_models]}."
82
- )
88
+ if not_permitted_upstream_models:
89
+ raise DbtBouncerFailedCheckError(
90
+ f"`{get_clean_model_name(self.model.unique_id)}` references upstream models that are not permitted: {[m.split('.')[-1] for m in not_permitted_upstream_models]}."
91
+ )
83
92
 
84
93
 
85
94
  class CheckLineageSeedCannotBeUsed(BaseCheck):
@@ -89,10 +98,10 @@ class CheckLineageSeedCannotBeUsed(BaseCheck):
89
98
  model (DbtBouncerModelBase): The DbtBouncerModelBase object to check.
90
99
 
91
100
  Other Parameters:
92
- description (Optional[str]): Description of what the check does and why it is implemented.
93
- exclude (Optional[str]): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
94
- include (Optional[str]): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
95
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
101
+ description (str | None): Description of what the check does and why it is implemented.
102
+ exclude (str | None): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
103
+ include (str | None): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
104
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
96
105
 
97
106
  Example(s):
98
107
  ```yaml
@@ -103,16 +112,26 @@ class CheckLineageSeedCannotBeUsed(BaseCheck):
103
112
 
104
113
  """
105
114
 
106
- model: "DbtBouncerModelBase" = Field(default=None)
115
+ model: "DbtBouncerModelBase | None" = Field(default=None)
107
116
  name: Literal["check_lineage_seed_cannot_be_used"]
108
117
 
109
118
  def execute(self) -> None:
110
- """Execute the check."""
111
- assert not [
112
- x for x in self.model.depends_on.nodes if x.split(".")[0] == "seed"
113
- ], (
114
- f"`{get_clean_model_name(self.model.unique_id)}` references a seed even though this is not permitted."
115
- )
119
+ """Execute the check.
120
+
121
+ Raises:
122
+ DbtBouncerFailedCheckError: If seed is referenced.
123
+
124
+ """
125
+ if self.model is None:
126
+ raise DbtBouncerFailedCheckError("self.model is None")
127
+ if [
128
+ x
129
+ for x in getattr(self.model.depends_on, "nodes", []) or []
130
+ if x.split(".")[0] == "seed"
131
+ ]:
132
+ raise DbtBouncerFailedCheckError(
133
+ f"`{get_clean_model_name(self.model.unique_id)}` references a seed even though this is not permitted."
134
+ )
116
135
 
117
136
 
118
137
  class CheckLineageSourceCannotBeUsed(BaseCheck):
@@ -122,10 +141,10 @@ class CheckLineageSourceCannotBeUsed(BaseCheck):
122
141
  model (DbtBouncerModelBase): The DbtBouncerModelBase object to check.
123
142
 
124
143
  Other Parameters:
125
- description (Optional[str]): Description of what the check does and why it is implemented.
126
- exclude (Optional[str]): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
127
- include (Optional[str]): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
128
- severity (Optional[Literal["error", "warn"]]): Severity level of the check. Default: `error`.
144
+ description (str | None): Description of what the check does and why it is implemented.
145
+ exclude (str | None): Regex pattern to match the model path. Model paths that match the pattern will not be checked.
146
+ include (str | None): Regex pattern to match the model path. Only model paths that match the pattern will be checked.
147
+ severity (Literal["error", "warn"] | None): Severity level of the check. Default: `error`.
129
148
 
130
149
  Example(s):
131
150
  ```yaml
@@ -136,13 +155,23 @@ class CheckLineageSourceCannotBeUsed(BaseCheck):
136
155
 
137
156
  """
138
157
 
139
- model: "DbtBouncerModelBase" = Field(default=None)
158
+ model: "DbtBouncerModelBase | None" = Field(default=None)
140
159
  name: Literal["check_lineage_source_cannot_be_used"]
141
160
 
142
161
  def execute(self) -> None:
143
- """Execute the check."""
144
- assert not [
145
- x for x in self.model.depends_on.nodes if x.split(".")[0] == "source"
146
- ], (
147
- f"`{get_clean_model_name(self.model.unique_id)}` references a source even though this is not permitted."
148
- )
162
+ """Execute the check.
163
+
164
+ Raises:
165
+ DbtBouncerFailedCheckError: If source is referenced.
166
+
167
+ """
168
+ if self.model is None:
169
+ raise DbtBouncerFailedCheckError("self.model is None")
170
+ if [
171
+ x
172
+ for x in getattr(self.model.depends_on, "nodes", []) or []
173
+ if x.split(".")[0] == "source"
174
+ ]:
175
+ raise DbtBouncerFailedCheckError(
176
+ f"`{get_clean_model_name(self.model.unique_id)}` references a source even though this is not permitted."
177
+ )