pycarlo 0.12.24__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.

Files changed (48) hide show
  1. pycarlo/__init__.py +0 -0
  2. pycarlo/common/__init__.py +31 -0
  3. pycarlo/common/errors.py +31 -0
  4. pycarlo/common/files.py +78 -0
  5. pycarlo/common/http.py +36 -0
  6. pycarlo/common/mcon.py +26 -0
  7. pycarlo/common/retries.py +129 -0
  8. pycarlo/common/settings.py +89 -0
  9. pycarlo/common/utils.py +51 -0
  10. pycarlo/core/__init__.py +10 -0
  11. pycarlo/core/client.py +267 -0
  12. pycarlo/core/endpoint.py +289 -0
  13. pycarlo/core/operations.py +25 -0
  14. pycarlo/core/session.py +127 -0
  15. pycarlo/features/__init__.py +10 -0
  16. pycarlo/features/circuit_breakers/__init__.py +3 -0
  17. pycarlo/features/circuit_breakers/exceptions.py +10 -0
  18. pycarlo/features/circuit_breakers/service.py +346 -0
  19. pycarlo/features/dbt/__init__.py +3 -0
  20. pycarlo/features/dbt/dbt_importer.py +208 -0
  21. pycarlo/features/dbt/queries.py +31 -0
  22. pycarlo/features/exceptions.py +18 -0
  23. pycarlo/features/metadata/__init__.py +32 -0
  24. pycarlo/features/metadata/asset_allow_block_list.py +22 -0
  25. pycarlo/features/metadata/asset_filters_container.py +79 -0
  26. pycarlo/features/metadata/base_allow_block_list.py +137 -0
  27. pycarlo/features/metadata/metadata_allow_block_list.py +94 -0
  28. pycarlo/features/metadata/metadata_filters_container.py +262 -0
  29. pycarlo/features/pii/__init__.py +5 -0
  30. pycarlo/features/pii/constants.py +3 -0
  31. pycarlo/features/pii/pii_filterer.py +179 -0
  32. pycarlo/features/pii/queries.py +20 -0
  33. pycarlo/features/pii/service.py +56 -0
  34. pycarlo/features/user/__init__.py +4 -0
  35. pycarlo/features/user/exceptions.py +10 -0
  36. pycarlo/features/user/models.py +9 -0
  37. pycarlo/features/user/queries.py +13 -0
  38. pycarlo/features/user/service.py +71 -0
  39. pycarlo/lib/README.md +35 -0
  40. pycarlo/lib/__init__.py +0 -0
  41. pycarlo/lib/schema.json +210020 -0
  42. pycarlo/lib/schema.py +82620 -0
  43. pycarlo/lib/types.py +68 -0
  44. pycarlo-0.12.24.dist-info/LICENSE +201 -0
  45. pycarlo-0.12.24.dist-info/METADATA +249 -0
  46. pycarlo-0.12.24.dist-info/RECORD +48 -0
  47. pycarlo-0.12.24.dist-info/WHEEL +5 -0
  48. pycarlo-0.12.24.dist-info/top_level.txt +1 -0
