epi-recorder 1.0.0__py3-none-any.whl → 1.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.
epi_recorder/api.py CHANGED
@@ -5,13 +5,15 @@ Provides a context manager for recording EPI packages programmatically
5
5
  with minimal code changes.
6
6
  """
7
7
 
8
+ import functools
8
9
  import json
10
+ import os
9
11
  import shutil
10
12
  import tempfile
11
13
  import threading
12
14
  from datetime import datetime
13
15
  from pathlib import Path
14
- from typing import Any, Dict, List, Optional
16
+ from typing import Any, Callable, Dict, List, Optional, Union
15
17
 
16
18
  from epi_core.container import EPIContainer
17
19
  from epi_core.schemas import ManifestModel
@@ -45,7 +47,13 @@ class EpiRecorderSession:
45
47
  tags: Optional[List[str]] = None,
46
48
  auto_sign: bool = True,
47
49
  redact: bool = True,
48
- default_key_name: str = "default"
50
+ default_key_name: str = "default",
51
+ # New metadata fields
52
+ goal: Optional[str] = None,
53
+ notes: Optional[str] = None,
54
+ metrics: Optional[Dict[str, Union[float, str]]] = None,
55
+ approved_by: Optional[str] = None,
56
+ metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict with tags parameter
49
57
  ):
50
58
  """
51
59
  Initialize EPI recording session.
@@ -57,6 +65,11 @@ class EpiRecorderSession:
57
65
  auto_sign: Whether to automatically sign on exit (default: True)
58
66
  redact: Whether to redact secrets (default: True)
59
67
  default_key_name: Name of key to use for signing (default: "default")
