iris-pex-embedded-python 3.7.2b2__py3-none-any.whl → 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. iop/__init__.py +276 -18
  2. iop/__main__.py +3 -3
  3. iop/cli/__init__.py +1 -0
  4. iop/cli/formatting.py +45 -0
  5. iop/{_cli.py → cli/main.py} +238 -216
  6. iop/cli/parser.py +165 -0
  7. iop/cli/types.py +73 -0
  8. iop/cls/IOP/Common.cls +11 -2
  9. iop/cls/IOP/PrivateSession/Duplex.cls +1 -1
  10. iop/cls/IOP/Service/Remote/Rest/v1.cls +152 -5
  11. iop/cls/IOP/Utils.cls +814 -7
  12. iop/cls/IOP/Wrapper.cls +1 -1
  13. iop/components/__init__.py +16 -0
  14. iop/{_async_request.py → components/async_request.py} +7 -7
  15. iop/components/business_host.py +363 -0
  16. iop/components/business_operation.py +84 -0
  17. iop/{_business_process.py → components/business_process.py} +145 -125
  18. iop/components/business_service.py +106 -0
  19. iop/{_common.py → components/common.py} +160 -164
  20. iop/{_debugpy.py → components/debugpy.py} +11 -11
  21. iop/{_generator_request.py → components/generator_request.py} +4 -4
  22. iop/{_inbound_adapter.py → components/inbound_adapter.py} +10 -18
  23. iop/{_log_manager.py → components/log_manager.py} +1 -1
  24. iop/{_outbound_adapter.py → components/outbound_adapter.py} +1 -1
  25. iop/components/polling_business_service.py +48 -0
  26. iop/{_private_session_duplex.py → components/private_session_duplex.py} +7 -9
  27. iop/{_private_session_process.py → components/private_session_process.py} +2 -2
  28. iop/{_settings.py → components/settings.py} +23 -3
  29. iop/messages/__init__.py +7 -0
  30. iop/messages/base.py +61 -0
  31. iop/{_decorators.py → messages/decorators.py} +8 -2
  32. iop/messages/dispatch.py +351 -0
  33. iop/{_persistent_message.py → messages/persistent.py} +24 -19
  34. iop/{_serialization.py → messages/serialization.py} +34 -21
  35. iop/{_message_validator.py → messages/validation.py} +15 -13
  36. iop/migration/__init__.py +1 -0
  37. iop/migration/io.py +61 -0
  38. iop/migration/manifest.py +534 -0
  39. iop/migration/plans.py +80 -0
  40. iop/migration/utils.py +1126 -0
  41. iop/production/__init__.py +55 -0
  42. iop/production/actions.py +525 -0
  43. iop/production/common.py +103 -0
  44. iop/production/component.py +234 -0
  45. iop/production/declarations.py +190 -0
  46. iop/production/declarative.py +214 -0
  47. iop/production/diff.py +327 -0
  48. iop/production/import_.py +296 -0
  49. iop/production/inspection.py +92 -0
  50. iop/production/model.py +1143 -0
  51. iop/production/planning.py +678 -0
  52. iop/production/reconstruction.py +341 -0
  53. iop/production/rendering.py +610 -0
  54. iop/production/runtime.py +91 -0
  55. iop/production/source_inference.py +729 -0
  56. iop/production/types.py +546 -0
  57. iop/production/validation.py +452 -0
  58. iop/runtime/__init__.py +1 -0
  59. iop/runtime/director.py +415 -0
  60. iop/runtime/environment.py +59 -0
  61. iop/{_iris.py → runtime/iris.py} +2 -2
  62. iop/runtime/local.py +145 -0
  63. iop/{_director_protocol.py → runtime/protocol.py} +25 -8
  64. iop/runtime/remote/__init__.py +12 -0
  65. iop/runtime/remote/client.py +90 -0
  66. iop/runtime/remote/director.py +283 -0
  67. iop/runtime/remote/migration.py +57 -0
  68. iop/runtime/remote/settings.py +69 -0
  69. iop/runtime/remote/setup.py +72 -0
  70. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/METADATA +11 -2
  71. iris_pex_embedded_python-4.0.0.dist-info/RECORD +99 -0
  72. iris_pex_embedded_python-4.0.0.dist-info/entry_points.txt +2 -0
  73. iris_pex_embedded_python-4.0.0.dist-info/top_level.txt +1 -0
  74. grongier/__init__.py +0 -0
  75. grongier/cls/Grongier/PEX/BusinessOperation.cls +0 -8
  76. grongier/cls/Grongier/PEX/BusinessProcess.cls +0 -13
  77. grongier/cls/Grongier/PEX/BusinessService.cls +0 -8
  78. grongier/cls/Grongier/PEX/Common.cls +0 -10
  79. grongier/cls/Grongier/PEX/Director.cls +0 -10
  80. grongier/cls/Grongier/PEX/Duplex/Operation.cls +0 -4
  81. grongier/cls/Grongier/PEX/Duplex/Process.cls +0 -13
  82. grongier/cls/Grongier/PEX/Duplex/Service.cls +0 -4
  83. grongier/cls/Grongier/PEX/InboundAdapter.cls +0 -8
  84. grongier/cls/Grongier/PEX/Message.cls +0 -13
  85. grongier/cls/Grongier/PEX/OutboundAdapter.cls +0 -8
  86. grongier/cls/Grongier/PEX/PickleMessage.cls +0 -13
  87. grongier/cls/Grongier/PEX/PrivateSession/Duplex.cls +0 -8
  88. grongier/cls/Grongier/PEX/PrivateSession/Message/Ack.cls +0 -14
  89. grongier/cls/Grongier/PEX/PrivateSession/Message/Poll.cls +0 -14
  90. grongier/cls/Grongier/PEX/PrivateSession/Message/Start.cls +0 -14
  91. grongier/cls/Grongier/PEX/PrivateSession/Message/Stop.cls +0 -14
  92. grongier/cls/Grongier/PEX/Test.cls +0 -10
  93. grongier/cls/Grongier/PEX/Utils.cls +0 -10
  94. grongier/cls/Grongier/Service/WSGI.cls +0 -4
  95. grongier/pex/__init__.py +0 -75
  96. grongier/pex/__main__.py +0 -4
  97. grongier/pex/_business_host.py +0 -1
  98. grongier/pex/_cli.py +0 -4
  99. grongier/pex/_common.py +0 -1
  100. grongier/pex/_director.py +0 -1
  101. grongier/pex/_utils.py +0 -1
  102. grongier/pex/wsgi/handlers.py +0 -104
  103. iop/_business_host.py +0 -322
  104. iop/_business_operation.py +0 -77
  105. iop/_business_service.py +0 -67
  106. iop/_director.py +0 -333
  107. iop/_dispatch.py +0 -194
  108. iop/_local.py +0 -106
  109. iop/_message.py +0 -45
  110. iop/_polling_business_service.py +0 -6
  111. iop/_remote.py +0 -473
  112. iop/_utils.py +0 -912
  113. iop/cls/IOP/Service/WSGI.cls +0 -310
  114. iop/wsgi/handlers.py +0 -112
  115. iris_pex_embedded_python-3.7.2b2.dist-info/RECORD +0 -96
  116. iris_pex_embedded_python-3.7.2b2.dist-info/entry_points.txt +0 -2
  117. iris_pex_embedded_python-3.7.2b2.dist-info/top_level.txt +0 -2
  118. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/WHEEL +0 -0
  119. {iris_pex_embedded_python-3.7.2b2.dist-info → iris_pex_embedded_python-4.0.0.dist-info}/licenses/LICENSE +0 -0
