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.
Files changed (122) hide show
  1. langgraph_api/__init__.py +1 -1
  2. langgraph_api/api/__init__.py +93 -27
  3. langgraph_api/api/a2a.py +36 -32
  4. langgraph_api/api/assistants.py +114 -26
  5. langgraph_api/api/mcp.py +3 -3
  6. langgraph_api/api/meta.py +15 -2
  7. langgraph_api/api/openapi.py +27 -17
  8. langgraph_api/api/profile.py +108 -0
  9. langgraph_api/api/runs.py +114 -57
  10. langgraph_api/api/store.py +19 -2
  11. langgraph_api/api/threads.py +133 -10
  12. langgraph_api/asgi_transport.py +14 -9
  13. langgraph_api/auth/custom.py +23 -13
  14. langgraph_api/cli.py +86 -41
  15. langgraph_api/command.py +2 -2
  16. langgraph_api/config/__init__.py +532 -0
  17. langgraph_api/config/_parse.py +58 -0
  18. langgraph_api/config/schemas.py +431 -0
  19. langgraph_api/cron_scheduler.py +17 -1
  20. langgraph_api/encryption/__init__.py +15 -0
  21. langgraph_api/encryption/aes_json.py +158 -0
  22. langgraph_api/encryption/context.py +35 -0
  23. langgraph_api/encryption/custom.py +280 -0
  24. langgraph_api/encryption/middleware.py +632 -0
  25. langgraph_api/encryption/shared.py +63 -0
  26. langgraph_api/errors.py +12 -1
  27. langgraph_api/executor_entrypoint.py +11 -6
  28. langgraph_api/feature_flags.py +19 -0
  29. langgraph_api/graph.py +163 -64
  30. langgraph_api/{grpc_ops → grpc}/client.py +142 -12
  31. langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
  32. langgraph_api/grpc/generated/__init__.py +29 -0
  33. langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
  34. langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
  35. langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
  36. langgraph_api/grpc/generated/core_api_pb2.py +216 -0
  37. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
  38. langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
  39. langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
  40. langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
  41. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
  42. langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
  43. langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
  44. langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
  45. langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
  46. langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
  47. langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
  48. langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
  49. langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
  50. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
  51. langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
  52. langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
  53. langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
  54. langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
  55. langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
  56. langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
  57. langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
  58. langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
  59. langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
  60. langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
  61. langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
  62. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
  63. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
  64. langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
  65. langgraph_api/grpc/generated/errors_pb2.py +39 -0
  66. langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
  67. langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
  68. langgraph_api/grpc/ops/__init__.py +370 -0
  69. langgraph_api/grpc/ops/assistants.py +424 -0
  70. langgraph_api/grpc/ops/runs.py +792 -0
  71. langgraph_api/grpc/ops/threads.py +1013 -0
  72. langgraph_api/http.py +16 -5
  73. langgraph_api/js/client.mts +1 -4
  74. langgraph_api/js/package.json +28 -27
  75. langgraph_api/js/remote.py +39 -17
  76. langgraph_api/js/sse.py +2 -2
  77. langgraph_api/js/ui.py +1 -1
  78. langgraph_api/js/yarn.lock +1139 -869
  79. langgraph_api/metadata.py +29 -3
  80. langgraph_api/middleware/http_logger.py +1 -1
  81. langgraph_api/middleware/private_network.py +7 -7
  82. langgraph_api/models/run.py +44 -26
  83. langgraph_api/otel_context.py +205 -0
  84. langgraph_api/patch.py +2 -2
  85. langgraph_api/queue_entrypoint.py +34 -35
  86. langgraph_api/route.py +33 -1
  87. langgraph_api/schema.py +84 -9
  88. langgraph_api/self_hosted_logs.py +2 -2
  89. langgraph_api/self_hosted_metrics.py +73 -3
  90. langgraph_api/serde.py +16 -4
  91. langgraph_api/server.py +33 -31
  92. langgraph_api/state.py +3 -2
  93. langgraph_api/store.py +25 -16
  94. langgraph_api/stream.py +20 -16
  95. langgraph_api/thread_ttl.py +28 -13
  96. langgraph_api/timing/__init__.py +25 -0
  97. langgraph_api/timing/profiler.py +200 -0
  98. langgraph_api/timing/timer.py +318 -0
  99. langgraph_api/utils/__init__.py +53 -8
  100. langgraph_api/utils/config.py +2 -1
  101. langgraph_api/utils/future.py +10 -6
  102. langgraph_api/utils/uuids.py +29 -62
  103. langgraph_api/validation.py +6 -0
  104. langgraph_api/webhook.py +120 -6
  105. langgraph_api/worker.py +54 -24
  106. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
  107. langgraph_api-0.7.3.dist-info/RECORD +168 -0
  108. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
  109. langgraph_runtime/__init__.py +1 -0
  110. langgraph_runtime/routes.py +11 -0
  111. logging.json +1 -3
  112. openapi.json +635 -537
  113. langgraph_api/config.py +0 -523
  114. langgraph_api/grpc_ops/generated/__init__.py +0 -5
  115. langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
  116. langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
  117. langgraph_api/grpc_ops/ops.py +0 -1045
  118. langgraph_api-0.5.4.dist-info/RECORD +0 -121
  119. /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
  120. /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
  121. {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
  122. {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 USE_DURABILITY, USE_RUNTIME_CONTEXT_API
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 f"values|{'|'.join(ns)}", chunk
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 ---
@@ -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 delete threads based on TTL configuration.
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
- Currently implements the 'delete' strategy, which deletes entire threads
18
- that have been inactive for longer than their configured TTL period.
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
- strategy = thread_ttl_config.get("strategy", "delete")
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
- f"Starting thread TTL sweeper with interval {sweep_interval_minutes} minutes",
32
- strategy=strategy,
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
- f"Thread TTL sweep completed. Processed {threads_processed}",
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
+ ]