68
+ goal: Goal or objective of this workflow execution
69
+ notes: Additional notes or context about this workflow
70
+ metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
71
+ approved_by: Person or entity who approved this workflow execution
72
+ metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
60
73
  """
61
74
  self.output_path = Path(output_path)
62
75
  self.workflow_name = workflow_name or "untitled"
@@ -65,6 +78,13 @@ class EpiRecorderSession:
65
78
  self.redact = redact
66
79
  self.default_key_name = default_key_name
67
80
 
81
+ # New metadata fields
82
+ self.goal = goal
83
+ self.notes = notes
84
+ self.metrics = metrics
85
+ self.approved_by = approved_by
86
+ self.metadata_tags = metadata_tags
87
+
68
88
  # Runtime state
69
89
  self.temp_dir: Optional[Path] = None
70
90
  self.recording_context: Optional[RecordingContext] = None
@@ -97,9 +117,9 @@ class EpiRecorderSession:
97
117
  set_recording_context(self.recording_context)
98
118
  _thread_local.active_session = self
99
119
 
100
- # Patch LLM libraries
101
- patch_openai() # Patches OpenAI if available
102
- # TODO: Add more patchers (Anthropic, etc.)
120
+ # Patch LLM libraries and HTTP
121
+ from epi_recorder.patcher import patch_all
122
+ patch_all()
103
123
 
104
124
  # Log session start
105
125
  self.log_step("session.start", {
@@ -134,16 +154,19 @@ class EpiRecorderSession:
134
154
  duration = (end_time - self.start_time).total_seconds()
135
155
 
136
156
  self.log_step("session.end", {
137
- "workflow_name": self.workflow_name,
138
157
  "timestamp": end_time.isoformat(),
139
158
  "duration_seconds": duration,
140
159
  "success": exc_type is None
141
160
  })
142
161
 
143
- # Create manifest
144
- # Note: workflow_name and tags are logged in steps, not manifest
162
+ # Create manifest with metadata
145
163
  manifest = ManifestModel(
146
- created_at=self.start_time
164
+ created_at=self.start_time,
165
+ goal=self.goal,
166
+ notes=self.notes,
167
+ metrics=self.metrics,
168
+ approved_by=self.approved_by,
169
+ tags=self.metadata_tags
147
170
  )
148
171
 
149
172
  # Pack into .epi file
@@ -333,9 +356,10 @@ class EpiRecorderSession:
333
356
  )
334
357
 
335
358
  # Repack the ZIP with signed manifest
336
- self.output_path.unlink() # Remove old file
359
+ # CRITICAL: Write to temp file first to prevent data loss
360
+ temp_output = self.output_path.with_suffix('.epi.tmp')
337
361
 
338
- with zipfile.ZipFile(self.output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
362
+ with zipfile.ZipFile(temp_output, 'w', zipfile.ZIP_DEFLATED) as zf:
339
363
  # Write mimetype first (uncompressed)
340
364
  from epi_core.container import EPI_MIMETYPE
341
365
  zf.writestr("mimetype", EPI_MIMETYPE, compress_type=zipfile.ZIP_STORED)
@@ -346,36 +370,209 @@ class EpiRecorderSession:
346
370
  arc_name = str(file_path.relative_to(tmp_path)).replace("\\", "/")
347
371
  zf.write(file_path, arc_name)
348
372
 
373
+ # Successfully created signed file, now safely replace original
374
+ self.output_path.unlink()
375
+ temp_output.rename(self.output_path)
376
+
349
377
  except Exception as e:
350
378
  # Non-fatal: log warning but continue
351
379
  print(f"Warning: Failed to sign .epi file: {e}")
352
380
 
353
381
 
354
- # Convenience function for users
382
+ def _auto_generate_output_path(name_hint: Optional[str] = None) -> Path:
383
+ """
384
+ Auto-generate output path in ./epi-recordings/ directory.
385
+
386
+ Args:
387
+ name_hint: Optional base name hint (script name, function name, etc.)
388
+
389
+ Returns:
390
+ Path object for the .epi file
391
+ """
392
+ # Get recordings directory from env or default
393
+ recordings_dir = Path(os.getenv("EPI_RECORDINGS_DIR", "epi-recordings"))
394
+ recordings_dir.mkdir(parents=True, exist_ok=True)
395
+
396
+ # Generate base name
397
+ if name_hint:
398
+ base = Path(name_hint).stem if "." in name_hint else name_hint
399
+ else:
400
+ base = "recording"
401
+
402
+ # Generate timestamp
403
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
404
+
405
+ # Ensure .epi extension
406
+ filename = f"{base}_{timestamp}.epi"
407
+
408
+ return recordings_dir / filename
409
+
410
+
411
+ def _resolve_output_path(output_path: Optional[Path | str]) -> Path:
412
+ """
413
+ Resolve output path, adding .epi extension and default directory if needed.
414
+
415
+ Args:
416
+ output_path: User-provided path or None for auto-generation
417
+
418
+ Returns:
419
+ Resolved Path object
420
+ """
421
+ if output_path is None:
422
+ return _auto_generate_output_path()
423
+
424
+ path = Path(output_path)
425
+
426
+ # Add .epi extension if missing
427
+ if path.suffix != ".epi":
428
+ path = path.with_suffix(".epi")
429
+
430
+ return path
431
+
432
+
433
+ # Convenience function for users (supports zero-config)
355
434
  def record(
356
- output_path: Path | str,
435
+ output_path: Optional[Path | str] = None,
357
436
  workflow_name: Optional[str] = None,
437
+ tags: Optional[List[str]] = None,
438
+ auto_sign: bool = True,
439
+ redact: bool = True,
440
+ default_key_name: str = "default",
441
+ # New metadata fields
442
+ goal: Optional[str] = None,
443
+ notes: Optional[str] = None,
444
+ metrics: Optional[Dict[str, Union[float, str]]] = None,
445
+ approved_by: Optional[str] = None,
446
+ metadata_tags: Optional[List[str]] = None, # Renamed to avoid conflict
358
447
  **kwargs
359
- ) -> EpiRecorderSession:
448
+ ) -> Union[EpiRecorderSession, Callable]:
360
449
  """
361
450
  Create an EPI recording session (context manager).
362
451
 
363
452
  Args:
364
- output_path: Path for output .epi file
453
+ output_path: Path for output .epi file (optional - auto-generates if None)
365
454
  workflow_name: Descriptive name for workflow
366
- **kwargs: Additional arguments (tags, auto_sign, redact, default_key_name)
455
+ tags: Tags for categorization
456
+ auto_sign: Whether to automatically sign on exit (default: True)
457
+ redact: Whether to redact secrets (default: True)
458
+ default_key_name: Name of key to use for signing (default: "default")
459
+ goal: Goal or objective of this workflow execution
460
+ notes: Additional notes or context about this workflow
461
+ metrics: Key-value metrics for this workflow (accuracy, latency, etc.)
462
+ approved_by: Person or entity who approved this workflow execution
463
+ metadata_tags: Tags for categorizing this workflow (renamed from tags to avoid conflict)
464
+ **kwargs: Additional arguments (backward compatibility)
367
465
 
368
466
  Returns:
369
- EpiRecorderSession context manager
467
+ EpiRecorderSession context manager or decorated function
370
468
 
371
469
  Example:
372
470
  from epi_recorder import record
373
471
 
