Skip to content

Commit d314922

Browse files
authored
Merge pull request #59 from keboola/PS-1858-workspace
PS-1858 support loading files and tables to file workspaces
2 parents f994e2b + d5529f3 commit d314922

6 files changed

Lines changed: 185 additions & 5 deletions

File tree

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ before_script:
77
- docker-compose build sapi-python-client
88
script:
99
- docker-compose run --rm --entrypoint=flake8 sapi-python-client
10-
- docker-compose run --rm -e KBC_TEST_TOKEN -e KBC_TEST_API_URL sapi-python-client -m unittest discover
10+
- docker-compose run --rm -e KBC_TEST_TOKEN -e KBC_TEST_API_URL -e SKIP_ABS_TEST=1 sapi-python-client -m unittest discover
1111
- docker-compose run --rm -e KBC_TEST_TOKEN=$KBC_AZ_TEST_TOKEN -e KBC_TEST_API_URL=$KBC_AZ_TEST_API_URL sapi-python-client -m unittest discover
1212
after_success:
1313
- docker images

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.6
1+
FROM python:3.8
22

33
WORKDIR /code
44
COPY . /code/

kbcstorage/workspaces.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
http://docs.keboola.apiary.io/#reference/workspaces/
88
"""
99
from kbcstorage.base import Endpoint
10+
from kbcstorage.files import Files
1011

1112

12-
def _make_body(mapping):
13+
def _make_body(mapping, source_key='source'):
1314
"""
1415
Given a dict mapping Keboola tables to aliases, construct the body of
1516
the HTTP request to load said tables.
@@ -22,7 +23,7 @@ def _make_body(mapping):
2223
body = {}
2324
template = 'input[{0}][{1}]'
2425
for i, (k, v) in enumerate(mapping.items()):
25-
body[template.format(i, 'source')] = k
26+
body[template.format(i, source_key)] = k
2627
body[template.format(i, 'destination')] = v
2728

2829
return body
@@ -76,7 +77,7 @@ def create(self, backend=None, timeout=None):
7677
7778
Args:
7879
backend (:obj:`str`): The type of engine for the workspace.
79-
'redshift' or 'snowflake'. Default redshift.
80+
'redshift', 'snowflake' or 'synapse'. Defaults to the project's default backend.
8081
timeout (int): The timeout, in seconds, for SQL statements.
8182
Only supported by snowflake backends.
8283
@@ -143,3 +144,30 @@ def load_tables(self, workspace_id, table_mapping, preserve=None):
143144
url = '{}/{}/load'.format(self.base_url, workspace_id)
144145

