netizen-cli 0.10.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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Application management boundary shared by Feishu and Admin adapters."""
|
|
2
|
+
|
|
3
|
+
from .coordination import ScopeCoordinator
|
|
4
|
+
from .chat_labels import ChatLabel, ChatLabelProvider, ChatLabelResolver
|
|
5
|
+
from .service import (
|
|
6
|
+
ActivePointerChanged,
|
|
7
|
+
BindingPrimaryStatusResolution,
|
|
8
|
+
BindingStatusProjection,
|
|
9
|
+
BindingScopeMismatch,
|
|
10
|
+
ClosedSide,
|
|
11
|
+
CreatedBinding,
|
|
12
|
+
CurrentBindingChanged,
|
|
13
|
+
CurrentBindingTarget,
|
|
14
|
+
CurrentSideTarget,
|
|
15
|
+
ExactBindingTarget,
|
|
16
|
+
InstanceManagementService,
|
|
17
|
+
ManagementError,
|
|
18
|
+
ManagementRuntimePort,
|
|
19
|
+
NativeCatalogInconsistent,
|
|
20
|
+
NativeThreadMissing,
|
|
21
|
+
NativeThreadView,
|
|
22
|
+
NoCurrentBinding,
|
|
23
|
+
ProjectInventoryItem,
|
|
24
|
+
ProjectInventoryPage,
|
|
25
|
+
ProjectDeletionResult,
|
|
26
|
+
ReleasedBinding,
|
|
27
|
+
RenamedBinding,
|
|
28
|
+
RuntimePrecondition,
|
|
29
|
+
RuntimeSnapshots,
|
|
30
|
+
RuntimeStateChanged,
|
|
31
|
+
SessionInventoryItem,
|
|
32
|
+
SessionInventoryPage,
|
|
33
|
+
SessionInventoryState,
|
|
34
|
+
SessionQuery,
|
|
35
|
+
ScopeSessionPage,
|
|
36
|
+
SideIdentityMismatch,
|
|
37
|
+
SideTopicInventoryItem,
|
|
38
|
+
SideTopicInventoryPage,
|
|
39
|
+
StoppedBinding,
|
|
40
|
+
classify_native_thread_view,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = (
|
|
44
|
+
"ActivePointerChanged",
|
|
45
|
+
"BindingPrimaryStatusResolution",
|
|
46
|
+
"BindingStatusProjection",
|
|
47
|
+
"BindingScopeMismatch",
|
|
48
|
+
"ChatLabel",
|
|
49
|
+
"ChatLabelProvider",
|
|
50
|
+
"ChatLabelResolver",
|
|
51
|
+
"ClosedSide",
|
|
52
|
+
"CreatedBinding",
|
|
53
|
+
"CurrentBindingChanged",
|
|
54
|
+
"CurrentBindingTarget",
|
|
55
|
+
"CurrentSideTarget",
|
|
56
|
+
"ExactBindingTarget",
|
|
57
|
+
"InstanceManagementService",
|
|
58
|
+
"ManagementError",
|
|
59
|
+
"ManagementRuntimePort",
|
|
60
|
+
"NativeCatalogInconsistent",
|
|
61
|
+
"NativeThreadMissing",
|
|
62
|
+
"NativeThreadView",
|
|
63
|
+
"NoCurrentBinding",
|
|
64
|
+
"ProjectInventoryItem",
|
|
65
|
+
"ProjectInventoryPage",
|
|
66
|
+
"ProjectDeletionResult",
|
|
67
|
+
"ReleasedBinding",
|
|
68
|
+
"RenamedBinding",
|
|
69
|
+
"RuntimePrecondition",
|
|
70
|
+
"RuntimeSnapshots",
|
|
71
|
+
"RuntimeStateChanged",
|
|
72
|
+
"ScopeCoordinator",
|
|
73
|
+
"SessionInventoryItem",
|
|
74
|
+
"SessionInventoryPage",
|
|
75
|
+
"SessionInventoryState",
|
|
76
|
+
"SessionQuery",
|
|
77
|
+
"ScopeSessionPage",
|
|
78
|
+
"SideIdentityMismatch",
|
|
79
|
+
"SideTopicInventoryItem",
|
|
80
|
+
"SideTopicInventoryPage",
|
|
81
|
+
"StoppedBinding",
|
|
82
|
+
"classify_native_thread_view",
|
|
83
|
+
)
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Bounded execution for blocking management-plane filesystem work.
|
|
2
|
+
|
|
3
|
+
The executor deliberately counts submitted ``concurrent.futures.Future``
|
|
4
|
+
objects rather than asyncio Tasks. Cancelling an HTTP handler therefore only
|
|
5
|
+
stops that handler from waiting; it neither cancels the blocking operation nor
|
|
6
|
+
returns its admission slot early.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import math
|
|
13
|
+
import threading
|
|
14
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
15
|
+
from functools import partial
|
|
16
|
+
from typing import Callable, TypeVar
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ResultT = TypeVar("ResultT")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BlockingIOExecutorClosed(RuntimeError):
|
|
23
|
+
"""Raised when work is submitted after admission has closed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BlockingIOExecutorSaturated(RuntimeError):
|
|
27
|
+
"""Raised when all running and queued work slots are occupied."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BlockingIOResultUnknown(RuntimeError):
|
|
31
|
+
"""A submitted operation did not finish before its response deadline.
|
|
32
|
+
|
|
33
|
+
The underlying operation is not cancelled and remains tracked by the
|
|
34
|
+
executor. Callers must reconcile the authoritative state; they must not
|
|
35
|
+
infer rollback or retry the operation automatically.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *, deadline: float) -> None:
|
|
39
|
+
self.deadline = deadline
|
|
40
|
+
super().__init__(
|
|
41
|
+
"blocking I/O deadline expired after submission; result is unknown "
|
|
42
|
+
"and the operation remains tracked"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BlockingIODrainTimeout(TimeoutError):
|
|
47
|
+
"""Raised when tracked work cannot drain within the caller's budget."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, in_flight: int, deadline: float) -> None:
|
|
50
|
+
self.in_flight = in_flight
|
|
51
|
+
self.deadline = deadline
|
|
52
|
+
super().__init__(
|
|
53
|
+
f"blocking I/O drain deadline expired with {in_flight} operation(s) "
|
|
54
|
+
"still tracked"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BlockingIOShutdownTimeout(TimeoutError):
|
|
59
|
+
"""Raised when the worker-thread join exceeds the caller's budget."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, *, deadline: float) -> None:
|
|
62
|
+
self.deadline = deadline
|
|
63
|
+
super().__init__("blocking I/O worker shutdown exceeded its deadline")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class BoundedBlockingIOExecutor:
|
|
67
|
+
"""Run synchronous callables without an unbounded executor queue.
|
|
68
|
+
|
|
69
|
+
``capacity`` counts both running and queued operations. Admission is
|
|
70
|
+
rejected synchronously within :meth:`submit` before
|
|
71
|
+
:class:`ThreadPoolExecutor` sees the callable. The instance belongs to one
|
|
72
|
+
asyncio event loop, while its completion accounting is protected for
|
|
73
|
+
callbacks arriving from worker threads.
|
|
74
|
+
|
|
75
|
+
``deadline`` values are absolute values from the owning loop's monotonic
|
|
76
|
+
clock (``loop.time()``), which is suitable for sharing one shutdown or
|
|
77
|
+
request budget across several awaits.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
max_workers: int = 1,
|
|
84
|
+
capacity: int = 2,
|
|
85
|
+
thread_name_prefix: str = "netizen-project-io",
|
|
86
|
+
) -> None:
|
|
87
|
+
if (
|
|
88
|
+
isinstance(max_workers, bool)
|
|
89
|
+
or not isinstance(max_workers, int)
|
|
90
|
+
or max_workers < 1
|
|
91
|
+
):
|
|
92
|
+
raise ValueError("max_workers must be a positive integer")
|
|
93
|
+
if (
|
|
94
|
+
isinstance(capacity, bool)
|
|
95
|
+
or not isinstance(capacity, int)
|
|
96
|
+
or capacity < max_workers
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("capacity must be an integer at least max_workers")
|
|
99
|
+
if not thread_name_prefix:
|
|
100
|
+
raise ValueError("thread_name_prefix must not be empty")
|
|
101
|
+
|
|
102
|
+
self._capacity = capacity
|
|
103
|
+
self._executor = ThreadPoolExecutor(
|
|
104
|
+
max_workers=max_workers,
|
|
105
|
+
thread_name_prefix=thread_name_prefix,
|
|
106
|
+
)
|
|
107
|
+
self._state_lock = threading.Lock()
|
|
108
|
+
self._tracked: set[Future[object]] = set()
|
|
109
|
+
self._accepting = True
|
|
110
|
+
self._shutdown_started = False
|
|
111
|
+
|
|
112
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
113
|
+
self._completion_event: asyncio.Event | None = None
|
|
114
|
+
self._join_task: asyncio.Task[None] | None = None
|
|
115
|
+
self._join_error: BaseException | None = None
|
|
116
|
+
self._joined = False
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def capacity(self) -> int:
|
|
120
|
+
return self._capacity
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def accepting(self) -> bool:
|
|
124
|
+
with self._state_lock:
|
|
125
|
+
return self._accepting
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def in_flight(self) -> int:
|
|
129
|
+
"""Return the number of running or queued underlying operations."""
|
|
130
|
+
|
|
131
|
+
with self._state_lock:
|
|
132
|
+
return len(self._tracked)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def joined(self) -> bool:
|
|
136
|
+
return self._joined
|
|
137
|
+
|
|
138
|
+
async def submit(
|
|
139
|
+
self,
|
|
140
|
+
function: Callable[..., ResultT],
|
|
141
|
+
/,
|
|
142
|
+
*args: object,
|
|
143
|
+
deadline: float | None = None,
|
|
144
|
+
**kwargs: object,
|
|
145
|
+
) -> ResultT:
|
|
146
|
+
"""Submit a synchronous callable and asynchronously await its result.
|
|
147
|
+
|
|
148
|
+
A deadline applies only to waiting for the already-submitted operation.
|
|
149
|
+
Expiry raises :class:`BlockingIOResultUnknown`; it never cancels or
|
|
150
|
+
retries the underlying future.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
loop, _ = self._bind_to_running_loop()
|
|
154
|
+
checked_deadline = _validate_deadline(deadline)
|
|
155
|
+
operation = partial(function, *args, **kwargs)
|
|
156
|
+
|
|
157
|
+
with self._state_lock:
|
|
158
|
+
if not self._accepting:
|
|
159
|
+
raise BlockingIOExecutorClosed(
|
|
160
|
+
"blocking I/O executor is closed to new work"
|
|
161
|
+
)
|
|
162
|
+
if len(self._tracked) >= self._capacity:
|
|
163
|
+
raise BlockingIOExecutorSaturated(
|
|
164
|
+
"blocking I/O executor has no free submission slots"
|
|
165
|
+
)
|
|
166
|
+
# This is the only call into ThreadPoolExecutor.submit. Keeping it
|
|
167
|
+
# in the same critical section as the checks guarantees that a
|
|
168
|
+
# rejected operation never reaches its unbounded internal queue.
|
|
169
|
+
future = self._executor.submit(operation)
|
|
170
|
+
self._tracked.add(future)
|
|
171
|
+
|
|
172
|
+
future.add_done_callback(self._operation_completed)
|
|
173
|
+
wrapped = asyncio.wrap_future(future, loop=loop)
|
|
174
|
+
# If the request is cancelled or times out, nobody may await ``wrapped``
|
|
175
|
+
# again. Retrieve a later exception to prevent an asyncio warning;
|
|
176
|
+
# this does not change the result seen by a normal waiter.
|
|
177
|
+
wrapped.add_done_callback(_consume_asyncio_future_exception)
|
|
178
|
+
|
|
179
|
+
if checked_deadline is None:
|
|
180
|
+
return await asyncio.shield(wrapped)
|
|
181
|
+
|
|
182
|
+
remaining = checked_deadline - loop.time()
|
|
183
|
+
if remaining > 0:
|
|
184
|
+
done, _ = await asyncio.wait((wrapped,), timeout=remaining)
|
|
185
|
+
if done:
|
|
186
|
+
return wrapped.result()
|
|
187
|
+
|
|
188
|
+
# Cover completion racing the timeout without claiming uncertainty
|
|
189
|
+
# when the concurrent result is already synchronously available.
|
|
190
|
+
if future.done():
|
|
191
|
+
return future.result()
|
|
192
|
+
raise BlockingIOResultUnknown(deadline=checked_deadline)
|
|
193
|
+
|
|
194
|
+
def close_admission(self) -> None:
|
|
195
|
+
"""Idempotently reject all future submissions without cancelling work."""
|
|
196
|
+
|
|
197
|
+
with self._state_lock:
|
|
198
|
+
self._accepting = False
|
|
199
|
+
|
|
200
|
+
async def drain(self, *, deadline: float | None = None) -> None:
|
|
201
|
+
"""Wait for all tracked operations within an optional absolute deadline."""
|
|
202
|
+
|
|
203
|
+
loop, completion_event = self._bind_to_running_loop()
|
|
204
|
+
checked_deadline = _validate_deadline(deadline)
|
|
205
|
+
|
|
206
|
+
while True:
|
|
207
|
+
# Clear before checking while the worker callback cannot acquire
|
|
208
|
+
# the state lock. A completion after the check schedules a fresh
|
|
209
|
+
# set(), so no wakeup can be lost.
|
|
210
|
+
completion_event.clear()
|
|
211
|
+
with self._state_lock:
|
|
212
|
+
remaining_work = len(self._tracked)
|
|
213
|
+
if remaining_work == 0:
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
if checked_deadline is None:
|
|
217
|
+
await completion_event.wait()
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
remaining_time = checked_deadline - loop.time()
|
|
221
|
+
if remaining_time <= 0:
|
|
222
|
+
raise BlockingIODrainTimeout(
|
|
223
|
+
in_flight=remaining_work,
|
|
224
|
+
deadline=checked_deadline,
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
await asyncio.wait_for(
|
|
228
|
+
completion_event.wait(),
|
|
229
|
+
timeout=remaining_time,
|
|
230
|
+
)
|
|
231
|
+
except TimeoutError:
|
|
232
|
+
with self._state_lock:
|
|
233
|
+
remaining_work = len(self._tracked)
|
|
234
|
+
if remaining_work == 0:
|
|
235
|
+
return
|
|
236
|
+
raise BlockingIODrainTimeout(
|
|
237
|
+
in_flight=remaining_work,
|
|
238
|
+
deadline=checked_deadline,
|
|
239
|
+
) from None
|
|
240
|
+
|
|
241
|
+
async def aclose(self, *, deadline: float | None = None) -> None:
|
|
242
|
+
"""Close admission, drain tracked work, and join worker threads.
|
|
243
|
+
|
|
244
|
+
The operation is idempotent. Cancellation or deadline expiry leaves
|
|
245
|
+
admission closed and the submitted work running to completion; a later
|
|
246
|
+
call can finish draining and joining it.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
loop, _ = self._bind_to_running_loop()
|
|
250
|
+
checked_deadline = _validate_deadline(deadline)
|
|
251
|
+
self.close_admission()
|
|
252
|
+
self._start_executor_shutdown()
|
|
253
|
+
|
|
254
|
+
await self.drain(deadline=checked_deadline)
|
|
255
|
+
if self._joined:
|
|
256
|
+
if self._join_error is not None:
|
|
257
|
+
raise self._join_error
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
join_task = self._join_task
|
|
261
|
+
if join_task is None:
|
|
262
|
+
join_task = asyncio.create_task(
|
|
263
|
+
asyncio.to_thread(
|
|
264
|
+
self._executor.shutdown,
|
|
265
|
+
wait=True,
|
|
266
|
+
cancel_futures=False,
|
|
267
|
+
),
|
|
268
|
+
name="netizen-blocking-io-shutdown",
|
|
269
|
+
)
|
|
270
|
+
join_task.add_done_callback(self._executor_joined)
|
|
271
|
+
self._join_task = join_task
|
|
272
|
+
|
|
273
|
+
if checked_deadline is None:
|
|
274
|
+
await asyncio.shield(join_task)
|
|
275
|
+
else:
|
|
276
|
+
remaining = checked_deadline - loop.time()
|
|
277
|
+
if remaining <= 0:
|
|
278
|
+
if not join_task.done():
|
|
279
|
+
raise BlockingIOShutdownTimeout(deadline=checked_deadline)
|
|
280
|
+
else:
|
|
281
|
+
done, _ = await asyncio.wait((join_task,), timeout=remaining)
|
|
282
|
+
if not done:
|
|
283
|
+
raise BlockingIOShutdownTimeout(deadline=checked_deadline)
|
|
284
|
+
|
|
285
|
+
# The callback consumes and records exceptions for deadline/cancelled
|
|
286
|
+
# callers; result() here preserves normal close failure propagation.
|
|
287
|
+
join_task.result()
|
|
288
|
+
|
|
289
|
+
def _bind_to_running_loop(
|
|
290
|
+
self,
|
|
291
|
+
) -> tuple[asyncio.AbstractEventLoop, asyncio.Event]:
|
|
292
|
+
loop = asyncio.get_running_loop()
|
|
293
|
+
with self._state_lock:
|
|
294
|
+
if self._loop is None:
|
|
295
|
+
self._loop = loop
|
|
296
|
+
self._completion_event = asyncio.Event()
|
|
297
|
+
elif self._loop is not loop:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
"BoundedBlockingIOExecutor cannot be used across event loops"
|
|
300
|
+
)
|
|
301
|
+
completion_event = self._completion_event
|
|
302
|
+
assert completion_event is not None
|
|
303
|
+
return loop, completion_event
|
|
304
|
+
|
|
305
|
+
def _operation_completed(self, future: Future[object]) -> None:
|
|
306
|
+
with self._state_lock:
|
|
307
|
+
self._tracked.discard(future)
|
|
308
|
+
loop = self._loop
|
|
309
|
+
completion_event = self._completion_event
|
|
310
|
+
if loop is None or completion_event is None:
|
|
311
|
+
return
|
|
312
|
+
try:
|
|
313
|
+
loop.call_soon_threadsafe(completion_event.set)
|
|
314
|
+
except RuntimeError:
|
|
315
|
+
# The service is expected to drain before closing its event loop.
|
|
316
|
+
# Accounting is already correct even if a broken shutdown cannot
|
|
317
|
+
# be woken after the loop itself has disappeared.
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
def _start_executor_shutdown(self) -> None:
|
|
321
|
+
with self._state_lock:
|
|
322
|
+
if self._shutdown_started:
|
|
323
|
+
return
|
|
324
|
+
self._shutdown_started = True
|
|
325
|
+
# wait=False never cancels queued work and returns immediately. A
|
|
326
|
+
# later off-loop wait=True call performs the clean thread join.
|
|
327
|
+
self._executor.shutdown(wait=False, cancel_futures=False)
|
|
328
|
+
|
|
329
|
+
def _executor_joined(self, task: asyncio.Task[None]) -> None:
|
|
330
|
+
try:
|
|
331
|
+
task.result()
|
|
332
|
+
except BaseException as error:
|
|
333
|
+
self._join_error = error
|
|
334
|
+
else:
|
|
335
|
+
self._joined = True
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _validate_deadline(deadline: float | None) -> float | None:
|
|
339
|
+
if deadline is None:
|
|
340
|
+
return None
|
|
341
|
+
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
|
|
342
|
+
raise TypeError("deadline must be a finite monotonic timestamp")
|
|
343
|
+
checked = float(deadline)
|
|
344
|
+
if not math.isfinite(checked):
|
|
345
|
+
raise ValueError("deadline must be a finite monotonic timestamp")
|
|
346
|
+
return checked
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _consume_asyncio_future_exception(future: asyncio.Future[object]) -> None:
|
|
350
|
+
if future.cancelled():
|
|
351
|
+
return
|
|
352
|
+
future.exception()
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Best-effort Feishu chat labels for management inventory pages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_POSITIVE_TTL_SECONDS = 10 * 60.0
|
|
14
|
+
_NEGATIVE_TTL_SECONDS = 30.0
|
|
15
|
+
_CACHE_CAPACITY = 4_096
|
|
16
|
+
_LOOKUP_CONCURRENCY = 10
|
|
17
|
+
_LOOKUP_BUDGET_SECONDS = 2.5
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ChatLabelProvider(Protocol):
|
|
21
|
+
async def get_chat_info(self, chat_id: str) -> Any: ...
|
|
22
|
+
|
|
23
|
+
async def get_chat_members(
|
|
24
|
+
self,
|
|
25
|
+
chat_id: str,
|
|
26
|
+
*,
|
|
27
|
+
page_size: int = 100,
|
|
28
|
+
max_pages: int = 10,
|
|
29
|
+
id_type: str = "open_id",
|
|
30
|
+
force: bool = False,
|
|
31
|
+
) -> Sequence[Any]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ChatLabel:
|
|
36
|
+
chat_id: str
|
|
37
|
+
display_name: str
|
|
38
|
+
chat_mode: str | None
|
|
39
|
+
resolved: bool = True
|
|
40
|
+
p2p_target_open_id: str | None = None
|
|
41
|
+
chat_type: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class _CacheEntry:
|
|
46
|
+
label: ChatLabel
|
|
47
|
+
expires_at: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ChatLabelResolver:
|
|
51
|
+
"""Resolve only requested chat IDs with bounded TTL/LRU caching."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
provider: ChatLabelProvider,
|
|
56
|
+
*,
|
|
57
|
+
clock: Callable[[], float] = time.monotonic,
|
|
58
|
+
positive_ttl_seconds: float = _POSITIVE_TTL_SECONDS,
|
|
59
|
+
negative_ttl_seconds: float = _NEGATIVE_TTL_SECONDS,
|
|
60
|
+
capacity: int = _CACHE_CAPACITY,
|
|
61
|
+
concurrency: int = _LOOKUP_CONCURRENCY,
|
|
62
|
+
lookup_budget_seconds: float = _LOOKUP_BUDGET_SECONDS,
|
|
63
|
+
) -> None:
|
|
64
|
+
if positive_ttl_seconds <= 0 or negative_ttl_seconds <= 0:
|
|
65
|
+
raise ValueError("chat label TTLs must be positive")
|
|
66
|
+
if capacity < 1:
|
|
67
|
+
raise ValueError("chat label cache capacity must be positive")
|
|
68
|
+
if concurrency < 1:
|
|
69
|
+
raise ValueError("chat label lookup concurrency must be positive")
|
|
70
|
+
if lookup_budget_seconds <= 0:
|
|
71
|
+
raise ValueError("chat label lookup budget must be positive")
|
|
72
|
+
self._provider = provider
|
|
73
|
+
self._clock = clock
|
|
74
|
+
self._positive_ttl_seconds = positive_ttl_seconds
|
|
75
|
+
self._negative_ttl_seconds = negative_ttl_seconds
|
|
76
|
+
self._capacity = capacity
|
|
77
|
+
self._semaphore = asyncio.Semaphore(concurrency)
|
|
78
|
+
self._lookup_budget_seconds = lookup_budget_seconds
|
|
79
|
+
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
|
80
|
+
self._inflight: dict[str, asyncio.Task[ChatLabel]] = {}
|
|
81
|
+
|
|
82
|
+
async def resolve_many(
|
|
83
|
+
self,
|
|
84
|
+
chat_ids: Iterable[str],
|
|
85
|
+
*,
|
|
86
|
+
deadline: float,
|
|
87
|
+
) -> dict[str, ChatLabel]:
|
|
88
|
+
unique = tuple(dict.fromkeys(chat_ids))
|
|
89
|
+
labels: dict[str, ChatLabel] = {}
|
|
90
|
+
tasks: dict[str, asyncio.Task[ChatLabel]] = {}
|
|
91
|
+
loop = asyncio.get_running_loop()
|
|
92
|
+
lookup_deadline = min(deadline, loop.time() + self._lookup_budget_seconds)
|
|
93
|
+
|
|
94
|
+
for chat_id in unique:
|
|
95
|
+
cached = self._get_cached(chat_id)
|
|
96
|
+
if cached is not None:
|
|
97
|
+
labels[chat_id] = cached
|
|
98
|
+
continue
|
|
99
|
+
task = self._inflight.get(chat_id)
|
|
100
|
+
if task is None:
|
|
101
|
+
task = asyncio.create_task(
|
|
102
|
+
self._resolve_one(chat_id, deadline=lookup_deadline)
|
|
103
|
+
)
|
|
104
|
+
self._inflight[chat_id] = task
|
|
105
|
+
task.add_done_callback(
|
|
106
|
+
lambda completed, resolved_id=chat_id: self._finish(
|
|
107
|
+
resolved_id,
|
|
108
|
+
completed,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
tasks[chat_id] = task
|
|
112
|
+
|
|
113
|
+
if tasks:
|
|
114
|
+
timeout = max(0.0, lookup_deadline - loop.time())
|
|
115
|
+
await asyncio.wait(set(tasks.values()), timeout=timeout)
|
|
116
|
+
for chat_id, task in tasks.items():
|
|
117
|
+
if not task.done() or task.cancelled():
|
|
118
|
+
labels[chat_id] = self.fallback(chat_id)
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
labels[chat_id] = task.result()
|
|
122
|
+
except Exception:
|
|
123
|
+
labels[chat_id] = self.fallback(chat_id)
|
|
124
|
+
|
|
125
|
+
return labels
|
|
126
|
+
|
|
127
|
+
async def aclose(self) -> None:
|
|
128
|
+
tasks = tuple(self._inflight.values())
|
|
129
|
+
for task in tasks:
|
|
130
|
+
task.cancel()
|
|
131
|
+
if tasks:
|
|
132
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
133
|
+
self._inflight.clear()
|
|
134
|
+
self._cache.clear()
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def fallback(
|
|
138
|
+
chat_id: str,
|
|
139
|
+
*,
|
|
140
|
+
chat_mode: str | None = None,
|
|
141
|
+
chat_type: str | None = None,
|
|
142
|
+
p2p_target_open_id: str | None = None,
|
|
143
|
+
) -> ChatLabel:
|
|
144
|
+
return ChatLabel(
|
|
145
|
+
chat_id=chat_id,
|
|
146
|
+
display_name=chat_id,
|
|
147
|
+
chat_mode=chat_mode,
|
|
148
|
+
resolved=False,
|
|
149
|
+
p2p_target_open_id=p2p_target_open_id,
|
|
150
|
+
chat_type=chat_type,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _get_cached(self, chat_id: str) -> ChatLabel | None:
|
|
154
|
+
entry = self._cache.get(chat_id)
|
|
155
|
+
if entry is None:
|
|
156
|
+
return None
|
|
157
|
+
if entry.expires_at <= self._clock():
|
|
158
|
+
del self._cache[chat_id]
|
|
159
|
+
return None
|
|
160
|
+
self._cache.move_to_end(chat_id)
|
|
161
|
+
return entry.label
|
|
162
|
+
|
|
163
|
+
def _finish(self, chat_id: str, task: asyncio.Task[ChatLabel]) -> None:
|
|
164
|
+
if self._inflight.get(chat_id) is task:
|
|
165
|
+
del self._inflight[chat_id]
|
|
166
|
+
if task.cancelled():
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
label = task.result()
|
|
170
|
+
except Exception:
|
|
171
|
+
label = self.fallback(chat_id)
|
|
172
|
+
ttl = (
|
|
173
|
+
self._positive_ttl_seconds
|
|
174
|
+
if label.resolved
|
|
175
|
+
else self._negative_ttl_seconds
|
|
176
|
+
)
|
|
177
|
+
self._cache[chat_id] = _CacheEntry(label, self._clock() + ttl)
|
|
178
|
+
self._cache.move_to_end(chat_id)
|
|
179
|
+
while len(self._cache) > self._capacity:
|
|
180
|
+
self._cache.popitem(last=False)
|
|
181
|
+
|
|
182
|
+
async def _resolve_one(self, chat_id: str, *, deadline: float) -> ChatLabel:
|
|
183
|
+
try:
|
|
184
|
+
async with self._semaphore:
|
|
185
|
+
info = await self._before_deadline(
|
|
186
|
+
self._provider.get_chat_info(chat_id),
|
|
187
|
+
deadline=deadline,
|
|
188
|
+
)
|
|
189
|
+
if info is None:
|
|
190
|
+
return self.fallback(chat_id)
|
|
191
|
+
name = self._text(getattr(info, "name", None))
|
|
192
|
+
chat_mode = self._text(getattr(info, "chat_mode", None))
|
|
193
|
+
chat_type = self._text(getattr(info, "chat_type", None))
|
|
194
|
+
p2p_target_open_id = None
|
|
195
|
+
if chat_mode == "p2p":
|
|
196
|
+
try:
|
|
197
|
+
members = await self._before_deadline(
|
|
198
|
+
self._provider.get_chat_members(
|
|
199
|
+
chat_id,
|
|
200
|
+
page_size=100,
|
|
201
|
+
max_pages=1,
|
|
202
|
+
id_type="open_id",
|
|
203
|
+
),
|
|
204
|
+
deadline=deadline,
|
|
205
|
+
)
|
|
206
|
+
except asyncio.CancelledError:
|
|
207
|
+
raise
|
|
208
|
+
except Exception:
|
|
209
|
+
if not name:
|
|
210
|
+
return self.fallback(
|
|
211
|
+
chat_id,
|
|
212
|
+
chat_mode=chat_mode,
|
|
213
|
+
chat_type=chat_type,
|
|
214
|
+
)
|
|
215
|
+
members = ()
|
|
216
|
+
member_name, p2p_target_open_id = self._p2p_member(members)
|
|
217
|
+
name = member_name or name
|
|
218
|
+
if not name:
|
|
219
|
+
return self.fallback(
|
|
220
|
+
chat_id,
|
|
221
|
+
chat_mode=chat_mode,
|
|
222
|
+
chat_type=chat_type,
|
|
223
|
+
p2p_target_open_id=p2p_target_open_id,
|
|
224
|
+
)
|
|
225
|
+
return ChatLabel(
|
|
226
|
+
chat_id=chat_id,
|
|
227
|
+
display_name=name,
|
|
228
|
+
chat_mode=chat_mode,
|
|
229
|
+
resolved=True,
|
|
230
|
+
p2p_target_open_id=p2p_target_open_id,
|
|
231
|
+
chat_type=chat_type,
|
|
232
|
+
)
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
raise
|
|
235
|
+
except TimeoutError:
|
|
236
|
+
return self.fallback(chat_id)
|
|
237
|
+
except Exception:
|
|
238
|
+
return self.fallback(chat_id)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
async def _before_deadline(awaitable: Any, *, deadline: float) -> Any:
|
|
242
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
243
|
+
if remaining <= 0:
|
|
244
|
+
if hasattr(awaitable, "close"):
|
|
245
|
+
awaitable.close()
|
|
246
|
+
raise TimeoutError("chat label lookup deadline elapsed")
|
|
247
|
+
return await asyncio.wait_for(awaitable, timeout=remaining)
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def _p2p_member(cls, members: Sequence[Any]) -> tuple[str, str | None]:
|
|
251
|
+
candidates: list[tuple[str, str | None]] = []
|
|
252
|
+
for member in members:
|
|
253
|
+
name = cls._text(getattr(member, "name", None))
|
|
254
|
+
member_id = cls._text(getattr(member, "id", None))
|
|
255
|
+
id_type = cls._text(getattr(member, "id_type", None))
|
|
256
|
+
if name:
|
|
257
|
+
candidates.append(
|
|
258
|
+
(name, member_id if id_type == "open_id" else None)
|
|
259
|
+
)
|
|
260
|
+
if len(candidates) != 1:
|
|
261
|
+
return "", None
|
|
262
|
+
return candidates[0]
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _text(value: Any) -> str:
|
|
266
|
+
return value.strip() if isinstance(value, str) else ""
|