mtx-api-py 0.4.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.
mtx_api/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from mtx_api.client.mtx_client import MTXClient
4
+ from mtx_api.models.errors import (
5
+ ApiDecodingError,
6
+ ApiError,
7
+ InvalidEmailError,
8
+ MissingBaseUrlError,
9
+ MissingClientIdError,
10
+ MissingJurisdictionCodesError,
11
+ MissingJwtSecretError,
12
+ )
13
+
14
+ __all__ = [
15
+ "MTXClient",
16
+ "ApiError",
17
+ "ApiDecodingError",
18
+ "InvalidEmailError",
19
+ "MissingBaseUrlError",
20
+ "MissingClientIdError",
21
+ "MissingJwtSecretError",
22
+ "MissingJurisdictionCodesError",
23
+ ]
24
+
25
+ __version__ = "0.4.0"
@@ -0,0 +1,3 @@
1
+ from mtx_api.client.mtx_client import MTXClient
2
+
3
+ __all__ = ["MTXClient"]
@@ -0,0 +1,230 @@
1
+ import re
2
+ from base64 import b64decode
3
+
4
+ import jwt
5
+
6
+ from mtx_api.client._mtx_http_client import Method, MtxHttpClient
7
+ from mtx_api.models.auth import Authentication
8
+ from mtx_api.models.errors import InvalidEmailError, MissingArgumentError, MissingJwtSecretError
9
+
10
+
11
+ class AuthClient:
12
+ def __init__(self, client: MtxHttpClient):
13
+ self._client: MtxHttpClient = client
14
+
15
+ async def login_anonymous(
16
+ self,
17
+ *,
18
+ device_id: str,
19
+ ) -> Authentication:
20
+ """Authenticates a user anonymously using a unique device identifier.
21
+
22
+ Use this tool to log in a user who does not have a registered account,
23
+ identifying them solely by their device ID. This enables access to
24
+ features that require authentication without user registration.
25
+
26
+ Args:
27
+ device_id: A stable and unique identifier for the device.
28
+
29
+ Returns:
30
+ Authentication: A model containing access and refresh tokens.
31
+
32
+ Raises:
33
+ MissingArgumentError: If `device_id` is empty or None.
34
+ """
35
+ if not device_id:
36
+ raise MissingArgumentError("device_id")
37
+
38
+ return await self._client.http_request(
39
+ method=Method.POST,
40
+ path="login-anonymous",
41
+ model=Authentication,
42
+ body={
43
+ "client_id": self._client.client_id,
44
+ "device_id": device_id,
45
+ },
46
+ )
47
+
48
+ async def login_refresh(
49
+ self,
50
+ *,
51
+ refresh_token: str,
52
+ ) -> Authentication:
53
+ """Refreshes an existing authentication session using a valid refresh token.
54
+
55
+ Use this tool to obtain a new access token when the current one has expired,
56
+ allowing the user's session to continue without requiring re-authentication.
57
+
58
+ Args:
59
+ refresh_token: The token issued during a previous authentication, used
60
+ to refresh the session.
61
+
62
+ Returns:
63
+ Authentication: A model containing access and refresh tokens.
64
+
65
+ Raises:
66
+ MissingArgumentError: If `refresh_token` is empty or None.
67
+ """
68
+ if not refresh_token:
69
+ raise MissingArgumentError("refresh_token")
70
+ return await self._client.http_request(
71
+ method=Method.POST,
72
+ path="oauth/v2/token",
73
+ model=Authentication,
74
+ body={
75
+ "grant_type": "refresh_token",
76
+ "refresh_token": refresh_token,
77
+ "client_id": self._client.client_id,
78
+ },
79
+ )
80
+
81
+ async def login_jwt(
82
+ self,
83
+ *,
84
+ email: str,
85
+ first_name: str | None = None,
86
+ last_name: str | None = None,
87
+ register: bool | None = None,
88
+ legal_terms_accepted: bool | None = None,
89
+ ) -> Authentication:
90
+ """Authenticates a user using a signed JSON Web Token (JWT).
91
+
92
+ Use this tool for trusted server-side authentication or Single Sign-On (SSO)
93
+ integration. It facilitates logging in or registering a user based on their
94
+ email address.
95
+
96
+ Args:
97
+ email: The user's email address.
98
+ first_name: Optional user's first name.
99
+ last_name: Optional user's last name.
100
+ register: Optional. If True, registers the user if they do not exist.
101
+ legal_terms_accepted: Optional. Whether the user has accepted the legal terms.
102
+
103
+ Returns:
104
+ Authentication: A model containing access and refresh tokens.
105
+
106
+ Raises:
107
+ InvalidEmailError: If the provided email format is invalid.
108
+ MissingJwtSecretError: If the JWT secret is not configured in the client.
109
+ """
110
+ if re.fullmatch(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", email) is None:
111
+ raise InvalidEmailError()
112
+ user_data = {
113
+ k: v
114
+ for k, v in {
115
+ "username": email,
116
+ "first_name": first_name,
117
+ "last_name": last_name,
118
+ "register": register,
119
+ "legal_terms_accepted": legal_terms_accepted,
120
+ }.items()
121
+ if v is not None
122
+ }
123
+
124
+ secret = self._client.jwt_secret
125
+ if not secret:
126
+ raise MissingJwtSecretError()
127
+ while len(secret) % 4 != 0:
128
+ secret += "="
129
+ dec = b64decode(secret)
130
+ token = jwt.encode(user_data, dec, algorithm="HS256")
131
+
132
+ return await self._client.http_request(
133
+ method=Method.POST,
134
+ path="login-jwt",
135
+ model=Authentication,
136
+ body={
137
+ "client_id": self._client.client_id,
138
+ "token": token,
139
+ },
140
+ )
141
+
142
+ async def register(
143
+ self,
144
+ *,
145
+ username: str,
146
+ password: str,
147
+ first_name: str,
148
+ last_name: str,
149
+ legal_terms_accepted: bool,
150
+ jurisdiction_code: str | None = None,
151
+ ) -> Authentication:
152
+ """Registers a new user account and immediately authenticates the user.
153
+
154
+ Use this tool to create a new user profile with the provided credentials
155
+ and personal information. This action also logs the user in.
156
+
157
+ Args:
158
+ username: The email address to use as the username.
159
+ password: The password for the new account.
160
+ first_name: The user's first name.
161
+ last_name: The user's last name.
162
+ legal_terms_accepted: Must be True to indicate acceptance of legal terms.
163
+ jurisdiction_code: Optional jurisdiction code for the user.
164
+
165
+ Returns:
166
+ Authentication: A model containing access and refresh tokens.
167
+
168
+ Raises:
169
+ MissingArgumentError: If any required argument (username, password,
170
+ first_name, last_name) is missing.
171
+ """
172
+ if not username:
173
+ raise MissingArgumentError("username")
174
+ if not password:
175
+ raise MissingArgumentError("password")
176
+ if not first_name:
177
+ raise MissingArgumentError("first_name")
178
+ if not last_name:
179
+ raise MissingArgumentError("last_name")
180
+
181
+ return await self._client.http_request(
182
+ method=Method.POST,
183
+ path="login",
184
+ model=Authentication,
185
+ body={
186
+ "client_id": self._client.client_id,
187
+ "username": username,
188
+ "password": password,
189
+ "register": "true",
190
+ "first_name": first_name,
191
+ "last_name": last_name,
192
+ "legal_terms_accepted": "true" if legal_terms_accepted else None,
193
+ "jurisdiction_id": jurisdiction_code,
194
+ },
195
+ )
196
+
197
+ async def login(
198
+ self,
199
+ *,
200
+ username: str,
201
+ password: str,
202
+ ) -> Authentication:
203
+ """Authenticates a user using their username (email) and password.
204
+
205
+ Use this tool for standard user login to obtain access credentials.
206
+
207
+ Args:
208
+ username: The user's email address.
209
+ password: The user's password.
210
+
211
+ Returns:
212
+ Authentication: A model containing access and refresh tokens.
213
+
214
+ Raises:
215
+ MissingArgumentError: If `username` or `password` is missing.
216
+ """
217
+ if not username:
218
+ raise MissingArgumentError("username")
219
+ if not password:
220
+ raise MissingArgumentError("password")
221
+ return await self._client.http_request(
222
+ method=Method.POST,
223
+ path="login",
224
+ model=Authentication,
225
+ body={
226
+ "client_id": self._client.client_id,
227
+ "username": username,
228
+ "password": password,
229
+ },
230
+ )
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from mtx_api.client._mtx_http_client import Method, MtxHttpClient
4
+ from mtx_api.models.base import EmptyResponse, TextResponse
5
+
6
+
7
+ class BaseClient:
8
+ def __init__(self, client: MtxHttpClient):
9
+ self._client: MtxHttpClient = client
10
+
11
+ async def status(
12
+ self,
13
+ *,
14
+ access_token: str | None = None,
15
+ ) -> bool:
16
+ """Checks the operational status of the platform.
17
+
18
+ Verifies if the platform is running and reachable. Use this tool
19
+ to confirm platform availability before performing other operations.
20
+
21
+ Args:
22
+ access_token: Optional token used for authentication. If not provided, the operation
23
+ will be made without authentication.
24
+
25
+ Returns:
26
+ True if the platform is available and the operation succeeds.
27
+ """
28
+ await self._client.http_request(
29
+ method=Method.GET,
30
+ path="",
31
+ model=EmptyResponse,
32
+ access_token=access_token,
33
+ )
34
+ return True
35
+
36
+ async def openapi(self) -> str:
37
+ """Retrieves the OpenAPI specification of the platform.
38
+
39
+ Fetches the complete OpenAPI documentation in plain text. This provides details on available
40
+ tools, operation structures, and expected responses.
41
+
42
+ Returns:
43
+ The OpenAPI document content as a string.
44
+ """
45
+ resp = await self._client.http_request(
46
+ method=Method.GET,
47
+ path="openapi",
48
+ model=TextResponse,
49
+ )
50
+ return resp.text
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from mtx_api.client._mtx_http_client import Method, MtxHttpClient
4
+ from mtx_api.models.errors import MissingJurisdictionCodesError
5
+ from mtx_api.models.jurisdiction import Jurisdiction, JurisdictionList
6
+
7
+
8
+ class JurisdictionClient:
9
+ def __init__(self, client: MtxHttpClient):
10
+ self._client: MtxHttpClient = client
11
+
12
+ async def list(
13
+ self,
14
+ *,
15
+ access_token: str | None = None,
16
+ lat: float | None = None,
17
+ lng: float | None = None,
18
+ ) -> JurisdictionList:
19
+ """Retrieves a list of jurisdictions from the platform.
20
+
21
+ This tool allows you to list all available jurisdictions or find those closest to a
22
+ specific location if coordinates are provided.
23
+
24
+ Args:
25
+ access_token: Optional token used for authentication. If not provided,
26
+ the operation will be made without authentication.
27
+ lat: Optional latitude coordinate to filter jurisdictions by proximity.
28
+ lng: Optional longitude coordinate to filter jurisdictions by proximity.
29
+
30
+ Returns:
31
+ JurisdictionList: A collection of Jurisdiction objects with basic details.
32
+ """
33
+ return await self._client.http_request(
34
+ method=Method.GET,
35
+ path="jurisdictions",
36
+ model=JurisdictionList,
37
+ access_token=access_token,
38
+ query_params={
39
+ "lat": lat,
40
+ "long": lng,
41
+ },
42
+ )
43
+
44
+ async def detail(
45
+ self,
46
+ *,
47
+ access_token: str | None = None,
48
+ jurisdiction_code: str,
49
+ ) -> Jurisdiction:
50
+ """Retrieves detailed information for a specific jurisdiction.
51
+
52
+ This tool fetches the complete details of a jurisdiction using its unique code.
53
+ Use this tool when you need more information about a specific jurisdiction than what is
54
+ provided in the list.
55
+
56
+ Args:
57
+ access_token: Optional token used for authentication. If not provided,
58
+ the operation will be made without authentication.
59
+ jurisdiction_code: The unique string code of the jurisdiction to query.
60
+
61
+ Raises:
62
+ MissingJurisdictionCodesError: If the provided jurisdiction_code is empty.
63
+
64
+ Returns:
65
+ Jurisdiction: An object containing comprehensive information about the jurisdiction.
66
+ """
67
+ if not jurisdiction_code:
68
+ raise MissingJurisdictionCodesError()
69
+ return await self._client.http_request(
70
+ method=Method.GET,
71
+ path=f"jurisdictions/{jurisdiction_code}",
72
+ model=Jurisdiction,
73
+ access_token=access_token,
74
+ )
@@ -0,0 +1,41 @@
1
+ from mtx_api.client._mtx_http_client import Method, MtxHttpClient
2
+ from mtx_api.models.errors import MissingJurisdictionCodesError
3
+ from mtx_api.models.jurisdiction import JurisdictionElementList
4
+
5
+
6
+ class JurisdictionElementsClient:
7
+ def __init__(self, client: MtxHttpClient):
8
+ self._client: MtxHttpClient = client
9
+
10
+ async def list(
11
+ self,
12
+ *,
13
+ access_token: str | None = None,
14
+ jurisdiction_code: str,
15
+ ) -> JurisdictionElementList:
16
+ """Retrieves jurisdiction elements associated with a specific jurisdiction code.
17
+
18
+ Use this tool to find all jurisdiction elements linked to a given jurisdiction code
19
+ from the platform.
20
+
21
+ Args:
22
+ access_token: Optional token used for authentication. If not provided, the operation
23
+ will be made without authentication.
24
+ jurisdiction_code: The unique string code of the jurisdiction to query.
25
+
26
+ Returns:
27
+ JurisdictionElementList: An object containing the list of retrieved
28
+ jurisdiction elements.
29
+
30
+ Raises:
31
+ MissingJurisdictionCodesError: If the `jurisdiction_code` argument is missing or empty.
32
+ """
33
+ if not jurisdiction_code:
34
+ raise MissingJurisdictionCodesError()
35
+
36
+ return await self._client.http_request(
37
+ method=Method.GET,
38
+ path=f"jurisdiction/{jurisdiction_code}/jurisdiction-elements",
39
+ model=JurisdictionElementList,
40
+ access_token=access_token,
41
+ )
@@ -0,0 +1,61 @@
1
+ from mtx_api.client._mtx_http_client import Method, MtxHttpClient
2
+ from mtx_api.models.additional_data import LocationAdditionalDataList
3
+ from mtx_api.models.errors import MissingJurisdictionElementIdError
4
+
5
+
6
+ class LocationClient:
7
+ def __init__(self, client: MtxHttpClient):
8
+ self.client: MtxHttpClient = client
9
+
10
+ async def resolve(
11
+ self,
12
+ *,
13
+ jurisdiction_element_id: str,
14
+ formatted_address: str | None = None,
15
+ lat: float | None = None,
16
+ lng: float | None = None,
17
+ ) -> LocationAdditionalDataList:
18
+ """
19
+ Retrieves latitude and longitude information from an address, or vice versa.
20
+
21
+ This tool fetches location-specific questions configured for a
22
+ jurisdiction element. You can identify the location by providing either a formatted
23
+ address, geographic coordinates (lat, lng), or both. Regardless of which parameters
24
+ you provide, the tool always returns the complete location information including
25
+ formatted address, geographic coordinates, and the list of additional data questions.
26
+
27
+ Use this tool when you need to know what additional information should be collected
28
+ for a specific location.
29
+
30
+ Args:
31
+ jurisdiction_element_id: The unique identifier string of the jurisdiction element
32
+ where the location is being queried.
33
+ formatted_address: Optional complete address string in human-readable format
34
+ to identify the location.
35
+ lat: Optional latitude coordinate as a decimal number to identify the location.
36
+ Must be provided together with lng.
37
+ lng: Optional longitude coordinate as a decimal number to identify the location.
38
+ Must be provided together with lat.
39
+
40
+ Returns:
41
+ LocationAdditionalDataList: A collection of location entries. Each entry always
42
+ contains the formatted address, geographic coordinates (latitude and longitude),
43
+ and a list of additional data questions configured for that location, regardless
44
+ of which input parameters were used to identify the location.
45
+
46
+ Raises:
47
+ MissingJurisdictionElementIdError: If jurisdiction_element_id is empty or not provided.
48
+ """
49
+ if not jurisdiction_element_id:
50
+ raise MissingJurisdictionElementIdError()
51
+ return await self.client.http_request(
52
+ method=Method.GET,
53
+ path="location-additional-data",
54
+ model=LocationAdditionalDataList,
55
+ query_params={
56
+ "jurisdiction_element_id": jurisdiction_element_id,
57
+ "formatted_address": formatted_address,
58
+ "lat": lat,
59
+ "lng": lng,
60
+ },
61
+ )
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Mapping, Sequence
6
+ from enum import Enum
7
+ from typing import TypeVar
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from mtx_api.models.base import EmptyResponse, TextResponse
13
+ from mtx_api.models.errors import (
14
+ ApiDecodingError,
15
+ ApiError,
16
+ MissingBaseUrlError,
17
+ MissingClientIdError,
18
+ )
19
+ from mtx_api.settings import MTXSettings
20
+
21
+ M = TypeVar("M", bound=BaseModel)
22
+
23
+
24
+ class Method(Enum):
25
+ PUT = "PUT"
26
+ GET = "GET"
27
+ POST = "POST"
28
+
29
+
30
+ class MtxHttpClient:
31
+ def __init__(
32
+ self,
33
+ *,
34
+ transport: httpx.AsyncBaseTransport | None = None,
35
+ base_url: str | None = None,
36
+ client_id: str | None = None,
37
+ jwt_secret: str | None = None,
38
+ ) -> None:
39
+ settings = MTXSettings()
40
+
41
+ base_url_value = base_url or settings.base_url
42
+ if not base_url_value:
43
+ raise MissingBaseUrlError()
44
+ self._base_url: str = base_url_value.rstrip("/")
45
+
46
+ client_id_value = client_id or settings.client_id
47
+ if not client_id_value:
48
+ raise MissingClientIdError()
49
+ self._client_id: str = client_id_value
50
+
51
+ self._jwt_secret: str | None = jwt_secret or settings.jwt_secret
52
+
53
+ logging.getLogger("httpx").setLevel(logging.WARNING)
54
+ self.logger = logging.getLogger("mtx_logs")
55
+
56
+ self._client = httpx.AsyncClient(
57
+ base_url=self._base_url,
58
+ timeout=30.0,
59
+ transport=transport,
60
+ follow_redirects=True,
61
+ )
62
+
63
+ async def __aenter__(self) -> MtxHttpClient:
64
+ return self
65
+
66
+ async def __aexit__(
67
+ self,
68
+ exc_type: type[BaseException] | None,
69
+ exc: BaseException | None,
70
+ tb: object | None,
71
+ ) -> None:
72
+ await self._client.aclose()
73
+
74
+ @property
75
+ def client_id(self) -> str:
76
+ return self._client_id
77
+
78
+ @property
79
+ def base_url(self) -> str:
80
+ return self._base_url
81
+
82
+ @property
83
+ def jwt_secret(self) -> str | None:
84
+ return self._jwt_secret
85
+
86
+ async def http_request(
87
+ self,
88
+ *,
89
+ method: Method,
90
+ path: str,
91
+ model: type[M],
92
+ access_token: str | None = None,
93
+ headers: Mapping[str, str] | None = None,
94
+ body: Mapping[str, object] | None = None,
95
+ query_params: Mapping[str, object] | None = None,
96
+ files: Mapping[str, tuple[str, bytes]] | None = None,
97
+ ) -> M:
98
+ if not path.startswith("/"):
99
+ path = f"/{path}"
100
+ try:
101
+ auth_header = {"Authorization": f"Bearer {access_token}"} if access_token else {}
102
+ headers = {
103
+ **auth_header,
104
+ **(headers or {}),
105
+ "X-CLIENT-ID": self._client_id,
106
+ }
107
+
108
+ is_multipart = files is not None
109
+
110
+ if not is_multipart:
111
+ headers["Content-type"] = "application/json"
112
+
113
+ def normalize(value: object) -> str | None:
114
+ if value is None:
115
+ return None
116
+ if isinstance(value, bool):
117
+ return "true" if value else "false"
118
+ if isinstance(value, list):
119
+ return ",".join(map(str, value)) if value else None
120
+ return str(value)
121
+
122
+ params = {k: normalize(v) for k, v in query_params.items()} if query_params else None
123
+
124
+ body = {k: v for k, v in body.items() if v is not None} if body else None
125
+
126
+ req = self._client.build_request(
127
+ method=method.value,
128
+ url=path,
129
+ headers=headers,
130
+ json=body if body and not is_multipart else None,
131
+ data=body if body and is_multipart else None,
132
+ params={k: v for k, v in params.items() if v is not None} if params else None,
133
+ files=files,
134
+ )
135
+
136
+ if is_multipart:
137
+ req.extensions["mtx_file_names"] = files
138
+ req.extensions["mtx_form_fields"] = body
139
+
140
+ self.log_as_curl(req)
141
+ resp = await self._client.send(req)
142
+ resp.raise_for_status()
143
+ self.log_response(resp)
144
+ try:
145
+ if model is EmptyResponse:
146
+ return model()
147
+ elif model is TextResponse:
148
+ return model(text=resp.text)
149
+ else:
150
+ return model.model_validate_json(resp.text)
151
+ except (ValidationError, ValueError) as de:
152
+ raise ApiDecodingError(str(de))
153
+ except httpx.HTTPStatusError as e:
154
+ resp = e.response
155
+ api_error = ApiError(body=resp.text, status_code=resp.status_code)
156
+ self.log_error(api_error)
157
+ raise api_error from e
158
+
159
+ def log_as_curl(
160
+ self,
161
+ request: httpx.Request,
162
+ ) -> None:
163
+ header_parts = " ".join([f'-H "{k}: {v}"' for k, v in request.headers.items()])
164
+ content_type = request.headers.get("content-type", "").lower()
165
+
166
+ def replace_quotes(s: str) -> str:
167
+ return s.replace("'", "'\"'\"'")
168
+
169
+ data_part = ""
170
+ if content_type.startswith("multipart/form-data"):
171
+ extensions = request.extensions
172
+ parts = []
173
+
174
+ form_meta = extensions.get("mtx_form_fields") or {}
175
+ parts.extend([f"{k}={v}" for k, v in form_meta.items()])
176
+
177
+ files_meta = extensions.get("mtx_file_names") or {}
178
+ parts.extend([f"{k}=@{v[0]}" for k, v in files_meta.items()])
179
+
180
+ data_part = " ".join([f"-F '{replace_quotes(p)}'" for p in parts])
181
+ elif request.content:
182
+ data = request.content.decode()
183
+ data_part = f" -d '{replace_quotes(data)}'"
184
+
185
+ self.logger.info(f"HTTP {request.method} to {request.url.path}")
186
+ self.logger.debug(f"curl -X {request.method} {str(request.url)} {header_parts} {data_part}")
187
+
188
+ def log_response(self, resp: httpx.Response) -> None:
189
+ try:
190
+ json_obj = json.loads(resp.text)
191
+ formatted_json = json.dumps(json_obj, indent=4)
192
+ self.logger.debug(f"Response JSON: {resp.status_code} {formatted_json}")
193
+ except json.JSONDecodeError:
194
+ self.logger.debug(f"Response Text: {resp.status_code} {resp.text}")
195
+
196
+ def log_error(self, e: ApiError) -> None:
197
+ self.logger.error(f"{type(e).__name__}: {e}")
198
+
199
+
200
+ def array_query_param(key: str, value: Sequence[object] | None) -> dict[str, object]:
201
+ return {} if value is None else {f"{key}[{i}]": v for i, v in enumerate(value)}