fluidattacks_refiner_sdk 0.1.1__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,7 @@
1
+ from ._core import (
2
+ Credentials,
3
+ )
4
+
5
+ __all__ = [
6
+ "Credentials",
7
+ ]
@@ -0,0 +1,9 @@
1
+ from dataclasses import (
2
+ dataclass,
3
+ field,
4
+ )
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Credentials:
9
+ api_key: str = field(repr=False)
@@ -0,0 +1,33 @@
1
+ from logging import (
2
+ Logger,
3
+ )
4
+
5
+ from fluidattacks_refiner_sdk.auth import (
6
+ Credentials,
7
+ )
8
+ from fluidattacks_refiner_sdk.client._get_contacts import (
9
+ get_contacts,
10
+ )
11
+ from fluidattacks_refiner_sdk.client._get_forms import (
12
+ get_forms,
13
+ )
14
+ from fluidattacks_refiner_sdk.client._get_reporting import (
15
+ get_nps_metrics,
16
+ )
17
+ from fluidattacks_refiner_sdk.client._get_responses import (
18
+ get_responses,
19
+ )
20
+ from fluidattacks_refiner_sdk.core import (
21
+ ApiClient,
22
+ )
23
+
24
+ __all__ = ["new_api_client"]
25
+
26
+
27
+ def new_api_client(log: Logger, creds: Credentials) -> ApiClient:
28
+ return ApiClient(
29
+ get_forms=get_forms(log, creds),
30
+ get_responses=lambda form_id: get_responses(log, creds, form_id),
31
+ get_contacts=get_contacts(log, creds),
32
+ get_nps_metrics=get_nps_metrics(log, creds),
33
+ )
@@ -0,0 +1,123 @@
1
+ import inspect
2
+ import logging
3
+ from collections.abc import (
4
+ Callable,
5
+ )
6
+ from dataclasses import (
7
+ dataclass,
8
+ )
9
+ from typing import (
10
+ Generic,
11
+ TypeVar,
12
+ )
13
+
14
+ from fa_purity import (
15
+ Cmd,
16
+ FrozenList,
17
+ Maybe,
18
+ Result,
19
+ ResultE,
20
+ cast_exception,
21
+ )
22
+ from fa_purity.json import (
23
+ JsonObj,
24
+ JsonPrimitiveUnfolder,
25
+ JsonUnfolder,
26
+ Unfolder,
27
+ )
28
+ from fluidattacks_etl_utils.bug import (
29
+ Bug,
30
+ )
31
+ from pure_requests.response import handle_status
32
+ from pure_requests.retry import HandledErrorFactory, cmd_if_fail, retry_cmd, sleep_cmd
33
+ from requests import HTTPError, RequestException, Response
34
+
35
+ _T = TypeVar("_T")
36
+ LOG = logging.getLogger(__name__)
37
+ _HTTP_5XX_START = 500
38
+ _HTTP_5XX_END = 600
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DataPage(Generic[_T]):
43
+ items: FrozenList[_T]
44
+ next_cursor: Maybe[str]
45
+
46
+
47
+ def decode_items(
48
+ raw: JsonObj,
49
+ decode: Callable[[JsonObj], ResultE[_T]],
50
+ ) -> ResultE[FrozenList[_T]]:
51
+ return JsonUnfolder.require(
52
+ raw,
53
+ "items",
54
+ lambda v: Unfolder.to_list_of(v, lambda j: Unfolder.to_json(j).bind(decode)),
55
+ ).alt(
56
+ lambda e: Bug[Exception].new(
57
+ "decode_items",
58
+ inspect.currentframe(),
59
+ e,
60
+ (JsonUnfolder.dumps(raw),),
61
+ ),
62
+ )
63
+
64
+
65
+ def decode_next_cursor(raw: JsonObj) -> ResultE[Maybe[str]]:
66
+ return JsonUnfolder.require(
67
+ raw,
68
+ "pagination",
69
+ lambda v: Unfolder.to_json(v).bind(
70
+ lambda pag: JsonUnfolder.optional(
71
+ pag,
72
+ "next_page_cursor",
73
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
74
+ ),
75
+ ),
76
+ )
77
+
78
+
79
+ def decode_page(
80
+ raw: JsonObj,
81
+ decode: Callable[[JsonObj], ResultE[_T]],
82
+ ) -> ResultE[DataPage[_T]]:
83
+ items = decode_items(raw, decode)
84
+ cursor = decode_next_cursor(raw)
85
+ return items.bind(lambda i: cursor.map(lambda c: DataPage(i, c))).alt(
86
+ lambda e: Bug[Exception].new(
87
+ "decode_page",
88
+ inspect.currentframe(),
89
+ e,
90
+ (JsonUnfolder.dumps(raw),),
91
+ ),
92
+ )
93
+
94
+
95
+ def with_retry_handler(
96
+ raw_page: Cmd[Result[Response, RequestException]],
97
+ retries: int,
98
+ ) -> Cmd[ResultE[Response]]:
99
+ error_factory: HandledErrorFactory[HTTPError, Exception] = HandledErrorFactory()
100
+ handled_get_page = raw_page.map(
101
+ lambda r: (
102
+ r.alt(cast_exception)
103
+ .alt(error_factory.unhandled)
104
+ .bind(
105
+ lambda r: handle_status(r).alt(
106
+ lambda e: (
107
+ error_factory.handled(e)
108
+ if e.errno and e.errno >= _HTTP_5XX_START and e.errno < _HTTP_5XX_END
109
+ else error_factory.unhandled(cast_exception(e))
110
+ ),
111
+ ),
112
+ )
113
+ ),
114
+ )
115
+ return retry_cmd(
116
+ handled_get_page,
117
+ lambda i, r: cmd_if_fail(
118
+ r,
119
+ Cmd[None].wrap_impure(lambda: LOG.info("Error handled i.e. %s", r))
120
+ + sleep_cmd(10 + 60 * (i - 1)),
121
+ ),
122
+ retries,
123
+ )
@@ -0,0 +1,212 @@
1
+ import inspect
2
+ import logging
3
+ from logging import (
4
+ Logger,
5
+ )
6
+
7
+ from dateutil.parser import parse as parse_dt
8
+ from fa_purity import (
9
+ Cmd,
10
+ FrozenDict,
11
+ FrozenList,
12
+ Maybe,
13
+ Result,
14
+ ResultE,
15
+ ResultSmash,
16
+ Stream,
17
+ cast_exception,
18
+ )
19
+ from fa_purity.json import (
20
+ JsonObj,
21
+ JsonPrimitive,
22
+ JsonPrimitiveUnfolder,
23
+ JsonUnfolder,
24
+ JsonValue,
25
+ UnfoldedFactory,
26
+ Unfolder,
27
+ )
28
+ from fluidattacks_etl_utils.bug import (
29
+ Bug,
30
+ )
31
+ from fluidattacks_etl_utils.paginate import (
32
+ cursor_pagination,
33
+ )
34
+ from pure_requests.basic import (
35
+ HttpClient,
36
+ HttpClientFactory,
37
+ Params,
38
+ )
39
+ from pure_requests.response import (
40
+ json_decode,
41
+ )
42
+ from requests import (
43
+ Response,
44
+ )
45
+
46
+ from fluidattacks_refiner_sdk.auth import (
47
+ Credentials,
48
+ )
49
+ from fluidattacks_refiner_sdk.client._common import (
50
+ DataPage,
51
+ decode_items,
52
+ decode_next_cursor,
53
+ with_retry_handler,
54
+ )
55
+ from fluidattacks_refiner_sdk.core import (
56
+ Account,
57
+ AccountDomain,
58
+ AccountId,
59
+ ContactId,
60
+ Person,
61
+ relative_endpoint,
62
+ )
63
+
64
+ LOG = logging.getLogger(__name__)
65
+
66
+ _MAX_PAGE_LENGTH = 1000
67
+
68
+
69
+ def _iso_to_unix(iso: str) -> ResultE[int]:
70
+ try:
71
+ return Result[int, Exception].success(int(parse_dt(iso).timestamp()))
72
+ except (ValueError, OverflowError) as e:
73
+ return Result[int, Exception].failure(e)
74
+
75
+
76
+ def _decode_account_obj(obj: JsonObj) -> ResultE[Account]:
77
+ acc_id = JsonUnfolder.require(
78
+ obj,
79
+ "uuid",
80
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
81
+ ).map(AccountId)
82
+ remote_id = JsonUnfolder.optional(
83
+ obj,
84
+ "remote_id",
85
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
86
+ )
87
+ display_name = JsonUnfolder.optional(
88
+ obj,
89
+ "display_name",
90
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
91
+ )
92
+ domain = JsonUnfolder.optional(
93
+ obj,
94
+ "domain",
95
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
96
+ ).map(lambda m: m.map(AccountDomain))
97
+ return ResultSmash.smash_result_4(acc_id, remote_id, display_name, domain).map(
98
+ lambda t: Account(*t),
99
+ )
100
+
101
+
102
+ def _decode_account(raw: JsonObj) -> ResultE[Maybe[Account]]:
103
+ return JsonUnfolder.optional(
104
+ raw,
105
+ "account",
106
+ lambda v: Unfolder.to_json(v).bind(_decode_account_obj),
107
+ )
108
+
109
+
110
+ def _decode_person(raw: JsonObj) -> ResultE[Person]:
111
+ contact_id = JsonUnfolder.require(
112
+ raw,
113
+ "uuid",
114
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
115
+ ).map(ContactId)
116
+ remote_id = JsonUnfolder.optional(
117
+ raw,
118
+ "remote_id",
119
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
120
+ )
121
+ email = JsonUnfolder.optional(
122
+ raw,
123
+ "email",
124
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
125
+ )
126
+ display_name = JsonUnfolder.optional(
127
+ raw,
128
+ "display_name",
129
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
130
+ )
131
+ first_seen_at = JsonUnfolder.optional(
132
+ raw,
133
+ "first_seen_at",
134
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str).bind(_iso_to_unix),
135
+ )
136
+ last_seen_at = JsonUnfolder.optional(
137
+ raw,
138
+ "last_seen_at",
139
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str).bind(_iso_to_unix),
140
+ )
141
+ account = _decode_account(raw)
142
+ group_1 = ResultSmash.smash_result_4(contact_id, remote_id, email, display_name)
143
+ group_2 = ResultSmash.smash_result_3(first_seen_at, last_seen_at, account)
144
+ return ResultSmash.smash_result_2(group_1, group_2).map(
145
+ lambda t: Person(*t[0], *t[1]),
146
+ )
147
+
148
+
149
+ def _decode_json_response(resp: Response) -> ResultE[DataPage[Person]]:
150
+ return (
151
+ json_decode(resp)
152
+ .alt(cast_exception)
153
+ .bind(
154
+ lambda c: c.map(
155
+ lambda obj: decode_items(obj, _decode_person).bind(
156
+ lambda items: decode_next_cursor(obj).map(
157
+ lambda cursor: DataPage(items, cursor),
158
+ ),
159
+ ),
160
+ lambda lst: Result[DataPage[Person], Exception].failure(
161
+ Bug[Exception].new(
162
+ "_decode_json_response",
163
+ inspect.currentframe(),
164
+ ValueError(f"Expected json object but got list: {lst!r}"),
165
+ (),
166
+ ),
167
+ ),
168
+ ),
169
+ )
170
+ )
171
+
172
+
173
+ def _get_contacts_page(
174
+ log: Logger,
175
+ client: HttpClient,
176
+ cursor: Maybe[str],
177
+ ) -> Cmd[tuple[FrozenList[Person], Maybe[str]]]:
178
+ params: dict[str, JsonValue] = {
179
+ "page_length": JsonValue.from_primitive(JsonPrimitive.from_int(_MAX_PAGE_LENGTH)),
180
+ }
181
+ cursor_params = cursor.map(
182
+ lambda c: {
183
+ "page_cursor": JsonValue.from_primitive(JsonPrimitive.from_str(c)),
184
+ },
185
+ ).value_or(dict[str, JsonValue]())
186
+ get_response = client.get(
187
+ relative_endpoint("v1/contacts"),
188
+ Params(FrozenDict({**params, **cursor_params})),
189
+ )
190
+ return Cmd[None].wrap_impure(
191
+ lambda: log.info("API: get_contacts_page()"),
192
+ ) + with_retry_handler(get_response, 3).map(
193
+ lambda r: r.bind(_decode_json_response),
194
+ ).map(
195
+ lambda r: Bug[DataPage[Person]].assume_success(
196
+ "_get_contacts_page",
197
+ inspect.currentframe(),
198
+ (),
199
+ r,
200
+ ),
201
+ ).map(
202
+ lambda page: (page.items, page.next_cursor),
203
+ )
204
+
205
+
206
+ def get_contacts(
207
+ log: Logger,
208
+ creds: Credentials,
209
+ ) -> Stream[FrozenList[Person]]:
210
+ headers = UnfoldedFactory.from_dict({"Authorization": " ".join(["Bearer", creds.api_key])})
211
+ client = HttpClientFactory.new_client(None, headers, None)
212
+ return cursor_pagination(lambda cursor: _get_contacts_page(log, client, cursor))
@@ -0,0 +1,144 @@
1
+ import inspect
2
+ import logging
3
+ from logging import (
4
+ Logger,
5
+ )
6
+
7
+ from fa_purity import (
8
+ Cmd,
9
+ FrozenDict,
10
+ FrozenList,
11
+ Maybe,
12
+ Result,
13
+ ResultE,
14
+ Stream,
15
+ cast_exception,
16
+ )
17
+ from fa_purity.json import (
18
+ JsonObj,
19
+ JsonPrimitive,
20
+ JsonPrimitiveUnfolder,
21
+ JsonUnfolder,
22
+ JsonValue,
23
+ UnfoldedFactory,
24
+ Unfolder,
25
+ )
26
+ from fluidattacks_etl_utils.bug import (
27
+ Bug,
28
+ )
29
+ from fluidattacks_etl_utils.paginate import (
30
+ cursor_pagination,
31
+ )
32
+ from pure_requests.basic import (
33
+ HttpClient,
34
+ HttpClientFactory,
35
+ Params,
36
+ )
37
+ from pure_requests.response import (
38
+ json_decode,
39
+ )
40
+ from requests import (
41
+ Response,
42
+ )
43
+
44
+ from fluidattacks_refiner_sdk.auth import (
45
+ Credentials,
46
+ )
47
+ from fluidattacks_refiner_sdk.client._common import (
48
+ DataPage,
49
+ decode_items,
50
+ decode_next_cursor,
51
+ with_retry_handler,
52
+ )
53
+ from fluidattacks_refiner_sdk.core import (
54
+ Form,
55
+ FormId,
56
+ FormName,
57
+ relative_endpoint,
58
+ )
59
+
60
+ LOG = logging.getLogger(__name__)
61
+
62
+
63
+ def _decode_form(raw: JsonObj) -> ResultE[Form]:
64
+ form_id = JsonUnfolder.require(
65
+ raw,
66
+ "uuid",
67
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
68
+ )
69
+ name = JsonUnfolder.require(
70
+ raw,
71
+ "name",
72
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
73
+ )
74
+ return form_id.bind(
75
+ lambda fid: name.map(
76
+ lambda n: Form(id=FormId(fid), name=FormName(n)),
77
+ ),
78
+ )
79
+
80
+
81
+ def _decode_json_response(resp: Response) -> ResultE[DataPage[Form]]:
82
+ return (
83
+ json_decode(resp)
84
+ .alt(cast_exception)
85
+ .bind(
86
+ lambda c: c.map(
87
+ lambda obj: decode_items(obj, _decode_form).bind(
88
+ lambda items: decode_next_cursor(obj).map(
89
+ lambda cursor: DataPage(items, cursor),
90
+ ),
91
+ ),
92
+ lambda _: Result[DataPage[Form], Exception].failure(
93
+ Bug[Exception].new(
94
+ "_decode_json_response",
95
+ inspect.currentframe(),
96
+ ValueError("Expected json object but got list"),
97
+ (),
98
+ ),
99
+ ),
100
+ ),
101
+ )
102
+ )
103
+
104
+
105
+ def _get_forms_page(
106
+ log: Logger,
107
+ client: HttpClient,
108
+ cursor: Maybe[str],
109
+ ) -> Cmd[tuple[FrozenList[Form], Maybe[str]]]:
110
+ params: dict[str, JsonValue] = {
111
+ "page_length": JsonValue.from_primitive(JsonPrimitive.from_int(100)),
112
+ }
113
+ cursor_params = cursor.map(
114
+ lambda c: {
115
+ "page_cursor": JsonValue.from_primitive(JsonPrimitive.from_str(c)),
116
+ },
117
+ ).value_or(dict[str, JsonValue]())
118
+ get_response = client.get(
119
+ relative_endpoint("v1/forms"),
120
+ Params(FrozenDict({**params, **cursor_params})),
121
+ )
122
+ return Cmd[None].wrap_impure(
123
+ lambda: log.info("API: get_forms_page()"),
124
+ ) + with_retry_handler(get_response, 3).map(
125
+ lambda r: r.bind(_decode_json_response),
126
+ ).map(
127
+ lambda r: Bug[DataPage[Form]].assume_success(
128
+ "_get_forms_page",
129
+ inspect.currentframe(),
130
+ (),
131
+ r,
132
+ ),
133
+ ).map(
134
+ lambda page: (page.items, page.next_cursor),
135
+ )
136
+
137
+
138
+ def get_forms(
139
+ log: Logger,
140
+ creds: Credentials,
141
+ ) -> Stream[FrozenList[Form]]:
142
+ headers = UnfoldedFactory.from_dict({"Authorization": " ".join(["Bearer", creds.api_key])})
143
+ client = HttpClientFactory.new_client(None, headers, None)
144
+ return cursor_pagination(lambda cursor: _get_forms_page(log, client, cursor))
@@ -0,0 +1,140 @@
1
+ import inspect
2
+ import logging
3
+ from logging import (
4
+ Logger,
5
+ )
6
+
7
+ from fa_purity import (
8
+ Cmd,
9
+ FrozenDict,
10
+ FrozenList,
11
+ Result,
12
+ ResultE,
13
+ cast_exception,
14
+ )
15
+ from fa_purity.json import (
16
+ JsonObj,
17
+ JsonPrimitive,
18
+ JsonPrimitiveUnfolder,
19
+ JsonUnfolder,
20
+ JsonValue,
21
+ UnfoldedFactory,
22
+ Unfolder,
23
+ )
24
+ from fluidattacks_etl_utils.bug import (
25
+ Bug,
26
+ )
27
+ from pure_requests.basic import (
28
+ HttpClientFactory,
29
+ Params,
30
+ )
31
+ from pure_requests.response import (
32
+ json_decode,
33
+ )
34
+ from requests import (
35
+ Response,
36
+ )
37
+
38
+ from fluidattacks_refiner_sdk.auth import (
39
+ Credentials,
40
+ )
41
+ from fluidattacks_refiner_sdk.client._common import (
42
+ with_retry_handler,
43
+ )
44
+ from fluidattacks_refiner_sdk.core import (
45
+ NpsMetrics,
46
+ relative_endpoint,
47
+ )
48
+
49
+ LOG = logging.getLogger(__name__)
50
+
51
+
52
+ def _decode_count(obj: JsonObj, key: str) -> ResultE[int]:
53
+ return JsonUnfolder.require(
54
+ obj,
55
+ key,
56
+ lambda v: Unfolder.to_primitive(v).bind(
57
+ lambda p: JsonPrimitiveUnfolder.to_int(p).lash(
58
+ lambda _: JsonPrimitiveUnfolder.to_float(p).map(int),
59
+ ),
60
+ ),
61
+ )
62
+
63
+
64
+ def _decode_nps_obj(obj: JsonObj) -> ResultE[NpsMetrics]:
65
+ nps = JsonUnfolder.require(
66
+ obj,
67
+ "nps",
68
+ lambda v: Unfolder.to_primitive(v).bind(
69
+ lambda p: JsonPrimitiveUnfolder.to_int(p).lash(
70
+ lambda _: JsonPrimitiveUnfolder.to_float(p).map(int),
71
+ ),
72
+ ),
73
+ )
74
+ count = _decode_count(obj, "count")
75
+ data_obj = JsonUnfolder.require(obj, "data", Unfolder.to_json)
76
+ promoters_count = data_obj.bind(lambda d: _decode_count(d, "promoters"))
77
+ passives_count = data_obj.bind(lambda d: _decode_count(d, "passives"))
78
+ detractors_count = data_obj.bind(lambda d: _decode_count(d, "detractors"))
79
+ return nps.bind(
80
+ lambda n: count.bind(
81
+ lambda c: promoters_count.bind(
82
+ lambda pc: passives_count.bind(
83
+ lambda pac: detractors_count.map(
84
+ lambda dc: NpsMetrics(
85
+ nps=n,
86
+ promoter_count=pc,
87
+ promoter_percent=pc / c * 100 if c else 0.0,
88
+ passive_count=pac,
89
+ passive_percent=pac / c * 100 if c else 0.0,
90
+ detractor_count=dc,
91
+ detractor_percent=dc / c * 100 if c else 0.0,
92
+ response_count=c,
93
+ ),
94
+ ),
95
+ ),
96
+ ),
97
+ ),
98
+ )
99
+
100
+
101
+ def _decode_nps_metrics(resp: Response) -> ResultE[NpsMetrics]:
102
+ def _decode_list(lst: FrozenList[JsonObj]) -> ResultE[NpsMetrics]:
103
+ if not lst:
104
+ return Result[NpsMetrics, Exception].failure(ValueError("Empty NPS metrics response"))
105
+ return _decode_nps_obj(lst[0])
106
+
107
+ return (
108
+ json_decode(resp).alt(cast_exception).bind(lambda c: c.map(_decode_nps_obj, _decode_list))
109
+ )
110
+
111
+
112
+ def get_nps_metrics(
113
+ log: Logger,
114
+ creds: Credentials,
115
+ ) -> Cmd[NpsMetrics]:
116
+ headers = UnfoldedFactory.from_dict({"Authorization": " ".join(["Bearer", creds.api_key])})
117
+ client = HttpClientFactory.new_client(None, headers, None)
118
+ get_response = client.get(
119
+ relative_endpoint("v1/reporting"),
120
+ Params(
121
+ FrozenDict(
122
+ {
123
+ "type": JsonValue.from_primitive(JsonPrimitive.from_str("nps")),
124
+ },
125
+ ),
126
+ ),
127
+ )
128
+ return Cmd[None].wrap_impure(lambda: log.info("API: get_nps_metrics()")) + with_retry_handler(
129
+ get_response,
130
+ 3,
131
+ ).map(
132
+ lambda r: r.bind(_decode_nps_metrics),
133
+ ).map(
134
+ lambda r: Bug[NpsMetrics].assume_success(
135
+ "get_nps_metrics",
136
+ inspect.currentframe(),
137
+ (),
138
+ r,
139
+ ),
140
+ )
@@ -0,0 +1,289 @@
1
+ import inspect
2
+ import logging
3
+ from logging import (
4
+ Logger,
5
+ )
6
+
7
+ from dateutil.parser import parse as parse_dt
8
+ from fa_purity import (
9
+ Cmd,
10
+ FrozenDict,
11
+ FrozenList,
12
+ Maybe,
13
+ Result,
14
+ ResultE,
15
+ ResultSmash,
16
+ Stream,
17
+ cast_exception,
18
+ )
19
+ from fa_purity.json import (
20
+ JsonObj,
21
+ JsonPrimitive,
22
+ JsonPrimitiveUnfolder,
23
+ JsonUnfolder,
24
+ JsonValue,
25
+ UnfoldedFactory,
26
+ Unfolder,
27
+ )
28
+ from fluidattacks_etl_utils.bug import (
29
+ Bug,
30
+ )
31
+ from fluidattacks_etl_utils.paginate import (
32
+ cursor_pagination,
33
+ )
34
+ from pure_requests.basic import (
35
+ HttpClient,
36
+ HttpClientFactory,
37
+ Params,
38
+ )
39
+ from pure_requests.response import (
40
+ json_decode,
41
+ )
42
+ from requests import (
43
+ Response,
44
+ )
45
+
46
+ from fluidattacks_refiner_sdk.auth import (
47
+ Credentials,
48
+ )
49
+ from fluidattacks_refiner_sdk.client._common import (
50
+ DataPage,
51
+ decode_items,
52
+ decode_next_cursor,
53
+ with_retry_handler,
54
+ )
55
+ from fluidattacks_refiner_sdk.core import (
56
+ Account,
57
+ AccountDomain,
58
+ AccountId,
59
+ Contact,
60
+ ContactId,
61
+ FormId,
62
+ FormName,
63
+ ResponseId,
64
+ SurveyResponse,
65
+ relative_endpoint,
66
+ )
67
+
68
+ LOG = logging.getLogger(__name__)
69
+
70
+ _MAX_PAGE_LENGTH = 1000
71
+
72
+
73
+ def _iso_to_unix(iso: str) -> ResultE[int]:
74
+ try:
75
+ return Result[int, Exception].success(int(parse_dt(iso).timestamp()))
76
+ except (ValueError, OverflowError) as e:
77
+ return Result[int, Exception].failure(e)
78
+
79
+
80
+ def _decode_int_value(v: JsonValue) -> ResultE[int]:
81
+ return Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_int)
82
+
83
+
84
+ def _decode_str_value(v: JsonValue) -> ResultE[str]:
85
+ return Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str)
86
+
87
+
88
+ def _decode_account_obj(obj: JsonObj) -> ResultE[Account]:
89
+ return JsonUnfolder.require(
90
+ obj,
91
+ "uuid",
92
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
93
+ ).map(
94
+ lambda acc_id: Account(
95
+ id=AccountId(acc_id),
96
+ remote_id=Maybe[str].empty(),
97
+ display_name=Maybe[str].empty(),
98
+ domain=Maybe[AccountDomain].empty(),
99
+ ),
100
+ )
101
+
102
+
103
+ def _decode_account(raw: JsonObj) -> ResultE[Maybe[Account]]:
104
+ return JsonUnfolder.optional(
105
+ raw,
106
+ "account",
107
+ lambda v: Unfolder.to_json(v).bind(_decode_account_obj),
108
+ )
109
+
110
+
111
+ def _decode_contact(raw: JsonObj) -> ResultE[Contact]:
112
+ contact_id = JsonUnfolder.require(
113
+ raw,
114
+ "uuid",
115
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
116
+ ).map(ContactId)
117
+ email = JsonUnfolder.optional(
118
+ raw,
119
+ "email",
120
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
121
+ )
122
+ display_name = JsonUnfolder.optional(
123
+ raw,
124
+ "display_name",
125
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
126
+ )
127
+ account = _decode_account(raw)
128
+ return ResultSmash.smash_result_4(contact_id, email, display_name, account).map(
129
+ lambda t: Contact(*t),
130
+ )
131
+
132
+
133
+ def _find_nps_score(data: JsonObj) -> Maybe[int]:
134
+ for key in ("nps", "NPS", "score", "nps_score"):
135
+ found: Maybe[int] = JsonUnfolder.optional(
136
+ data,
137
+ key,
138
+ _decode_int_value,
139
+ ).value_or(Maybe[int].empty())
140
+ if found != Maybe[int].empty():
141
+ return found
142
+ return Maybe[int].empty()
143
+
144
+
145
+ def _find_comment(data: JsonObj) -> Maybe[str]:
146
+ for key in ("comment", "feedback", "message", "text"):
147
+ found: Maybe[str] = JsonUnfolder.optional(
148
+ data,
149
+ key,
150
+ _decode_str_value,
151
+ ).value_or(Maybe[str].empty())
152
+ if found != Maybe[str].empty():
153
+ return found
154
+ return Maybe[str].empty()
155
+
156
+
157
+ def _decode_response(raw: JsonObj) -> ResultE[SurveyResponse]:
158
+ resp_id = JsonUnfolder.require(
159
+ raw,
160
+ "uuid",
161
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str),
162
+ ).map(ResponseId)
163
+ completed_at = JsonUnfolder.optional(
164
+ raw,
165
+ "completed_at",
166
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str).bind(_iso_to_unix),
167
+ )
168
+ received_at = JsonUnfolder.optional(
169
+ raw,
170
+ "received_at",
171
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str).bind(_iso_to_unix),
172
+ )
173
+ dismissed_at = JsonUnfolder.optional(
174
+ raw,
175
+ "dismissed_at",
176
+ lambda v: Unfolder.to_primitive(v).bind(JsonPrimitiveUnfolder.to_str).bind(_iso_to_unix),
177
+ )
178
+ form_id = JsonUnfolder.require(
179
+ raw,
180
+ "form",
181
+ lambda v: Unfolder.to_json(v).bind(
182
+ lambda obj: JsonUnfolder.require(
183
+ obj,
184
+ "uuid",
185
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
186
+ ),
187
+ ),
188
+ ).map(FormId)
189
+ form_name = JsonUnfolder.require(
190
+ raw,
191
+ "form",
192
+ lambda v: Unfolder.to_json(v).bind(
193
+ lambda obj: JsonUnfolder.require(
194
+ obj,
195
+ "name",
196
+ lambda vv: Unfolder.to_primitive(vv).bind(JsonPrimitiveUnfolder.to_str),
197
+ ),
198
+ ),
199
+ ).map(FormName)
200
+ contact = JsonUnfolder.require(
201
+ raw,
202
+ "contact",
203
+ lambda v: Unfolder.to_json(v).bind(_decode_contact),
204
+ )
205
+ nps_score = JsonUnfolder.optional(
206
+ raw,
207
+ "data",
208
+ lambda v: Unfolder.to_json(v).map(_find_nps_score),
209
+ ).map(lambda m: m.bind(lambda x: x))
210
+ comment = JsonUnfolder.optional(
211
+ raw,
212
+ "data",
213
+ lambda v: Unfolder.to_json(v).map(_find_comment),
214
+ ).map(lambda m: m.bind(lambda x: x))
215
+ raw_data: ResultE[Maybe[str]] = Result[Maybe[str], Exception].success(Maybe[str].empty())
216
+ group_1 = ResultSmash.smash_result_5(resp_id, completed_at, received_at, dismissed_at, form_id)
217
+ group_2 = ResultSmash.smash_result_5(form_name, contact, nps_score, comment, raw_data)
218
+ return ResultSmash.smash_result_2(group_1, group_2).map(
219
+ lambda t: SurveyResponse(*t[0], *t[1]),
220
+ )
221
+
222
+
223
+ def _decode_json_response(resp: Response) -> ResultE[DataPage[SurveyResponse]]:
224
+ return (
225
+ json_decode(resp)
226
+ .alt(cast_exception)
227
+ .bind(
228
+ lambda c: c.map(
229
+ lambda obj: decode_items(obj, _decode_response).bind(
230
+ lambda items: decode_next_cursor(obj).map(
231
+ lambda cursor: DataPage(items, cursor),
232
+ ),
233
+ ),
234
+ lambda lst: Result[DataPage[SurveyResponse], Exception].failure(
235
+ Bug[Exception].new(
236
+ "_decode_json_response",
237
+ inspect.currentframe(),
238
+ ValueError(f"Expected json object but got list: {lst!r}"),
239
+ (),
240
+ ),
241
+ ),
242
+ ),
243
+ )
244
+ )
245
+
246
+
247
+ def _get_responses_page(
248
+ log: Logger,
249
+ client: HttpClient,
250
+ form_id: FormId,
251
+ cursor: Maybe[str],
252
+ ) -> Cmd[tuple[FrozenList[SurveyResponse], Maybe[str]]]:
253
+ params: dict[str, JsonValue] = {
254
+ "page_length": JsonValue.from_primitive(JsonPrimitive.from_int(_MAX_PAGE_LENGTH)),
255
+ "form_uuid": JsonValue.from_primitive(JsonPrimitive.from_str(form_id.form)),
256
+ }
257
+ cursor_params = cursor.map(
258
+ lambda c: {
259
+ "page_cursor": JsonValue.from_primitive(JsonPrimitive.from_str(c)),
260
+ },
261
+ ).value_or(dict[str, JsonValue]())
262
+ get_response = client.get(
263
+ relative_endpoint("v1/responses"),
264
+ Params(FrozenDict({**params, **cursor_params})),
265
+ )
266
+ return Cmd[None].wrap_impure(
267
+ lambda: log.info("API: get_responses_page(form=%s)", form_id.form),
268
+ ) + with_retry_handler(get_response, 3).map(
269
+ lambda r: r.bind(_decode_json_response),
270
+ ).map(
271
+ lambda r: Bug[DataPage[SurveyResponse]].assume_success(
272
+ "_get_responses_page",
273
+ inspect.currentframe(),
274
+ (),
275
+ r,
276
+ ),
277
+ ).map(
278
+ lambda page: (page.items, page.next_cursor),
279
+ )
280
+
281
+
282
+ def get_responses(
283
+ log: Logger,
284
+ creds: Credentials,
285
+ form_id: FormId,
286
+ ) -> Stream[FrozenList[SurveyResponse]]:
287
+ headers = UnfoldedFactory.from_dict({"Authorization": " ".join(["Bearer", creds.api_key])})
288
+ client = HttpClientFactory.new_client(None, headers, None)
289
+ return cursor_pagination(lambda cursor: _get_responses_page(log, client, form_id, cursor))
@@ -0,0 +1,124 @@
1
+ from collections.abc import (
2
+ Callable,
3
+ )
4
+ from dataclasses import (
5
+ dataclass,
6
+ )
7
+
8
+ from fa_purity import (
9
+ Cmd,
10
+ FrozenList,
11
+ Maybe,
12
+ Stream,
13
+ )
14
+ from pure_requests.basic import (
15
+ Endpoint,
16
+ )
17
+
18
+ REFINER_API = Endpoint("https://api.refiner.io")
19
+
20
+
21
+ def relative_endpoint(relative: str) -> Endpoint:
22
+ return Endpoint(REFINER_API.raw.rstrip("/") + "/" + relative.lstrip("/"))
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FormId:
27
+ form: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FormName:
32
+ name: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AccountId:
37
+ account: str
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AccountName:
42
+ name: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class AccountDomain:
47
+ domain: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Account:
52
+ id: AccountId
53
+ remote_id: Maybe[str]
54
+ display_name: Maybe[str]
55
+ domain: Maybe[AccountDomain]
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ContactId:
60
+ contact: str
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Contact:
65
+ id: ContactId
66
+ email: Maybe[str]
67
+ display_name: Maybe[str]
68
+ account: Maybe[Account]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ResponseId:
73
+ response: str
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SurveyResponse:
78
+ id: ResponseId
79
+ completed_at: Maybe[int]
80
+ received_at: Maybe[int]
81
+ dismissed_at: Maybe[int]
82
+ form_id: FormId
83
+ form_name: FormName
84
+ contact: Contact
85
+ nps_score: Maybe[int]
86
+ comment: Maybe[str]
87
+ raw_data: Maybe[str]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Person:
92
+ id: ContactId
93
+ remote_id: Maybe[str]
94
+ email: Maybe[str]
95
+ display_name: Maybe[str]
96
+ first_seen_at: Maybe[int]
97
+ last_seen_at: Maybe[int]
98
+ account: Maybe[Account]
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class NpsMetrics:
103
+ nps: int
104
+ promoter_count: int
105
+ promoter_percent: float
106
+ passive_count: int
107
+ passive_percent: float
108
+ detractor_count: int
109
+ detractor_percent: float
110
+ response_count: int
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class Form:
115
+ id: FormId
116
+ name: FormName
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class ApiClient:
121
+ get_forms: Stream[FrozenList[Form]]
122
+ get_responses: Callable[[FormId], Stream[FrozenList[SurveyResponse]]]
123
+ get_contacts: Stream[FrozenList[Person]]
124
+ get_nps_metrics: Cmd[NpsMetrics]
File without changes
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluidattacks_refiner_sdk
3
+ Version: 0.1.1
4
+ Summary: refiner SDK
5
+ Author-email: Product Team <development@fluidattacks.com>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: click >=8.3.0, <9.0.0
8
+ Requires-Dist: fluidattacks-etl-utils >=5.0.0, <6.0.0
9
+ Requires-Dist: fa-purity >=2.5.2, <3.0.0
10
+ Requires-Dist: pure-requests >=3.0.0, <4.0.0
11
+ Requires-Dist: python-dateutil >=2.8.0, <3.0.0
@@ -0,0 +1,14 @@
1
+ fluidattacks_refiner_sdk/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ fluidattacks_refiner_sdk/core.py,sha256=cq_RkGp_ngAR7GRX94Xwp67IALK4dBVE435g8ZAqIII,2156
3
+ fluidattacks_refiner_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ fluidattacks_refiner_sdk/auth/__init__.py,sha256=N28zBB_AOpt43qbLf1Duy56dj4DQ-Z7WBAMBEc8erRA,74
5
+ fluidattacks_refiner_sdk/auth/_core.py,sha256=dJaZZCfsc8ZW56jXPSZREmhNw84R5u-6Vrrza_OjCNY,136
6
+ fluidattacks_refiner_sdk/client/__init__.py,sha256=pZYeiKSpC71NLlCATvAavefjj6lEEO4_icivwPG0enk,822
7
+ fluidattacks_refiner_sdk/client/_common.py,sha256=AGfCg5q0hWBjj_Ep7gZMakRUFI1-HIJ3cAUmV6DxPiw,3101
8
+ fluidattacks_refiner_sdk/client/_get_contacts.py,sha256=1KZzSiNL0TRMDwu1I7tFpF0rk-qP7zvbe74-cBE-MM8,5974
9
+ fluidattacks_refiner_sdk/client/_get_forms.py,sha256=ZT_lrxKd-DDfgNUxY832e8MBEvYJoFG13YYHxvMaQzE,3614
10
+ fluidattacks_refiner_sdk/client/_get_reporting.py,sha256=80lOOf1MdVw2OTDdlLOXxDFOicHJehC5Je7AgDqz1xw,3900
11
+ fluidattacks_refiner_sdk/client/_get_responses.py,sha256=omhdtzXTL_xiG9qc7MfMw5d7QIRvklJvkAMjflIJN54,8424
12
+ fluidattacks_refiner_sdk-0.1.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
13
+ fluidattacks_refiner_sdk-0.1.1.dist-info/METADATA,sha256=R03A18lk4pEkiuZlt6BsjHXS5IqW5UcXCoKNItq_Dyk,395
14
+ fluidattacks_refiner_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any