logtap 0.3.0__py3-none-any.whl → 0.4.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,123 @@
1
+ """Ingest command for logtap CLI."""
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ console = Console(stderr=True) # Output status to stderr so it doesn't mix with piped data
11
+
12
+
13
+ def ingest(
14
+ name: Optional[str] = typer.Argument(
15
+ None,
16
+ help="Run name. If omitted, generates: run-YYYYMMDD-HHMMSS",
17
+ ),
18
+ server: str = typer.Option(
19
+ "http://localhost:8000",
20
+ "--server",
21
+ "-s",
22
+ help="Collector server URL.",
23
+ envvar="LOGTAP_SERVER",
24
+ ),
25
+ api_key: Optional[str] = typer.Option(
26
+ None,
27
+ "--api-key",
28
+ "-k",
29
+ help="API key for authentication.",
30
+ envvar="LOGTAP_API_KEY",
31
+ ),
32
+ tag: Optional[List[str]] = typer.Option(
33
+ None,
34
+ "--tag",
35
+ "-t",
36
+ help="Tags as key=value (repeatable).",
37
+ ),
38
+ quiet: bool = typer.Option(
39
+ False,
40
+ "--quiet",
41
+ "-q",
42
+ help="Suppress status messages.",
43
+ ),
44
+ ) -> None:
45
+ """
46
+ Pipe stdin to collector as a named run.
47
+
48
+ Reads from stdin and streams lines to the collector server.
49
+ Designed to be used with pipes for training logs.
50
+
51
+ Example:
52
+ python train.py 2>&1 | logtap ingest run1
53
+ python train.py 2>&1 | logtap ingest --tag node=gpu1
54
+ cat training.log | logtap ingest my-run --server http://collector:8000
55
+ """
56
+ import httpx
57
+
58
+ # Generate run name if not provided
59
+ if name is None:
60
+ name = datetime.now().strftime("run-%Y%m%d-%H%M%S")
61
+
62
+ # Build headers as list of tuples (supports duplicate keys)
63
+ headers: List[tuple] = [("Content-Type", "text/plain")]
64
+ if api_key:
65
+ headers.append(("X-API-Key", api_key))
66
+ if tag:
67
+ for t in tag:
68
+ headers.append(("X-Logtap-Tag", t))
69
+
70
+ url = f"{server.rstrip('/')}/runs/{name}/ingest"
71
+
72
+ if not quiet:
73
+ console.print(f"[dim]Ingesting to[/dim] {name} [dim]@[/dim] {server}")
74
+
75
+ try:
76
+ # Read all stdin
77
+ content = sys.stdin.read()
78
+
79
+ if not quiet:
80
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
81
+ console.print(f"[dim]Sending {line_count} lines...[/dim]")
82
+
83
+ # Use httpx
84
+ with httpx.Client(timeout=None) as client:
85
+ response = client.post(url, content=content, headers=headers)
86
+
87
+ if response.status_code == 401:
88
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
89
+ raise typer.Exit(1)
90
+ elif response.status_code == 409:
91
+ try:
92
+ data = response.json()
93
+ msg = data.get("detail", {}).get("message", "Tag conflict")
94
+ except Exception:
95
+ msg = "Tag conflict"
96
+ console.print(f"[red]Error: {msg}[/red]")
97
+ raise typer.Exit(1)
98
+ elif response.status_code == 507:
99
+ console.print("[red]Error: Server storage limit exceeded.[/red]")
100
+ raise typer.Exit(1)
101
+ elif response.status_code >= 400:
102
+ console.print(f"[red]Error: Server returned {response.status_code}[/red]")
103
+ try:
104
+ console.print(f"[dim]{response.text}[/dim]")
105
+ except Exception:
106
+ pass
107
+ raise typer.Exit(1)
108
+
109
+ data = response.json()
110
+ if not quiet:
111
+ console.print(
112
+ f"[green]Done.[/green] "
113
+ f"Ingested {data['lines_ingested']} lines to [bold]{data['run_id']}[/bold] "
114
+ f"(cursor: {data['cursor_end']})"
115
+ )
116
+
117
+ except httpx.ConnectError:
118
+ console.print(f"[red]Error: Could not connect to {server}[/red]")
119
+ console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
120
+ raise typer.Exit(1)
121
+ except httpx.TimeoutException:
122
+ console.print("[red]Error: Connection timed out[/red]")
123
+ raise typer.Exit(1)
@@ -0,0 +1,116 @@
1
+ """Runs command for logtap CLI."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ console = Console()
10
+
11
+
12
+ def runs(
13
+ server: str = typer.Option(
14
+ "http://localhost:8000",
15
+ "--server",
16
+ "-s",
17
+ help="Collector server URL.",
18
+ envvar="LOGTAP_SERVER",
19
+ ),
20
+ api_key: Optional[str] = typer.Option(
21
+ None,
22
+ "--api-key",
23
+ "-k",
24
+ help="API key for authentication.",
25
+ envvar="LOGTAP_API_KEY",
26
+ ),
27
+ since_hours: Optional[int] = typer.Option(
28
+ 24,
29
+ "--since-hours",
30
+ help="Show runs active within N hours.",
31
+ ),
32
+ ) -> None:
33
+ """
34
+ List runs on a collector.
35
+
36
+ Shows all active and recent runs with their line counts and tags.
37
+
38
+ Example:
39
+ logtap runs
40
+ logtap runs --server http://gpu-box:8000
41
+ logtap runs --since-hours 48
42
+ """
43
+ from datetime import datetime, timezone
44
+
45
+ import httpx
46
+
47
+ # Build headers
48
+ headers = {}
49
+ if api_key:
50
+ headers["X-API-Key"] = api_key
51
+
52
+ url = f"{server.rstrip('/')}/runs"
53
+ params = {}
54
+ if since_hours is not None:
55
+ params["since_hours"] = since_hours
56
+
57
+ try:
58
+ with httpx.Client(timeout=30) as client:
59
+ response = client.get(url, headers=headers, params=params)
60
+
61
+ if response.status_code == 401:
62
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
63
+ raise typer.Exit(1)
64
+ elif response.status_code >= 400:
65
+ console.print(f"[red]Error: Server returned {response.status_code}[/red]")
66
+ raise typer.Exit(1)
67
+
68
+ data = response.json()
69
+ runs_list = data.get("runs", [])
70
+
71
+ if not runs_list:
72
+ console.print("[dim]No runs found.[/dim]")
73
+ return
74
+
75
+ # Create table
76
+ table = Table(show_header=True, header_style="bold")
77
+ table.add_column("RUN", style="cyan")
78
+ table.add_column("LINES", justify="right")
79
+ table.add_column("LAST ACTIVITY", style="dim")
80
+ table.add_column("TAGS")
81
+
82
+ now = datetime.now(timezone.utc)
83
+
84
+ for run in runs_list:
85
+ # Format last activity as relative time
86
+ last_activity = datetime.fromisoformat(run["last_activity"].replace("Z", "+00:00"))
87
+ delta = now - last_activity
88
+ if delta.total_seconds() < 60:
89
+ time_str = f"{int(delta.total_seconds())}s ago"
90
+ elif delta.total_seconds() < 3600:
91
+ time_str = f"{int(delta.total_seconds() / 60)}m ago"
92
+ elif delta.total_seconds() < 86400:
93
+ time_str = f"{int(delta.total_seconds() / 3600)}h ago"
94
+ else:
95
+ time_str = f"{int(delta.total_seconds() / 86400)}d ago"
96
+
97
+ # Format tags
98
+ tags = run.get("tags", {})
99
+ tags_str = ", ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
100
+
101
+ table.add_row(
102
+ run["id"],
103
+ f"{run['lines']:,}",
104
+ time_str,
105
+ tags_str,
106
+ )
107
+
108
+ console.print(table)
109
+
110
+ except httpx.ConnectError:
111
+ console.print(f"[red]Error: Could not connect to {server}[/red]")
112
+ console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
113
+ raise typer.Exit(1)
114
+ except httpx.TimeoutException:
115
+ console.print("[red]Error: Connection timed out[/red]")
116
+ raise typer.Exit(1)
@@ -1,6 +1,7 @@
1
1
  """Tail command for logtap CLI - real-time log streaming."""
2
2
 
3
- from typing import Optional
3
+ import json
4
+ from typing import List, Optional
4
5
 
5
6
  import typer
6
7
  from rich.console import Console
@@ -9,12 +10,12 @@ console = Console()
9
10
 
10
11
 
11
12
  def tail(
12
- filename: str = typer.Argument(
13
- "syslog",
14
- help="Name of the log file to tail.",
13
+ target: str = typer.Argument(
14
+ ...,
15
+ help="Run name or file path to tail.",
15
16
  ),
16
- server: str = typer.Option(
17
- "http://localhost:8000",
17
+ server: Optional[str] = typer.Option(
18
+ None,
18
19
  "--server",
19
20
  "-s",
20
21
  help="URL of the logtap server.",
@@ -24,10 +25,10 @@ def tail(
24
25
  False,
25
26
  "--follow",
26
27
  "-f",
27
- help="Follow log output (like tail -f). Requires WebSocket support.",
28
+ help="Follow log output (like tail -f).",
28
29
  ),
29
30
  lines: int = typer.Option(
30
- 10,
31
+ 50,
31
32
  "--lines",
32
33
  "-n",
33
34
  help="Number of lines to show initially.",
@@ -39,22 +40,198 @@ def tail(
39
40
  help="API key for authentication.",
40
41
  envvar="LOGTAP_API_KEY",
41
42
  ),
43
+ mode: str = typer.Option(
44
+ "auto",
45
+ "--mode",
46
+ "-m",
47
+ help="Resolution mode: auto, runs, or files.",
48
+ ),
49
+ since: Optional[int] = typer.Option(
50
+ None,
51
+ "--since",
52
+ help="Resume from cursor (exclusive). For runs mode only.",
53
+ ),
54
+ tag: Optional[List[str]] = typer.Option(
55
+ None,
56
+ "--tag",
57
+ "-t",
58
+ help="Filter by tag (key=value, repeatable). For runs mode only.",
59
+ ),
60
+ output: str = typer.Option(
61
+ "pretty",
62
+ "--output",
63
+ "-o",
64
+ help="Output format: pretty, plain, or jsonl.",
65
+ ),
42
66
  ) -> None:
43
67
  """
