@josephyan/qingflow-cli 0.2.0-beta.988 → 0.2.0-beta.990

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,626 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import sys
8
+ import threading
9
+ import time
10
+ import webbrowser
11
+ from dataclasses import dataclass
12
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, urlencode, urljoin, urlparse
15
+ from uuid import uuid4
16
+
17
+ import httpx
18
+
19
+ from ..config import get_config_value, get_timeout_seconds
20
+ from ..errors import QingflowApiError
21
+
22
+
23
+ _DEFAULT_SCOPE = "openid profile email"
24
+ _DEFAULT_CALLBACK_HOST = "127.0.0.1"
25
+ _DEFAULT_CALLBACK_PATH = "/callback"
26
+ _DEFAULT_BROWSER_TIMEOUT_SECONDS = 180.0
27
+ _DEFAULT_DEVICE_INTERVAL_SECONDS = 5.0
28
+ _SUCCESS_HTML = """<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <title>Qingflow CLI Login Complete</title>
33
+ </head>
34
+ <body>
35
+ <p>Qingflow CLI login complete. You can return to the terminal.</p>
36
+ </body>
37
+ </html>
38
+ """
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class CliOAuthConfig:
43
+ client_id: str
44
+ authorization_endpoint: str
45
+ token_endpoint: str
46
+ device_authorization_endpoint: str | None
47
+ scope: str
48
+ callback_host: str
49
+ callback_port: int
50
+ callback_path: str
51
+ browser_timeout_seconds: float
52
+ credential_field: str
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class CliOAuthCredential:
57
+ credential: str
58
+ flow: str
59
+ authorize_url: str | None = None
60
+ verification_uri: str | None = None
61
+ user_code: str | None = None
62
+ warnings: list[str] | None = None
63
+
64
+
65
+ class _LoopbackAuthServer:
66
+ def __init__(
67
+ self,
68
+ *,
69
+ host: str,
70
+ port: int,
71
+ callback_path: str,
72
+ timeout_seconds: float,
73
+ status_stream,
74
+ ) -> None:
75
+ self._host = host
76
+ self._port = port
77
+ self._callback_path = callback_path
78
+ self._timeout_seconds = timeout_seconds
79
+ self._status_stream = status_stream
80
+ self._event = threading.Event()
81
+ self._params: dict[str, str] | None = None
82
+ self._error: str | None = None
83
+ self._server = self._build_server()
84
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
85
+
86
+ @property
87
+ def redirect_uri(self) -> str:
88
+ return f"http://{self._host}:{self._server.server_port}{self._callback_path}"
89
+
90
+ def start(self) -> None:
91
+ self._thread.start()
92
+
93
+ def wait(self) -> dict[str, str]:
94
+ if not self._event.wait(timeout=self._timeout_seconds):
95
+ raise QingflowApiError.config_error(
96
+ f"timed out waiting for browser callback after {int(self._timeout_seconds)} seconds"
97
+ )
98
+ if self._error:
99
+ raise QingflowApiError(category="auth", message=self._error)
100
+ if self._params is None:
101
+ raise QingflowApiError(category="auth", message="browser callback did not return any parameters")
102
+ return self._params
103
+
104
+ def close(self) -> None:
105
+ self._server.shutdown()
106
+ self._server.server_close()
107
+ self._thread.join(timeout=1)
108
+
109
+ def _build_server(self) -> HTTPServer:
110
+ parent = self
111
+
112
+ class CallbackServer(HTTPServer):
113
+ allow_reuse_address = True
114
+
115
+ class Handler(BaseHTTPRequestHandler):
116
+ def do_GET(self) -> None: # noqa: N802
117
+ parsed = urlparse(self.path)
118
+ if parsed.path != parent._callback_path:
119
+ self.send_error(404)
120
+ return
121
+ query = {
122
+ key: values[0]
123
+ for key, values in parse_qs(parsed.query, keep_blank_values=True).items()
124
+ if values
125
+ }
126
+ parent._params = query
127
+ parent._event.set()
128
+ self.send_response(200)
129
+ self.send_header("Content-Type", "text/html; charset=utf-8")
130
+ self.end_headers()
131
+ self.wfile.write(_SUCCESS_HTML.encode("utf-8"))
132
+
133
+ def log_message(self, format: str, *args: object) -> None: # noqa: A003
134
+ return
135
+
136
+ try:
137
+ return CallbackServer((self._host, self._port), Handler)
138
+ except OSError as exc:
139
+ raise QingflowApiError.config_error(f"failed to start local callback server: {exc}") from exc
140
+
141
+
142
+ class CliOAuthLoginHelper:
143
+ def __init__(
144
+ self,
145
+ *,
146
+ http_client: httpx.Client | None = None,
147
+ browser_opener=webbrowser.open,
148
+ sleep_fn=time.sleep,
149
+ status_stream=None,
150
+ ) -> None:
151
+ self._owns_client = http_client is None
152
+ self._http_client = http_client or httpx.Client(
153
+ timeout=get_timeout_seconds(),
154
+ follow_redirects=True,
155
+ trust_env=False,
156
+ )
157
+ self._browser_opener = browser_opener
158
+ self._sleep = sleep_fn
159
+ self._status_stream = status_stream or sys.stderr
160
+
161
+ def close(self) -> None:
162
+ if self._owns_client:
163
+ self._http_client.close()
164
+
165
+ def login(
166
+ self,
167
+ *,
168
+ base_url: str | None,
169
+ client_id: str | None = None,
170
+ authorization_endpoint: str | None = None,
171
+ token_endpoint: str | None = None,
172
+ device_authorization_endpoint: str | None = None,
173
+ scope: str | None = None,
174
+ credential_field: str | None = None,
175
+ force_device: bool = False,
176
+ ) -> CliOAuthCredential:
177
+ config = load_cli_oauth_config(
178
+ base_url=base_url,
179
+ client_id=client_id,
180
+ authorization_endpoint=authorization_endpoint,
181
+ token_endpoint=token_endpoint,
182
+ device_authorization_endpoint=device_authorization_endpoint,
183
+ scope=scope,
184
+ credential_field=credential_field,
185
+ )
186
+ if force_device:
187
+ return self._device_flow(config)
188
+ try:
189
+ return self._authorization_code_flow(config)
190
+ except _BrowserUnavailableError as exc:
191
+ if not config.device_authorization_endpoint:
192
+ raise QingflowApiError.config_error(
193
+ "browser auth is unavailable and no device_authorization_endpoint is configured"
194
+ ) from exc
195
+ self._write_status("Browser unavailable, switching to device flow.")
196
+ return self._device_flow(config)
197
+
198
+ def _authorization_code_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
199
+ state = uuid4().hex
200
+ code_verifier = _generate_code_verifier()
201
+ code_challenge = _code_challenge_s256(code_verifier)
202
+ server = _LoopbackAuthServer(
203
+ host=config.callback_host,
204
+ port=config.callback_port,
205
+ callback_path=config.callback_path,
206
+ timeout_seconds=config.browser_timeout_seconds,
207
+ status_stream=self._status_stream,
208
+ )
209
+ try:
210
+ server.start()
211
+ authorize_url = self._build_authorize_url(
212
+ config=config,
213
+ redirect_uri=server.redirect_uri,
214
+ state=state,
215
+ code_challenge=code_challenge,
216
+ )
217
+ self._write_status(f"Opening browser for Qingflow login: {authorize_url}")
218
+ opened = False
219
+ try:
220
+ opened = bool(self._browser_opener(authorize_url))
221
+ except Exception as exc: # pragma: no cover - depends on local browser runtime
222
+ raise _BrowserUnavailableError(str(exc)) from exc
223
+ if not opened:
224
+ raise _BrowserUnavailableError("system browser refused to open authorization URL")
225
+ callback_params = server.wait()
226
+ finally:
227
+ server.close()
228
+
229
+ if callback_params.get("error"):
230
+ error_description = callback_params.get("error_description") or callback_params["error"]
231
+ raise QingflowApiError(category="auth", message=f"authorization failed: {error_description}")
232
+ returned_state = str(callback_params.get("state") or "")
233
+ if returned_state != state:
234
+ raise QingflowApiError(category="auth", message="authorization callback state mismatch")
235
+ credential = self._extract_credential(callback_params, credential_field=config.credential_field)
236
+ if credential:
237
+ return CliOAuthCredential(
238
+ credential=credential,
239
+ flow="authorization_code",
240
+ authorize_url=authorize_url,
241
+ )
242
+ code = str(callback_params.get("code") or "").strip()
243
+ if not code:
244
+ raise QingflowApiError(category="auth", message="authorization callback did not return a code")
245
+ token_payload = self._post_form(
246
+ config.token_endpoint,
247
+ {
248
+ "grant_type": "authorization_code",
249
+ "client_id": config.client_id,
250
+ "code": code,
251
+ "redirect_uri": server.redirect_uri,
252
+ "code_verifier": code_verifier,
253
+ },
254
+ )
255
+ credential = self._extract_credential(token_payload, credential_field=config.credential_field)
256
+ if not credential:
257
+ raise QingflowApiError(
258
+ category="auth",
259
+ message=(
260
+ "token exchange succeeded but did not return a Qingflow credential; "
261
+ "configure cli_auth.credential_field if your broker uses a custom field"
262
+ ),
263
+ )
264
+ return CliOAuthCredential(
265
+ credential=credential,
266
+ flow="authorization_code",
267
+ authorize_url=authorize_url,
268
+ )
269
+
270
+ def _device_flow(self, config: CliOAuthConfig) -> CliOAuthCredential:
271
+ if not config.device_authorization_endpoint:
272
+ raise QingflowApiError.config_error("device_authorization_endpoint is required for device flow")
273
+ start_payload = self._post_form(
274
+ config.device_authorization_endpoint,
275
+ {
276
+ "client_id": config.client_id,
277
+ "scope": config.scope,
278
+ },
279
+ )
280
+ device_code = str(start_payload.get("device_code") or "").strip()
281
+ if not device_code:
282
+ raise QingflowApiError(category="auth", message="device authorization did not return device_code")
283
+ verification_uri = (
284
+ str(start_payload.get("verification_uri_complete") or "").strip()
285
+ or str(start_payload.get("verification_uri") or "").strip()
286
+ )
287
+ user_code = str(start_payload.get("user_code") or "").strip() or None
288
+ interval_seconds = _coerce_positive_float(start_payload.get("interval")) or _DEFAULT_DEVICE_INTERVAL_SECONDS
289
+ expires_in_seconds = _coerce_positive_float(start_payload.get("expires_in")) or config.browser_timeout_seconds
290
+ if verification_uri:
291
+ self._write_status(f"Open this URL to continue Qingflow login: {verification_uri}")
292
+ if user_code:
293
+ self._write_status(f"Device code: {user_code}")
294
+ deadline = time.monotonic() + expires_in_seconds
295
+ interval = interval_seconds
296
+ while time.monotonic() < deadline:
297
+ payload, status_code = self._post_form_with_status(
298
+ config.token_endpoint,
299
+ {
300
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
301
+ "device_code": device_code,
302
+ "client_id": config.client_id,
303
+ },
304
+ )
305
+ if 200 <= status_code < 300:
306
+ credential = self._extract_credential(payload, credential_field=config.credential_field)
307
+ if not credential:
308
+ raise QingflowApiError(
309
+ category="auth",
310
+ message=(
311
+ "device token exchange succeeded but did not return a Qingflow credential; "
312
+ "configure cli_auth.credential_field if your broker uses a custom field"
313
+ ),
314
+ )
315
+ return CliOAuthCredential(
316
+ credential=credential,
317
+ flow="device_code",
318
+ verification_uri=verification_uri or None,
319
+ user_code=user_code,
320
+ )
321
+ error_code = str(payload.get("error") or "").strip()
322
+ if error_code == "authorization_pending":
323
+ self._sleep(interval)
324
+ continue
325
+ if error_code == "slow_down":
326
+ interval += interval_seconds
327
+ self._sleep(interval)
328
+ continue
329
+ if error_code:
330
+ error_description = str(payload.get("error_description") or error_code).strip()
331
+ raise QingflowApiError(category="auth", message=f"device flow failed: {error_description}")
332
+ raise QingflowApiError(
333
+ category="auth",
334
+ message=f"device flow token polling failed with HTTP {status_code}",
335
+ )
336
+ raise QingflowApiError(
337
+ category="auth",
338
+ message="device flow expired before authorization completed",
339
+ )
340
+
341
+ def _build_authorize_url(
342
+ self,
343
+ *,
344
+ config: CliOAuthConfig,
345
+ redirect_uri: str,
346
+ state: str,
347
+ code_challenge: str,
348
+ ) -> str:
349
+ query = urlencode(
350
+ {
351
+ "response_type": "code",
352
+ "client_id": config.client_id,
353
+ "redirect_uri": redirect_uri,
354
+ "scope": config.scope,
355
+ "state": state,
356
+ "code_challenge": code_challenge,
357
+ "code_challenge_method": "S256",
358
+ }
359
+ )
360
+ separator = "&" if "?" in config.authorization_endpoint else "?"
361
+ return f"{config.authorization_endpoint}{separator}{query}"
362
+
363
+ def _post_form(self, url: str, form: dict[str, str]) -> dict[str, Any]:
364
+ payload, status_code = self._post_form_with_status(url, form)
365
+ if status_code >= 400:
366
+ error_text = str(payload.get("error_description") or payload.get("message") or payload.get("error") or "")
367
+ raise QingflowApiError(category="auth", message=error_text or f"HTTP {status_code}")
368
+ return payload
369
+
370
+ def _post_form_with_status(self, url: str, form: dict[str, str]) -> tuple[dict[str, Any], int]:
371
+ try:
372
+ response = self._http_client.post(
373
+ url,
374
+ data=form,
375
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
376
+ )
377
+ except httpx.RequestError as exc:
378
+ raise QingflowApiError(category="network", message=str(exc)) from exc
379
+ return _parse_response_payload(response), response.status_code
380
+
381
+ def _extract_credential(self, payload: dict[str, Any], *, credential_field: str) -> str | None:
382
+ if not payload:
383
+ return None
384
+ candidate = _extract_nested_field(payload, credential_field)
385
+ if candidate:
386
+ return candidate
387
+ for alias in ("credential", "qingflow_credential", "x-qingflow-client-id"):
388
+ candidate = _extract_nested_field(payload, alias)
389
+ if candidate:
390
+ return candidate
391
+ return None
392
+
393
+ def _write_status(self, message: str) -> None:
394
+ self._status_stream.write(message.rstrip() + "\n")
395
+ self._status_stream.flush()
396
+
397
+
398
+ class _BrowserUnavailableError(RuntimeError):
399
+ pass
400
+
401
+
402
+ def login_with_cli_oauth(
403
+ *,
404
+ base_url: str | None,
405
+ client_id: str | None = None,
406
+ authorization_endpoint: str | None = None,
407
+ token_endpoint: str | None = None,
408
+ device_authorization_endpoint: str | None = None,
409
+ scope: str | None = None,
410
+ credential_field: str | None = None,
411
+ force_device: bool = False,
412
+ ) -> CliOAuthCredential:
413
+ helper = CliOAuthLoginHelper()
414
+ try:
415
+ return helper.login(
416
+ base_url=base_url,
417
+ client_id=client_id,
418
+ authorization_endpoint=authorization_endpoint,
419
+ token_endpoint=token_endpoint,
420
+ device_authorization_endpoint=device_authorization_endpoint,
421
+ scope=scope,
422
+ credential_field=credential_field,
423
+ force_device=force_device,
424
+ )
425
+ finally:
426
+ helper.close()
427
+
428
+
429
+ def load_cli_oauth_config(
430
+ *,
431
+ base_url: str | None,
432
+ client_id: str | None = None,
433
+ authorization_endpoint: str | None = None,
434
+ token_endpoint: str | None = None,
435
+ device_authorization_endpoint: str | None = None,
436
+ scope: str | None = None,
437
+ credential_field: str | None = None,
438
+ ) -> CliOAuthConfig:
439
+ resolved_base_url = str(base_url or "").strip() or None
440
+ resolved_client_id = _first_text(
441
+ client_id,
442
+ os.getenv("QINGFLOW_CLI_AUTH_CLIENT_ID"),
443
+ get_config_value("cli_auth.client_id", default=None),
444
+ )
445
+ resolved_authorization_endpoint = _resolve_endpoint(
446
+ authorization_endpoint,
447
+ resolved_base_url,
448
+ env_var="QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT",
449
+ config_key="cli_auth.authorization_endpoint",
450
+ )
451
+ resolved_token_endpoint = _resolve_endpoint(
452
+ token_endpoint,
453
+ resolved_base_url,
454
+ env_var="QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT",
455
+ config_key="cli_auth.token_endpoint",
456
+ )
457
+ resolved_device_endpoint = _resolve_endpoint(
458
+ device_authorization_endpoint,
459
+ resolved_base_url,
460
+ env_var="QINGFLOW_CLI_AUTH_DEVICE_AUTHORIZATION_ENDPOINT",
461
+ config_key="cli_auth.device_authorization_endpoint",
462
+ required=False,
463
+ )
464
+ resolved_scope = _first_text(
465
+ scope,
466
+ os.getenv("QINGFLOW_CLI_AUTH_SCOPE"),
467
+ get_config_value("cli_auth.scope", default=None),
468
+ fallback=_DEFAULT_SCOPE,
469
+ )
470
+ callback_host = _first_text(
471
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_HOST"),
472
+ get_config_value("cli_auth.callback_host", default=None),
473
+ fallback=_DEFAULT_CALLBACK_HOST,
474
+ )
475
+ callback_path = _normalize_callback_path(
476
+ _first_text(
477
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PATH"),
478
+ get_config_value("cli_auth.callback_path", default=None),
479
+ fallback=_DEFAULT_CALLBACK_PATH,
480
+ )
481
+ )
482
+ callback_port = _coerce_port(
483
+ os.getenv("QINGFLOW_CLI_AUTH_CALLBACK_PORT")
484
+ or get_config_value("cli_auth.callback_port", default=0)
485
+ )
486
+ browser_timeout_seconds = _coerce_positive_float(
487
+ os.getenv("QINGFLOW_CLI_AUTH_BROWSER_TIMEOUT_SECONDS")
488
+ or get_config_value("cli_auth.browser_timeout_seconds", default=_DEFAULT_BROWSER_TIMEOUT_SECONDS)
489
+ ) or _DEFAULT_BROWSER_TIMEOUT_SECONDS
490
+ resolved_credential_field = _first_text(
491
+ credential_field,
492
+ os.getenv("QINGFLOW_CLI_AUTH_CREDENTIAL_FIELD"),
493
+ get_config_value("cli_auth.credential_field", default=None),
494
+ fallback="credential",
495
+ )
496
+
497
+ missing: list[str] = []
498
+ if not resolved_client_id:
499
+ missing.append("cli_auth.client_id / QINGFLOW_CLI_AUTH_CLIENT_ID")
500
+ if not resolved_authorization_endpoint:
501
+ missing.append("cli_auth.authorization_endpoint / QINGFLOW_CLI_AUTH_AUTHORIZATION_ENDPOINT")
502
+ if not resolved_token_endpoint:
503
+ missing.append("cli_auth.token_endpoint / QINGFLOW_CLI_AUTH_TOKEN_ENDPOINT")
504
+ if missing:
505
+ raise QingflowApiError.config_error(
506
+ "CLI auth login requires OAuth config: " + ", ".join(missing)
507
+ )
508
+
509
+ return CliOAuthConfig(
510
+ client_id=resolved_client_id,
511
+ authorization_endpoint=resolved_authorization_endpoint,
512
+ token_endpoint=resolved_token_endpoint,
513
+ device_authorization_endpoint=resolved_device_endpoint,
514
+ scope=resolved_scope,
515
+ callback_host=callback_host,
516
+ callback_port=callback_port,
517
+ callback_path=callback_path,
518
+ browser_timeout_seconds=browser_timeout_seconds,
519
+ credential_field=resolved_credential_field,
520
+ )
521
+
522
+
523
+ def _resolve_endpoint(
524
+ override: str | None,
525
+ base_url: str | None,
526
+ *,
527
+ env_var: str,
528
+ config_key: str,
529
+ required: bool = True,
530
+ ) -> str | None:
531
+ value = _first_text(
532
+ override,
533
+ os.getenv(env_var),
534
+ get_config_value(config_key, default=None),
535
+ )
536
+ if not value:
537
+ return None if not required else ""
538
+ parsed = urlparse(value)
539
+ if parsed.scheme and parsed.netloc:
540
+ return value
541
+ if not base_url:
542
+ raise QingflowApiError.config_error(
543
+ f"{config_key} is relative, but no base_url was provided to resolve it"
544
+ )
545
+ return urljoin(base_url.rstrip("/") + "/", value)
546
+
547
+
548
+ def _first_text(*values: Any, fallback: str | None = None) -> str | None:
549
+ for value in values:
550
+ text = str(value or "").strip()
551
+ if text:
552
+ return text
553
+ return fallback
554
+
555
+
556
+ def _normalize_callback_path(value: str) -> str:
557
+ normalized = str(value or "").strip() or _DEFAULT_CALLBACK_PATH
558
+ return normalized if normalized.startswith("/") else f"/{normalized}"
559
+
560
+
561
+ def _coerce_positive_float(value: Any) -> float | None:
562
+ if value in (None, ""):
563
+ return None
564
+ try:
565
+ parsed = float(value)
566
+ except (TypeError, ValueError):
567
+ return None
568
+ return parsed if parsed > 0 else None
569
+
570
+
571
+ def _coerce_port(value: Any) -> int:
572
+ if value in (None, ""):
573
+ return 0
574
+ try:
575
+ parsed = int(value)
576
+ except (TypeError, ValueError):
577
+ raise QingflowApiError.config_error("cli_auth.callback_port must be an integer") from None
578
+ if parsed < 0 or parsed > 65535:
579
+ raise QingflowApiError.config_error("cli_auth.callback_port must be between 0 and 65535")
580
+ return parsed
581
+
582
+
583
+ def _generate_code_verifier() -> str:
584
+ random_bytes = os.urandom(32)
585
+ return base64.urlsafe_b64encode(random_bytes).decode("ascii").rstrip("=")
586
+
587
+
588
+ def _code_challenge_s256(code_verifier: str) -> str:
589
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
590
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
591
+
592
+
593
+ def _parse_response_payload(response: httpx.Response) -> dict[str, Any]:
594
+ if not response.content:
595
+ return {}
596
+ content_type = str(response.headers.get("Content-Type") or "").lower()
597
+ if "application/json" in content_type:
598
+ payload = response.json()
599
+ return payload if isinstance(payload, dict) else {"value": payload}
600
+ if "application/x-www-form-urlencoded" in content_type:
601
+ return {
602
+ key: values[0]
603
+ for key, values in parse_qs(response.text, keep_blank_values=True).items()
604
+ if values
605
+ }
606
+ try:
607
+ payload = response.json()
608
+ except ValueError:
609
+ try:
610
+ return json.loads(response.text)
611
+ except (TypeError, ValueError):
612
+ return {"message": response.text}
613
+ return payload if isinstance(payload, dict) else {"value": payload}
614
+
615
+
616
+ def _extract_nested_field(payload: dict[str, Any], key: str) -> str | None:
617
+ value = payload.get(key)
618
+ if isinstance(value, str) and value.strip():
619
+ return value.strip()
620
+ for container_key in ("data", "result", "payload"):
621
+ nested = payload.get(container_key)
622
+ if isinstance(nested, dict):
623
+ nested_value = nested.get(key)
624
+ if isinstance(nested_value, str) and nested_value.strip():
625
+ return nested_value.strip()
626
+ return None