145146
return self._post(url, data=body)
147+
148+
def load_files(self, workspace_id, file_mapping, preserve=None):
149+
"""
150+
Load tabes from storage into a workspace.
151+
* only supports abs workspace
152+
153+
Args:
154+
workspace_id (int or str): The id of the workspace to which to load
155+
the tables.
156+
file_mapping (:obj:`dict`): contains tags: [], destination: string
157+
preserve (bool): If False, drop files, else keep files in workspace.
158+
159+
Raises:
160+
requests.HTTPError: If the API request fails.
161+
"""
162+
workspace = self.detail(workspace_id)
163+
if (workspace['type'] != 'file' and workspace['connection']['backend'] != 'abs'):
164+
raise Exception('Loading files to workspace is only available for ABS workspaces')
165+
files = Files(self.root_url, self.token)
166+
file_list = files.list(tags=file_mapping['tags'])
167+
inputs = {}
168+
for file in file_list:
169+
inputs[file['id']] = file_mapping['destination']
170+
body = _make_body(inputs, source_key='dataFileId')
171+
body['preserve'] = preserve
172+
url = '{}/{}/load'.format(self.base_url, workspace['id'])
173+
return self._post(url, data=body)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import csv
2+
import os
3+
import tempfile
4+
import unittest
5+
import warnings
6+
7+
from azure.storage.blob import BlobServiceClient
8+
from requests import exceptions
9+
from kbcstorage.buckets import Buckets
10+
from kbcstorage.jobs import Jobs
11+
from kbcstorage.files import Files
12+
from kbcstorage.tables import Tables
13+
from kbcstorage.workspaces import Workspaces
14+
15+
16+
class TestWorkspaces(unittest.TestCase):
17+
def setUp(self):
18+
self.workspaces = Workspaces(os.getenv('KBC_TEST_API_URL'), os.getenv('KBC_TEST_TOKEN'))
19+
self.buckets = Buckets(os.getenv('KBC_TEST_API_URL'), os.getenv('KBC_TEST_TOKEN'))
20+
self.jobs = Jobs(os.getenv('KBC_TEST_API_URL'), os.getenv('KBC_TEST_TOKEN'))
21+
self.tables = Tables(os.getenv('KBC_TEST_API_URL'), os.getenv('KBC_TEST_TOKEN'))
22+
self.files = Files(os.getenv('KBC_TEST_API_URL'), os.getenv('KBC_TEST_TOKEN'))
23+
try:
24+
file_list = self.files.list(tags=['sapi-client-python-tests'])
25+
for file in file_list:
26+
self.files.delete(file['id'])
27+
except exceptions.HTTPError as e:
28+
if e.response.status_code != 404:
29+
raise
30+
try:
31+
self.buckets.delete('in.c-py-test-buckets', force=True)
32+
except exceptions.HTTPError as e:
33+
if e.response.status_code != 404:
34+
raise
35+
# https://github.com/boto/boto3/issues/454
36+
warnings.simplefilter("ignore", ResourceWarning)
37+
38+
def tearDown(self):
39+
try:
40+
if hasattr(self, 'workspace_id'):
41+
self.workspaces.delete(self.workspace_id)
42+
except exceptions.HTTPError as e:
43+
if e.response.status_code != 404:
44+
raise
45+
try:
46+
self.buckets.delete('in.c-py-test-tables', force=True)
47+
except exceptions.HTTPError as e:
48+
if e.response.status_code != 404:
49+
raise
50+
51+
def test_create_workspace(self):
52+
workspace = self.workspaces.create()
53+
self.workspace_id = workspace['id']
54+
with self.subTest():
55+
self.assertTrue('id' in workspace)
56+
with self.subTest():
57+
self.assertTrue('type' in workspace)
58+
self.assertTrue(workspace['type'] in ['table', 'file'])
59+
with self.subTest():
60+
self.assertTrue('name' in workspace)
61+
with self.subTest():
62+
self.assertTrue('component' in workspace)
63+
with self.subTest():
64+
self.assertTrue('configurationId' in workspace)
65+
with self.subTest():
66+
self.assertTrue('created' in workspace)
67+
with self.subTest():
68+
self.assertTrue('connection' in workspace)
69+
with self.subTest():
70+
self.assertTrue('backend' in workspace['connection'])
71+
with self.subTest():
72+
self.assertTrue('creatorToken' in workspace)
73+
74+
def test_load_tables_to_workspace(self):
75+
bucket_id = self.buckets.create('py-test-tables')['id']
76+
table1_id = self.__create_table(bucket_id, 'test-table-1', {'col1': 'ping', 'col2': 'pong'})
77+
table2_id = self.__create_table(bucket_id, 'test-table-2', {'col1': 'king', 'col2': 'kong'})
78+
workspace = self.workspaces.create()
79+
self.workspace_id = workspace['id']
80+
job = self.workspaces.load_tables(
81+
workspace['id'],
82+
{table1_id: 'destination_1', table2_id: 'destination_2'}
83+
)
84+
self.jobs.block_until_completed(job['id'])
85+
86+
job = self.tables.create_raw(
87+
bucket_id,
88+
'back-and-forth-table',
89+
data_workspace_id=workspace['id'],
90+
data_table_name='destination_1'
91+
)
92+
self.jobs.block_until_completed(job['id'])
93+
94+
new_table = self.tables.detail(bucket_id + '.back-and-forth-table')
95+
self.assertEqual('back-and-forth-table', new_table['name'])
96+
97+
# test load files into an abs workspace
98+
def test_load_files_to_workspace(self):
99+
if (os.getenv('SKIP_ABS_TEST')):
100+
self.skipTest('Skipping ABS test because env var SKIP_ABS_TESTS was set')
101+
# put a test file to storage
102+
file, path = tempfile.mkstemp(prefix='sapi-test')
103+
os.write(file, bytes('fooBar', 'utf-8'))
104+
os.close(file)
105+
file_id = self.files.upload_file(path, tags=['sapi-client-python-tests', 'file1'])
106+
107+
# create a workspace and load the file to it
108+
workspace = self.workspaces.create('abs')
109+
self.workspace_id = workspace['id']
110+
job = self.workspaces.load_files(
111+
workspace['id'],
112+
{'tags': ['sapi-client-python-tests'], 'destination': 'data/in/files'}
113+
)
114+
self.jobs.block_until_completed(job['id'])
115+
116+
# assert that the file was loaded to the workspace
117+
blob_service_client = BlobServiceClient.from_connection_string(workspace['connection']['connectionString'])
118+
blob_client = blob_service_client.get_blob_client(
119+
container=workspace['connection']['container'],
120+
blob='data/in/files/%s' % str(file_id)
121+
)
122+
self.assertEqual('fooBar', blob_client.download_blob().readall().decode('utf-8'))
123+
124+
def __create_table(self, bucket_id, table_name, row):
125+
file, path = tempfile.mkstemp(prefix='sapi-test')
126+
with open(path, 'w') as csv_file:
127+
writer = csv.DictWriter(csv_file, fieldnames=['col1', 'col2'],
128+
lineterminator='\n', delimiter=',', quotechar='"')
129+
writer.writeheader()
130+
writer.writerow(row)
131+
return self.tables.create(name=table_name, file_path=path, bucket_id=bucket_id)

tests/mocks/test_workspaces.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,22 @@ def test_reset_password_for_inexistent_workspace(self):
217217
with self.assertRaises(HTTPError) as error_context:
218218
self.ws.reset_password(workspace_id)
219219
assert error_context.exception.args[0] == msg
220+
221+
@responses.activate
222+
def test_load_files_to_invalid_workspace(self):
223+
"""
224+
Raises exception when mock loading_files to invalid workspace
225+
"""
226+
msg = ('Loading files to workspace is only available for ABS workspaces')
227+
responses.add(
228+
responses.Response(
229+
method='GET',
230+
url='https://connection.keboola.com/v2/storage/workspaces/1',
231+
json=detail_response
232+
)
233+
)
234+
workspace_id = '1'
235+
try:
236+
self.ws.load_files(workspace_id, {'tags': ['sapi-client-python-tests'], 'destination': 'data/in/files'})
237+
except Exception as ex:
238+
assert str(ex) == msg

tests/mocks/workspace_responses.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
detail_response = {
2828
"id": 1,
2929
"name": "boring_wozniak",
30+
"type": "table",
3031
"component": "wr-db",
3132
"configurationId": "aws-1",
3233
"created": "2016-05-17T11:11:20+0200",
@@ -51,6 +52,7 @@
5152
create_response = {
5253
"id": 234,
5354
"name": "boring_wozniak",
55+
"type": "table",
5456
"component": "wr-db",
5557
"configurationId": "aws-1",
5658
"created": "2016-05-17T11:11:20+0200",

0 commit comments

Comments
 (0)