yamlgraph 0.1.1__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.

Potentially problematic release.


This version of yamlgraph might be problematic. Click here for more details.

Files changed (111) hide show
  1. examples/__init__.py +1 -0
  2. examples/storyboard/__init__.py +1 -0
  3. examples/storyboard/generate_videos.py +335 -0
  4. examples/storyboard/nodes/__init__.py +10 -0
  5. examples/storyboard/nodes/animated_character_node.py +248 -0
  6. examples/storyboard/nodes/animated_image_node.py +138 -0
  7. examples/storyboard/nodes/character_node.py +162 -0
  8. examples/storyboard/nodes/image_node.py +118 -0
  9. examples/storyboard/nodes/replicate_tool.py +238 -0
  10. examples/storyboard/retry_images.py +118 -0
  11. tests/__init__.py +1 -0
  12. tests/conftest.py +178 -0
  13. tests/integration/__init__.py +1 -0
  14. tests/integration/test_animated_storyboard.py +63 -0
  15. tests/integration/test_cli_commands.py +242 -0
  16. tests/integration/test_map_demo.py +50 -0
  17. tests/integration/test_memory_demo.py +281 -0
  18. tests/integration/test_pipeline_flow.py +105 -0
  19. tests/integration/test_providers.py +163 -0
  20. tests/integration/test_resume.py +75 -0
  21. tests/unit/__init__.py +1 -0
  22. tests/unit/test_agent_nodes.py +200 -0
  23. tests/unit/test_checkpointer.py +212 -0
  24. tests/unit/test_cli.py +121 -0
  25. tests/unit/test_cli_package.py +81 -0
  26. tests/unit/test_compile_graph_map.py +132 -0
  27. tests/unit/test_conditions_routing.py +253 -0
  28. tests/unit/test_config.py +93 -0
  29. tests/unit/test_conversation_memory.py +270 -0
  30. tests/unit/test_database.py +145 -0
  31. tests/unit/test_deprecation.py +104 -0
  32. tests/unit/test_executor.py +60 -0
  33. tests/unit/test_executor_async.py +179 -0
  34. tests/unit/test_export.py +150 -0
  35. tests/unit/test_expressions.py +178 -0
  36. tests/unit/test_format_prompt.py +145 -0
  37. tests/unit/test_generic_report.py +200 -0
  38. tests/unit/test_graph_commands.py +327 -0
  39. tests/unit/test_graph_loader.py +299 -0
  40. tests/unit/test_graph_schema.py +193 -0
  41. tests/unit/test_inline_schema.py +151 -0
  42. tests/unit/test_issues.py +164 -0
  43. tests/unit/test_jinja2_prompts.py +85 -0
  44. tests/unit/test_langsmith.py +319 -0
  45. tests/unit/test_llm_factory.py +109 -0
  46. tests/unit/test_llm_factory_async.py +118 -0
  47. tests/unit/test_loops.py +403 -0
  48. tests/unit/test_map_node.py +144 -0
  49. tests/unit/test_no_backward_compat.py +56 -0
  50. tests/unit/test_node_factory.py +225 -0
  51. tests/unit/test_prompts.py +166 -0
  52. tests/unit/test_python_nodes.py +198 -0
  53. tests/unit/test_reliability.py +298 -0
  54. tests/unit/test_result_export.py +234 -0
  55. tests/unit/test_router.py +296 -0
  56. tests/unit/test_sanitize.py +99 -0
  57. tests/unit/test_schema_loader.py +295 -0
  58. tests/unit/test_shell_tools.py +229 -0
  59. tests/unit/test_state_builder.py +331 -0
  60. tests/unit/test_state_builder_map.py +104 -0
  61. tests/unit/test_state_config.py +197 -0
  62. tests/unit/test_template.py +190 -0
  63. tests/unit/test_tool_nodes.py +129 -0
  64. yamlgraph/__init__.py +35 -0
  65. yamlgraph/builder.py +110 -0
  66. yamlgraph/cli/__init__.py +139 -0
  67. yamlgraph/cli/__main__.py +6 -0
  68. yamlgraph/cli/commands.py +232 -0
  69. yamlgraph/cli/deprecation.py +92 -0
  70. yamlgraph/cli/graph_commands.py +382 -0
  71. yamlgraph/cli/validators.py +37 -0
  72. yamlgraph/config.py +67 -0
  73. yamlgraph/constants.py +66 -0
  74. yamlgraph/error_handlers.py +226 -0
  75. yamlgraph/executor.py +275 -0
  76. yamlgraph/executor_async.py +122 -0
  77. yamlgraph/graph_loader.py +337 -0
  78. yamlgraph/map_compiler.py +138 -0
  79. yamlgraph/models/__init__.py +36 -0
  80. yamlgraph/models/graph_schema.py +141 -0
  81. yamlgraph/models/schemas.py +124 -0
  82. yamlgraph/models/state_builder.py +236 -0
  83. yamlgraph/node_factory.py +240 -0
  84. yamlgraph/routing.py +87 -0
  85. yamlgraph/schema_loader.py +160 -0
  86. yamlgraph/storage/__init__.py +17 -0
  87. yamlgraph/storage/checkpointer.py +72 -0
  88. yamlgraph/storage/database.py +320 -0
  89. yamlgraph/storage/export.py +269 -0
  90. yamlgraph/tools/__init__.py +1 -0
  91. yamlgraph/tools/agent.py +235 -0
  92. yamlgraph/tools/nodes.py +124 -0
  93. yamlgraph/tools/python_tool.py +178 -0
  94. yamlgraph/tools/shell.py +205 -0
  95. yamlgraph/utils/__init__.py +47 -0
  96. yamlgraph/utils/conditions.py +157 -0
  97. yamlgraph/utils/expressions.py +111 -0
  98. yamlgraph/utils/langsmith.py +308 -0
  99. yamlgraph/utils/llm_factory.py +118 -0
  100. yamlgraph/utils/llm_factory_async.py +105 -0
  101. yamlgraph/utils/logging.py +127 -0
  102. yamlgraph/utils/prompts.py +116 -0
  103. yamlgraph/utils/sanitize.py +98 -0
  104. yamlgraph/utils/template.py +102 -0
  105. yamlgraph/utils/validators.py +181 -0
  106. yamlgraph-0.1.1.dist-info/METADATA +854 -0
  107. yamlgraph-0.1.1.dist-info/RECORD +111 -0
  108. yamlgraph-0.1.1.dist-info/WHEEL +5 -0
  109. yamlgraph-0.1.1.dist-info/entry_points.txt +2 -0
  110. yamlgraph-0.1.1.dist-info/licenses/LICENSE +21 -0
  111. yamlgraph-0.1.1.dist-info/top_level.txt +3 -0
