pycarlo 0.10.210__py3-none-any.whl → 0.12.57__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.

Potentially problematic release.


This version of pycarlo might be problematic. Click here for more details.

@@ -1,15 +1,32 @@
1
- from pycarlo.features.metadata.allow_block_list import (
2
- AllowBlockList,
1
+ from pycarlo.features.metadata.asset_allow_block_list import AssetAllowBlockList
2
+ from pycarlo.features.metadata.asset_filters_container import AssetFiltersContainer
3
+ from pycarlo.features.metadata.base_allow_block_list import (
4
+ BaseAllowBlockList,
5
+ ComparisonType,
3
6
  FilterEffectType,
7
+ FilterRule,
4
8
  FilterType,
9
+ RuleEffect,
10
+ )
11
+ from pycarlo.features.metadata.metadata_allow_block_list import (
12
+ MetadataAllowBlockList,
5
13
  MetadataFilter,
6
14
  )
7
15
  from pycarlo.features.metadata.metadata_filters_container import MetadataFiltersContainer
8
16
 
9
17
  __all__ = [
18
+ # Base classes
19
+ "FilterRule",
20
+ "BaseAllowBlockList",
10
21
  "FilterEffectType",
22
+ "RuleEffect",
11
23
  "FilterType",
24
+ "ComparisonType",
25
+ # Metadata filtering classes
12
26
  "MetadataFilter",
13
- "AllowBlockList",
27
+ "MetadataAllowBlockList",
14
28
  "MetadataFiltersContainer",
29
+ # Asset filtering classes
30
+ "AssetAllowBlockList",
31
+ "AssetFiltersContainer",
15
32
  ]
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+ from dataclasses_json import DataClassJsonMixin
5
+
6
+ from pycarlo.common import get_logger
7
+ from pycarlo.features.metadata.base_allow_block_list import BaseAllowBlockList, FilterRule
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class AssetAllowBlockList(BaseAllowBlockList[FilterRule], DataClassJsonMixin):
14
+ # JSON deserialization fails without this ugly override
15
+ rules: Optional[List[FilterRule]] = field(default_factory=list)
16
+
17
+ asset_type: Optional[str] = None
18
+
19
+ def __post_init__(self):
20
+ # We can't remove the default value because of properties with defaults in the parent class.
21
+ if not self.asset_type:
22
+ raise ValueError("asset_type is required")
@@ -0,0 +1,79 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Dict, List
3
+
4
+ from dataclasses_json import DataClassJsonMixin
5
+
6
+ from .asset_allow_block_list import AssetAllowBlockList
7
+ from .base_allow_block_list import FilterEffectType
8
+
9
+ # Mapping of resource types to their supported asset types for collection preferences.
10
+ # This is used for validating asset collection preferences.
11
+ # When support for filtering an asset type is implemented in the DC, it should be added here.
12
+ # The reason it is here instead of in Monolith, is so that it can be referenced by the CLI.
13
+ # The pycarlo version in CLI and monolith should be updated after updating this and releasing a
14
+ # new version.
15
+ ASSET_TYPE_ATTRIBUTES = {"tableau": {"project": ["name"], "workbook": ["name", "luid"]}}
16
+
17
+
18
+ @dataclass
19
+ class AssetFiltersContainer(DataClassJsonMixin):
20
+ """
21
+ Simple container for asset filtering that focuses on in-memory filtering for REST APIs.
22
+
23
+ This class provides basic asset filtering functionality without SQL generation complexity.
24
+ It's designed for the initial phase where assets are collected via REST APIs rather than
25
+ SQL queries.
26
+
27
+ Example usage:
28
+ # Block all external assets
29
+ filters = AssetAllowBlockList(
30
+ filters=[AssetFilter(asset_type="external", effect=FilterEffectType.BLOCK)]
31
+ )
32
+ container = AssetFiltersContainer(asset_filters=filters)
33
+
34
+ # Check if an asset is blocked
35
+ is_blocked = container.is_asset_blocked("external", "my_table") # True
36
+ is_blocked = container.is_asset_blocked("table", "users") # False
37
+ """
38
+
39
+ asset_filters: List[AssetAllowBlockList] = field(default_factory=list)
40
+
41
+ def is_asset_type_filtered(self, asset_type: str) -> bool:
42
+ """Returns True if any filters are configured for the given asset type."""
43
+ return bool(self._get_asset_filters(asset_type))
44
+
45
+ def is_asset_blocked(self, asset_type: str, attributes: Dict[str, str]) -> bool:
46
+ """
47
+ Returns True if the specified asset is blocked by the current filters.
48
+
49
+ Args:
50
+ asset_type: The type of asset (e.g., 'tableau_workbook_v2', 'jobs', 'power_bi_workspace')
51
+ attributes: A dictionary representing the attributes of the asset
52
+
53
+ Returns:
54
+ True if the asset is blocked, False if it's allowed
55
+ """
56
+ asset_filters = self._get_asset_filters(asset_type)
57
+
58
+ is_blocked = False
59
+
60
+ for asset_filter in asset_filters:
61
+ default_effect_matches = asset_filter.get_default_effect_rules(
62
+ lambda f: f.matches(force_regexp=False, **attributes)
63
+ )
64
+ if default_effect_matches:
65
+ is_blocked = asset_filter.default_effect == FilterEffectType.BLOCK
66
+ else:
67
+ other_effect_matches = asset_filter.get_other_effect_rules(
68
+ lambda f: f.matches(force_regexp=False, **attributes)
69
+ )
70
+ if other_effect_matches:
71
+ is_blocked = asset_filter.other_effect == FilterEffectType.BLOCK
72
+ else:
73
+ # No matches, use default effect
74
+ is_blocked = asset_filter.default_effect == FilterEffectType.BLOCK
75
+
76
+ return is_blocked
77
+
78
+ def _get_asset_filters(self, asset_type: str) -> List[AssetAllowBlockList]:
79
+ return [f for f in self.asset_filters if f.asset_type == asset_type]
@@ -0,0 +1,137 @@
1
+ import enum
2
+ import re
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Generic, List, Optional, TypeVar
5
+
6
+ from dataclasses_json import DataClassJsonMixin
7
+
8
+ from pycarlo.common import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+ # For documentation and samples check the link below:
13
+ # https://www.notion.so/montecarlodata/Catalog-Schema-Filtering-59edd6eff7f74c94ab6bfca75d2e3ff1
14
+
15
+
16
+ def _exclude_none_values(value: Any) -> bool:
17
+ return value is None
18
+
19
+
20
+ class FilterEffectType(enum.Enum):
21
+ BLOCK = "block"
22
+ ALLOW = "allow"
23
+
24
+
25
+ RuleEffect = FilterEffectType
26
+
27
+
28
+ class FilterType(enum.Enum):
29
+ EXACT_MATCH = "exact_match"
30
+ PREFIX = "prefix"
31
+ SUFFIX = "suffix"
32
+ SUBSTRING = "substring"
33
+ REGEXP = "regexp"
34
+
35
+
36
+ ComparisonType = FilterType
37
+
38
+ # Type variable for the filter class
39
+ FilterRuleT = TypeVar("FilterRuleT", bound="FilterRule")
40
+
41
+
42
+ @dataclass
43
+ class RuleCondition(DataClassJsonMixin):
44
+ attribute_name: str
45
+ value: str
46
+ comparison_type: ComparisonType = ComparisonType.EXACT_MATCH
47
+
48
+
49
+ @dataclass
50
+ class FilterRule(DataClassJsonMixin):
51
+ """
52
+ Base class for all filter types. Provides common filtering logic that can be
53
+ shared between different filter implementations (e.g., metadata filters, asset filters).
54
+ """
55
+
56
+ conditions: Optional[List[RuleCondition]] = field(default_factory=list)
57
+ effect: RuleEffect = RuleEffect.BLOCK
58
+
59
+ def matches(self, force_regexp: bool = False, **kwargs: Any) -> bool:
60
+ """
61
+ Returns True if all properties specified in kwargs match the conditions specified in
62
+ properties of the same name in this object.
63
+ If any of the conditions (for example self.field) is None, that condition will be matched.
64
+ """
65
+ if not kwargs:
66
+ raise ValueError("At least one field needs to be specified for matching")
67
+
68
+ # kwargs must match the field names in this class, if any of them do not,
69
+ # invalidate the filter.
70
+ try:
71
+ return all(
72
+ condition.attribute_name not in kwargs
73
+ or self._match(
74
+ condition=condition,
75
+ value=kwargs.get(condition.attribute_name),
76
+ force_regexp=force_regexp,
77
+ )
78
+ for condition in self.conditions or []
79
+ )
80
+ except AttributeError:
81
+ return False
82
+
83
+ @classmethod
84
+ def _match(cls, condition: RuleCondition, value: Optional[str], force_regexp: bool) -> bool:
85
+ # Field not specified on this object, e.g. self.field=None, which matches everything
86
+ if value is None:
87
+ return False
88
+
89
+ # The comparison is performed case-insensitive (check BaseFilter._safe_match)
90
+ # We can use LOWER here since it is part of standard SQL (like AND/OR/NOT), so including it
91
+ # here is a way to make sure that all comparisons are case-insensitive in the SQL sentences
92
+ # for all engines. Added option to not always LOWER since customers do have lower/upper case
93
+ # databases logged in MC
94
+ filter_value = condition.value.lower()
95
+ value = value.lower()
96
+
97
+ if force_regexp or condition.comparison_type == FilterType.REGEXP:
98
+ regexp = f"^{filter_value}$"
99
+ return re.match(regexp, value) is not None
100
+ elif condition.comparison_type == FilterType.PREFIX:
101
+ return value.startswith(filter_value)
102
+ elif condition.comparison_type == FilterType.SUFFIX:
103
+ return value.endswith(filter_value)
104
+ elif condition.comparison_type == FilterType.SUBSTRING:
105
+ return filter_value in value
106
+ else: # filter_type == FilterType.EXACT_MATCH
107
+ return filter_value == value
108
+
109
+
110
+ @dataclass
111
+ class BaseAllowBlockList(Generic[FilterRuleT], DataClassJsonMixin):
112
+ rules: Optional[List[FilterRuleT]] = field(default_factory=list)
113
+ default_effect: RuleEffect = RuleEffect.ALLOW
114
+
115
+ @property
116
+ def other_effect(self) -> RuleEffect:
117
+ return RuleEffect.ALLOW if self.default_effect == RuleEffect.BLOCK else RuleEffect.BLOCK
118
+
119
+ def get_default_effect_rules(
120
+ self, condition: Optional[Callable[[FilterRuleT], bool]] = None
121
+ ) -> List[FilterRuleT]:
122
+ return list(
123
+ filter(
124
+ lambda f: f.effect == self.default_effect and (condition is None or condition(f)),
125
+ self.rules or [],
126
+ )
127
+ )
128
+
129
+ def get_other_effect_rules(
130
+ self, condition: Optional[Callable[[FilterRuleT], bool]] = None
131
+ ) -> List[FilterRuleT]:
132
+ return list(
133
+ filter(
134
+ lambda f: f.effect != self.default_effect and (condition is None or condition(f)),
135
+ self.rules or [],
136
+ )
137
+ )
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Optional
3
+
4
+ from dataclasses_json import config, dataclass_json
5
+
6
+ from pycarlo.common import get_logger
7
+ from pycarlo.features.metadata.base_allow_block_list import (
8
+ BaseAllowBlockList,
9
+ ComparisonType,
10
+ FilterRule,
11
+ FilterType,
12
+ RuleCondition,
13
+ )
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ # For documentation and samples check the link below:
18
+ # https://www.notion.so/montecarlodata/Catalog-Schema-Filtering-59edd6eff7f74c94ab6bfca75d2e3ff1
19
+
20
+
21
+ @dataclass_json
22
+ @dataclass
23
+ class MetadataFilter(FilterRule):
24
+ type: FilterType = FilterType.EXACT_MATCH
25
+
26
+ # we're using exclude=_exclude_none_values to prevent these properties to be serialized to json
27
+ # when None, to keep the json doc simpler
28
+ project: Optional[str] = field(metadata=config(exclude=lambda x: x is None), default=None)
29
+ dataset: Optional[str] = field(metadata=config(exclude=lambda x: x is None), default=None)
30
+ table_type: Optional[str] = field(metadata=config(exclude=lambda x: x is None), default=None)
31
+ table_name: Optional[str] = field(metadata=config(exclude=lambda x: x is None), default=None)
32
+
33
+ def __post_init__(self):
34
+ # For backwards compatibility, we now create a set of conditions based on the
35
+ # metadata-specific fields.
36
+ self.conditions = self.conditions or []
37
+ if self.table_name is not None:
38
+ is_target_field = self.filter_type_target_field() == "table_name"
39
+ condition = RuleCondition(
40
+ comparison_type=self.type if is_target_field else ComparisonType.EXACT_MATCH,
41
+ attribute_name="table_name",
42
+ value=self.table_name,
43
+ )
44
+ self.conditions.append(condition)
45
+
46
+ if self.dataset is not None:
47
+ is_target_field = self.filter_type_target_field() == "dataset"
48
+ condition = RuleCondition(
49
+ comparison_type=self.type if is_target_field else ComparisonType.EXACT_MATCH,
50
+ attribute_name="dataset",
51
+ value=self.dataset,
52
+ )
53
+ self.conditions.append(condition)
54
+
55
+ if self.project is not None:
56
+ is_target_field = self.filter_type_target_field() == "project"
57
+ condition = RuleCondition(
58
+ comparison_type=self.type if is_target_field else ComparisonType.EXACT_MATCH,
59
+ attribute_name="project",
60
+ value=self.project,
61
+ )
62
+ self.conditions.append(condition)
63
+
64
+ if self.table_type is not None:
65
+ condition = RuleCondition(
66
+ comparison_type=ComparisonType.EXACT_MATCH,
67
+ attribute_name="table_type",
68
+ value=self.table_type,
69
+ )
70
+ self.conditions.append(condition)
71
+
72
+ def filter_type_target_field(self) -> str:
73
+ """
74
+ The field that is evaluated using filter type. Other fields should be
75
+ compared using exact match.
76
+ """
77
+ if self.table_name is not None:
78
+ return "table_name"
79
+ if self.dataset is not None:
80
+ return "dataset"
81
+ if self.project is not None:
82
+ return "project"
83
+
84
+ logger.exception("Invalid filter, missing target values")
85
+ return ""
86
+
87
+
88
+ @dataclass_json
89
+ @dataclass
90
+ class MetadataAllowBlockList(BaseAllowBlockList[MetadataFilter]):
91
+ filters: List[MetadataFilter] = field(default_factory=list)
92
+
93
+ def __post_init__(self):
94
+ self.rules = self.filters
@@ -3,7 +3,12 @@ from typing import Any, Callable, Dict, Optional
3
3
 
4
4
  from dataclasses_json import dataclass_json
5
5
 
6
- from pycarlo.features.metadata import AllowBlockList, FilterEffectType, FilterType, MetadataFilter
6
+ from pycarlo.features.metadata import (
7
+ FilterEffectType,
8
+ FilterType,
9
+ MetadataAllowBlockList,
10
+ MetadataFilter,
11
+ )
7
12
 
8
13
 
9
14
  @dataclass_json
@@ -62,7 +67,7 @@ class MetadataFiltersContainer:
62
67
  - allowed: (project_3, dataset_1), (project_4, dataset_4)
63
68
  """
64
69
 
65
- metadata_filters: AllowBlockList = field(default_factory=AllowBlockList)
70
+ metadata_filters: MetadataAllowBlockList = field(default_factory=MetadataAllowBlockList)
66
71
 
67
72
  @property
68
73
  def is_metadata_filtered(self) -> bool:
@@ -129,7 +134,7 @@ class MetadataFiltersContainer:
129
134
 
130
135
  @staticmethod
131
136
  def _get_effect(
132
- metadata_filters: AllowBlockList,
137
+ metadata_filters: MetadataAllowBlockList,
133
138
  force_regexp: bool,
134
139
  condition: Optional[Callable[[MetadataFilter], bool]] = None,
135
140
  **kwargs: Any,
@@ -145,13 +150,13 @@ class MetadataFiltersContainer:
145
150
  """
146
151
  if not metadata_filters.filters or any(
147
152
  f.matches(force_regexp, **kwargs)
148
- for f in metadata_filters.get_default_effect_filters(condition=condition)
153
+ for f in metadata_filters.get_default_effect_rules(condition=condition)
149
154
  ):
150
155
  return metadata_filters.default_effect
151
156
 
152
157
  if any(
153
158
  f.matches(force_regexp, **kwargs)
154
- for f in metadata_filters.get_other_effect_filters(condition=condition)
159
+ for f in metadata_filters.get_other_effect_rules(condition=condition)
155
160
  ):
156
161
  return metadata_filters.other_effect
157
162
 
@@ -198,10 +203,10 @@ class MetadataFiltersContainer:
198
203
  return not project or f.matches(project=project)
199
204
 
200
205
  default_effect = self.metadata_filters.default_effect
201
- default_effect_filters = self.metadata_filters.get_default_effect_filters(
206
+ default_effect_filters = self.metadata_filters.get_default_effect_rules(
202
207
  condition=project_condition
203
208
  )
204
- other_effect_filters = self.metadata_filters.get_other_effect_filters(
209
+ other_effect_filters = self.metadata_filters.get_other_effect_rules(
205
210
  condition=project_condition
206
211
  )
207
212
  default_effect_op = " OR " if default_effect == FilterEffectType.ALLOW else " AND "
@@ -209,7 +214,7 @@ class MetadataFiltersContainer:
209
214
 
210
215
  default_effect_conditions = default_effect_op.join(
211
216
  [
212
- f"({self._get_sql_field_condition(f, column_mapping, encoder, force_lowercase)})"
217
+ self._get_sql_field_condition(f, column_mapping, encoder, force_lowercase)
213
218
  for f in default_effect_filters
214
219
  ]
215
220
  )
@@ -219,13 +224,15 @@ class MetadataFiltersContainer:
219
224
  for f in other_effect_filters
220
225
  ]
221
226
  )
222
- conditions = default_effect_conditions
223
- if conditions and other_effect_conditions:
224
- conditions += default_effect_op
225
- conditions += "(" + other_effect_conditions + ")"
226
- elif not conditions:
227
- conditions = other_effect_conditions
228
- return f"({conditions})" if conditions else ""
227
+
228
+ if default_effect_conditions and other_effect_conditions:
229
+ return f"(({default_effect_conditions}){default_effect_op}({other_effect_conditions}))"
230
+ elif default_effect_conditions:
231
+ return f"({default_effect_conditions})"
232
+ elif other_effect_conditions:
233
+ return f"({other_effect_conditions})"
234
+ else:
235
+ return None
229
236
 
230
237
  @staticmethod
231
238
  def _get_sql_field_condition(
@@ -233,7 +240,7 @@ class MetadataFiltersContainer:
233
240
  column_mapping: Dict,
234
241
  encoder: Callable[[str, str, FilterType], str],
235
242
  force_lowercase: Optional[bool] = True,
236
- ):
243
+ ) -> str:
237
244
  # The comparison is performed case-insensitive (check MetadataFilter._safe_match)
