pyquipu-application 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyquipu_application-0.1.0.dist-info/METADATA +14 -0
- pyquipu_application-0.1.0.dist-info/RECORD +13 -0
- pyquipu_application-0.1.0.dist-info/WHEEL +4 -0
- quipu/__init__.py +3 -0
- quipu/application/__init__.py +0 -0
- quipu/application/controller.py +214 -0
- quipu/application/controller.stitcher.yaml +41 -0
- quipu/application/factory.py +56 -0
- quipu/application/factory.stitcher.yaml +10 -0
- quipu/application/plugin_manager.py +40 -0
- quipu/application/plugin_manager.stitcher.yaml +5 -0
- quipu/application/utils.py +16 -0
- quipu/application/utils.stitcher.yaml +2 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyquipu-application
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core application logic for Quipu, orchestrating engine and runtime.
|
|
5
|
+
Author-email: doucx <doucxldh@gmail.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pyquipu-bus~=0.1.0
|
|
11
|
+
Requires-Dist: pyquipu-common~=0.1.0
|
|
12
|
+
Requires-Dist: pyquipu-engine~=0.1.0
|
|
13
|
+
Requires-Dist: pyquipu-interfaces~=0.1.0
|
|
14
|
+
Requires-Dist: pyquipu-runtime~=0.1.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
quipu/__init__.py,sha256=dyHnnj2etUVMEOCjNbEguf3eMOLeylDaY3b4XcC8kFQ,180
|
|
2
|
+
quipu/application/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
quipu/application/controller.py,sha256=Cx2-q0OXy5j8qujw7ER8d-K0FTCeFnN7y56LRVweqtA,7630
|
|
4
|
+
quipu/application/controller.stitcher.yaml,sha256=NH3TehBZEpTmHqULWuRleA9Y3mCyRQbXqDSFUSMHK7o,1641
|
|
5
|
+
quipu/application/factory.py,sha256=qaDiv0GgeZrYMxXulR1S-f6u14bfRd9XAieNhuDpho4,2019
|
|
6
|
+
quipu/application/factory.stitcher.yaml,sha256=pOsTN3wQdZwtlJSQkcamTL60BIgstWIdEt7ul1NgfH0,436
|
|
7
|
+
quipu/application/plugin_manager.py,sha256=tDhjOi8V4e-usllvDcr4m-IAiV_gTkbHeOwN_p3dSmk,1315
|
|
8
|
+
quipu/application/plugin_manager.stitcher.yaml,sha256=1Azs30JFpr8WyGp89l3csiFKGyeGjZHCXJTQg0jv_18,224
|
|
9
|
+
quipu/application/utils.py,sha256=g0Hf5vy3hp3djUgRpBSmcSTu0r2aYKpmM_ftSofuwbo,403
|
|
10
|
+
quipu/application/utils.stitcher.yaml,sha256=me8Ukxc33Vt199rF4eJw99rs8yDtoj92cRA1MMig7rA,94
|
|
11
|
+
pyquipu_application-0.1.0.dist-info/METADATA,sha256=ZaqSrA-T3LqyjxCMhziK37l2k5zf0-9hs1zC0IDdtDI,518
|
|
12
|
+
pyquipu_application-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
13
|
+
pyquipu_application-0.1.0.dist-info/RECORD,,
|
quipu/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, Dict, List
|
|
5
|
+
|
|
6
|
+
from quipu.acts import register_core_acts
|
|
7
|
+
from quipu.engine.state_machine import Engine
|
|
8
|
+
from quipu.interfaces.exceptions import ExecutionError as CoreExecutionError
|
|
9
|
+
from quipu.interfaces.exceptions import OperationCancelledError
|
|
10
|
+
from quipu.interfaces.result import QuipuResult
|
|
11
|
+
from quipu.runtime.executor import Executor
|
|
12
|
+
from quipu.runtime.parser import detect_best_parser, get_parser
|
|
13
|
+
|
|
14
|
+
from .factory import create_engine
|
|
15
|
+
from .plugin_manager import PluginManager
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_available_acts(work_dir: Path) -> Dict[str, str]:
|
|
21
|
+
# A dummy confirmation handler is used as it's not required for listing.
|
|
22
|
+
# Yolo=True ensures no interactive prompts can be triggered.
|
|
23
|
+
executor = Executor(
|
|
24
|
+
root_dir=work_dir,
|
|
25
|
+
yolo=True,
|
|
26
|
+
confirmation_handler=lambda diff, prompt: True,
|
|
27
|
+
)
|
|
28
|
+
register_core_acts(executor)
|
|
29
|
+
PluginManager().load_from_sources(executor, work_dir)
|
|
30
|
+
return executor.get_registered_acts()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 定义 ConfirmationHandler 类型别名: (diff_lines, prompt) -> bool
|
|
34
|
+
# 注意: Executor 期望如果不确认则抛出异常,或者返回 False (取决于 Executor 实现)。
|
|
35
|
+
# 为了保持与 CLI 行为一致,调用方传入的 handler 应该在用户拒绝时抛出 OperationCancelledError。
|
|
36
|
+
ConfirmationHandler = Callable[[List[str], str], bool]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class QuipuApplication:
|
|
40
|
+
def __init__(self, work_dir: Path, confirmation_handler: ConfirmationHandler, yolo: bool = False):
|
|
41
|
+
self.work_dir = work_dir
|
|
42
|
+
self.confirmation_handler = confirmation_handler
|
|
43
|
+
self.yolo = yolo
|
|
44
|
+
self.engine: Engine = create_engine(work_dir)
|
|
45
|
+
logger.info(f"Operation boundary set to: {self.work_dir}")
|
|
46
|
+
|
|
47
|
+
def _prepare_workspace(self) -> str:
|
|
48
|
+
current_hash = self.engine.git_db.get_tree_hash()
|
|
49
|
+
|
|
50
|
+
# 1. 正常 Clean: current_node 存在且与当前 hash 一致
|
|
51
|
+
is_node_clean = (self.engine.current_node is not None) and (
|
|
52
|
+
self.engine.current_node.output_tree == current_hash
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 2. 创世 Clean: 历史为空 且 当前是空树 (即没有任何文件被追踪)
|
|
56
|
+
EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
57
|
+
is_genesis_clean = (not self.engine.history_graph) and (current_hash == EMPTY_TREE_HASH)
|
|
58
|
+
|
|
59
|
+
is_clean = is_node_clean or is_genesis_clean
|
|
60
|
+
|
|
61
|
+
if not is_clean:
|
|
62
|
+
self.engine.capture_drift(current_hash)
|
|
63
|
+
|
|
64
|
+
if self.engine.current_node:
|
|
65
|
+
return self.engine.current_node.output_tree
|
|
66
|
+
else:
|
|
67
|
+
return current_hash
|
|
68
|
+
|
|
69
|
+
def _setup_executor(self) -> Executor:
|
|
70
|
+
executor = Executor(
|
|
71
|
+
root_dir=self.work_dir,
|
|
72
|
+
yolo=self.yolo,
|
|
73
|
+
confirmation_handler=self.confirmation_handler,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# 加载核心 acts
|
|
77
|
+
register_core_acts(executor)
|
|
78
|
+
|
|
79
|
+
# 加载外部插件
|
|
80
|
+
plugin_manager = PluginManager()
|
|
81
|
+
plugin_manager.load_from_sources(executor, self.work_dir)
|
|
82
|
+
|
|
83
|
+
return executor
|
|
84
|
+
|
|
85
|
+
def run(self, content: str, parser_name: str) -> QuipuResult:
|
|
86
|
+
# --- Phase 1 & 2: Perception & Decision (Lazy Capture) ---
|
|
87
|
+
input_tree_hash = self._prepare_workspace()
|
|
88
|
+
|
|
89
|
+
# --- Phase 3: Action (Execution) ---
|
|
90
|
+
# 3.1 Parser
|
|
91
|
+
final_parser_name = parser_name
|
|
92
|
+
if parser_name == "auto":
|
|
93
|
+
final_parser_name = detect_best_parser(content)
|
|
94
|
+
if final_parser_name != "backtick":
|
|
95
|
+
logger.info(f"🔍 自动检测到解析器: {final_parser_name}")
|
|
96
|
+
|
|
97
|
+
parser = get_parser(final_parser_name)
|
|
98
|
+
statements = parser.parse(content)
|
|
99
|
+
|
|
100
|
+
if not statements:
|
|
101
|
+
return QuipuResult(
|
|
102
|
+
success=True, # No failure, just nothing to do
|
|
103
|
+
exit_code=0,
|
|
104
|
+
message="axon.warning.noStatements",
|
|
105
|
+
msg_kwargs={"parser": final_parser_name},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# 3.2 Executor Setup
|
|
109
|
+
executor = self._setup_executor()
|
|
110
|
+
|
|
111
|
+
# 3.3 Execute
|
|
112
|
+
executor.execute(statements)
|
|
113
|
+
|
|
114
|
+
# --- Phase 4: Recording (Plan Crystallization) ---
|
|
115
|
+
final_summary = None
|
|
116
|
+
# 优先级 1: 从 Markdown 内容中提取 # 标题
|
|
117
|
+
title_match = re.search(r"^\s*#{1,6}\s+(.*)", content, re.MULTILINE)
|
|
118
|
+
if title_match:
|
|
119
|
+
final_summary = title_match.group(1).strip()
|
|
120
|
+
# 优先级 2: 从第一个 act 指令生成摘要
|
|
121
|
+
elif statements:
|
|
122
|
+
final_summary = executor.summarize_statement(statements[0])
|
|
123
|
+
|
|
124
|
+
output_tree_hash = self.engine.git_db.get_tree_hash()
|
|
125
|
+
|
|
126
|
+
self.engine.create_plan_node(
|
|
127
|
+
input_tree=input_tree_hash,
|
|
128
|
+
output_tree=output_tree_hash,
|
|
129
|
+
plan_content=content,
|
|
130
|
+
summary_override=final_summary,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return QuipuResult(success=True, exit_code=0, message="run.success")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run_quipu(
|
|
137
|
+
content: str,
|
|
138
|
+
work_dir: Path,
|
|
139
|
+
confirmation_handler: ConfirmationHandler,
|
|
140
|
+
parser_name: str = "auto",
|
|
141
|
+
yolo: bool = False,
|
|
142
|
+
) -> QuipuResult:
|
|
143
|
+
app = None
|
|
144
|
+
try:
|
|
145
|
+
app = QuipuApplication(work_dir=work_dir, confirmation_handler=confirmation_handler, yolo=yolo)
|
|
146
|
+
return app.run(content=content, parser_name=parser_name)
|
|
147
|
+
|
|
148
|
+
except OperationCancelledError as e:
|
|
149
|
+
logger.info(f"🚫 操作已取消: {e}")
|
|
150
|
+
return QuipuResult(
|
|
151
|
+
success=False, exit_code=2, message="run.error.cancelled", msg_kwargs={"error": str(e)}, error=e
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
except CoreExecutionError as e:
|
|
155
|
+
logger.error(f"❌ 操作失败: {e}")
|
|
156
|
+
return QuipuResult(
|
|
157
|
+
success=False, exit_code=1, message="run.error.execution", msg_kwargs={"error": str(e)}, error=e
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error(f"运行时错误: {e}", exc_info=True)
|
|
162
|
+
return QuipuResult(
|
|
163
|
+
success=False, exit_code=1, message="run.error.system", msg_kwargs={"error": str(e)}, error=e
|
|
164
|
+
)
|
|
165
|
+
finally:
|
|
166
|
+
# 确保无论成功或失败,引擎资源都被关闭
|
|
167
|
+
if app and hasattr(app, "engine") and app.engine:
|
|
168
|
+
app.engine.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_stateless_plan(
|
|
172
|
+
content: str,
|
|
173
|
+
work_dir: Path,
|
|
174
|
+
confirmation_handler: ConfirmationHandler,
|
|
175
|
+
parser_name: str = "auto",
|
|
176
|
+
yolo: bool = False,
|
|
177
|
+
) -> QuipuResult:
|
|
178
|
+
try:
|
|
179
|
+
executor = Executor(
|
|
180
|
+
root_dir=work_dir,
|
|
181
|
+
yolo=yolo,
|
|
182
|
+
confirmation_handler=confirmation_handler,
|
|
183
|
+
)
|
|
184
|
+
register_core_acts(executor)
|
|
185
|
+
PluginManager().load_from_sources(executor, work_dir)
|
|
186
|
+
|
|
187
|
+
final_parser_name = parser_name
|
|
188
|
+
if parser_name == "auto":
|
|
189
|
+
final_parser_name = detect_best_parser(content)
|
|
190
|
+
|
|
191
|
+
parser = get_parser(final_parser_name)
|
|
192
|
+
statements = parser.parse(content)
|
|
193
|
+
|
|
194
|
+
if not statements:
|
|
195
|
+
return QuipuResult(
|
|
196
|
+
success=True,
|
|
197
|
+
exit_code=0,
|
|
198
|
+
message="axon.warning.noStatements",
|
|
199
|
+
msg_kwargs={"parser": final_parser_name},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
executor.execute(statements)
|
|
203
|
+
return QuipuResult(success=True, exit_code=0, message="axon.success")
|
|
204
|
+
|
|
205
|
+
except CoreExecutionError as e:
|
|
206
|
+
logger.error(f"❌ 操作失败: {e}")
|
|
207
|
+
return QuipuResult(
|
|
208
|
+
success=False, exit_code=1, message="run.error.execution", msg_kwargs={"error": str(e)}, error=e
|
|
209
|
+
)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"运行时错误: {e}", exc_info=True)
|
|
212
|
+
return QuipuResult(
|
|
213
|
+
success=False, exit_code=1, message="run.error.system", msg_kwargs={"error": str(e)}, error=e
|
|
214
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"QuipuApplication": |-
|
|
2
|
+
封装了 Quipu 核心业务流程的高层应用对象。
|
|
3
|
+
负责协调 Engine, Parser, Executor。
|
|
4
|
+
"QuipuApplication._prepare_workspace": |-
|
|
5
|
+
检查并准备工作区,处理状态漂移。
|
|
6
|
+
返回执行前的 input_tree_hash。
|
|
7
|
+
"QuipuApplication._setup_executor": |-
|
|
8
|
+
创建、配置并返回一个 Executor 实例,并注入确认处理器。
|
|
9
|
+
"QuipuApplication.run": |-
|
|
10
|
+
执行一个完整的 Plan。
|
|
11
|
+
"run_quipu": |-
|
|
12
|
+
Quipu 核心业务逻辑的入口包装器。
|
|
13
|
+
|
|
14
|
+
实例化并运行 QuipuApplication,捕获所有异常并转化为 QuipuResult。
|
|
15
|
+
确保资源被安全释放。
|
|
16
|
+
get_available_acts: |-
|
|
17
|
+
Statelessly discovers and returns all available acts.
|
|
18
|
+
|
|
19
|
+
This function initializes a temporary, stateless executor to discover
|
|
20
|
+
core acts and acts from plugins found relative to the working directory.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
work_dir: The directory from which to discover project-level plugins.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
A dictionary mapping act names to their docstrings.
|
|
27
|
+
run_stateless_plan: |-
|
|
28
|
+
Executes a plan in a stateless manner, bypassing the Quipu engine.
|
|
29
|
+
|
|
30
|
+
This function sets up a temporary executor, loads plugins, parses the content,
|
|
31
|
+
and executes the statements against the specified working directory.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
content: The string content of the plan to execute.
|
|
35
|
+
work_dir: The root directory for the execution.
|
|
36
|
+
confirmation_handler: A callable to handle user confirmations.
|
|
37
|
+
parser_name: The name of the parser to use ('auto' by default).
|
|
38
|
+
yolo: If True, skips all confirmation prompts.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A QuipuResult object indicating the outcome of the execution.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from quipu.engine.config import ConfigManager
|
|
5
|
+
from quipu.engine.git_db import GitDB
|
|
6
|
+
from quipu.engine.git_object_storage import GitObjectHistoryReader, GitObjectHistoryWriter
|
|
7
|
+
from quipu.engine.state_machine import Engine
|
|
8
|
+
|
|
9
|
+
from .utils import find_git_repository_root
|
|
10
|
+
|
|
11
|
+
# 迟延导入以避免循环依赖
|
|
12
|
+
try:
|
|
13
|
+
from quipu.engine.sqlite_db import DatabaseManager
|
|
14
|
+
from quipu.engine.sqlite_storage import SQLiteHistoryReader, SQLiteHistoryWriter
|
|
15
|
+
except ImportError:
|
|
16
|
+
DatabaseManager = None
|
|
17
|
+
SQLiteHistoryWriter = None
|
|
18
|
+
SQLiteHistoryReader = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_engine(work_dir: Path, lazy: bool = False) -> Engine:
|
|
25
|
+
project_root = find_git_repository_root(work_dir) or work_dir
|
|
26
|
+
config = ConfigManager(project_root)
|
|
27
|
+
storage_type = config.get("storage.type", "git_object")
|
|
28
|
+
logger.debug(f"Engine factory configured with storage type: '{storage_type}'")
|
|
29
|
+
git_db = GitDB(project_root)
|
|
30
|
+
db_manager = None
|
|
31
|
+
|
|
32
|
+
# 默认和备用后端
|
|
33
|
+
reader = GitObjectHistoryReader(git_db)
|
|
34
|
+
writer = GitObjectHistoryWriter(git_db)
|
|
35
|
+
|
|
36
|
+
if storage_type == "sqlite":
|
|
37
|
+
if not DatabaseManager or not SQLiteHistoryWriter or not SQLiteHistoryReader:
|
|
38
|
+
raise ImportError("SQLite dependencies could not be loaded. Please check your installation.")
|
|
39
|
+
|
|
40
|
+
logger.debug("Using SQLite storage format for reads and writes.")
|
|
41
|
+
db_manager = DatabaseManager(project_root)
|
|
42
|
+
db_manager.init_schema()
|
|
43
|
+
|
|
44
|
+
# 切换到 SQLite 后端
|
|
45
|
+
reader = SQLiteHistoryReader(db_manager=db_manager, git_db=git_db)
|
|
46
|
+
writer = SQLiteHistoryWriter(git_writer=writer, db_manager=db_manager)
|
|
47
|
+
|
|
48
|
+
elif storage_type != "git_object":
|
|
49
|
+
raise NotImplementedError(f"Storage type '{storage_type}' is not supported.")
|
|
50
|
+
|
|
51
|
+
# 将所有资源注入 Engine
|
|
52
|
+
engine = Engine(project_root, db=git_db, reader=reader, writer=writer, db_manager=db_manager)
|
|
53
|
+
if not lazy:
|
|
54
|
+
engine.align()
|
|
55
|
+
|
|
56
|
+
return engine
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from quipu.runtime.executor import Executor
|
|
5
|
+
from quipu.runtime.plugin_loader import load_plugins
|
|
6
|
+
|
|
7
|
+
from .utils import find_git_repository_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PluginManager:
|
|
11
|
+
def load_from_sources(self, executor: Executor, work_dir: Path):
|
|
12
|
+
plugin_sources = []
|
|
13
|
+
|
|
14
|
+
# 优先级由低到高添加,后面的会覆盖前面的
|
|
15
|
+
# 1. User Home (Lowest priority)
|
|
16
|
+
home_acts = Path.home() / ".quipu" / "acts"
|
|
17
|
+
plugin_sources.append(("🏠 Global", home_acts))
|
|
18
|
+
|
|
19
|
+
# 2. Config / Env
|
|
20
|
+
env_path = os.getenv("AXON_EXTRA_ACTS_DIR")
|
|
21
|
+
if env_path:
|
|
22
|
+
plugin_sources.append(("🔧 Env", Path(env_path)))
|
|
23
|
+
|
|
24
|
+
# 3. Project Root (Highest priority)
|
|
25
|
+
project_root_for_plugins = find_git_repository_root(work_dir)
|
|
26
|
+
if project_root_for_plugins:
|
|
27
|
+
proj_acts = project_root_for_plugins / ".quipu" / "acts"
|
|
28
|
+
plugin_sources.append(("📦 Project", proj_acts))
|
|
29
|
+
|
|
30
|
+
seen_paths = set()
|
|
31
|
+
for label, path in plugin_sources:
|
|
32
|
+
if not path.exists() or not path.is_dir():
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
resolved_path = path.resolve()
|
|
36
|
+
if resolved_path in seen_paths:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
load_plugins(executor, path)
|
|
40
|
+
seen_paths.add(resolved_path)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def find_git_repository_root(start_path: Path) -> Optional[Path]:
|
|
9
|
+
try:
|
|
10
|
+
current = start_path.resolve()
|
|
11
|
+
for parent in [current] + list(current.parents):
|
|
12
|
+
if (parent / ".git").exists():
|
|
13
|
+
return parent
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
return None
|