44
- Tail a log file, optionally following new entries.
68
+ Tail a run or file, optionally following new entries.
69
+
70
+ For ML training logs (runs mode):
71
+ logtap tail run1 -f
72
+ logtap tail run1 --since 5000 -f
73
+ logtap tail run1 --tag node=gpu1
45
74
 
46
- Example:
47
- logtap tail syslog
75
+ For static files (legacy mode):
76
+ logtap tail syslog --server http://mybox:8000
48
77
  logtap tail auth.log -f
49
- logtap tail syslog --lines 100
50
78
  """
51
- import httpx
52
79
 
53
- # First, get initial lines
80
+ # If no server specified and target looks like a path, use local file mode
81
+ if server is None:
82
+ if "/" in target or "\\" in target or target.startswith("."):
83
+ console.print("[red]Error: Local file tailing requires --server[/red]")
84
+ console.print("[dim]For runs: logtap tail run1 --server http://collector:8000[/dim]")
85
+ console.print(
86
+ "[dim]For files: logtap tail syslog --server http://fileserver:8000[/dim]"
87
+ )
88
+ raise typer.Exit(1)
89
+ # Default to localhost
90
+ server = "http://localhost:8000"
91
+
54
92
  headers = {}
55
93
  if api_key:
56
94
  headers["X-API-Key"] = api_key
57
95
 
96
+ # Determine mode
97
+ use_runs_mode = False
98
+ if mode == "runs":
99
+ use_runs_mode = True
100
+ elif mode == "files":
101
+ use_runs_mode = False
102
+ else:
103
+ # Auto mode: check server capabilities and run existence
104
+ use_runs_mode = _detect_runs_mode(server, target, headers)
105
+
106
+ if use_runs_mode:
107
+ _tail_run(server, target, headers, follow, lines, since, tag, output)
108
+ else:
109
+ _tail_file(server, target, headers, follow, lines, output)
110
+
111
+
112
+ def _detect_runs_mode(server: str, target: str, headers: dict) -> bool:
113
+ """Detect if server supports runs and target exists as a run."""
114
+ import httpx
115
+
116
+ try:
117
+ with httpx.Client(timeout=5) as client:
118
+ # Check health endpoint for capabilities
119
+ response = client.get(f"{server}/health", headers=headers)
120
+ if response.status_code == 200:
121
+ data = response.json()
122
+ features = data.get("features", [])
123
+ if "runs" in features:
124
+ # Check if run exists
125
+ run_response = client.get(f"{server}/runs/{target}", headers=headers)
126
+ if run_response.status_code == 200:
127
+ return True
128
+ except Exception:
129
+ pass
130
+
131
+ return False
132
+
133
+
134
+ def _tail_run(
135
+ server: str,
136
+ run_id: str,
137
+ headers: dict,
138
+ follow: bool,
139
+ lines: int,
140
+ since: Optional[int],
141
+ tags: Optional[List[str]],
142
+ output: str,
143
+ ) -> None:
144
+ """Tail a run using SSE."""
145
+ import httpx
146
+
147
+ # Build URL with query params
148
+ params = {"tail": lines, "follow": str(follow).lower()}
149
+ if since is not None:
150
+ params["since"] = since
151
+ if tags:
152
+ params["tag"] = tags
153
+
154
+ url = f"{server}/runs/{run_id}/stream"
155
+
156
+ try:
157
+ with httpx.Client(timeout=None) as client:
158
+ with client.stream("GET", url, headers=headers, params=params) as response:
159
+ if response.status_code == 401:
160
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
161
+ raise typer.Exit(1)
162
+ elif response.status_code == 404:
163
+ console.print(f"[red]Error: Run '{run_id}' not found.[/red]")
164
+ raise typer.Exit(1)
165
+ elif response.status_code >= 400:
166
+ console.print(f"[red]Error: Server returned {response.status_code}[/red]")
167
+ raise typer.Exit(1)
168
+
169
+ # Track cursor for gap detection message
170
+ last_cursor = since
171
+
172
+ # Parse SSE stream
173
+ buffer = ""
174
+ for chunk in response.iter_text():
175
+ buffer += chunk
176
+ while "\n\n" in buffer:
177
+ event_str, buffer = buffer.split("\n\n", 1)
178
+ _process_sse_event(event_str, output, last_cursor)
179
+
180
+ except httpx.ConnectError:
181
+ console.print(f"[red]Error: Could not connect to {server}[/red]")
182
+ console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
183
+ raise typer.Exit(1)
184
+ except KeyboardInterrupt:
185
+ console.print("\n[dim]Stopped.[/dim]")
186
+
187
+
188
+ def _process_sse_event(event_str: str, output: str, last_cursor: Optional[int]) -> None:
189
+ """Process a single SSE event."""
190
+ event_type = None
191
+ data = None
192
+
193
+ for line in event_str.split("\n"):
194
+ if line.startswith(":"):
195
+ # Comment (heartbeat)
196
+ continue
197
+ elif line.startswith("event: "):
198
+ event_type = line[7:]
199
+ elif line.startswith("data: "):
200
+ data = line[6:]
201
+ # Note: id: lines are parsed by SSE but not used for now
202
+
203
+ if event_type == "meta" and data:
204
+ try:
205
+ meta = json.loads(data)
206
+ if meta.get("gap") and meta.get("missed"):
207
+ console.print(f"[yellow]reconnected (missed {meta['missed']} lines)[/yellow]")
208
+ except Exception:
209
+ pass
210
+ elif event_type == "line" and data:
211
+ try:
212
+ line_data = json.loads(data)
213
+ if output == "jsonl":
214
+ console.print(data)
215
+ elif output == "plain":
216
+ console.print(line_data["line"])
217
+ else:
218
+ # Pretty output - just the line
219
+ console.print(line_data["line"])
220
+ except Exception:
221
+ pass
222
+
223
+
224
+ def _tail_file(
225
+ server: str,
226
+ filename: str,
227
+ headers: dict,
228
+ follow: bool,
229
+ lines: int,
230
+ output: str,
231
+ ) -> None:
232
+ """Tail a static file (legacy mode)."""
233
+ import httpx
234
+
58
235
  params = {
59
236
  "filename": filename,
60
237
  "limit": lines,
@@ -64,9 +241,15 @@ def tail(
64
241
  with httpx.Client(timeout=30.0) as client:
65
242
  response = client.get(f"{server}/logs", params=params, headers=headers)
66
243
 
67
- if response.status_code != 200:
68
- error_detail = response.json().get("detail", response.text)
69
- console.print(f"[bold red]Error:[/bold red] {error_detail}")
244
+ if response.status_code == 401:
245
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
246
+ raise typer.Exit(1)
247
+ elif response.status_code != 200:
248
+ try:
249
+ error_detail = response.json().get("detail", response.text)
250
+ except Exception:
251
+ error_detail = response.text
252
+ console.print(f"[red]Error:[/red] {error_detail}")
70
253
  raise typer.Exit(1)
71
254
 
72
255
  data = response.json()
@@ -74,7 +257,10 @@ def tail(
74
257
 
75
258
  # Print initial lines
76
259
  for line in log_lines:
77
- console.print(line)
260
+ if output == "jsonl":
261
+ console.print(json.dumps({"line": line}))
262
+ else:
263
+ console.print(line)
78
264
 
79
265
  if follow:
80
266
  console.print()
@@ -91,13 +277,16 @@ def tail(
91
277
  ws_url = f"{ws_url}/logs/stream?filename={filename}"
92
278
 
93
279
  extra_headers = {}
94
- if api_key:
95
- extra_headers["X-API-Key"] = api_key
280
+ if headers.get("X-API-Key"):
281
+ extra_headers["X-API-Key"] = headers["X-API-Key"]
96
282
 
97
283
  try:
98
284
  async with websockets.connect(ws_url, extra_headers=extra_headers) as ws:
99
285
  async for message in ws:
100
- console.print(message)
286
+ if output == "jsonl":
287
+ console.print(json.dumps({"line": message}))
288
+ else:
289
+ console.print(message)
101
290
  except websockets.exceptions.InvalidStatusCode as e:
102
291
  if e.status_code == 404:
103
292
  console.print("[yellow]Streaming not available.[/yellow]")
@@ -113,9 +302,9 @@ def tail(
113
302
  console.print("\n[dim]Stopped.[/dim]")
114
303
 
115
304
  except httpx.ConnectError:
116
- console.print(f"[bold red]Error:[/bold red] Could not connect to {server}")
305
+ console.print(f"[red]Error:[/red] Could not connect to {server}")
117
306
  console.print("[dim]Is the logtap server running? Start it with 'logtap serve'[/dim]")
118
307
  raise typer.Exit(1)
119
308
  except Exception as e:
120
- console.print(f"[bold red]Error:[/bold red] {e}")
309
+ console.print(f"[red]Error:[/red] {e}")
121
310
  raise typer.Exit(1)
logtap/cli/main.py CHANGED
@@ -3,11 +3,11 @@
3
3
  import typer
4
4
 
5
5
  from logtap import __version__
6
- from logtap.cli.commands import files, query, serve, tail
6
+ from logtap.cli.commands import collect, files, ingest, query, runs, serve, tail
7
7
 
8
8
  app = typer.Typer(
9
9
  name="logtap",
10
- help="A CLI-first log access tool for Unix systems. Remote log file access without SSH.",
10
+ help="tail -f for GPU clouds. Survives disconnects, aggregates multi-node.",
11
11
  rich_markup_mode="rich",
12
12
  no_args_is_help=True,
13
13
  )
@@ -32,14 +32,20 @@ def main(
32
32
  ),
33
33
  ) -> None:
34
34
  """
35
- logtap - Remote log file access without SSH.
35
+ logtap - tail -f for GPU clouds.
36
36
 
37
- Start a server with 'logtap serve' or query a remote server with 'logtap query'.
37
+ Start a collector with 'logtap collect', pipe logs with 'logtap ingest',
38
+ and tail from anywhere with 'logtap tail'.
38
39
  """
39
40
  pass
40
41
 
41
42
 
42
- # Add commands
43
+ # Primary commands (new ML workflow)
44
+ app.command()(collect.collect)
45
+ app.command()(ingest.ingest)
46
+ app.command()(runs.runs)
47
+
48
+ # Legacy commands (file-based workflow)
43
49
  app.command()(serve.serve)
44
50
  app.command()(query.query)
45
51
  app.command()(tail.tail)