envfix 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {envfix-0.4.0 → envfix-0.5.0}/PKG-INFO +4 -1
  2. {envfix-0.4.0 → envfix-0.5.0}/envfix/__init__.py +1 -1
  3. {envfix-0.4.0 → envfix-0.5.0}/envfix/ai.py +3 -79
  4. {envfix-0.4.0 → envfix-0.5.0}/envfix/cache.py +19 -2
  5. envfix-0.5.0/envfix/config.py +66 -0
  6. {envfix-0.4.0 → envfix-0.5.0}/envfix/context.py +88 -0
  7. envfix-0.5.0/envfix/dependencies.py +102 -0
  8. envfix-0.5.0/envfix/embeddings.py +43 -0
  9. envfix-0.5.0/envfix/git_utils.py +63 -0
  10. {envfix-0.4.0 → envfix-0.5.0}/envfix/logger.py +7 -0
  11. {envfix-0.4.0 → envfix-0.5.0}/envfix/main.py +232 -7
  12. envfix-0.5.0/envfix/providers/__init__.py +44 -0
  13. envfix-0.5.0/envfix/providers/base.py +29 -0
  14. envfix-0.5.0/envfix/providers/gemini.py +40 -0
  15. envfix-0.5.0/envfix/providers/groq.py +42 -0
  16. envfix-0.5.0/envfix/providers/ollama.py +36 -0
  17. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/PKG-INFO +4 -1
  18. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/SOURCES.txt +14 -1
  19. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/requires.txt +4 -0
  20. {envfix-0.4.0 → envfix-0.5.0}/pyproject.toml +7 -1
  21. envfix-0.5.0/tests/test_config_yaml.py +92 -0
  22. envfix-0.5.0/tests/test_dependencies.py +77 -0
  23. envfix-0.5.0/tests/test_embeddings.py +48 -0
  24. envfix-0.5.0/tests/test_git_utils.py +62 -0
  25. {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase4.py +1 -1
  26. {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase6.py +59 -1
  27. envfix-0.5.0/tests/test_providers.py +86 -0
  28. envfix-0.4.0/envfix/config.py +0 -36
  29. {envfix-0.4.0 → envfix-0.5.0}/LICENSE +0 -0
  30. {envfix-0.4.0 → envfix-0.5.0}/README.md +0 -0
  31. {envfix-0.4.0 → envfix-0.5.0}/envfix/preview.py +0 -0
  32. {envfix-0.4.0 → envfix-0.5.0}/envfix/runner.py +0 -0
  33. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/dependency_links.txt +0 -0
  34. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/entry_points.txt +0 -0
  35. {envfix-0.4.0 → envfix-0.5.0}/envfix.egg-info/top_level.txt +0 -0
  36. {envfix-0.4.0 → envfix-0.5.0}/setup.cfg +0 -0
  37. {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase1.py +0 -0
  38. {envfix-0.4.0 → envfix-0.5.0}/tests/test_phase2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envfix
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CLI tool that diagnoses and auto-fixes Python/ML environment errors using a local LLM.
5
5
  License: Business Source License 1.1
6
6
 
@@ -116,6 +116,9 @@ Requires-Dist: groq>=0.9.0
116
116
  Requires-Dist: google-genai>=0.1.0
117
117
  Requires-Dist: tomli>=2.0.1; python_version < "3.11"
118
118
  Requires-Dist: tomli-w>=1.0.0
119
+ Requires-Dist: pyyaml>=6.0
120
+ Provides-Extra: semantic
121
+ Requires-Dist: sentence-transformers>=3.0.0; extra == "semantic"
119
122
  Dynamic: license-file
120
123
 
121
124
  # envfix 🛠️
@@ -1,3 +1,3 @@
1
1
  """envfix — Automatic Python/ML environment error fixer."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -1,6 +1,5 @@
1
- """ai.py — Calls local or cloud AI models and parses their responses."""
1
+ """ai.py — Prompt building, response parsing, and provider dispatch for envfix."""
2
2
 
3
- import os
4
3
  import re
5
4
  from dataclasses import dataclass
6
5
  from typing import TYPE_CHECKING, Optional
@@ -8,21 +7,7 @@ from typing import TYPE_CHECKING, Optional
8
7
  if TYPE_CHECKING:
9
8
  from envfix.context import CodeContext
10
9
 
11
-
12
- try:
13
- import ollama
14
- except ImportError: # pragma: no cover
15
- ollama = None # type: ignore[assignment]
16
-
17
- try:
18
- import groq
19
- except ImportError:
20
- groq = None
21
-
22
- try:
23
- from google import genai
24
- except ImportError:
25
- genai = None
10
+ from envfix.providers import get_provider
26
11
 
27
12
 
28
13
  PROMPT_TEMPLATE = (
@@ -125,68 +110,7 @@ def get_diagnosis(
125
110
  else:
126
111
  prompt = PROMPT_TEMPLATE.format(stderr=stderr, category=category)
127
112
 
128
- if provider == "ollama":
129
- if ollama is None:
130
- raise RuntimeError(
131
- "The 'ollama' Python package is not installed. "
132
- "Run: pip install ollama"
133
- )
134
- try:
135
- response = ollama.chat(
136
- model=model,
137
- messages=[{"role": "user", "content": prompt}],
138
- )
139
- raw = response["message"]["content"].strip()
140
- except Exception as exc:
141
- raise RuntimeError(
142
- f"Ollama request failed: {exc}\n"
143
- "Make sure the Ollama service is running (`ollama serve`) "
144
- f"and the model '{model}' is pulled (`ollama pull {model}`)."
145
- ) from exc
146
-
147
- elif provider == "groq":
148
- if groq is None:
149
- raise RuntimeError("The 'groq' Python package is not installed.")
150
- api_key = os.getenv("GROQ_API_KEY")
151
- if not api_key:
152
- raise RuntimeError(
153
- "GROQ_API_KEY environment variable is not set. "
154
- "Please set it to your Groq API key."
155
- )
156
- try:
157
- client = groq.Groq(api_key=api_key)
158
- actual_model = get_actual_model(model, provider)
159
- chat_completion = client.chat.completions.create(
160
- messages=[{"role": "user", "content": prompt}],
161
- model=actual_model,
162
- )
163
- raw = chat_completion.choices[0].message.content.strip()
164
- except Exception as exc:
165
- raise RuntimeError(f"Groq request failed: {exc}") from exc
166
-
167
- elif provider == "gemini":
168
- if genai is None:
169
- raise RuntimeError("The 'google-genai' Python package is not installed.")
170
- api_key = os.getenv("GEMINI_API_KEY")
171
- if not api_key:
172
- raise RuntimeError(
173
- "GEMINI_API_KEY environment variable is not set. "
174
- "Please set it to your Gemini API key."
175
- )
176
- try:
177
- client = genai.Client(api_key=api_key)
178
- actual_model = get_actual_model(model, provider)
179
- response = client.models.generate_content(
180
- model=actual_model,
181
- contents=prompt
182
- )
183
- raw = response.text.strip()
184
- except Exception as exc:
185
- raise RuntimeError(f"Gemini API call failed: {exc}") from exc
186
-
187
- else:
188
- raise ValueError(f"Unknown provider: {provider}")
189
-
113
+ raw = get_provider(provider, model).diagnose(prompt)
190
114
  return _parse_response(raw)
191
115
 
192
116
 
@@ -7,6 +7,7 @@ from difflib import SequenceMatcher
7
7
  from typing import Optional
8
8
 
9
9
  from envfix.logger import get_log_file
10
+ from envfix.embeddings import get_embedding, cosine_similarity
10
11
 
11
12
  # Minimum similarity ratio (0–1) to consider a log entry a match.
12
13
  # 0.92 is deliberately conservative: we only surface cache hits when
@@ -15,6 +16,10 @@ from envfix.logger import get_log_file
15
16
  # get matched to each other even when the missing package differs.
16
17
  SIMILARITY_THRESHOLD = 0.92
17
18
 
19
+ # Threshold for semantic cosine similarity if sentence-transformers is installed.
20
+ # 0.80 is empirically chosen to match identical errors with different variable names.
21
+ SEMANTIC_THRESHOLD = 0.80
22
+
18
23
 
19
24
  @dataclass
20
25
  class CacheHit:
@@ -76,6 +81,8 @@ def find_cached_fix(
76
81
  # We keep two best candidates: one that worked, one that was merely approved.
77
82
  best_worked: Optional[tuple[float, CacheHit]] = None
78
83
  best_approved: Optional[tuple[float, CacheHit]] = None
84
+
85
+ current_embedding = get_embedding(error_text)
79
86
 
80
87
  for entry in log_data:
81
88
  # ── Support both Phase 1 and Phase 2 schemas ─────────────────────
@@ -105,9 +112,19 @@ def find_cached_fix(
105
112
 
106
113
  if not stored_error or not fix:
107
114
  continue
115
+
116
+ stored_embedding = entry.get("embedding")
117
+ is_semantic = False
118
+
119
+ if current_embedding and stored_embedding:
120
+ score = cosine_similarity(current_embedding, stored_embedding)
121
+ is_semantic = True
122
+ hit_threshold = SEMANTIC_THRESHOLD
123
+ else:
124
+ score = SequenceMatcher(None, error_text, stored_error).ratio()
125
+ hit_threshold = SIMILARITY_THRESHOLD
108
126
 
109
- score = SequenceMatcher(None, error_text, stored_error).ratio()
110
- if score < threshold:
127
+ if score < hit_threshold:
111
128
  continue
112
129
 
113
130
  hit = CacheHit(
@@ -0,0 +1,66 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+ from typing import Dict, Any
5
+
6
+ import typer
7
+ import yaml
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+ try:
13
+ import tomllib
14
+ except ImportError:
15
+ import tomli as tomllib
16
+
17
+ import tomli_w
18
+
19
+ CONFIG_DIR = Path.home() / ".envfix"
20
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
21
+
22
+ def load_config() -> Dict[str, Any]:
23
+ """Load the persistent TOML configuration file."""
24
+ if not CONFIG_FILE.exists():
25
+ return {}
26
+
27
+ try:
28
+ with open(CONFIG_FILE, "rb") as f:
29
+ global_config = tomllib.load(f)
30
+ except Exception:
31
+ global_config = {}
32
+
33
+ project_config = load_project_config()
34
+
35
+ # Merge project config over global config
36
+ for k, v in project_config.items():
37
+ global_config[k] = v
38
+
39
+ return global_config
40
+
41
+ def load_project_config() -> Dict[str, Any]:
42
+ """Load the project-specific YAML configuration file."""
43
+ yaml_file = Path.cwd() / ".envfix.yaml"
44
+ if not yaml_file.exists():
45
+ return {}
46
+
47
+ try:
48
+ with open(yaml_file, "r", encoding="utf-8") as f:
49
+ data = yaml.safe_load(f)
50
+ return data if isinstance(data, dict) else {}
51
+ except yaml.YAMLError as e:
52
+ console.print(f"\n[bold red]✗ Failed to parse .envfix.yaml:[/bold red]\n{e}")
53
+ raise typer.Exit(code=1)
54
+ except OSError:
55
+ return {}
56
+
57
+ def save_config(data: Dict[str, Any]) -> None:
58
+ """Save the configuration dict to TOML."""
59
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
60
+ with open(CONFIG_FILE, "wb") as f:
61
+ tomli_w.dump(data, f)
62
+
63
+ def reset_config() -> None:
64
+ """Delete the configuration file."""
65
+ if CONFIG_FILE.exists():
66
+ CONFIG_FILE.unlink()
@@ -120,3 +120,91 @@ def _read_safe(filepath: str, lineno: int, root: Path) -> Optional[CodeContext]:
120
120
  )
121
121
  except (ValueError, OSError, UnicodeDecodeError):
122
122
  return None
123
+
124
+
125
+ def is_external_path(path_str: str, root: Path) -> bool:
126
+ """Check if a path indicates a framework/stdlib or is outside the project root."""
127
+ path_str = path_str.replace("\\", "/")
128
+ if any(marker in path_str for marker in ["site-packages", "node_modules", "lib/python", "vendor/"]):
129
+ return True
130
+
131
+ path_obj = Path(path_str)
132
+ if path_obj.is_absolute():
133
+ try:
134
+ path_obj.resolve().relative_to(root)
135
+ except (ValueError, OSError):
136
+ return True
137
+
138
+ return False
139
+
140
+
141
+ def trim_stack_trace(stderr: str, cwd: Optional[str] = None, ignore_patterns: Optional[list[str]] = None) -> str:
142
+ """
143
+ Trim external frames (site-packages, node_modules) from stack traces.
144
+ Strips any lines matching the provided regex patterns (ignore_patterns).
145
+ Caps the final text at ~12000 chars (approx 3000 tokens), keeping the bottom.
146
+ """
147
+ root = Path(cwd or os.getcwd()).resolve()
148
+ lines = stderr.splitlines()
149
+
150
+ compiled_patterns = []
151
+ if ignore_patterns:
152
+ for p in ignore_patterns:
153
+ try:
154
+ compiled_patterns.append(re.compile(p))
155
+ except re.error:
156
+ pass
157
+
158
+ out_lines = []
159
+ hidden_count = 0
160
+ skip_next = False
161
+
162
+ for line in lines:
163
+ if compiled_patterns and any(p.search(line) for p in compiled_patterns):
164
+ continue
165
+
166
+ if skip_next:
167
+ skip_next = False
168
+ # Python's code line under the frame is usually indented
169
+ if line.startswith(" ") or line.startswith("\t"):
170
+ continue
171
+
172
+ # Check Python frame
173
+ py_match = re.search(r'File "([^"]+)",\s*line \d+', line)
174
+ if py_match:
175
+ path = py_match.group(1)
176
+ if is_external_path(path, root):
177
+ hidden_count += 1
178
+ skip_next = True
179
+ continue
180
+
181
+ # Check Node frame
182
+ node_match = re.search(r'at\s+(?:[^\s(]+\s+\()?([^()]+?\.(?:js|ts|jsx|tsx|mjs|cjs)):(\d+)', line)
183
+ if node_match:
184
+ path = node_match.group(1)
185
+ if is_external_path(path, root):
186
+ hidden_count += 1
187
+ continue
188
+
189
+ # Keep this line, flush hidden marker if needed
190
+ if hidden_count > 0:
191
+ out_lines.append(f" [... {hidden_count} external frames hidden ...]")
192
+ hidden_count = 0
193
+
194
+ out_lines.append(line)
195
+
196
+ if hidden_count > 0:
197
+ out_lines.append(f" [... {hidden_count} external frames hidden ...]")
198
+
199
+ trimmed = "\n".join(out_lines)
200
+
201
+ # Fallback if trimming removed everything useful
202
+ if not re.search(r'[a-zA-Z]', trimmed):
203
+ trimmed = stderr
204
+
205
+ # Cap at ~12000 chars (keeping the bottom, which holds the actual error)
206
+ MAX_CHARS = 12000
207
+ if len(trimmed) > MAX_CHARS:
208
+ trimmed = "... [truncated] ...\n" + trimmed[-(MAX_CHARS - 25):]
209
+
210
+ return trimmed
@@ -0,0 +1,102 @@
1
+ """dependencies.py — Auto-append fixed packages to requirements files."""
2
+
3
+ import os
4
+ import re
5
+
6
+
7
+ def extract_package_name(fix_command: str) -> str | None:
8
+ """
9
+ Extract the package name from a pip install command.
10
+ Example: 'python -m pip install -U torch' -> 'torch'
11
+ """
12
+ # Matches 'pip install', optional flags like '-U' or '--upgrade',
13
+ # and then captures the package name.
14
+ # Note: this is a heuristic and will grab the first package if multiple are specified.
15
+ match = re.search(r"pip\s+install\s+(?:-[a-zA-Z-]+\s+)*([a-zA-Z0-9_.-]+)", fix_command, re.IGNORECASE)
16
+ if match:
17
+ pkg = match.group(1).strip()
18
+ # Ignore common flags that might have been caught if regex missed them
19
+ if not pkg.startswith("-") and not pkg.endswith(".txt"):
20
+ return pkg
21
+ return None
22
+
23
+
24
+ def update_requirements_txt(path: str, package: str) -> None:
25
+ """Append the package to requirements.txt, ensuring a trailing newline."""
26
+ if not os.path.exists(path):
27
+ return
28
+
29
+ with open(path, "r", encoding="utf-8") as f:
30
+ content = f.read()
31
+
32
+ # Avoid adding if it's already there
33
+ if re.search(rf"^{re.escape(package)}(?:[<>=!].*)?$", content, re.MULTILINE | re.IGNORECASE):
34
+ return
35
+
36
+ with open(path, "a", encoding="utf-8") as f:
37
+ if content and not content.endswith("\n"):
38
+ f.write("\n")
39
+ f.write(f"{package}\n")
40
+
41
+
42
+ def update_pyproject_toml(path: str, package: str) -> None:
43
+ """
44
+ Insert the package into the [project] dependencies array in pyproject.toml.
45
+ Uses string manipulation to preserve comments and formatting.
46
+ """
47
+ if not os.path.exists(path):
48
+ return
49
+
50
+ with open(path, "r", encoding="utf-8") as f:
51
+ content = f.read()
52
+
53
+ # Avoid adding if it's already there (rudimentary check)
54
+ if f'"{package}"' in content or f"'{package}'" in content:
55
+ return
56
+
57
+ project_start = content.find("[project]")
58
+ if project_start == -1:
59
+ return
60
+
61
+ next_section = content.find("\n[", project_start + 1)
62
+ if next_section == -1:
63
+ next_section = len(content)
64
+
65
+ project_block = content[project_start:next_section]
66
+
67
+ # Find the dependencies array
68
+ match = re.search(r"(dependencies\s*=\s*\[)(.*?)(\])", project_block, flags=re.DOTALL)
69
+ if not match:
70
+ return
71
+
72
+ inner = match.group(2)
73
+ if "\n" in inner:
74
+ # Multi-line array: insert at the top to avoid trailing comma issues at the bottom
75
+ new_project_block = re.sub(
76
+ r"(dependencies\s*=\s*\[\s*\n)",
77
+ rf'\g<1> "{package}",\n',
78
+ project_block,
79
+ count=1
80
+ )
81
+ else:
82
+ # Single-line array
83
+ inner_stripped = inner.strip()
84
+ if inner_stripped:
85
+ # Add at the beginning of the single line
86
+ new_inner = f'"{package}", ' + inner_stripped
87
+ else:
88
+ new_inner = f'"{package}"'
89
+
90
+ new_project_block = re.sub(
91
+ r"(dependencies\s*=\s*\[).*?(\])",
92
+ rf'\g<1>{new_inner}\g<2>',
93
+ project_block,
94
+ count=1,
95
+ flags=re.DOTALL
96
+ )
97
+
98
+ # Reconstruct the file
99
+ new_content = content[:project_start] + new_project_block + content[next_section:]
100
+
101
+ with open(path, "w", encoding="utf-8") as f:
102
+ f.write(new_content)
@@ -0,0 +1,43 @@
1
+ """embeddings.py — Semantic similarity using sentence-transformers."""
2
+
3
+ from typing import Optional, List
4
+
5
+ try:
6
+ from sentence_transformers import SentenceTransformer, util
7
+ _HAS_SENTENCE_TRANSFORMERS = True
8
+ except ImportError:
9
+ _HAS_SENTENCE_TRANSFORMERS = False
10
+ SentenceTransformer = None
11
+ util = None
12
+
13
+
14
+ _model = None
15
+
16
+
17
+ def get_embedding(text: str) -> Optional[List[float]]:
18
+ """
19
+ Generate an embedding for the given text using all-MiniLM-L6-v2.
20
+ Returns None if sentence-transformers is not installed.
21
+ """
22
+ if not _HAS_SENTENCE_TRANSFORMERS:
23
+ return None
24
+
25
+ global _model
26
+ if _model is None:
27
+ _model = SentenceTransformer("all-MiniLM-L6-v2")
28
+
29
+ # Encode returns a numpy array or tensor, convert to standard Python floats for JSON serialization
30
+ embedding = _model.encode(text)
31
+ return embedding.tolist()
32
+
33
+
34
+ def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
35
+ """
36
+ Compute cosine similarity between two embedding vectors.
37
+ Returns 0.0 if sentence-transformers is not installed.
38
+ """
39
+ if not _HAS_SENTENCE_TRANSFORMERS:
40
+ return 0.0
41
+
42
+ # util.cos_sim returns a 2D tensor (1x1), we extract the float item
43
+ return util.cos_sim(vec1, vec2).item()
@@ -0,0 +1,63 @@
1
+ """git_utils.py — Git safety backup utilities."""
2
+
3
+ import subprocess
4
+ import time
5
+ from typing import Optional
6
+
7
+
8
+ def is_in_git_repo(cwd: Optional[str] = None) -> bool:
9
+ """Check if the current directory is inside a Git repository."""
10
+ try:
11
+ result = subprocess.run(
12
+ ["git", "rev-parse", "--is-inside-work-tree"],
13
+ cwd=cwd, capture_output=True, text=True, check=False
14
+ )
15
+ return result.returncode == 0 and result.stdout.strip() == "true"
16
+ except FileNotFoundError:
17
+ return False
18
+
19
+
20
+ def has_uncommitted_changes(cwd: Optional[str] = None) -> bool:
21
+ """Check if there are any uncommitted changes in the repository."""
22
+ try:
23
+ result = subprocess.run(
24
+ ["git", "status", "--porcelain"],
25
+ cwd=cwd, capture_output=True, text=True, check=False
26
+ )
27
+ return result.returncode == 0 and bool(result.stdout.strip())
28
+ except FileNotFoundError:
29
+ return False
30
+
31
+
32
+ def create_safety_stash(cwd: Optional[str] = None) -> bool:
33
+ """
34
+ Create a non-destructive stash of the current working directory.
35
+ Uses 'git stash create' and 'git stash store' to avoid removing changes
36
+ from the working directory (unlike 'git stash push').
37
+ """
38
+ try:
39
+ # Create stash object
40
+ create_result = subprocess.run(
41
+ ["git", "stash", "create"],
42
+ cwd=cwd, capture_output=True, text=True, check=False
43
+ )
44
+
45
+ if create_result.returncode != 0:
46
+ return False
47
+
48
+ commit_hash = create_result.stdout.strip()
49
+ if not commit_hash:
50
+ return False # Nothing to stash (e.g. only untracked files and no -u)
51
+
52
+ # Store it
53
+ timestamp = int(time.time())
54
+ msg = f"envfix-auto-backup-{timestamp}"
55
+
56
+ store_result = subprocess.run(
57
+ ["git", "stash", "store", "-m", msg, commit_hash],
58
+ cwd=cwd, capture_output=True, text=True, check=False
59
+ )
60
+
61
+ return store_result.returncode == 0
62
+ except FileNotFoundError:
63
+ return False
@@ -7,6 +7,8 @@ import re
7
7
  from datetime import datetime, timezone
8
8
  from typing import Any, Optional
9
9
 
10
+ from envfix.embeddings import get_embedding
11
+
10
12
 
11
13
  def get_log_file() -> str:
12
14
  """
@@ -85,6 +87,11 @@ def log_attempt(
85
87
  "context_included": context_included,
86
88
  "provider": provider,
87
89
  }
90
+
91
+ # Optionally compute and store a semantic embedding of the error text
92
+ embedding = get_embedding(error_text)
93
+ if embedding:
94
+ record["embedding"] = embedding
88
95
 
89
96
  log_data = _load_log(get_log_file())
90
97
  log_data.append(record)