Skip to content

Commit a94f4ee

Browse files
iakov-awsIakov Gandarken99
authored
Recursive update (#243)
* Feature: recursive update * Feature: parameters for update * Refactoring: CUR, Dashboards, Datasets * Feature: Athena database auto detection * New classes: Dataset * Fix: plugin loader * Improvement: logging * Bump version Co-authored-by: Iakov Gan <[email protected]> Co-authored-by: Oleksandr Moskalenko <[email protected]>
1 parent d05f26e commit a94f4ee

File tree

9 files changed

+387
-212
lines changed

9 files changed

+387
-212
lines changed

cid/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11

2-
__version__ = '0.1.22'
2+
__version__ = '0.1.23'

cid/cli.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,12 @@ def delete(ctx, dashboard_id, **kwargs):
124124

125125

126126
@click.option('--dashboard-id', help='QuickSight dashboard id', default=None)
127-
@click.option('--force/--noforce', help='Allow force update', default=False)
127+
@click.option('--force/--noforce', help='allow selecting up to date dashboards (flags must be before options)', default=False)
128+
@click.option('--recursive/--norecursive', help='Recursive update all Datasets and Views (flags must be before options)', default=False)
128129
@cid_command
129-
def update(ctx, dashboard_id, force):
130+
def update(ctx, dashboard_id, force, recursive, **kwargs):
130131
"""Update Dashboard"""
131-
ctx.obj.update(dashboard_id, force=force)
132+
ctx.obj.update(dashboard_id, force=force, recursive=recursive)
132133

133134

134135
@click.option('--dashboard-id', help='QuickSight dashboard id', default=None)

cid/common.py

Lines changed: 282 additions & 184 deletions
Large diffs are not rendered by default.

cid/helpers/athena.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ def CatalogName(self) -> str:
5959
logger.info(f'Using datacatalog: {self._CatalogName}')
6060
return self._CatalogName
6161

62+
@CatalogName.setter
63+
def set_catalog_name(self, catalog):
64+
self._CatalogName = catalog
65+
6266
@property
6367
def DatabaseName(self) -> str:
6468
""" Check if Athena database exist """
@@ -92,6 +96,11 @@ def DatabaseName(self) -> str:
9296
logger.info(f'Using Athena database: {self._DatabaseName}')
9397
return self._DatabaseName
9498

99+
@DatabaseName.setter
100+
def DatabaseName(self, database):
101+
self._DatabaseName = database
102+
103+
95104
def list_data_catalogs(self) -> list:
96105
return self.client.list_data_catalogs().get('DataCatalogsSummary')
97106

@@ -322,7 +331,7 @@ def wait_for_view(self, view_name: str, poll_interval=1, timeout=60) -> None:
322331
def delete_table(self, name: str, catalog: str=None, database: str=None):
323332
if get_parameter(
324333
param_name=f'confirm-{name}',
325-
message=f'Delete athena table {name}?',
334+
message=f'Delete Athena table {name}?',
326335
choices=['yes', 'no'],
327336
default='no') != 'yes':
328337
return False
@@ -345,7 +354,7 @@ def delete_table(self, name: str, catalog: str=None, database: str=None):
345354
def delete_view(self, name: str, catalog: str=None, database: str=None):
346355
if get_parameter(
347356
param_name=f'confirm-{name}',
348-
message=f'Delete athena view {name}?',
357+
message=f'Delete Athena view {name}?',
349358
choices=['yes', 'no'],
350359
default='no') != 'yes':
351360
return False

cid/helpers/glue.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ def __init__(self, session):
1313
self.client = session.client('glue', region_name=self.region)
1414

1515

16-
def create_table(self, table: dict) -> dict:
17-
""" Creates an AWS Glue table """
18-
return self.client.create_table(**table)
19-
20-
def ensure_glue_table_created(self, view_name: str, view_query: str) -> None:
16+
def create_or_upate_table(self, view_name: str, view_query: str) -> None:
17+
table = json.loads(view_query)
2118
try:
22-
self.create_table(json.loads(view_query))
23-
except self.glue.client.exceptions.AlreadyExistsException:
19+
self.client.create_table(**table)
20+
except self.client.exceptions.AlreadyExistsException:
2421
logger.info(f'Glue table "{view_name}" exists')
22+
self.client.update_table(**table)
2523

2624
def delete_table(self, name, catalog, database):
2725
""" Delete an AWS Glue table """

cid/helpers/quicksight/__init__.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class QuickSight():
1717
# Define defaults
1818
cidAccountId = '223485597511'
1919
_dashboards: Dict[str, Dashboard] = None
20-
_datasets: Dict[str, Dataset] = {}
20+
_datasets: Dict[str, Dataset] = None
2121
_datasources: dict() = {}
2222
_user: dict = None
2323

@@ -93,6 +93,13 @@ def dashboards(self) -> Dict[str, Dashboard]:
9393
self.discover_dashboards()
9494
return self._dashboards
9595

96+
@property
97+
def datasets(self) -> Dict[str, Dataset]:
98+
"""Returns a list of deployed dashboards"""
99+
if self._datasets is None:
100+
self.discover_datasets()
101+
return self._datasets or {}
102+
96103
@property
97104
def athena_datasources(self) -> dict:
98105
"""Returns a list of existing athena datasources"""
@@ -109,7 +116,6 @@ def datasources(self) -> dict:
109116

110117
def discover_dashboard(self, dashboardId: str):
111118
"""Discover single dashboard"""
112-
113119
dashboard = self.describe_dashboard(DashboardId=dashboardId)
114120
# Look for dashboard definition by DashboardId
115121
_definition = next((v for v in self.supported_dashboards.values() if v['dashboardId'] == dashboard.id), None)
@@ -152,7 +158,7 @@ def _recoursive_add_view(view):
152158
for dep_view in self.supported_views.get(view, {}).get('dependsOn', {}).get('views', []):
153159
_recoursive_add_view(dep_view)
154160
for dataset_name in dashboard.datasets.keys():
155-
for view in self.supported_datasets.get(dataset_name).get('dependsOn', {}).get('views', []):
161+
for view in self.supported_datasets.get(dataset_name, {}).get('dependsOn', {}).get('views', []):
156162
_recoursive_add_view(view)
157163
dashboard.views = all_views
158164
self._dashboards = self._dashboards or {}
@@ -267,7 +273,7 @@ def discover_data_sources(self) -> None:
267273
self.describe_data_source(v.get('DataSourceId'))
268274
except Exception as e:
269275
logger.debug(e, stack_info=True)
270-
for _,v in self._datasets.items():
276+
for _,v in self.datasets.items():
271277
for d in v.datasources:
272278
logger.info(f'Discovering data source {d}')
273279
self.describe_data_source(d)
@@ -517,7 +523,7 @@ def delete_dataset(self, id: str) -> bool:
517523
AwsAccountId=self.account_id,
518524
DataSetId=id
519525
)
520-
self._datasets.pop(id)
526+
self.datasets.pop(id)
521527
except self.client.exceptions.AccessDeniedException:
522528
logger.info('Access denied deleting dataset')
523529
return False
@@ -529,10 +535,24 @@ def delete_dataset(self, id: str) -> bool:
529535
return True
530536