374
- with record("my_workflow.epi", workflow_name="Demo"):
472
+ # Zero-config (auto-generates filename in ./epi-recordings/)
473
+ with record():
474
+ # Your code here
475
+ pass
476
+
477
+ # With custom name
478
+ with record("my_workflow"):
479
+ # Your code here
480
+ pass
481
+
482
+ # With metadata
483
+ with record(
484
+ goal="reduce hallucinations",
485
+ notes="switched to GPT-4",
486
+ metrics={"accuracy": 0.89},
487
+ approved_by="alice@company.com",
488
+ metadata_tags=["prod-candidate"]
489
+ ):
490
+ # Your code here
491
+ pass
492
+
493
+ # Decorator usage
494
+ @record
495
+ def main():
496
+ # Your code here
497
+ pass
498
+
499
+ # Decorator with metadata
500
+ @record(goal="decorator test", metrics={"test_score": 0.95})
501
+ def main():
375
502
  # Your code here
376
503
  pass
377
504
  """
378
- return EpiRecorderSession(output_path, workflow_name, **kwargs)
505
+ # Check if this is being used as a decorator with arguments
506
+ # If the first argument is not a path but keyword arguments are provided,
507
+ # we need to return a decorator function
508
+ if output_path is None and (goal is not None or notes is not None or metrics is not None or
509
+ approved_by is not None or metadata_tags is not None):
510
+ # This is a decorator with arguments, return a decorator function
511
+ def decorator(func):
512
+ @functools.wraps(func)
513
+ def wrapper(*args, **kwargs):
514
+ # Auto-generate path based on function name
515
+ auto_path = _auto_generate_output_path(func.__name__)
516
+ with EpiRecorderSession(
517
+ auto_path,
518
+ workflow_name or func.__name__,
519
+ tags=tags,
520
+ auto_sign=auto_sign,
521
+ redact=redact,
522
+ default_key_name=default_key_name,
523
+ goal=goal,
524
+ notes=notes,
525
+ metrics=metrics,
526
+ approved_by=approved_by,
527
+ metadata_tags=metadata_tags,
528
+ **kwargs
529
+ ):
530
+ return func(*args, **kwargs)
531
+ return wrapper
532
+ return decorator
533
+
534
+ # Handle decorator usage: record is called without parentheses
535
+ if callable(output_path):
536
+ func = output_path
537
+
538
+ @functools.wraps(func)
539
+ def wrapper(*args, **kwargs):
540
+ # Auto-generate path based on function name
541
+ auto_path = _auto_generate_output_path(func.__name__)
542
+ with EpiRecorderSession(
543
+ auto_path,
544
+ workflow_name or func.__name__,
545
+ tags=tags,
546
+ auto_sign=auto_sign,
547
+ redact=redact,
548
+ default_key_name=default_key_name,
549
+ goal=goal,
550
+ notes=notes,
551
+ metrics=metrics,
552
+ approved_by=approved_by,
553
+ metadata_tags=metadata_tags,
554
+ **kwargs
555
+ ):
556
+ return func(*args, **kwargs)
557
+
558
+ return wrapper
559
+
560
+ # Normal context manager usage
561
+ resolved_path = _resolve_output_path(output_path)
562
+ return EpiRecorderSession(
563
+ resolved_path,
564
+ workflow_name,
565
+ tags=tags,
566
+ auto_sign=auto_sign,
567
+ redact=redact,
568
+ default_key_name=default_key_name,
569
+ goal=goal,
570
+ notes=notes,
571
+ metrics=metrics,
572
+ approved_by=approved_by,
573
+ metadata_tags=metadata_tags,
574
+ **kwargs
575
+ )
379
576
 
380
577
 
381
578
  # Make it easy to get current session
@@ -386,4 +583,4 @@ def get_current_session() -> Optional[EpiRecorderSession]:
386
583
  Returns:
387
584
  EpiRecorderSession or None
388
585
  """
389
- return getattr(_thread_local, 'active_session', None)
586
+ return getattr(_thread_local, 'active_session', None)
@@ -214,3 +214,24 @@ def get_environment_summary() -> str:
214
214
  lines.append(f"Working Directory: {env['working_directory']['path']}")
215
215
 
216
216
  return "\n".join(lines)
