datamaker-py 0.10.0__tar.gz → 0.11.0__tar.gz
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.
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/PKG-INFO +30 -1
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/README.md +29 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/pyproject.toml +1 -1
- datamaker_py-0.11.0/src/datamaker/__init__.py +12 -0
- datamaker_py-0.11.0/src/datamaker/error.py +94 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/main.py +105 -1
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/__init__.py +2 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/base.py +10 -2
- datamaker_py-0.11.0/src/datamaker/routes/tosca.py +270 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker_py.egg-info/PKG-INFO +30 -1
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker_py.egg-info/SOURCES.txt +3 -1
- datamaker_py-0.11.0/tests/test_tosca.py +425 -0
- datamaker_py-0.10.0/src/datamaker/__init__.py +0 -2
- datamaker_py-0.10.0/src/datamaker/error.py +0 -2
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/LICENSE +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/setup.cfg +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/core.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/generated/__init__.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/generated/schema.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/api_keys.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/connections.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/custom_types.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/export_and_validation.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/folders_and_utils.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/generation.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/keymaps.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/masking_policies.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/plans.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/projects.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/scenario_files.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/sets.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/teams.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/templates.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/routes/users.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/template.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker/types.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker_py.egg-info/dependency_links.txt +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker_py.egg-info/requires.txt +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/src/datamaker_py.egg-info/top_level.txt +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/tests/test_integration.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/tests/test_main.py +0 -0
- {datamaker_py-0.10.0 → datamaker_py-0.11.0}/tests/test_routes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datamaker-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: The official Python library for the Automators DataMaker API.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -62,6 +62,35 @@ if __name__ == "__main__":
|
|
|
62
62
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
## Push a set into Tosca Cloud
|
|
66
|
+
|
|
67
|
+
`push_tosca_dataset` creates or replaces a native Tosca Cloud data set from a saved
|
|
68
|
+
set, an input set version, a dataset or inline rows. A Tosca test step reads it with
|
|
69
|
+
`{TDM[<data set>][<subset>][<column>]}`.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from datamaker import DataMaker, ToscaDatasetSchemaChangedError
|
|
73
|
+
|
|
74
|
+
dm = DataMaker()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = dm.push_tosca_dataset(
|
|
78
|
+
integration_id,
|
|
79
|
+
name="Orders",
|
|
80
|
+
input_set_id="is_123", # or set_id=..., dataset_id=..., rows=[...]
|
|
81
|
+
on_existing="overwrite", # "fail" (default) | "overwrite" | "new"
|
|
82
|
+
)
|
|
83
|
+
print(result.dataset_id, result.rows_count, result.outcome, result.example)
|
|
84
|
+
except ToscaDatasetSchemaChangedError as e:
|
|
85
|
+
# The rows dropped a column the data set has. Pass on_schema_change="recreate"
|
|
86
|
+
# to delete and recreate it instead.
|
|
87
|
+
print("removed:", e.removed_columns, "added:", e.added_columns)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An input set gets one Tosca subset per test case by default; `subsets_by` picks another
|
|
91
|
+
column or turns it off with `False`. `get_tosca_datasets(integration_id)` lists what a
|
|
92
|
+
space already holds.
|
|
93
|
+
|
|
65
94
|
## Autocomplete Types
|
|
66
95
|
|
|
67
96
|
This SDK automatically generates TypeScript autocomplete definitions for use in the [datamaker application](https://github.com/automators/datamaker).
|
|
@@ -51,6 +51,35 @@ if __name__ == "__main__":
|
|
|
51
51
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
## Push a set into Tosca Cloud
|
|
55
|
+
|
|
56
|
+
`push_tosca_dataset` creates or replaces a native Tosca Cloud data set from a saved
|
|
57
|
+
set, an input set version, a dataset or inline rows. A Tosca test step reads it with
|
|
58
|
+
`{TDM[<data set>][<subset>][<column>]}`.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from datamaker import DataMaker, ToscaDatasetSchemaChangedError
|
|
62
|
+
|
|
63
|
+
dm = DataMaker()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = dm.push_tosca_dataset(
|
|
67
|
+
integration_id,
|
|
68
|
+
name="Orders",
|
|
69
|
+
input_set_id="is_123", # or set_id=..., dataset_id=..., rows=[...]
|
|
70
|
+
on_existing="overwrite", # "fail" (default) | "overwrite" | "new"
|
|
71
|
+
)
|
|
72
|
+
print(result.dataset_id, result.rows_count, result.outcome, result.example)
|
|
73
|
+
except ToscaDatasetSchemaChangedError as e:
|
|
74
|
+
# The rows dropped a column the data set has. Pass on_schema_change="recreate"
|
|
75
|
+
# to delete and recreate it instead.
|
|
76
|
+
print("removed:", e.removed_columns, "added:", e.added_columns)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
An input set gets one Tosca subset per test case by default; `subsets_by` picks another
|
|
80
|
+
column or turns it off with `False`. `get_tosca_datasets(integration_id)` lists what a
|
|
81
|
+
space already holds.
|
|
82
|
+
|
|
54
83
|
## Autocomplete Types
|
|
55
84
|
|
|
56
85
|
This SDK automatically generates TypeScript autocomplete definitions for use in the [datamaker application](https://github.com/automators/datamaker).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .main import DataMaker # noqa
|
|
2
|
+
from .template import Template # noqa
|
|
3
|
+
from .error import ( # noqa
|
|
4
|
+
DataMakerError,
|
|
5
|
+
DataMakerAPIError,
|
|
6
|
+
ReadOnlyConnectionError,
|
|
7
|
+
ToscaDatasetExistsError,
|
|
8
|
+
ToscaDatasetLockedError,
|
|
9
|
+
ToscaDatasetSchemaChangedError,
|
|
10
|
+
ToscaDatasetTooLargeError,
|
|
11
|
+
)
|
|
12
|
+
from .routes.tosca import ToscaDatasetPushResult # noqa
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from typing import Any, List, Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DataMakerError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DataMakerAPIError(DataMakerError):
|
|
9
|
+
"""The API answered with a non-2xx status.
|
|
10
|
+
|
|
11
|
+
Subclasses ``DataMakerError`` and keeps its message, so existing
|
|
12
|
+
``except DataMakerError`` handlers are unaffected. Adds the status code and
|
|
13
|
+
the decoded JSON body (``None`` when the body was not JSON).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, status_code: int, body: Any = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
def _detail(self, key: str) -> Any:
|
|
22
|
+
return self.body.get(key) if isinstance(self.body, dict) else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReadOnlyConnectionError(DataMakerAPIError):
|
|
26
|
+
"""403: the connection is read-only, so DataMaker refused the write."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToscaDatasetExistsError(DataMakerAPIError):
|
|
30
|
+
"""409: a Tosca data set of that name exists and ``on_existing`` is "fail"."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def dataset_id(self) -> Optional[str]:
|
|
34
|
+
"""The id of the data set that is already there."""
|
|
35
|
+
return self._detail("datasetId")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToscaDatasetLockedError(DataMakerAPIError):
|
|
39
|
+
"""409: the data set is locked in Tosca Cloud, so it cannot be overwritten.
|
|
40
|
+
|
|
41
|
+
A test run or a "Test data - Expert" step holds it. Retry once it is
|
|
42
|
+
unlocked.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def dataset_id(self) -> Optional[str]:
|
|
47
|
+
return self._detail("datasetId")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ToscaDatasetSchemaChangedError(DataMakerAPIError):
|
|
51
|
+
"""409: an overwrite would drop columns the Tosca data set has.
|
|
52
|
+
|
|
53
|
+
Tosca only overwrites in place when the columns match. Added columns are
|
|
54
|
+
handled by the API; a removed column is refused unless the push asks for
|
|
55
|
+
``on_schema_change="recreate"``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def dataset_id(self) -> Optional[str]:
|
|
60
|
+
return self._detail("datasetId")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def added_columns(self) -> List[str]:
|
|
64
|
+
return self._detail("added") or []
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def removed_columns(self) -> List[str]:
|
|
68
|
+
return self._detail("removed") or []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToscaDatasetTooLargeError(DataMakerAPIError):
|
|
72
|
+
"""413: more rows or bytes than one push may write.
|
|
73
|
+
|
|
74
|
+
The row pair (``max_rows``, ``rows``) or the byte pair (``max_bytes``,
|
|
75
|
+
``bytes``) is set, depending on which cap was hit. A set, input set or
|
|
76
|
+
dataset refused before its rows are loaded carries neither; the message
|
|
77
|
+
names the cap.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def max_rows(self) -> Optional[int]:
|
|
82
|
+
return self._detail("maxRows")
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def rows(self) -> Optional[int]:
|
|
86
|
+
return self._detail("rows")
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def max_bytes(self) -> Optional[int]:
|
|
90
|
+
return self._detail("maxBytes")
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def bytes(self) -> Optional[int]:
|
|
94
|
+
return self._detail("bytes")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from dotenv import load_dotenv
|
|
3
|
-
from typing import Any, Optional, Dict, List
|
|
3
|
+
from typing import Any, Optional, Dict, List, Union
|
|
4
4
|
from .types import WorkspaceUploadResult
|
|
5
5
|
from .routes.base import BaseClient
|
|
6
6
|
from .routes.generation import GenerationClient
|
|
@@ -26,6 +26,7 @@ from .routes.sets import SetsClient
|
|
|
26
26
|
from .routes.plans import PlansClient
|
|
27
27
|
from .routes.masking_policies import MaskingPoliciesClient
|
|
28
28
|
from .routes.keymaps import KeyMapsClient
|
|
29
|
+
from .routes.tosca import ToscaClient, ToscaDatasetPushResult
|
|
29
30
|
|
|
30
31
|
load_dotenv()
|
|
31
32
|
|
|
@@ -79,6 +80,7 @@ class DataMaker:
|
|
|
79
80
|
self._masking_policies = MaskingPoliciesClient(
|
|
80
81
|
api_key, default_headers, base_url, verify
|
|
81
82
|
)
|
|
83
|
+
self._tosca = ToscaClient(api_key, default_headers, base_url, verify)
|
|
82
84
|
|
|
83
85
|
# Maintain backward compatibility
|
|
84
86
|
self.api_key = self._generation.api_key
|
|
@@ -939,6 +941,103 @@ class DataMaker:
|
|
|
939
941
|
project_id=project_id,
|
|
940
942
|
)
|
|
941
943
|
|
|
944
|
+
# =================== TOSCA METHODS ===================
|
|
945
|
+
def get_tosca_datasets(self, integration_id: str, space: Optional[str] = None):
|
|
946
|
+
"""List the test data sets in a Tosca Cloud space.
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
integration_id: The Tosca Cloud connection to read through.
|
|
950
|
+
space: The Tosca Cloud space (id or name). Defaults to "default".
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
A dictionary with space and items, each item carrying id, name,
|
|
954
|
+
subsetCount and isLocked.
|
|
955
|
+
"""
|
|
956
|
+
return self._tosca.get_tosca_datasets(integration_id, space)
|
|
957
|
+
|
|
958
|
+
def push_tosca_dataset(
|
|
959
|
+
self,
|
|
960
|
+
integration_id: str,
|
|
961
|
+
name: str,
|
|
962
|
+
*,
|
|
963
|
+
set_id: Optional[str] = None,
|
|
964
|
+
input_set_id: Optional[str] = None,
|
|
965
|
+
version: Optional[Union[int, str]] = None,
|
|
966
|
+
dataset_id: Optional[str] = None,
|
|
967
|
+
rows: Optional[List[Dict[str, Any]]] = None,
|
|
968
|
+
allow_unlocked: bool = False,
|
|
969
|
+
on_existing: str = "fail",
|
|
970
|
+
on_schema_change: str = "refuse",
|
|
971
|
+
subsets_by: Optional[Union[str, bool]] = None,
|
|
972
|
+
space: str = "default",
|
|
973
|
+
) -> ToscaDatasetPushResult:
|
|
974
|
+
"""Create or replace a Tosca Cloud data set from DataMaker rows.
|
|
975
|
+
|
|
976
|
+
A Tosca test step reads the data set with
|
|
977
|
+
{TDM[<data set>][<subset>][<column>]}. Give exactly one source: set_id,
|
|
978
|
+
input_set_id (with an optional version), dataset_id or rows.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
integration_id: The Tosca Cloud connection to push through.
|
|
982
|
+
name: The Tosca data set name. Unique per space.
|
|
983
|
+
set_id: Push the rows of this DataMaker set. It must be locked
|
|
984
|
+
unless allow_unlocked is True.
|
|
985
|
+
input_set_id: Push a frozen version of this input set.
|
|
986
|
+
version: The input set version number, or "latest" (the default).
|
|
987
|
+
dataset_id: Push the rows of this DataMaker dataset.
|
|
988
|
+
rows: Inline rows, each a dictionary of column name to value.
|
|
989
|
+
allow_unlocked: Push a set that is not locked.
|
|
990
|
+
on_existing: "fail" (default), "overwrite" (replace the rows,
|
|
991
|
+
keeping the id and subsets) or "new" (a copy beside it).
|
|
992
|
+
on_schema_change: On overwrite, when the rows drop a column:
|
|
993
|
+
"refuse" (default) or "recreate" (the id and subsets are lost).
|
|
994
|
+
subsets_by: Create one subset per distinct value of this column.
|
|
995
|
+
An input set is split by "TestCase" by default; False turns
|
|
996
|
+
it off.
|
|
997
|
+
space: The Tosca Cloud space (id or name). Defaults to "default".
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
A result with dataset_id, name, space, rows_count, outcome
|
|
1001
|
+
("created", "overwritten", "recreated" or "copied"), columns,
|
|
1002
|
+
added_columns, subsets and example.
|
|
1003
|
+
|
|
1004
|
+
Raises:
|
|
1005
|
+
ReadOnlyConnectionError: The connection is read-only (403).
|
|
1006
|
+
ToscaDatasetExistsError: The name is taken and on_existing is
|
|
1007
|
+
"fail" (409). Carries dataset_id.
|
|
1008
|
+
ToscaDatasetLockedError: An overwrite of a data set that is
|
|
1009
|
+
locked in Tosca Cloud (409). Carries dataset_id.
|
|
1010
|
+
ToscaDatasetSchemaChangedError: An overwrite would drop columns
|
|
1011
|
+
(409). Carries added_columns and removed_columns.
|
|
1012
|
+
ToscaDatasetTooLargeError: Too many rows or bytes for one push
|
|
1013
|
+
(413). Carries max_rows or max_bytes when the API sends them.
|
|
1014
|
+
|
|
1015
|
+
Example:
|
|
1016
|
+
>>> dm = DataMaker()
|
|
1017
|
+
>>> result = dm.push_tosca_dataset(
|
|
1018
|
+
... integration_id,
|
|
1019
|
+
... name="Orders",
|
|
1020
|
+
... input_set_id="is_123",
|
|
1021
|
+
... on_existing="overwrite",
|
|
1022
|
+
... )
|
|
1023
|
+
>>> result.example
|
|
1024
|
+
'{TDM[Orders][Customer]}'
|
|
1025
|
+
"""
|
|
1026
|
+
return self._tosca.push_tosca_dataset(
|
|
1027
|
+
integration_id,
|
|
1028
|
+
name,
|
|
1029
|
+
set_id=set_id,
|
|
1030
|
+
input_set_id=input_set_id,
|
|
1031
|
+
version=version,
|
|
1032
|
+
dataset_id=dataset_id,
|
|
1033
|
+
rows=rows,
|
|
1034
|
+
allow_unlocked=allow_unlocked,
|
|
1035
|
+
on_existing=on_existing,
|
|
1036
|
+
on_schema_change=on_schema_change,
|
|
1037
|
+
subsets_by=subsets_by,
|
|
1038
|
+
space=space,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
942
1041
|
# =================== PROPERTY ACCESS TO CLIENTS ===================
|
|
943
1042
|
# For advanced users who want direct access to specific clients
|
|
944
1043
|
|
|
@@ -1174,3 +1273,8 @@ class DataMaker:
|
|
|
1174
1273
|
def masking_policies(self):
|
|
1175
1274
|
"""Access to masking policies client."""
|
|
1176
1275
|
return self._masking_policies
|
|
1276
|
+
|
|
1277
|
+
@property
|
|
1278
|
+
def tosca(self):
|
|
1279
|
+
"""Access to Tosca client."""
|
|
1280
|
+
return self._tosca
|
|
@@ -14,6 +14,7 @@ from .export_and_validation import ExportClient, ValidationClient
|
|
|
14
14
|
from .scenario_files import ScenarioFilesClient
|
|
15
15
|
from .sets import SetsClient
|
|
16
16
|
from .keymaps import KeyMapsClient
|
|
17
|
+
from .tosca import ToscaClient
|
|
17
18
|
|
|
18
19
|
__all__ = [
|
|
19
20
|
"BaseClient",
|
|
@@ -36,4 +37,5 @@ __all__ = [
|
|
|
36
37
|
"ScenarioFilesClient",
|
|
37
38
|
"SetsClient",
|
|
38
39
|
"KeyMapsClient",
|
|
40
|
+
"ToscaClient",
|
|
39
41
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import requests
|
|
3
3
|
from typing import Optional, Dict
|
|
4
|
-
from ..error import
|
|
4
|
+
from ..error import DataMakerAPIError
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class BaseClient:
|
|
@@ -35,6 +35,14 @@ class BaseClient:
|
|
|
35
35
|
response = requests.request(method, url, headers=self.headers, **kwargs)
|
|
36
36
|
|
|
37
37
|
if response.status_code not in [200, 201]:
|
|
38
|
-
|
|
38
|
+
try:
|
|
39
|
+
body = response.json()
|
|
40
|
+
except ValueError:
|
|
41
|
+
body = None
|
|
42
|
+
raise DataMakerAPIError(
|
|
43
|
+
f"API request failed: {response.text}",
|
|
44
|
+
status_code=response.status_code,
|
|
45
|
+
body=body,
|
|
46
|
+
)
|
|
39
47
|
|
|
40
48
|
return response
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Client for Tosca Cloud test data sets.
|
|
2
|
+
|
|
3
|
+
A Tosca Cloud data set (Prepare > Test data) is what a test step reads with
|
|
4
|
+
``{TDM[<data set>][<subset>][<column>]}``. The push goes through DataMaker:
|
|
5
|
+
the API reads the rows with the caller's own access, uploads them to Tosca
|
|
6
|
+
with the connection's credentials and writes the audit event, so a scenario
|
|
7
|
+
never holds Tosca credentials or calls Tosca itself.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from .base import BaseClient
|
|
15
|
+
from ..error import (
|
|
16
|
+
DataMakerAPIError,
|
|
17
|
+
DataMakerError,
|
|
18
|
+
ReadOnlyConnectionError,
|
|
19
|
+
ToscaDatasetExistsError,
|
|
20
|
+
ToscaDatasetLockedError,
|
|
21
|
+
ToscaDatasetSchemaChangedError,
|
|
22
|
+
ToscaDatasetTooLargeError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
ON_EXISTING = ("fail", "overwrite", "new")
|
|
26
|
+
ON_SCHEMA_CHANGE = ("refuse", "recreate")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ToscaDatasetPushResult:
|
|
31
|
+
"""The data set as Tosca holds it after a push."""
|
|
32
|
+
|
|
33
|
+
dataset_id: str
|
|
34
|
+
name: str
|
|
35
|
+
space: str
|
|
36
|
+
rows_count: int
|
|
37
|
+
# "created", "overwritten", "recreated" or "copied"
|
|
38
|
+
outcome: str
|
|
39
|
+
# A ready-to-paste reference, e.g. "{TDM[Orders][Customer]}"
|
|
40
|
+
example: str
|
|
41
|
+
# [{"name", "type"}] as Tosca typed them: String, Double, Boolean, DateTime
|
|
42
|
+
columns: List[Dict[str, str]] = field(default_factory=list)
|
|
43
|
+
# Columns an overwrite added to the existing data set
|
|
44
|
+
added_columns: List[str] = field(default_factory=list)
|
|
45
|
+
# {"created": [names], "existing": [names], "skipped": count}
|
|
46
|
+
subsets: Dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
# The API's response as sent, for fields this class does not name yet.
|
|
48
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ToscaClient(BaseClient):
|
|
52
|
+
"""Client for Tosca Cloud test data operations."""
|
|
53
|
+
|
|
54
|
+
def get_tosca_datasets(
|
|
55
|
+
self, integration_id: str, space: Optional[str] = None
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
"""List the test data sets in a Tosca Cloud space.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
integration_id: The Tosca Cloud connection to read through.
|
|
61
|
+
space: The Tosca Cloud space (id or name). Defaults to "default".
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A dictionary with ``space`` and ``items``, each item carrying
|
|
65
|
+
``id``, ``name``, ``subsetCount`` and ``isLocked``.
|
|
66
|
+
"""
|
|
67
|
+
if not integration_id:
|
|
68
|
+
raise DataMakerError("integration_id is required to list Tosca data sets.")
|
|
69
|
+
|
|
70
|
+
endpoint = f"/integrations/{integration_id}/tosca/datasets"
|
|
71
|
+
if space:
|
|
72
|
+
endpoint += f"?space={quote(space, safe='')}"
|
|
73
|
+
|
|
74
|
+
response = self._make_request("GET", endpoint)
|
|
75
|
+
return response.json()
|
|
76
|
+
|
|
77
|
+
def push_tosca_dataset(
|
|
78
|
+
self,
|
|
79
|
+
integration_id: str,
|
|
80
|
+
name: str,
|
|
81
|
+
*,
|
|
82
|
+
set_id: Optional[str] = None,
|
|
83
|
+
input_set_id: Optional[str] = None,
|
|
84
|
+
version: Optional[Union[int, str]] = None,
|
|
85
|
+
dataset_id: Optional[str] = None,
|
|
86
|
+
rows: Optional[List[Dict[str, Any]]] = None,
|
|
87
|
+
allow_unlocked: bool = False,
|
|
88
|
+
on_existing: str = "fail",
|
|
89
|
+
on_schema_change: str = "refuse",
|
|
90
|
+
subsets_by: Optional[Union[str, bool]] = None,
|
|
91
|
+
space: str = "default",
|
|
92
|
+
) -> ToscaDatasetPushResult:
|
|
93
|
+
"""Create or replace a Tosca Cloud data set from DataMaker rows.
|
|
94
|
+
|
|
95
|
+
Give exactly one source: ``set_id``, ``input_set_id`` (with an optional
|
|
96
|
+
``version``), ``dataset_id`` or ``rows``.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
integration_id: The Tosca Cloud connection to push through.
|
|
100
|
+
name: The Tosca data set name. Unique per space; test steps
|
|
101
|
+
reference the data set by this name.
|
|
102
|
+
set_id: Push the rows of this DataMaker set. The set must be
|
|
103
|
+
locked unless ``allow_unlocked`` is True.
|
|
104
|
+
input_set_id: Push a frozen version of this input set.
|
|
105
|
+
version: The input set version number, or "latest" (the default).
|
|
106
|
+
Only valid with ``input_set_id``.
|
|
107
|
+
dataset_id: Push the rows of this DataMaker dataset.
|
|
108
|
+
rows: Inline rows, each a dictionary of column name to value.
|
|
109
|
+
Column types are inferred.
|
|
110
|
+
allow_unlocked: Push a set that is not locked. Only valid with
|
|
111
|
+
``set_id``.
|
|
112
|
+
on_existing: What to do when a data set called ``name`` exists:
|
|
113
|
+
"fail" (default), "overwrite" (replace its rows, keeping its
|
|
114
|
+
id and subsets) or "new" (Tosca creates a copy beside it).
|
|
115
|
+
on_schema_change: On overwrite, when the rows drop a column the
|
|
116
|
+
data set has: "refuse" (default) or "recreate" (delete and
|
|
117
|
+
recreate it; test steps keep working, its id and subsets are
|
|
118
|
+
lost). Added columns are always handled.
|
|
119
|
+
subsets_by: Create one subset per distinct value of this column,
|
|
120
|
+
so each Tosca test case reads only its rows. Left unset, an
|
|
121
|
+
input set is split by "TestCase" and other sources are not
|
|
122
|
+
split. False turns it off.
|
|
123
|
+
space: The Tosca Cloud space (id or name). Defaults to "default".
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A ``ToscaDatasetPushResult`` with ``dataset_id``, ``name``,
|
|
127
|
+
``space``, ``rows_count``, ``outcome``, ``columns``,
|
|
128
|
+
``added_columns``, ``subsets`` and ``example``.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
DataMakerError: If the arguments do not describe exactly one source.
|
|
132
|
+
ReadOnlyConnectionError: 403, the connection is read-only.
|
|
133
|
+
ToscaDatasetExistsError: 409, the name is taken and ``on_existing``
|
|
134
|
+
is "fail". Carries ``dataset_id``.
|
|
135
|
+
ToscaDatasetLockedError: 409, an overwrite of a data set that is
|
|
136
|
+
locked in Tosca Cloud. Carries ``dataset_id``.
|
|
137
|
+
ToscaDatasetSchemaChangedError: 409, an overwrite would drop
|
|
138
|
+
columns. Carries ``added_columns`` and ``removed_columns``.
|
|
139
|
+
ToscaDatasetTooLargeError: 413, more rows or bytes than one push
|
|
140
|
+
allows. Carries ``max_rows`` or ``max_bytes`` when the API
|
|
141
|
+
sends them.
|
|
142
|
+
DataMakerAPIError: Any other non-2xx answer, with ``status_code``
|
|
143
|
+
and ``body``.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> dm = DataMaker()
|
|
147
|
+
>>> result = dm.push_tosca_dataset(
|
|
148
|
+
... integration_id,
|
|
149
|
+
... name="Orders",
|
|
150
|
+
... input_set_id="is_123",
|
|
151
|
+
... on_existing="overwrite",
|
|
152
|
+
... )
|
|
153
|
+
>>> result.example
|
|
154
|
+
'{TDM[Orders][Customer]}'
|
|
155
|
+
"""
|
|
156
|
+
if not integration_id:
|
|
157
|
+
raise DataMakerError("integration_id is required to push a Tosca data set.")
|
|
158
|
+
if not name:
|
|
159
|
+
raise DataMakerError("name is required to push a Tosca data set.")
|
|
160
|
+
if on_existing not in ON_EXISTING:
|
|
161
|
+
raise DataMakerError(
|
|
162
|
+
f"on_existing must be one of {', '.join(ON_EXISTING)}; got {on_existing!r}."
|
|
163
|
+
)
|
|
164
|
+
if on_schema_change not in ON_SCHEMA_CHANGE:
|
|
165
|
+
raise DataMakerError(
|
|
166
|
+
f"on_schema_change must be one of {', '.join(ON_SCHEMA_CHANGE)}; "
|
|
167
|
+
f"got {on_schema_change!r}."
|
|
168
|
+
)
|
|
169
|
+
if subsets_by is True or subsets_by == "":
|
|
170
|
+
raise DataMakerError("subsets_by must be a column name or False.")
|
|
171
|
+
|
|
172
|
+
given = [
|
|
173
|
+
label
|
|
174
|
+
for label, value in (
|
|
175
|
+
("set_id", set_id),
|
|
176
|
+
("input_set_id", input_set_id),
|
|
177
|
+
("dataset_id", dataset_id),
|
|
178
|
+
("rows", rows),
|
|
179
|
+
)
|
|
180
|
+
if value is not None
|
|
181
|
+
]
|
|
182
|
+
if len(given) != 1:
|
|
183
|
+
raise DataMakerError(
|
|
184
|
+
"Give exactly one source: set_id, input_set_id, dataset_id or rows"
|
|
185
|
+
+ (f"; got {', '.join(given)}." if given else "; got none.")
|
|
186
|
+
)
|
|
187
|
+
if version is not None and input_set_id is None:
|
|
188
|
+
raise DataMakerError("version only applies to input_set_id.")
|
|
189
|
+
if allow_unlocked and set_id is None:
|
|
190
|
+
raise DataMakerError("allow_unlocked only applies to set_id.")
|
|
191
|
+
|
|
192
|
+
source: Dict[str, Any]
|
|
193
|
+
if set_id is not None:
|
|
194
|
+
source = {"kind": "set", "setId": set_id}
|
|
195
|
+
if allow_unlocked:
|
|
196
|
+
source["allowUnlocked"] = True
|
|
197
|
+
elif input_set_id is not None:
|
|
198
|
+
source = {
|
|
199
|
+
"kind": "inputSet",
|
|
200
|
+
"inputSetId": input_set_id,
|
|
201
|
+
"version": "latest" if version is None else version,
|
|
202
|
+
}
|
|
203
|
+
elif dataset_id is not None:
|
|
204
|
+
source = {"kind": "dataset", "datasetId": dataset_id}
|
|
205
|
+
else:
|
|
206
|
+
if not rows:
|
|
207
|
+
raise DataMakerError("rows must not be empty.")
|
|
208
|
+
source = {"kind": "rows", "rows": rows}
|
|
209
|
+
|
|
210
|
+
payload: Dict[str, Any] = {
|
|
211
|
+
"space": space,
|
|
212
|
+
"name": name,
|
|
213
|
+
"source": source,
|
|
214
|
+
"onExisting": on_existing,
|
|
215
|
+
"onSchemaChange": on_schema_change,
|
|
216
|
+
}
|
|
217
|
+
if subsets_by is not None:
|
|
218
|
+
payload["subsetsBy"] = subsets_by
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
response = self._make_request(
|
|
222
|
+
"POST", f"/integrations/{integration_id}/tosca/datasets", json=payload
|
|
223
|
+
)
|
|
224
|
+
except DataMakerAPIError as error:
|
|
225
|
+
raise self._typed_error(error, on_existing) from error
|
|
226
|
+
|
|
227
|
+
data = response.json()
|
|
228
|
+
return ToscaDatasetPushResult(
|
|
229
|
+
dataset_id=data["datasetId"],
|
|
230
|
+
name=data["name"],
|
|
231
|
+
space=data["space"],
|
|
232
|
+
rows_count=data["rowsCount"],
|
|
233
|
+
outcome=data["outcome"],
|
|
234
|
+
example=data["example"],
|
|
235
|
+
columns=data.get("columns") or [],
|
|
236
|
+
added_columns=data.get("addedColumns") or [],
|
|
237
|
+
subsets=data.get("subsets") or {},
|
|
238
|
+
raw=data,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _typed_error(error: DataMakerAPIError, on_existing: str) -> DataMakerAPIError:
|
|
243
|
+
"""Narrow a failed push to the error a scenario can act on."""
|
|
244
|
+
body = error.body if isinstance(error.body, dict) else {}
|
|
245
|
+
args = (str(error), error.status_code, error.body)
|
|
246
|
+
|
|
247
|
+
# 403 is also what a caller without read access to the source gets,
|
|
248
|
+
# so the status alone does not mean the connection is read-only.
|
|
249
|
+
if error.status_code == 403 and "read-only" in str(body.get("error", "")):
|
|
250
|
+
return ReadOnlyConnectionError(*args)
|
|
251
|
+
if error.status_code == 409:
|
|
252
|
+
by_code = {
|
|
253
|
+
"exists": ToscaDatasetExistsError,
|
|
254
|
+
"locked": ToscaDatasetLockedError,
|
|
255
|
+
"schemaChanged": ToscaDatasetSchemaChangedError,
|
|
256
|
+
}
|
|
257
|
+
if body.get("code") in by_code:
|
|
258
|
+
return by_code[body["code"]](*args)
|
|
259
|
+
# No code: an API from before the 409s were named. "Name taken"
|
|
260
|
+
# and "locked" then share a body shape, and which one it is
|
|
261
|
+
# follows from what the push asked for.
|
|
262
|
+
if "added" in body or "removed" in body:
|
|
263
|
+
return ToscaDatasetSchemaChangedError(*args)
|
|
264
|
+
if "datasetId" in body and on_existing == "fail":
|
|
265
|
+
return ToscaDatasetExistsError(*args)
|
|
266
|
+
if "datasetId" in body and on_existing == "overwrite":
|
|
267
|
+
return ToscaDatasetLockedError(*args)
|
|
268
|
+
if error.status_code == 413:
|
|
269
|
+
return ToscaDatasetTooLargeError(*args)
|
|
270
|
+
return error
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: datamaker-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: The official Python library for the Automators DataMaker API.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -62,6 +62,35 @@ if __name__ == "__main__":
|
|
|
62
62
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
## Push a set into Tosca Cloud
|
|
66
|
+
|
|
67
|
+
`push_tosca_dataset` creates or replaces a native Tosca Cloud data set from a saved
|
|
68
|
+
set, an input set version, a dataset or inline rows. A Tosca test step reads it with
|
|
69
|
+
`{TDM[<data set>][<subset>][<column>]}`.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from datamaker import DataMaker, ToscaDatasetSchemaChangedError
|
|
73
|
+
|
|
74
|
+
dm = DataMaker()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = dm.push_tosca_dataset(
|
|
78
|
+
integration_id,
|
|
79
|
+
name="Orders",
|
|
80
|
+
input_set_id="is_123", # or set_id=..., dataset_id=..., rows=[...]
|
|
81
|
+
on_existing="overwrite", # "fail" (default) | "overwrite" | "new"
|
|
82
|
+
)
|
|
83
|
+
print(result.dataset_id, result.rows_count, result.outcome, result.example)
|
|
84
|
+
except ToscaDatasetSchemaChangedError as e:
|
|
85
|
+
# The rows dropped a column the data set has. Pass on_schema_change="recreate"
|
|
86
|
+
# to delete and recreate it instead.
|
|
87
|
+
print("removed:", e.removed_columns, "added:", e.added_columns)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An input set gets one Tosca subset per test case by default; `subsets_by` picks another
|
|
91
|
+
column or turns it off with `False`. `get_tosca_datasets(integration_id)` lists what a
|
|
92
|
+
space already holds.
|
|
93
|
+
|
|
65
94
|
## Autocomplete Types
|
|
66
95
|
|
|
67
96
|
This SDK automatically generates TypeScript autocomplete definitions for use in the [datamaker application](https://github.com/automators/datamaker).
|
|
@@ -25,6 +25,7 @@ src/datamaker/routes/scenario_files.py
|
|
|
25
25
|
src/datamaker/routes/sets.py
|
|
26
26
|
src/datamaker/routes/teams.py
|
|
27
27
|
src/datamaker/routes/templates.py
|
|
28
|
+
src/datamaker/routes/tosca.py
|
|
28
29
|
src/datamaker/routes/users.py
|
|
29
30
|
src/datamaker_py.egg-info/PKG-INFO
|
|
30
31
|
src/datamaker_py.egg-info/SOURCES.txt
|
|
@@ -33,4 +34,5 @@ src/datamaker_py.egg-info/requires.txt
|
|
|
33
34
|
src/datamaker_py.egg-info/top_level.txt
|
|
34
35
|
tests/test_integration.py
|
|
35
36
|
tests/test_main.py
|
|
36
|
-
tests/test_routes.py
|
|
37
|
+
tests/test_routes.py
|
|
38
|
+
tests/test_tosca.py
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Tests for pushing rows into Tosca Cloud data sets."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from unittest.mock import Mock, patch
|
|
7
|
+
|
|
8
|
+
from src.datamaker.main import DataMaker
|
|
9
|
+
from src.datamaker.error import (
|
|
10
|
+
DataMakerAPIError,
|
|
11
|
+
DataMakerError,
|
|
12
|
+
ReadOnlyConnectionError,
|
|
13
|
+
ToscaDatasetExistsError,
|
|
14
|
+
ToscaDatasetLockedError,
|
|
15
|
+
ToscaDatasetSchemaChangedError,
|
|
16
|
+
ToscaDatasetTooLargeError,
|
|
17
|
+
)
|
|
18
|
+
from src.datamaker.routes.tosca import ToscaDatasetPushResult
|
|
19
|
+
|
|
20
|
+
PUSHED = {
|
|
21
|
+
"datasetId": "TDS-1",
|
|
22
|
+
"name": "Orders",
|
|
23
|
+
"space": "default",
|
|
24
|
+
"rowsCount": 2,
|
|
25
|
+
"columns": [{"name": "Customer", "type": "String"}],
|
|
26
|
+
"outcome": "created",
|
|
27
|
+
"addedColumns": [],
|
|
28
|
+
"subsets": {"created": ["TC-1"], "existing": [], "skipped": 0},
|
|
29
|
+
"example": "{TDM[Orders][Customer]}",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ok(body=None, status_code=201):
|
|
34
|
+
response = Mock()
|
|
35
|
+
response.status_code = status_code
|
|
36
|
+
response.json.return_value = body or PUSHED
|
|
37
|
+
return response
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def failed(status_code, body):
|
|
41
|
+
response = Mock()
|
|
42
|
+
response.status_code = status_code
|
|
43
|
+
response.text = json.dumps(body)
|
|
44
|
+
response.json.return_value = body
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def push(api_key, **kwargs):
|
|
49
|
+
return DataMaker(api_key=api_key).push_tosca_dataset(
|
|
50
|
+
"int-1", name="Orders", **kwargs
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestPushToscaDataset:
|
|
55
|
+
"""The request each source kind sends, and the result it returns."""
|
|
56
|
+
|
|
57
|
+
@patch("requests.request")
|
|
58
|
+
def test_set_source(self, mock_request, api_key):
|
|
59
|
+
mock_request.return_value = ok()
|
|
60
|
+
|
|
61
|
+
result = push(api_key, set_id="set-1")
|
|
62
|
+
|
|
63
|
+
method, url = mock_request.call_args.args
|
|
64
|
+
assert method == "POST"
|
|
65
|
+
assert url.endswith("/integrations/int-1/tosca/datasets")
|
|
66
|
+
assert mock_request.call_args.kwargs["json"] == {
|
|
67
|
+
"space": "default",
|
|
68
|
+
"name": "Orders",
|
|
69
|
+
"source": {"kind": "set", "setId": "set-1"},
|
|
70
|
+
"onExisting": "fail",
|
|
71
|
+
"onSchemaChange": "refuse",
|
|
72
|
+
}
|
|
73
|
+
assert isinstance(result, ToscaDatasetPushResult)
|
|
74
|
+
assert result.dataset_id == "TDS-1"
|
|
75
|
+
assert result.name == "Orders"
|
|
76
|
+
assert result.space == "default"
|
|
77
|
+
assert result.rows_count == 2
|
|
78
|
+
assert result.outcome == "created"
|
|
79
|
+
assert result.columns == [{"name": "Customer", "type": "String"}]
|
|
80
|
+
assert result.added_columns == []
|
|
81
|
+
assert result.subsets == {"created": ["TC-1"], "existing": [], "skipped": 0}
|
|
82
|
+
assert result.example == "{TDM[Orders][Customer]}"
|
|
83
|
+
assert result.raw == PUSHED
|
|
84
|
+
|
|
85
|
+
@patch("requests.request")
|
|
86
|
+
def test_unlocked_set(self, mock_request, api_key):
|
|
87
|
+
mock_request.return_value = ok()
|
|
88
|
+
|
|
89
|
+
push(api_key, set_id="set-1", allow_unlocked=True)
|
|
90
|
+
|
|
91
|
+
assert mock_request.call_args.kwargs["json"]["source"] == {
|
|
92
|
+
"kind": "set",
|
|
93
|
+
"setId": "set-1",
|
|
94
|
+
"allowUnlocked": True,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@patch("requests.request")
|
|
98
|
+
def test_input_set_source_defaults_to_latest(self, mock_request, api_key):
|
|
99
|
+
mock_request.return_value = ok()
|
|
100
|
+
|
|
101
|
+
push(api_key, input_set_id="is-1")
|
|
102
|
+
|
|
103
|
+
payload = mock_request.call_args.kwargs["json"]
|
|
104
|
+
assert payload["source"] == {
|
|
105
|
+
"kind": "inputSet",
|
|
106
|
+
"inputSetId": "is-1",
|
|
107
|
+
"version": "latest",
|
|
108
|
+
}
|
|
109
|
+
# Left out so the API applies its own default (TestCase for an input set).
|
|
110
|
+
assert "subsetsBy" not in payload
|
|
111
|
+
|
|
112
|
+
@patch("requests.request")
|
|
113
|
+
def test_input_set_source_with_options(self, mock_request, api_key):
|
|
114
|
+
mock_request.return_value = ok(
|
|
115
|
+
{**PUSHED, "outcome": "overwritten", "addedColumns": ["Region"]},
|
|
116
|
+
status_code=200,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
result = push(
|
|
120
|
+
api_key,
|
|
121
|
+
input_set_id="is-1",
|
|
122
|
+
version=3,
|
|
123
|
+
on_existing="overwrite",
|
|
124
|
+
on_schema_change="recreate",
|
|
125
|
+
subsets_by="Region",
|
|
126
|
+
space="qa",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
payload = mock_request.call_args.kwargs["json"]
|
|
130
|
+
assert payload["source"]["version"] == 3
|
|
131
|
+
assert payload["onExisting"] == "overwrite"
|
|
132
|
+
assert payload["onSchemaChange"] == "recreate"
|
|
133
|
+
assert payload["subsetsBy"] == "Region"
|
|
134
|
+
assert payload["space"] == "qa"
|
|
135
|
+
assert result.outcome == "overwritten"
|
|
136
|
+
assert result.added_columns == ["Region"]
|
|
137
|
+
|
|
138
|
+
@patch("requests.request")
|
|
139
|
+
def test_subsets_can_be_turned_off(self, mock_request, api_key):
|
|
140
|
+
mock_request.return_value = ok()
|
|
141
|
+
|
|
142
|
+
push(api_key, input_set_id="is-1", subsets_by=False)
|
|
143
|
+
|
|
144
|
+
assert mock_request.call_args.kwargs["json"]["subsetsBy"] is False
|
|
145
|
+
|
|
146
|
+
@patch("requests.request")
|
|
147
|
+
def test_dataset_source(self, mock_request, api_key):
|
|
148
|
+
mock_request.return_value = ok()
|
|
149
|
+
|
|
150
|
+
push(api_key, dataset_id="ds-1")
|
|
151
|
+
|
|
152
|
+
assert mock_request.call_args.kwargs["json"]["source"] == {
|
|
153
|
+
"kind": "dataset",
|
|
154
|
+
"datasetId": "ds-1",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@patch("requests.request")
|
|
158
|
+
def test_rows_source(self, mock_request, api_key):
|
|
159
|
+
mock_request.return_value = ok({**PUSHED, "outcome": "copied"})
|
|
160
|
+
rows = [{"Customer": "0042"}, {"Customer": "0043"}]
|
|
161
|
+
|
|
162
|
+
result = push(api_key, rows=rows, on_existing="new")
|
|
163
|
+
|
|
164
|
+
payload = mock_request.call_args.kwargs["json"]
|
|
165
|
+
assert payload["source"] == {"kind": "rows", "rows": rows}
|
|
166
|
+
assert payload["onExisting"] == "new"
|
|
167
|
+
assert result.outcome == "copied"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestPushToscaDatasetValidation:
|
|
171
|
+
"""Bad arguments are refused before any request is made."""
|
|
172
|
+
|
|
173
|
+
@pytest.mark.parametrize(
|
|
174
|
+
"kwargs, match",
|
|
175
|
+
[
|
|
176
|
+
({}, "exactly one source"),
|
|
177
|
+
({"set_id": "s", "input_set_id": "i"}, "exactly one source"),
|
|
178
|
+
({"set_id": "s", "rows": [{"a": 1}]}, "exactly one source"),
|
|
179
|
+
({"input_set_id": "i", "dataset_id": "d"}, "exactly one source"),
|
|
180
|
+
({"rows": []}, "rows must not be empty"),
|
|
181
|
+
({"set_id": "s", "version": 2}, "version only applies"),
|
|
182
|
+
({"dataset_id": "d", "allow_unlocked": True}, "allow_unlocked only applies"),
|
|
183
|
+
({"set_id": "s", "on_existing": "replace"}, "on_existing must be one of"),
|
|
184
|
+
({"set_id": "s", "on_schema_change": "drop"}, "on_schema_change must be"),
|
|
185
|
+
({"set_id": "s", "subsets_by": True}, "subsets_by must be"),
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
@patch("requests.request")
|
|
189
|
+
def test_rejected_locally(self, mock_request, api_key, kwargs, match):
|
|
190
|
+
with pytest.raises(DataMakerError, match=match):
|
|
191
|
+
push(api_key, **kwargs)
|
|
192
|
+
mock_request.assert_not_called()
|
|
193
|
+
|
|
194
|
+
@patch("requests.request")
|
|
195
|
+
def test_requires_integration_id_and_name(self, mock_request, api_key):
|
|
196
|
+
dm = DataMaker(api_key=api_key)
|
|
197
|
+
with pytest.raises(DataMakerError, match="integration_id is required"):
|
|
198
|
+
dm.push_tosca_dataset("", name="Orders", set_id="s")
|
|
199
|
+
with pytest.raises(DataMakerError, match="name is required"):
|
|
200
|
+
dm.push_tosca_dataset("int-1", name="", set_id="s")
|
|
201
|
+
mock_request.assert_not_called()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestPushToscaDatasetErrors:
|
|
205
|
+
"""Each failure a scenario can act on arrives as its own type."""
|
|
206
|
+
|
|
207
|
+
@patch("requests.request")
|
|
208
|
+
def test_read_only_connection(self, mock_request, api_key):
|
|
209
|
+
body = {
|
|
210
|
+
"error": "This Tosca connection is read-only, so creating a test "
|
|
211
|
+
"data set is blocked."
|
|
212
|
+
}
|
|
213
|
+
mock_request.return_value = failed(403, body)
|
|
214
|
+
|
|
215
|
+
with pytest.raises(ReadOnlyConnectionError, match="read-only") as raised:
|
|
216
|
+
push(api_key, set_id="s")
|
|
217
|
+
|
|
218
|
+
assert raised.value.status_code == 403
|
|
219
|
+
assert raised.value.body == body
|
|
220
|
+
|
|
221
|
+
@patch("requests.request")
|
|
222
|
+
def test_403_for_an_unreadable_source_is_not_read_only(
|
|
223
|
+
self, mock_request, api_key
|
|
224
|
+
):
|
|
225
|
+
mock_request.return_value = failed(403, {"error": "Missing set:read"})
|
|
226
|
+
|
|
227
|
+
with pytest.raises(DataMakerAPIError) as raised:
|
|
228
|
+
push(api_key, set_id="s")
|
|
229
|
+
|
|
230
|
+
assert type(raised.value) is DataMakerAPIError
|
|
231
|
+
assert raised.value.status_code == 403
|
|
232
|
+
|
|
233
|
+
@patch("requests.request")
|
|
234
|
+
def test_schema_change(self, mock_request, api_key):
|
|
235
|
+
mock_request.return_value = failed(
|
|
236
|
+
409,
|
|
237
|
+
{
|
|
238
|
+
"error": "The rows no longer have column Country",
|
|
239
|
+
"datasetId": "TDS-1",
|
|
240
|
+
"added": ["Region"],
|
|
241
|
+
"removed": ["Country"],
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
with pytest.raises(ToscaDatasetSchemaChangedError) as raised:
|
|
246
|
+
push(api_key, set_id="s", on_existing="overwrite")
|
|
247
|
+
|
|
248
|
+
assert raised.value.status_code == 409
|
|
249
|
+
assert raised.value.dataset_id == "TDS-1"
|
|
250
|
+
assert raised.value.added_columns == ["Region"]
|
|
251
|
+
assert raised.value.removed_columns == ["Country"]
|
|
252
|
+
|
|
253
|
+
@patch("requests.request")
|
|
254
|
+
def test_name_exists(self, mock_request, api_key):
|
|
255
|
+
mock_request.return_value = failed(
|
|
256
|
+
409, {"error": "A data set named Orders exists", "datasetId": "TDS-1"}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with pytest.raises(ToscaDatasetExistsError) as raised:
|
|
260
|
+
push(api_key, set_id="s")
|
|
261
|
+
|
|
262
|
+
assert raised.value.status_code == 409
|
|
263
|
+
assert raised.value.dataset_id == "TDS-1"
|
|
264
|
+
|
|
265
|
+
@pytest.mark.parametrize(
|
|
266
|
+
"body, on_existing, expected",
|
|
267
|
+
[
|
|
268
|
+
({"code": "exists", "datasetId": "TDS-1"}, "fail", ToscaDatasetExistsError),
|
|
269
|
+
({"code": "locked", "datasetId": "TDS-1"}, "overwrite", ToscaDatasetLockedError),
|
|
270
|
+
(
|
|
271
|
+
{"code": "schemaChanged", "datasetId": "TDS-1", "added": [], "removed": ["A"]},
|
|
272
|
+
"overwrite",
|
|
273
|
+
ToscaDatasetSchemaChangedError,
|
|
274
|
+
),
|
|
275
|
+
# The code wins over what the request's on_existing would suggest.
|
|
276
|
+
({"code": "locked", "datasetId": "TDS-1"}, "fail", ToscaDatasetLockedError),
|
|
277
|
+
],
|
|
278
|
+
)
|
|
279
|
+
@patch("requests.request")
|
|
280
|
+
def test_409_code_names_the_reason(
|
|
281
|
+
self, mock_request, api_key, body, on_existing, expected
|
|
282
|
+
):
|
|
283
|
+
mock_request.return_value = failed(409, {"error": "refused", **body})
|
|
284
|
+
|
|
285
|
+
with pytest.raises(expected) as raised:
|
|
286
|
+
push(api_key, set_id="s", on_existing=on_existing)
|
|
287
|
+
|
|
288
|
+
assert type(raised.value) is expected
|
|
289
|
+
assert raised.value.dataset_id == "TDS-1"
|
|
290
|
+
|
|
291
|
+
@patch("requests.request")
|
|
292
|
+
def test_409_with_an_unknown_code_stays_generic(self, mock_request, api_key):
|
|
293
|
+
mock_request.return_value = failed(409, {"error": "refused", "code": "other"})
|
|
294
|
+
|
|
295
|
+
with pytest.raises(DataMakerAPIError) as raised:
|
|
296
|
+
push(api_key, set_id="s", on_existing="new")
|
|
297
|
+
|
|
298
|
+
assert type(raised.value) is DataMakerAPIError
|
|
299
|
+
|
|
300
|
+
@patch("requests.request")
|
|
301
|
+
def test_locked_data_set(self, mock_request, api_key):
|
|
302
|
+
# No code: same body shape as "name exists", told apart by the request.
|
|
303
|
+
mock_request.return_value = failed(
|
|
304
|
+
409, {"error": 'Data set "Orders" is locked', "datasetId": "TDS-1"}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
with pytest.raises(ToscaDatasetLockedError) as raised:
|
|
308
|
+
push(api_key, set_id="s", on_existing="overwrite")
|
|
309
|
+
|
|
310
|
+
assert raised.value.dataset_id == "TDS-1"
|
|
311
|
+
|
|
312
|
+
@patch("requests.request")
|
|
313
|
+
def test_too_large_before_loading_names_no_numbers(self, mock_request, api_key):
|
|
314
|
+
mock_request.return_value = failed(
|
|
315
|
+
413, {"error": "A push may write at most 50000 rows; dataset has 60000."}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with pytest.raises(ToscaDatasetTooLargeError, match="50000") as raised:
|
|
319
|
+
push(api_key, dataset_id="d")
|
|
320
|
+
|
|
321
|
+
assert raised.value.max_rows is None
|
|
322
|
+
assert raised.value.max_bytes is None
|
|
323
|
+
|
|
324
|
+
@patch("requests.request")
|
|
325
|
+
def test_too_many_rows(self, mock_request, api_key):
|
|
326
|
+
mock_request.return_value = failed(
|
|
327
|
+
413, {"error": "Too many rows", "maxRows": 50000, "rows": 60000}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
with pytest.raises(ToscaDatasetTooLargeError) as raised:
|
|
331
|
+
push(api_key, set_id="s")
|
|
332
|
+
|
|
333
|
+
assert raised.value.status_code == 413
|
|
334
|
+
assert raised.value.max_rows == 50000
|
|
335
|
+
assert raised.value.rows == 60000
|
|
336
|
+
assert raised.value.max_bytes is None
|
|
337
|
+
|
|
338
|
+
@patch("requests.request")
|
|
339
|
+
def test_too_many_bytes(self, mock_request, api_key):
|
|
340
|
+
mock_request.return_value = failed(
|
|
341
|
+
413, {"error": "Too large", "maxBytes": 1000, "bytes": 2000}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
with pytest.raises(ToscaDatasetTooLargeError) as raised:
|
|
345
|
+
push(api_key, set_id="s")
|
|
346
|
+
|
|
347
|
+
assert raised.value.max_bytes == 1000
|
|
348
|
+
assert raised.value.bytes == 2000
|
|
349
|
+
assert raised.value.max_rows is None
|
|
350
|
+
|
|
351
|
+
@pytest.mark.parametrize(
|
|
352
|
+
"status_code, body",
|
|
353
|
+
[
|
|
354
|
+
(400, {"error": "The set is not locked"}),
|
|
355
|
+
(404, {"error": "Set not found"}),
|
|
356
|
+
(429, {"message": "Too many Tosca data set pushes. Try again in 60s."}),
|
|
357
|
+
(502, {"error": "Tosca refused the data set", "toscaError": "boom"}),
|
|
358
|
+
],
|
|
359
|
+
)
|
|
360
|
+
@patch("requests.request")
|
|
361
|
+
def test_other_failures_carry_status_and_body(
|
|
362
|
+
self, mock_request, api_key, status_code, body
|
|
363
|
+
):
|
|
364
|
+
mock_request.return_value = failed(status_code, body)
|
|
365
|
+
|
|
366
|
+
with pytest.raises(DataMakerAPIError) as raised:
|
|
367
|
+
push(api_key, set_id="s")
|
|
368
|
+
|
|
369
|
+
assert type(raised.value) is DataMakerAPIError
|
|
370
|
+
assert raised.value.status_code == status_code
|
|
371
|
+
assert raised.value.body == body
|
|
372
|
+
|
|
373
|
+
@patch("requests.request")
|
|
374
|
+
def test_typed_errors_are_still_datamaker_errors(self, mock_request, api_key):
|
|
375
|
+
mock_request.return_value = failed(413, {"error": "too big", "maxRows": 10})
|
|
376
|
+
|
|
377
|
+
with pytest.raises(DataMakerError):
|
|
378
|
+
push(api_key, set_id="s")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class TestGetToscaDatasets:
|
|
382
|
+
@patch("requests.request")
|
|
383
|
+
def test_lists_a_space(self, mock_request, api_key):
|
|
384
|
+
listing = {
|
|
385
|
+
"space": "qa",
|
|
386
|
+
"items": [
|
|
387
|
+
{"id": "TDS-1", "name": "Orders", "subsetCount": 2, "isLocked": False}
|
|
388
|
+
],
|
|
389
|
+
}
|
|
390
|
+
mock_request.return_value = ok(listing, status_code=200)
|
|
391
|
+
|
|
392
|
+
result = DataMaker(api_key=api_key).get_tosca_datasets("int-1", space="my qa")
|
|
393
|
+
|
|
394
|
+
method, url = mock_request.call_args.args
|
|
395
|
+
assert method == "GET"
|
|
396
|
+
assert url.endswith("/integrations/int-1/tosca/datasets?space=my%20qa")
|
|
397
|
+
assert result == listing
|
|
398
|
+
|
|
399
|
+
@patch("requests.request")
|
|
400
|
+
def test_default_space(self, mock_request, api_key):
|
|
401
|
+
mock_request.return_value = ok({"space": "default", "items": []}, 200)
|
|
402
|
+
|
|
403
|
+
DataMaker(api_key=api_key).get_tosca_datasets("int-1")
|
|
404
|
+
|
|
405
|
+
assert mock_request.call_args.args[1].endswith(
|
|
406
|
+
"/integrations/int-1/tosca/datasets"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class TestApiErrorDetails:
|
|
411
|
+
"""Every failed request now carries its status and body."""
|
|
412
|
+
|
|
413
|
+
@patch("requests.request")
|
|
414
|
+
def test_non_json_body(self, mock_request, api_key):
|
|
415
|
+
response = Mock()
|
|
416
|
+
response.status_code = 502
|
|
417
|
+
response.text = "Bad Gateway"
|
|
418
|
+
response.json.side_effect = ValueError("not json")
|
|
419
|
+
mock_request.return_value = response
|
|
420
|
+
|
|
421
|
+
with pytest.raises(DataMakerAPIError, match="Bad Gateway") as raised:
|
|
422
|
+
DataMaker(api_key=api_key).get_projects()
|
|
423
|
+
|
|
424
|
+
assert raised.value.status_code == 502
|
|
425
|
+
assert raised.value.body is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|