@@ -0,0 +1,308 @@
1
+ """LangSmith Utilities - Tracing and observability helpers.
2
+
3
+ Provides functions for interacting with LangSmith traces,
4
+ printing execution trees, and logging run information.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from yamlgraph.config import WORKING_DIR
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def get_client() -> Any | None:
19
+ """Get a LangSmith client if available.
20
+
21
+ Returns:
22
+ LangSmith Client instance or None if not configured
23
+ """
24
+ try:
25
+ from langsmith import Client
26
+
27
+ # Support both LANGCHAIN_* and LANGSMITH_* env vars
28
+ api_key = os.environ.get("LANGCHAIN_API_KEY") or os.environ.get(
29
+ "LANGSMITH_API_KEY"
30
+ )
31
+ if not api_key:
32
+ return None
33
+
34
+ endpoint = (
35
+ os.environ.get("LANGCHAIN_ENDPOINT")
36
+ or os.environ.get("LANGSMITH_ENDPOINT")
37
+ or "https://api.smith.langchain.com"
38
+ )
39
+ return Client(api_url=endpoint, api_key=api_key)
40
+ except ImportError:
41
+ logger.debug("LangSmith package not installed, client unavailable")
42
+ return None
43
+
44
+
45
+ def get_project_name() -> str:
46
+ """Get the current LangSmith project name.
47
+
48
+ Returns:
49
+ Project name from environment or default
50
+ """
51
+ return (
52
+ os.environ.get("LANGCHAIN_PROJECT")
53
+ or os.environ.get("LANGSMITH_PROJECT")
54
+ or "yamlgraph"
55
+ )
56
+
57
+
58
+ def is_tracing_enabled() -> bool:
59
+ """Check if LangSmith tracing is enabled.
60
+
61
+ Returns:
62
+ True if tracing is enabled
63
+ """
64
+ # Support both env var names and values
65
+ tracing_v2 = os.environ.get("LANGCHAIN_TRACING_V2", "").lower()
66
+ tracing = os.environ.get("LANGSMITH_TRACING", "").lower()
67
+ return tracing_v2 == "true" or tracing == "true"
68
+
69
+
70
+ def get_latest_run_id(project_name: str | None = None) -> str | None:
71
+ """Get the ID of the most recent run.
72
+
73
+ Args:
74
+ project_name: Optional project name (uses default if not provided)
75
+
76
+ Returns:
77
+ Run ID string or None
78
+ """
79
+ client = get_client()
80
+ if not client:
81
+ return None
82
+
83
+ project = project_name or get_project_name()
84
+
85
+ try:
86
+ runs = list(client.list_runs(project_name=project, limit=1))
87
+ if runs:
88
+ return str(runs[0].id)
89
+ except Exception as e:
90
+ logger.warning("Could not get latest run: %s", e)
91
+
92
+ return None
93
+
94
+
95
+ def share_run(run_id: str | None = None) -> str | None:
96
+ """Create a public share link for a run.
97
+
98
+ Args:
99
+ run_id: Run ID (uses latest if not provided)
100
+
101
+ Returns:
102
+ Public URL string or None if failed
103
+
104
+ Example:
105
+ >>> url = share_run()
106
+ >>> print(url)
107
+ https://eu.smith.langchain.com/public/abc123.../r
108
+ """
109
+ client = get_client()
110
+ if not client:
111
+ return None
112
+
113
+ if not run_id:
114
+ run_id = get_latest_run_id()
115
+
116
+ if not run_id:
117
+ return None
118
+
119
+ try:
120
+ # Use the share_run method from LangSmith SDK
121
+ return client.share_run(run_id)
122
+ except Exception as e:
123
+ logger.warning("Could not share run: %s", e)
124
+ return None
125
+
126
+
127
+ def read_run_shared_link(run_id: str) -> str | None:
128
+ """Get existing share link for a run if it exists.
129
+
130
+ Args:
131
+ run_id: The run ID to check
132
+
133
+ Returns:
134
+ Public URL string or None if not shared
135
+ """
136
+ client = get_client()
137
+ if not client:
138
+ return None
139
+
140
+ try:
141
+ return client.read_run_shared_link(run_id)
142
+ except Exception as e:
143
+ logger.debug("Could not read run shared link for %s: %s", run_id, e)
144
+ return None
145
+
146
+
147
+ def print_run_tree(run_id: str | None = None, verbose: bool = False) -> None:
148
+ """Print an execution tree for a run.
149
+
150
+ Args:
151
+ run_id: Specific run ID (uses latest if not provided)
152
+ verbose: Include timing and status details
153
+ """
154
+ client = get_client()
155
+ if not client:
156
+ logger.warning("LangSmith client not available")
157
+ return
158
+
159
+ if not run_id:
160
+ run_id = get_latest_run_id()
161
+
162
+ if not run_id:
163
+ logger.warning("No run found")
164
+ return
165
+
166
+ try:
167
+ run = client.read_run(run_id)
168
+ _print_run_node(run, client, verbose=verbose, indent=0)
169
+ except Exception as e:
170
+ logger.warning("Error reading run: %s", e)
171
+
172
+
173
+ def _print_run_node(
174
+ run,
175
+ client,
176
+ verbose: bool = False,
177
+ indent: int = 0,
178
+ is_last: bool = True,
179
+ prefix: str = "",
180
+ ):
181
+ """Recursively print a run node and its children in tree format.
182
+
183
+ Args:
184
+ run: The LangSmith run object
185
+ client: LangSmith client
186
+ verbose: Include timing details
187
+ indent: Current indentation level
188
+ is_last: Whether this is the last sibling
189
+ prefix: Prefix string for tree drawing
190
+ """
191
+ # Status emoji
192
+ if run.status == "success":
193
+ status = "✅"
194
+ elif run.status == "error":
195
+ status = "❌"
196
+ else:
197
+ status = "⏳"
198
+
199
+ # Timing
200
+ timing = ""
201
+ if run.end_time and run.start_time:
202
+ duration = (run.end_time - run.start_time).total_seconds()
203
+ timing = f" ({duration:.1f}s)"
204
+
205
+ # Tree connectors
206
+ if indent == 0:
207
+ connector = "📊 "
208
+ new_prefix = ""
209
+ else:
210
+ connector = "└─ " if is_last else "├─ "
211
+ new_prefix = prefix + (" " if is_last else "│ ")
212
+
213
+ # Clean up run name for display
214
+ display_name = run.name
215
+ if display_name.startswith("Chat"):
216
+ display_name = f"🤖 {display_name}"
217
+ elif "generate" in display_name.lower():
218
+ display_name = f"📝 {display_name}"
219
+ elif "analyze" in display_name.lower():
220
+ display_name = f"🔍 {display_name}"
221
+ elif "summarize" in display_name.lower():
222
+ display_name = f"📊 {display_name}"
223
+
224
+ logger.info("%s%s%s%s %s", prefix, connector, display_name, timing, status)
225
+
226
+ # Get child runs
227
+ try:
228
+ children = list(
229
+ client.list_runs(
230
+ parent_run_id=run.id,
231
+ limit=50,
232
+ )
233
+ )
234
+ # Sort by start time to show in execution order
235
+ children.sort(key=lambda r: r.start_time or datetime.min)
236
+
237
+ for i, child in enumerate(children):
238
+ child_is_last = i == len(children) - 1
239
+ _print_run_node(
240
+ child,
241
+ client,
242
+ verbose=verbose,
243
+ indent=indent + 1,
244
+ is_last=child_is_last,
245
+ prefix=new_prefix,
246
+ )
247
+ except Exception as e:
248
+ logger.debug("Could not fetch child runs for %s: %s", run.id, e)
249
+
250
+
251
+ def log_execution(
252
+ step_name: str,
253
+ inputs: dict | None = None,
254
+ outputs: dict | None = None,
255
+ log_dir: str | Path | None = None,
256
+ ) -> None:
257
+ """Log execution details to a file.
258
+
259
+ Args:
260
+ step_name: Name of the pipeline step
261
+ inputs: Input data for the step
262
+ outputs: Output data from the step
263
+ log_dir: Directory for log files (default: outputs/logs)
264
+ """
265
+ import json
266
+
267
+ if log_dir is None:
268
+ log_dir = WORKING_DIR / "outputs" / "logs"
269
+ log_path = Path(log_dir)
270
+ log_path.mkdir(parents=True, exist_ok=True)
271
+
272
+ log_file = log_path / f"{datetime.now().strftime('%Y%m%d')}_execution.jsonl"
273
+
274
+ entry = {
275
+ "timestamp": datetime.now().isoformat(),
276
+ "step": step_name,
277
+ "inputs": inputs or {},
278
+ "outputs": outputs or {},
279
+ }
280
+
281
+ with open(log_file, "a") as f:
282
+ f.write(json.dumps(entry, default=str) + "\n")
283
+
284
+
285
+ def get_run_url(run_id: str | None = None) -> str | None:
286
+ """Get the LangSmith URL for a run.
287
+
288
+ Args:
289
+ run_id: Run ID (uses latest if not provided)
290
+
291
+ Returns:
292
+ URL string or None
293
+ """
294
+ if not run_id:
295
+ run_id = get_latest_run_id()
296
+
297
+ if not run_id:
298
+ return None
299
+
300
+ endpoint = os.environ.get("LANGCHAIN_ENDPOINT", "https://api.smith.langchain.com")
301
+ project = get_project_name()
302
+
303
+ # Convert API endpoint to web URL
304
+ web_url = endpoint.replace("api.", "").replace("/api", "")
305
+ if "smith.langchain" in web_url:
306
+ return f"{web_url}/o/default/projects/p/{project}/runs/{run_id}"
307
+
308
+ return f"{web_url}/projects/{project}/runs/{run_id}"
@@ -0,0 +1,118 @@
1
+ """LLM Factory - Multi-provider abstraction for language models.
2
+
3
+ This module provides a simple factory pattern for creating LLM instances
4
+ across different providers (Anthropic, Mistral, OpenAI).
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import threading
10
+ from typing import Literal
11
+
12
+ from langchain_core.language_models.chat_models import BaseChatModel
13
+
14
+ from yamlgraph.config import DEFAULT_MODELS
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Type alias for supported providers
19
+ ProviderType = Literal["anthropic", "mistral", "openai"]
20
+
21
+ # Thread-safe cache for LLM instances
22
+ _llm_cache: dict[tuple, BaseChatModel] = {}
23
+ _cache_lock = threading.Lock()
24
+
25
+
26
+ def create_llm(
27
+ provider: ProviderType | None = None,
28
+ model: str | None = None,
29
+ temperature: float = 0.7,
30
+ ) -> BaseChatModel:
31
+ """Create an LLM instance with multi-provider support.
32
+
33
+ Supports Anthropic (default), Mistral, and OpenAI providers.
34
+ Provider can be specified via parameter or PROVIDER environment variable.
35
+ Model can be specified via parameter or {PROVIDER}_MODEL environment variable.
36
+
37
+ LLM instances are cached by (provider, model, temperature) to improve performance.
38
+
39
+ Args:
40
+ provider: LLM provider ("anthropic", "mistral", "openai").
41
+ Defaults to PROVIDER env var or "anthropic".
42
+ model: Model name. Defaults to {PROVIDER}_MODEL env var or provider default.
43
+ temperature: Temperature for generation (0.0-1.0).
44
+
45
+ Returns:
46
+ Configured LLM instance.
47
+
48
+ Raises:
49
+ ValueError: If provider is invalid.
50
+
51
+ Examples:
52
+ >>> # Use default Anthropic
53
+ >>> llm = create_llm(temperature=0.7)
54
+
55
+ >>> # Override provider
56
+ >>> llm = create_llm(provider="mistral", temperature=0.8)
57
+
58
+ >>> # Custom model
59
+ >>> llm = create_llm(provider="openai", model="gpt-4o-mini")
60
+ """
61
+ # Determine provider (parameter > env var > default)
62
+ selected_provider = provider or os.getenv("PROVIDER") or "anthropic"
63
+
64
+ # Validate provider
65
+ if selected_provider not in DEFAULT_MODELS:
66
+ raise ValueError(
67
+ f"Invalid provider: {selected_provider}. "
68
+ f"Must be one of: {', '.join(DEFAULT_MODELS.keys())}"
69
+ )
70
+
71
+ # Determine model (parameter > env var > default)
72
+ # Note: DEFAULT_MODELS already handles env var via config.py
73
+ selected_model = model or DEFAULT_MODELS[selected_provider]
74
+
75
+ # Create cache key
76
+ cache_key = (selected_provider, selected_model, temperature)
77
+
78
+ # Thread-safe cache access
79
+ with _cache_lock:
80
+ # Return cached instance if available
81
+ if cache_key in _llm_cache:
82
+ logger.debug(
83
+ f"Using cached LLM: {selected_provider}/{selected_model} (temp={temperature})"
84
+ )
85
+ return _llm_cache[cache_key]
86
+
87
+ # Create new LLM instance
88
+ logger.info(
89
+ f"Creating LLM: {selected_provider}/{selected_model} (temp={temperature})"
90
+ )
91
+
92
+ if selected_provider == "mistral":
93
+ from langchain_mistralai import ChatMistralAI
94
+
95
+ llm = ChatMistralAI(model=selected_model, temperature=temperature)
96
+ elif selected_provider == "openai":
97
+ from langchain_openai import ChatOpenAI
98
+
99
+ llm = ChatOpenAI(model=selected_model, temperature=temperature)
100
+ else: # anthropic (default)
101
+ from langchain_anthropic import ChatAnthropic
102
+
103
+ llm = ChatAnthropic(model=selected_model, temperature=temperature)
104
+
105
+ # Cache the instance
106
+ _llm_cache[cache_key] = llm
107
+
108
+ return llm
109
+
110
+
111
+ def clear_cache() -> None:
112
+ """Clear the LLM instance cache.
113
+
114
+ Useful for testing or when you want to force recreation of LLM instances.
115
+ """
116
+ with _cache_lock:
117
+ _llm_cache.clear()
118
+ logger.debug("LLM cache cleared")
@@ -0,0 +1,105 @@
1
+ """Async LLM Factory - Async versions of LLM creation.
2
+
3
+ This module provides async-compatible LLM creation with support for
4
+ non-blocking I/O operations in async contexts.
5
+
6
+ Note: This module is a foundation for future async support. Currently,
7
+ LangChain's LLM implementations use sync HTTP clients internally, so
8
+ this wraps them for use in async contexts via run_in_executor.
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from functools import partial
15
+ from typing import TypeVar
16
+
17
+ from langchain_core.language_models.chat_models import BaseChatModel
18
+ from langchain_core.messages import BaseMessage
19
+ from pydantic import BaseModel
20
+
21
+ from yamlgraph.utils.llm_factory import ProviderType, create_llm
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ T = TypeVar("T", bound=BaseModel)
26
+
27
+ # Shared executor for running sync LLM calls
28
+ _executor: ThreadPoolExecutor | None = None
29
+
30
+
31
+ def get_executor() -> ThreadPoolExecutor:
32
+ """Get or create the shared thread pool executor."""
33
+ global _executor
34
+ if _executor is None:
35
+ _executor = ThreadPoolExecutor(max_workers=4)
36
+ return _executor
37
+
38
+
39
+ async def create_llm_async(
40
+ provider: ProviderType | None = None,
41
+ model: str | None = None,
42
+ temperature: float = 0.7,
43
+ ) -> BaseChatModel:
44
+ """Create an LLM instance asynchronously.
45
+
46
+ Currently wraps the sync create_llm. Future versions may use
47
+ native async LLM implementations.
48
+
49
+ Args:
50
+ provider: LLM provider ("anthropic", "mistral", "openai")
51
+ model: Model name
52
+ temperature: Temperature for generation
53
+
54
+ Returns:
55
+ Configured LLM instance
56
+ """
57
+ loop = asyncio.get_event_loop()
58
+ return await loop.run_in_executor(
59
+ get_executor(),
60
+ partial(create_llm, provider=provider, model=model, temperature=temperature),
61
+ )
62
+
63
+
64
+ async def invoke_async(
65
+ llm: BaseChatModel,
66
+ messages: list[BaseMessage],
67
+ output_model: type[T] | None = None,
68
+ ) -> T | str:
69
+ """Invoke LLM asynchronously.
70
+
71
+ Runs the sync invoke in a thread pool to avoid blocking.
72
+
73
+ Args:
74
+ llm: The LLM instance
75
+ messages: Messages to send
76
+ output_model: Optional Pydantic model for structured output
77
+
78
+ Returns:
79
+ LLM response (parsed model or string)
80
+ """
81
+ loop = asyncio.get_event_loop()
82
+
83
+ def sync_invoke() -> T | str:
84
+ if output_model:
85
+ structured_llm = llm.with_structured_output(output_model)
86
+ return structured_llm.invoke(messages)
87
+ else:
88
+ response = llm.invoke(messages)
89
+ return response.content
90
+
91
+ return await loop.run_in_executor(get_executor(), sync_invoke)
92
+
93
+
94
+ def shutdown_executor() -> None:
95
+ """Shutdown the thread pool executor.
96
+
97
+ Call this during application shutdown to clean up resources.
98
+ """
99
+ global _executor
100
+ if _executor is not None:
101
+ _executor.shutdown(wait=True)
102
+ _executor = None
103
+
104
+
105
+ __all__ = ["create_llm_async", "invoke_async", "shutdown_executor"]
@@ -0,0 +1,127 @@
1
+ """Structured logging configuration for yamlgraph.
2
+
3
+ Provides consistent logging across all modules with JSON-formatted
4
+ output for production environments.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import sys
10
+ from typing import Any
11
+
12
+
13
+ class StructuredFormatter(logging.Formatter):
14
+ """Formatter that outputs structured log messages.
15
+
16
+ In production (LOG_FORMAT=json), outputs JSON lines.
17
+ In development, outputs human-readable format.
18
+ """
19
+
20
+ def __init__(self, use_json: bool = False):
21
+ super().__init__()
22
+ self.use_json = use_json
23
+
24
+ def format(self, record: logging.LogRecord) -> str:
25
+ """Format a log record."""
26
+ if self.use_json:
27
+ import json
28
+
29
+ log_data = {
30
+ "timestamp": self.formatTime(record),
31
+ "level": record.levelname,
32
+ "logger": record.name,
33
+ "message": record.getMessage(),
34
+ }
35
+ # Add extra fields if present
36
+ if hasattr(record, "extra"):
37
+ log_data.update(record.extra)
38
+ if record.exc_info:
39
+ log_data["exception"] = self.formatException(record.exc_info)
40
+ return json.dumps(log_data)
41
+ else:
42
+ # Human-readable format
43
+ base = f"{self.formatTime(record)} [{record.levelname}] {record.name}: {record.getMessage()}"
44
+ if record.exc_info:
45
+ base += f"\n{self.formatException(record.exc_info)}"
46
+ return base
47
+
48
+
49
+ def setup_logging(
50
+ level: str | None = None,
51
+ use_json: bool | None = None,
52
+ ) -> logging.Logger:
53
+ """Configure logging for yamlgraph.
54
+
55
+ Args:
56
+ level: Log level (DEBUG, INFO, WARNING, ERROR).
57
+ Defaults to LOG_LEVEL env var or INFO.
58
+ use_json: If True, output JSON lines.
59
+ Defaults to LOG_FORMAT=json env var.
60
+
61
+ Returns:
62
+ Root logger for the yamlgraph package
63
+ """
64
+ if level is None:
65
+ level = os.getenv("LOG_LEVEL", "INFO")
66
+
67
+ if use_json is None:
68
+ use_json = os.getenv("LOG_FORMAT", "").lower() == "json"
69
+
70
+ # Get the yamlgraph logger
71
+ logger = logging.getLogger("yamlgraph")
72
+ logger.setLevel(getattr(logging, level.upper()))
73
+
74
+ # Remove existing handlers
75
+ logger.handlers.clear()
76
+
77
+ # Add handler with formatter
78
+ handler = logging.StreamHandler(sys.stderr)
79
+ handler.setFormatter(StructuredFormatter(use_json=use_json))
80
+ logger.addHandler(handler)
81
+
82
+ # Don't propagate to root logger
83
+ logger.propagate = False
84
+
85
+ return logger
86
+
87
+
88
+ def get_logger(name: str) -> logging.Logger:
89
+ """Get a logger for a specific module.
90
+
91
+ Args:
92
+ name: Module name (typically __name__)
93
+
94
+ Returns:
95
+ Logger instance
96
+
97
+ Example:
98
+ >>> logger = get_logger(__name__)
99
+ >>> logger.info("Processing started", extra={"topic": "AI"})
100
+ """
101
+ return logging.getLogger(name)
102
+
103
+
104
+ def log_with_context(
105
+ logger: logging.Logger,
106
+ level: int,
107
+ message: str,
108
+ **context: Any,
109
+ ) -> None:
110
+ """Log a message with additional context fields.
111
+
112
+ Args:
113
+ logger: Logger instance
114
+ level: Log level (logging.INFO, etc.)
115
+ message: Log message
116
+ **context: Additional context fields
117
+
118
+ Example:
119
+ >>> log_with_context(logger, logging.INFO, "Node completed",
120
+ ... node="generate", duration=1.5)
121
+ """
122
+ extra = {"extra": context} if context else {}
123
+ logger.log(level, message, extra=extra)
124
+
125
+
126
+ # Initialize logging on import
127
+ _root_logger = setup_logging()