appnigma-sdk 0.1.0__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.
- appnigma/__init__.py +37 -0
- appnigma/_checks.py +96 -0
- appnigma/_client.py +211 -0
- appnigma/_connections.py +38 -0
- appnigma/_constants.py +83 -0
- appnigma/_crm.py +45 -0
- appnigma/_errors.py +156 -0
- appnigma/_generated/__init__.py +132 -0
- appnigma/_generated/api/__init__.py +5 -0
- appnigma/_generated/api/default_api.py +4195 -0
- appnigma/_generated/api_client.py +842 -0
- appnigma/_generated/api_response.py +21 -0
- appnigma/_generated/configuration.py +654 -0
- appnigma/_generated/exceptions.py +218 -0
- appnigma/_generated/models/__init__.py +58 -0
- appnigma/_generated/models/connection_status.py +38 -0
- appnigma/_generated/models/create_connection_link_input.py +114 -0
- appnigma/_generated/models/create_connection_link_output.py +90 -0
- appnigma/_generated/models/crm_company.py +141 -0
- appnigma/_generated/models/crm_company_get_output.py +92 -0
- appnigma/_generated/models/crm_company_list_output.py +102 -0
- appnigma/_generated/models/crm_contact.py +173 -0
- appnigma/_generated/models/crm_contact_get_output.py +92 -0
- appnigma/_generated/models/crm_contact_list_output.py +102 -0
- appnigma/_generated/models/crm_custom_field.py +99 -0
- appnigma/_generated/models/crm_deal.py +215 -0
- appnigma/_generated/models/crm_deal_get_output.py +92 -0
- appnigma/_generated/models/crm_deal_list_output.py +102 -0
- appnigma/_generated/models/crm_email.py +107 -0
- appnigma/_generated/models/crm_get_input.py +107 -0
- appnigma/_generated/models/crm_labelled.py +95 -0
- appnigma/_generated/models/crm_list_input.py +109 -0
- appnigma/_generated/models/crm_phone.py +107 -0
- appnigma/_generated/models/function_entry.py +102 -0
- appnigma/_generated/models/function_owner.py +137 -0
- appnigma/_generated/models/function_owner_one_of.py +95 -0
- appnigma/_generated/models/function_owner_one_of1.py +97 -0
- appnigma/_generated/models/get_connection_output.py +93 -0
- appnigma/_generated/models/get_guide_output.py +88 -0
- appnigma/_generated/models/json_value.py +197 -0
- appnigma/_generated/models/list_functions_output.py +95 -0
- appnigma/_generated/models/platform_request_input.py +122 -0
- appnigma/_generated/models/platform_request_input_body.py +139 -0
- appnigma/_generated/models/platform_request_output.py +101 -0
- appnigma/_generated/models/post_input.py +130 -0
- appnigma/_generated/models/post_input_record_value.py +161 -0
- appnigma/_generated/models/post_output.py +90 -0
- appnigma/_generated/models/pull_input.py +118 -0
- appnigma/_generated/models/pull_output.py +115 -0
- appnigma/_generated/models/pull_output_records_inner.py +90 -0
- appnigma/_generated/models/ran_as.py +98 -0
- appnigma/_generated/models/run_input.py +135 -0
- appnigma/_generated/models/run_output.py +103 -0
- appnigma/_generated/models/sdk_error.py +92 -0
- appnigma/_generated/models/sdk_error_error.py +103 -0
- appnigma/_generated/models/sdk_error_error_details.py +107 -0
- appnigma/_generated/models/sdk_problem.py +90 -0
- appnigma/_generated/rest.py +334 -0
- appnigma/_names.py +51 -0
- appnigma/_retry.py +93 -0
- appnigma/_transport.py +149 -0
- appnigma/_version.py +5 -0
- appnigma/py.typed +0 -0
- appnigma_sdk-0.1.0.dist-info/METADATA +424 -0
- appnigma_sdk-0.1.0.dist-info/RECORD +67 -0
- appnigma_sdk-0.1.0.dist-info/WHEEL +4 -0
- appnigma_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
appnigma/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The package a customer's product installs to reach its Appnigma microserver."""
|
|
2
|
+
|
|
3
|
+
from appnigma._client import Appnigma
|
|
4
|
+
from appnigma._constants import CONNECTION_STATUSES, ERROR_CODES, OBJECTS, PLATFORM_REQUEST_METHODS
|
|
5
|
+
from appnigma._errors import (
|
|
6
|
+
AppnigmaError,
|
|
7
|
+
AuthError,
|
|
8
|
+
InputError,
|
|
9
|
+
NetworkError,
|
|
10
|
+
NotAvailableError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ReconnectError,
|
|
14
|
+
RetiredError,
|
|
15
|
+
ServerError,
|
|
16
|
+
)
|
|
17
|
+
from appnigma._version import API_DESCRIPTION_VERSION, __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"API_DESCRIPTION_VERSION",
|
|
21
|
+
"CONNECTION_STATUSES",
|
|
22
|
+
"ERROR_CODES",
|
|
23
|
+
"OBJECTS",
|
|
24
|
+
"PLATFORM_REQUEST_METHODS",
|
|
25
|
+
"Appnigma",
|
|
26
|
+
"AppnigmaError",
|
|
27
|
+
"AuthError",
|
|
28
|
+
"InputError",
|
|
29
|
+
"NetworkError",
|
|
30
|
+
"NotAvailableError",
|
|
31
|
+
"NotFoundError",
|
|
32
|
+
"RateLimitError",
|
|
33
|
+
"ReconnectError",
|
|
34
|
+
"RetiredError",
|
|
35
|
+
"ServerError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
appnigma/_checks.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Refusals made here, before any call, for a setting or an input the server
|
|
2
|
+
would refuse anyway. Same shape as the server's, so one `except` handles both."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any, NoReturn
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from appnigma._constants import (
|
|
9
|
+
API_URL_ENV_NAME,
|
|
10
|
+
DEFAULT_BASE_URL,
|
|
11
|
+
END_CUSTOMER_ID_PATH_RULE,
|
|
12
|
+
END_CUSTOMER_ID_PATH_UNSAFE,
|
|
13
|
+
END_CUSTOMER_ID_PATTERN,
|
|
14
|
+
END_CUSTOMER_ID_RULE,
|
|
15
|
+
KEY_ENV_NAME,
|
|
16
|
+
KEY_PREFIX,
|
|
17
|
+
LOCAL_HOSTS,
|
|
18
|
+
MAX_TIMER_MS,
|
|
19
|
+
)
|
|
20
|
+
from appnigma._errors import AuthError, InputError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def refuse(field: str, problem: str) -> NoReturn:
|
|
24
|
+
raise InputError(
|
|
25
|
+
code="INVALID_INPUT",
|
|
26
|
+
message=f"{field} {problem}",
|
|
27
|
+
details={"problems": [{"field": field, "problem": problem}]},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_whole(value: Any) -> bool:
|
|
32
|
+
# In Python `True` is an int; a setting that asks for a number refuses it.
|
|
33
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_key(key: Any) -> str:
|
|
37
|
+
if not key:
|
|
38
|
+
raise AuthError(code="SDK_KEY_MISSING", message=f"No SDK key: pass key=, or set {KEY_ENV_NAME}")
|
|
39
|
+
if not isinstance(key, str) or not key.startswith(KEY_PREFIX):
|
|
40
|
+
raise AuthError(code="SDK_KEY_MISSING", message=f"That is not an Appnigma SDK key, it starts with {KEY_PREFIX}")
|
|
41
|
+
return key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def check_timeout_ms(value: Any) -> int:
|
|
45
|
+
if _is_whole(value) and 1 <= value <= MAX_TIMER_MS:
|
|
46
|
+
return value
|
|
47
|
+
refuse("timeout_ms", f"must be a whole number of milliseconds from 1 to {MAX_TIMER_MS}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_max_retries(value: Any) -> int:
|
|
51
|
+
if _is_whole(value) and value >= 0:
|
|
52
|
+
return value
|
|
53
|
+
refuse("max_retries", "must be a whole number, 0 or more")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_max_retry_wait_ms(value: Any) -> float:
|
|
57
|
+
is_number = _is_whole(value) or (isinstance(value, float) and math.isfinite(value))
|
|
58
|
+
if is_number and 0 <= value <= MAX_TIMER_MS:
|
|
59
|
+
return value
|
|
60
|
+
refuse("max_retry_wait_ms", f"must be a number of milliseconds from 0 to {MAX_TIMER_MS}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_end_customer_id(value: Any) -> str:
|
|
64
|
+
if not isinstance(value, str) or not END_CUSTOMER_ID_PATTERN.fullmatch(value):
|
|
65
|
+
refuse("end_customer_id", END_CUSTOMER_ID_RULE)
|
|
66
|
+
if value in END_CUSTOMER_ID_PATH_UNSAFE:
|
|
67
|
+
refuse("end_customer_id", END_CUSTOMER_ID_PATH_RULE)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_platform(value: Any) -> str:
|
|
72
|
+
if isinstance(value, str) and value:
|
|
73
|
+
return value
|
|
74
|
+
refuse("platform", "must be a platform key, such as hubspot")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_base_url(setting: str | None) -> str:
|
|
78
|
+
"""The setting if there is one, else production. An empty setting counts as
|
|
79
|
+
none. Only https, or http to this machine, so a key never crosses a network
|
|
80
|
+
unencrypted."""
|
|
81
|
+
address = (setting or "").strip() or DEFAULT_BASE_URL
|
|
82
|
+
address = address.rstrip("/")
|
|
83
|
+
if not _allowed_address(address):
|
|
84
|
+
refuse(API_URL_ENV_NAME, "must be an https:// address, or http:// to localhost")
|
|
85
|
+
return address
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _allowed_address(address: str) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
parsed = urlsplit(address)
|
|
91
|
+
host = parsed.hostname
|
|
92
|
+
except ValueError:
|
|
93
|
+
return False
|
|
94
|
+
if parsed.scheme == "https":
|
|
95
|
+
return bool(host)
|
|
96
|
+
return parsed.scheme == "http" and host in LOCAL_HOSTS
|
appnigma/_client.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from appnigma import _checks, _errors, _names
|
|
6
|
+
from appnigma._connections import Connections
|
|
7
|
+
from appnigma._constants import (
|
|
8
|
+
API_URL_ENV_NAME,
|
|
9
|
+
DEFAULT_MAX_RETRIES,
|
|
10
|
+
DEFAULT_MAX_RETRY_WAIT_MS,
|
|
11
|
+
DEFAULT_TIMEOUT_MS,
|
|
12
|
+
KEY_ENV_NAME,
|
|
13
|
+
)
|
|
14
|
+
from appnigma._crm import Crm
|
|
15
|
+
from appnigma._errors import ServerError
|
|
16
|
+
from appnigma._retry import Policy, with_retries
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Appnigma:
|
|
20
|
+
"""What a customer creates. The key comes from `key` or from the
|
|
21
|
+
APPNIGMA_SDK_KEY environment variable; a client started with no key, or
|
|
22
|
+
with one that does not start with apn_live_, raises AuthError at once,
|
|
23
|
+
before any call.
|
|
24
|
+
|
|
25
|
+
Every call takes keyword arguments in Python style and answers a dict with
|
|
26
|
+
keys in Python style, except inside content that is not ours (a record's
|
|
27
|
+
properties, a function's input and output, a platform's answer), which
|
|
28
|
+
comes back exactly as it was sent. Every call also takes `timeout_ms`, the
|
|
29
|
+
time limit of one attempt.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
key: str | None = None,
|
|
35
|
+
*,
|
|
36
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
37
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
38
|
+
max_retry_wait_ms: float = DEFAULT_MAX_RETRY_WAIT_MS,
|
|
39
|
+
transport: Any = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""`transport` is for tests; a customer never passes it."""
|
|
42
|
+
self._key = _checks.check_key(key if key is not None else os.environ.get(KEY_ENV_NAME))
|
|
43
|
+
self._base_url = _checks.check_base_url(os.environ.get(API_URL_ENV_NAME))
|
|
44
|
+
self._timeout_ms = _checks.check_timeout_ms(timeout_ms)
|
|
45
|
+
self._max_retries = _checks.check_max_retries(max_retries)
|
|
46
|
+
self._max_retry_wait_ms = _checks.check_max_retry_wait_ms(max_retry_wait_ms)
|
|
47
|
+
self._policy = Policy(max_retries=self._max_retries, max_retry_wait_ms=self._max_retry_wait_ms)
|
|
48
|
+
# A write whose repeat is not harmless gets one attempt. create_link
|
|
49
|
+
# makes a new link every time; run is your own function, and only you
|
|
50
|
+
# know whether running it twice is harmless; request with a method
|
|
51
|
+
# other than GET may change the platform. post is retried because the
|
|
52
|
+
# server updates the record that matches.
|
|
53
|
+
self._one_attempt = Policy(max_retries=0, max_retry_wait_ms=self._max_retry_wait_ms)
|
|
54
|
+
if transport is None:
|
|
55
|
+
# Imported here, not at the top, so `import appnigma` does not load
|
|
56
|
+
# the generated client and pydantic until a client is made.
|
|
57
|
+
from appnigma._transport import Transport
|
|
58
|
+
|
|
59
|
+
transport = Transport(base_url=self._base_url, key=self._key)
|
|
60
|
+
self._transport = transport
|
|
61
|
+
self._connections = Connections(self)
|
|
62
|
+
self._crm = Crm(self)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def base_url(self) -> str:
|
|
66
|
+
return self._base_url
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def timeout_ms(self) -> int:
|
|
70
|
+
return self._timeout_ms
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def max_retries(self) -> int:
|
|
74
|
+
return self._max_retries
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def max_retry_wait_ms(self) -> float:
|
|
78
|
+
return self._max_retry_wait_ms
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def connections(self) -> Connections:
|
|
82
|
+
"""Connecting an end customer: `create_link` and `get`."""
|
|
83
|
+
return self._connections
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def crm(self) -> Crm:
|
|
87
|
+
"""An end customer's CRM data in one shape: `contacts`, `companies` and
|
|
88
|
+
`deals`, each with `list` and `get`."""
|
|
89
|
+
return self._crm
|
|
90
|
+
|
|
91
|
+
def post(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
end_customer_id: str,
|
|
95
|
+
platform: str,
|
|
96
|
+
object: str,
|
|
97
|
+
record: Mapping[str, Any],
|
|
98
|
+
unique_by: str | None = None,
|
|
99
|
+
timeout_ms: int | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Creates a record in the end customer's HubSpot, or updates the one
|
|
102
|
+
that already matches on `unique_by` (email for a contact unless you
|
|
103
|
+
name another property; required for a company and a deal)."""
|
|
104
|
+
# The generated client serialises only a plain dict: a read-only
|
|
105
|
+
# mapping would raise, and a UserDict would be sent under "data".
|
|
106
|
+
body = _names.camel_keys(
|
|
107
|
+
end_customer_id=end_customer_id, platform=platform, object=object, record=dict(record), unique_by=unique_by
|
|
108
|
+
)
|
|
109
|
+
return self._call("post_record", {"post_input": body}, timeout_ms=timeout_ms)
|
|
110
|
+
|
|
111
|
+
def pull(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
end_customer_id: str,
|
|
115
|
+
platform: str,
|
|
116
|
+
object: str,
|
|
117
|
+
limit: int | None = None,
|
|
118
|
+
after: str | None = None,
|
|
119
|
+
timeout_ms: int | None = None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""One page of records and a marker for the next; `next` is None on
|
|
122
|
+
the last page. `limit` is 1 to 100."""
|
|
123
|
+
body = _names.camel_keys(
|
|
124
|
+
end_customer_id=end_customer_id, platform=platform, object=object, limit=limit, after=after
|
|
125
|
+
)
|
|
126
|
+
return self._call("pull_records", {"pull_input": body}, timeout_ms=timeout_ms)
|
|
127
|
+
|
|
128
|
+
def run(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
end_customer_id: str,
|
|
132
|
+
platform: str,
|
|
133
|
+
function_name: str,
|
|
134
|
+
input: Any,
|
|
135
|
+
timeout_ms: int | None = None,
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""One of your own functions, by platform and name: its live version
|
|
138
|
+
runs, and the answer's `ran_as` says whose it was. Made once, never
|
|
139
|
+
retried."""
|
|
140
|
+
body = _names.camel_keys(
|
|
141
|
+
end_customer_id=end_customer_id,
|
|
142
|
+
platform=platform,
|
|
143
|
+
function_name=function_name,
|
|
144
|
+
input=input,
|
|
145
|
+
)
|
|
146
|
+
return self._call("run_function", {"run_input": body}, policy=self._one_attempt, timeout_ms=timeout_ms)
|
|
147
|
+
|
|
148
|
+
def catalog(self, *, platform: str | None = None, timeout_ms: int | None = None) -> dict[str, Any]:
|
|
149
|
+
"""Your own functions, with their shapes. `platform` narrows the list."""
|
|
150
|
+
return self._call("list_functions", {"platform": platform}, timeout_ms=timeout_ms)
|
|
151
|
+
|
|
152
|
+
def guide(self, *, timeout_ms: int | None = None) -> dict[str, Any]:
|
|
153
|
+
"""The guide for your developers, as markdown, written for your company
|
|
154
|
+
from what is live right now."""
|
|
155
|
+
return self._call("get_guide", timeout_ms=timeout_ms)
|
|
156
|
+
|
|
157
|
+
def request(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
end_customer_id: str,
|
|
161
|
+
platform: str,
|
|
162
|
+
method: str,
|
|
163
|
+
path: str,
|
|
164
|
+
body: Any = None,
|
|
165
|
+
timeout_ms: int | None = None,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Anything else on the end customer's platform, answered as the
|
|
168
|
+
platform answered, refusals included. A GET is retried like a read;
|
|
169
|
+
any other method is made once."""
|
|
170
|
+
payload = _names.camel_keys(
|
|
171
|
+
end_customer_id=end_customer_id, platform=platform, method=method, path=path, body=body
|
|
172
|
+
)
|
|
173
|
+
policy = self._policy if method == "GET" else self._one_attempt
|
|
174
|
+
return self._call("platform_request", {"platform_request_input": payload}, policy=policy, timeout_ms=timeout_ms)
|
|
175
|
+
|
|
176
|
+
def _call(
|
|
177
|
+
self,
|
|
178
|
+
operation: str,
|
|
179
|
+
params: Mapping[str, Any] | None = None,
|
|
180
|
+
*,
|
|
181
|
+
policy: Policy | None = None,
|
|
182
|
+
timeout_ms: int | None = None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""One call: through the transport, under the given retry policy, and
|
|
185
|
+
into the answer or the error for its status. Used by Connections and
|
|
186
|
+
Crm as well."""
|
|
187
|
+
attempt_ms = self._timeout_ms if timeout_ms is None else _checks.check_timeout_ms(timeout_ms)
|
|
188
|
+
answer = with_retries(
|
|
189
|
+
policy or self._policy, lambda: self._transport.attempt(operation, params, timeout_ms=attempt_ms)
|
|
190
|
+
)
|
|
191
|
+
return _read_answer(answer)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _read_answer(answer: dict[str, Any]) -> dict[str, Any]:
|
|
195
|
+
"""A success whose body is not JSON is a server error: a proxy page or an
|
|
196
|
+
empty body is never handed back as data."""
|
|
197
|
+
if not answer["ok"]:
|
|
198
|
+
raise _errors.from_answer(
|
|
199
|
+
status=answer["status"],
|
|
200
|
+
body=answer["json"],
|
|
201
|
+
request_id=answer["request_id"],
|
|
202
|
+
retry_after_seconds=answer["retry_after_seconds"],
|
|
203
|
+
)
|
|
204
|
+
if answer["json"] is None:
|
|
205
|
+
raise ServerError(
|
|
206
|
+
code="INTERNAL_ERROR",
|
|
207
|
+
status=answer["status"],
|
|
208
|
+
request_id=answer["request_id"],
|
|
209
|
+
message=f"The Appnigma API answered {answer['status']} with a body that is not JSON",
|
|
210
|
+
)
|
|
211
|
+
return _names.answer(answer["json"])
|
appnigma/_connections.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from appnigma import _checks, _names
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from appnigma._client import Appnigma
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Connections:
|
|
10
|
+
"""Connecting an end customer to their account on a platform:
|
|
11
|
+
`client.connections.create_link` and `client.connections.get`."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, client: "Appnigma") -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def create_link(
|
|
17
|
+
self, *, end_customer_id: str, platform: str, return_url: str, timeout_ms: int | None = None
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
"""A link to the connect page for one end customer on one platform. It
|
|
20
|
+
works once and expires after 15 minutes. Made once, never retried:
|
|
21
|
+
every call makes a new link."""
|
|
22
|
+
body = _names.camel_keys(end_customer_id=end_customer_id, platform=platform, return_url=return_url)
|
|
23
|
+
return self._client._call(
|
|
24
|
+
"create_connection_link",
|
|
25
|
+
{"create_connection_link_input": body},
|
|
26
|
+
policy=self._client._one_attempt,
|
|
27
|
+
timeout_ms=timeout_ms,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def get(self, *, end_customer_id: str, platform: str, timeout_ms: int | None = None) -> dict[str, Any]:
|
|
31
|
+
"""Whether one end customer is connected on one platform. The id travels
|
|
32
|
+
in the path, so it is checked here first: a bad one is refused as the
|
|
33
|
+
server would refuse it, and never becomes a wrong route."""
|
|
34
|
+
_checks.check_end_customer_id(end_customer_id)
|
|
35
|
+
_checks.check_platform(platform)
|
|
36
|
+
return self._client._call(
|
|
37
|
+
"get_connection", {"end_customer_id": end_customer_id, "platform": platform}, timeout_ms=timeout_ms
|
|
38
|
+
)
|
appnigma/_constants.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
KEY_PREFIX = "apn_live_"
|
|
4
|
+
|
|
5
|
+
# Where `Appnigma()` finds the key when none is passed.
|
|
6
|
+
KEY_ENV_NAME = "APPNIGMA_SDK_KEY"
|
|
7
|
+
|
|
8
|
+
# Where the client calls: production.
|
|
9
|
+
DEFAULT_BASE_URL = "https://engine.appnigma.ai"
|
|
10
|
+
|
|
11
|
+
# Points the client at a local or staging server for our own testing. Read
|
|
12
|
+
# from the environment, never a client setting, and never in the README: a
|
|
13
|
+
# customer always calls production.
|
|
14
|
+
API_URL_ENV_NAME = "APPNIGMA_API_URL"
|
|
15
|
+
|
|
16
|
+
# The only hosts the address may reach over plain http://, so a key never
|
|
17
|
+
# crosses a network unencrypted. As `urlsplit` gives them: lowercase, and an
|
|
18
|
+
# IPv6 address without its brackets.
|
|
19
|
+
LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
20
|
+
|
|
21
|
+
ROUTE_BASE = "/sdk/v1"
|
|
22
|
+
|
|
23
|
+
REQUEST_ID_HEADER = "x-request-id"
|
|
24
|
+
RETRY_AFTER_HEADER = "retry-after"
|
|
25
|
+
|
|
26
|
+
# How long one attempt may take before it counts as a network failure.
|
|
27
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
28
|
+
|
|
29
|
+
# The same ceiling the TypeScript package applies, so the READMEs show the
|
|
30
|
+
# same numbers.
|
|
31
|
+
MAX_TIMER_MS = 2_147_483_647
|
|
32
|
+
|
|
33
|
+
# Tries again after the first attempt: three attempts in all.
|
|
34
|
+
DEFAULT_MAX_RETRIES = 2
|
|
35
|
+
|
|
36
|
+
# A Retry-After longer than this is reported, not waited for.
|
|
37
|
+
DEFAULT_MAX_RETRY_WAIT_MS = 30_000
|
|
38
|
+
|
|
39
|
+
# This package's own code for "no answer arrived". Not in the server's table,
|
|
40
|
+
# which only lists codes the server can send.
|
|
41
|
+
NETWORK_ERROR_CODE = "NETWORK_ERROR"
|
|
42
|
+
|
|
43
|
+
# The one 409 that sends an end customer through the connect flow again.
|
|
44
|
+
RECONNECT_ERROR_CODE = "CONNECTION_NEEDS_RECONNECT"
|
|
45
|
+
|
|
46
|
+
# An end customer id is 1 to 100 of letters, digits, dot, dash or underscore.
|
|
47
|
+
# Matched whole, with `fullmatch`.
|
|
48
|
+
END_CUSTOMER_ID_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,100}")
|
|
49
|
+
END_CUSTOMER_ID_RULE = "must be 1 to 100 characters: letters, digits, dot, dash or underscore"
|
|
50
|
+
|
|
51
|
+
# `.` and `..` fit the pattern but cannot travel in a URL path.
|
|
52
|
+
END_CUSTOMER_ID_PATH_UNSAFE = (".", "..")
|
|
53
|
+
END_CUSTOMER_ID_PATH_RULE = "cannot be only dots, it travels in the URL path"
|
|
54
|
+
|
|
55
|
+
# The server's error codes (the API description, 18.2). This package keeps its
|
|
56
|
+
# own copy: it is published on its own. A test compares it with the
|
|
57
|
+
# TypeScript package's list.
|
|
58
|
+
ERROR_CODES = (
|
|
59
|
+
"INVALID_INPUT",
|
|
60
|
+
"SDK_KEY_MISSING",
|
|
61
|
+
"SDK_KEY_INVALID",
|
|
62
|
+
"SDK_KEY_CANCELLED",
|
|
63
|
+
"MICROSERVER_NOT_ENABLED",
|
|
64
|
+
"PLATFORM_NOT_ENABLED",
|
|
65
|
+
"END_CUSTOMER_NOT_FOUND",
|
|
66
|
+
"FUNCTION_NOT_FOUND",
|
|
67
|
+
"FUNCTION_RETIRED",
|
|
68
|
+
"CONNECTION_NEEDS_RECONNECT",
|
|
69
|
+
"RATE_LIMITED",
|
|
70
|
+
"PLATFORM_RATE_LIMITED",
|
|
71
|
+
"PLATFORM_ERROR",
|
|
72
|
+
"TIMED_OUT",
|
|
73
|
+
"LOGIC_FAILED",
|
|
74
|
+
"INTERNAL_ERROR",
|
|
75
|
+
"RECORD_NOT_FOUND",
|
|
76
|
+
"DATA_TYPE_NOT_AVAILABLE",
|
|
77
|
+
"ACTION_NOT_FOUND",
|
|
78
|
+
"MISSING_SCOPES",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
OBJECTS = ("contact", "company", "deal")
|
|
82
|
+
CONNECTION_STATUSES = ("connected", "needs_reconnect", "disconnected")
|
|
83
|
+
PLATFORM_REQUEST_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
appnigma/_crm.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
from appnigma import _names
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from appnigma._client import Appnigma
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CrmObjects:
|
|
10
|
+
"""The two reads of one CRM object. Reads, so they are retried."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, client: "Appnigma", *, list_operation: str, get_operation: str) -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
self._list_operation = list_operation
|
|
15
|
+
self._get_operation = get_operation
|
|
16
|
+
|
|
17
|
+
def list(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
end_customer_id: str,
|
|
21
|
+
platform: str,
|
|
22
|
+
limit: int | None = None,
|
|
23
|
+
cursor: str | None = None,
|
|
24
|
+
timeout_ms: int | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""One page of records and a marker for the next; pass `next` as
|
|
27
|
+
`cursor` for the following page. `limit` is 1 to 100."""
|
|
28
|
+
body = _names.camel_keys(end_customer_id=end_customer_id, platform=platform, limit=limit, cursor=cursor)
|
|
29
|
+
return self._client._call(self._list_operation, {"crm_list_input": body}, timeout_ms=timeout_ms)
|
|
30
|
+
|
|
31
|
+
def get(self, *, end_customer_id: str, platform: str, id: str, timeout_ms: int | None = None) -> dict[str, Any]:
|
|
32
|
+
"""One record by its id in the CRM."""
|
|
33
|
+
body = _names.camel_keys(end_customer_id=end_customer_id, platform=platform, id=id)
|
|
34
|
+
return self._client._call(self._get_operation, {"crm_get_input": body}, timeout_ms=timeout_ms)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Crm:
|
|
38
|
+
"""An end customer's CRM data in one shape, whichever CRM they use:
|
|
39
|
+
`client.crm.contacts`, `client.crm.companies` and `client.crm.deals`, each
|
|
40
|
+
with `list` and `get`."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: "Appnigma") -> None:
|
|
43
|
+
self.contacts = CrmObjects(client, list_operation="list_crm_contacts", get_operation="get_crm_contact")
|
|
44
|
+
self.companies = CrmObjects(client, list_operation="list_crm_companies", get_operation="get_crm_company")
|
|
45
|
+
self.deals = CrmObjects(client, list_operation="list_crm_deals", get_operation="get_crm_deal")
|
appnigma/_errors.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from appnigma._constants import NETWORK_ERROR_CODE, RECONNECT_ERROR_CODE
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AppnigmaError(Exception):
|
|
7
|
+
"""Every failure this package raises, whether the server answered with an
|
|
8
|
+
error or could not be reached at all.
|
|
9
|
+
|
|
10
|
+
Catch this for "anything from Appnigma", or one of the subclasses for one
|
|
11
|
+
kind of failure. `code` is the string the server sent, known to this
|
|
12
|
+
package or not; `status` and `request_id` are None when nothing came back.
|
|
13
|
+
`str(error)` is the message.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
code: str,
|
|
20
|
+
message: str,
|
|
21
|
+
status: int | None = None,
|
|
22
|
+
request_id: str | None = None,
|
|
23
|
+
details: dict[str, Any] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.code = code
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status = status
|
|
29
|
+
self.request_id = request_id
|
|
30
|
+
self.details = details if details is not None else {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InputError(AppnigmaError):
|
|
34
|
+
"""400. The server names each field it could not accept, as `problems`: a
|
|
35
|
+
list of `{"field": ..., "problem": ...}`. Also raised locally for a bad
|
|
36
|
+
setting or a bad end customer id, before any call."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
39
|
+
super().__init__(**kwargs)
|
|
40
|
+
self.problems = read_problems(self.details)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthError(AppnigmaError):
|
|
44
|
+
"""401 and 403, and a client started without a key."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(AppnigmaError):
|
|
48
|
+
"""404."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RetiredError(AppnigmaError):
|
|
52
|
+
"""410 FUNCTION_RETIRED: the function was live and was removed from your
|
|
53
|
+
logic. It will not answer again, so it is never tried again."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ReconnectError(AppnigmaError):
|
|
57
|
+
"""409 CONNECTION_NEEDS_RECONNECT: the end customer has to approve again."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NotAvailableError(AppnigmaError):
|
|
61
|
+
"""Any other 409: the platform cannot give this yet."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RateLimitError(AppnigmaError):
|
|
65
|
+
"""429. `retry_after_seconds` is what the server's Retry-After header said,
|
|
66
|
+
or None when it sent none."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, retry_after_seconds: float | None = None, **kwargs: Any) -> None:
|
|
69
|
+
super().__init__(**kwargs)
|
|
70
|
+
self.retry_after_seconds = retry_after_seconds
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ServerError(AppnigmaError):
|
|
74
|
+
"""Any 5xx, any status this package does not know, and any answer that is
|
|
75
|
+
not JSON. Our side is at fault, not the caller's input."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NetworkError(AppnigmaError):
|
|
79
|
+
"""No answer arrived: the call failed, or the attempt ran out of time.
|
|
80
|
+
`__cause__` is the underlying exception when there is one."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, *, message: str, cause: BaseException | None = None) -> None:
|
|
83
|
+
super().__init__(code=NETWORK_ERROR_CODE, message=message)
|
|
84
|
+
self.__cause__ = cause
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Turns one answer from the server into the error for its status. The class is
|
|
88
|
+
# chosen by status, not by code, so a code this package has never seen still
|
|
89
|
+
# lands in the right class with its string kept.
|
|
90
|
+
|
|
91
|
+
_PROBLEM_KEYS = ("field", "problem")
|
|
92
|
+
|
|
93
|
+
# Every status the server's table names; any other is a ServerError. 409 is
|
|
94
|
+
# chosen by code as well, in error_class.
|
|
95
|
+
_CLASS_BY_STATUS: dict[int, type[AppnigmaError]] = {
|
|
96
|
+
400: InputError,
|
|
97
|
+
401: AuthError,
|
|
98
|
+
403: AuthError,
|
|
99
|
+
404: NotFoundError,
|
|
100
|
+
410: RetiredError,
|
|
101
|
+
429: RateLimitError,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def from_answer(
|
|
106
|
+
*, status: int, body: Any, request_id: str | None, retry_after_seconds: float | None = None
|
|
107
|
+
) -> AppnigmaError:
|
|
108
|
+
shape = read_error_shape(body) or {"code": "INTERNAL_ERROR", "message": "", "details": {}}
|
|
109
|
+
kwargs = {
|
|
110
|
+
"code": shape["code"],
|
|
111
|
+
"message": shape["message"] or f"The Appnigma API answered {status}",
|
|
112
|
+
"status": status,
|
|
113
|
+
"request_id": request_id,
|
|
114
|
+
"details": shape["details"],
|
|
115
|
+
}
|
|
116
|
+
cls = error_class(status, shape["code"])
|
|
117
|
+
if cls is RateLimitError:
|
|
118
|
+
return RateLimitError(retry_after_seconds=retry_after_seconds, **kwargs)
|
|
119
|
+
return cls(**kwargs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def error_class(status: int, code: str) -> type[AppnigmaError]:
|
|
123
|
+
"""Only CONNECTION_NEEDS_RECONNECT asks for a reconnect; any other 409 is
|
|
124
|
+
something the platform cannot give yet."""
|
|
125
|
+
if status == 409:
|
|
126
|
+
return ReconnectError if code == RECONNECT_ERROR_CODE else NotAvailableError
|
|
127
|
+
return _CLASS_BY_STATUS.get(status, ServerError)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_error_shape(body: Any) -> dict[str, Any] | None:
|
|
131
|
+
"""Our shape is `{"error": {"code", "message", "details"}}`, as JSON parses
|
|
132
|
+
it. Anything else reads as "not our shape"."""
|
|
133
|
+
if not isinstance(body, dict) or not isinstance(body.get("error"), dict):
|
|
134
|
+
return None
|
|
135
|
+
error = body["error"]
|
|
136
|
+
code = error.get("code")
|
|
137
|
+
if not isinstance(code, str) or not code:
|
|
138
|
+
return None
|
|
139
|
+
message = error.get("message")
|
|
140
|
+
details = error.get("details")
|
|
141
|
+
return {
|
|
142
|
+
"code": code,
|
|
143
|
+
"message": message if isinstance(message, str) else "",
|
|
144
|
+
"details": details if isinstance(details, dict) else {},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def read_problems(details: dict[str, Any]) -> list[dict[str, str]]:
|
|
149
|
+
entries = details.get("problems")
|
|
150
|
+
if not isinstance(entries, list):
|
|
151
|
+
return []
|
|
152
|
+
return [
|
|
153
|
+
{"field": entry["field"], "problem": entry["problem"]}
|
|
154
|
+
for entry in entries
|
|
155
|
+
if isinstance(entry, dict) and all(isinstance(entry.get(key), str) for key in _PROBLEM_KEYS)
|
|
156
|
+
]
|