pycarlo/__init__.py ADDED
File without changes
@@ -0,0 +1,31 @@
1
+ import logging
2
+
3
+ from pycarlo.common.files import BytesFileReader, JsonFileReader, TextFileReader
4
+ from pycarlo.common.mcon import MCONParser, ParsedMCON
5
+ from pycarlo.common.settings import MCD_VERBOSE_ERRORS
6
+
7
+
8
+ def get_logger(name: str) -> logging.Logger:
9
+ """
10
+ Returns a logger with the specified name.
11
+
12
+ :param name: Name of the logger.
13
+ """
14
+
15
+ logger = logging.getLogger(name)
16
+ handler = logging.StreamHandler()
17
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
18
+ handler.setFormatter(formatter)
19
+ logger.addHandler(handler)
20
+ logger.setLevel(logging.DEBUG if MCD_VERBOSE_ERRORS else logging.CRITICAL)
21
+ return logger
22
+
23
+
24
+ __all__ = [
25
+ "BytesFileReader",
26
+ "JsonFileReader",
27
+ "MCONParser",
28
+ "ParsedMCON",
29
+ "TextFileReader",
30
+ "get_logger",
31
+ ]
@@ -0,0 +1,31 @@
1
+ from typing import Dict, Mapping, Optional, Union
2
+
3
+
4
+ class GqlError(Exception):
5
+ def __init__(
6
+ self,
7
+ body: Optional[Union[Dict, str]] = None,
8
+ headers: Optional[Mapping] = None,
9
+ message: str = "",
10
+ status_code: int = 0,
11
+ summary: str = "",
12
+ retryable: Optional[bool] = None,
13
+ ):
14
+ self.body = body
15
+ self.headers = headers
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.summary = summary
19
+ self.retryable = status_code >= 500 if retryable is None else retryable
20
+ super(GqlError, self).__init__()
21
+
22
+ def __str__(self) -> str:
23
+ return self.summary
24
+
25
+
26
+ class InvalidSessionError(Exception):
27
+ pass
28
+
29
+
30
+ class InvalidConfigFileError(Exception):
31
+ pass
@@ -0,0 +1,78 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Callable, Dict, Generic, TypeVar, Union
4
+
5
+ # file reader return type
6
+ T = TypeVar("T")
7
+
8
+
9
+ class FileReader(Generic[T]):
10
+ """
11
+ Utility for reading a local file. Return type is determined by the given `decoder` function.
12
+ """
13
+
14
+ def __init__(self, path: Union[Path, str], decoder: Callable[[bytes], T]):
15
+ """
16
+ :param path: local file path
17
+ :param decoder: function that translates file content as bytes into an object of type T
18
+ """
19
+ self._path = to_path(path)
20
+ self._decoder = decoder
21
+
22
+ def read(self) -> T:
23
+ """
24
+ Read local file.
25
+
26
+ :return: contents of file, represented as type T
27
+ """
28
+ with open(self._path, "rb") as file:
29
+ content = file.read()
30
+ return self._decoder(content)
31
+
32
+
33
+ class BytesFileReader(FileReader[bytes]):
34
+ """
35
+ Utility for reading a local file as bytes.
36
+ """
37
+
38
+ def __init__(self, path: Union[Path, str]):
39
+ """
40
+ :param path: local file path
41
+ """
42
+ super().__init__(path=path, decoder=lambda b: b)
43
+
44
+
45
+ class JsonFileReader(FileReader[Dict]):
46
+ """
47
+ Utility for reading a local JSON file as a dictionary.
48
+ """
49
+
50
+ def __init__(self, path: Union[Path, str]):
51
+ """
52
+ :param path: local file path
53
+ """
54
+ super().__init__(path=path, decoder=lambda b: json.loads(b))
55
+
56
+
57
+ class TextFileReader(FileReader[str]):
58
+ """
59
+ Utility for reading a local file as a string.
60
+ """
61
+
62
+ def __init__(self, path: Union[Path, str], encoding: str = "utf-8"):
63
+ """
64
+ :param path: local file path
65
+ :param encoding: character encoding to use when translating file content to a string
66
+ """
67
+ super().__init__(path=path, decoder=lambda b: b.decode(encoding))
68
+
69
+
70
+ def to_path(path: Union[Path, str]) -> Path:
71
+ """
72
+ Simple function to normalize a file path passed as either a string or pathlib.Path to a
73
+ pathlib.Path instance.
74
+
75
+ :param path: local file path
76
+ :return: local file path represented as an instance of pathlib.Path
77
+ """
78
+ return path if isinstance(path, Path) else Path(path)
pycarlo/common/http.py ADDED
@@ -0,0 +1,36 @@
1
+ import json
2
+ from typing import Dict, Union
3
+
4
+ import requests
5
+
6
+ from pycarlo.common.retries import ExponentialBackoffJitter, retry_with_backoff
7
+ from pycarlo.common.settings import DEFAULT_RETRY_INITIAL_WAIT_TIME, DEFAULT_RETRY_MAX_WAIT_TIME
8
+
9
+
10
+ @retry_with_backoff(
11
+ backoff=ExponentialBackoffJitter(DEFAULT_RETRY_INITIAL_WAIT_TIME, DEFAULT_RETRY_MAX_WAIT_TIME),
12
+ exceptions=(requests.exceptions.ConnectionError, requests.exceptions.Timeout),
13
+ )
14
+ def upload(
15
+ url: str,
16
+ content: Union[bytes, str, Dict],
17
+ method: str = "post",
18
+ encoding: str = "utf-8",
19
+ ):
20
+ """
21
+ Upload a file to a given URL.
22
+
23
+ :param url: URL to use for upload
24
+ :param content: file content
25
+ :param method: HTTP method (e.g. 'post' or 'put')
26
+ :param encoding: character encoding to use (unless raw bytes are provided)
27
+ """
28
+ if isinstance(content, str):
29
+ data = content.encode(encoding)
30
+ elif isinstance(content, dict):
31
+ data = json.dumps(content).encode(encoding)
32
+ else:
33
+ data = content
34
+
35
+ response = requests.request(method=method, url=url, data=data)
36
+ response.raise_for_status()
pycarlo/common/mcon.py ADDED
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+
6
+ @dataclass
7
+ class ParsedMCON:
8
+ account_id: UUID
9
+ resource_id: UUID
10
+ object_type: str
11
+ object_id: str
12
+
13
+
14
+ class MCONParser:
15
+ @staticmethod
16
+ def parse_mcon(mcon: Optional[str]) -> Optional[ParsedMCON]:
17
+ if not mcon or not mcon.startswith("MCON++"):
18
+ return None
19
+
20
+ mcon_parts = mcon.split("++", 4)
21
+ return ParsedMCON(
22
+ account_id=UUID(mcon_parts[1]),
23
+ resource_id=UUID(mcon_parts[2]),
24
+ object_type=mcon_parts[3],
25
+ object_id=mcon_parts[4],
26
+ )
@@ -0,0 +1,129 @@
1
+ import random
2
+ import time
3
+ from abc import ABC, abstractmethod
4
+ from functools import wraps
5
+ from typing import Any, Callable, Generator, Optional, Tuple, Type, Union
6
+
7
+
8
+ class Backoff(ABC):
9
+ """
10
+ Backoff is an abstract class that dictates a retry strategy.
11
+ It contains an abstract method `backoff` that returns a calculated delay based on the provided
12
+ attempt.
13
+ """
14
+
15
+ def __init__(self, start: float, maximum: float):
16
+ """
17
+ Defines a new backoff retry strategy.
18
+
19
+ :param start: the scaling factor for any calculated delays. Must be >= 0.
20
+ :param maximum: defines a cap on the calculated delays to prevent prohibitively long waits
21
+ that could time out.
22
+ """
23
+ if start < 0:
24
+ raise ValueError("start must be >= 0")
25
+ self.start = start
26
+ self.maximum = maximum
27
+
28
+ @abstractmethod
29
+ def backoff(self, attempt: int) -> float:
30
+ pass
31
+
32
+ def delays(self) -> Generator[float, None, None]:
33
+ """
34
+ Generates a duration of time to delay for each successive call based on the configured
35
+ backoff strategy.
36
+
37
+ :return: a generator that yields the next delay duration.
38
+ """
39
+ retries = 0
40
+ max_retries = 100 # Safety limit to prevent infinite loops
41
+
42
+ while retries < max_retries:
43
+ duration = self.backoff(retries)
44
+ # duration might be 0 when start == 0.
45
+ # In that case, retry once immediately, and then on maximum.
46
+ if duration >= self.maximum or duration <= 0:
47
+ break
48
+ yield duration
49
+ retries += 1
50
+
51
+ yield self.maximum
52
+
53
+
54
+ def retry_with_backoff(
55
+ backoff: "Backoff",
56
+ exceptions: Union[Type[Exception], Tuple[Type[Exception], ...]],
57
+ should_retry: Optional[Callable[[Exception], bool]] = None,
58
+ ) -> Callable[[Callable], Callable]:
59
+ """
60
+ A decorator to retry a function based on the :param:`backoff` provided
61
+ if any of the provided :param:`exceptions` are raised and the :param:`should_retry`
62
+ condition is met.
63
+
64
+ :param backoff: the retry strategy to employ.
65
+ :param exceptions: the exceptions that should trigger the retry. Can be further customized by
66
+ defining a custom attribute `retryable` on the exception class. The retries
67
+ are abandoned if retryable returns False.
68
+ :param should_retry: Optional callable to further determine whether to retry on an exception.
69
+ Takes an exception and returns a boolean. If `None`, all given exceptions
70
+ are retried.
71
+ :return: The same result the decorated function returns.
72
+ """
73
+
74
+ def _retry(func: Callable) -> Callable:
75
+ @wraps(func)
76
+ def _impl(*args: Any, **kwargs: Any) -> Any:
77
+ delays = backoff.delays()
78
+ while True:
79
+ try:
80
+ return func(*args, **kwargs)
81
+ except exceptions as exception:
82
+ # If the exception is marked as NOT retryable, stop now.
83
+ retryable = getattr(exception, "retryable", True)
84
+ if not retryable:
85
+ raise exception
86
+ # If a custom should_retry function is provided, call it to determine
87
+ # if we should retry or not. Otherwise, default to the retryable value.
88
+ current_should_retry = should_retry or (lambda _: retryable)
89
+ if not current_should_retry(exception):
90
+ raise exception
91
+ try:
92
+ delay = next(delays)
93
+ except StopIteration:
94
+ raise exception
95
+ time.sleep(delay)
96
+
97
+ return _impl
98
+
99
+ return _retry
100
+
101
+
102
+ class ExponentialBackoff(Backoff):
103
+ """
104
+ A backoff strategy with an exponentially increasing delay in between attempts.
105
+ """
106
+
107
+ def exponential(self, attempt: int) -> float:
108
+ # Prevent overflow by using float arithmetic and limiting attempt size
109
+ if attempt > 1000: # Safety limit to prevent overflow
110
+ return self.maximum
111
+ return min(self.maximum, pow(2.0, float(attempt)) * self.start)
112
+
113
+ def backoff(self, attempt: int) -> float:
114
+ return self.exponential(attempt)
115
+
116
+
117
+ class ExponentialBackoffJitter(ExponentialBackoff):
118
+ """
119
+ An exponential backoff strategy with an added jitter that randomly spreads out the delays
120
+ uniformly while ensuring monotonically increasing delays.
121
+ """
122
+
123
+ def backoff(self, attempt: int) -> float:
124
+ base_delay = self.exponential(max(0, attempt - 1))
125
+ added_delay = self.exponential(max(0, attempt)) - base_delay
126
+ # Add jitter between 50% and 100% of the base delay to ensure monotonically increasing
127
+ # delays while still providing randomization
128
+ jitter_factor = random.uniform(0.5, 1.0)
129
+ return base_delay + (added_delay * jitter_factor)
@@ -0,0 +1,89 @@
1
+ import os
2
+ from enum import Enum
3
+ from pathlib import Path
4
+
5
+ """Environmental configuration"""
6
+
7
+ # Enable error logging.
8
+ MCD_VERBOSE_ERRORS = os.getenv("MCD_VERBOSE_ERRORS", False) in (True, "true", "True")
9
+
10
+ # MCD API endpoint.
11
+ MCD_API_ENDPOINT = os.getenv("MCD_API_ENDPOINT")
12
+
13
+ # Override MCD Default Profile when reading from the config-file in a session.
14
+ MCD_DEFAULT_PROFILE = os.getenv("MCD_DEFAULT_PROFILE")
15
+
16
+ # Override MCD API ID when creating a session.
17
+ MCD_DEFAULT_API_ID = os.getenv("MCD_DEFAULT_API_ID")
18
+
19
+ # Override MCD API Token when creating a session.
20
+ MCD_DEFAULT_API_TOKEN = os.getenv("MCD_DEFAULT_API_TOKEN")
21
+
22
+ # MCD ID header (for use in local development and testing)
23
+ MCD_USER_ID_HEADER = os.getenv("MCD_USER_ID_HEADER")
24
+
25
+ # dbt cloud API token
26
+ DBT_CLOUD_API_TOKEN = os.getenv("DBT_CLOUD_API_TOKEN")
27
+
28
+ # dbt cloud account ID
29
+ DBT_CLOUD_ACCOUNT_ID = os.getenv("DBT_CLOUD_ACCOUNT_ID")
30
+
31
+ """Internal Use"""
32
+
33
+ # Default API endpoint when not provided through env variable nor profile
34
+ DEFAULT_MCD_API_ENDPOINT = "https://api.getmontecarlo.com/graphql"
35
+
36
+ # Default Gateway endpoint used when no endpoint is provided through env var or profile
37
+ DEFAULT_MCD_IGW_ENDPOINT = "https://integrations.getmontecarlo.com"
38
+
39
+ # Name of the current package.
40
+ DEFAULT_PACKAGE_NAME = "pycarlo"
41
+
42
+ # Default config keys for the MC config file. Created via the CLI.
43
+ DEFAULT_MCD_API_ID_CONFIG_KEY = "mcd_id"
44
+ DEFAULT_MCD_API_TOKEN_CONFIG_KEY = "mcd_token"
45
+ DEFAULT_MCD_API_ENDPOINT_CONFIG_KEY = "mcd_api_endpoint"
46
+
47
+ # Default headers for the MC API.
48
+ DEFAULT_MCD_API_ID_HEADER = f"x-{DEFAULT_MCD_API_ID_CONFIG_KEY.replace('_', '-')}"
49
+ DEFAULT_MCD_API_TOKEN_HEADER = f"x-{DEFAULT_MCD_API_TOKEN_CONFIG_KEY.replace('_', '-')}"
50
+ DEFAULT_MCD_USER_ID_HEADER = "user-id"
51
+
52
+ # Default headers to trace and help identify requests. For debugging.
53
+ DEFAULT_MCD_SESSION_ID = "x-mcd-session-id" # Generally the session name.
54
+ DEFAULT_MCD_TRACE_ID = "x-mcd-trace-id"
55
+
56
+ # File name for profile configuration.
57
+ PROFILE_FILE_NAME = "profiles.ini"
58
+
59
+ # Default profile to be used.
60
+ DEFAULT_PROFILE_NAME = "default"
61
+
62
+ # Default path where any configuration files are written.
63
+ DEFAULT_CONFIG_PATH = os.path.join(str(Path.home()), ".mcd")
64
+
65
+ # Default initial wait time for retries in seconds.
66
+ DEFAULT_RETRY_INITIAL_WAIT_TIME = 0.25
67
+
68
+ # Default maximum wait time for retries in seconds.
69
+ DEFAULT_RETRY_MAX_WAIT_TIME = 10.0
70
+
71
+ # Default initial wait time for idempotent request retries in seconds.
72
+ DEFAULT_IDEMPOTENT_RETRY_INITIAL_WAIT_TIME = 4.0
73
+
74
+ # Default maximum wait time for idempotent request retries in seconds.
75
+ DEFAULT_IDEMPOTENT_RETRY_MAX_WAIT_TIME = 4 * pow(
76
+ 2, 4
77
+ ) # retry 4 times, max wait 64 seconds, total wait 124
78
+
79
+ # Default timeout for requests sent to Integration Gateway
80
+ DEFAULT_IGW_TIMEOUT_SECS = 10
81
+
82
+ # Additional request headers
83
+ HEADER_MCD_TELEMETRY_REASON = "x-mcd-telemetry-reason" # why the request was made
84
+ HEADER_MCD_TELEMETRY_SERVICE = "x-mcd-telemetry-service" # what service made the request
85
+
86
+
87
+ class RequestReason(Enum):
88
+ USER = "user" # request made directly by a user
89
+ SERVICE = "service" # request made by a service, on behalf of a user or automation
@@ -0,0 +1,51 @@
1
+ from collections.abc import Mapping
2
+ from functools import wraps
3
+ from typing import Any, Callable, List, Optional
4
+
5
+ from box import Box
6
+
7
+
8
+ def chunks(lst: List, n: int):
9
+ """Yield successive n-sized chunks from lst."""
10
+ for i in range(0, len(lst), n):
11
+ yield lst[i : i + n]
12
+
13
+
14
+ def boxify(
15
+ use_snakes: bool = False,
16
+ default_box_attr: Any = object(),
17
+ default_box: bool = False,
18
+ ) -> Callable:
19
+ """
20
+ Convenience decorator to convert a returned dictionary into a Box object for ease of use.
21
+
22
+ :param use_snakes: Convert camelCase into snake_case.
23
+ :param default_box_attr: Set default attribute to return.
24
+ :param default_box: Behave like a recursive default dict.
25
+ :return: Box object if original return was a Mapping (dictionary).
26
+ """
27
+
28
+ def _boxify(func: Callable):
29
+ @wraps(func)
30
+ def _impl(self: Any, *args: Any, **kwargs: Any) -> Optional[Box]:
31
+ dict_ = func(self, *args, **kwargs)
32
+ if dict_ and isinstance(dict_, Mapping):
33
+ return Box(
34
+ dict_,
35
+ camel_killer_box=use_snakes,
36
+ default_box_attr=default_box_attr,
37
+ default_box=default_box,
38
+ )
39
+ return dict_
40
+
41
+ return _impl
42
+
43
+ return _boxify
44
+
45
+
46
+ def truncate_string(unicode_str: str, max_size: int) -> str:
47
+ str_bytes = unicode_str.encode("utf-8")
48
+ if len(str_bytes) < max_size:
49
+ return unicode_str # keep the original string
50
+ else:
51
+ return str_bytes[:max_size].decode("utf-8", errors="ignore")
@@ -0,0 +1,10 @@
1
+ from pycarlo.core.client import Client
2
+ from pycarlo.core.operations import Mutation, Query
3
+ from pycarlo.core.session import Session
4
+
5
+ __all__ = [
6
+ "Client",
7
+ "Mutation",
8
+ "Query",
9
+ "Session",
10
+ ]