curious-pyapi 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/.github/workflows/publish_to_pypi.yaml +3 -3
- curious_pyapi-0.1.2/.markdownlint.yaml +5 -0
- curious_pyapi-0.1.2/.vscode/settings.json +9 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/CHANGELOG.md +14 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/PKG-INFO +1 -1
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/pyproject.toml +16 -0
- curious_pyapi-0.1.2/src/curious_pyapi/api/curious.py +335 -0
- curious_pyapi-0.1.2/src/curious_pyapi/exceptions/__init__.py +5 -0
- curious_pyapi-0.1.2/src/curious_pyapi/exceptions/exceptions.py +66 -0
- curious_pyapi-0.1.2/src/curious_pyapi/schema/curious.py +108 -0
- curious_pyapi-0.1.2/src/curious_pyapi/schema/pyapi.py +116 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/utils/data.py +3 -1
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/utils/defaults.py +8 -2
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/uv.lock +36 -36
- curious_pyapi-0.1.0/src/curious_pyapi/api/curious.py +0 -81
- curious_pyapi-0.1.0/src/curious_pyapi/schema/curious.py +0 -68
- curious_pyapi-0.1.0/src/curious_pyapi/schema/pyapi.py +0 -29
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/.github/dependabot.yaml +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/.gitignore +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/.pre-commit-config.yaml +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/.python-version +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/LICENSE +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/README.md +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/__init__.py +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/api/__init__.py +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/py.typed +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/schema/__init__.py +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/schema/regex.py +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/utils/__init__.py +0 -0
- {curious_pyapi-0.1.0 → curious_pyapi-0.1.2}/src/curious_pyapi/utils/logging.py +0 -0
|
@@ -16,17 +16,17 @@ jobs:
|
|
|
16
16
|
|
|
17
17
|
steps:
|
|
18
18
|
- name: Checkout repository
|
|
19
|
-
uses: actions/checkout@
|
|
19
|
+
uses: actions/checkout@v7
|
|
20
20
|
with:
|
|
21
21
|
fetch-depth: 0 # Full git history required for uv-dynamic-versioning
|
|
22
22
|
|
|
23
23
|
- name: Install uv
|
|
24
|
-
uses: astral-sh/setup-uv@
|
|
24
|
+
uses: astral-sh/setup-uv@v7
|
|
25
25
|
with:
|
|
26
26
|
enable-cache: true
|
|
27
27
|
|
|
28
28
|
- name: Set up Python 3.14
|
|
29
|
-
uses: actions/setup-python@
|
|
29
|
+
uses: actions/setup-python@v7
|
|
30
30
|
with:
|
|
31
31
|
python-version: "3.14"
|
|
32
32
|
|
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.2]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Badges to README.
|
|
13
|
+
|
|
14
|
+
## [0.1.1]
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Push account to Curious method.
|
|
19
|
+
- Create new Curious user method.
|
|
20
|
+
- Accept Curious invitation method.
|
|
21
|
+
|
|
8
22
|
## [0.1.0]
|
|
9
23
|
|
|
10
24
|
### Added
|
|
@@ -20,3 +20,19 @@ dependencies = [
|
|
|
20
20
|
|
|
21
21
|
[tool.hatch.version]
|
|
22
22
|
source = "uv-dynamic-versioning"
|
|
23
|
+
|
|
24
|
+
[tool.pyright]
|
|
25
|
+
reportUnknownMemberType = false
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 88
|
|
29
|
+
target-version = "py314"
|
|
30
|
+
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
extend-select = ["A", "B904", "C4", "D", "E", "EM", "F541", "G", "I", "ICN", "LOG007", "N", "NPY", "PL", "RET", "RSE", "RUF", "Q", "T20", "TRY002", "TRY201", "TRY400", "TRY401", "UP032", "W"]
|
|
33
|
+
ignore = ["D203", "D212", "RUF002", "RUF003"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint.isort]
|
|
36
|
+
combine-as-imports = true
|
|
37
|
+
force-sort-within-sections = true
|
|
38
|
+
order-by-type = false
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Curious API functionality."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import polars as pl
|
|
8
|
+
from pydantic import AnyHttpUrl, SecretStr
|
|
9
|
+
|
|
10
|
+
from ..exceptions import ApiStatusError, AuthenticationError, CuriousApiError
|
|
11
|
+
from ..schema.curious import CuriousId, NewUserData, Record
|
|
12
|
+
from ..schema.pyapi import ApiClient, CuriousAuth, EndpointPath, Tokens, url
|
|
13
|
+
from ..utils.data import api_data
|
|
14
|
+
from ..utils.defaults import CURIOUS_BASE_URL, headers
|
|
15
|
+
from ..utils.logging import get_logger
|
|
16
|
+
|
|
17
|
+
LOGGER = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CuriousApiClient(ApiClient):
|
|
21
|
+
"""API Client managing authentication, connection pooling, and endpoint routing."""
|
|
22
|
+
|
|
23
|
+
# Declarative Endpoints
|
|
24
|
+
applets = EndpointPath("/applets")
|
|
25
|
+
applet_by_id = EndpointPath("/applets/{applet_id}")
|
|
26
|
+
auth_login = EndpointPath("/auth/login")
|
|
27
|
+
invite_user = EndpointPath("/invitations/{applet_id}/{user_type}")
|
|
28
|
+
invitations = EndpointPath("/invitations")
|
|
29
|
+
invitation_accept = EndpointPath("/invitations/{key}/accept")
|
|
30
|
+
|
|
31
|
+
me = EndpointPath("/users/me")
|
|
32
|
+
user_create = EndpointPath("/users")
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
auth: Optional[CuriousAuth] = None,
|
|
38
|
+
base_url: httpx.URL | AnyHttpUrl = CURIOUS_BASE_URL,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize Curious API Client."""
|
|
41
|
+
self.base_url = httpx.URL(str(base_url))
|
|
42
|
+
self._tokens: Optional[Tokens] = None
|
|
43
|
+
self._auth_header: Optional[str] = None
|
|
44
|
+
self._my_name: Optional[str] = None
|
|
45
|
+
self._applets: dict[str, Applet] = {}
|
|
46
|
+
|
|
47
|
+
# Always initialize a valid client instance
|
|
48
|
+
self._client = httpx.Client(
|
|
49
|
+
base_url=str(self.base_url),
|
|
50
|
+
follow_redirects=True,
|
|
51
|
+
event_hooks={
|
|
52
|
+
"request": [self._inject_auth_header],
|
|
53
|
+
"build": [self._inject_auth_header],
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if auth:
|
|
58
|
+
self.authenticate(auth)
|
|
59
|
+
# Pre-load applets only after successful authentication
|
|
60
|
+
self.get_applets(refresh=True)
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
"""Return string representation of Curious API client without side-effects."""
|
|
64
|
+
name_str = self._my_name if self._my_name else "Unauthenticated"
|
|
65
|
+
return f"CuriousApiClient({name_str})"
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
"""Return reproducible string without triggering network calls."""
|
|
69
|
+
name_str = f'"{self._my_name}"' if self._my_name else "None"
|
|
70
|
+
return f'CuriousApiClient(name={name_str}, base_url="{self.base_url}")'
|
|
71
|
+
|
|
72
|
+
def fetch_name(self) -> str:
|
|
73
|
+
"""Fetch and return the user's name from the API."""
|
|
74
|
+
me = self.me.get()
|
|
75
|
+
me_json = me.json().get("result", {})
|
|
76
|
+
|
|
77
|
+
first = me_json.get("firstName", "")
|
|
78
|
+
last = me_json.get("lastName", "")
|
|
79
|
+
self._my_name = " ".join(filter(None, [first, last]))
|
|
80
|
+
return self._my_name
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def my_name(self) -> str:
|
|
84
|
+
"""Return memoized string name, fetching only if authenticated and missing."""
|
|
85
|
+
if self._my_name is None:
|
|
86
|
+
if self.is_authenticated:
|
|
87
|
+
self.fetch_name()
|
|
88
|
+
else:
|
|
89
|
+
return "Unauthenticated"
|
|
90
|
+
assert isinstance(self._my_name, str)
|
|
91
|
+
return self._my_name
|
|
92
|
+
|
|
93
|
+
def _inject_auth_header(self, request: httpx.Request) -> None:
|
|
94
|
+
"""Event hook executed right before any request is sent over the wire."""
|
|
95
|
+
if self._auth_header:
|
|
96
|
+
request.headers["Authorization"] = self._auth_header
|
|
97
|
+
|
|
98
|
+
def get_applets(self, refresh: bool = False) -> None:
|
|
99
|
+
"""Gather bound Applets into a dict."""
|
|
100
|
+
if not refresh and self._applets:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
applets: "dict[str, Applet]" = {}
|
|
104
|
+
result = self.applets.get().json().get("result", [])
|
|
105
|
+
for applet in result:
|
|
106
|
+
if {"displayName", "id"}.issubset(applet):
|
|
107
|
+
applets[applet["displayName"]] = Applet(
|
|
108
|
+
self, applet["id"], display_name=applet["displayName"]
|
|
109
|
+
)
|
|
110
|
+
self._applets = applets
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_authenticated(self) -> bool:
|
|
114
|
+
"""Return True if the client is authenticated."""
|
|
115
|
+
return self._auth_header is not None
|
|
116
|
+
|
|
117
|
+
def authenticate(self, auth: CuriousAuth) -> None:
|
|
118
|
+
"""Authenticate immediately and store Bearer token."""
|
|
119
|
+
tokens = self._fetch_tokens(auth.login_credentials)
|
|
120
|
+
if not tokens.access:
|
|
121
|
+
msg = "Authentication succeeded but accessToken was empty."
|
|
122
|
+
raise AuthenticationError(msg)
|
|
123
|
+
|
|
124
|
+
self._tokens = tokens
|
|
125
|
+
self._auth_header = f"Bearer {tokens.access.get_secret_value()}"
|
|
126
|
+
LOGGER.info("Successfully authenticated as Bearer token.")
|
|
127
|
+
|
|
128
|
+
def _fetch_tokens(self, credentials: dict[str, str | SecretStr]) -> Tokens:
|
|
129
|
+
"""Post login credentials to obtain tokens."""
|
|
130
|
+
target_url = str(self.base_url.join(httpx.URL("auth/login")))
|
|
131
|
+
try:
|
|
132
|
+
response = httpx.post(
|
|
133
|
+
target_url,
|
|
134
|
+
json=api_data(credentials),
|
|
135
|
+
headers=headers(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if httpx.codes.is_success(response.status_code):
|
|
139
|
+
data = response.json()
|
|
140
|
+
token_data = data.get("result", {}).get("token", {})
|
|
141
|
+
access = token_data.get("accessToken")
|
|
142
|
+
refresh = token_data.get("refreshToken")
|
|
143
|
+
|
|
144
|
+
if not access:
|
|
145
|
+
msg = f"Auth response missing 'accessToken': {data}"
|
|
146
|
+
raise AuthenticationError(msg)
|
|
147
|
+
|
|
148
|
+
return Tokens(access=access, refresh=refresh)
|
|
149
|
+
|
|
150
|
+
msg = f"Login failed with status {response.status_code}: {response.text}"
|
|
151
|
+
raise AuthenticationError(msg)
|
|
152
|
+
|
|
153
|
+
except (httpx.HTTPError, json.JSONDecodeError) as e:
|
|
154
|
+
msg = f"Authentication request failed: {e}"
|
|
155
|
+
raise AuthenticationError(msg) from e
|
|
156
|
+
|
|
157
|
+
def request(
|
|
158
|
+
self, method: str, path: str | httpx.URL, **kwargs: Any
|
|
159
|
+
) -> httpx.Response:
|
|
160
|
+
"""Centralized transport gateway with error mapping and auth checking."""
|
|
161
|
+
clean_path = str(path).lstrip("/")
|
|
162
|
+
|
|
163
|
+
if not self.is_authenticated:
|
|
164
|
+
LOGGER.warning(
|
|
165
|
+
"Attempting unauthenticated '%s' request to '%s'", method, clean_path
|
|
166
|
+
)
|
|
167
|
+
request = self._client.build_request(method, clean_path, **kwargs)
|
|
168
|
+
LOGGER.exception(request)
|
|
169
|
+
try:
|
|
170
|
+
response = self._client.request(method, clean_path, **kwargs)
|
|
171
|
+
response.raise_for_status()
|
|
172
|
+
return response
|
|
173
|
+
except httpx.HTTPStatusError as e:
|
|
174
|
+
LOGGER.exception("Sent Headers: %s", dict(e.request.headers))
|
|
175
|
+
LOGGER.exception("Response Error Details: %s", e.response.text)
|
|
176
|
+
raise ApiStatusError(
|
|
177
|
+
str(e.response), request=e.request, response=e.response
|
|
178
|
+
) from e
|
|
179
|
+
except httpx.HTTPError as e:
|
|
180
|
+
msg = f"Network transport error: {e}"
|
|
181
|
+
raise CuriousApiError(msg) from e
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
"""Close internal HTTP connection pool."""
|
|
185
|
+
self._client.close()
|
|
186
|
+
|
|
187
|
+
def create_accounts_from_df(
|
|
188
|
+
self,
|
|
189
|
+
df: pl.DataFrame,
|
|
190
|
+
applet_id: CuriousId,
|
|
191
|
+
user_type: str = "respondent",
|
|
192
|
+
*,
|
|
193
|
+
limit=None,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""For each row in DataFrame, create an account in Curious."""
|
|
196
|
+
_create_accounts_from_df(self, df, applet_id, user_type, limit=limit)
|
|
197
|
+
|
|
198
|
+
def __enter__(self) -> CuriousApiClient:
|
|
199
|
+
"""Enter context manager and return self."""
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
203
|
+
"""Exit context manager and close the client."""
|
|
204
|
+
self.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Applet:
|
|
208
|
+
"""Applet bound to a client."""
|
|
209
|
+
|
|
210
|
+
def __init__(
|
|
211
|
+
self,
|
|
212
|
+
client: CuriousApiClient,
|
|
213
|
+
applet_id: CuriousId,
|
|
214
|
+
*,
|
|
215
|
+
display_name: Optional[str] = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Initialize Applet."""
|
|
218
|
+
self._id = applet_id
|
|
219
|
+
self._client = client
|
|
220
|
+
if display_name:
|
|
221
|
+
self._name = display_name
|
|
222
|
+
else:
|
|
223
|
+
self._name = (
|
|
224
|
+
self._client.applet_by_id(applet_id=applet_id)
|
|
225
|
+
.get()
|
|
226
|
+
.json()["result"]["displayName"]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def __str__(self) -> str:
|
|
230
|
+
"""Return string representation of Applet."""
|
|
231
|
+
return f"Applet({self._name})"
|
|
232
|
+
|
|
233
|
+
def __repr__(self) -> str:
|
|
234
|
+
"""Return reproducible string representation of Applet."""
|
|
235
|
+
return (
|
|
236
|
+
f'Applet(client={self._client}, applet_id="{self._id}", '
|
|
237
|
+
f'display_name="{self._name}")'
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def create_toplevel_user(new_user_data: NewUserData) -> None:
|
|
242
|
+
"""Create a top-level user in Curious."""
|
|
243
|
+
client = CuriousApiClient()
|
|
244
|
+
response = client.user_create.post(json=new_user_data)
|
|
245
|
+
if not httpx.codes.is_success(response.status_code):
|
|
246
|
+
msg = f"Failed to create user: {response.status_code} - {response.text}"
|
|
247
|
+
raise ApiStatusError(
|
|
248
|
+
msg,
|
|
249
|
+
request=response.request,
|
|
250
|
+
response=response,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_curious_token(
|
|
255
|
+
curious_data: Record,
|
|
256
|
+
curious_headers: Optional[Record] = None,
|
|
257
|
+
curious_url: httpx.URL | AnyHttpUrl = CURIOUS_BASE_URL,
|
|
258
|
+
) -> Optional[Tokens]:
|
|
259
|
+
"""
|
|
260
|
+
Process the response to a POST request to the specified URL.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
curious_data
|
|
265
|
+
The data to include in the POST request.
|
|
266
|
+
curious_headers
|
|
267
|
+
The headers to include in the POST request.
|
|
268
|
+
curious_url
|
|
269
|
+
The URL to send the POST request to.
|
|
270
|
+
|
|
271
|
+
Returns
|
|
272
|
+
-------
|
|
273
|
+
Tokens or None
|
|
274
|
+
An object containing (access, refresh) token strings, or None if unsuccessful.
|
|
275
|
+
|
|
276
|
+
Raises
|
|
277
|
+
------
|
|
278
|
+
RuntimeError
|
|
279
|
+
If there is an error with the request or response.
|
|
280
|
+
|
|
281
|
+
"""
|
|
282
|
+
curious_headers = headers(headers=curious_headers)
|
|
283
|
+
curious_url = url(curious_url)
|
|
284
|
+
try:
|
|
285
|
+
# Sending the POST request
|
|
286
|
+
response = httpx.post(
|
|
287
|
+
curious_url.join("/auth/login"),
|
|
288
|
+
json=api_data(curious_data),
|
|
289
|
+
headers=curious_headers,
|
|
290
|
+
)
|
|
291
|
+
if httpx.codes.is_success(response.status_code):
|
|
292
|
+
response_data = response.json()
|
|
293
|
+
access_token = (
|
|
294
|
+
response_data.get("result", {})
|
|
295
|
+
.get("token", {})
|
|
296
|
+
.get("accessToken", None)
|
|
297
|
+
) # Get access token
|
|
298
|
+
refresh_token = (
|
|
299
|
+
response_data.get("result", {})
|
|
300
|
+
.get("token", {})
|
|
301
|
+
.get("refreshToken", None)
|
|
302
|
+
) # Get refresh token
|
|
303
|
+
else:
|
|
304
|
+
LOGGER.exception(
|
|
305
|
+
"Failed to fetch data: %d - %s", response.status_code, response.text
|
|
306
|
+
)
|
|
307
|
+
response.raise_for_status()
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
return Tokens(access=access_token, refresh=refresh_token)
|
|
311
|
+
|
|
312
|
+
except httpx.HTTPError as e:
|
|
313
|
+
msg = f"Error sending request: {e}"
|
|
314
|
+
raise RuntimeError(msg) from e
|
|
315
|
+
except json.JSONDecodeError as e:
|
|
316
|
+
msg = f"Error: Response is not valid JSON! {e}"
|
|
317
|
+
raise RuntimeError(msg) from e
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def create_accounts_from_df(
|
|
321
|
+
client: CuriousApiClient,
|
|
322
|
+
df: pl.DataFrame,
|
|
323
|
+
applet_id: CuriousId,
|
|
324
|
+
user_type: str = "respondent",
|
|
325
|
+
*,
|
|
326
|
+
limit=None,
|
|
327
|
+
) -> None:
|
|
328
|
+
"""For each row in DataFrame, create an account in Curious."""
|
|
329
|
+
if limit:
|
|
330
|
+
df = df.head(limit)
|
|
331
|
+
for record in df.to_dicts():
|
|
332
|
+
client.invite_user(applet_id=applet_id, user_type=user_type).post(json=record)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
_create_accounts_from_df = create_accounts_from_df
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Custom exceptions and exception handling."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from httpx import codes, HTTPStatusError, RequestError, Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def allow_existing(
|
|
10
|
+
post: Callable[[], Response], *, warn_if_existing: bool = True, **kwargs: Any
|
|
11
|
+
) -> Response | None:
|
|
12
|
+
"""Post, but allow existing."""
|
|
13
|
+
try:
|
|
14
|
+
response = post(**kwargs)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
if hasattr(e, "response"):
|
|
17
|
+
_response = getattr(e, "response")
|
|
18
|
+
if getattr(_response, "status_code") == codes.BAD_REQUEST:
|
|
19
|
+
results = getattr(_response, "json")().get("result", [])
|
|
20
|
+
if results:
|
|
21
|
+
if isinstance(results[0], dict):
|
|
22
|
+
_request = getattr(e, "request")
|
|
23
|
+
warning: tuple[str, ...]
|
|
24
|
+
message = results[0].get("message")
|
|
25
|
+
match message:
|
|
26
|
+
case (
|
|
27
|
+
"That email address is already associated "
|
|
28
|
+
"with a Curious account."
|
|
29
|
+
):
|
|
30
|
+
warning = (
|
|
31
|
+
"%s is already associated with a Curious account.",
|
|
32
|
+
str(
|
|
33
|
+
json.loads(
|
|
34
|
+
getattr(_request, "content", json.dumps({}))
|
|
35
|
+
).get("email", "That email address")
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
case "Non-unique value.":
|
|
39
|
+
warning = (
|
|
40
|
+
(
|
|
41
|
+
"A record with the same unique "
|
|
42
|
+
"value already exists."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
case _:
|
|
46
|
+
raise
|
|
47
|
+
if warn_if_existing:
|
|
48
|
+
from ..utils.logging import get_logger # noqa: PLC0415
|
|
49
|
+
|
|
50
|
+
logger = get_logger(__name__)
|
|
51
|
+
logger.warning(*warning)
|
|
52
|
+
return None
|
|
53
|
+
raise
|
|
54
|
+
return response
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ApiStatusError(HTTPStatusError):
|
|
58
|
+
"""Raised when an API request returns a non-successful status code."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AuthenticationError(RequestError):
|
|
62
|
+
"""Raised when authentication fails."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CuriousApiError(RequestError):
|
|
66
|
+
"""Raised for general Curious API errors."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Curious schemas."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
from pydantic.types import StringConstraints
|
|
8
|
+
|
|
9
|
+
from ..utils.logging import get_logger
|
|
10
|
+
from .regex import EMAIL_REGEX
|
|
11
|
+
|
|
12
|
+
LOGGER = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
type IntStr = int | str
|
|
16
|
+
"""Integer or integers string."""
|
|
17
|
+
type IntStrContainer = list[IntStr] | set[IntStr] | tuple[IntStr, ...]
|
|
18
|
+
"""Iterable container of integers and/or integers strings."""
|
|
19
|
+
type Record = (
|
|
20
|
+
dict[str, IntStr | IntStrContainer] | Mapping[str, IntStr | IntStrContainer]
|
|
21
|
+
)
|
|
22
|
+
"""Dictionary/mapping with string keys and IntStr or IntStrContainer values."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate(
|
|
26
|
+
df: pl.DataFrame, schema: pl.Schema, required_cols: list[str]
|
|
27
|
+
) -> pl.DataFrame:
|
|
28
|
+
"""Validate DataFrame, adding missing columns with nulls and casting types."""
|
|
29
|
+
exprs = [
|
|
30
|
+
pl.col(col).cast(dtype)
|
|
31
|
+
if col in df.columns
|
|
32
|
+
else pl.lit(None, dtype=dtype).alias(col)
|
|
33
|
+
for col, dtype in schema.items()
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
out_df = df.select(exprs)
|
|
37
|
+
|
|
38
|
+
# Evaluate against out_df where 'email' is guaranteed to exist
|
|
39
|
+
is_valid_email = pl.col("email").str.contains(EMAIL_REGEX).fill_null(False)
|
|
40
|
+
|
|
41
|
+
failures_df = out_df.filter(~is_valid_email)
|
|
42
|
+
out_df = out_df.filter(is_valid_email)
|
|
43
|
+
|
|
44
|
+
if not failures_df.is_empty():
|
|
45
|
+
log_cols = [col for col in required_cols if col in failures_df.columns]
|
|
46
|
+
LOGGER.error(
|
|
47
|
+
"Dropped %d records due to invalid email addresses:\n%s",
|
|
48
|
+
failures_df.height,
|
|
49
|
+
failures_df.select(log_cols),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return out_df
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pl.api.register_dataframe_namespace("curious_account")
|
|
56
|
+
class CuriousAccount:
|
|
57
|
+
"""Curious account invitation."""
|
|
58
|
+
|
|
59
|
+
SCHEMA = pl.Schema(
|
|
60
|
+
{
|
|
61
|
+
"email": pl.String,
|
|
62
|
+
"firstName": pl.String,
|
|
63
|
+
"lastName": pl.String,
|
|
64
|
+
"language": pl.String,
|
|
65
|
+
"secretUserId": pl.String,
|
|
66
|
+
"nickname": pl.String,
|
|
67
|
+
"tag": pl.String,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __init__(self, df: pl.DataFrame):
|
|
72
|
+
"""Initialize CuriousAccount."""
|
|
73
|
+
self._df = df
|
|
74
|
+
|
|
75
|
+
def enforce_schema(self) -> pl.DataFrame:
|
|
76
|
+
"""Enforces schema, adds missing columns with nulls, and casts types."""
|
|
77
|
+
return _validate(self._df, self.SCHEMA, ["email", "secretUserId", "nickname"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
CuriousId = Annotated[
|
|
81
|
+
str,
|
|
82
|
+
StringConstraints(pattern=r"^[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}$"),
|
|
83
|
+
]
|
|
84
|
+
"""ID string for a Curious entity."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pl.api.register_dataframe_namespace("new_curious_user")
|
|
88
|
+
class NewUserData:
|
|
89
|
+
"""Required data for creating a new Curious user account."""
|
|
90
|
+
|
|
91
|
+
SCHEMA = pl.Schema(
|
|
92
|
+
{
|
|
93
|
+
"email": pl.String,
|
|
94
|
+
"firstName": pl.String,
|
|
95
|
+
"lastName": pl.String,
|
|
96
|
+
"password": pl.String,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __init__(self, df: pl.DataFrame):
|
|
101
|
+
"""Initialize NewUserData."""
|
|
102
|
+
self._df = df
|
|
103
|
+
|
|
104
|
+
def enforce_schema(self) -> pl.DataFrame:
|
|
105
|
+
"""Enforces schema, adds missing columns with nulls, and casts types."""
|
|
106
|
+
return _validate(
|
|
107
|
+
self._df, self.SCHEMA, ["email", "firstName", "lastName", "password"]
|
|
108
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Curious-PyAPI schemas."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, overload
|
|
4
|
+
|
|
5
|
+
from httpx import Response, URL
|
|
6
|
+
from pydantic import AnyHttpUrl, BaseModel, EmailStr, SecretStr
|
|
7
|
+
|
|
8
|
+
from ..utils.logging import get_logger
|
|
9
|
+
from .curious import CuriousId
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CuriousAuth(BaseModel):
|
|
15
|
+
"""Required data for authentication and decryption."""
|
|
16
|
+
|
|
17
|
+
curious_email: EmailStr
|
|
18
|
+
curious_password: SecretStr
|
|
19
|
+
applet_id: Optional[CuriousId] = None
|
|
20
|
+
applet_password: Optional[SecretStr] = None
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def login_credentials(self) -> dict[str, str | SecretStr]:
|
|
24
|
+
"""Return email and password as dictionary for API request."""
|
|
25
|
+
return {"email": self.curious_email, "password": self.curious_password}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiClient:
|
|
29
|
+
"""Generic API client."""
|
|
30
|
+
|
|
31
|
+
base_url: URL
|
|
32
|
+
|
|
33
|
+
def request(self, method: str, path: str | URL, **kwargs: Any) -> Response:
|
|
34
|
+
"""Centralized transport gateway with error mapping and auth checking."""
|
|
35
|
+
return NotImplemented()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BoundEndpoint:
|
|
39
|
+
"""Class to bind endpoint to an instance."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, client: ApiClient, path: str) -> None:
|
|
42
|
+
"""Initialize bound endpoint."""
|
|
43
|
+
self._client = client
|
|
44
|
+
self.path = path
|
|
45
|
+
|
|
46
|
+
def __call__(self, **kwargs: Any) -> "BoundEndpoint":
|
|
47
|
+
"""Allow calling endpoint as a function to format path parameters."""
|
|
48
|
+
return BoundEndpoint(self._client, self.path.format(**kwargs))
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def url(self) -> URL:
|
|
52
|
+
"""Return base URL."""
|
|
53
|
+
return self._client.base_url.join(URL(self.path.lstrip("/")))
|
|
54
|
+
|
|
55
|
+
def get(self, **kwargs: Any) -> Response:
|
|
56
|
+
"""Return GET response."""
|
|
57
|
+
return self._client.request("GET", self.path, **kwargs)
|
|
58
|
+
|
|
59
|
+
def post(self, **kwargs: Any) -> Response:
|
|
60
|
+
"""Return POST response."""
|
|
61
|
+
return self._client.request("POST", self.path, **kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EndpointPath:
|
|
65
|
+
"""Endpoint path or full URL."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, path: str) -> None:
|
|
68
|
+
"""Initialize endpoint path."""
|
|
69
|
+
self.path = path
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def __get__(self, instance: None, owner: Any) -> str: ...
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def __get__(self, instance: ApiClient, owner: Any) -> BoundEndpoint: ...
|
|
76
|
+
|
|
77
|
+
def __get__(self, instance: Optional[ApiClient], owner: Any) -> str | BoundEndpoint:
|
|
78
|
+
"""Return path or bound endpoint."""
|
|
79
|
+
if instance is None:
|
|
80
|
+
return self.path
|
|
81
|
+
return BoundEndpoint(instance, self.path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Endpoint:
|
|
85
|
+
"""API endpoint."""
|
|
86
|
+
|
|
87
|
+
auth = EndpointPath("/auth/login")
|
|
88
|
+
|
|
89
|
+
def __init__(self, base_url: URL | AnyHttpUrl) -> None:
|
|
90
|
+
"""Initialize API endpoint."""
|
|
91
|
+
self._base_url = url(base_url)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def base_url(self) -> URL:
|
|
95
|
+
"""Return base URL for API endpoint."""
|
|
96
|
+
return self._base_url
|
|
97
|
+
|
|
98
|
+
@base_url.setter
|
|
99
|
+
def base_url(self, value: URL) -> None:
|
|
100
|
+
"""Update base URL for API endpoint."""
|
|
101
|
+
self._base_url = value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Tokens(BaseModel):
|
|
105
|
+
"""Curious tokens."""
|
|
106
|
+
|
|
107
|
+
access: SecretStr
|
|
108
|
+
refresh: SecretStr
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def url(url: URL | AnyHttpUrl) -> URL:
|
|
112
|
+
"""Typecheck and cast to `httpx.URL`."""
|
|
113
|
+
if isinstance(url, str):
|
|
114
|
+
url = URL(url)
|
|
115
|
+
assert isinstance(url, URL)
|
|
116
|
+
return url
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from pydantic import SecretStr
|
|
4
4
|
|
|
5
|
+
from ..schema.curious import Record
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
|
|
8
|
+
def api_data(data: Record | dict[str, str | SecretStr]) -> Record:
|
|
7
9
|
"""Prep data for API calls."""
|
|
8
10
|
return {
|
|
9
11
|
k: v.get_secret_value() if isinstance(v, SecretStr) else v
|
|
@@ -4,13 +4,19 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from httpx import URL
|
|
6
6
|
|
|
7
|
+
from ..schema.curious import Record
|
|
8
|
+
|
|
7
9
|
CURIOUS_BASE_URL = URL("https://api-v2.gettingcurious.com")
|
|
8
10
|
"""Canonical URL for Curious API."""
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def headers(
|
|
12
|
-
token: Optional[str] = None, headers: Optional[
|
|
14
|
+
token: Optional[str] = None, headers: Optional[Record] = None
|
|
13
15
|
) -> dict[str, str]:
|
|
14
16
|
"""Return Curious headers."""
|
|
15
17
|
auth = {"Authorization": f"Bearer {token}"} if token else {}
|
|
16
|
-
return
|
|
18
|
+
return (
|
|
19
|
+
{"Content-Type": "application/json"}
|
|
20
|
+
| auth
|
|
21
|
+
| ({k: str(v) for k, v in headers.items()} if headers else {})
|
|
22
|
+
)
|
|
@@ -171,7 +171,7 @@ wheels = [
|
|
|
171
171
|
|
|
172
172
|
[[package]]
|
|
173
173
|
name = "pydantic"
|
|
174
|
-
version = "2.13.
|
|
174
|
+
version = "2.13.5"
|
|
175
175
|
source = { registry = "https://pypi.org/simple" }
|
|
176
176
|
dependencies = [
|
|
177
177
|
{ name = "annotated-types" },
|
|
@@ -179,9 +179,9 @@ dependencies = [
|
|
|
179
179
|
{ name = "typing-extensions" },
|
|
180
180
|
{ name = "typing-inspection" },
|
|
181
181
|
]
|
|
182
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
182
|
+
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
|
183
183
|
wheels = [
|
|
184
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
184
|
+
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
|
185
185
|
]
|
|
186
186
|
|
|
187
187
|
[package.optional-dependencies]
|
|
@@ -191,43 +191,43 @@ email = [
|
|
|
191
191
|
|
|
192
192
|
[[package]]
|
|
193
193
|
name = "pydantic-core"
|
|
194
|
-
version = "2.46.
|
|
194
|
+
version = "2.46.5"
|
|
195
195
|
source = { registry = "https://pypi.org/simple" }
|
|
196
196
|
dependencies = [
|
|
197
197
|
{ name = "typing-extensions" },
|
|
198
198
|
]
|
|
199
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
200
|
-
wheels = [
|
|
201
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
202
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
203
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
204
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
205
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
206
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
207
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
208
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
209
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
210
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
211
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
212
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
213
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
214
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
215
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
216
|
-
{ url = "https://files.pythonhosted.org/packages/e8/
|
|
217
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
218
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
219
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
220
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
221
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
222
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
223
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
224
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
225
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
226
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
227
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
228
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
229
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
230
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
199
|
+
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
|
200
|
+
wheels = [
|
|
201
|
+
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
|
202
|
+
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
|
203
|
+
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
|
204
|
+
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
|
205
|
+
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
|
206
|
+
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
|
207
|
+
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
|
208
|
+
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
|
209
|
+
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
|
210
|
+
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
|
211
|
+
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
|
212
|
+
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
|
213
|
+
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
|
214
|
+
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
|
215
|
+
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
|
216
|
+
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
|
217
|
+
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
|
218
|
+
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
|
219
|
+
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
|
220
|
+
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
|
221
|
+
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
|
222
|
+
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
|
223
|
+
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
|
224
|
+
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
|
225
|
+
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
|
226
|
+
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
|
227
|
+
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
|
228
|
+
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
|
229
|
+
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
|
230
|
+
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
|
231
231
|
]
|
|
232
232
|
|
|
233
233
|
[[package]]
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Curious API functionality."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Optional
|
|
5
|
-
import httpx
|
|
6
|
-
|
|
7
|
-
from pydantic import AnyHttpUrl
|
|
8
|
-
|
|
9
|
-
from ..schema.pyapi import Tokens
|
|
10
|
-
from ..utils.defaults import CURIOUS_BASE_URL, headers
|
|
11
|
-
from ..utils.data import api_data
|
|
12
|
-
from ..utils.logging import get_logger
|
|
13
|
-
|
|
14
|
-
LOGGER = get_logger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def get_curious_token(
|
|
18
|
-
curious_data: dict,
|
|
19
|
-
curious_headers: Optional[dict] = None,
|
|
20
|
-
curious_url: httpx.URL | AnyHttpUrl = CURIOUS_BASE_URL,
|
|
21
|
-
) -> Optional[Tokens]:
|
|
22
|
-
"""Process the response to a POST request to the specified URL.
|
|
23
|
-
|
|
24
|
-
Parameters
|
|
25
|
-
----------
|
|
26
|
-
curious_data
|
|
27
|
-
The data to include in the POST request.
|
|
28
|
-
curious_headers
|
|
29
|
-
The headers to include in the POST request.
|
|
30
|
-
curious_url
|
|
31
|
-
The URL to send the POST request to.
|
|
32
|
-
|
|
33
|
-
Returns
|
|
34
|
-
-------
|
|
35
|
-
Tokens or None
|
|
36
|
-
An object containing (access, refresh) token strings, or None if unsuccessful.
|
|
37
|
-
|
|
38
|
-
Raises
|
|
39
|
-
------
|
|
40
|
-
RuntimeError
|
|
41
|
-
If there is an error with the request or response.
|
|
42
|
-
|
|
43
|
-
"""
|
|
44
|
-
curious_headers = headers(headers=curious_headers)
|
|
45
|
-
if isinstance(curious_url, str):
|
|
46
|
-
curious_url = httpx.URL(curious_url)
|
|
47
|
-
assert isinstance(curious_url, httpx.URL)
|
|
48
|
-
try:
|
|
49
|
-
# Sending the POST request
|
|
50
|
-
response = httpx.post(
|
|
51
|
-
curious_url.join("/auth/login"),
|
|
52
|
-
json=api_data(curious_data),
|
|
53
|
-
headers=curious_headers,
|
|
54
|
-
)
|
|
55
|
-
if response.status_code == httpx.codes.OK:
|
|
56
|
-
response_data = response.json() # Convert response to JSON
|
|
57
|
-
access_token = (
|
|
58
|
-
response_data.get("result", {})
|
|
59
|
-
.get("token", {})
|
|
60
|
-
.get("accessToken", None)
|
|
61
|
-
) # Get access token
|
|
62
|
-
refresh_token = (
|
|
63
|
-
response_data.get("result", {})
|
|
64
|
-
.get("token", {})
|
|
65
|
-
.get("refreshToken", None)
|
|
66
|
-
) # Get refresh token
|
|
67
|
-
else:
|
|
68
|
-
LOGGER.exception(
|
|
69
|
-
"Failed to fetch data: %d - %s", response.status_code, response.text
|
|
70
|
-
)
|
|
71
|
-
response.raise_for_status()
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
return Tokens(access=access_token, refresh=refresh_token)
|
|
75
|
-
|
|
76
|
-
except httpx.HTTPError as e:
|
|
77
|
-
msg = f"Error sending request: {e}"
|
|
78
|
-
raise RuntimeError(msg) from e
|
|
79
|
-
except json.JSONDecodeError as e:
|
|
80
|
-
msg = f"Error: Response is not valid JSON! {e}"
|
|
81
|
-
raise RuntimeError(msg) from e
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
"""Curious schemas."""
|
|
2
|
-
|
|
3
|
-
from typing import Annotated
|
|
4
|
-
|
|
5
|
-
import polars as pl
|
|
6
|
-
from pydantic.types import StringConstraints
|
|
7
|
-
|
|
8
|
-
from .regex import EMAIL_REGEX
|
|
9
|
-
from ..utils.logging import get_logger
|
|
10
|
-
|
|
11
|
-
LOGGER = get_logger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pl.api.register_dataframe_namespace("curious_pyapi")
|
|
15
|
-
class CuriousAccount:
|
|
16
|
-
SCHEMA = pl.Schema(
|
|
17
|
-
{
|
|
18
|
-
"email": pl.String,
|
|
19
|
-
"firstName": pl.String,
|
|
20
|
-
"lastName": pl.String,
|
|
21
|
-
"language": pl.String,
|
|
22
|
-
"secretUserId": pl.String,
|
|
23
|
-
"nickname": pl.String,
|
|
24
|
-
"tag": pl.String,
|
|
25
|
-
}
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
def __init__(self, df: pl.DataFrame):
|
|
29
|
-
self._df = df
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def enforce_schema(self) -> pl.DataFrame:
|
|
33
|
-
"""Enforces schema, adds missing columns with nulls, and casts types."""
|
|
34
|
-
exprs = [
|
|
35
|
-
pl.col(col).cast(dtype)
|
|
36
|
-
if col in self._df.columns
|
|
37
|
-
else pl.lit(None, dtype=dtype).alias(col)
|
|
38
|
-
for col, dtype in self.SCHEMA.items()
|
|
39
|
-
]
|
|
40
|
-
|
|
41
|
-
out_df = self._df.select(exprs)
|
|
42
|
-
|
|
43
|
-
# Evaluate against out_df where 'email' is guaranteed to exist
|
|
44
|
-
is_valid_email = pl.col("email").str.contains(EMAIL_REGEX).fill_null(False)
|
|
45
|
-
|
|
46
|
-
failures_df = out_df.filter(~is_valid_email)
|
|
47
|
-
out_df = out_df.filter(is_valid_email)
|
|
48
|
-
|
|
49
|
-
if not failures_df.is_empty():
|
|
50
|
-
log_cols = [
|
|
51
|
-
col
|
|
52
|
-
for col in ["email", "secretUserId", "nickname"]
|
|
53
|
-
if col in failures_df.columns
|
|
54
|
-
]
|
|
55
|
-
LOGGER.error(
|
|
56
|
-
"Dropped %d records due to invalid email addresses:\n%s",
|
|
57
|
-
failures_df.height,
|
|
58
|
-
failures_df.select(log_cols),
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
return out_df
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
CuriousId = Annotated[
|
|
65
|
-
str,
|
|
66
|
-
StringConstraints(pattern=r"^[a-zA-Z0-9]{8}(-[a-zA-Z0-9]{4}){3}-[a-zA-Z0-9]{12}$"),
|
|
67
|
-
]
|
|
68
|
-
"""ID string for a Curious entity."""
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
"""Curious-PyAPI schemas."""
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel, EmailStr, SecretStr
|
|
4
|
-
|
|
5
|
-
from .curious import CuriousId
|
|
6
|
-
from ..utils.logging import get_logger
|
|
7
|
-
|
|
8
|
-
logger = get_logger(__name__)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class CuriousAuth(BaseModel):
|
|
12
|
-
"""Required data for authentication and decryption."""
|
|
13
|
-
|
|
14
|
-
curious_email: EmailStr
|
|
15
|
-
curious_password: SecretStr
|
|
16
|
-
applet_id: CuriousId
|
|
17
|
-
applet_password: SecretStr
|
|
18
|
-
|
|
19
|
-
@property
|
|
20
|
-
def login_credentials(self) -> dict[str, str | SecretStr]:
|
|
21
|
-
"""Return email and password as dictionary for API request."""
|
|
22
|
-
return {"email": self.curious_email, "password": self.curious_password}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class Tokens(BaseModel):
|
|
26
|
-
"""Curious tokens."""
|
|
27
|
-
|
|
28
|
-
access: SecretStr
|
|
29
|
-
refresh: SecretStr
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|