gooddata-pipelines 1.52.1.dev1__py3-none-any.whl → 1.52.1.dev3__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 gooddata-pipelines might be problematic. Click here for more details.

@@ -10,6 +10,10 @@ from .backup_and_restore.models.storage import (
10
10
  S3StorageConfig,
11
11
  StorageType,
12
12
  )
13
+ from .backup_and_restore.restore_manager import (
14
+ RestoreManager,
15
+ WorkspaceToRestore,
16
+ )
13
17
  from .backup_and_restore.storage.local_storage import LocalStorage
14
18
  from .backup_and_restore.storage.s3_storage import S3Storage
15
19
 
@@ -57,6 +61,8 @@ from .provisioning.generic.provision import provision
57
61
 
58
62
  __all__ = [
59
63
  "BackupManager",
64
+ "RestoreManager",
65
+ "WorkspaceToRestore",
60
66
  "BackupRestoreConfig",
61
67
  "StorageType",
62
68
  "LocalStorage",
@@ -167,6 +167,17 @@ class ApiMethods:
167
167
  endpoint = f"/layout/workspaces/{workspace_id}/userDataFilters"
168
168
  return self._get(endpoint)
169
169
 
170
+ def put_user_data_filters(
171
+ self, workspace_id: str, user_data_filters: dict[str, Any]
172
+ ) -> requests.Response:
173
+ """Puts the user data filters into GoodData workspace."""
174
+ headers = {**self.headers, "Content-Type": "application/json"}
175
+ return self._put(
176
+ f"/layout/workspaces/{workspace_id}/userDataFilters",
177
+ user_data_filters,
178
+ headers,
179
+ )
180
+
170
181
  def get_automations(self, workspace_id: str) -> requests.Response:
171
182
  """Gets the automations for a given workspace."""
172
183
  endpoint = (
@@ -174,6 +185,22 @@ class ApiMethods:
174
185
  )
175
186
  return self._get(endpoint)
176
187
 
188
+ def post_automation(
189
+ self, workspace_id: str, automation: dict[str, Any]
190
+ ) -> requests.Response:
191
+ """Posts an automation for a given workspace."""
192
+ endpoint = f"/entities/workspaces/{workspace_id}/automations"
193
+ return self._post(endpoint, automation)
194
+
195
+ def delete_automation(
196
+ self, workspace_id: str, automation_id: str
197
+ ) -> requests.Response:
198
+ """Deletes an automation for a given workspace."""
199
+ endpoint = (
200
+ f"/entities/workspaces/{workspace_id}/automations/{automation_id}"
201
+ )
202
+ return self._delete(endpoint)
203
+
177
204
  def get_all_metrics(self, workspace_id: str) -> requests.Response:
178
205
  """Get all metrics from the specified workspace.
179
206
 
@@ -1,23 +1,20 @@
1
1
  # (C) 2025 GoodData Corporation
2
2
 
3
- import json
4
3
  import os
5
4
  import shutil
6
5
  import tempfile
7
6
  import time
8
7
  import traceback
9
8
  from pathlib import Path
10
- from typing import Any, Type
9
+ from typing import Any
11
10
 
12
11
  import attrs
13
12
  import requests
14
- import yaml
15
- from gooddata_sdk.utils import PROFILES_FILE_PATH, profile_content
16
13
 
17
- from gooddata_pipelines.api.gooddata_api_wrapper import GoodDataApi
18
14
  from gooddata_pipelines.backup_and_restore.backup_input_processor import (
19
15
  BackupInputProcessor,
20
16
  )
17
+ from gooddata_pipelines.backup_and_restore.base_manager import BaseManager
21
18
  from gooddata_pipelines.backup_and_restore.constants import (
22
19
  BackupSettings,
23
20
  DirNames,
@@ -25,18 +22,10 @@ from gooddata_pipelines.backup_and_restore.constants import (
25
22
  from gooddata_pipelines.backup_and_restore.models.input_type import InputType
26
23
  from gooddata_pipelines.backup_and_restore.models.storage import (
27
24
  BackupRestoreConfig,
28
- StorageType,
29
25
  )
30
26
  from gooddata_pipelines.backup_and_restore.storage.base_storage import (
31
27
  BackupStorage,
32
28
  )
33
- from gooddata_pipelines.backup_and_restore.storage.local_storage import (
34
- LocalStorage,
35
- )
36
- from gooddata_pipelines.backup_and_restore.storage.s3_storage import (
37
- S3Storage,
38
- )
39
- from gooddata_pipelines.logger import LogObserver
40
29
  from gooddata_pipelines.utils.rate_limiter import RateLimiter
41
30
 
42
31
 
@@ -45,16 +34,12 @@ class BackupBatch:
45
34
  list_of_ids: list[str]
46
35
 
47
36
 
48
- class BackupManager:
37
+ class BackupManager(BaseManager):
49
38
  storage: BackupStorage
50
39
 
51
40
  def __init__(self, host: str, token: str, config: BackupRestoreConfig):
52
- self._api = GoodDataApi(host, token)
53
- self.logger = LogObserver()
54
-
55
- self.config = config
41
+ super().__init__(host, token, config)
56
42
 
57
- self.storage = self._get_storage(self.config)
58
43
  self.org_id = self._api.get_organization_id()
59
44
 
60
45
  self.loader = BackupInputProcessor(self._api, self.config.api_page_size)
@@ -63,39 +48,6 @@ class BackupManager:
63
48
  calls_per_second=self.config.api_calls_per_second,
64
49
  )
65
50
 
66
- @classmethod
67
- def create(
68
- cls: Type["BackupManager"],
69
- config: BackupRestoreConfig,
70
- host: str,
71
- token: str,
72
- ) -> "BackupManager":
73
- """Creates a backup worker instance using the provided host and token."""
74
- return cls(host=host, token=token, config=config)
75
-
76
- @classmethod
77
- def create_from_profile(
78
- cls: Type["BackupManager"],
79
- config: BackupRestoreConfig,
80
- profile: str = "default",
81
- profiles_path: Path = PROFILES_FILE_PATH,
82
- ) -> "BackupManager":
83
- """Creates a backup worker instance using a GoodData profile file."""
84
- content = profile_content(profile, profiles_path)
85
- return cls(**content, config=config)
86
-
87
- @staticmethod
88
- def _get_storage(conf: BackupRestoreConfig) -> BackupStorage:
89
- """Returns the storage class based on the storage type."""
90
- if conf.storage_type == StorageType.S3:
91
- return S3Storage(conf)
92
- elif conf.storage_type == StorageType.LOCAL:
93
- return LocalStorage(conf)
94
- else:
95
- raise RuntimeError(
96
- f'Unsupported storage type "{conf.storage_type.value}".'
97
- )
98
-
99
51
  def get_user_data_filters(self, ws_id: str) -> dict:
100
52
  """Returns the user data filters for the specified workspace."""
101
53
  with self._api_rate_limiter:
@@ -133,19 +85,13 @@ class BackupManager:
133
85
  "user_data_filters",
134
86
  filter["id"] + ".yaml",
135
87
  )
136
- self._write_to_yaml(udf_file_path, filter)
88
+ self.yaml_utils.dump(udf_file_path, filter)
137
89
 
138
90
  @staticmethod
139
91
  def _move_folder(source: Path, destination: Path) -> None:
140
92
  """Moves the source folder to the destination."""
141
93
  shutil.move(source, destination)
142
94
 
143
- @staticmethod
144
- def _write_to_yaml(path: str, source: Any) -> None:
145
- """Writes the source to a YAML file."""
146
- with open(path, "w") as outfile:
147
- yaml.dump(source, outfile)
148
-
149
95
  def _get_automations_from_api(self, workspace_id: str) -> Any:
150
96
  """Returns automations for the workspace as JSON."""
151
97
  with self._api_rate_limiter:
@@ -182,8 +128,7 @@ class BackupManager:
182
128
 
183
129
  # Store the automations in a JSON file
184
130
  if len(automations["data"]) > 0:
185
- with open(automations_file_path, "w") as f:
186
- json.dump(automations, f)
131
+ self.json_utils.dump(automations_file_path, automations)
187
132
 
188
133
  def store_declarative_filter_views(
189
134
  self, export_path: Path, workspace_id: str
@@ -0,0 +1,73 @@
1
+ # (C) 2025 GoodData Corporation
2
+
3
+ import abc
4
+ from pathlib import Path
5
+ from typing import Type, TypeVar
6
+
7
+ from gooddata_sdk.utils import PROFILES_FILE_PATH, profile_content
8
+
9
+ from gooddata_pipelines.api.gooddata_api_wrapper import GoodDataApi
10
+ from gooddata_pipelines.backup_and_restore.models.storage import (
11
+ BackupRestoreConfig,
12
+ StorageType,
13
+ )
14
+ from gooddata_pipelines.backup_and_restore.storage.base_storage import (
15
+ BackupStorage,
16
+ )
17
+ from gooddata_pipelines.backup_and_restore.storage.local_storage import (
18
+ LocalStorage,
19
+ )
20
+ from gooddata_pipelines.backup_and_restore.storage.s3_storage import S3Storage
21
+ from gooddata_pipelines.logger import LogObserver
22
+ from gooddata_pipelines.utils.file_utils import JsonUtils, YamlUtils
23
+
24
+ ManagerT = TypeVar("ManagerT", bound="BaseManager")
25
+
26
+
27
+ class BaseManager(abc.ABC):
28
+ """Base class to provide constructors for backup and restore managers."""
29
+
30
+ storage: BackupStorage
31
+
32
+ def __init__(self, host: str, token: str, config: BackupRestoreConfig):
33
+ self.config = config
34
+
35
+ self._api: GoodDataApi = GoodDataApi(host, token)
36
+ self.logger: LogObserver = LogObserver()
37
+
38
+ self.storage = self._get_storage(self.config)
39
+
40
+ self.yaml_utils = YamlUtils()
41
+ self.json_utils = JsonUtils()
42
+
43
+ def _get_storage(self, conf: BackupRestoreConfig) -> BackupStorage:
44
+ """Returns the storage class based on the storage type."""
45
+ if conf.storage_type == StorageType.S3:
46
+ return S3Storage(conf)
47
+ elif conf.storage_type == StorageType.LOCAL:
48
+ return LocalStorage(conf)
49
+ else:
50
+ raise RuntimeError(
51
+ f'Unsupported storage type "{conf.storage_type.value}".'
52
+ )
53
+
54
+ @classmethod
55
+ def create(
56
+ cls: Type[ManagerT],
57
+ config: BackupRestoreConfig,
58
+ host: str,
59
+ token: str,
60
+ ) -> ManagerT:
61
+ """Creates a backup worker instance using the provided host and token."""
62
+ return cls(host=host, token=token, config=config)
63
+
64
+ @classmethod
65
+ def create_from_profile(
66
+ cls: Type[ManagerT],
67
+ config: BackupRestoreConfig,
68
+ profile: str = "default",
69
+ profiles_path: Path = PROFILES_FILE_PATH,
70
+ ) -> ManagerT:
71
+ """Creates a backup worker instance using a GoodData profile file."""
72
+ content = profile_content(profile, profiles_path)
73
+ return cls(host=content["host"], token=content["token"], config=config)
@@ -23,13 +23,14 @@ class DirNames:
23
23
 
24
24
  @attrs.frozen
25
25
  class ApiDefaults:
26
- DEFAULT_PAGE_SIZE = 100
27
- DEFAULT_BATCH_SIZE = 100
28
- DEFAULT_API_CALLS_PER_SECOND = 1.0
26
+ PAGE_SIZE = 100
27
+ BATCH_SIZE = 100
28
+ CALLS_PER_SECOND = 1.0
29
29
 
30
30
 
31
31
  @attrs.frozen
32
- class BackupSettings(ApiDefaults):
32
+ class BackupSettings:
33
+ API = ApiDefaults()
33
34
  MAX_RETRIES = 3
34
35
  RETRY_DELAY = 5 # seconds
35
36
  TIMESTAMP_SDK_FOLDER = (
@@ -1,10 +1,10 @@
1
1
  # (C) 2025 GoodData Corporation
2
2
 
3
3
  from enum import Enum
4
- from typing import Annotated, TypeAlias, Optional
4
+ from typing import Annotated
5
5
 
6
6
  import yaml
7
- from pydantic import BaseModel, Field
7
+ from pydantic import BaseModel, Field, model_validator
8
8
 
9
9
  from gooddata_pipelines.backup_and_restore.constants import BackupSettings
10
10
 
@@ -17,18 +17,32 @@ class StorageType(Enum):
17
17
 
18
18
 
19
19
  class S3StorageConfig(BaseModel):
20
- """Configuration for S3 storage."""
20
+ """Configuration for S3 storage.
21
+
22
+ Can be created using the following constructor methods:
23
+ - `from_iam_role`
24
+ - `from_aws_credentials`
25
+ - `from_aws_profile`
26
+ """
21
27
 
22
28
  backup_path: str
23
29
  bucket: str
24
- profile: Optional[str] = None
25
- aws_access_key_id: Optional[str] = None
26
- aws_secret_access_key: Optional[str] = None
27
- aws_default_region: Optional[str] = "us-east-1"
30
+ profile: str | None = None
31
+ aws_access_key_id: str | None = None
32
+ aws_secret_access_key: str | None = None
33
+ aws_default_region: str = "us-east-1"
28
34
 
29
35
  @classmethod
30
36
  def from_iam_role(cls, backup_path: str, bucket: str) -> "S3StorageConfig":
31
- """Use default IAM role or environment credentials."""
37
+ """Use default IAM role or environment credentials.
38
+
39
+ Args:
40
+ backup_path: The path to the backup directory.
41
+ bucket: The name of the S3 bucket.
42
+
43
+ Returns:
44
+ S3StorageConfig: The S3 storage configuration.
45
+ """
32
46
  return cls(backup_path=backup_path, bucket=bucket)
33
47
 
34
48
  @classmethod
@@ -40,7 +54,18 @@ class S3StorageConfig(BaseModel):
40
54
  aws_secret_access_key: str,
41
55
  aws_default_region: str,
42
56
  ) -> "S3StorageConfig":
43
- """Use explicit AWS access keys and region."""
57
+ """Use explicit AWS access keys and region.
58
+
59
+ Args:
60
+ backup_path: The path to the backup directory.
61
+ bucket: The name of the S3 bucket.
62
+ aws_access_key_id: The AWS access key ID.
63
+ aws_secret_access_key: The AWS secret access key.
64
+ aws_default_region: The AWS default region.
65
+
66
+ Returns:
67
+ S3StorageConfig: The S3 storage configuration.
68
+ """
44
69
  return cls(
45
70
  backup_path=backup_path,
46
71
  bucket=bucket,
@@ -53,46 +78,79 @@ class S3StorageConfig(BaseModel):
53
78
  def from_aws_profile(
54
79
  cls, backup_path: str, bucket: str, profile: str
55
80
  ) -> "S3StorageConfig":
56
- """Use a named AWS CLI profile."""
81
+ """Use a named AWS CLI profile.
82
+
83
+ Args:
84
+ backup_path: The path to the backup directory.
85
+ bucket: The name of the S3 bucket.
86
+ profile: The name of the AWS profile.
87
+
88
+ Returns:
89
+ S3StorageConfig: The S3 storage configuration.
90
+ """
57
91
  return cls(backup_path=backup_path, bucket=bucket, profile=profile)
58
92
 
59
93
 
60
94
  class LocalStorageConfig(BaseModel):
61
95
  """Placeholder for local storage config."""
62
96
 
63
-
64
- StorageConfig: TypeAlias = S3StorageConfig | LocalStorageConfig
97
+ backup_path: str = Field(default="local_backups")
65
98
 
66
99
 
67
100
  class BackupRestoreConfig(BaseModel):
68
- """Configuration for backup and restore."""
69
-
70
- storage_type: StorageType
71
- storage: StorageConfig | None = Field(default=None)
101
+ """Configuration for backup and restore.
102
+
103
+ Args:
104
+ storage_type: The type of storage to use. Defaults to `StorageType.LOCAL`.
105
+ storage: Storage configuration. Either `S3StorageConfig` or `LocalStorageConfig`. Defaults to `LocalStorageConfig()`.
106
+ api_page_size: The page size for fetching workspace relationships. Defaults to `BackupSettings.API.PAGE_SIZE`.
107
+ batch_size: The batch size for fetching workspace relationships. Defaults to `BackupSettings.API.BATCH_SIZE`.
108
+ api_calls_per_second: The maximum API calls per second (rate limiting). Defaults to `BackupSettings.API.CALLS_PER_SECOND`.
109
+ """
110
+
111
+ storage_type: StorageType = Field(default=StorageType.LOCAL)
112
+ storage: S3StorageConfig | LocalStorageConfig = Field(
113
+ default_factory=LocalStorageConfig
114
+ )
72
115
  api_page_size: Annotated[
73
116
  int,
74
117
  Field(
75
118
  gt=0,
76
119
  description="Page size must be greater than 0",
77
120
  ),
78
- ] = Field(default=BackupSettings.DEFAULT_PAGE_SIZE)
121
+ ] = Field(default=BackupSettings.API.PAGE_SIZE)
79
122
  batch_size: Annotated[
80
123
  int,
81
124
  Field(
82
125
  gt=0,
83
126
  description="Batch size must be greater than 0",
84
127
  ),
85
- ] = Field(default=BackupSettings.DEFAULT_BATCH_SIZE)
128
+ ] = Field(default=BackupSettings.API.BATCH_SIZE)
86
129
  api_calls_per_second: Annotated[
87
130
  float,
88
131
  Field(
89
132
  gt=0,
90
133
  description="Maximum API calls per second (rate limiting)",
91
134
  ),
92
- ] = Field(default=BackupSettings.DEFAULT_API_CALLS_PER_SECOND)
135
+ ] = Field(default=BackupSettings.API.CALLS_PER_SECOND)
93
136
 
94
137
  @classmethod
95
138
  def from_yaml(cls, conf_path: str) -> "BackupRestoreConfig":
96
139
  with open(conf_path, "r") as stream:
97
140
  conf: dict = yaml.safe_load(stream)
98
141
  return cls(**conf)
142
+
143
+ @model_validator(mode="after")
144
+ def validate_storage(self) -> "BackupRestoreConfig":
145
+ """Check that the storage gets correct configuration when using S3 storage"""
146
+ if self.storage_type == StorageType.S3:
147
+ if not isinstance(self.storage, S3StorageConfig):
148
+ raise ValueError(
149
+ "S3 storage must be configured with S3StorageConfig object"
150
+ )
151
+ elif self.storage_type == StorageType.LOCAL:
152
+ if not isinstance(self.storage, LocalStorageConfig):
153
+ raise ValueError(
154
+ "Local storage must be configured with LocalStorageConfig object"
155
+ )
156
+ return self
@@ -0,0 +1,266 @@
1
+ # (C) 2025 GoodData Corporation
2
+
3
+ import os
4
+ import tempfile
5
+ import zipfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import attrs
10
+ from gooddata_sdk.catalog.workspace.declarative_model.workspace.analytics_model.analytics_model import (
11
+ CatalogDeclarativeAnalytics,
12
+ )
13
+ from gooddata_sdk.catalog.workspace.declarative_model.workspace.automation import (
14
+ CatalogDeclarativeAutomation,
15
+ )
16
+ from gooddata_sdk.catalog.workspace.declarative_model.workspace.logical_model.ldm import (
17
+ CatalogDeclarativeModel,
18
+ )
19
+ from gooddata_sdk.catalog.workspace.declarative_model.workspace.workspace import (
20
+ CatalogDeclarativeFilterView,
21
+ )
22
+ from pydantic import BaseModel, ConfigDict
23
+
24
+ from gooddata_pipelines.backup_and_restore.base_manager import BaseManager
25
+ from gooddata_pipelines.backup_and_restore.constants import DirNames
26
+ from gooddata_pipelines.utils.decorators import log_and_reraise_exception
27
+
28
+
29
+ @attrs.define
30
+ class WorkspaceModel:
31
+ logical_data_model: CatalogDeclarativeModel
32
+ analytics_model: CatalogDeclarativeAnalytics
33
+
34
+
35
+ class WorkspaceToRestore(BaseModel):
36
+ """Workspace to restore.
37
+
38
+ Args:
39
+ id: The ID of the workspace to restore.
40
+ path: The path to the folder containing the `gooddata_layouts.zip` file
41
+ to restore. Should be a continuation of the `backup_path` specified
42
+ in the storage configuration. Typically, it would look something like
43
+ `organization_id/workspace_id/backup_timestamp`
44
+ """
45
+
46
+ model_config = ConfigDict(extra="forbid")
47
+
48
+ id: str
49
+ path: str
50
+
51
+
52
+ class RestoreManager(BaseManager):
53
+ """Restores previsouly created backups of workspace metadata."""
54
+
55
+ @log_and_reraise_exception("Failed to extract backup from zip archive.")
56
+ def _extract_zip_archive(
57
+ self, file_to_extract: Path, destination: Path
58
+ ) -> None:
59
+ """Extracts the backup from zip archive."""
60
+ with zipfile.ZipFile(file_to_extract, "r") as zip_ref:
61
+ zip_ref.extractall(destination)
62
+
63
+ def _check_workspace_is_valid(self, workspace_root_dir_path: Path) -> None:
64
+ """Checks if the workspace layout is valid."""
65
+ if (
66
+ not workspace_root_dir_path.exists()
67
+ or not workspace_root_dir_path.is_dir()
68
+ ):
69
+ self.logger.error(
70
+ "Invalid source path found upon backup fetch. "
71
+ f"Got {workspace_root_dir_path}. "
72
+ "Check if target zip contains gooddata_layouts directory."
73
+ )
74
+ raise RuntimeError("Invalid source path upon load.")
75
+
76
+ children = list(workspace_root_dir_path.iterdir())
77
+ am_path = workspace_root_dir_path / DirNames.AM
78
+ ldm_path = workspace_root_dir_path / DirNames.LDM
79
+ udf_path = workspace_root_dir_path / DirNames.UDF
80
+
81
+ if (
82
+ am_path not in children
83
+ or ldm_path not in children
84
+ or udf_path not in children
85
+ ):
86
+ self.logger.error(
87
+ f"{DirNames.AM} or {DirNames.LDM} directory missing in the "
88
+ + "workspace hierarchy. "
89
+ )
90
+ raise RuntimeError(
91
+ f"{DirNames.AM} or {DirNames.LDM} directory missing."
92
+ )
93
+
94
+ @log_and_reraise_exception("Failed to load workspace declaration.")
95
+ def _load_workspace_layout(
96
+ self, workspace_root_dir_path: Path
97
+ ) -> WorkspaceModel:
98
+ """Loads the workspace layout from the backup."""
99
+ sdk_catalog = self._api._sdk.catalog_workspace_content
100
+
101
+ ldm = sdk_catalog.load_ldm_from_disk(workspace_root_dir_path)
102
+ am = sdk_catalog.load_analytics_model_from_disk(workspace_root_dir_path)
103
+
104
+ return WorkspaceModel(logical_data_model=ldm, analytics_model=am)
105
+
106
+ @log_and_reraise_exception("Failed to load user data filters from folder.")
107
+ def _load_user_data_filters(self, workspace_root_dir_path: Path) -> dict:
108
+ user_data_filters: dict = {"userDataFilters": []}
109
+ user_data_filters_folder = os.path.join(
110
+ workspace_root_dir_path, DirNames.UDF
111
+ )
112
+ for filename in os.listdir(user_data_filters_folder):
113
+ file_path = os.path.join(user_data_filters_folder, filename)
114
+ user_data_filter = self.yaml_utils.safe_load(Path(file_path))
115
+ user_data_filters["userDataFilters"].append(user_data_filter)
116
+
117
+ return user_data_filters
118
+
119
+ @log_and_reraise_exception("Failed to put workspace layout into GoodData.")
120
+ def _put_workspace_layout(
121
+ self, workspace_id: str, workspace_model: WorkspaceModel
122
+ ) -> None:
123
+ """Puts the workspace layout into GoodData."""
124
+ self._api._sdk.catalog_workspace_content.put_declarative_ldm(
125
+ workspace_id, workspace_model.logical_data_model
126
+ )
127
+ self._api._sdk.catalog_workspace_content.put_declarative_analytics_model(
128
+ workspace_id, workspace_model.analytics_model
129
+ )
130
+
131
+ @log_and_reraise_exception("Failed to put user data filters.")
132
+ def _put_user_data_filters(
133
+ self, workspace_id: str, user_data_filters: dict
134
+ ) -> None:
135
+ """Puts the user data filters into GoodData workspace."""
136
+ response = self._api.put_user_data_filters(
137
+ workspace_id, user_data_filters
138
+ )
139
+ self._api.raise_if_response_not_ok(response)
140
+
141
+ def _load_and_put_filter_views(
142
+ self, workspace_id: str, workspace_root_dir_path: Path
143
+ ) -> None:
144
+ """Loads and puts filter views into GoodData workspace."""
145
+ filter_views: list[CatalogDeclarativeFilterView] = []
146
+ if not (workspace_root_dir_path / "filter_views").exists():
147
+ # Skip if the filter_views directory does not exist
148
+ return
149
+
150
+ for file in Path(workspace_root_dir_path / "filter_views").iterdir():
151
+ filter_view_content: dict[str, Any] = dict(
152
+ self.yaml_utils.safe_load(file)
153
+ )
154
+ filter_view: CatalogDeclarativeFilterView = (
155
+ CatalogDeclarativeFilterView.from_dict(filter_view_content)
156
+ )
157
+ filter_views.append(filter_view)
158
+
159
+ if filter_views:
160
+ self._api._sdk.catalog_workspace.put_declarative_filter_views(
161
+ workspace_id, filter_views
162
+ )
163
+
164
+ def _load_and_post_automations(
165
+ self, workspace_id: str, workspace_root_dir_path: Path
166
+ ) -> None:
167
+ """Loads automations from specified json file and creates them in the workspace."""
168
+ # Load automations from JSON
169
+ path_to_json: Path = Path(
170
+ workspace_root_dir_path, "automations", "automations.json"
171
+ )
172
+
173
+ # Both the folder and the file must exist, otherwise skip
174
+ if not (workspace_root_dir_path.exists() and path_to_json.exists()):
175
+ return
176
+
177
+ # Delete all automations from the workspace and restore the automations from the backup.
178
+ self._delete_all_automations(workspace_id)
179
+
180
+ data: dict = self.json_utils.load(path_to_json)
181
+ automations: list[dict] = data["data"]
182
+
183
+ for automation in automations:
184
+ self._post_automation(workspace_id, automation)
185
+
186
+ def _delete_all_automations(self, workspace_id: str) -> None:
187
+ """Deletes all automations in the workspace."""
188
+ automations: list[CatalogDeclarativeAutomation] = (
189
+ self._api._sdk.catalog_workspace.get_declarative_automations(
190
+ workspace_id
191
+ )
192
+ )
193
+ for automation in automations:
194
+ self._api.delete_automation(workspace_id, automation.id)
195
+
196
+ def _post_automation(self, workspace_id: str, automation: dict) -> None:
197
+ """Posts a scheduled export to the workspace."""
198
+ attributes: dict = automation["attributes"]
199
+ relationships: dict = automation["relationships"]
200
+ id: str = automation["id"]
201
+
202
+ if attributes.get("schedule"):
203
+ if attributes["schedule"].get("cronDescription"):
204
+ # The cron description attribute is causing a 500 ("No mapping found...")
205
+ # error. Known and reported issue.
206
+ del attributes["schedule"]["cronDescription"]
207
+
208
+ data = {
209
+ "data": {
210
+ "attributes": attributes,
211
+ "id": id,
212
+ "type": "automation",
213
+ "relationships": relationships,
214
+ }
215
+ }
216
+
217
+ response = self._api.post_automation(workspace_id, data)
218
+
219
+ if not response.ok:
220
+ self.logger.error(
221
+ f"Failed to post automation ({response.status_code}): {response.text}"
222
+ )
223
+
224
+ def _restore_backup(
225
+ self, workspace_to_restore: WorkspaceToRestore, tempdir_path: Path
226
+ ) -> None:
227
+ """Restores the backup of a workspace."""
228
+
229
+ zip_target = tempdir_path / f"{DirNames.LAYOUTS}.zip"
230
+ src_path = tempdir_path / DirNames.LAYOUTS
231
+
232
+ try:
233
+ self.storage.get_ws_declaration(
234
+ workspace_to_restore.path, str(zip_target)
235
+ )
236
+ self._extract_zip_archive(zip_target, tempdir_path)
237
+ self._check_workspace_is_valid(src_path)
238
+ workspace_model: WorkspaceModel = self._load_workspace_layout(
239
+ src_path
240
+ )
241
+ user_data_filters = self._load_user_data_filters(src_path)
242
+ self._put_workspace_layout(workspace_to_restore.id, workspace_model)
243
+ self._put_user_data_filters(
244
+ workspace_to_restore.id, user_data_filters
245
+ )
246
+ self._load_and_put_filter_views(workspace_to_restore.id, src_path)
247
+ self._load_and_post_automations(workspace_to_restore.id, src_path)
248
+ self.logger.info(
249
+ f"Finished backup restore of {workspace_to_restore.id} from {workspace_to_restore.path}."
250
+ )
251
+ except Exception as e:
252
+ self.logger.error(
253
+ f"Failed to restore backup of {workspace_to_restore.id} from {workspace_to_restore.path}. "
254
+ f"Error caused by {e.__class__.__name__}: {e}."
255
+ )
256
+
257
+ def restore(self, workspaces_to_restore: list[WorkspaceToRestore]) -> None:
258
+ """Restores the backups of workspaces.
259
+
260
+ Args:
261
+ workspaces_to_restore: List of workspaces to restore.
262
+ """
263
+ for workspace_to_restore in workspaces_to_restore:
264
+ with tempfile.TemporaryDirectory() as tempdir:
265
+ tempdir_path = Path(tempdir)
266
+ self._restore_backup(workspace_to_restore, tempdir_path)
@@ -12,7 +12,18 @@ class BackupStorage(abc.ABC):
12
12
  def __init__(self, conf: BackupRestoreConfig):
13
13
  self.logger = LogObserver()
14
14
 
15
+ suffix = "/" if not conf.storage.backup_path.endswith("/") else ""
16
+ self._backup_path = conf.storage.backup_path + suffix
17
+
15
18
  @abc.abstractmethod
16
19
  def export(self, folder: str, org_id: str) -> None:
17
20
  """Exports the content of the folder to the storage."""
18
21
  raise NotImplementedError
22
+
23
+ @abc.abstractmethod
24
+ def get_ws_declaration(
25
+ self, target_path: str, local_target_path: str
26
+ ) -> None:
27
+ raise NotImplementedError(
28
+ "This method should be implemented by the subclass."
29
+ )
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from gooddata_pipelines.backup_and_restore.models.storage import (
7
7
  BackupRestoreConfig,
8
+ LocalStorageConfig,
8
9
  )
9
10
  from gooddata_pipelines.backup_and_restore.storage.base_storage import (
10
11
  BackupStorage,
@@ -14,24 +15,34 @@ from gooddata_pipelines.backup_and_restore.storage.base_storage import (
14
15
  class LocalStorage(BackupStorage):
15
16
  def __init__(self, conf: BackupRestoreConfig):
16
17
  super().__init__(conf)
18
+ if not isinstance(conf.storage, LocalStorageConfig):
19
+ raise ValueError("Local storage config is required")
20
+ self._config: LocalStorageConfig = conf.storage
17
21
 
18
- def _export(
19
- self, folder: str, org_id: str, export_folder: str = "local_backups"
20
- ) -> None:
22
+ def _export(self, folder: str, org_id: str, export_folder: str) -> None:
21
23
  """Copies the content of the folder to local storage as backup."""
24
+
22
25
  self.logger.info(f"Saving {org_id} to local storage")
23
26
  shutil.copytree(
24
27
  Path(folder), Path(Path.cwd(), export_folder), dirs_exist_ok=True
25
28
  )
26
29
 
27
- def export(
28
- self, folder: str, org_id: str, export_folder: str = "local_backups"
29
- ) -> None:
30
+ def export(self, folder: str, org_id: str) -> None:
30
31
  """Copies the content of the folder to local storage as backup."""
31
32
  try:
32
- self._export(folder, org_id, export_folder)
33
+ self._export(folder, org_id, self._config.backup_path)
33
34
  except Exception as e:
34
35
  self.logger.error(
35
- f"Error exporting {folder} to {export_folder}: {e}"
36
+ f"Error exporting {folder} to {self._config.backup_path}: {e}"
36
37
  )
37
38
  raise
39
+
40
+ def get_ws_declaration(
41
+ self, target_path: str, local_target_path: str
42
+ ) -> None:
43
+ """Retrieves workspace declaration from local storage and copies to the local target path.
44
+
45
+ The local target should be a temporary directory.
46
+ """
47
+ file_to_copy = self._backup_path + target_path + "/gooddata_layouts.zip"
48
+ shutil.copy(file_to_copy, local_target_path)
@@ -25,8 +25,6 @@ class S3Storage(BackupStorage):
25
25
  self._client = self._session.client("s3")
26
26
  self._resource = self._session.resource("s3")
27
27
  self._bucket = self._resource.Bucket(self._config.bucket) # type: ignore [missing library stubs]
28
- suffix = "/" if not self._config.backup_path.endswith("/") else ""
29
- self._backup_path = self._config.backup_path + suffix
30
28
 
31
29
  self._verify_connection()
32
30
 
@@ -95,3 +93,28 @@ class S3Storage(BackupStorage):
95
93
  self._client.put_object(
96
94
  Bucket=self._config.bucket, Key=export_path, Body=data
97
95
  )
96
+
97
+ def get_ws_declaration(
98
+ self, target_path: str, local_target_path: str
99
+ ) -> None:
100
+ """Retrieves workspace declaration from S3 bucket."""
101
+ target_s3_prefix = f"{self._backup_path}{target_path}"
102
+
103
+ objs_found = list(self._bucket.objects.filter(Prefix=target_s3_prefix))
104
+
105
+ # Remove the included directory (which equals prefix) on hit
106
+ objs_found = objs_found[1:] if len(objs_found) > 0 else objs_found
107
+
108
+ if not objs_found:
109
+ message = f"No target backup found for {target_s3_prefix}."
110
+ self.logger.error(message)
111
+ raise Exception(message)
112
+
113
+ if len(objs_found) > 1:
114
+ self.logger.warning(
115
+ f"Multiple backups found at {target_s3_prefix}."
116
+ " Continuing with the first one, ignoring the rest..."
117
+ )
118
+
119
+ s3_obj = objs_found[0]
120
+ self._bucket.download_file(s3_obj.key, local_target_path)
@@ -26,10 +26,11 @@ class AttributesMixin:
26
26
  Returns:
27
27
  dict: Returns a dictionary of the objects' attributes.
28
28
  """
29
- # TODO: This might not work great with nested objects, values which are lists of objects etc.
30
- # If we care about parsing the logs back from the string, we should consider some other approach
31
29
  attributes: dict[str, str] = {}
32
- for context_object in objects:
30
+ for index, context_object in enumerate(objects):
31
+ if isinstance(context_object, str):
32
+ attributes[f"string_context_{index}"] = context_object
33
+
33
34
  if isinstance(context_object, Response):
34
35
  # for request.Response objects, keys need to be renamed to match the log schema
35
36
  attributes.update(
@@ -48,10 +49,12 @@ class AttributesMixin:
48
49
  cast(attrs.AttrsInstance, context_object)
49
50
  ).items():
50
51
  self._add_to_dict(attributes, key, value)
51
- else:
52
+ elif hasattr(context_object, "__dict__"):
52
53
  # Generic handling for other objects
53
54
  for key, value in context_object.__dict__.items():
54
55
  self._add_to_dict(attributes, key, value)
56
+ else:
57
+ attributes[f"object_{index}"] = str(context_object)
55
58
 
56
59
  if overrides:
57
60
  attributes.update(overrides)
@@ -0,0 +1,30 @@
1
+ # (C) 2025 GoodData Corporation
2
+
3
+ from typing import Any, Callable
4
+
5
+ from gooddata_pipelines.logger.logger import LogObserver
6
+
7
+ logger: LogObserver = LogObserver()
8
+
9
+
10
+ def log_and_reraise_exception(message: str) -> Callable:
11
+ """
12
+ Decorator to log an exception and re-raise it.
13
+
14
+ Args:
15
+ message (str): The message to log.
16
+ """
17
+
18
+ def decorator(fn: Callable) -> Callable:
19
+ def wrapper(*method_args: Any, **method_kwargs: Any) -> Callable:
20
+ try:
21
+ return fn(*method_args, **method_kwargs)
22
+ except Exception:
23
+ logger.error(
24
+ f"{message}, {fn.__name__}, Args: {method_args}, Kwargs: {method_kwargs}"
25
+ )
26
+ raise
27
+
28
+ return wrapper
29
+
30
+ return decorator
@@ -0,0 +1,63 @@
1
+ # (C) 2025 GoodData Corporation
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import attrs
8
+ import yaml
9
+
10
+
11
+ class PathUtils:
12
+ """Handles common path operations."""
13
+
14
+ @staticmethod
15
+ def validate_path(path: str | Path) -> Path:
16
+ """Validates a path."""
17
+ if not isinstance(path, Path):
18
+ path = Path(path)
19
+
20
+ return path
21
+
22
+ def check_path_exists(self, path: Path) -> None:
23
+ """Checks if a path exists."""
24
+ if not self.validate_path(path).exists():
25
+ raise FileNotFoundError(f"File {path} does not exist.")
26
+
27
+
28
+ @attrs.define
29
+ class JsonUtils:
30
+ """Handles common JSON interactions."""
31
+
32
+ path_utils: PathUtils = attrs.field(factory=PathUtils)
33
+
34
+ def load(self, path: Path) -> Any:
35
+ """Loads a JSON file."""
36
+ self.path_utils.check_path_exists(path)
37
+
38
+ with open(path, "r") as f:
39
+ return json.load(f)
40
+
41
+ def dump(self, path: Path, data: Any) -> None:
42
+ """Writes the source to a JSON file."""
43
+ with open(path, "w") as output_file:
44
+ json.dump(data, output_file)
45
+
46
+
47
+ @attrs.define
48
+ class YamlUtils:
49
+ """Handles common YMAL interactions."""
50
+
51
+ path_utils: PathUtils = attrs.field(factory=PathUtils)
52
+
53
+ def safe_load(self, path: Path) -> Any:
54
+ """Safe loads a YAML file."""
55
+ self.path_utils.check_path_exists(path)
56
+
57
+ with open(path, "r") as f:
58
+ return yaml.safe_load(f)
59
+
60
+ def dump(self, path: str, data: Any) -> None:
61
+ """Writes the source to a YAML file."""
62
+ with open(path, "w") as output_file:
63
+ yaml.dump(data, output_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gooddata-pipelines
3
- Version: 1.52.1.dev1
3
+ Version: 1.52.1.dev3
4
4
  Summary: GoodData Cloud lifecycle automation pipelines
5
5
  Author-email: GoodData <support@gooddata.com>
6
6
  License: MIT
@@ -8,7 +8,7 @@ License-File: LICENSE.txt
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: boto3-stubs<2.0.0,>=1.39.3
10
10
  Requires-Dist: boto3<2.0.0,>=1.39.3
11
- Requires-Dist: gooddata-sdk~=1.52.1.dev1
11
+ Requires-Dist: gooddata-sdk~=1.52.1.dev3
12
12
  Requires-Dist: pydantic<3.0.0,>=2.11.3
13
13
  Requires-Dist: requests<3.0.0,>=2.32.3
14
14
  Requires-Dist: types-pyyaml<7.0.0,>=6.0.12.20250326
@@ -27,9 +27,10 @@ You can use the package to manage following resources in GDC:
27
27
  - User/Group permissions
28
28
  - User Data Filters
29
29
  - Child workspaces (incl. Workspace Data Filter settings)
30
- 1. _[PLANNED]:_ Backup and restore of workspaces
31
- 1. _[PLANNED]:_ Custom fields management
32
- - extend the Logical Data Model of a child workspace
30
+ 1. Backup and restore of workspaces
31
+ - Create and backup snapshots of workspace metadata.
32
+ 1. LDM Extension
33
+ - extend the Logical Data Model of a child workspace with custom datasets and fields
33
34
 
34
35
  In case you are not interested in incorporating a library in your own program but would like to use a ready-made script, consider having a look at [GoodData Productivity Tools](https://github.com/gooddata/gooddata-productivity-tools).
35
36
 
@@ -1,25 +1,27 @@
1
- gooddata_pipelines/__init__.py,sha256=dGbyckzT8MrxL5SljSOj2jo5DFXZ2HYn0kBbAe0vO7s,2602
1
+ gooddata_pipelines/__init__.py,sha256=2vDjg0bX5qOdAFbr3H9Q2IuoTk2_kH-bfRK_miVtcoY,2746
2
2
  gooddata_pipelines/_version.py,sha256=Zi8Ht5ofjFeSYGG5USixQtJNB1po6okh0Rez8VyAsFM,200
3
3
  gooddata_pipelines/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  gooddata_pipelines/api/__init__.py,sha256=0WaBI2XMdkkZgnUsQ9kqipNzh2l2zamZvUt_qjp8xCk,106
5
5
  gooddata_pipelines/api/exceptions.py,sha256=rddQXfv8Ktckz7RONKBnKfm53M7dzPCh50Dl1k-8hqs,1545
6
- gooddata_pipelines/api/gooddata_api.py,sha256=8AZ5-mGGvo_4pPFjaf_DxkzQQqp2dRtiRPTM2sIdfYs,10934
6
+ gooddata_pipelines/api/gooddata_api.py,sha256=-A_yqk8JbfNW16B4egXvJcWSSzYSS8fwVk1Bc2AsEEU,11956
7
7
  gooddata_pipelines/api/gooddata_api_wrapper.py,sha256=t7dFrXJ6X4yXS9XDthOmvd2CyzdnDDNPeIngTEW72YU,1152
8
8
  gooddata_pipelines/api/gooddata_sdk.py,sha256=wd5O4e9BQLWUawt6odrs5a51nqFGthBkvqh9WOiW36Q,13734
9
9
  gooddata_pipelines/api/utils.py,sha256=3QY_aYH17I9THoCINE3l-n5oj52k-gNeT1wv6Z_VxN8,1433
10
10
  gooddata_pipelines/backup_and_restore/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
11
11
  gooddata_pipelines/backup_and_restore/backup_input_processor.py,sha256=iUB-bvYMsOc6FGiHsIzHPWkz-TGM8VontudlVWzmBDI,7921
12
- gooddata_pipelines/backup_and_restore/backup_manager.py,sha256=ubYgePX72pg4hCMPXCeX8_T6QHHhQS8fMbHx__nbfso,15209
13
- gooddata_pipelines/backup_and_restore/constants.py,sha256=SpCll6C-QcWYYVhcYZOyOGG7ELP6M87ioarbUr1nN9I,833
12
+ gooddata_pipelines/backup_and_restore/backup_manager.py,sha256=yhUqw_DnJ8PYBUPPH_10tgjaoGToWCbty6cLlG2PS-A,13351
13
+ gooddata_pipelines/backup_and_restore/base_manager.py,sha256=dceZQ-ACSuLtrt-GO9wS9rnRv4uIEVrBCikD87LQlJs,2448
14
+ gooddata_pipelines/backup_and_restore/constants.py,sha256=tEFpz-GavUvyZADMb_zGsoxbRs1wQ4CDuQkeXRGmpyA,816
14
15
  gooddata_pipelines/backup_and_restore/csv_reader.py,sha256=0Kw7mJT7REj3Gjqfsc6YT9MbhcqfCGNB_SKBwzTI1rk,1268
16
+ gooddata_pipelines/backup_and_restore/restore_manager.py,sha256=itvhu7MjxwdX0fJxA4F-g8BjxIpe8cxG2CVsYmq37z0,10491
15
17
  gooddata_pipelines/backup_and_restore/models/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
16
18
  gooddata_pipelines/backup_and_restore/models/input_type.py,sha256=CBKJigKdmZ-NJD9MSfNhq89bo86W0AqCMMoyonbd1QA,239
17
- gooddata_pipelines/backup_and_restore/models/storage.py,sha256=BcgOGIk4u3EaH0u0gArDHQpDyIPjx_c3fmoc-i_Ptj4,2795
19
+ gooddata_pipelines/backup_and_restore/models/storage.py,sha256=kESVWGM_qq6HC_BCbrxsN6IyAFE4G4nPC96ggQy05ZI,5107
18
20
  gooddata_pipelines/backup_and_restore/models/workspace_response.py,sha256=eQbYLgRQc17IRG0yPTAJVrD-Xs05SzuwtzoNrPT2DoY,833
19
21
  gooddata_pipelines/backup_and_restore/storage/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
20
- gooddata_pipelines/backup_and_restore/storage/base_storage.py,sha256=67wdItlG3neExeb_eCUDQhswdUB62X5Nyj9sOImB_Hg,487
21
- gooddata_pipelines/backup_and_restore/storage/local_storage.py,sha256=NvhPRzRAvuSpc5qCDyPqZaMB0i1jeZOZczaSwjUSGEg,1155
22
- gooddata_pipelines/backup_and_restore/storage/s3_storage.py,sha256=ZAysu4sPMAvdWs3RUroHHp2XZLHeU_LhJ5qBHlBQ7n4,3732
22
+ gooddata_pipelines/backup_and_restore/storage/base_storage.py,sha256=lzkfjsp_WZQS1U-178iSlzDplZ-ejsoodLQscK1Vd9s,858
23
+ gooddata_pipelines/backup_and_restore/storage/local_storage.py,sha256=LYM8uZCQKwe_SiItVSxKr7DqaoW-1ePhHjB3usQgFck,1703
24
+ gooddata_pipelines/backup_and_restore/storage/s3_storage.py,sha256=mcd91cHB8NMgo2t_F_DxMXqsCiBhSXCbULxWgaVAXr0,4535
23
25
  gooddata_pipelines/ldm_extension/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
24
26
  gooddata_pipelines/ldm_extension/input_processor.py,sha256=lNIx6YfU4OJpSLyAitCoPwwf6eFIT6OyivRnqYX5O-o,11678
25
27
  gooddata_pipelines/ldm_extension/input_validator.py,sha256=sAl-tixrS69G_lP19U9CjKHiWZinXOcjeAqwiydVctQ,7459
@@ -58,10 +60,12 @@ gooddata_pipelines/provisioning/generic/provision.py,sha256=TgBFbOroG9nHVhHQkqAm
58
60
  gooddata_pipelines/provisioning/utils/__init__.py,sha256=-BG28PGDbalLyZGQjpFG0pjdIvtf25ut0r8ZwZVbi4s,32
59
61
  gooddata_pipelines/provisioning/utils/context_objects.py,sha256=HJoeumH_gXwM6X-GO3HkC4w-6RYozz6-aqQOhDnu7no,879
60
62
  gooddata_pipelines/provisioning/utils/exceptions.py,sha256=1WnAOlPhqOf0xRcvn70lxAlLb8Oo6m6WCYS4hj9uzDU,3630
61
- gooddata_pipelines/provisioning/utils/utils.py,sha256=u-nVVp6ykY4MZqRXBoPCKLrFlUunLF-cugF9SpSzL1E,2766
63
+ gooddata_pipelines/provisioning/utils/utils.py,sha256=OSskOvxx4Eu5_khLxuby6t5YGR3qJa-h5G6m7pVOUgQ,2818
62
64
  gooddata_pipelines/utils/__init__.py,sha256=s9TtSjKqo1gSGWOVoGrXaGi1TsbRowjRDYKtjmKy7BY,155
65
+ gooddata_pipelines/utils/decorators.py,sha256=k2v0Kk-fnTyVKXBBCkWI6f5cbv_vE9QXpCNL8Us11IY,775
66
+ gooddata_pipelines/utils/file_utils.py,sha256=mr2rVicXjEaEfq8cV7oeIkXaYY1PdtuL7QBvhV4C-l4,1619
63
67
  gooddata_pipelines/utils/rate_limiter.py,sha256=owbcEZhUxlTnE7rRHiWQ8XBC-vML2fVPbt41EeGEM7o,2002
64
- gooddata_pipelines-1.52.1.dev1.dist-info/METADATA,sha256=Bq0BXaTIq36gNxCeBc63ijIms2O87QuZsDGDKg8T82o,3658
65
- gooddata_pipelines-1.52.1.dev1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
66
- gooddata_pipelines-1.52.1.dev1.dist-info/licenses/LICENSE.txt,sha256=PNC7WXGIo6OKkNoPLRxlVrw6jaLcjSTUsSxy9Xcu9Jo,560365
67
- gooddata_pipelines-1.52.1.dev1.dist-info/RECORD,,
68
+ gooddata_pipelines-1.52.1.dev3.dist-info/METADATA,sha256=kdehzVDqJEinfjJR4320aRSm5Ya5Qwhr9EZWU2aaVW4,3709
69
+ gooddata_pipelines-1.52.1.dev3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
70
+ gooddata_pipelines-1.52.1.dev3.dist-info/licenses/LICENSE.txt,sha256=PNC7WXGIo6OKkNoPLRxlVrw6jaLcjSTUsSxy9Xcu9Jo,560365
71
+ gooddata_pipelines-1.52.1.dev3.dist-info/RECORD,,