531537

538+
def get_datasets(self, id: str=None, name: str=None) -> Dataset:
539+
""" get dataset that match parameters """
540+
result = []
541+
for dataset in self.datasets.values():
542+
if id is not None and dataset.id != id:
543+
continue
544+
if name is not None and dataset.name != name:
545+
continue
546+
result.append(dataset)
547+
return result
548+
549+
550+
532551
def describe_dataset(self, id, timeout: int=1) -> dict:
533552
""" Describes an AWS QuickSight dataset """
534-
if id in self._datasets:
553+
if self._datasets and id in self._datasets:
535554
return self._datasets.get(id)
555+
self._datasets = self._datasets or {}
536556
poll_interval = 1
537557
_dataset = None
538558
deadline = time.time() + timeout
@@ -556,6 +576,7 @@ def discover_datasets(self):
556576
""" Discover datasets in the account """
557577

558578
logger.info('Discovering datasets')
579+
self._datasets = self._datasets or {}
559580
try:
560581
for dataset in self.list_data_sets():
561582
try:
@@ -626,6 +647,7 @@ def create_dataset(self, definition: dict) -> str:
626647
dataset_id = None
627648
try:
628649
logger.info(f'Creating dataset {definition.get("Name")} ({dataset_id})')
650+
logger.debug(f'Dataset definition: {definition}')
629651
response = self.client.create_data_set(**definition)
630652
dataset_id = response.get('DataSetId')
631653
except self.client.exceptions.ResourceExistsException:
@@ -646,6 +668,19 @@ def create_dataset(self, definition: dict) -> str:
646668
return dataset_id
647669

648670

671+
def update_dataset(self, definition: dict) -> str:
672+
""" Creates an AWS QuickSight dataset """
673+
definition.update({'AwsAccountId': self.account_id})
674+
logger.info(f'Updating dataset {definition.get("Name")}')
675+
676+
if "Permissions" in definition:
677+
logger.info('Ignoring permissions for dataset update.')
678+
del definition['Permissions']
679+
response = self.client.update_data_set(**definition)
680+
logger.info(f'Dataset {definition.get("Name")} is updated')
681+
return True
682+
683+
649684
def create_dashboard(self, definition: dict, **kwargs) -> Dashboard:
650685
""" Creates an AWS QuickSight dashboard """
651686
DataSetReferences = list()
@@ -684,9 +719,12 @@ def create_dashboard(self, definition: dict, **kwargs) -> Dashboard:
684719

685720
create_parameters = always_merger.merge(create_parameters, kwargs)
686721
try:
722+
logger.info(f'Creating dashboard "{definition.get("name")}"')
723+
logger.debug(create_parameters)
687724
create_status = self.client.create_dashboard(**create_parameters)
688725
logger.debug(create_status)
689726
except self.client.exceptions.ResourceExistsException:
727+
logger.info(f'Dashboard {definition.get("name")} already exists')
690728
raise
691729
created_version = int(create_status['VersionArn'].split('/')[-1])
692730

