logtap 0.4.0__py3-none-any.whl → 0.4.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.
logtap/api/routes/logs.py CHANGED
@@ -11,42 +11,39 @@ from starlette.responses import StreamingResponse
11
11
  from logtap.api.dependencies import get_settings, verify_api_key
12
12
  from logtap.core.reader import tail_async
13
13
  from logtap.core.search import filter_lines
14
- from logtap.core.validation import is_filename_valid, is_limit_valid, is_search_term_valid
14
+ from logtap.core.validation import (
15
+ is_limit_valid,
16
+ is_search_term_valid,
17
+ resolve_safe_path,
18
+ )
15
19
  from logtap.models.config import Settings
16
20
  from logtap.models.responses import LogResponse
17
21
 
18
22
  router = APIRouter()
19
23
 
20
24
 
21
- # Error messages (matching original for backward compatibility)
22
- ERROR_INVALID_FILENAME = 'Invalid filename: must not contain ".." or start with "/"'
25
+ # Error messages
26
+ ERROR_INVALID_FILENAME = "Invalid filename"
23
27
  ERROR_LONG_SEARCH_TERM = "Search term is too long: must be 100 characters or fewer"
24
28
  ERROR_INVALID_LIMIT = "Invalid limit value: must be between 1 and 1000"
25
29
 
26
30
 
27
- def validate_filename(filename: str) -> None:
28
- """Validate filename and raise HTTPException if invalid."""
29
- if not is_filename_valid(filename):
30
- raise HTTPException(
31
- status_code=status.HTTP_400_BAD_REQUEST,
32
- detail=ERROR_INVALID_FILENAME,
33
- )
34
- # Block any filename with path separators
35
- if "/" in filename or "\\" in filename:
31
+ def get_filepath(filename: str, settings: Settings) -> str:
32
+ """Get full filepath and validate it exists and is within allowed directory."""
33
+ log_dir = settings.get_log_directory()
34
+ # resolve_safe_path handles: NUL bytes, control chars, path traversal,
35
+ # separators, absolute paths, symlink escape, and containment check
36
+ filepath = resolve_safe_path(log_dir, filename)
37
+ if filepath is None:
36
38
  raise HTTPException(
37
39
  status_code=status.HTTP_400_BAD_REQUEST,
38
40
  detail=ERROR_INVALID_FILENAME,
39
41
  )
40
-
41
-
42
- def get_filepath(filename: str, settings: Settings) -> str:
43
- """Get full filepath and validate it exists."""
44
- log_dir = settings.get_log_directory()
45
- filepath = os.path.join(log_dir, filename)
42
+ # Separate existence check for correct 404 response
46
43
  if not os.path.isfile(filepath):
47
44
  raise HTTPException(
48
45
  status_code=status.HTTP_404_NOT_FOUND,
49
- detail=f"File not found: {filepath} does not exist",
46
+ detail=f"File not found: {filename} does not exist",
50
47
  )
51
48
  return filepath
52
49
 
