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.
Files changed (67) hide show
  1. appnigma/__init__.py +37 -0
  2. appnigma/_checks.py +96 -0
  3. appnigma/_client.py +211 -0
  4. appnigma/_connections.py +38 -0
  5. appnigma/_constants.py +83 -0
  6. appnigma/_crm.py +45 -0
  7. appnigma/_errors.py +156 -0
  8. appnigma/_generated/__init__.py +132 -0
  9. appnigma/_generated/api/__init__.py +5 -0
  10. appnigma/_generated/api/default_api.py +4195 -0
  11. appnigma/_generated/api_client.py +842 -0
  12. appnigma/_generated/api_response.py +21 -0
  13. appnigma/_generated/configuration.py +654 -0
  14. appnigma/_generated/exceptions.py +218 -0
  15. appnigma/_generated/models/__init__.py +58 -0
  16. appnigma/_generated/models/connection_status.py +38 -0
  17. appnigma/_generated/models/create_connection_link_input.py +114 -0
  18. appnigma/_generated/models/create_connection_link_output.py +90 -0
  19. appnigma/_generated/models/crm_company.py +141 -0
  20. appnigma/_generated/models/crm_company_get_output.py +92 -0
  21. appnigma/_generated/models/crm_company_list_output.py +102 -0
  22. appnigma/_generated/models/crm_contact.py +173 -0
  23. appnigma/_generated/models/crm_contact_get_output.py +92 -0
  24. appnigma/_generated/models/crm_contact_list_output.py +102 -0
  25. appnigma/_generated/models/crm_custom_field.py +99 -0
  26. appnigma/_generated/models/crm_deal.py +215 -0
  27. appnigma/_generated/models/crm_deal_get_output.py +92 -0
  28. appnigma/_generated/models/crm_deal_list_output.py +102 -0
  29. appnigma/_generated/models/crm_email.py +107 -0
  30. appnigma/_generated/models/crm_get_input.py +107 -0
  31. appnigma/_generated/models/crm_labelled.py +95 -0
  32. appnigma/_generated/models/crm_list_input.py +109 -0
  33. appnigma/_generated/models/crm_phone.py +107 -0
  34. appnigma/_generated/models/function_entry.py +102 -0
  35. appnigma/_generated/models/function_owner.py +137 -0
  36. appnigma/_generated/models/function_owner_one_of.py +95 -0
  37. appnigma/_generated/models/function_owner_one_of1.py +97 -0
  38. appnigma/_generated/models/get_connection_output.py +93 -0
  39. appnigma/_generated/models/get_guide_output.py +88 -0
  40. appnigma/_generated/models/json_value.py +197 -0
  41. appnigma/_generated/models/list_functions_output.py +95 -0
  42. appnigma/_generated/models/platform_request_input.py +122 -0
  43. appnigma/_generated/models/platform_request_input_body.py +139 -0
  44. appnigma/_generated/models/platform_request_output.py +101 -0
  45. appnigma/_generated/models/post_input.py +130 -0
  46. appnigma/_generated/models/post_input_record_value.py +161 -0
  47. appnigma/_generated/models/post_output.py +90 -0
  48. appnigma/_generated/models/pull_input.py +118 -0
  49. appnigma/_generated/models/pull_output.py +115 -0
  50. appnigma/_generated/models/pull_output_records_inner.py +90 -0
  51. appnigma/_generated/models/ran_as.py +98 -0
  52. appnigma/_generated/models/run_input.py +135 -0
  53. appnigma/_generated/models/run_output.py +103 -0
  54. appnigma/_generated/models/sdk_error.py +92 -0
  55. appnigma/_generated/models/sdk_error_error.py +103 -0
  56. appnigma/_generated/models/sdk_error_error_details.py +107 -0
  57. appnigma/_generated/models/sdk_problem.py +90 -0
  58. appnigma/_generated/rest.py +334 -0
  59. appnigma/_names.py +51 -0
  60. appnigma/_retry.py +93 -0
  61. appnigma/_transport.py +149 -0
  62. appnigma/_version.py +5 -0
  63. appnigma/py.typed +0 -0
  64. appnigma_sdk-0.1.0.dist-info/METADATA +424 -0
  65. appnigma_sdk-0.1.0.dist-info/RECORD +67 -0
  66. appnigma_sdk-0.1.0.dist-info/WHEEL +4 -0
  67. 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"])
@@ -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
+ ]