@@ -726,6 +764,7 @@ def update_dashboard(self, dashboard: Dashboard, **kwargs):
726764
}
727765

728766
update_parameters = always_merger.merge(update_parameters, kwargs)
767+
logger.info(f'Updating dashboard "{dashboard.name}"')
729768
logger.debug(f"Update parameters: {update_parameters}")
730769
update_status = self.client.update_dashboard(**update_parameters)
731770
logger.debug(update_status)
@@ -742,6 +781,7 @@ def update_dashboard(self, dashboard: Dashboard, **kwargs):
742781
'VersionNumber': updated_version
743782
}
744783
result = self.client.update_dashboard_published_version(**update_params)
784+
logger.debug(result)
745785
if result['Status'] != 200:
746786
raise Exception(result)
747787

cid/helpers/quicksight/dashboard.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66

77
from cid.helpers.quicksight.resource import CidQsResource
8+
from cid.utils import is_unattendent_mode
89

910
import logging
1011

@@ -65,7 +66,7 @@ def status(self) -> str:
6566
elif not self.definition:
6667
self._status = 'undiscovered'
6768
# Missing dataset
68-
elif not self.datasets or (len(self.datasets) < len(self.definition.get('dependsOn').get('datasets'))):
69+
elif not self.datasets or (len(set(self.datasets)) < len(set(self.definition.get('dependsOn').get('datasets')))):
6970
self.status_detail = 'missing dataset(s)'
7071
self._status = 'broken'
7172
logger.info(f"Found datasets: {self.datasets}")
@@ -82,7 +83,12 @@ def status(self) -> str:
8283

8384
@property
8485
def templateId(self) -> str:
85-
return str(self.version.get('SourceEntityArn').split('/')[1])
86+
if 'SourceEntityArn' not in self.version:
87+
return ''
88+
arn = self.version.get('SourceEntityArn')
89+
if ":template/" not in arn:
90+
return ''
91+
return str(arn.split('/')[1])
8692

8793
def find_local_config(self) -> Union[dict, None]:
8894

@@ -136,11 +142,11 @@ def display_status(self) -> None:
136142
print(f" Datasets: {', '.join(sorted(self.datasets.keys()))}")
137143
print('\n')
138144
if click.confirm('Display dashboard raw data?'):
139-
print(json.dumps(self.dashboard, indent=4, sort_keys=True, default=str))
145+
print(json.dumps(self.raw, indent=4, sort_keys=True, default=str))
140146

141147
def display_url(self, url_template: str, launch: bool = False, **kwargs) -> None:
142148
url = url_template.format(dashboard_id=self.id, **kwargs)
143149
print(f"#######\n####### {self.name} is available at: " + url + "\n#######")
144150
_supported_env = os.environ.get('AWS_EXECUTION_ENV') not in ['CloudShell', 'AWS_Lambda']
145-
if _supported_env and launch and click.confirm('Do you wish to open it in your browser?'):
151+
if _supported_env and not is_unattendent_mode() and launch and click.confirm('Do you wish to open it in your browser?'):
146152
click.launch(url)

cid/helpers/quicksight/dataset.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,20 @@ def id(self) -> str:
1414
def datasources(self) -> list:
1515
_datasources = []
1616
try:
17-
for _,map in self.raw.get('PhysicalTableMap').items():
18-
_datasources.append(map.get('RelationalTable').get('DataSourceArn').split('/')[-1])
17+
for table_map in self.raw.get('PhysicalTableMap').values():
18+
_datasources.append(table_map.get('RelationalTable').get('DataSourceArn').split('/')[-1])
1919
except Exception as e:
2020
logger.debug(e, stack_info=True)
2121
return _datasources
22+
23+
@property
24+
def schemas (self) -> list:
25+
schemas = []
26+
try:
27+
for table_map in self.raw.get('PhysicalTableMap').values():
28+
schema = table_map.get('RelationalTable', {}).get('Schema', None)
29+
if schema:
30+
schemas.append(schema)
31+
except Exception as e:
32+
logger.debug(e, stack_info=True)
33+
return schemas

cid/utils.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
import questionary
2-
import boto3
1+
import logging
2+
from collections.abc import Iterable
33

4+
import boto3
5+
import questionary
46
from botocore.exceptions import NoCredentialsError, CredentialRetrievalError, NoRegionError
57

6-
import logging
78
logger = logging.getLogger(__name__)
89

910
params = {} # parameters from command line
1011
_all_yes = False # parameters from command line
1112

13+
14+
def intersection(a: Iterable, b: Iterable) -> Iterable:
15+
return sorted(set(a).intersection(b))
16+
17+
def difference(a: Iterable, b: Iterable) -> Iterable:
18+
return sorted(list(set(a).difference(b)))
19+
1220
def get_aws_region():
1321
return get_boto_session().region_name
1422

@@ -61,6 +69,9 @@ def set_parameters(parameters: dict, all_yes: bool=False) -> None:
6169
global _all_yes
6270
_all_yes = all_yes
6371

72+
def is_unattendent_mode() -> bool:
73+
return _all_yes
74+
6475
def get_parameters():
6576
return dict(params)
6677

0 commit comments

Comments
 (0)