@@ -67,8 +64,6 @@ async def get_logs(
67
64
  This endpoint reads the last N lines from a log file and optionally
68
65
  filters them by a search term or regex pattern.
69
66
  """
70
- validate_filename(filename)
71
-
72
67
  if term and not is_search_term_valid(term):
73
68
  raise HTTPException(
74
69
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -126,8 +121,11 @@ async def get_logs_multi(
126
121
 
127
122
  for filename in file_list:
128
123
  try:
129
- validate_filename(filename)
130
- filepath = os.path.join(log_dir, filename)
124
+ filepath = resolve_safe_path(log_dir, filename)
125
+
126
+ if filepath is None:
127
+ results[filename] = {"error": ERROR_INVALID_FILENAME, "lines": []}
128
+ continue
131
129
 
132
130
  if not os.path.isfile(filepath):
133
131
  results[filename] = {"error": "File not found", "lines": []}
@@ -164,16 +162,14 @@ async def stream_logs(
164
162
  # Get settings (can't use Depends in WebSocket easily)
165
163
  settings = get_settings()
166
164
 
167
- try:
168
- validate_filename(filename)
169
- except HTTPException as e:
170
- await websocket.send_json({"error": e.detail})
165
+ log_dir = settings.get_log_directory()
166
+ filepath = resolve_safe_path(log_dir, filename)
167
+
168
+ if filepath is None:
169
+ await websocket.send_json({"error": ERROR_INVALID_FILENAME})
171
170
  await websocket.close()
172
171
  return
173
172
 
174
- log_dir = settings.get_log_directory()
175
- filepath = os.path.join(log_dir, filename)
176
-
177
173
  if not os.path.isfile(filepath):
178
174
  await websocket.send_json({"error": f"File not found: {filename}"})
179
175
  await websocket.close()
@@ -221,7 +217,6 @@ async def stream_logs_sse(
221
217
 
222
218
  Alternative to WebSocket for simpler clients.
223
219
  """
224
- validate_filename(filename)
225
220
  filepath = get_filepath(filename, settings)
226
221
 
227
222
  async def event_generator():
@@ -9,6 +9,7 @@ from logtap.api.dependencies import get_settings, verify_api_key
9
9
  from logtap.core.parsers import AutoParser, LogLevel
10
10
  from logtap.core.reader import tail_async
11
11
  from logtap.core.search import filter_entries
12
+ from logtap.core.validation import resolve_safe_path
12
13
  from logtap.models.config import Settings
13
14
 
14
15
  router = APIRouter()
@@ -40,21 +41,21 @@ async def get_parsed_logs(
40
41
 
41
42
  Supported formats: syslog, JSON, nginx, apache (auto-detected).
42
43
  """
43
- # Validate filename
44
- if ".." in filename or filename.startswith("/") or "/" in filename or "\\" in filename:
44
+ # Validate filename and resolve safe path
45
+ log_dir = settings.get_log_directory()
46
+ # resolve_safe_path handles all path security: traversal, symlinks, containment
47
+ filepath = resolve_safe_path(log_dir, filename)
48
+
49
+ if filepath is None:
45
50
  raise HTTPException(
46
51
  status_code=status.HTTP_400_BAD_REQUEST,
47
52
  detail='Invalid filename: must not contain ".." or start with "/"',
48
53
  )
49
54
 
50
- # Build file path
51
- log_dir = settings.get_log_directory()
52
- filepath = os.path.join(log_dir, filename)
53
-
54
55
  if not os.path.isfile(filepath):
55
56
  raise HTTPException(
56
57
  status_code=status.HTTP_404_NOT_FOUND,
57
- detail=f"File not found: {filepath} does not exist",
58
+ detail=f"File not found: {filename} does not exist",
58
59
  )
59
60
 
60
61
  # Read file lines
logtap/api/routes/runs.py CHANGED
@@ -128,14 +128,14 @@ async def ingest(
128
128
  # Get or create run
129
129
  run, created = store.get_or_create(run_id)
130
130
 
131
- # Handle tags
131
+ # Handle tags (validate and track in metadata)
132
132
  tags = parse_tags(x_logtap_tag)
133
133
  if tags:
134
134
  err = run.set_tags(tags)
135
135
  if err:
136
136
  raise HTTPException(
137
- status_code=409,
138
- detail={"error": "tag_conflict", "message": err},
137
+ status_code=400,
138
+ detail={"error": "invalid_tag", "message": err},
139
139
  )
140
140
 
141
141
  # Read and ingest body
@@ -149,12 +149,12 @@ async def ingest(
149
149
  # Process complete lines
150
150
  while "\n" in buffer:
151
151
  line, buffer = buffer.split("\n", 1)
152
- run.append(line)
152
+ run.append(line, tags) # Pass tags to each line
153
153
  lines_ingested += 1
154
154
 
155
155
  # Flush remaining partial line
156
156
  if buffer:
157
- run.append(buffer)
157
+ run.append(buffer, tags) # Pass tags to each line
158
158
  lines_ingested += 1
159
159
 
160
160
  # Save metadata
@@ -192,33 +192,12 @@ async def stream_run(
192
192
  detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
193
193
  )
194
194
 
195
- # Tag filtering (if specified, check run has all tags)
196
- if tag:
197
- required_tags = parse_tags(tag)
198
- for key, value in required_tags.items():
199
- if run.metadata.tags.get(key) != value:
200
- # Run doesn't match filter - return empty stream
201
- async def empty_stream():
202
- meta = StreamMetaEvent(
203
- cursor_earliest=run.cursor_earliest,
204
- cursor_latest=run.cursor_latest,
205
- gap=False,
206
- )
207
- yield f"event: meta\ndata: {meta.model_dump_json()}\n\n"
208
-
209
- return StreamingResponse(
210
- empty_stream(),
211
- media_type="text/event-stream",
212
- headers={
213
- "Cache-Control": "no-cache",
214
- "X-Logtap-Earliest-Cursor": str(run.cursor_earliest),
215
- "X-Logtap-Latest-Cursor": str(run.cursor_latest),
216
- },
217
- )
195
+ # Parse tag filter for per-line filtering
196
+ tag_filter = parse_tags(tag) if tag else None
218
197
 
219
198
  async def generate_sse():
220
199
  # Get initial lines and check for gap
221
- lines, gap = run.get_lines(since=since, tail=tail)
200
+ lines, gap = run.get_lines(since=since, tail=tail, tag_filter=tag_filter)
222
201
 
223
202
  # Send meta event
224
203
  missed = None
@@ -255,7 +234,7 @@ async def stream_run(
255
234
 
256
235
  while True:
257
236
  # Get new lines since last cursor
258
- new_lines, _ = run.get_lines(since=last_cursor, limit=100)
237
+ new_lines, _ = run.get_lines(since=last_cursor, limit=100, tag_filter=tag_filter)
259
238
 
260
239
  for line in new_lines:
261
240
  event = StreamLineEvent(
@@ -0,0 +1,127 @@
1
+ """Doctor command for logtap CLI - diagnose connection issues."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def doctor(
12
+ server: str = typer.Option(
13
+ "http://localhost:8000",
14
+ "--server",
15
+ "-s",
16
+ help="Server URL to check.",
17
+ envvar="LOGTAP_SERVER",
18
+ ),
19
+ api_key: Optional[str] = typer.Option(
20
+ None,
21
+ "--api-key",
22
+ "-k",
23
+ help="API key for authentication.",
24
+ envvar="LOGTAP_API_KEY",
25
+ ),
26
+ ) -> None:
27
+ """
28
+ Check server connectivity and diagnose issues.
29
+
30
+ Verifies the server is reachable, auth works, and reports capabilities.
31
+
32
+ Example:
33
+ logtap doctor
34
+ logtap doctor --server http://gpu-box:8000
35
+ """
36
+ import httpx
37
+
38
+ console.print(f"[bold]Checking {server}[/bold]\n")
39
+
40
+ # Build headers
41
+ headers = {}
42
+ if api_key:
43
+ headers["X-API-Key"] = api_key
44
+
45
+ # Step 1: Check if server is reachable
46
+ console.print("[dim]1. Server reachable?[/dim]", end=" ")
47
+ try:
48
+ with httpx.Client(timeout=10) as client:
49
+ response = client.get(f"{server}/health", headers=headers)
50
+ except httpx.ConnectError:
51
+ console.print("[red]NO[/red]")
52
+ console.print(f"\n[red]Could not connect to {server}[/red]")
53
+ console.print("\n[dim]Possible causes:[/dim]")
54
+ console.print(" - Server not running (start with: logtap collect)")
55
+ console.print(" - Wrong host/port")
56
+ console.print(" - Firewall blocking connection")
57
+ console.print(f"\n[dim]Try:[/dim] curl {server}/health")
58
+ raise typer.Exit(1)
59
+ except httpx.TimeoutException:
60
+ console.print("[red]TIMEOUT[/red]")
61
+ console.print(f"\n[red]Connection timed out to {server}[/red]")
62
+ raise typer.Exit(1)
63
+
64
+ console.print("[green]YES[/green]")
65
+
66
+ # Step 2: Check auth
67
+ console.print("[dim]2. Auth working?[/dim]", end=" ")
68
+ if response.status_code == 401:
69
+ console.print("[red]NO (401 Unauthorized)[/red]")
70
+ console.print("\n[red]API key required or invalid[/red]")
71
+ if not api_key:
72
+ console.print("\n[dim]Try:[/dim] logtap doctor --api-key YOUR_KEY")
73
+ console.print("[dim]Or:[/dim] export LOGTAP_API_KEY=YOUR_KEY")
74
+ else:
75
+ console.print("\n[dim]Check that your API key matches the server's --api-key[/dim]")
76
+ raise typer.Exit(1)
77
+ elif response.status_code >= 400:
78
+ console.print(f"[red]NO ({response.status_code})[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ if api_key:
82
+ console.print("[green]YES (key accepted)[/green]")
83
+ else:
84
+ console.print("[green]YES (no auth required)[/green]")
85
+
86
+ # Step 3: Parse health response
87
+ try:
88
+ health = response.json()
89
+ except Exception:
90
+ console.print("\n[yellow]Warning: Could not parse health response[/yellow]")
91
+ health = {}
92
+
93
+ # Step 4: Check features
94
+ console.print("[dim]3. Features?[/dim]", end=" ")
95
+ mode = health.get("mode", "unknown")
96
+ features = health.get("features", [])
97
+ version = health.get("version", "unknown")
98
+
99
+ if "runs" in features:
100
+ console.print("[green]runs[/green] (collector mode)")
101
+ elif "files" in features:
102
+ console.print("[cyan]files[/cyan] (legacy serve mode)")
103
+ else:
104
+ console.print(f"[yellow]{mode}[/yellow]")
105
+
106
+ # Step 5: Show summary
107
+ console.print("\n[bold green]Server OK[/bold green]")
108
+ console.print(f" Version: {version}")
109
+ console.print(f" Mode: {mode}")
110
+ console.print(f" Features: {', '.join(features) if features else 'unknown'}")
111
+
112
+ if "runs" in features:
113
+ runs_count = health.get("runs", 0)
114
+ console.print(f" Active runs: {runs_count}")
115
+
116
+ # Show curl command for debugging
117
+ console.print("\n[dim]Debug command:[/dim]")
118
+ if api_key:
119
+ console.print(f' curl -H "X-API-Key: {api_key}" {server}/health')
120
+ else:
121
+ console.print(f" curl {server}/health")
122
+
123
+ # Show quick start if collector mode
124
+ if "runs" in features:
125
+ console.print("\n[dim]Quick start:[/dim]")
126
+ console.print(" python train.py 2>&1 | logtap ingest run1")
127
+ console.print(" logtap tail run1 --follow")
@@ -166,8 +166,8 @@ def _tail_run(
166
166
  console.print(f"[red]Error: Server returned {response.status_code}[/red]")
167
167
  raise typer.Exit(1)
168
168
 
169
- # Track cursor for gap detection message
170
- last_cursor = since
169
+ # Track if this is a reconnect (user provided --since)
170
+ is_reconnect = since is not None
171
171
 
172
172
  # Parse SSE stream
173
173
  buffer = ""
@@ -175,7 +175,7 @@ def _tail_run(
175
175
  buffer += chunk
176
176
  while "\n\n" in buffer:
177
177
  event_str, buffer = buffer.split("\n\n", 1)
178
- _process_sse_event(event_str, output, last_cursor)
178
+ _process_sse_event(event_str, output, is_reconnect)
179
179
 
180
180
  except httpx.ConnectError:
181
181
  console.print(f"[red]Error: Could not connect to {server}[/red]")
@@ -185,7 +185,7 @@ def _tail_run(
185
185
  console.print("\n[dim]Stopped.[/dim]")
186
186
 
187
187
 
188
- def _process_sse_event(event_str: str, output: str, last_cursor: Optional[int]) -> None:
188
+ def _process_sse_event(event_str: str, output: str, is_reconnect: bool) -> None:
189
189
  """Process a single SSE event."""
190
190
  event_type = None
191
191
  data = None
@@ -204,7 +204,15 @@ def _process_sse_event(event_str: str, output: str, last_cursor: Optional[int])
204
204
  try:
205
205
  meta = json.loads(data)
206
206
  if meta.get("gap") and meta.get("missed"):
207
- console.print(f"[yellow]reconnected (missed {meta['missed']} lines)[/yellow]")
207
+ # Gap detected - warn user about missed lines
208
+ console.print(
209
+ f"[yellow]reconnected (missed {meta['missed']} lines)[/yellow]",
210
+ stderr=True,
211
+ )
212
+ elif is_reconnect:
213
+ # Clean reconnect - show cursor position
214
+ cursor = meta.get("cursor_earliest", meta.get("cursor_latest", "?"))
215
+ console.print(f"[dim]resumed at cursor {cursor}[/dim]", stderr=True)
208
216
  except Exception:
209
217
  pass
210
218
  elif event_type == "line" and data:
logtap/cli/main.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import typer
4
4
 
5
5
  from logtap import __version__
6
- from logtap.cli.commands import collect, files, ingest, query, runs, serve, tail
6
+ from logtap.cli.commands import collect, doctor, files, ingest, query, runs, serve, tail
7
7
 
8
8
  app = typer.Typer(
9
9
  name="logtap",
@@ -44,6 +44,7 @@ def main(
44
44
  app.command()(collect.collect)
45
45
  app.command()(ingest.ingest)
46
46
  app.command()(runs.runs)
47
+ app.command()(doctor.doctor)
47
48
 
48
49
  # Legacy commands (file-based workflow)
49
50
  app.command()(serve.serve)
logtap/core/runs.py CHANGED
@@ -17,11 +17,12 @@ TAG_VALUE_MAX_LEN = 256
17
17
 
18
18
  @dataclass
19
19
  class RunLine:
20
- """A single log line with cursor and timestamp."""
20
+ """A single log line with cursor, timestamp, and optional tags."""
21
21
 
22
22
  cursor: int
23
23
  line: str
24
24
  ts: datetime
25
+ tags: Dict[str, str] = field(default_factory=dict)
25
26
 
26
27
 
27
28
  @dataclass
@@ -100,32 +101,52 @@ class Run:
100
101
 
101
102
  def _populate_cache_from_disk(self) -> None:
102
103
  """Load last N lines from disk into cache."""
104
+ import json
105
+
103
106
  if not self.log_file.exists():
104
107
  return
105
108
 
106
- lines: List[str] = []
109
+ run_lines: List[RunLine] = []
107
110
  with open(self.log_file, "r", encoding="utf-8", errors="replace") as f:
108
- # Read all lines (for small files) or tail
109
- for line in f:
110
- lines.append(line.rstrip("\n"))
111
+ for raw_line in f:
112
+ raw_line = raw_line.rstrip("\n")
113
+ if not raw_line:
114
+ continue
115
+
116
+ # Try JSONL format first
117
+ if raw_line.startswith("{"):
118
+ try:
119
+ record = json.loads(raw_line)
120
+ run_lines.append(
121
+ RunLine(
122
+ cursor=record["c"],
123
+ line=record["l"],
124
+ ts=datetime.fromisoformat(record["t"]),
125
+ tags=record.get("g", {}),
126
+ )
127
+ )
128
+ continue
129
+ except (json.JSONDecodeError, KeyError):
130
+ pass
131
+
132
+ # Legacy plain text format
133
+ run_lines.append(
134
+ RunLine(
135
+ cursor=len(run_lines),
136
+ line=raw_line,
137
+ ts=self.metadata.last_activity,
138
+ tags={},
139
+ )
140
+ )
111
141
 
112
142
  # Only keep last buffer_lines
113
- if len(lines) > self.buffer_lines:
114
- lines = lines[-self.buffer_lines :]
115
- start_cursor = self.metadata.cursor_latest - len(lines) + 1
116
- else:
117
- start_cursor = 0
143
+ if len(run_lines) > self.buffer_lines:
144
+ run_lines = run_lines[-self.buffer_lines :]
118
145
 
119
- self._cache_start_cursor = start_cursor
146
+ self._cache_start_cursor = run_lines[0].cursor if run_lines else 0
120
147
  self._cache.clear()
121
- for i, line in enumerate(lines):
122
- self._cache.append(
123
- RunLine(
124
- cursor=start_cursor + i,
125
- line=line,
126
- ts=self.metadata.last_activity, # Approximate
127
- )
128
- )
148
+ for rl in run_lines:
149
+ self._cache.append(rl)
129
150
 
130
151
  def _save_metadata(self) -> None:
131
152
  """Save metadata to disk."""
@@ -147,17 +168,26 @@ class Run:
147
168
  f,
148
169
  )
149
170
 
150
- def append(self, line: str) -> RunLine:
171
+ def append(self, line: str, tags: Optional[Dict[str, str]] = None) -> RunLine:
151
172
  """Append a line to the run. Returns the line with assigned cursor."""
173
+ import json
174
+
152
175
  with self._lock:
153
176
  now = datetime.now(timezone.utc)
154
177
  cursor = self.metadata.cursor_latest + 1
155
178
 
156
- run_line = RunLine(cursor=cursor, line=line, ts=now)
179
+ run_line = RunLine(cursor=cursor, line=line, ts=now, tags=tags or {})
157
180
 
158
- # Append to disk
181
+ # Append to disk as JSONL
182
+ record = {
183
+ "c": cursor,
184
+ "l": line,
185
+ "t": now.isoformat(),
186
+ }
187
+ if tags:
188
+ record["g"] = tags # g for tags (short key)
159
189
  with open(self.log_file, "a", encoding="utf-8") as f:
160
- written = f.write(line + "\n")
190
+ written = f.write(json.dumps(record, separators=(",", ":")) + "\n")
161
191
  self.metadata.bytes_on_disk += written
162
192
 
163
193
  # Update cache
@@ -172,17 +202,23 @@ class Run:
172
202
 
173
203
  return run_line
174
204
 
175
- def append_batch(self, lines: List[str]) -> List[RunLine]:
205
+ def append_batch(
206
+ self, lines: List[str], tags: Optional[Dict[str, str]] = None
207
+ ) -> List[RunLine]:
176
208
  """Append multiple lines atomically."""
177
209
  with self._lock:
178
210
  result = []
179
211
  for line in lines:
180
- result.append(self.append(line))
212
+ result.append(self.append(line, tags))
181
213
  self._save_metadata()
182
214
  return result
183
215
 
184
216
  def set_tags(self, tags: Dict[str, str]) -> Optional[str]:
185
- """Set tags, merging with existing. Returns error message on conflict, None on success."""
217
+ """Validate tags. Returns error message on invalid tag, None on success.
218
+
219
+ Note: Tags are now stored per-line, not per-run. This method just validates
220
+ and tracks known tag keys in run metadata for discoverability.
221
+ """
186
222
  import re
187
223
 
188
224
  with self._lock:
@@ -193,12 +229,8 @@ class Run:
193
229
  # Validate value length
194
230
  if len(value) > TAG_VALUE_MAX_LEN:
195
231
  return f"Tag value too long: {key}"
196
- # Check for conflict
197
- if key in self.metadata.tags and self.metadata.tags[key] != value:
198
- existing = self.metadata.tags[key]
199
- return f"Tag conflict for key '{key}': existing='{existing}', new='{value}'"
200
232
 
201
- # Merge tags
233
+ # Track tag keys in metadata (last value wins, just for discoverability)
202
234
  self.metadata.tags.update(tags)
203
235
  self._save_metadata()
204
236
  return None
@@ -222,6 +254,7 @@ class Run:
222
254
  since: Optional[int] = None,
223
255
  tail: int = 50,
224
256
  limit: int = 1000,
257
+ tag_filter: Optional[Dict[str, str]] = None,
225
258
  ) -> tuple[List[RunLine], bool]:
226
259
  """
227
260
  Get lines from run.
@@ -230,6 +263,7 @@ class Run:
230
263
  since: Cursor to start from (exclusive). If None, returns last `tail` lines.
231
264
  tail: Number of recent lines if since is None.
232
265
  limit: Maximum lines to return.
266
+ tag_filter: Filter lines by tags (AND semantics).
233
267
 
234
268
  Returns:
235
269
  Tuple of (lines, gap_detected).
@@ -253,6 +287,12 @@ class Run:
253
287
  # Tail mode - get last N lines
254
288
  lines = list(self._cache)[-tail:]
255
289
 
290
+ # Filter by tags (AND semantics)
291
+ if tag_filter:
292
+ lines = [
293
+ ln for ln in lines if all(ln.tags.get(k) == v for k, v in tag_filter.items())
294
+ ]
295
+
256
296
  # Apply limit
257
297
  if len(lines) > limit:
258
298
  lines = lines[:limit]