smartcomment 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.
@@ -0,0 +1,971 @@
1
+ """Public API for smartcomment, the ``comment_*`` family of functions."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import inspect
6
+ from pydantic import BaseModel, JsonValue
7
+ from ..runtime import (
8
+ none_full_node_id,
9
+ none_op_id,
10
+ none_session_id,
11
+ current_graph,
12
+ current_context,
13
+ current_op,
14
+ current_session,
15
+ is_tracing_enabled,
16
+ )
17
+ from ..runtime.context import _OP
18
+ from ..runtime.operation import RuntimeEdge, RuntimeOp
19
+ from ..runtime.variable import RuntimeVariable
20
+ from ..schema.operation import OpEdge, OpRecord
21
+ from ..logging import logger
22
+ from ._mutation import _MutationScope
23
+ from ._helpers import (
24
+ _resolve_call_context,
25
+ _ensure_variable,
26
+ _resolve_item,
27
+ )
28
+ from typing import (
29
+ Any,
30
+ Callable,
31
+ Literal,
32
+ TypeVar,
33
+ overload,
34
+ )
35
+
36
+
37
+ T = TypeVar("T")
38
+
39
+
40
+ @overload
41
+ def comment_variable(
42
+ value: T,
43
+ *,
44
+ to_runtime: Literal[True],
45
+ variable_name: str | None = ...,
46
+ encoding_fn: Callable[[T], str] | None = ...,
47
+ decoding_fn: Callable[[str], T] | None = ...,
48
+ schema: type[BaseModel] | None = ...,
49
+ id_strategy: Callable[[T], str] | str | None = ...,
50
+ comment: str | None = ...,
51
+ category: str = ...,
52
+ class_name: str | None = ...,
53
+ auto_class_name: bool = ...,
54
+ identity_only: bool = ...,
55
+ metadata: dict[str, JsonValue] | None = ...,
56
+ ) -> RuntimeVariable[T]: ...
57
+
58
+
59
+ @overload
60
+ def comment_variable(
61
+ value: T,
62
+ *,
63
+ to_runtime: Literal[False] = ...,
64
+ variable_name: str | None = ...,
65
+ encoding_fn: Callable[[T], str] | None = ...,
66
+ decoding_fn: Callable[[str], T] | None = ...,
67
+ schema: type[BaseModel] | None = ...,
68
+ id_strategy: Callable[[T], str] | str | None = ...,
69
+ comment: str | None = ...,
70
+ category: str = ...,
71
+ class_name: str | None = ...,
72
+ auto_class_name: bool = ...,
73
+ identity_only: bool = ...,
74
+ metadata: dict[str, JsonValue] | None = ...,
75
+ ) -> T: ...
76
+
77
+
78
+ def comment_variable(
79
+ value: T,
80
+ *,
81
+ to_runtime: bool = False,
82
+ variable_name: str | None = None,
83
+ encoding_fn: Callable[[T], str] | None = None,
84
+ decoding_fn: Callable[[str], T] | None = None,
85
+ schema: type[BaseModel] | None = None,
86
+ id_strategy: Callable[[T], str] | str | None = None,
87
+ comment: str | None = None,
88
+ category: str = "variable",
89
+ class_name: str | None = None,
90
+ auto_class_name: bool = False,
91
+ identity_only: bool = False,
92
+ metadata: dict[str, JsonValue] | None = None,
93
+ ) -> RuntimeVariable[T] | T:
94
+ """Register a value as a tracked variable in the execution graph.
95
+
96
+ Use this for starting variables (no parent operation) or when you
97
+ need to store the handle in the current tracing context for cross-scope
98
+ access.
99
+
100
+ When no session is active the variable is placed under the reserved
101
+ NONE sentinel session.
102
+
103
+ Args:
104
+ value (`T`):
105
+ The Python value to track.
106
+ to_runtime (`bool`, defaults to `False`):
107
+ When enabled, a runtime variable handle is returned.
108
+ Otherwise, the original variable is returned after it has been
109
+ recorded in the graph.
110
+ variable_name (`str | None`, optional):
111
+ If provided, it registers the handle in the current tracing context
112
+ so the variable can be retrieved by name in inner scopes.
113
+ encoding_fn (`Callable[[T], str] | None`, optional):
114
+ Custom encoder from Python value to string.
115
+ decoding_fn (`Callable[[str], T] | None`, optional):
116
+ Custom decoder from string back to Python value.
117
+ schema (`type[BaseModel] | None`, optional):
118
+ Pydantic schema for auto encoding and decoding.
119
+ id_strategy (`Callable[[T], str] | str | None`, optional):
120
+ An identity strategy. It can be a callable, a registered name.
121
+ If not provided, it will be looked up by type.
122
+ comment (`str | None`, optional):
123
+ A comment for the variable.
124
+ category (`str`, defaults to `"variable"`):
125
+ Variable category.
126
+ class_name (`str | None`, optional):
127
+ Namespace from the Python class of the value.
128
+ auto_class_name (`bool`, defaults to `False`):
129
+ If enabled, it will auto-detect the class name from the value.
130
+ identity_only (`bool`, defaults to `False`):
131
+ When enabled, the snapshot consistency check is bypassed for
132
+ this variable. If a node with the same identity already exists
133
+ in the graph but has a different encoded value, the existing
134
+ node is returned instead of raising an error or creating a new
135
+ version. This is useful when the caller only holds a lightweight
136
+ representation (e.g. an identifier) of a variable that was
137
+ previously recorded with a richer snapshot.
138
+ metadata (`dict[str, JsonValue] | None`, optional):
139
+ Extra metadata for the variable.
140
+
141
+ Returns:
142
+ `RuntimeVariable[T] | T`:
143
+ The runtime handle when ``to_runtime`` is enabled, or the
144
+ original variable otherwise. It always returns the original
145
+ variable when the tracing is disabled.
146
+ """
147
+ if not is_tracing_enabled():
148
+ return value # type: ignore[return-value]
149
+
150
+ if isinstance(value, RuntimeVariable):
151
+ if variable_name is not None:
152
+ ctx = current_context()
153
+ assert ctx is not None, (
154
+ "The current tracing context is not active."
155
+ )
156
+
157
+ logger.debug(
158
+ "`comment_variable` receives a runtime variable handle as the value. "
159
+ "It will be registered in the current tracing context "
160
+ "under the name '{variable_name}'.",
161
+ variable_name=variable_name,
162
+ )
163
+ ctx.register_variable(variable_name, value, overwrite=True)
164
+ return value # type: ignore[return-value]
165
+
166
+ graph, session_id, filename, lineno = _resolve_call_context(
167
+ caller_name="comment_variable",
168
+ )
169
+
170
+ rv = _ensure_variable(
171
+ value,
172
+ id_strategy=id_strategy,
173
+ encoding_fn=encoding_fn,
174
+ decoding_fn=decoding_fn,
175
+ schema=schema,
176
+ comment=comment,
177
+ category=category,
178
+ class_name=class_name,
179
+ auto_class_name=auto_class_name,
180
+ variable_name=variable_name,
181
+ metadata=metadata,
182
+ identity_only=identity_only,
183
+ graph=graph,
184
+ session_id=session_id,
185
+ filename=filename,
186
+ lineno=lineno,
187
+ force_new_version=False,
188
+ )
189
+
190
+ if to_runtime:
191
+ return rv
192
+ return value
193
+
194
+
195
+ def comment_op(
196
+ *,
197
+ inputs: list[Any],
198
+ outputs: list[Any],
199
+ op_name: str | None = None,
200
+ comment: str | None = None,
201
+ category: str = "operation",
202
+ id_strategy: Callable[[Any], str] | str | None = None,
203
+ encoding_fn: Callable[[Any], str] | None = None,
204
+ decoding_fn: Callable[[str], Any] | None = None,
205
+ schema: type[BaseModel] | None = None,
206
+ auto_class_name: bool = False,
207
+ metadata: dict[str, JsonValue] | None = None,
208
+ reuse_op: bool = False,
209
+ ) -> RuntimeOp | None:
210
+ """Record an operation that links input variables to output variables.
211
+
212
+ Each element in ``inputs`` and ``outputs`` is interpreted in one of three
213
+ ways. If the entry is already a runtime variable handle, that handle is
214
+ wired into the graph as given. If it is an ordinary Python value, a
215
+ variable node is created or reused using the same encoding and identity
216
+ rules as ``comment_variable``, using the shared parameters of this call
217
+ (``id_strategy``, ``encoding_fn``, and so on) as defaults. If it is a
218
+ value-option tuple where the first element is the value and the second is
219
+ a dictionary of per-item options, those keys in each option dictionary override
220
+ the shared defaults. For available options for each variable, please
221
+ refer to ``comment_variable``.
222
+
223
+ When ``inputs`` or ``outputs`` is empty, the NONE sentinel is
224
+ auto-injected so that graph edges always exist.
225
+
226
+ Args:
227
+ inputs (`list[Any]`):
228
+ Input items.
229
+ outputs (`list[Any]`):
230
+ Output items.
231
+ op_name (`str | None`, optional):
232
+ Canonical operation name. When not provided, an active
233
+ operation context is required and its operation is reused.
234
+ comment (`str | None`, optional):
235
+ A comment for the operation.
236
+ category (`str`, defaults to `"operation"`):
237
+ Operation category.
238
+ id_strategy (`Callable[[Any], str] | str | None`, optional):
239
+ Shared identity strategy for auto-created variables.
240
+ encoding_fn (`Callable[[Any], str] | None`, optional):
241
+ Shared encoder for auto-created variables.
242
+ decoding_fn (`Callable[[str], Any] | None`, optional):
243
+ Shared decoder for auto-created variables.
244
+ schema (`type[BaseModel] | None`, optional):
245
+ Shared Pydantic schema for auto encoding and decoding.
246
+ auto_class_name (`bool`, defaults to `False`):
247
+ Auto-detect class name for auto-created variables.
248
+ metadata (`dict[str, JsonValue] | None`, optional):
249
+ Extra metadata merged into the operation record and corresponding
250
+ operation edges. It is also used as the default for auto-created variables.
251
+ To attach metadata to a specific variable instead of sharing this
252
+ dictionary, pass that slot as ``(value, {"metadata": ...})``
253
+ inside ``inputs``.
254
+ reuse_op (`bool`, defaults to `False`):
255
+ When enabled and an active operation context exists, edges are
256
+ attributed to the existing operation instead of creating a new
257
+ one. The ``category``, ``comment``, and ``metadata`` are applied
258
+ to the edges only.
259
+
260
+ Returns:
261
+ `RuntimeOp | None`:
262
+ A runtime view of the operation, or ``None`` if tracing is
263
+ disabled.
264
+
265
+ Raises:
266
+ `RuntimeError`:
267
+ If the operation name is not provided and no active operation context
268
+ exists.
269
+ """
270
+ if not is_tracing_enabled():
271
+ return None
272
+
273
+ graph, session_id, filename, lineno = _resolve_call_context(
274
+ caller_name="comment_op",
275
+ )
276
+
277
+ # Resolve the operation before creating any variable nodes so that a
278
+ # failure does not leave orphan nodes in the driver.
279
+ ctx = current_context()
280
+ assert ctx is not None, (
281
+ "The current tracing context is not active."
282
+ )
283
+
284
+ propagated = ctx.metadata
285
+
286
+ should_try_reuse = op_name is None or reuse_op
287
+ active_op = current_op() if should_try_reuse else None
288
+
289
+ if active_op is not None:
290
+ op = active_op
291
+ elif op_name is None:
292
+ raise RuntimeError(
293
+ "`comment_op` requires either an explicit operation name or an "
294
+ "active operation context. When the operation name is not provided, "
295
+ "please wrap the call in `with comment_op_scope(op_name=...) as op:`."
296
+ )
297
+ else:
298
+ op = OpRecord(
299
+ graph_id=graph.graph_id,
300
+ session_id=session_id,
301
+ user_id=graph.user_id,
302
+ project_id=graph.project_id,
303
+ op_name=op_name,
304
+ comment=comment,
305
+ category=category,
306
+ filename=filename,
307
+ lineno=lineno,
308
+ )
309
+ if metadata:
310
+ op.update_metadata(metadata)
311
+ if propagated:
312
+ op.update_metadata(propagated)
313
+ graph._driver.add_operation(op)
314
+
315
+ resolve_kwargs = dict(
316
+ shared_id_strategy=id_strategy,
317
+ shared_encoding_fn=encoding_fn,
318
+ shared_decoding_fn=decoding_fn,
319
+ shared_schema=schema,
320
+ shared_auto_class_name=auto_class_name,
321
+ shared_category="variable",
322
+ shared_metadata=metadata,
323
+ graph=graph,
324
+ session_id=session_id,
325
+ filename=filename,
326
+ lineno=lineno,
327
+ )
328
+
329
+ # Resolve input items first so we can detect self-loops in outputs.
330
+ input_rvs = [_resolve_item(item, **resolve_kwargs) for item in inputs]
331
+ input_node_ids = {rv.full_node_id for rv in input_rvs}
332
+
333
+ # Resolve output items.
334
+ # If an output resolves to the same node as an input,
335
+ # force a new version to prevent self-loop edges.
336
+ output_rvs = []
337
+ for item in outputs:
338
+ # Resolve each output to a runtime variable. When an output would resolve to
339
+ # the same graph node as an already-resolved input (same identity and encoded
340
+ # value), the first resolution returns that existing node.
341
+ rv = _resolve_item(item, **resolve_kwargs)
342
+ if rv.full_node_id in input_node_ids:
343
+ logger.warning(
344
+ "Output collides with an input node whose full name is '{!r}' "
345
+ "in the operation named '{}'. "
346
+ "A new variable version is created and edges are added. This is valid "
347
+ "but non-idiomatic and increases graph complexity.",
348
+ rv.full_name,
349
+ op_name,
350
+ )
351
+
352
+ # We then call `_resolve_item` again with `force_new_version=True`
353
+ # so a new variable version is created, avoiding a self-loop edge
354
+ # from the node to itself while still recording edges from inputs to this output.
355
+ rv = _resolve_item(item, force_new_version=True, **resolve_kwargs)
356
+ output_rvs.append(rv)
357
+
358
+ runtime_op = RuntimeOp(op=op)
359
+
360
+ if not input_rvs and not output_rvs:
361
+ logger.debug(
362
+ "No input or output variables are provided for the operation named '{}'.",
363
+ op_name,
364
+ )
365
+ return runtime_op
366
+
367
+ # NONE sentinel injection.
368
+ if not input_rvs or not output_rvs:
369
+ graph._ensure_none_node()
370
+
371
+ # Build two lists of source and target full node identifiers.
372
+ _none = none_full_node_id()
373
+ source_ids = [rv.full_node_id for rv in input_rvs] if input_rvs else [_none]
374
+ target_ids = [rv.full_node_id for rv in output_rvs] if output_rvs else [_none]
375
+
376
+ # Pre-compute merged metadata for edges.
377
+ edge_metadata = {}
378
+ if metadata:
379
+ edge_metadata.update(metadata)
380
+ if propagated:
381
+ edge_metadata.update(propagated)
382
+
383
+ # Create edges (cartesian product).
384
+ for src in source_ids:
385
+ for tgt in target_ids:
386
+ edge = OpEdge(
387
+ graph_id=graph.graph_id,
388
+ session_id=session_id,
389
+ user_id=graph.user_id,
390
+ project_id=graph.project_id,
391
+ op_id=op.op_id,
392
+ category=category,
393
+ source_full_node_id=src,
394
+ target_full_node_id=tgt,
395
+ comment=comment,
396
+ filename=filename,
397
+ lineno=lineno,
398
+ )
399
+ if edge_metadata:
400
+ edge.update_metadata(edge_metadata)
401
+ graph._driver.add_edge(edge)
402
+
403
+ return runtime_op
404
+
405
+
406
+ def comment_mutation(
407
+ *,
408
+ target: T,
409
+ inputs: list[Any] | None = None,
410
+ comment: str | None = None,
411
+ category: str = "variable",
412
+ class_name: str | None = None,
413
+ auto_class_name: bool = False,
414
+ id_strategy: Callable[[Any], str] | str | None = None,
415
+ encoding_fn: Callable[[Any], str] | None = None,
416
+ decoding_fn: Callable[[str], Any] | None = None,
417
+ schema: type[BaseModel] | None = None,
418
+ mutation_name: str | None = None,
419
+ mutation_comment: str | None = None,
420
+ mutation_category: str = "mutation",
421
+ metadata: dict[str, JsonValue] | None = None,
422
+ mutation_metadata: dict[str, JsonValue] | None = None,
423
+ reuse_op: bool = False,
424
+ ) -> _MutationScope[T]:
425
+ """Context manager that records an in-place mutation as a new version.
426
+
427
+ It wraps the code that mutates a target variable inside the ``with``
428
+ block. On entry, the current state of target is snapshotted. On
429
+ normal exit, the target is re-encoded and a new version node, an
430
+ operation record, and connecting edges are written to the graph.
431
+
432
+ ``target`` must be a mutable Python object. Passing a
433
+ runtime variable raises an error because it is a read-only
434
+ view and mutating it would violate tracing invariants.
435
+
436
+ Example::
437
+
438
+ with comment_mutation(target=my_list, mutation_name="append") as scope:
439
+ my_list.append("new_item")
440
+ new_rv = scope.result # RuntimeVariable for the new version
441
+
442
+ Args:
443
+ target (`T`):
444
+ The mutable Python object to track.
445
+ inputs (`list[Any] | None`, optional):
446
+ Additional input variables involved in the mutation. Each
447
+ element may be a raw value, a runtime variable, or a
448
+ value-option tuple whose dict keys override the
449
+ shared defaults (``id_strategy``, ``encoding_fn``, etc.).
450
+ comment (`str | None`, optional):
451
+ Comment attached to the target variable (the new version
452
+ node). For the operation comment, use `mutation_comment`.
453
+ category (`str`, defaults to `"variable"`):
454
+ Category for the target variable node. Also used as the
455
+ shared default for input variables that do not override it.
456
+ class_name (`str | None`, optional):
457
+ Namespace prefix for the target variable's full name.
458
+ auto_class_name (`bool`, defaults to `False`):
459
+ Auto-detect `class_name` from the target's Python type.
460
+ id_strategy (`Callable[[Any], str] | str | None`, optional):
461
+ Identity strategy for the target variable. Also used as the
462
+ shared default for input variables that do not override it.
463
+ encoding_fn (`Callable[[Any], str] | None`, optional):
464
+ Encoder for the target and input variables (shared default).
465
+ decoding_fn (`Callable[[str], Any] | None`, optional):
466
+ Decoder paired with the encoding function (shared default).
467
+ schema (`type[BaseModel] | None`, optional):
468
+ Pydantic schema for auto encoding and decoding (shared default).
469
+ mutation_name (`str | None`, optional):
470
+ Canonical operation name. If not provided, it will be auto-generated
471
+ from the target variable's full name.
472
+ mutation_comment (`str | None`, optional):
473
+ Comment attached to the operation record and propagated
474
+ to the version edge and input edges. If not provided, the
475
+ version edge will use the default comment.
476
+ mutation_category (`str`, defaults to `"mutation"`):
477
+ Category for the operation record and edges.
478
+ metadata (`dict[str, JsonValue] | None`, optional):
479
+ Extra metadata attached to the target variable (the new
480
+ version node).
481
+ mutation_metadata (`dict[str, JsonValue] | None`, optional):
482
+ Extra metadata attached to the operation record. Edges
483
+ also receive a copy.
484
+ reuse_op (`bool`, defaults to `False`):
485
+ When enabled and an active operation context exists, edges
486
+ are attributed to the existing operation instead of creating a
487
+ new mutation operation. The ``mutation_comment`` and
488
+ ``mutation_category`` are still applied to the edges. The
489
+ version bump (new variable node) always happens regardless
490
+ of this flag. When no active operation context is found,
491
+ a new mutation operation is created as usual.
492
+
493
+ Returns:
494
+ `_MutationScope[T]`:
495
+ A context manager for the in-place mutation operation.
496
+
497
+ Raises:
498
+ `ValueError`:
499
+ If target variable is a runtime variable or an input collides
500
+ with the target's full node identifier.
501
+ `RuntimeError`:
502
+ If no graph context is active.
503
+ """
504
+ if isinstance(target, RuntimeVariable):
505
+ raise ValueError(
506
+ "`comment_mutation` does not accept a runtime variable as "
507
+ "target because it is a read-only view. Pass the mutable "
508
+ "Python object directly."
509
+ )
510
+
511
+ return _MutationScope(
512
+ target=target,
513
+ inputs=inputs,
514
+ comment=comment,
515
+ category=category,
516
+ class_name=class_name,
517
+ auto_class_name=auto_class_name,
518
+ id_strategy=id_strategy,
519
+ encoding_fn=encoding_fn,
520
+ decoding_fn=decoding_fn,
521
+ schema=schema,
522
+ mutation_name=mutation_name,
523
+ mutation_comment=mutation_comment,
524
+ mutation_category=mutation_category,
525
+ metadata=metadata,
526
+ mutation_metadata=mutation_metadata,
527
+ reuse_op=reuse_op,
528
+ )
529
+
530
+
531
+ def comment_fn(
532
+ *,
533
+ op_name: str | None = None,
534
+ comment: str | None = None,
535
+ category: str = "operation",
536
+ id_strategy: Callable[[Any], str] | str | None = None,
537
+ encoding_fn: Callable[[Any], str] | None = None,
538
+ decoding_fn: Callable[[str], Any] | None = None,
539
+ schema: type[BaseModel] | None = None,
540
+ auto_class_name: bool = False,
541
+ op_metadata: dict[str, JsonValue] | None = None,
542
+ metadata: dict[str, JsonValue] | None = None,
543
+ param_options: dict[str, dict[str, Any]] | None = None,
544
+ include_source: bool = False,
545
+ ) -> Callable:
546
+ """Decorator for automatically tracing function-level calls.
547
+
548
+ It wraps a synchronous or asynchronous function so that every call records
549
+ an operation with the function arguments as inputs and the return value as
550
+ a single output.
551
+
552
+ When an active operation context is present, the edges created by this
553
+ decorator are attributed to that existing operation (the ``category`` and
554
+ ``comment`` are applied to each edge only). When no operation context is
555
+ active, a new operation record is created and installed as the active
556
+ operation context for the duration of the function body, so that inner calls to
557
+ ``comment_link`` can reference it.
558
+
559
+ Per-parameter options are configured via ``param_options``, a dictionary
560
+ mapping parameter names to option dictionaries. Unmentioned parameters
561
+ use the shared defaults supplied to this decorator. To configure the output,
562
+ you can pass the key ``"-o"`` together with its option dictionary.
563
+
564
+ Example::
565
+
566
+ @comment_fn(
567
+ category="extraction",
568
+ param_options={
569
+ "query": {"id_strategy": "content"},
570
+ "entries": {"auto_class_name": True},
571
+ },
572
+ )
573
+ def extract(query: str, entries: list) -> list:
574
+ ...
575
+
576
+ @comment_fn(op_name="async_op")
577
+ async def fetch(url: str) -> str:
578
+ ...
579
+
580
+ # Reusing an outer operation scope:
581
+ with comment_op_scope(op_name="pipeline") as op:
582
+ result = extract(query, entries)
583
+
584
+ Args:
585
+ op_name (`str | None`, optional):
586
+ Canonical operation name recorded in the graph. When not
587
+ provided, the decorated function's qualified name is used.
588
+ comment (`str | None`, optional):
589
+ Static operation description. When not provided, the decorated
590
+ function's docstring is used. If the function has no docstring,
591
+ its qualified name is used as a fallback.
592
+ category (`str`, defaults to `"operation"`):
593
+ Operation category. It is applied to the operation record when a new
594
+ one is created, and always applied to every edge.
595
+ id_strategy (`Callable[[Any], str] | str | None`, optional):
596
+ Shared identity strategy for auto-created input and output variables.
597
+ encoding_fn (`Callable[[Any], str] | None`, optional):
598
+ Shared encoder for auto-created variables.
599
+ decoding_fn (`Callable[[str], Any] | None`, optional):
600
+ Shared decoder for auto-created variables.
601
+ schema (`type[BaseModel] | None`, optional):
602
+ Shared Pydantic schema for auto encoding and decoding.
603
+ auto_class_name (`bool`, defaults to ``False``):
604
+ Auto-detect class name for auto-created variables.
605
+ op_metadata (`dict[str, JsonValue] | None`, optional):
606
+ Extra metadata attached to the operation record (when created)
607
+ and its edges.
608
+ metadata (`dict[str, JsonValue] | None`, optional):
609
+ Shared default metadata for auto-created variable nodes.
610
+ param_options (`dict[str, dict[str, Any]] | None`, optional):
611
+ Per-parameter option overrides. Keys are parameter names, values
612
+ are option dicts whose keys override the shared defaults for that
613
+ parameter only. If you want to configure the output, you can
614
+ pass the key ``"-o"`` together with its option dict, which is
615
+ applied to the output variable instead of an input parameter.
616
+ include_source (`bool`, defaults to ``False``):
617
+ When enabled, the decorated function's source code is captured
618
+ and attached as ``"source_code"`` in every edge's metadata.
619
+
620
+ Returns:
621
+ `Callable`:
622
+ The decorated function (sync or async, matching the original).
623
+ """
624
+ opts_map = param_options or {}
625
+
626
+ def decorator(fn: Callable) -> Callable:
627
+ sig = inspect.signature(fn)
628
+
629
+ resolved_op_name = op_name if op_name is not None else fn.__qualname__
630
+ if comment is not None:
631
+ auto_comment = comment
632
+ else:
633
+ # If the user writes a docstring, we use it as the comment.
634
+ docstring = inspect.getdoc(fn)
635
+ if docstring:
636
+ auto_comment = f"[{fn.__qualname__}] {inspect.cleandoc(docstring)}"
637
+ else:
638
+ # Otherwise, we use the qualified name as the comment.
639
+ auto_comment = fn.__qualname__
640
+
641
+ fn_source = None
642
+ if include_source:
643
+ try:
644
+ fn_source = inspect.getsource(fn)
645
+ except Exception as e:
646
+ logger.warning(
647
+ "It is unable to get the source code for {}.\n"
648
+ "Below is the error information:\n\n"
649
+ " {}\n",
650
+ fn.__qualname__,
651
+ e,
652
+ )
653
+ fn_source = None
654
+
655
+ fn_filename = fn.__code__.co_filename
656
+ fn_lineno = fn.__code__.co_firstlineno
657
+
658
+ def _resolve_session_id(graph):
659
+ """Return the active session identifier or fall back to the NONE sentinel."""
660
+ session = current_session()
661
+ if session is not None:
662
+ return session.session_id
663
+ graph._ensure_none_session()
664
+ return none_session_id()
665
+
666
+ def _setup_op(graph, session_id):
667
+ """Create or reuse an operation.
668
+
669
+ When an active operation context is present, its operation is
670
+ reused. Otherwise a fresh operation record is created, persisted during
671
+ the function body.
672
+ """
673
+ active_op = current_op()
674
+ if active_op is not None:
675
+ return active_op, None, True
676
+
677
+ fn_ctx = current_context()
678
+ assert fn_ctx is not None, (
679
+ "The current tracing context is not active."
680
+ )
681
+
682
+ op = OpRecord(
683
+ graph_id=graph.graph_id,
684
+ session_id=session_id,
685
+ user_id=graph.user_id,
686
+ project_id=graph.project_id,
687
+ op_name=resolved_op_name,
688
+ comment=auto_comment,
689
+ category=category,
690
+ filename=fn_filename,
691
+ lineno=fn_lineno,
692
+ )
693
+
694
+ merged = {}
695
+ if op_metadata:
696
+ merged.update(op_metadata)
697
+ propagated = fn_ctx.metadata
698
+ if propagated:
699
+ merged.update(propagated)
700
+ if merged:
701
+ op.update_metadata(merged)
702
+
703
+ graph._driver.add_operation(op)
704
+ token = _OP.set(op)
705
+ return op, token, False
706
+
707
+ def _trace_edges(result, bound, op, graph, session_id):
708
+ """Resolve input, output variables, and write edges."""
709
+ input_items = []
710
+
711
+ # We iterate over the arguments and create a list of input variables.
712
+ for name, value in bound.arguments.items():
713
+ per_param = opts_map.get(name)
714
+ if per_param is not None:
715
+ input_items.append((value, per_param))
716
+ else:
717
+ input_items.append(value)
718
+
719
+ # The reserved `"-o"` key configures the output variable.
720
+ output_items = []
721
+ if result is not None:
722
+ output_opts = opts_map.get("-o")
723
+ if output_opts is not None:
724
+ output_items.append((result, output_opts))
725
+ else:
726
+ output_items.append(result)
727
+
728
+ resolve_kwargs = dict(
729
+ shared_id_strategy=id_strategy,
730
+ shared_encoding_fn=encoding_fn,
731
+ shared_decoding_fn=decoding_fn,
732
+ shared_schema=schema,
733
+ shared_auto_class_name=auto_class_name,
734
+ shared_category="variable",
735
+ shared_metadata=metadata,
736
+ graph=graph,
737
+ session_id=session_id,
738
+ filename=fn_filename,
739
+ lineno=fn_lineno,
740
+ )
741
+
742
+ input_rvs = [_resolve_item(item, **resolve_kwargs) for item in input_items]
743
+ output_rvs = [_resolve_item(item, **resolve_kwargs) for item in output_items]
744
+
745
+ if not input_rvs and not output_rvs:
746
+ logger.debug(
747
+ "No input or output variables for operation '{}'.",
748
+ resolved_op_name,
749
+ )
750
+ return
751
+
752
+ if not input_rvs or not output_rvs:
753
+ graph._ensure_none_node()
754
+
755
+ fn_ctx = current_context()
756
+ assert fn_ctx is not None, (
757
+ "The current tracing context is not active."
758
+ )
759
+
760
+ edge_meta = {}
761
+ if op_metadata:
762
+ edge_meta.update(op_metadata)
763
+ if fn_source is not None:
764
+ edge_meta["source_code"] = fn_source
765
+ propagated = fn_ctx.metadata
766
+ if propagated:
767
+ edge_meta.update(propagated)
768
+
769
+ _none = none_full_node_id()
770
+ source_ids = [rv.full_node_id for rv in input_rvs] if input_rvs else [_none]
771
+ target_ids = [rv.full_node_id for rv in output_rvs] if output_rvs else [_none]
772
+
773
+ for src in source_ids:
774
+ for tgt in target_ids:
775
+ edge = OpEdge(
776
+ graph_id=graph.graph_id,
777
+ session_id=session_id,
778
+ user_id=graph.user_id,
779
+ project_id=graph.project_id,
780
+ op_id=op.op_id,
781
+ category=category,
782
+ source_full_node_id=src,
783
+ target_full_node_id=tgt,
784
+ comment=auto_comment,
785
+ filename=fn_filename,
786
+ lineno=fn_lineno,
787
+ )
788
+ if edge_meta:
789
+ edge.update_metadata(edge_meta)
790
+ graph._driver.add_edge(edge)
791
+
792
+ if asyncio.iscoroutinefunction(fn):
793
+ @functools.wraps(fn)
794
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
795
+ if not is_tracing_enabled():
796
+ return await fn(*args, **kwargs)
797
+ graph = current_graph()
798
+ if graph is None:
799
+ return await fn(*args, **kwargs)
800
+
801
+ session_id = _resolve_session_id(graph)
802
+ op, token, _reused = _setup_op(graph, session_id)
803
+ try:
804
+ result = await fn(*args, **kwargs)
805
+ finally:
806
+ if token is not None:
807
+ _OP.reset(token)
808
+
809
+ bound = sig.bind(*args, **kwargs)
810
+ bound.apply_defaults()
811
+ _trace_edges(result, bound, op, graph, session_id)
812
+ return result
813
+ return async_wrapper
814
+ else:
815
+ @functools.wraps(fn)
816
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
817
+ if not is_tracing_enabled():
818
+ return fn(*args, **kwargs)
819
+ graph = current_graph()
820
+ if graph is None:
821
+ return fn(*args, **kwargs)
822
+
823
+ session_id = _resolve_session_id(graph)
824
+ op, token, _reused = _setup_op(graph, session_id)
825
+ try:
826
+ result = fn(*args, **kwargs)
827
+ finally:
828
+ if token is not None:
829
+ _OP.reset(token)
830
+
831
+ bound = sig.bind(*args, **kwargs)
832
+ bound.apply_defaults()
833
+ _trace_edges(result, bound, op, graph, session_id)
834
+ return result
835
+ return sync_wrapper
836
+
837
+ return decorator
838
+
839
+
840
+ def comment_link(
841
+ *,
842
+ source: Any,
843
+ target: Any,
844
+ comment: str | None = None,
845
+ category: str | None = None,
846
+ id_strategy: Callable[[Any], str] | str | None = None,
847
+ encoding_fn: Callable[[Any], str] | None = None,
848
+ decoding_fn: Callable[[str], Any] | None = None,
849
+ schema: type[BaseModel] | None = None,
850
+ auto_class_name: bool = False,
851
+ metadata: dict[str, JsonValue] | None = None,
852
+ edge_metadata: dict[str, JsonValue] | None = None,
853
+ ) -> RuntimeEdge | None:
854
+ """Record a single directed edge between two variables.
855
+
856
+ Unlike ``comment_op`` which creates a Cartesian product of edges
857
+ between all inputs and all outputs, ``comment_link`` creates exactly
858
+ one edge from one source variable to one target variable. This allows
859
+ fine-grained control over graph topology within the same operation scope.
860
+
861
+ If an active operation context exists, the edge is associated with that
862
+ operation and inherits its category and comment as defaults.
863
+ When no operation context is active, a per-graph NONE sentinel operation
864
+ is lazily created.
865
+
866
+ Note that if you want to specify the category and comment for the variable,
867
+ you should pass these as options to the variable or use ``comment_variable``.
868
+
869
+ Example::
870
+
871
+ with comment_op_scope(op_name="custom_links") as op:
872
+ comment_link(source=query, target=result_a, category="primary")
873
+ comment_link(source=query, target=result_b, category="secondary")
874
+
875
+ Args:
876
+ source (`Any`):
877
+ The upstream variable. It can be a raw Python value, a runtime variable,
878
+ or a value-option tuple.
879
+ target (`Any`):
880
+ The downstream variable. It can be a raw Python value, a runtime variable,
881
+ or a value-option tuple.
882
+ comment (`str | None`, optional):
883
+ Edge annotation. When not provided, it falls back to the active
884
+ operation's comment as default.
885
+ category (`str | None`, optional):
886
+ Edge category. When not provided, it falls back to the active
887
+ operation's category as default, or `"link"` when no
888
+ operation context is active.
889
+ id_strategy (`Callable[[Any], str] | str | None`, optional):
890
+ Shared identity strategy for auto-created variable nodes.
891
+ encoding_fn (`Callable[[Any], str] | None`, optional):
892
+ Shared encoder for auto-created variables.
893
+ decoding_fn (`Callable[[str], Any] | None`, optional):
894
+ Shared decoder for auto-created variables.
895
+ schema (`type[BaseModel] | None`, optional):
896
+ Shared Pydantic schema for auto encoding and decoding.
897
+ auto_class_name (`bool`, defaults to `False`):
898
+ Auto-detect class name for auto-created variables.
899
+ metadata (`dict[str, JsonValue] | None`, optional):
900
+ Metadata used as the default for auto-created variable nodes.
901
+ edge_metadata (`dict[str, JsonValue] | None`, optional):
902
+ Extra metadata attached to the created edge.
903
+
904
+ Returns:
905
+ `RuntimeEdge | None`:
906
+ A runtime view of the created edge, or ``None`` if tracing is
907
+ disabled.
908
+ """
909
+ if not is_tracing_enabled():
910
+ return None
911
+
912
+ graph, session_id, filename, lineno = _resolve_call_context(
913
+ caller_name="comment_link",
914
+ )
915
+
916
+ resolve_kwargs = dict(
917
+ shared_id_strategy=id_strategy,
918
+ shared_encoding_fn=encoding_fn,
919
+ shared_decoding_fn=decoding_fn,
920
+ shared_schema=schema,
921
+ shared_auto_class_name=auto_class_name,
922
+ shared_category="variable",
923
+ shared_metadata=metadata,
924
+ graph=graph,
925
+ session_id=session_id,
926
+ filename=filename,
927
+ lineno=lineno,
928
+ )
929
+
930
+ source_rv = _resolve_item(source, **resolve_kwargs)
931
+ target_rv = _resolve_item(target, **resolve_kwargs)
932
+
933
+ active_op = current_op()
934
+ if active_op is not None:
935
+ op_id = active_op.op_id
936
+ resolved_category = category if category is not None else active_op.category
937
+ resolved_comment = comment if comment is not None else active_op.comment
938
+ else:
939
+ graph._ensure_none_op()
940
+ op_id = none_op_id()
941
+ resolved_category = category if category is not None else "link"
942
+ resolved_comment = comment
943
+
944
+ ctx = current_context()
945
+ assert ctx is not None, "The current tracing context is not active."
946
+
947
+ merged_edge_metadata = {}
948
+ if edge_metadata:
949
+ merged_edge_metadata.update(edge_metadata)
950
+ propagated = ctx.metadata
951
+ if propagated:
952
+ merged_edge_metadata.update(propagated)
953
+
954
+ edge = OpEdge(
955
+ graph_id=graph.graph_id,
956
+ session_id=session_id,
957
+ user_id=graph.user_id,
958
+ project_id=graph.project_id,
959
+ op_id=op_id,
960
+ category=resolved_category,
961
+ source_full_node_id=source_rv.full_node_id,
962
+ target_full_node_id=target_rv.full_node_id,
963
+ comment=resolved_comment,
964
+ filename=filename,
965
+ lineno=lineno,
966
+ )
967
+ if merged_edge_metadata:
968
+ edge.update_metadata(merged_edge_metadata)
969
+ graph._driver.add_edge(edge)
970
+
971
+ return RuntimeEdge(edge=edge)