Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable dev mode #76

Merged
merged 10 commits into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example.config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"token": "<myapitoken>",
"refresh_token": "<my_refresh_token>",
"client_id": "<my_client_id>",
"client_secret": "<my_client_secret>",
"start_date": "2018-01-01T00:00:00Z",
"forms": "ZFuC6U,bFPlvG,WFBGBZ,WF0XE6,xFWoCE,OFHRwO,QFh3FI",
"page_size": 100,
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
url="http://singer.io",
classifiers=["Programming Language :: Python :: 3 :: Only"],
install_requires=[
"singer-python==5.10.0",
"singer-python==5.13.0",
"pendulum",
"ratelimit",
"backoff",
Expand Down
12 changes: 8 additions & 4 deletions tap_typeform/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
import singer
from singer import utils
from singer import utils as _utils
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this import create a conflict with the tap utils.py?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it does because of which I have used an alias here.

Copy link
Contributor Author

@RushiT0122 RushiT0122 Nov 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was causing issue with below import statement in client.py#L8

from tap_typeform.utils import write_config

from tap_typeform.discover import discover as _discover
from tap_typeform.sync import sync as _sync
from tap_typeform.client import Client
Expand Down Expand Up @@ -34,11 +34,15 @@ def validate_form_ids(client, config):
return config_forms


@utils.handle_top_exception(LOGGER)
@_utils.handle_top_exception(LOGGER)
def main():
args = utils.parse_args(REQUIRED_CONFIG_KEYS)
args = _utils.parse_args(REQUIRED_CONFIG_KEYS)
config = args.config
client = Client(config)

if args.dev:
LOGGER.warning("Executing Tap in Dev mode")

client = Client(config, args.config_path, args.dev)
valid_forms = validate_form_ids(client, config)
if args.discover:
catalog = _discover()
Expand Down
67 changes: 59 additions & 8 deletions tap_typeform/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
import backoff
import singer

from datetime import timedelta
from singer.utils import now
from requests.exceptions import ChunkedEncodingError, Timeout, ConnectionError
from tap_typeform.utils import write_config

Comment on lines 2 to +9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from datetime import timedelta
import backoff
from requests.exceptions import ChunkedEncodingError, Timeout, ConnectionError
import singer
from singer.utils import now
from tap_typeform.utils import write_config

i suggest keeping standard imports first


LOGGER = singer.get_logger()

Expand Down Expand Up @@ -113,22 +117,69 @@ class Client(object):
The client class is used for making REST calls to the Github API.
"""
BASE_URL = 'https://api.typeform.com'
OAUTH_URL = 'https://api.typeform.com/oauth/token'

def __init__(self, config):
self.token = 'Bearer ' + config.get('token')
def __init__(self, config, config_path, dev_mode):
self.metric = config.get('metric')
self.session = requests.Session()
self.page_size = MAX_RESPONSES_PAGE_SIZE
self.form_page_size = FORMS_PAGE_SIZE
self.config_path = config_path
self.get_page_size(config)

self.client_id = config.get('client_id')
self.client_secret = config.get('client_secret')
self.refresh_token = config.get('refresh_token')
self.access_token = config.get('token')
self.dev_mode = dev_mode
self.refresh()

# Set and pass request timeout to config param `request_timeout` value.
config_request_timeout = config.get('request_timeout')
if config_request_timeout and float(config_request_timeout):
self.request_timeout = float(config_request_timeout)
else:
self.request_timeout = REQUEST_TIMEOUT # If value is 0,"0","" or not passed then it set default to 300 seconds.

@backoff.on_exception(backoff.expo,(Timeout, ConnectionError), # Backoff for Timeout and ConnectionError.
max_tries=5, factor=2, jitter=None)
@backoff.on_exception(backoff.expo, (TypeformInternalError, TypeformNotAvailableError, TypeformTooManyError, ChunkedEncodingError),
max_tries=3, factor=2)
def refresh(self):
"""
Refreshes access token and refresh token
"""
# Existing connections won't have refresh token so use the existing access token
if not self.refresh_token:
return

# In dev mode, don't refresh access token
if self.dev_mode:
if not self.access_token:
raise Exception('Access token is missing')

return

response = self.session.post(url=self.OAUTH_URL,
headers={
'Content-Type': 'application/x-www-form-urlencoded'},
data={'client_id': self.client_id,
'client_secret': self.client_secret,
'refresh_token': self.refresh_token,
'grant_type': 'refresh_token',
'scope': 'forms:read accounts:read images:read responses:read themes:read workspaces:read offline'})

if response.status_code != 200:
raise_for_error(response)

data = response.json()
self.refresh_token = data['refresh_token']
self.access_token = data['access_token']

write_config(self.config_path,
{'refresh_token': self.refresh_token,
'token': self.access_token})

def get_page_size(self, config):
"""
This function will get page size from config,
Expand All @@ -138,7 +189,7 @@ def get_page_size(self, config):
if page_size is None:
return
if ((type(page_size) == int or type(page_size) == float) and (page_size > 0)) or \
(type(page_size) == str and page_size.replace('.', '', 1).isdigit() and (float(page_size) > 0) ):
(type(page_size) == str and page_size.replace('.', '', 1).isdigit() and (float(page_size) > 0)):
Copy link
Member

@cngpowered cngpowered Nov 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can simplify this by a small block instead of using a function to parse page_size in the init
eg:

try:
    page_size = int(config.get('page_size') or 0)
    if page_size > 0:
        self.page_size = page_size
        self.form_page_size = min(self.form_page_size, self.page_size)
    else:
        raise ValueError("Negative or invalid ....")
 except (ValueError, TypeError) as err:
    LOGGER.info("Invalid Value: %s, for page_size", config.get('email_activity_date_window', 0))
    raise from err

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unintentional refactoring change without any logical change. I would not prefer to cause any regression because of new changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay

self.page_size = int(float(page_size))
self.form_page_size = min(self.form_page_size, self.page_size)
else:
Expand All @@ -150,19 +201,19 @@ def build_url(self, endpoint):
"""
return f"{self.BASE_URL}/{endpoint}"

@backoff.on_exception(backoff.expo,(Timeout, ConnectionError), # Backoff for Timeout and ConnectionError.
max_tries=5, factor=2, jitter=None)
@backoff.on_exception(backoff.expo, (Timeout, ConnectionError), # Backoff for Timeout and ConnectionError.
max_tries=5, factor=2, jitter=None)
@backoff.on_exception(backoff.expo, (TypeformInternalError, TypeformNotAvailableError, TypeformTooManyError, ChunkedEncodingError),
max_tries=3, factor=2)
max_tries=3, factor=2)
def request(self, url, params={}, **kwargs):
"""
Call rest API and return the response in case of status code 200.
"""

if 'headers' not in kwargs:
kwargs['headers'] = {}
if self.token:
kwargs['headers']['Authorization'] = self.token
if self.access_token:
kwargs['headers']['Authorization'] = 'Bearer ' + self.access_token

LOGGER.info("URL: %s and Params: %s", url, params)
response = self.session.get(url, params=params, headers=kwargs['headers'], timeout=self.request_timeout)
Expand Down
24 changes: 24 additions & 0 deletions tap_typeform/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import json


def read_config(config_path):
"""
Performs read on the provided filepath,
returns empty dict if invalid path provided
"""
try:
with open(config_path, "r") as tap_config:
return json.load(tap_config)
except FileNotFoundError as err:
raise Exception("Failed to load config in dev mode") from err


def write_config(config_path, data):
"""
Updates the provided filepath with json format of the `data` object
"""
config = read_config(config_path)
config.update(data)
with open(config_path, "w") as tap_config:
json.dump(config, tap_config, indent=2)
return config
15 changes: 14 additions & 1 deletion tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def get_type():
def get_properties(self, original: bool = True):
"""Configuration properties required for the tap."""
return_value = {
'client_id': os.getenv('TAP_TYPEFORM_CLIENT_ID'),
'start_date' : '2021-05-10T00:00:00Z',
'forms': os.getenv('TAP_TYPEFORM_FORMS'),
'incremental_range': 'daily',
Expand All @@ -64,7 +65,19 @@ def get_forms(self):
@staticmethod
def get_credentials():
"""Authentication information for the test account"""
return {'token': os.getenv('TAP_TYPEFORM_TOKEN')}
return {
'refresh_token': os.getenv('TAP_TYPEFORM_REFRESH_TOKEN'),
'token': os.getenv('TAP_TYPEFORM_TOKEN'),
'client_secret': os.getenv('TAP_TYPEFORM_CLIENT_SECRET')}

@staticmethod
def preserve_refresh_token(existing_conns, payload):
"""This method is used get the refresh token from an existing refresh token"""
if not existing_conns:
return payload
conn_with_creds = connections.fetch_existing_connection_with_creds(existing_conns[0]['id'])
payload['properties']['refresh_token'] = conn_with_creds['credentials'].get('refresh_token')
return payload

def expected_metadata(self):
"""The expected streams and metadata about the streams"""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_typeform_all_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class TypeformAllFieldsTest(TypeformBaseTest):
"""Ensure running the tap with all streams and fields selected results in the replication of all fields."""

def name(self):
return "tap_tester_typeform_all_fields_test"
return "tap_tester_typeform_using_shared_token_chaining"

def test_run(self):
"""
Expand All @@ -29,7 +29,7 @@ def test_run(self):
# Streams to verify all fields tests
streams_to_test = self.expected_streams()

conn_id = connections.ensure_connection(self)
conn_id = connections.ensure_connection(self, payload_hook=self.preserve_refresh_token)

expected_automatic_fields = self.expected_automatic_fields()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_typeform_automatic_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class TypeformAutomaticFields(TypeformBaseTest):

@staticmethod
def name():
return "tap_tester_typeform_automatic_fields"
return "tap_tester_typeform_using_shared_token_chaining"

def test_run(self):
"""
Expand All @@ -32,7 +32,7 @@ def test_run(self):
expected_streams = self.expected_streams()

# Instantiate connection
conn_id = connections.ensure_connection(self)
conn_id = connections.ensure_connection(self, payload_hook=self.preserve_refresh_token)

# Run check mode
found_catalogs = self.run_and_verify_check_mode(conn_id)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_typeform_bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class TypeformBookmarks(TypeformBaseTest):

@staticmethod
def name():
return "tap_tester_typeform_bookmarks"
return "tap_tester_typeform_using_shared_token_chaining"

@staticmethod
def convert_state_to_utc(date_str):
Expand All @@ -39,7 +39,7 @@ def test_run(self):
self.start_date_2 = self.timedelta_formatted(self.start_date_1, days=3)

self.start_date = self.start_date_1
conn_id = connections.ensure_connection(self, original_properties=False)
conn_id = connections.ensure_connection(self, original_properties=False, payload_hook=self.preserve_refresh_token)

# Run in check mode
found_catalogs = self.run_and_verify_check_mode(conn_id)
Expand Down Expand Up @@ -72,7 +72,7 @@ def test_run(self):
}
for stream, new_state in simulated_states.items():
new_states['bookmarks'][stream] = new_state
conn_id_2 = connections.ensure_connection(self, original_properties=False)
conn_id_2 = connections.ensure_connection(self, original_properties=False, payload_hook=self.preserve_refresh_token)
menagerie.set_state(conn_id_2, new_states)

for stream in simulated_states.keys():
Expand Down
4 changes: 2 additions & 2 deletions tests/test_typeform_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class DiscoveryTest(TypeformBaseTest):

@staticmethod
def name():
return "tap_tester_typeform_discovery_test"
return "tap_tester_typeform_using_shared_token_chaining"

def test_run(self):
"""
Expand All @@ -32,7 +32,7 @@ def test_run(self):
"""
streams_to_test = self.expected_streams()

conn_id = connections.ensure_connection(self)
conn_id = connections.ensure_connection(self, payload_hook=self.preserve_refresh_token)

# Verify that there are catalogs found
found_catalogs = self.run_and_verify_check_mode(conn_id)
Expand Down
5 changes: 3 additions & 2 deletions tests/test_typeform_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ class TypeformPaginationTest(TypeformBaseTest):
"""

def name(self):
return "tap_tester_typeform_pagination_test"
return "tap_tester_typeform_using_shared_token_chaining"

def get_properties(self, original: bool = True):
"""Configuration properties required for the tap."""
return_value = {
'client_id': os.getenv('TAP_TYPEFORM_CLIENT_ID'),
'start_date' : '2021-05-10T00:00:00Z',
'forms': os.getenv('TAP_TYPEFORM_FORMS'),
'incremental_range': 'daily',
Expand Down Expand Up @@ -44,7 +45,7 @@ def run_test(self, expected_streams, page_size):

self.PAGE_SIZE = page_size

conn_id = connections.ensure_connection(self)
conn_id = connections.ensure_connection(self, payload_hook=self.preserve_refresh_token)

# Verify that there are catalogs found
found_catalogs = self.run_and_verify_check_mode(conn_id)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_typeform_start_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class TypeformStartDateTest(TypeformBaseTest):

@staticmethod
def name():
return "tap_tester_typeform_start_date_test"
return "tap_tester_typeform_using_shared_token_chaining"

start_date_1 = ""
start_date_2 = ""
Expand All @@ -39,7 +39,7 @@ def test_run(self):
##########################################################################

# Instantiate connection
conn_id_1 = connections.ensure_connection(self)
conn_id_1 = connections.ensure_connection(self, payload_hook=self.preserve_refresh_token)

# Run check mode
found_catalogs_1 = self.run_and_verify_check_mode(conn_id_1)
Expand All @@ -65,7 +65,7 @@ def test_run(self):
##########################################################################

# Create a new connection with the new start_date
conn_id_2 = connections.ensure_connection(self, original_properties=False)
conn_id_2 = connections.ensure_connection(self, original_properties=False, payload_hook=self.preserve_refresh_token)

# Run check mode
found_catalogs_2 = self.run_and_verify_check_mode(conn_id_2)
Expand Down Expand Up @@ -150,4 +150,4 @@ def test_run(self):
self.assertEqual(record_count_sync_2, record_count_sync_1)

# Verify by primary key values that the same records are replicated in the 1st and 2nd syncs
self.assertSetEqual(primary_keys_sync_1, primary_keys_sync_2)
self.assertSetEqual(primary_keys_sync_1, primary_keys_sync_2)
Loading