Skip to content

Commit 0dddc1e

Browse files
authored
🎉 Source Asana: migrate to new SAT, added base HTTP errors handling (#19561)
1 parent a68846c commit 0dddc1e

File tree

8 files changed

+85
-49
lines changed

8 files changed

+85
-49
lines changed

‎airbyte-config/init/src/main/resources/seed/source_definitions.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
- name: Asana
110110
sourceDefinitionId: d0243522-dccf-4978-8ba0-37ed47a0bdbf
111111
dockerRepository: airbyte/source-asana
112-
dockerImageTag: 0.1.4
112+
dockerImageTag: 0.1.5
113113
documentationUrl: https://docs.airbyte.com/integrations/sources/asana
114114
icon: asana.svg
115115
sourceType: api

‎airbyte-config/init/src/main/resources/seed/source_specs.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1343,7 +1343,7 @@
13431343
supportsNormalization: false
13441344
supportsDBT: false
13451345
supported_destination_sync_modes: []
1346-
- dockerImage: "airbyte/source-asana:0.1.4"
1346+
- dockerImage: "airbyte/source-asana:0.1.5"
13471347
spec:
13481348
documentationUrl: "https://docsurl.com"
13491349
connectionSpecification:

‎airbyte-integrations/connectors/source-asana/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@ RUN pip install .
1212
ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py"
1313
ENTRYPOINT ["python", "/airbyte/integration_code/main.py"]
1414

15-
LABEL io.airbyte.version=0.1.4
15+
LABEL io.airbyte.version=0.1.5
1616
LABEL io.airbyte.name=airbyte/source-asana

‎airbyte-integrations/connectors/source-asana/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ Customize `acceptance-test-config.yml` file to configure tests. See [Source Acce
101101
If your connector requires to create or destroy resources for use during acceptance tests create fixtures for it and place them inside integration_tests/acceptance.py.
102102
To run your integration tests with acceptance tests, from the connector root, run
103103
```
104-
python -m pytest integration_tests -p integration_tests.acceptance
104+
docker build . --no-cache -t airbyte/source-asana:dev \
105+
&& python -m pytest -p source_acceptance_test.plugin
105106
```
106107
To run your integration tests with docker
107108

Original file line numberDiff line numberDiff line change
@@ -1,21 +1,34 @@
11
# See [Source Acceptance Tests](https://docs.airbyte.com/connector-development/testing-connectors/source-acceptance-tests-reference)
22
# for more information about how to configure these tests
33
connector_image: airbyte/source-asana:dev
4-
tests:
4+
test_strictness_level: high
5+
acceptance_tests:
56
spec:
6-
- spec_path: "source_asana/spec.json"
7+
tests:
8+
- spec_path: "source_asana/spec.json"
79
connection:
8-
- config_path: "secrets/config.json"
9-
status: "succeed"
10-
- config_path: "integration_tests/invalid_config.json"
11-
status: "failed"
10+
tests:
11+
- config_path: "secrets/config.json"
12+
status: "succeed"
13+
- config_path: "integration_tests/invalid_config.json"
14+
status: "failed"
1215
discovery:
13-
- config_path: "secrets/config.json"
16+
tests:
17+
- config_path: "secrets/config.json"
1418
basic_read:
15-
- config_path: "secrets/config.json"
16-
configured_catalog_path: "integration_tests/configured_catalog.json"
17-
timeout_seconds: 7200
19+
tests:
20+
- config_path: "secrets/config.json"
21+
timeout_seconds: 7200
22+
expect_records:
23+
bypass_reason: "Bypassed until dedicated sandbox account is up and running. Please follow https://github.com/airbytehq/airbyte/issues/19662."
24+
empty_streams:
25+
- name: custom_fields
26+
bypass_reason: "This stream is not available on the account we're currently using. Please follow https://github.com/airbytehq/airbyte/issues/19662."
1827
full_refresh:
19-
- config_path: "secrets/config.json"
20-
configured_catalog_path: "integration_tests/configured_catalog.json"
21-
timeout_seconds: 7200
28+
# tests:
29+
# - config_path: "secrets/config.json"
30+
# configured_catalog_path: "integration_tests/configured_catalog.json"
31+
# timeout_seconds: 7200
32+
bypass_reason: "As we are using an internal account the data is not frozen and results of `two-sequential-reads` are flaky. Please follow https://github.com/airbytehq/airbyte/issues/19662."
33+
incremental:
34+
bypass_reason: "Incremental syncs are not supported on this connector."

‎airbyte-integrations/connectors/source-asana/source_asana/streams.py

+32-32
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,41 @@
22
# Copyright (c) 2022 Airbyte, Inc., all rights reserved.
33
#
44

5-
from __future__ import annotations
65

76
from abc import ABC
8-
from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Type
7+
from typing import Any, Iterable, Mapping, MutableMapping, Optional, Type
98

109
import requests
1110
from airbyte_cdk.models import SyncMode
1211
from airbyte_cdk.sources.streams.http import HttpStream
1312

13+
ASANA_ERRORS_MAPPING = {
14+
402: "This stream is available to premium organizations and workspaces only",
15+
403: "Missing permissions to consume this stream enough permissions",
16+
404: "The object specified by the request does not exist",
17+
451: "This request was blocked for legal reasons",
18+
}
19+
1420

1521
class AsanaStream(HttpStream, ABC):
1622
url_base = "https://app.asana.com/api/1.0/"
17-
1823
primary_key = "gid"
19-
2024
# Asana pagination could be from 1 to 100.
2125
page_size = 100
26+
raise_on_http_errors = True
27+
28+
@property
29+
def AsanaStreamType(self) -> Type:
30+
return self.__class__
31+
32+
def should_retry(self, response: requests.Response) -> bool:
33+
if response.status_code in ASANA_ERRORS_MAPPING.keys():
34+
self.logger.error(
35+
f"Skipping stream {self.name}. {ASANA_ERRORS_MAPPING.get(response.status_code)}. Full error message: {response.text}"
36+
)
37+
setattr(self, "raise_on_http_errors", False)
38+
return False
39+
return super().should_retry(response)
2240

2341
def backoff_time(self, response: requests.Response) -> Optional[int]:
2442
delay_time = response.headers.get("Retry-After")
@@ -31,17 +49,11 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str,
3149
if next_page:
3250
return {"offset": next_page["offset"]}
3351

34-
def request_params(
35-
self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, any] = None, next_page_token: Mapping[str, Any] = None
36-
) -> MutableMapping[str, Any]:
37-
52+
def request_params(self, next_page_token: Mapping[str, Any] = None, **kwargs) -> MutableMapping[str, Any]:
3853
params = {"limit": self.page_size}
39-
4054
params.update(self.get_opt_fields())
41-
4255
if next_page_token:
4356
params.update(next_page_token)
44-
4557
return params
4658

4759
def get_opt_fields(self) -> MutableMapping[str, str]:
@@ -81,7 +93,7 @@ def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapp
8193
response_json = response.json()
8294
yield from response_json.get("data", []) # Asana puts records in a container array "data"
8395

84-
def read_slices_from_records(self, stream_class: Type[AsanaStream], slice_field: str) -> Iterable[Optional[Mapping[str, Any]]]:
96+
def read_slices_from_records(self, stream_class: AsanaStreamType, slice_field: str) -> Iterable[Optional[Mapping[str, Any]]]:
8597
"""
8698
General function for getting parent stream (which should be passed through `stream_class`) slice.
8799
Generates dicts with `gid` of parent streams.
@@ -100,9 +112,7 @@ class WorkspaceRelatedStream(AsanaStream, ABC):
100112
into the path or will pass it as a request parameter.
101113
"""
102114

103-
def stream_slices(
104-
self, sync_mode: SyncMode, cursor_field: List[str] = None, stream_state: Mapping[str, Any] = None
105-
) -> Iterable[Optional[Mapping[str, Any]]]:
115+
def stream_slices(self, **kwargs) -> Iterable[Optional[Mapping[str, Any]]]:
106116
workspaces_stream = Workspaces(authenticator=self.authenticator)
107117
for workspace in workspaces_stream.read_records(sync_mode=SyncMode.full_refresh):
108118
yield {"workspace_gid": workspace["gid"]}
@@ -114,10 +124,8 @@ class WorkspaceRequestParamsRelatedStream(WorkspaceRelatedStream, ABC):
114124
So this is basically the whole point of this class - to pass `workspace` as request argument.
115125
"""
116126

117-
def request_params(
118-
self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None
119-
) -> MutableMapping[str, Any]:
120-
params = super().request_params(stream_state=stream_state, stream_slice=stream_slice, next_page_token=next_page_token)
127+
def request_params(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> MutableMapping[str, Any]:
128+
params = super().request_params(**kwargs)
121129
params["workspace"] = stream_slice["workspace_gid"]
122130
return params
123131

@@ -128,9 +136,7 @@ class ProjectRelatedStream(AsanaStream, ABC):
128136
argument in request.
129137
"""
130138

131-
def stream_slices(
132-
self, sync_mode: SyncMode, cursor_field: List[str] = None, stream_state: Mapping[str, Any] = None
133-
) -> Iterable[Optional[Mapping[str, Any]]]:
139+
def stream_slices(self, **kwargs) -> Iterable[Optional[Mapping[str, Any]]]:
134140
yield from self.read_slices_from_records(stream_class=Projects, slice_field="project_gid")
135141

136142

@@ -158,9 +164,7 @@ def path(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> str:
158164
task_gid = stream_slice["task_gid"]
159165
return f"tasks/{task_gid}/stories"
160166

161-
def stream_slices(
162-
self, sync_mode: SyncMode, cursor_field: List[str] = None, stream_state: Mapping[str, Any] = None
163-
) -> Iterable[Optional[Mapping[str, Any]]]:
167+
def stream_slices(self, **kwargs) -> Iterable[Optional[Mapping[str, Any]]]:
164168
yield from self.read_slices_from_records(stream_class=Tasks, slice_field="task_gid")
165169

166170

@@ -173,10 +177,8 @@ class Tasks(ProjectRelatedStream):
173177
def path(self, **kwargs) -> str:
174178
return "tasks"
175179

176-
def request_params(
177-
self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None
178-
) -> MutableMapping[str, Any]:
179-
params = super().request_params(stream_state=stream_state, stream_slice=stream_slice, next_page_token=next_page_token)
180+
def request_params(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> MutableMapping[str, Any]:
181+
params = super().request_params(stream_slice=stream_slice, **kwargs)
180182
params["project"] = stream_slice["project_gid"]
181183
return params
182184

@@ -202,9 +204,7 @@ def path(self, stream_slice: Mapping[str, Any] = None, **kwargs) -> str:
202204
team_gid = stream_slice["team_gid"]
203205
return f"teams/{team_gid}/team_memberships"
204206

205-
def stream_slices(
206-
self, sync_mode: SyncMode, cursor_field: List[str] = None, stream_state: Mapping[str, Any] = None
207-
) -> Iterable[Optional[Mapping[str, Any]]]:
207+
def stream_slices(self, **kwargs) -> Iterable[Optional[Mapping[str, Any]]]:
208208
yield from self.read_slices_from_records(stream_class=Teams, slice_field="team_gid")
209209

210210

‎airbyte-integrations/connectors/source-asana/unit_tests/test_streams.py

+21
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,24 @@ def test_next_page_token():
3030
inputs = {"response": MagicMock()}
3131
expected = "offset"
3232
assert expected in stream.next_page_token(**inputs)
33+
34+
35+
@pytest.mark.parametrize(
36+
("http_status_code", "should_retry"),
37+
[
38+
(402, False),
39+
(403, False),
40+
(404, False),
41+
(451, False),
42+
(429, True),
43+
],
44+
)
45+
def test_should_retry(http_status_code, should_retry):
46+
"""
47+
402, 403, 404, 451 - should not retry.
48+
429 - should retry.
49+
"""
50+
response_mock = MagicMock()
51+
response_mock.status_code = http_status_code
52+
stream = Stories(MagicMock())
53+
assert stream.should_retry(response_mock) == should_retry

‎docs/integrations/sources/asana.md

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ The connector is restricted by normal Asana [requests limitation](https://develo
6666

6767
| Version | Date | Pull Request | Subject |
6868
| :------ | :--------- | :------------------------------------------------------- | :---------------------------------------------------------- |
69+
| 0.1.5 | 2022-11-16 | [19561](https://github.com/airbytehq/airbyte/pull/19561) | Added errors handling, updated SAT with new format
6970
| 0.1.4 | 2022-08-18 | [15749](https://github.com/airbytehq/airbyte/pull/15749) | Add cache to project stream |
7071
| 0.1.3 | 2021-10-06 | [6832](https://github.com/airbytehq/airbyte/pull/6832) | Add oauth init flow parameters support |
7172
| 0.1.2 | 2021-09-24 | [6402](https://github.com/airbytehq/airbyte/pull/6402) | Fix SAT tests: update schemas and invalid\_config.json file |

0 commit comments

Comments
 (0)