langgraph-api 0.5.4__py3-none-any.whl → 0.7.3__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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +93 -27
- langgraph_api/api/a2a.py +36 -32
- langgraph_api/api/assistants.py +114 -26
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +15 -2
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +114 -57
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +133 -10
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/auth/custom.py +23 -13
- langgraph_api/cli.py +86 -41
- langgraph_api/command.py +2 -2
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +19 -0
- langgraph_api/graph.py +163 -64
- langgraph_api/{grpc_ops → grpc}/client.py +142 -12
- langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/js/client.mts +1 -4
- langgraph_api/js/package.json +28 -27
- langgraph_api/js/remote.py +39 -17
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1139 -869
- langgraph_api/metadata.py +29 -3
- langgraph_api/middleware/http_logger.py +1 -1
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +44 -26
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +2 -2
- langgraph_api/queue_entrypoint.py +34 -35
- langgraph_api/route.py +33 -1
- langgraph_api/schema.py +84 -9
- langgraph_api/self_hosted_logs.py +2 -2
- langgraph_api/self_hosted_metrics.py +73 -3
- langgraph_api/serde.py +16 -4
- langgraph_api/server.py +33 -31
- langgraph_api/state.py +3 -2
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +20 -16
- langgraph_api/thread_ttl.py +28 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +6 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +54 -24
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +635 -537
- langgraph_api/config.py +0 -523
- langgraph_api/grpc_ops/generated/__init__.py +0 -5
- langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
- langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
- langgraph_api/grpc_ops/ops.py +0 -1045
- langgraph_api-0.5.4.dist-info/RECORD +0 -121
- /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
- /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/stream.py
CHANGED
|
@@ -2,7 +2,7 @@ import uuid
|
|
|
2
2
|
from collections.abc import AsyncIterator, Callable
|
|
3
3
|
from contextlib import AsyncExitStack, aclosing, asynccontextmanager
|
|
4
4
|
from functools import lru_cache
|
|
5
|
-
from typing import Any, cast
|
|
5
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
6
6
|
|
|
7
7
|
import langgraph.version
|
|
8
8
|
import langsmith
|
|
@@ -16,7 +16,6 @@ from langchain_core.messages import (
|
|
|
16
16
|
convert_to_messages,
|
|
17
17
|
message_chunk_to_message,
|
|
18
18
|
)
|
|
19
|
-
from langchain_core.runnables import RunnableConfig
|
|
20
19
|
from langgraph.errors import (
|
|
21
20
|
EmptyChannelError,
|
|
22
21
|
EmptyInputError,
|
|
@@ -32,7 +31,11 @@ from langgraph_api import __version__
|
|
|
32
31
|
from langgraph_api import store as api_store
|
|
33
32
|
from langgraph_api.asyncio import ValueEvent, wait_if_not_done
|
|
34
33
|
from langgraph_api.command import map_cmd
|
|
35
|
-
from langgraph_api.feature_flags import
|
|
34
|
+
from langgraph_api.feature_flags import (
|
|
35
|
+
UPDATES_NEEDED_FOR_INTERRUPTS,
|
|
36
|
+
USE_DURABILITY,
|
|
37
|
+
USE_RUNTIME_CONTEXT_API,
|
|
38
|
+
)
|
|
36
39
|
from langgraph_api.graph import get_graph
|
|
37
40
|
from langgraph_api.js.base import BaseRemotePregel
|
|
38
41
|
from langgraph_api.metadata import HOST, PLAN, USER_API_URL, incr_nodes
|
|
@@ -42,6 +45,10 @@ from langgraph_api.utils.config import run_in_executor
|
|
|
42
45
|
from langgraph_runtime.checkpoint import Checkpointer
|
|
43
46
|
from langgraph_runtime.ops import Runs
|
|
44
47
|
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from langchain_core.runnables import RunnableConfig
|
|
50
|
+
|
|
51
|
+
|
|
45
52
|
logger = structlog.stdlib.get_logger(__name__)
|
|
46
53
|
|
|
47
54
|
|
|
@@ -147,7 +154,7 @@ async def astream_state(
|
|
|
147
154
|
subgraphs = kwargs.get("subgraphs", False)
|
|
148
155
|
temporary = kwargs.pop("temporary", False)
|
|
149
156
|
context = kwargs.pop("context", None)
|
|
150
|
-
config = cast(RunnableConfig, kwargs.pop("config"))
|
|
157
|
+
config = cast("RunnableConfig", kwargs.pop("config"))
|
|
151
158
|
configurable = config["configurable"]
|
|
152
159
|
stack = AsyncExitStack()
|
|
153
160
|
graph = await stack.enter_async_context(
|
|
@@ -156,6 +163,7 @@ async def astream_state(
|
|
|
156
163
|
config,
|
|
157
164
|
store=(await api_store.get_store()),
|
|
158
165
|
checkpointer=None if temporary else Checkpointer(),
|
|
166
|
+
is_for_execution=True,
|
|
159
167
|
)
|
|
160
168
|
)
|
|
161
169
|
|
|
@@ -180,7 +188,7 @@ async def astream_state(
|
|
|
180
188
|
if "messages-tuple" in stream_modes_set and not isinstance(graph, BaseRemotePregel):
|
|
181
189
|
stream_modes_set.remove("messages-tuple")
|
|
182
190
|
stream_modes_set.add("messages")
|
|
183
|
-
if "updates" not in stream_modes_set:
|
|
191
|
+
if "updates" not in stream_modes_set and UPDATES_NEEDED_FOR_INTERRUPTS:
|
|
184
192
|
stream_modes_set.add("updates")
|
|
185
193
|
only_interrupt_updates = True
|
|
186
194
|
else:
|
|
@@ -250,7 +258,7 @@ async def astream_state(
|
|
|
250
258
|
event = await wait_if_not_done(anext(stream, sentinel), done)
|
|
251
259
|
if event is sentinel:
|
|
252
260
|
break
|
|
253
|
-
event = cast(dict, event)
|
|
261
|
+
event = cast("dict", event)
|
|
254
262
|
if event.get("tags") and "langsmith:hidden" in event["tags"]:
|
|
255
263
|
continue
|
|
256
264
|
if (
|
|
@@ -288,7 +296,7 @@ async def astream_state(
|
|
|
288
296
|
yield "messages", chunk
|
|
289
297
|
else:
|
|
290
298
|
msg_, meta = cast(
|
|
291
|
-
tuple[BaseMessage | dict, dict[str, Any]], chunk
|
|
299
|
+
"tuple[BaseMessage | dict, dict[str, Any]]", chunk
|
|
292
300
|
)
|
|
293
301
|
is_chunk = False
|
|
294
302
|
if isinstance(msg_, dict):
|
|
@@ -338,11 +346,9 @@ async def astream_state(
|
|
|
338
346
|
and len(chunk["__interrupt__"]) > 0
|
|
339
347
|
and only_interrupt_updates
|
|
340
348
|
):
|
|
341
|
-
# We always want to return interrupt events by default.
|
|
342
|
-
# If updates aren't specified as a stream mode, we return these as values events.
|
|
343
349
|
# If the interrupt doesn't have any actions (e.g. interrupt before or after a node is specified), we don't return the interrupt at all today.
|
|
344
350
|
if subgraphs and ns:
|
|
345
|
-
yield
|
|
351
|
+
yield "values|{'|'.join(ns)}", chunk
|
|
346
352
|
else:
|
|
347
353
|
yield "values", chunk
|
|
348
354
|
# --- end shared logic with astream ---
|
|
@@ -370,9 +376,9 @@ async def astream_state(
|
|
|
370
376
|
if event is sentinel:
|
|
371
377
|
break
|
|
372
378
|
if subgraphs:
|
|
373
|
-
ns, mode, chunk = cast(tuple[str, str, dict[str, Any]], event)
|
|
379
|
+
ns, mode, chunk = cast("tuple[str, str, dict[str, Any]]", event)
|
|
374
380
|
else:
|
|
375
|
-
mode, chunk = cast(tuple[str, dict[str, Any]], event)
|
|
381
|
+
mode, chunk = cast("tuple[str, dict[str, Any]]", event)
|
|
376
382
|
ns = None
|
|
377
383
|
# --- begin shared logic with astream_events ---
|
|
378
384
|
if mode == "debug":
|
|
@@ -390,7 +396,7 @@ async def astream_state(
|
|
|
390
396
|
yield "messages", chunk
|
|
391
397
|
else:
|
|
392
398
|
msg_, meta = cast(
|
|
393
|
-
tuple[BaseMessage | dict, dict[str, Any]], chunk
|
|
399
|
+
"tuple[BaseMessage | dict, dict[str, Any]]", chunk
|
|
394
400
|
)
|
|
395
401
|
is_chunk = False
|
|
396
402
|
if isinstance(msg_, dict):
|
|
@@ -440,11 +446,9 @@ async def astream_state(
|
|
|
440
446
|
and len(chunk["__interrupt__"]) > 0
|
|
441
447
|
and only_interrupt_updates
|
|
442
448
|
):
|
|
443
|
-
# We always want to return interrupt events by default.
|
|
444
|
-
# If updates aren't specified as a stream mode, we return these as values events.
|
|
445
449
|
# If the interrupt doesn't have any actions (e.g. interrupt before or after a node is specified), we don't return the interrupt at all today.
|
|
446
450
|
if subgraphs and ns:
|
|
447
|
-
yield "values|{'|'.join(ns)}", chunk
|
|
451
|
+
yield f"values|{'|'.join(ns)}", chunk
|
|
448
452
|
else:
|
|
449
453
|
yield "values", chunk
|
|
450
454
|
# --- end shared logic with astream_events ---
|
langgraph_api/thread_ttl.py
CHANGED
|
@@ -6,32 +6,47 @@ from typing import cast
|
|
|
6
6
|
import structlog
|
|
7
7
|
|
|
8
8
|
from langgraph_api.config import THREAD_TTL
|
|
9
|
+
from langgraph_api.feature_flags import FF_USE_CORE_API
|
|
9
10
|
from langgraph_runtime.database import connect
|
|
10
11
|
|
|
11
12
|
logger = structlog.stdlib.get_logger(__name__)
|
|
12
13
|
|
|
14
|
+
# Supported TTL strategies
|
|
15
|
+
SUPPORTED_STRATEGIES = {"delete", "keep_latest"}
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
async def thread_ttl_sweep_loop():
|
|
15
|
-
"""Periodically
|
|
19
|
+
"""Periodically sweep threads based on TTL configuration.
|
|
20
|
+
|
|
21
|
+
Supported strategies:
|
|
22
|
+
- 'delete': Remove the thread and all its data entirely
|
|
23
|
+
- 'keep_latest': Prune old checkpoints but keep thread and latest state
|
|
24
|
+
(requires FF_USE_CORE_API=true)
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
Per-thread TTL strategies are stored in the thread_ttl table and can vary
|
|
27
|
+
by thread. This loop processes all expired threads regardless of strategy.
|
|
19
28
|
"""
|
|
20
|
-
# Use the same interval as store TTL sweep
|
|
21
29
|
thread_ttl_config = THREAD_TTL or {}
|
|
22
|
-
|
|
23
|
-
if strategy != "delete":
|
|
24
|
-
raise NotImplementedError(
|
|
25
|
-
f"Unrecognized thread deletion strategy: {strategy}. Expected 'delete'."
|
|
26
|
-
)
|
|
30
|
+
default_strategy = thread_ttl_config.get("strategy", "delete")
|
|
27
31
|
sweep_interval_minutes = cast(
|
|
28
|
-
int, thread_ttl_config.get("sweep_interval_minutes", 5)
|
|
32
|
+
"int", thread_ttl_config.get("sweep_interval_minutes", 5)
|
|
29
33
|
)
|
|
34
|
+
sweep_limit = thread_ttl_config.get("sweep_limit", 1000)
|
|
35
|
+
|
|
30
36
|
await logger.ainfo(
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
"Starting thread TTL sweeper",
|
|
38
|
+
default_strategy=default_strategy,
|
|
33
39
|
interval_minutes=sweep_interval_minutes,
|
|
40
|
+
sweep_limit=sweep_limit,
|
|
41
|
+
core_api_enabled=FF_USE_CORE_API,
|
|
34
42
|
)
|
|
43
|
+
|
|
44
|
+
if default_strategy == "keep_latest" and not FF_USE_CORE_API:
|
|
45
|
+
await logger.awarning(
|
|
46
|
+
"keep_latest strategy configured but FF_USE_CORE_API is not enabled. "
|
|
47
|
+
"Threads with keep_latest strategy will be skipped during sweep."
|
|
48
|
+
)
|
|
49
|
+
|
|
35
50
|
loop = asyncio.get_running_loop()
|
|
36
51
|
|
|
37
52
|
from langgraph_runtime.ops import Threads
|
|
@@ -44,7 +59,7 @@ async def thread_ttl_sweep_loop():
|
|
|
44
59
|
threads_processed, threads_deleted = await Threads.sweep_ttl(conn)
|
|
45
60
|
if threads_processed > 0:
|
|
46
61
|
await logger.ainfo(
|
|
47
|
-
|
|
62
|
+
"Thread TTL sweep completed",
|
|
48
63
|
threads_processed=threads_processed,
|
|
49
64
|
threads_deleted=threads_deleted,
|
|
50
65
|
duration=loop.time() - sweep_start,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Timing utilities for startup profiling and performance monitoring."""
|
|
2
|
+
|
|
3
|
+
from langgraph_api.timing.profiler import (
|
|
4
|
+
profiled_import,
|
|
5
|
+
)
|
|
6
|
+
from langgraph_api.timing.timer import (
|
|
7
|
+
TimerConfig,
|
|
8
|
+
aenter_timed,
|
|
9
|
+
combine_lifespans,
|
|
10
|
+
get_startup_elapsed,
|
|
11
|
+
time_aenter,
|
|
12
|
+
timer,
|
|
13
|
+
wrap_lifespan_context_aenter,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"TimerConfig",
|
|
18
|
+
"aenter_timed",
|
|
19
|
+
"combine_lifespans",
|
|
20
|
+
"get_startup_elapsed",
|
|
21
|
+
"profiled_import",
|
|
22
|
+
"time_aenter",
|
|
23
|
+
"timer",
|
|
24
|
+
"wrap_lifespan_context_aenter",
|
|
25
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Import profiling utilities for diagnosing slow module loads.
|
|
2
|
+
|
|
3
|
+
When FF_PROFILE_IMPORTS is true, this module provides detailed
|
|
4
|
+
profiling of what's slow during module imports - including nested imports
|
|
5
|
+
and module-level code execution (network calls, file I/O, etc.).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import cProfile
|
|
11
|
+
import pstats
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TypedDict, cast
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# Minimum time (in seconds) for an operation to be reported
|
|
21
|
+
MIN_REPORT_THRESHOLD_SECS = 1.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def profiled_import(path: str, top_n: int = 10):
|
|
26
|
+
"""Context manager for profiling an import with automatic reporting.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
with profiled_import("./my_module.py:obj") as profiler:
|
|
30
|
+
module = exec_module(...)
|
|
31
|
+
# Automatically logs slow calls if any
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: The module path (for logging)
|
|
35
|
+
top_n: Maximum number of slow calls to report
|
|
36
|
+
"""
|
|
37
|
+
from langgraph_api import config
|
|
38
|
+
|
|
39
|
+
if not config.FF_PROFILE_IMPORTS:
|
|
40
|
+
yield None
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
start = time.perf_counter()
|
|
44
|
+
pr = cProfile.Profile()
|
|
45
|
+
pr.enable()
|
|
46
|
+
|
|
47
|
+
class ProfilerResult:
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self.slow_calls: list[_SlowCall] = []
|
|
50
|
+
self.total_secs: float = 0.0
|
|
51
|
+
|
|
52
|
+
result = ProfilerResult()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
yield result
|
|
56
|
+
finally:
|
|
57
|
+
pr.disable()
|
|
58
|
+
result.total_secs = time.perf_counter() - start
|
|
59
|
+
|
|
60
|
+
# Extract the module filename from path (e.g., "./foo/bar.py:obj" -> "bar.py")
|
|
61
|
+
module_file = path.split(":")[0].rsplit("/", 1)[-1]
|
|
62
|
+
|
|
63
|
+
stats_obj = pstats.Stats(pr)
|
|
64
|
+
stats = cast("dict", stats_obj.stats) # type: ignore[attr-defined]
|
|
65
|
+
slow_calls: list[_SlowCall] = []
|
|
66
|
+
|
|
67
|
+
for (filename, lineno, funcname), (
|
|
68
|
+
_cc,
|
|
69
|
+
nc,
|
|
70
|
+
_tt,
|
|
71
|
+
ct,
|
|
72
|
+
callers,
|
|
73
|
+
) in stats.items():
|
|
74
|
+
cumtime_secs = ct
|
|
75
|
+
if cumtime_secs >= MIN_REPORT_THRESHOLD_SECS:
|
|
76
|
+
# Skip non-actionable entries
|
|
77
|
+
if "cProfile" in filename or "<frozen" in filename:
|
|
78
|
+
continue
|
|
79
|
+
# Skip built-in exec (just wrapper around module execution)
|
|
80
|
+
if filename == "~" and "builtins.exec" in funcname:
|
|
81
|
+
continue
|
|
82
|
+
# Skip the top-level <module> entry (not actionable)
|
|
83
|
+
if funcname == "<module>":
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
# Find call site in user's module
|
|
87
|
+
call_site = _find_user_call_site(callers, module_file, stats)
|
|
88
|
+
|
|
89
|
+
slow_calls.append(
|
|
90
|
+
_SlowCall(
|
|
91
|
+
function=funcname,
|
|
92
|
+
file=f"{filename}:{lineno}",
|
|
93
|
+
cumulative_secs=round(cumtime_secs, 2),
|
|
94
|
+
calls=nc,
|
|
95
|
+
call_site=call_site,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
slow_calls.sort(key=lambda x: x["cumulative_secs"], reverse=True)
|
|
100
|
+
result.slow_calls = slow_calls[:top_n]
|
|
101
|
+
|
|
102
|
+
# Only log if we have slow calls worth reporting
|
|
103
|
+
if result.slow_calls:
|
|
104
|
+
report = _format_slow_calls_report(
|
|
105
|
+
path, result.total_secs, result.slow_calls
|
|
106
|
+
)
|
|
107
|
+
logger.warning(
|
|
108
|
+
f"slow_import_profile: {report}",
|
|
109
|
+
path=path,
|
|
110
|
+
total_secs=round(result.total_secs, 2),
|
|
111
|
+
slow_calls=result.slow_calls,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _find_user_call_site(
|
|
116
|
+
callers: dict, module_file: str, all_stats: dict, max_depth: int = 20
|
|
117
|
+
) -> str | None:
|
|
118
|
+
"""Walk up the call chain to find where in the user's module this was called from."""
|
|
119
|
+
visited: set[tuple] = set()
|
|
120
|
+
to_check = list(callers.keys())
|
|
121
|
+
|
|
122
|
+
for _ in range(max_depth):
|
|
123
|
+
if not to_check:
|
|
124
|
+
break
|
|
125
|
+
caller_key = to_check.pop(0)
|
|
126
|
+
if caller_key in visited:
|
|
127
|
+
continue
|
|
128
|
+
visited.add(caller_key)
|
|
129
|
+
|
|
130
|
+
caller_file, caller_line, caller_func = caller_key
|
|
131
|
+
# Found a call from the user's module
|
|
132
|
+
if caller_file.endswith(module_file):
|
|
133
|
+
# cProfile attributes all module-level code to <module> at line 1,
|
|
134
|
+
# so we can't get the actual line number for top-level calls
|
|
135
|
+
if caller_func == "<module>":
|
|
136
|
+
return f"{module_file} (module-level)"
|
|
137
|
+
return f"{module_file}:{caller_line} in {caller_func}()"
|
|
138
|
+
|
|
139
|
+
# Keep walking up
|
|
140
|
+
if caller_key in all_stats:
|
|
141
|
+
parent_callers = all_stats[caller_key][4] # callers is index 4
|
|
142
|
+
to_check.extend(parent_callers.keys())
|
|
143
|
+
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _SlowCall(TypedDict):
|
|
148
|
+
"""A slow function call detected during import profiling."""
|
|
149
|
+
|
|
150
|
+
function: str
|
|
151
|
+
file: str
|
|
152
|
+
cumulative_secs: float
|
|
153
|
+
calls: int
|
|
154
|
+
call_site: str | None # Where in user's module this was called from
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _format_slow_calls_report(
|
|
158
|
+
path: str,
|
|
159
|
+
total_secs: float,
|
|
160
|
+
slow_calls: list[_SlowCall],
|
|
161
|
+
) -> str:
|
|
162
|
+
"""Format slow calls into a human-readable report."""
|
|
163
|
+
lines = [
|
|
164
|
+
"",
|
|
165
|
+
f"Slow startup for '{path}' ({total_secs:.1f}s)",
|
|
166
|
+
"",
|
|
167
|
+
" Slowest operations:",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
for call in slow_calls:
|
|
171
|
+
secs = call["cumulative_secs"]
|
|
172
|
+
func = call["function"]
|
|
173
|
+
# Show last 2 path components for context (e.g., "requests/sessions.py:500")
|
|
174
|
+
file_path = call["file"]
|
|
175
|
+
parts = file_path.rsplit("/", 2)
|
|
176
|
+
loc = "/".join(parts[-2:]) if len(parts) > 2 else file_path
|
|
177
|
+
|
|
178
|
+
call_site = call.get("call_site")
|
|
179
|
+
if call_site:
|
|
180
|
+
lines.append(f" {secs:>6.2f}s {func:<24} {loc}")
|
|
181
|
+
lines.append(f" ↳ from {call_site}")
|
|
182
|
+
else:
|
|
183
|
+
lines.append(f" {secs:>6.2f}s {func:<24} {loc}")
|
|
184
|
+
|
|
185
|
+
lines.extend(
|
|
186
|
+
[
|
|
187
|
+
"",
|
|
188
|
+
" Slow operations (network calls, file I/O, heavy computation) at",
|
|
189
|
+
" import time delay startup. Consider moving them inside functions",
|
|
190
|
+
" or using lazy initialization.",
|
|
191
|
+
"",
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = [
|
|
199
|
+
"profiled_import",
|
|
200
|
+
]
|