omniload 0.0.0.dev0__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.
- omniload/conftest.py +72 -0
- omniload/main.py +810 -0
- omniload/src/.gitignore +10 -0
- omniload/src/adjust/__init__.py +108 -0
- omniload/src/adjust/adjust_helpers.py +122 -0
- omniload/src/airtable/__init__.py +84 -0
- omniload/src/allium/__init__.py +128 -0
- omniload/src/anthropic/__init__.py +277 -0
- omniload/src/anthropic/helpers.py +525 -0
- omniload/src/applovin/__init__.py +316 -0
- omniload/src/applovin_max/__init__.py +117 -0
- omniload/src/appsflyer/__init__.py +325 -0
- omniload/src/appsflyer/client.py +110 -0
- omniload/src/appstore/__init__.py +142 -0
- omniload/src/appstore/client.py +126 -0
- omniload/src/appstore/errors.py +15 -0
- omniload/src/appstore/models.py +117 -0
- omniload/src/appstore/resources.py +179 -0
- omniload/src/arrow/__init__.py +81 -0
- omniload/src/asana_source/__init__.py +281 -0
- omniload/src/asana_source/helpers.py +30 -0
- omniload/src/asana_source/settings.py +158 -0
- omniload/src/attio/__init__.py +102 -0
- omniload/src/attio/helpers.py +65 -0
- omniload/src/blob.py +95 -0
- omniload/src/bruin/__init__.py +76 -0
- omniload/src/chess/__init__.py +180 -0
- omniload/src/chess/helpers.py +35 -0
- omniload/src/chess/settings.py +18 -0
- omniload/src/clickup/__init__.py +85 -0
- omniload/src/clickup/helpers.py +47 -0
- omniload/src/collector/spinner.py +43 -0
- omniload/src/couchbase_source/__init__.py +118 -0
- omniload/src/couchbase_source/helpers.py +135 -0
- omniload/src/cursor/__init__.py +83 -0
- omniload/src/cursor/helpers.py +188 -0
- omniload/src/customer_io/__init__.py +486 -0
- omniload/src/customer_io/helpers.py +530 -0
- omniload/src/destinations.py +982 -0
- omniload/src/docebo/__init__.py +589 -0
- omniload/src/docebo/client.py +435 -0
- omniload/src/docebo/helpers.py +97 -0
- omniload/src/dune/__init__.py +104 -0
- omniload/src/dune/helpers.py +108 -0
- omniload/src/dynamodb/__init__.py +86 -0
- omniload/src/elasticsearch/__init__.py +80 -0
- omniload/src/elasticsearch/helpers.py +141 -0
- omniload/src/errors.py +26 -0
- omniload/src/facebook_ads/__init__.py +403 -0
- omniload/src/facebook_ads/exceptions.py +19 -0
- omniload/src/facebook_ads/helpers.py +296 -0
- omniload/src/facebook_ads/settings.py +224 -0
- omniload/src/facebook_ads/utils.py +53 -0
- omniload/src/factory.py +305 -0
- omniload/src/filesystem/__init__.py +133 -0
- omniload/src/filesystem/helpers.py +114 -0
- omniload/src/filesystem/readers.py +187 -0
- omniload/src/filters.py +62 -0
- omniload/src/fireflies/__init__.py +151 -0
- omniload/src/fireflies/helpers.py +753 -0
- omniload/src/fluxx/__init__.py +10013 -0
- omniload/src/fluxx/helpers.py +233 -0
- omniload/src/frankfurter/__init__.py +157 -0
- omniload/src/frankfurter/helpers.py +48 -0
- omniload/src/freshdesk/__init__.py +103 -0
- omniload/src/freshdesk/freshdesk_client.py +151 -0
- omniload/src/freshdesk/settings.py +23 -0
- omniload/src/fundraiseup/__init__.py +95 -0
- omniload/src/fundraiseup/client.py +81 -0
- omniload/src/github/__init__.py +202 -0
- omniload/src/github/helpers.py +207 -0
- omniload/src/github/queries.py +129 -0
- omniload/src/github/settings.py +24 -0
- omniload/src/google_ads/__init__.py +198 -0
- omniload/src/google_ads/field.py +17 -0
- omniload/src/google_ads/metrics.py +254 -0
- omniload/src/google_ads/predicates.py +37 -0
- omniload/src/google_ads/reports.py +411 -0
- omniload/src/google_ads/test_google_ads.py +184 -0
- omniload/src/google_analytics/__init__.py +144 -0
- omniload/src/google_analytics/helpers.py +312 -0
- omniload/src/google_sheets/README.md +95 -0
- omniload/src/google_sheets/__init__.py +166 -0
- omniload/src/google_sheets/helpers/__init__.py +15 -0
- omniload/src/google_sheets/helpers/api_calls.py +160 -0
- omniload/src/google_sheets/helpers/data_processing.py +316 -0
- omniload/src/gorgias/__init__.py +595 -0
- omniload/src/gorgias/helpers.py +166 -0
- omniload/src/hostaway/__init__.py +302 -0
- omniload/src/hostaway/client.py +288 -0
- omniload/src/http/__init__.py +38 -0
- omniload/src/http/readers.py +146 -0
- omniload/src/http_client.py +24 -0
- omniload/src/hubspot/__init__.py +800 -0
- omniload/src/hubspot/helpers.py +417 -0
- omniload/src/hubspot/settings.py +329 -0
- omniload/src/indeed/__init__.py +153 -0
- omniload/src/indeed/helpers.py +228 -0
- omniload/src/influxdb/__init__.py +46 -0
- omniload/src/influxdb/client.py +34 -0
- omniload/src/intercom/__init__.py +142 -0
- omniload/src/intercom/helpers.py +674 -0
- omniload/src/intercom/settings.py +279 -0
- omniload/src/isoc_pulse/__init__.py +159 -0
- omniload/src/jira_source/__init__.py +377 -0
- omniload/src/jira_source/helpers.py +510 -0
- omniload/src/jira_source/settings.py +184 -0
- omniload/src/kafka/__init__.py +120 -0
- omniload/src/kafka/helpers.py +241 -0
- omniload/src/kinesis/__init__.py +153 -0
- omniload/src/kinesis/helpers.py +96 -0
- omniload/src/klaviyo/__init__.py +237 -0
- omniload/src/klaviyo/client.py +212 -0
- omniload/src/klaviyo/helpers.py +19 -0
- omniload/src/linear/__init__.py +634 -0
- omniload/src/linear/helpers.py +111 -0
- omniload/src/linkedin_ads/__init__.py +266 -0
- omniload/src/linkedin_ads/dimension_time_enum.py +17 -0
- omniload/src/linkedin_ads/helpers.py +246 -0
- omniload/src/loader.py +69 -0
- omniload/src/mailchimp/__init__.py +126 -0
- omniload/src/mailchimp/helpers.py +226 -0
- omniload/src/mailchimp/settings.py +164 -0
- omniload/src/masking.py +344 -0
- omniload/src/mixpanel/__init__.py +62 -0
- omniload/src/mixpanel/client.py +104 -0
- omniload/src/monday/__init__.py +246 -0
- omniload/src/monday/helpers.py +392 -0
- omniload/src/monday/settings.py +325 -0
- omniload/src/mongodb/__init__.py +281 -0
- omniload/src/mongodb/helpers.py +975 -0
- omniload/src/notion/__init__.py +69 -0
- omniload/src/notion/helpers/__init__.py +14 -0
- omniload/src/notion/helpers/client.py +178 -0
- omniload/src/notion/helpers/database.py +92 -0
- omniload/src/notion/settings.py +17 -0
- omniload/src/partition.py +32 -0
- omniload/src/personio/__init__.py +345 -0
- omniload/src/personio/helpers.py +100 -0
- omniload/src/phantombuster/__init__.py +65 -0
- omniload/src/phantombuster/client.py +87 -0
- omniload/src/pinterest/__init__.py +82 -0
- omniload/src/pipedrive/__init__.py +212 -0
- omniload/src/pipedrive/helpers/__init__.py +37 -0
- omniload/src/pipedrive/helpers/custom_fields_munger.py +116 -0
- omniload/src/pipedrive/helpers/pages.py +129 -0
- omniload/src/pipedrive/settings.py +41 -0
- omniload/src/pipedrive/typing.py +17 -0
- omniload/src/plusvibeai/__init__.py +335 -0
- omniload/src/plusvibeai/helpers.py +544 -0
- omniload/src/plusvibeai/settings.py +252 -0
- omniload/src/primer/__init__.py +45 -0
- omniload/src/primer/helpers.py +79 -0
- omniload/src/quickbooks/__init__.py +117 -0
- omniload/src/reddit_ads/__init__.py +183 -0
- omniload/src/reddit_ads/helpers.py +232 -0
- omniload/src/resource.py +40 -0
- omniload/src/revenuecat/__init__.py +83 -0
- omniload/src/revenuecat/helpers.py +237 -0
- omniload/src/salesforce/__init__.py +170 -0
- omniload/src/salesforce/helpers.py +78 -0
- omniload/src/shopify/__init__.py +1953 -0
- omniload/src/shopify/exceptions.py +17 -0
- omniload/src/shopify/helpers.py +202 -0
- omniload/src/shopify/settings.py +19 -0
- omniload/src/slack/__init__.py +290 -0
- omniload/src/slack/helpers.py +218 -0
- omniload/src/slack/settings.py +36 -0
- omniload/src/smartsheets/__init__.py +82 -0
- omniload/src/snapchat_ads/__init__.py +455 -0
- omniload/src/snapchat_ads/client.py +72 -0
- omniload/src/snapchat_ads/helpers.py +630 -0
- omniload/src/snapchat_ads/settings.py +130 -0
- omniload/src/socrata_source/__init__.py +83 -0
- omniload/src/socrata_source/helpers.py +85 -0
- omniload/src/socrata_source/settings.py +8 -0
- omniload/src/solidgate/__init__.py +219 -0
- omniload/src/solidgate/helpers.py +154 -0
- omniload/src/sources.py +5408 -0
- omniload/src/sql_database/__init__.py +0 -0
- omniload/src/sql_database/callbacks.py +66 -0
- omniload/src/stripe_analytics/__init__.py +183 -0
- omniload/src/stripe_analytics/helpers.py +386 -0
- omniload/src/stripe_analytics/settings.py +80 -0
- omniload/src/table_definition.py +15 -0
- omniload/src/testdata/fakebqcredentials.json +14 -0
- omniload/src/tiktok_ads/__init__.py +150 -0
- omniload/src/tiktok_ads/tiktok_helpers.py +130 -0
- omniload/src/time.py +11 -0
- omniload/src/trustpilot/__init__.py +48 -0
- omniload/src/trustpilot/client.py +48 -0
- omniload/src/version.py +6 -0
- omniload/src/wise/__init__.py +68 -0
- omniload/src/wise/client.py +63 -0
- omniload/src/zendesk/__init__.py +480 -0
- omniload/src/zendesk/helpers/__init__.py +39 -0
- omniload/src/zendesk/helpers/api_helpers.py +119 -0
- omniload/src/zendesk/helpers/credentials.py +68 -0
- omniload/src/zendesk/helpers/talk_api.py +132 -0
- omniload/src/zendesk/settings.py +71 -0
- omniload/src/zoom/__init__.py +99 -0
- omniload/src/zoom/helpers.py +102 -0
- omniload/testdata/.gitignore +2 -0
- omniload/testdata/create_replace.csv +21 -0
- omniload/testdata/delete_insert_expected.csv +6 -0
- omniload/testdata/delete_insert_part1.csv +5 -0
- omniload/testdata/delete_insert_part2.csv +6 -0
- omniload/testdata/merge_expected.csv +5 -0
- omniload/testdata/merge_part1.csv +4 -0
- omniload/testdata/merge_part2.csv +5 -0
- omniload/tests/unit/test_smartsheets.py +133 -0
- omniload-0.0.0.dev0.dist-info/METADATA +439 -0
- omniload-0.0.0.dev0.dist-info/RECORD +218 -0
- omniload-0.0.0.dev0.dist-info/WHEEL +4 -0
- omniload-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- omniload-0.0.0.dev0.dist-info/licenses/LICENSE.Apache-2.0 +201 -0
- omniload-0.0.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- omniload-0.0.0.dev0.dist-info/licenses/NOTICE +35 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copyright 2022-2025 ScaleVector
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
This module handles how credentials are read in dlt sources
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import ClassVar, List, Union
|
|
20
|
+
|
|
21
|
+
import dlt
|
|
22
|
+
from dlt.common.configuration import configspec
|
|
23
|
+
from dlt.common.configuration.specs import CredentialsConfiguration
|
|
24
|
+
from dlt.common.typing import TSecretValue
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@configspec
|
|
28
|
+
class ZendeskCredentialsBase(CredentialsConfiguration):
|
|
29
|
+
"""
|
|
30
|
+
The Base version of all the ZendeskCredential classes.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
subdomain: str = dlt.config.value
|
|
34
|
+
__config_gen_annotations__: ClassVar[List[str]] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@configspec
|
|
38
|
+
class ZendeskCredentialsEmailPass(ZendeskCredentialsBase):
|
|
39
|
+
"""
|
|
40
|
+
This class is used to store credentials for Email + Password Authentication
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
email: str = dlt.config.value
|
|
44
|
+
password: TSecretValue = dlt.secrets.value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@configspec
|
|
48
|
+
class ZendeskCredentialsOAuth(ZendeskCredentialsBase):
|
|
49
|
+
"""
|
|
50
|
+
This class is used to store credentials for OAuth Token Authentication
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
oauth_token: TSecretValue = dlt.secrets.value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@configspec
|
|
57
|
+
class ZendeskCredentialsToken(ZendeskCredentialsBase):
|
|
58
|
+
"""
|
|
59
|
+
This class is used to store credentials for Token Authentication
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
email: str = dlt.config.value
|
|
63
|
+
token: TSecretValue = dlt.secrets.value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
TZendeskCredentials = Union[
|
|
67
|
+
ZendeskCredentialsEmailPass, ZendeskCredentialsToken, ZendeskCredentialsOAuth
|
|
68
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Copyright 2022-2025 ScaleVector
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any, Dict, Iterator, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from dlt.common.typing import DictStrStr, TDataItems, TSecretValue
|
|
19
|
+
from dlt.sources.helpers.requests import client
|
|
20
|
+
|
|
21
|
+
from .. import settings
|
|
22
|
+
from .credentials import (
|
|
23
|
+
TZendeskCredentials,
|
|
24
|
+
ZendeskCredentialsEmailPass,
|
|
25
|
+
ZendeskCredentialsOAuth,
|
|
26
|
+
ZendeskCredentialsToken,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PaginationType(Enum):
|
|
31
|
+
OFFSET = 0
|
|
32
|
+
CURSOR = 1
|
|
33
|
+
STREAM = 2
|
|
34
|
+
START_TIME = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ZendeskAPIClient:
|
|
38
|
+
"""
|
|
39
|
+
API client used to make requests to Zendesk talk, support and chat API
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
subdomain: str = ""
|
|
43
|
+
url: str = ""
|
|
44
|
+
headers: Optional[DictStrStr]
|
|
45
|
+
auth: Optional[Tuple[str, TSecretValue]]
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, credentials: TZendeskCredentials, url_prefix: Optional[str] = None
|
|
49
|
+
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initializer for the API client which is then used to make API calls to the ZendeskAPI
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
credentials: ZendeskCredentials object which contains the necessary credentials to authenticate to ZendeskAPI
|
|
55
|
+
"""
|
|
56
|
+
# oauth token is the preferred way to authenticate, followed by api token and then email + password combo
|
|
57
|
+
# fill headers and auth for every possibility of credentials given, raise error if credentials are of incorrect type
|
|
58
|
+
if isinstance(credentials, ZendeskCredentialsOAuth):
|
|
59
|
+
self.headers = {"Authorization": f"Bearer {credentials.oauth_token}"}
|
|
60
|
+
self.auth = None
|
|
61
|
+
elif isinstance(credentials, ZendeskCredentialsToken):
|
|
62
|
+
self.headers = None
|
|
63
|
+
self.auth = (f"{credentials.email}/token", credentials.token)
|
|
64
|
+
elif isinstance(credentials, ZendeskCredentialsEmailPass):
|
|
65
|
+
self.auth = (credentials.email, credentials.password)
|
|
66
|
+
self.headers = None
|
|
67
|
+
else:
|
|
68
|
+
raise TypeError(
|
|
69
|
+
"Wrong credentials type provided to ZendeskAPIClient. The credentials need to be of type: ZendeskCredentialsOAuth, ZendeskCredentialsToken or ZendeskCredentialsEmailPass"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# If url_prefix is set it overrides the default API URL (e.g. chat api uses zopim.com domain)
|
|
73
|
+
if url_prefix:
|
|
74
|
+
self.url = url_prefix
|
|
75
|
+
else:
|
|
76
|
+
self.subdomain = credentials.subdomain
|
|
77
|
+
self.url = f"https://{self.subdomain}.zendesk.com"
|
|
78
|
+
|
|
79
|
+
def get_pages(
|
|
80
|
+
self,
|
|
81
|
+
endpoint: str,
|
|
82
|
+
data_point_name: str,
|
|
83
|
+
pagination: PaginationType,
|
|
84
|
+
params: Optional[Dict[str, Any]] = None,
|
|
85
|
+
) -> Iterator[TDataItems]:
|
|
86
|
+
"""
|
|
87
|
+
Makes a request to a paginated endpoint and returns a generator of data items per page.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
endpoint: The url to the endpoint, e.g. /api/v2/calls
|
|
91
|
+
data_point_name: The key which data items are nested under in the response object (e.g. calls)
|
|
92
|
+
params: Optional dict of query params to include in the request
|
|
93
|
+
pagination: Type of pagination type used by endpoint
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Generator of pages, each page is a list of dict data items
|
|
97
|
+
"""
|
|
98
|
+
# update the page size to enable cursor pagination
|
|
99
|
+
params = params or {}
|
|
100
|
+
if pagination == PaginationType.CURSOR:
|
|
101
|
+
params["page[size]"] = settings.PAGE_SIZE
|
|
102
|
+
elif pagination == PaginationType.STREAM:
|
|
103
|
+
params["per_page"] = settings.INCREMENTAL_PAGE_SIZE
|
|
104
|
+
elif pagination == PaginationType.START_TIME:
|
|
105
|
+
params["limit"] = settings.INCREMENTAL_PAGE_SIZE
|
|
106
|
+
|
|
107
|
+
# make request and keep looping until there is no next page
|
|
108
|
+
get_url = f"{self.url}{endpoint}"
|
|
109
|
+
while get_url:
|
|
110
|
+
response = client.get(
|
|
111
|
+
get_url, headers=self.headers, auth=self.auth, params=params
|
|
112
|
+
)
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
response_json = response.json()
|
|
115
|
+
result = response_json[data_point_name]
|
|
116
|
+
yield result
|
|
117
|
+
|
|
118
|
+
get_url = None
|
|
119
|
+
if pagination == PaginationType.CURSOR:
|
|
120
|
+
if response_json["meta"]["has_more"]:
|
|
121
|
+
get_url = response_json["links"]["next"]
|
|
122
|
+
elif pagination == PaginationType.OFFSET:
|
|
123
|
+
get_url = response_json.get("next_page", None)
|
|
124
|
+
elif pagination == PaginationType.STREAM:
|
|
125
|
+
# See https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/#json-format
|
|
126
|
+
if not response_json["end_of_stream"]:
|
|
127
|
+
get_url = response_json["next_page"]
|
|
128
|
+
elif pagination == PaginationType.START_TIME:
|
|
129
|
+
if response_json["count"] > 0:
|
|
130
|
+
get_url = response_json["next_page"]
|
|
131
|
+
|
|
132
|
+
params = {}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright 2022-2025 ScaleVector
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Zendesk source settings and constants"""
|
|
16
|
+
|
|
17
|
+
from dlt.common import pendulum
|
|
18
|
+
|
|
19
|
+
DEFAULT_START_DATE = pendulum.datetime(year=2024, month=10, day=3)
|
|
20
|
+
|
|
21
|
+
INCREMENTAL_PAGE_SIZE = 1000
|
|
22
|
+
PAGE_SIZE = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CUSTOM_FIELDS_STATE_KEY = "ticket_custom_fields_v2"
|
|
26
|
+
|
|
27
|
+
# Tuples of (Resource name, endpoint URL, data_key, supports pagination)
|
|
28
|
+
# data_key is the key which data list is nested under in responses
|
|
29
|
+
# if the data key is None it is assumed to be the same as the resource name
|
|
30
|
+
# The last element of the tuple says if endpoint supports cursor pagination
|
|
31
|
+
SUPPORT_ENDPOINTS = [
|
|
32
|
+
("users", "/api/v2/users.json", "users", True),
|
|
33
|
+
("sla_policies", "/api/v2/slas/policies.json", None, False),
|
|
34
|
+
("groups", "/api/v2/groups.json", None, True),
|
|
35
|
+
("organizations", "/api/v2/organizations.json", None, True),
|
|
36
|
+
("brands", "/api/v2/brands.json", None, True),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
SUPPORT_EXTRA_ENDPOINTS = [
|
|
40
|
+
("activities", "/api/v2/activities.json", None, True),
|
|
41
|
+
("automations", "/api/v2/automations.json", None, True),
|
|
42
|
+
("macros", "/api/v2/macros.json", None, True),
|
|
43
|
+
("recipient_addresses", "/api/v2/recipient_addresses.json", None, True),
|
|
44
|
+
("requests", "/api/v2/requests.json", None, True),
|
|
45
|
+
("targets", "/api/v2/targets.json", None, False),
|
|
46
|
+
("ticket_forms", "/api/v2/ticket_forms.json", None, False),
|
|
47
|
+
("ticket_metrics", "/api/v2/ticket_metrics.json", None, True),
|
|
48
|
+
("triggers", "/api/v2/triggers.json", None, True),
|
|
49
|
+
("user_fields", "/api/v2/user_fields.json", None, True),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
TALK_ENDPOINTS = [
|
|
53
|
+
("calls", "/api/v2/channels/voice/calls", None, False),
|
|
54
|
+
("addresses", "/api/v2/channels/voice/addresses", None, False),
|
|
55
|
+
("greetings", "/api/v2/channels/voice/greetings", None, False),
|
|
56
|
+
("phone_numbers", "/api/v2/channels/voice/phone_numbers", None, False),
|
|
57
|
+
("settings", "/api/v2/channels/voice/settings", None, False),
|
|
58
|
+
("lines", "/api/v2/channels/voice/lines", None, False),
|
|
59
|
+
("agents_activity", "/api/v2/channels/voice/stats/agents_activity", None, False),
|
|
60
|
+
(
|
|
61
|
+
"current_queue_activity",
|
|
62
|
+
"/api/v2/channels/voice/stats/current_queue_activity",
|
|
63
|
+
None,
|
|
64
|
+
False,
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
INCREMENTAL_TALK_ENDPOINTS = {
|
|
69
|
+
"calls": "/api/v2/channels/voice/stats/incremental/calls.json",
|
|
70
|
+
"legs": "/api/v2/channels/voice/stats/incremental/legs.json",
|
|
71
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, Sequence
|
|
2
|
+
|
|
3
|
+
import dlt
|
|
4
|
+
import pendulum
|
|
5
|
+
from dlt.common.typing import TAnyDateTime, TDataItem
|
|
6
|
+
from dlt.sources import DltResource
|
|
7
|
+
|
|
8
|
+
from .helpers import ZoomClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dlt.source(name="zoom", max_table_nesting=0)
|
|
12
|
+
def zoom_source(
|
|
13
|
+
client_id: str,
|
|
14
|
+
client_secret: str,
|
|
15
|
+
account_id: str,
|
|
16
|
+
start_date: pendulum.DateTime,
|
|
17
|
+
end_date: pendulum.DateTime | None = None,
|
|
18
|
+
) -> Sequence[DltResource]:
|
|
19
|
+
"""Create a Zoom source with meetings resource for all users in the account."""
|
|
20
|
+
client = ZoomClient(
|
|
21
|
+
client_id=client_id,
|
|
22
|
+
client_secret=client_secret,
|
|
23
|
+
account_id=account_id,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@dlt.resource(write_disposition="merge", primary_key="id")
|
|
27
|
+
def meetings(
|
|
28
|
+
datetime: dlt.sources.incremental[TAnyDateTime] = dlt.sources.incremental(
|
|
29
|
+
"start_time",
|
|
30
|
+
initial_value=start_date.isoformat(),
|
|
31
|
+
end_value=end_date.isoformat() if end_date is not None else None,
|
|
32
|
+
range_start="closed",
|
|
33
|
+
range_end="closed",
|
|
34
|
+
),
|
|
35
|
+
) -> Iterable[TDataItem]:
|
|
36
|
+
if datetime.last_value:
|
|
37
|
+
start_dt = pendulum.parse(datetime.last_value)
|
|
38
|
+
else:
|
|
39
|
+
start_dt = pendulum.parse(start_date)
|
|
40
|
+
|
|
41
|
+
if end_date is None:
|
|
42
|
+
end_dt = pendulum.now("UTC")
|
|
43
|
+
else:
|
|
44
|
+
end_dt = pendulum.parse(datetime.end_value)
|
|
45
|
+
|
|
46
|
+
base_params: Dict[str, Any] = {
|
|
47
|
+
"type": "scheduled",
|
|
48
|
+
"page_size": 300,
|
|
49
|
+
"from": start_dt.to_date_string(),
|
|
50
|
+
"to": end_dt.to_date_string(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for user in client.get_users():
|
|
54
|
+
user_id = user["id"]
|
|
55
|
+
yield from client.get_meetings(user_id, base_params)
|
|
56
|
+
|
|
57
|
+
@dlt.resource(write_disposition="merge", primary_key="id")
|
|
58
|
+
def users() -> Iterable[TDataItem]:
|
|
59
|
+
yield from client.get_users()
|
|
60
|
+
|
|
61
|
+
@dlt.resource(write_disposition="merge", primary_key="id")
|
|
62
|
+
def participants(
|
|
63
|
+
datetime: dlt.sources.incremental[TAnyDateTime] = dlt.sources.incremental(
|
|
64
|
+
"join_time",
|
|
65
|
+
initial_value=start_date.isoformat(),
|
|
66
|
+
end_value=end_date.isoformat() if end_date is not None else None,
|
|
67
|
+
range_start="closed",
|
|
68
|
+
range_end="closed",
|
|
69
|
+
),
|
|
70
|
+
) -> Iterable[TDataItem]:
|
|
71
|
+
if datetime.last_value:
|
|
72
|
+
start_dt = pendulum.parse(datetime.last_value)
|
|
73
|
+
else:
|
|
74
|
+
start_dt = pendulum.parse(start_date)
|
|
75
|
+
|
|
76
|
+
if end_date is None:
|
|
77
|
+
end_dt = pendulum.now("UTC")
|
|
78
|
+
else:
|
|
79
|
+
end_dt = pendulum.parse(datetime.end_value)
|
|
80
|
+
|
|
81
|
+
participant_params: Dict[str, Any] = {
|
|
82
|
+
"page_size": 300,
|
|
83
|
+
}
|
|
84
|
+
meeting_params = {
|
|
85
|
+
"type": "previous_meetings",
|
|
86
|
+
"page_size": 300,
|
|
87
|
+
}
|
|
88
|
+
for user in client.get_users():
|
|
89
|
+
user_id = user["id"]
|
|
90
|
+
for meeting in client.get_meetings(user_id=user_id, params=meeting_params):
|
|
91
|
+
meeting_id = meeting["id"]
|
|
92
|
+
yield from client.get_participants(
|
|
93
|
+
meeting_id=meeting_id,
|
|
94
|
+
params=participant_params,
|
|
95
|
+
start_date=start_dt,
|
|
96
|
+
end_date=end_dt,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return meetings, users, participants
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Dict, Iterator, Optional
|
|
3
|
+
|
|
4
|
+
import pendulum
|
|
5
|
+
|
|
6
|
+
from omniload.src.http_client import create_client
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ZoomClient:
|
|
10
|
+
"""Minimal Zoom API client supporting Server-to-Server OAuth."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
client_id: Optional[str] = None,
|
|
15
|
+
client_secret: Optional[str] = None,
|
|
16
|
+
account_id: Optional[str] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.client_id = client_id
|
|
19
|
+
self.client_secret = client_secret
|
|
20
|
+
self.account_id = account_id
|
|
21
|
+
self.token_expires_at: float = 0
|
|
22
|
+
self.base_url = "https://api.zoom.us/v2"
|
|
23
|
+
self.session = create_client()
|
|
24
|
+
self._refresh_access_token()
|
|
25
|
+
|
|
26
|
+
def _refresh_access_token(self) -> None:
|
|
27
|
+
token_url = "https://zoom.us/oauth/token"
|
|
28
|
+
auth = (self.client_id, self.client_secret)
|
|
29
|
+
resp = self.session.post(
|
|
30
|
+
token_url,
|
|
31
|
+
params={"grant_type": "account_credentials", "account_id": self.account_id},
|
|
32
|
+
auth=auth,
|
|
33
|
+
)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
data = resp.json()
|
|
36
|
+
self.access_token = data.get("access_token")
|
|
37
|
+
self.token_expires_at = time.time() + data.get("expires_in", 3600)
|
|
38
|
+
|
|
39
|
+
def _ensure_token(self) -> None:
|
|
40
|
+
if self.access_token is None or self.token_expires_at <= time.time():
|
|
41
|
+
self._refresh_access_token()
|
|
42
|
+
|
|
43
|
+
def _headers(self) -> Dict[str, str]:
|
|
44
|
+
self._ensure_token()
|
|
45
|
+
return {
|
|
46
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
47
|
+
"Accept": "application/json",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def get_users(self) -> Iterator[Dict[str, Any]]:
|
|
51
|
+
url = f"{self.base_url}/users"
|
|
52
|
+
|
|
53
|
+
params = {"page_size": 1000}
|
|
54
|
+
while True:
|
|
55
|
+
response = self.session.get(url, headers=self._headers(), params=params)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
data = response.json()
|
|
58
|
+
for user in data.get("users", []):
|
|
59
|
+
yield user
|
|
60
|
+
token = data.get("next_page_token")
|
|
61
|
+
if not token:
|
|
62
|
+
break
|
|
63
|
+
params["next_page_token"] = token
|
|
64
|
+
|
|
65
|
+
# https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetings
|
|
66
|
+
def get_meetings(
|
|
67
|
+
self, user_id: str, params: Dict[str, Any]
|
|
68
|
+
) -> Iterator[Dict[str, Any]]:
|
|
69
|
+
url = f"{self.base_url}/users/{user_id}/meetings"
|
|
70
|
+
while True:
|
|
71
|
+
response = self.session.get(url, headers=self._headers(), params=params)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
data = response.json()
|
|
74
|
+
for item in data.get("meetings", []):
|
|
75
|
+
item["zoom_user_id"] = user_id
|
|
76
|
+
yield item
|
|
77
|
+
token = data.get("next_page_token")
|
|
78
|
+
if not token:
|
|
79
|
+
break
|
|
80
|
+
params["next_page_token"] = token
|
|
81
|
+
|
|
82
|
+
# https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/reportMeetingParticipants
|
|
83
|
+
def get_participants(
|
|
84
|
+
self,
|
|
85
|
+
meeting_id: str,
|
|
86
|
+
params: Dict[str, Any],
|
|
87
|
+
start_date: pendulum.DateTime,
|
|
88
|
+
end_date: pendulum.DateTime,
|
|
89
|
+
) -> Iterator[Dict[str, Any]]:
|
|
90
|
+
url = f"{self.base_url}/report/meetings/{meeting_id}/participants"
|
|
91
|
+
while True:
|
|
92
|
+
response = self.session.get(url, headers=self._headers(), params=params)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
data = response.json()
|
|
95
|
+
for item in data.get("participants", []):
|
|
96
|
+
join_time = pendulum.parse(item["join_time"])
|
|
97
|
+
if join_time >= start_date and join_time <= end_date:
|
|
98
|
+
yield item
|
|
99
|
+
token = data.get("next_page_token")
|
|
100
|
+
if not token:
|
|
101
|
+
break
|
|
102
|
+
params["next_page_token"] = token
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"symbol","date","isEnabled","name"
|
|
2
|
+
"A","2024-04-19","True","AGILENT TECHNOLOGIES INC"
|
|
3
|
+
"AA","2024-04-19","True","ALCOA CORP"
|
|
4
|
+
"AAA","2024-04-19","True","ALTERNATIVE ACCESS FIRST PRI"
|
|
5
|
+
"AAAU","2024-04-19","True","GOLDMAN SACHS PHYSICAL GOLD"
|
|
6
|
+
"AACG","2024-04-19","True","ATA CREATIVITY GLOBAL - ADR"
|
|
7
|
+
"AACI","2024-04-19","True","ARMADA ACQUISITION CORP I"
|
|
8
|
+
"AACIU","2024-04-19","True","ARMADA ACQUISITION CORP I"
|
|
9
|
+
"AACIW","2024-04-19","True",""
|
|
10
|
+
"AACT","2024-04-19","True","ARES ACQUISITION CORP II"
|
|
11
|
+
"AACT+","2024-04-19","True",""
|
|
12
|
+
"AACT=","2024-04-19","True","ARES ACQUISITION CORP II"
|
|
13
|
+
"AADI","2024-04-19","True","AADI BIOSCIENCE INC"
|
|
14
|
+
"AADR","2024-04-19","True","ADVISORSHARES DORSEY WRIGHT"
|
|
15
|
+
"AAGR","2024-04-19","True","AFRICAN AGRICULTURE HOLDINGS"
|
|
16
|
+
"AAGRW","2024-04-19","True",""
|
|
17
|
+
"AAL","2024-04-19","True","AMERICAN AIRLINES GROUP INC"
|
|
18
|
+
"AAMC","2024-04-19","True","ALTISOURCE ASSET MANAGEMENT"
|
|
19
|
+
"AAME","2024-04-19","True","ATLANTIC AMERICAN CORP"
|
|
20
|
+
"AAN","2024-04-19","True","AARON'S CO INC/THE"
|
|
21
|
+
"AAOI","2024-04-19","True","APPLIED OPTOELECTRONICS INC"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"symbol","date","isEnabled","name"
|
|
2
|
+
"A","2024-04-20","True","AGILENT TECHNOLOGIES INC____updated"
|
|
3
|
+
"AA","2024-04-19","True","ALCOA CORP____updated"
|
|
4
|
+
"AAA","2024-04-21","True","ALTERNATIVE ACCESS FIRST PRI____updated"
|
|
5
|
+
"AAAU","2024-04-22","True","GOLDMAN SACHS PHYSICAL GOLD____updated"
|
|
6
|
+
"B","2024-04-18","True","SOME TECHNOLOGIES INC"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"symbol","date","isEnabled","name"
|
|
2
|
+
"A","2024-04-20","True","AGILENT TECHNOLOGIES INC____updated"
|
|
3
|
+
"AA","2024-04-19","True","ALCOA CORP____updated"
|
|
4
|
+
"AAA","2024-04-21","True","ALTERNATIVE ACCESS FIRST PRI____updated"
|
|
5
|
+
"AAAU","2024-04-22","True","GOLDMAN SACHS PHYSICAL GOLD____updated"
|
|
6
|
+
"BBB","2024-04-18","True","SOME CORP____updated"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"symbol","date","isEnabled","name"
|
|
2
|
+
"A","2024-04-20","True","AGILENT TECHNOLOGIES INC____updated"
|
|
3
|
+
"AA","2024-04-19","True","ALCOA CORP"
|
|
4
|
+
"AAA","2024-04-21","True","ALTERNATIVE ACCESS FIRST PRI____updated"
|
|
5
|
+
"AAAU","2024-04-22","True","GOLDMAN SACHS PHYSICAL GOLD____updated"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"symbol","date","isEnabled","name"
|
|
2
|
+
"A","2024-04-20","True","AGILENT TECHNOLOGIES INC____updated"
|
|
3
|
+
"AA","2024-04-19","True","ALCOA CORP____updated"
|
|
4
|
+
"AAA","2024-04-21","True","ALTERNATIVE ACCESS FIRST PRI____updated"
|
|
5
|
+
"AAAU","2024-04-22","True","GOLDMAN SACHS PHYSICAL GOLD____updated"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import smartsheet # type: ignore
|
|
6
|
+
from smartsheet.models import Cell, Column, Row, Sheet # type: ignore
|
|
7
|
+
|
|
8
|
+
from omniload.src.smartsheets import _get_sheet_data, smartsheet_source
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pp(x):
|
|
12
|
+
print(x, file=sys.stderr)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestSmartsheetSource(unittest.TestCase):
|
|
16
|
+
@patch("omniload.src.smartsheets.smartsheet.Smartsheet")
|
|
17
|
+
def test_smartsheet_source_success(self, mock_smartsheet_client):
|
|
18
|
+
# Mock Smartsheet client and its methods
|
|
19
|
+
mock_client_instance = mock_smartsheet_client.return_value
|
|
20
|
+
|
|
21
|
+
# Mock sheet details response
|
|
22
|
+
mock_sheet_details = Sheet(
|
|
23
|
+
{
|
|
24
|
+
"id": 123,
|
|
25
|
+
"name": "Test Sheet 1",
|
|
26
|
+
"columns": [
|
|
27
|
+
Column(
|
|
28
|
+
{"id": 1, "title": "Col A", "type": "TEXT_NUMBER", "index": 0}
|
|
29
|
+
),
|
|
30
|
+
Column(
|
|
31
|
+
{"id": 2, "title": "Col B", "type": "TEXT_NUMBER", "index": 1}
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
"rows": [
|
|
35
|
+
Row(
|
|
36
|
+
{
|
|
37
|
+
"id": 101,
|
|
38
|
+
"sheetId": 123,
|
|
39
|
+
"cells": [
|
|
40
|
+
Cell({"columnId": 1, "value": "r1c1"}),
|
|
41
|
+
Cell({"columnId": 2, "value": "r1c2"}),
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
),
|
|
45
|
+
Row(
|
|
46
|
+
{
|
|
47
|
+
"id": 102,
|
|
48
|
+
"sheetId": 123,
|
|
49
|
+
"cells": [
|
|
50
|
+
Cell({"columnId": 1, "value": "r2c1"}),
|
|
51
|
+
Cell({"columnId": 2, "value": "r2c2"}),
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
mock_client_instance.Sheets.get_sheet.return_value = mock_sheet_details
|
|
59
|
+
|
|
60
|
+
resource = smartsheet_source(access_token="test_token", sheet_id="123")
|
|
61
|
+
data = list(resource)
|
|
62
|
+
self.assertEqual(len(data), 2)
|
|
63
|
+
self.assertEqual(data[0], {"_row_id": 101, "Col A": "r1c1", "Col B": "r1c2"})
|
|
64
|
+
self.assertEqual(data[1], {"_row_id": 102, "Col A": "r2c1", "Col B": "r2c2"})
|
|
65
|
+
|
|
66
|
+
mock_smartsheet_client.assert_called_once_with("test_token")
|
|
67
|
+
mock_client_instance.Sheets.get_sheet.assert_any_call(
|
|
68
|
+
123, include=["objectValue"]
|
|
69
|
+
) # for resource name
|
|
70
|
+
mock_client_instance.Sheets.get_sheet.assert_any_call(
|
|
71
|
+
123
|
|
72
|
+
) # for _get_sheet_data
|
|
73
|
+
|
|
74
|
+
@patch("omniload.src.smartsheets.smartsheet.Smartsheet")
|
|
75
|
+
def test_smartsheet_source_api_error(self, mock_smartsheet_client):
|
|
76
|
+
mock_client_instance = mock_smartsheet_client.return_value
|
|
77
|
+
mock_client_instance.Sheets.get_sheet.side_effect = (
|
|
78
|
+
smartsheet.exceptions.ApiError("API Error", 500)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
with self.assertRaises(smartsheet.exceptions.ApiError):
|
|
82
|
+
source = smartsheet_source(access_token="test_token", sheet_id="123")
|
|
83
|
+
# Consume the generator to trigger the API call
|
|
84
|
+
list(source)
|
|
85
|
+
|
|
86
|
+
def test_get_sheet_data(self):
|
|
87
|
+
mock_sheet = Sheet(
|
|
88
|
+
{
|
|
89
|
+
"id": 456,
|
|
90
|
+
"name": "Data Sheet",
|
|
91
|
+
"columns": [
|
|
92
|
+
Column(
|
|
93
|
+
{"id": 10, "title": "ID", "type": "TEXT_NUMBER", "index": 0}
|
|
94
|
+
),
|
|
95
|
+
Column(
|
|
96
|
+
{"id": 20, "title": "Value", "type": "TEXT_NUMBER", "index": 1}
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
"rows": [
|
|
100
|
+
Row(
|
|
101
|
+
{
|
|
102
|
+
"id": 201,
|
|
103
|
+
"sheetId": 456,
|
|
104
|
+
"cells": [
|
|
105
|
+
Cell({"columnId": 10, "value": 1}),
|
|
106
|
+
Cell({"columnId": 20, "value": "Alpha"}),
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
),
|
|
110
|
+
Row(
|
|
111
|
+
{
|
|
112
|
+
"id": 202,
|
|
113
|
+
"sheetId": 456,
|
|
114
|
+
"cells": [
|
|
115
|
+
Cell({"columnId": 10, "value": 2}),
|
|
116
|
+
Cell({"columnId": 20, "value": "Beta"}),
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
data_generator = _get_sheet_data(mock_sheet)
|
|
125
|
+
data = list(data_generator)
|
|
126
|
+
|
|
127
|
+
self.assertEqual(len(data), 2)
|
|
128
|
+
self.assertEqual(data[0], {"_row_id": 201, "ID": 1, "Value": "Alpha"})
|
|
129
|
+
self.assertEqual(data[1], {"_row_id": 202, "ID": 2, "Value": "Beta"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
unittest.main()
|