iop/_remote.py DELETED
@@ -1,473 +0,0 @@
1
- """Remote director: mirrors _Director's interface over the IOP REST API.
2
-
3
- Configure via environment variables:
4
- IOP_URL Required. e.g. http://localhost:8080
5
- IOP_USERNAME Optional. Default: ""
6
- IOP_PASSWORD Optional. Default: ""
7
- IOP_NAMESPACE Optional. Default: "USER"
8
- IOP_VERIFY_SSL Optional. "0"/"false" to disable TLS verification.
9
-
10
- Or pass a RemoteSettings dict directly (same shape as REMOTE_SETTINGS in settings.py).
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import os
16
- import signal
17
- import time
18
- from typing import Any, Dict, List, Optional
19
-
20
- import requests
21
- import urllib3
22
-
23
- from ._director_protocol import DirectorProtocol as _DirectorProtocol # noqa: F401 --- IGNORE ---
24
-
25
-
26
- class _RemoteDirector(_DirectorProtocol):
27
- """Implements DirectorProtocol over the IOP REST API."""
28
-
29
- def __init__(self, remote_settings: Dict[str, Any]) -> None:
30
- self._url = remote_settings["url"].rstrip("/")
31
- self._base = self._url + "/api/iop"
32
- self._auth = (
33
- remote_settings.get("username", ""),
34
- remote_settings.get("password", ""),
35
- )
36
- self._namespace: str = remote_settings.get("namespace", "USER")
37
- self._verify: bool = remote_settings.get("verify_ssl", True)
38
- if not self._verify:
39
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
40
-
41
- # ------------------------------------------------------------------
42
- # Internal helpers
43
- # ------------------------------------------------------------------
44
-
45
- @staticmethod
46
- def _raise_for_status(resp: requests.Response) -> None:
47
- """Like resp.raise_for_status() but includes the response body error message."""
48
- if not resp.ok:
49
- try:
50
- body = resp.json()
51
- error = body.get("error") or body.get("message") or resp.text
52
- except Exception:
53
- error = resp.text or resp.reason
54
- raise requests.exceptions.HTTPError(
55
- f"{resp.status_code} {resp.reason}: {error}",
56
- response=resp,
57
- )
58
-
59
- def _get(self, path: str, params: Optional[dict] = None) -> Any:
60
- p = {"namespace": self._namespace, **(params or {})}
61
- resp = requests.get(
62
- f"{self._base}{path}",
63
- params=p,
64
- auth=self._auth,
65
- verify=self._verify,
66
- timeout=30,
67
- )
68
- self._raise_for_status(resp)
69
- return resp.json()
70
-
71
- def _post(self, path: str, body: Optional[dict] = None) -> Any:
72
- resp = requests.post(
73
- f"{self._base}{path}",
74
- json=(body or {}),
75
- params={"namespace": self._namespace},
76
- auth=self._auth,
77
- verify=self._verify,
78
- timeout=30,
79
- )
80
- self._raise_for_status(resp)
81
- return resp.json()
82
-
83
- def _put(self, path: str, body: Optional[dict] = None) -> Any:
84
- resp = requests.put(
85
- f"{self._base}{path}",
86
- json=(body or {}),
87
- params={"namespace": self._namespace},
88
- auth=self._auth,
89
- verify=self._verify,
90
- timeout=30,
91
- )
92
- self._raise_for_status(resp)
93
- return resp.json()
94
-
95
- def _check_error(self, data: Any) -> Any:
96
- if isinstance(data, dict) and "error" in data:
97
- raise RuntimeError(data["error"])
98
- return data
99
-
100
- # ------------------------------------------------------------------
101
- # Production lifecycle
102
- # ------------------------------------------------------------------
103
-
104
- def get_default_production(self) -> str:
105
- data = self._check_error(self._get("/default"))
106
- return data.get("production") or "Not defined"
107
-
108
- def set_default_production(self, production_name: str = "") -> None:
109
- self._check_error(self._put("/default", {"production": production_name}))
110
-
111
- def list_productions(self) -> dict:
112
- return self._check_error(self._get("/list"))
113
-
114
- def status_production(self) -> dict:
115
- data = self._check_error(self._get("/status"))
116
- if not data.get("production"):
117
- data["production"] = self.get_default_production()
118
- return data
119
-
120
- def start_production(self, production_name: Optional[str] = None) -> None:
121
- body: dict = {}
122
- if production_name:
123
- body["production"] = production_name
124
- self._check_error(self._post("/start", body))
125
-
126
- def start_production_with_log(self, production_name: Optional[str] = None) -> None:
127
- """Start remotely then stream the log until Ctrl-C (which also stops)."""
128
- self.start_production(production_name)
129
- prod = production_name or self.get_default_production()
130
- print(f"Production '{prod}' started. Streaming log — Ctrl-C to stop.")
131
- running = True
132
-
133
- def _sigint(sig, frame): # pragma: no cover
134
- nonlocal running
135
- running = False
136
-
137
- signal.signal(signal.SIGINT, _sigint)
138
-
139
- last_id = 0
140
- for entry in self._get_log_entries(top=10):
141
- _print_log_entry(entry)
142
- last_id = max(last_id, entry.get("id", 0))
143
-
144
- while running:
145
- time.sleep(1)
146
- entries = self._get_log_entries(since_id=last_id)
147
- for entry in entries:
148
- _print_log_entry(entry)
149
- last_id = max(last_id, entry.get("id", 0))
150
-
151
- self.stop_production()
152
-
153
- def stop_production(self) -> None:
154
- self._check_error(self._post("/stop"))
155
-
156
- def shutdown_production(self) -> None:
157
- self._check_error(self._post("/kill"))
158
-
159
- def restart_production(self) -> None:
160
- self._check_error(self._post("/restart"))
161
-
162
- def update_production(self) -> None:
163
- self._check_error(self._post("/update"))
164
-
165
- # ------------------------------------------------------------------
166
- # Logging
167
- # ------------------------------------------------------------------
168
-
169
- def _get_log_entries(
170
- self,
171
- top: int = 10,
172
- since_id: Optional[int] = None,
173
- ) -> List[dict]:
174
- params: dict = {}
175
- if since_id is not None:
176
- params["since_id"] = since_id
177
- else:
178
- params["top"] = top
179
- data = self._check_error(self._get("/log", params))
180
- return data if isinstance(data, list) else []
181
-
182
- def log_production_top(self, top: int = 10) -> None:
183
- entries = self._get_log_entries(top=top)
184
- for entry in reversed(entries):
185
- _print_log_entry(entry)
186
-
187
- def log_production(self) -> None:
188
- """Stream log continuously until Ctrl-C."""
189
- running = True
190
-
191
- def _sigint(sig, frame): # pragma: no cover
192
- nonlocal running
193
- running = False
194
-
195
- signal.signal(signal.SIGINT, _sigint)
196
-
197
- last_id = 0
198
- for entry in self._get_log_entries(top=10):
199
- _print_log_entry(entry)
200
- last_id = max(last_id, entry.get("id", 0))
201
-
202
- while running:
203
- time.sleep(1)
204
- entries = self._get_log_entries(since_id=last_id)
205
- for entry in entries:
206
- _print_log_entry(entry)
207
- last_id = max(last_id, entry.get("id", 0))
208
-
209
- # ------------------------------------------------------------------
210
- # Test
211
- # ------------------------------------------------------------------
212
-
213
- def test_component(
214
- self,
215
- target: Optional[str],
216
- message=None, # ignored remotely — not serialisable over HTTP
217
- classname: Optional[str] = None,
218
- body: "str | dict | None" = None,
219
- restart: bool = True,
220
- ) -> dict:
221
- """Returns a dict: {"classname": "...", "body": "...", "truncated": false}.
222
-
223
- If *restart* is True the target component is stopped and restarted on
224
- the server before the test message is dispatched.
225
- """
226
- payload: dict = {"target": target or ""}
227
- if classname:
228
- payload["classname"] = classname
229
- if body is not None:
230
- payload["body"] = body
231
- if restart:
232
- payload["restart"] = True
233
- try:
234
- return self._check_error(self._post("/test", payload))
235
- except requests.exceptions.HTTPError as exc:
236
- try:
237
- err_msg = exc.response.json().get("error", str(exc))
238
- except Exception:
239
- err_msg = str(exc)
240
- raise RuntimeError(err_msg) from exc
241
-
242
- # ------------------------------------------------------------------
243
- # Export
244
- # ------------------------------------------------------------------
245
-
246
- def export_production(self, production_name: str) -> dict:
247
- return self._check_error(self._get("/export", {"production": production_name}))
248
-
249
- # ------------------------------------------------------------------
250
- # Migrate
251
- # ------------------------------------------------------------------
252
-
253
- def migrate(self, path: str) -> None:
254
- """Upload .py and .cls files from *path*'s folder to remote IRIS via the IOP migrate API.
255
-
256
- *path* must be an absolute path to a ``settings.py`` file whose directory
257
- (and sub-directories) will be walked for ``.py`` / ``.cls`` files.
258
- ``REMOTE_SETTINGS`` fields (package, namespace, remote_folder) are read
259
- from that same settings file when present; otherwise the director's own
260
- namespace / defaults are used.
261
- """
262
- import importlib.util
263
-
264
- folder = os.path.dirname(path)
265
-
266
- # Try to read optional keys from the settings file
267
- package = "python"
268
- remote_folder = ""
269
- try:
270
- spec = importlib.util.spec_from_file_location("_iop_migrate_settings", path)
271
- mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
272
- spec.loader.exec_module(mod) # type: ignore[union-attr]
273
- rs = getattr(mod, "REMOTE_SETTINGS", {})
274
- package = rs.get("package", package)
275
- remote_folder = rs.get("remote_folder", remote_folder)
276
- except Exception:
277
- pass
278
-
279
- body: List[dict] = []
280
- for dirpath, _, filenames in os.walk(folder):
281
- for fname in sorted(filenames):
282
- if not (fname.endswith(".py") or fname.endswith(".cls")):
283
- continue
284
- full = os.path.join(dirpath, fname)
285
- rel = os.path.relpath(full, folder).replace(os.sep, "/")
286
- with open(full, encoding="utf-8") as fh:
287
- body.append({"name": rel, "data": fh.read()})
288
-
289
- payload = {
290
- "namespace": self._namespace,
291
- "package": package,
292
- "remote_folder": remote_folder,
293
- "body": body,
294
- }
295
- resp = requests.put(
296
- f"{self._base}/migrate",
297
- json=payload,
298
- params={"namespace": self._namespace},
299
- auth=self._auth,
300
- verify=self._verify,
301
- timeout=30,
302
- )
303
- self._raise_for_status(resp)
304
- # Server returns $$$OK (the integer 1) on success — not meaningful to print
305
-
306
- # ------------------------------------------------------------------
307
- # Init / setup — uploads .cls files via the Atelier API
308
- # ------------------------------------------------------------------
309
-
310
- def setup(self, path: Optional[str] = None) -> None:
311
- """Upload and compile IOP .cls files to remote IRIS via the Atelier REST API.
312
-
313
- When *path* is ``None`` the bundled ``iop/cls/`` directory (and the
314
- optional ``grongier/cls/`` directory for retrocompatibility) is used.
315
- Any explicit *path* must point to a directory containing ``.cls`` files.
316
- """
317
- import importlib.resources
318
-
319
- paths_to_upload: List[str] = []
320
- if path is None:
321
- try:
322
- paths_to_upload.append(
323
- str(importlib.resources.files("iop").joinpath("cls"))
324
- )
325
- except ModuleNotFoundError:
326
- pass
327
- try: # retrocompatibility with the grongier.pex package
328
- paths_to_upload.append(
329
- str(importlib.resources.files("grongier").joinpath("cls"))
330
- )
331
- except ModuleNotFoundError:
332
- pass
333
- else:
334
- paths_to_upload.append(path)
335
-
336
- atelier_base = f"{self._url}/api/atelier/v1"
337
- doc_names: List[str] = []
338
-
339
- for cls_root in paths_to_upload:
340
- for dirpath, _, filenames in os.walk(cls_root):
341
- for fname in sorted(filenames):
342
- if not fname.endswith(".cls"):
343
- continue
344
- full_path = os.path.join(dirpath, fname)
345
- doc_name = (
346
- os.path.relpath(full_path, cls_root)
347
- .replace(os.sep, ".")
348
- .replace("/", ".")
349
- )
350
- with open(full_path, encoding="utf-8") as fh:
351
- content = fh.read().splitlines()
352
- resp = requests.put(
353
- f"{atelier_base}/{self._namespace}/doc/{doc_name}",
354
- json={"enc": False, "content": content},
355
- params={"ignoreConflict": "1"},
356
- auth=self._auth,
357
- verify=self._verify,
358
- timeout=30,
359
- )
360
- self._raise_for_status(resp)
361
- doc_names.append(doc_name)
362
- print(f"Uploaded: {doc_name}")
363
-
364
- if not doc_names:
365
- raise RuntimeError("No .cls files found to upload.")
366
-
367
- # Compile all uploaded documents in one request
368
- resp = requests.post(
369
- f"{atelier_base}/{self._namespace}/action/compile",
370
- json=doc_names,
371
- params={"flags": "cuk"},
372
- auth=self._auth,
373
- verify=self._verify,
374
- timeout=120,
375
- )
376
- self._raise_for_status(resp)
377
- result = resp.json()
378
- for line in result.get("console", []):
379
- if line:
380
- print(line)
381
- errors = result.get("status", {}).get("errors", [])
382
- if errors:
383
- raise RuntimeError(f"Compilation errors: {errors}")
384
- print(
385
- "\n.cls files uploaded and compiled successfully."
386
- "\nNext step: ensure the 'iop' Python package is installed on the IRIS server:"
387
- "\n python3 -m pip install iris-pex-embedded-python"
388
- "\nThis is required for full IOP Support; without it, only the migrate() and export_production() methods will work remotely."
389
- )
390
-
391
- # ------------------------------------------------------------------
392
- # Metadata
393
- # ------------------------------------------------------------------
394
-
395
- @property
396
- def namespace(self) -> str:
397
- return self._namespace
398
-
399
-
400
- # ------------------------------------------------------------------
401
- # Shared helpers
402
- # ------------------------------------------------------------------
403
-
404
-
405
- def _print_log_entry(entry: dict) -> None:
406
- print(
407
- entry.get("time_logged", ""),
408
- entry.get("type", ""),
409
- entry.get("config_name", ""),
410
- entry.get("job", ""),
411
- entry.get("message_id", ""),
412
- entry.get("session_id", ""),
413
- entry.get("source_class", ""),
414
- entry.get("source_method", ""),
415
- entry.get("text", ""),
416
- )
417
-
418
-
419
- def _load_remote_settings_from_file(settings_path: str) -> Optional[Dict[str, Any]]:
420
- """Load a ``REMOTE_SETTINGS`` dict from an arbitrary settings.py file."""
421
- try:
422
- import importlib.util
423
-
424
- spec = importlib.util.spec_from_file_location(
425
- "_iop_settings_remote", settings_path
426
- )
427
- mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
428
- spec.loader.exec_module(mod) # type: ignore[union-attr]
429
- remote = getattr(mod, "REMOTE_SETTINGS", None)
430
- if isinstance(remote, dict) and "url" in remote:
431
- return remote
432
- except Exception:
433
- pass
434
- return None
435
-
436
-
437
- def get_remote_settings(
438
- explicit_settings_path: Optional[str] = None,
439
- fallback_settings_path: Optional[str] = None,
440
- ) -> Optional[Dict[str, Any]]:
441
- """Detect remote settings from the environment or an explicit file.
442
-
443
- Priority:
444
- 1. ``IOP_URL`` env var (direct inline configuration).
445
- 2. ``explicit_settings_path`` — the file supplied via ``--remote-settings``.
446
- 3. ``IOP_SETTINGS`` env var pointing to a settings.py with ``REMOTE_SETTINGS``.
447
- 4. ``fallback_settings_path`` — e.g. the file passed via ``-m settings.py``;
448
- its ``REMOTE_SETTINGS`` dict is used when present.
449
- """
450
- url = os.environ.get("IOP_URL")
451
- if url:
452
- verify_raw = os.environ.get("IOP_VERIFY_SSL", "1")
453
- return {
454
- "url": url,
455
- "username": os.environ.get("IOP_USERNAME", ""),
456
- "password": os.environ.get("IOP_PASSWORD", ""),
457
- "namespace": os.environ.get("IOP_NAMESPACE", "USER"),
458
- "verify_ssl": verify_raw.lower() not in ("0", "false"),
459
- }
460
-
461
- for path in filter(
462
- None,
463
- [
464
- explicit_settings_path,
465
- os.environ.get("IOP_SETTINGS"),
466
- fallback_settings_path,
467
- ],
468
- ):
469
- result = _load_remote_settings_from_file(path)
470
- if result:
471
- return result
472
-
473
- return None