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,440 @@
1
+ """Internal helpers for the public API."""
2
+
3
+ import contextlib
4
+ import inspect
5
+ from pydantic import BaseModel, JsonValue
6
+ from ..runtime import (
7
+ ExecNetwork,
8
+ none_session_id,
9
+ current_graph,
10
+ current_session,
11
+ current_context,
12
+ none_identity,
13
+ )
14
+ from ..runtime.variable import RuntimeVariable
15
+ from ..runtime.errors import TraceConsistencyError, _diff_context
16
+ from ..schema.variable import Variable
17
+ from ..identity.registry import IdentityRegistry
18
+ from ..logging import logger
19
+ from typing import (
20
+ Any,
21
+ Callable,
22
+ TypeVar,
23
+ )
24
+
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ def _resolve_identity(
30
+ value: Any,
31
+ *,
32
+ id_strategy: Callable[[Any], str] | str | None = None,
33
+ ) -> str:
34
+ """Resolve the identity string for a value.
35
+
36
+ Args:
37
+ value (`Any`):
38
+ The value to resolve the identity for.
39
+ id_strategy (`Callable[[Any], str] | str | None`, optional):
40
+ The identity strategy to use. If not provided, it will be looked up by type.
41
+
42
+ Returns:
43
+ `str`:
44
+ The resolved identity string.
45
+
46
+ Raises:
47
+ `ValueError`:
48
+ If the identity is the built-in NONE sentinel.
49
+ """
50
+ fn = IdentityRegistry.resolve(value, override=id_strategy)
51
+ identity = fn(value)
52
+
53
+ none_id = none_identity()
54
+ if identity == none_id:
55
+ raise ValueError(
56
+ f"The identity '{none_id}' is reserved for the built-in "
57
+ "NONE sentinel. Choose a different identity strategy."
58
+ )
59
+ return identity
60
+
61
+
62
+ def _resolve_call_context(
63
+ caller_name: str = "comment_*",
64
+ stack_level: int = 2,
65
+ ) -> tuple[ExecNetwork, str, str, int]:
66
+ """Resolve the active graph, session identifier, and caller location.
67
+
68
+ It obtains the current execution graph, determines the current session
69
+ identifier (falling back to the NONE sentinel session when no session is active),
70
+ and captures the caller's source location for attribution.
71
+
72
+ Args:
73
+ caller_name (`str`, defaults to `"comment_*"`):
74
+ Name of the calling public function, included in the error
75
+ message when no graph context is active.
76
+ stack_level (`int`, defaults to `2`):
77
+ Frame index passed to ``inspect.stack`` to locate the
78
+ original call site.
79
+
80
+ Returns:
81
+ `tuple[ExecNetwork, str, str, int]`:
82
+ A 4-tuple including the current execution graph, session identifier,
83
+ caller's source location and line number.
84
+
85
+ Raises:
86
+ `RuntimeError`:
87
+ If no graph context is active.
88
+ """
89
+ graph = current_graph()
90
+ if graph is None:
91
+ raise RuntimeError(
92
+ f"`{caller_name}` requires an active graph context. "
93
+ "Wrap your code in `with comment_graph() as graph:`."
94
+ )
95
+
96
+ session = current_session()
97
+ if session is not None:
98
+ session_id = session.session_id
99
+ else:
100
+ # Check whether the NONE sentinel session has been inserted yet.
101
+ graph._ensure_none_session()
102
+ session_id = none_session_id()
103
+
104
+ frame = inspect.stack()[stack_level]
105
+ filename, lineno = frame.filename, frame.lineno
106
+ return graph, session_id, filename, lineno
107
+
108
+
109
+ def _ensure_variable(
110
+ value: T,
111
+ *,
112
+ graph: ExecNetwork,
113
+ session_id: str,
114
+ filename: str,
115
+ lineno: int,
116
+ id_strategy: Callable[[T], str] | str | None = None,
117
+ encoding_fn: Callable[[T], str] | None = None,
118
+ decoding_fn: Callable[[str], T] | None = None,
119
+ schema: type[BaseModel] | None = None,
120
+ comment: str | None = None,
121
+ category: str = "variable",
122
+ class_name: str | None = None,
123
+ auto_class_name: bool = False,
124
+ variable_name: str | None = None,
125
+ metadata: dict[str, JsonValue] | None = None,
126
+ identity_only: bool = False,
127
+ force_new_version: bool = False,
128
+ ) -> RuntimeVariable[T]:
129
+ """Create or reuse a graph variable node and return a runtime handle.
130
+
131
+ It resolves the variable to a stable identity, encodes it to a string for storage,
132
+ and either reuses the latest matching node (same encoded value) or
133
+ appends a new version. When the variable's name is set and a tracing context
134
+ exists, the handle is registered for lookup by name.
135
+
136
+ Args:
137
+ value (`T`):
138
+ The Python value to record in the graph.
139
+ graph (`ExecNetwork`):
140
+ Target execution graph.
141
+ session_id (`str`):
142
+ Session identifier stored on the variable node.
143
+ filename (`str`):
144
+ Source file recorded on the variable node (call-site attribution).
145
+ lineno (`int`):
146
+ Source line recorded on the variable node.
147
+ id_strategy (`Callable[[T], str] | str | None`, optional):
148
+ An identity strategy. It can be a callable, a registered name.
149
+ If not provided, it will be looked up by type.
150
+ encoding_fn (`Callable[[T], str] | None`, optional):
151
+ Custom encoder from the value to string.
152
+ decoding_fn (`Callable[[str], T] | None`, optional):
153
+ Optional decoder paired with the encoding function for the runtime handle.
154
+ schema (`type[BaseModel] | None`, optional):
155
+ Pydantic schema for auto encoding and decoding.
156
+ comment (`str | None`, optional):
157
+ A comment for the variable.
158
+ category (`str`, defaults to `"variable"`):
159
+ Variable category.
160
+ class_name (`str | None`, optional):
161
+ Namespace prefix for the variable's full name when set.
162
+ auto_class_name (`bool`, defaults to `False`):
163
+ If enabled, it will auto-detect the class name from the value.
164
+ variable_name (`str | None`, optional):
165
+ If provided, it registers the returned handle on the current
166
+ tracing context under this name (overwriting any prior registration).
167
+ metadata (`dict[str, JsonValue] | None`, optional):
168
+ Extra metadata for the variable.
169
+ identity_only (`bool`, defaults to `False`):
170
+ When enabled, the snapshot consistency check is bypassed for this
171
+ variable. If a node with the same identity already exists in the
172
+ graph but has a different encoded value, the existing node is
173
+ returned as-is instead of raising error or creating a new version.
174
+ This is useful when the caller holds a lightweight representation
175
+ (e.g. only an identifier) of a variable that was previously recorded
176
+ with a richer snapshot.
177
+ force_new_version (`bool`, defaults to `False`):
178
+ When enabled, it always creates a new version even if the encoded
179
+ value matches the latest node. It is used by ``comment_op`` to avoid
180
+ self-loop edges when the same value appears in both inputs and outputs.
181
+
182
+ Returns:
183
+ `RuntimeVariable[T]`:
184
+ A handle for the latest or newly created variable version.
185
+
186
+ Raises:
187
+ ValueError:
188
+ If identity resolution yields the reserved NONE sentinel identity.
189
+ TraceConsistencyError:
190
+ If the graph is in strict mode, an existing node for this identity
191
+ has a different encoded value, ``identity_only`` is not set, and
192
+ the change was not recorded via ``comment_mutation``.
193
+ """
194
+ if auto_class_name and class_name is None:
195
+ class_name = type(value).__qualname__
196
+
197
+ # Determine the string representation.
198
+ if schema is not None and encoding_fn is None:
199
+ value_str = value.model_dump_json()
200
+ elif encoding_fn is not None:
201
+ value_str = encoding_fn(value)
202
+ else:
203
+ logger.debug(
204
+ "No encoding function is provided. `repr` is used to encode the value.",
205
+ )
206
+ value_str = repr(value)
207
+
208
+ name = _resolve_identity(value, id_strategy=id_strategy)
209
+ full_name = f"{class_name}:{name}" if class_name is not None else name
210
+
211
+ # Acquire the driver lock (if present) to make the read-modify-write
212
+ # sequence atomic.
213
+ # Drivers without a lock are assumed to handle concurrency internally.
214
+ driver_lock = getattr(graph._driver, "_lock", None)
215
+ lock_ctx = driver_lock if driver_lock is not None else contextlib.nullcontext()
216
+
217
+ with lock_ctx:
218
+ # Check if this variable already exists in the graph.
219
+ raw = graph._driver.get_latest_node(full_name)
220
+ if raw is None:
221
+ existing = None
222
+ else:
223
+ existing = RuntimeVariable(variable=raw)
224
+
225
+ if existing is not None and existing.raw_value == value_str and not force_new_version:
226
+ rv_existing = RuntimeVariable(
227
+ variable=existing._variable,
228
+ encoding_fn=encoding_fn,
229
+ decoding_fn=decoding_fn,
230
+ schema=schema,
231
+ )
232
+ if variable_name is not None:
233
+ ctx = current_context()
234
+ assert ctx is not None, (
235
+ "The current tracing context is not active."
236
+ )
237
+ ctx.register_variable(variable_name, rv_existing, overwrite=True)
238
+ return rv_existing
239
+
240
+ if existing is not None and existing.raw_value != value_str and identity_only:
241
+ logger.debug(
242
+ "For the variable with the identity '{full_name}', "
243
+ "a snapshot mismatch is detected. "
244
+ "However, the caller explicitly opts out of snapshot consistency. "
245
+ "Therefore, the existing node will be returned instead of raising "
246
+ "an error or creating a new version.",
247
+ full_name=full_name,
248
+ )
249
+ rv_existing = RuntimeVariable(
250
+ variable=existing._variable,
251
+ encoding_fn=encoding_fn,
252
+ decoding_fn=decoding_fn,
253
+ schema=schema,
254
+ )
255
+ if variable_name is not None:
256
+ ctx = current_context()
257
+ assert ctx is not None, (
258
+ "The current tracing context is not active."
259
+ )
260
+ ctx.register_variable(variable_name, rv_existing, overwrite=True)
261
+ return rv_existing
262
+
263
+ # Strict mode check whether the value changed for an existing identity.
264
+ if existing is not None and existing.raw_value != value_str:
265
+ if graph.strict:
266
+ raise TraceConsistencyError(
267
+ user_value=value,
268
+ user_value_encoded=value_str,
269
+ existing_variable=existing,
270
+ identity_name=name,
271
+ )
272
+ else:
273
+ old_snippet, new_snippet = _diff_context(existing.raw_value, value_str)
274
+ logger.info(
275
+ "A value changed for an existing identity (identity_name={name}) "
276
+ "but strict mode is disabled. A new version will be created. "
277
+ "The first difference between the recorded and provided values is:\n"
278
+ " Recorded: {old}\n"
279
+ " Provided: {new}",
280
+ name=name,
281
+ old=old_snippet,
282
+ new=new_snippet,
283
+ )
284
+
285
+ # Determine the version.
286
+ version = 1
287
+ if existing is not None:
288
+ version = existing.version + 1
289
+
290
+ var = Variable(
291
+ name=name,
292
+ version=version,
293
+ value=value_str,
294
+ comment=comment,
295
+ category=category,
296
+ class_name=class_name,
297
+ graph_id=graph.graph_id,
298
+ user_id=graph.user_id,
299
+ project_id=graph.project_id,
300
+ session_id=session_id,
301
+ filename=filename,
302
+ lineno=lineno,
303
+ )
304
+ if metadata:
305
+ var.update_metadata(metadata)
306
+
307
+ # Propagate context metadata.
308
+ ctx = current_context()
309
+ assert ctx is not None, (
310
+ "The current tracing context is not active."
311
+ )
312
+ ctx_meta = ctx.metadata
313
+ if ctx_meta:
314
+ var.update_metadata(ctx_meta)
315
+
316
+ # Add the raw variable to the driver and build one runtime variable handle.
317
+ graph._driver.add_node(var)
318
+
319
+ rv_full = RuntimeVariable(
320
+ variable=var,
321
+ encoding_fn=encoding_fn,
322
+ decoding_fn=decoding_fn,
323
+ schema=schema,
324
+ )
325
+
326
+ # Register in context if a variable name is provided.
327
+ if variable_name is not None:
328
+ ctx.register_variable(variable_name, rv_full, overwrite=True)
329
+
330
+ return rv_full
331
+
332
+
333
+ def _resolve_item(
334
+ item: Any,
335
+ *,
336
+ shared_id_strategy: Callable[[Any], str] | str | None,
337
+ shared_encoding_fn: Callable[[Any], str] | None,
338
+ shared_decoding_fn: Callable[[str], Any] | None,
339
+ shared_schema: type[BaseModel] | None,
340
+ shared_auto_class_name: bool,
341
+ shared_category: str,
342
+ shared_metadata: dict[str, JsonValue] | None,
343
+ graph: ExecNetwork,
344
+ session_id: str,
345
+ filename: str,
346
+ lineno: int,
347
+ force_new_version: bool = False,
348
+ ) -> RuntimeVariable[Any]:
349
+ """Resolve a single ``comment_op`` or ``comment_fn`` slot to a runtime variable.
350
+
351
+ If ``item`` is already a ``RuntimeVariable``, it is returned unchanged.
352
+ If ``item`` is a plain Python value, it is passed to :func:`_ensure_variable`
353
+ with the shared keyword defaults from the caller. If ``item`` is a length-2
354
+ tuple ``(value, options)`` and ``options`` is a ``dict``, keys in that dict
355
+ override the shared defaults for that slot only (for example
356
+ ``id_strategy``, ``encoding_fn``, ``metadata``). At debug level, tuple
357
+ items log a truncated ``repr`` of ``value`` and the full options dict.
358
+
359
+ Args:
360
+ item (`Any`):
361
+ A runtime handle, a raw value, or ``(value, options_dict)``.
362
+ shared_id_strategy (`Callable[[Any], str] | str | None`):
363
+ Default identity strategy when the item does not override it.
364
+ shared_encoding_fn (`Callable[[Any], str] | None`):
365
+ Default encoder when the item does not override it.
366
+ shared_decoding_fn (`Callable[[str], Any] | None`):
367
+ Default decoder when the item does not override it.
368
+ shared_schema (`type[BaseModel] | None`):
369
+ Default Pydantic schema when the item does not override it.
370
+ shared_auto_class_name (`bool`):
371
+ Default flag for auto class name detection.
372
+ shared_category (`str`):
373
+ Default variable category for newly created nodes.
374
+ shared_metadata (`dict[str, JsonValue] | None`):
375
+ Default metadata merged into new variable nodes when not overridden
376
+ per item.
377
+ graph (`ExecNetwork`):
378
+ Target execution graph.
379
+ session_id (`str`):
380
+ Session identifier for new variable nodes.
381
+ filename (`str`):
382
+ Call-site file path for new variable nodes.
383
+ lineno (`int`):
384
+ Call-site line number for new variable nodes.
385
+ force_new_version (`bool`, defaults to ``False``):
386
+ When enabled, forwarded to :func:`_ensure_variable` so a new
387
+ version is always created even if the encoded value matches the
388
+ latest node.
389
+
390
+ Returns:
391
+ `RuntimeVariable[Any]`:
392
+ The existing handle or a handle for the latest or newly created node.
393
+
394
+ Raises:
395
+ `ValueError`:
396
+ If identity resolution yields the reserved NONE sentinel identity.
397
+ `TraceConsistencyError`:
398
+ If the graph is in strict mode and an existing identity's encoded
399
+ value changed without ``comment_mutation``.
400
+ """
401
+ if isinstance(item, RuntimeVariable):
402
+ return item
403
+
404
+ config = {}
405
+ value = item
406
+
407
+ if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], dict):
408
+ value, config = item
409
+
410
+ value_str = repr(value)
411
+ if len(value_str) > 30:
412
+ value_str = value_str[:27] + "..."
413
+ logger.debug(
414
+ "A value-option tuple is provided for an input or output item. "
415
+ "The options will override the shared defaults.\n"
416
+ " Value: {value!r}\n"
417
+ " Options: {config!r}",
418
+ value=value_str,
419
+ config=config,
420
+ )
421
+
422
+ return _ensure_variable(
423
+ value,
424
+ id_strategy=config.get("id_strategy", shared_id_strategy),
425
+ encoding_fn=config.get("encoding_fn", shared_encoding_fn),
426
+ decoding_fn=config.get("decoding_fn", shared_decoding_fn),
427
+ schema=config.get("schema", shared_schema),
428
+ comment=config.get("comment"),
429
+ category=config.get("category", shared_category),
430
+ class_name=config.get("class_name"),
431
+ auto_class_name=config.get("auto_class_name", shared_auto_class_name),
432
+ variable_name=config.get("variable_name"),
433
+ metadata=config.get("metadata", shared_metadata),
434
+ identity_only=config.get("identity_only", False),
435
+ graph=graph,
436
+ session_id=session_id,
437
+ filename=filename,
438
+ lineno=lineno,
439
+ force_new_version=force_new_version,
440
+ )