238
245
  # We can use LOWER here since it is part of standard SQL (like AND/OR/NOT), so including it
239
246
  # here is a way to make sure that all comparisons are case-insensitive in the SQL sentences
@@ -250,4 +257,6 @@ class MetadataFiltersContainer:
250
257
  if getattr(mf, field) is not None
251
258
  ]
252
259
  )
260
+ if not conditions:
261
+ return ""
253
262
  return f"NOT({conditions})" if mf.effect == FilterEffectType.BLOCK else f"({conditions})"
pycarlo/lib/README.md CHANGED
@@ -1,3 +1,35 @@
1
- These files are auto-generated. **Do not edit**!
1
+ # Monte Carlo GraphQL Schema Library
2
2
 
3
- Use `make generate` to update the schema (e.g. support new/modified queries & mutations).
3
+ The `schema.json` and `schema.py` files are auto-generated. **Do not edit them directly**!
4
+
5
+ If you need to customize the schema, see below. Refer to the
6
+ [CONTRIBUTING.md](../../CONTRIBUTING.md) for general development guidelines.
7
+
8
+ ## Schema Customizations
9
+
10
+ The generated `schema.py` is automatically modified during the build process to apply the following
11
+ customizations. This is done via `sed` commands in the [Makefile](../../Makefile), but if we need to
12
+ get fancier, we just can update the `customize-schema` target there to call whatever we need to do.
13
+
14
+ ### Connection Type Fix
15
+
16
+ The `Connection` class is changed from `sgqlc.types.relay.Connection` to `sgqlc.types.Type`.
17
+
18
+ **Why:** sgqlc automatically makes all types ending in "Connection" inherit from `relay.Connection`,
19
+ which makes `Connection` not a valid field type. This causes requests to fail when attempting to
20
+ resolve it. Changing it to inherit from `sgqlc.types.Type` fixes this issue.
21
+
22
+ [Related PR](https://github.com/monte-carlo-data/python-sdk/pull/63)
23
+
24
+ ### Backward-Compatible Enums
25
+
26
+ All GraphQL enum types use `pycarlo.lib.types.Enum` instead of `sgqlc.types.Enum`. This custom enum
27
+ class gracefully handles unknown enum values by returning them as strings instead of raising errors.
28
+
29
+ **Why:** When new enum values are added to the Monte Carlo API, older SDK versions would crash when
30
+ deserializing responses containing these new values. Our custom Enum prevents this by:
31
+
32
+ - Returning unknown values as plain strings (same type as known values)
33
+ - Logging a warning when unknown values are encountered
34
+
35
+ See [pycarlo/lib/types.py](types.py) for implementation details.