217
+
218
+
219
+ # Backward compatibility alias
220
+ def capture_environment(
221
+ include_all_env_vars: bool = False,
222
+ redact_env_vars: bool = True
223
+ ) -> Dict[str, Any]:
224
+ """
225
+ Alias for capture_full_environment for backward compatibility.
226
+
227
+ Args:
228
+ include_all_env_vars: Whether to include all environment variables
229
+ redact_env_vars: Whether to redact sensitive env vars
230
+
231
+ Returns:
232
+ dict: Complete environment snapshot
233
+ """
234
+ return capture_full_environment(
235
+ include_all_env_vars=include_all_env_vars,
236
+ redact_env_vars=redact_env_vars
237
+ )
epi_recorder/patcher.py CHANGED
@@ -32,7 +32,7 @@ class RecordingContext:
32
32
  enable_redaction: Whether to redact secrets (default: True)
33
33
  """
34
34
  self.output_dir = output_dir
35
- self.steps: List[StepModel] = []
35
+ # self.steps: List[StepModel] = [] # Removed for scalability
36
36
  self.step_index = 0
37
37
  self.enable_redaction = enable_redaction
38
38
  self.redactor = get_default_redactor() if enable_redaction else None
@@ -83,8 +83,8 @@ class RecordingContext:
83
83
  # Write to file
84
84
  self._write_step(step)
85
85
 
86
- # Store in memory
87
- self.steps.append(step)
86
+ # Store in memory - REMOVED for scalability
87
+ # self.steps.append(step)
88
88
  self.step_index += 1
89
89
 
90
90
  def _write_step(self, step: StepModel) -> None:
@@ -325,9 +325,91 @@ def _patch_openai_legacy() -> bool:
325
325
  return False
326
326
 
327
327
 
328
+ return results
329
+
330
+
331
+ def patch_requests() -> bool:
332
+ """
333
+ Patch the requests library to intercept all HTTP calls.
334
+
335
+ Returns:
336
+ bool: True if patching succeeded, False otherwise
337
+ """
338
+ try:
339
+ import requests
340
+ from requests.sessions import Session
341
+
342
+ # Store original method
343
+ original_request = Session.request
344
+
345
+ @wraps(original_request)
346
+ def wrapped_request(self, method, url, *args, **kwargs):
347
+ """Wrapped requests.Session.request with recording."""
348
+
349
+ # Only record if context is active
350
+ if not is_recording():
351
+ return original_request(self, method, url, *args, **kwargs)
352
+
353
+ context = get_recording_context()
354
+ start_time = time.time()
355
+
356
+ # Capture request
357
+ # We don't capture full body by default to avoid massive logs,
358
+ # but we capture metadata
359
+ request_data = {
360
+ "provider": "http",
361
+ "method": method,
362
+ "url": url,
363
+ "headers": dict(kwargs.get("headers", {})),
364
+ }
365
+
366
+ # Log request step
367
+ context.add_step("http.request", request_data)
368
+
369
+ # Execute original call
370
+ try:
371
+ response = original_request(self, method, url, *args, **kwargs)
372
+ elapsed = time.time() - start_time
373
+
374
+ # Capture response
375
+ response_data = {
376
+ "provider": "http",
377
+ "status_code": response.status_code,
378
+ "reason": response.reason,
379
+ "url": response.url,
380
+ "headers": dict(response.headers),
381
+ "latency_seconds": round(elapsed, 3)
382
+ }
383
+
384
+ # Log response step
385
+ context.add_step("http.response", response_data)
386
+
387
+ return response
388
+
389
+ except Exception as e:
390
+ # Log error step
391
+ context.add_step("http.error", {
392
+ "provider": "http",
393
+ "error": str(e),
394
+ "error_type": type(e).__name__,
395
+ "url": url
396
+ })
397
+ raise
398
+
399
+ # Apply patch
400
+ Session.request = wrapped_request
401
+ return True
402
+
403
+ except ImportError:
404
+ return False
405
+ except Exception as e:
406
+ print(f"Warning: Failed to patch requests: {e}")
407
+ return False
408
+
409
+
328
410
  def patch_all() -> Dict[str, bool]:
329
411
  """
330
- Patch all supported LLM providers.
412
+ Patch all supported LLM providers and HTTP libraries.
331
413
 
332
414
  Returns:
333
415
  dict: Provider name -> success status
@@ -337,9 +419,8 @@ def patch_all() -> Dict[str, bool]:
337
419
  # Patch OpenAI
338
420
  results["openai"] = patch_openai()
339
421
 
340
- # Future: Add Anthropic, Gemini, etc.
341
- # results["anthropic"] = patch_anthropic()
342
- # results["gemini"] = patch_gemini()
422
+ # Patch generic requests (covers LangChain, Anthropic, etc.)
423
+ results["requests"] = patch_requests()
343
424
 
344
425
  return results
345
426