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/core/validation.py CHANGED
@@ -3,8 +3,140 @@ Input validation functions for logtap.
3
3
 
4
4
  These functions validate user input to prevent security issues
5
5
  like path traversal attacks and DoS via overly large inputs.
6
+
7
+ Path Traversal Prevention Model
8
+ ===============================
9
+ 1. Input validation: reject NUL bytes, control chars, path separators, ".."
10
+ 2. Join filename to base directory
11
+ 3. Resolve to canonical absolute path (follows symlinks)
12
+ 4. Containment check: commonpath([base, resolved]) == base
13
+ 5. File type check: must be regular file (not dir, device, etc.)
14
+
15
+ This prevents:
16
+ - Directory traversal (../)
17
+ - Absolute path injection (/etc/passwd)
18
+ - Symlink escape attacks
19
+ - Null byte injection
20
+ - Path prefix collisions (/var/log vs /var/logs)
6
21
  """
7
22
 
23
+ import os
24
+ import stat
25
+ from typing import Optional, Tuple
26
+
27
+
28
+ def resolve_safe_path(base_dir: str, filename: str, require_exists: bool = False) -> Optional[str]:
29
+ """
30
+ Safely resolve a filename within a base directory.
31
+
32
+ Security guarantees:
33
+ - Resolved path is always within base_dir (symlink-safe)
34
+ - No path traversal via "..", separators, or absolute paths
35
+ - No NUL bytes or control characters
36
+ - Containment verified via os.path.commonpath
37
+
38
+ Args:
39
+ base_dir: The base directory that files must be within.
40
+ filename: The user-provided filename (single component, no path separators).
41
+ require_exists: If True, also verify the file exists and is a regular file.
42
+
43
+ Returns:
44
+ The resolved filepath if safe, None if validation fails.
45
+ """
46
+ # Reject empty filenames
47
+ if not filename:
48
+ return None
49
+
50
+ # Reject NUL bytes (can truncate paths in some contexts)
51
+ if "\x00" in filename:
52
+ return None
53
+
54
+ # Reject control characters (0x00-0x1F, 0x7F)
55
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in filename):
56
+ return None
57
+
58
+ # Reject special directory entries
59
+ # Note: ".." substring check removed - it over-blocks valid names like "my..log"
60
+ # Traversal requires separators which we reject below; containment check is authoritative
61
+ if filename in {".", ".."}:
62
+ return None
63
+
64
+ # Reject path separators - filename must be a single component
65
+ if "/" in filename or "\\" in filename:
66
+ return None
67
+
68
+ # Reject absolute paths (Unix and Windows)
69
+ if filename.startswith("/") or filename.startswith("\\"):
70
+ return None
71
+ # Windows drive letters (C:, D:, etc.)
72
+ if len(filename) >= 2 and filename[1] == ":":
73
+ return None
74
+
75
+ # Resolve base directory to canonical absolute form
76
+ base_resolved = os.path.realpath(base_dir)
77
+
78
+ # Join and resolve to canonical absolute path (follows symlinks)
79
+ filepath = os.path.join(base_resolved, filename)
80
+ filepath_resolved = os.path.realpath(filepath)
81
+
82
+ # Containment check using commonpath
83
+ # This is the authoritative check - handles prefix collisions correctly
84
+ # e.g., base=/var/log, candidate=/var/logs/evil will fail
85
+ try:
86
+ common = os.path.commonpath([base_resolved, filepath_resolved])
87
+ if common != base_resolved:
88
+ return None
89
+ except ValueError:
90
+ # Paths on different drives (Windows) or other path issues
91
+ return None
92
+
93
+ # Optional: verify file exists and is a regular file
94
+ if require_exists:
95
+ try:
96
+ file_stat = os.stat(filepath_resolved)
97
+ if not stat.S_ISREG(file_stat.st_mode):
98
+ return None
99
+ except OSError:
100
+ return None
101
+
102
+ return filepath_resolved
103
+
104
+
105
+ def resolve_safe_path_checked(base_dir: str, filename: str) -> Tuple[Optional[str], str]:
106
+ """
107
+ Resolve a safe path and return detailed error reason if validation fails.
108
+
109
+ Returns:
110
+ Tuple of (resolved_path, error_reason). If path is valid, error_reason is empty.
111
+ """
112
+ if not filename:
113
+ return None, "empty filename"
114
+ if "\x00" in filename:
115
+ return None, "filename contains NUL byte"
116
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in filename):
117
+ return None, "filename contains control character"
118
+ if filename in {".", ".."}:
119
+ return None, "filename is special directory entry"
120
+ if "/" in filename or "\\" in filename:
121
+ return None, "filename contains path separator"
122
+ if filename.startswith("/") or filename.startswith("\\"):
123
+ return None, "filename is absolute path"
124
+ if len(filename) >= 2 and filename[1] == ":":
125
+ return None, "filename contains Windows drive letter"
126
+
127
+ base_resolved = os.path.realpath(base_dir)
128
+ filepath = os.path.join(base_resolved, filename)
129
+ filepath_resolved = os.path.realpath(filepath)
130
+
131
+ try:
132
+ common = os.path.commonpath([base_resolved, filepath_resolved])
133
+ if common != base_resolved:
134
+ return None, "resolved path escapes base directory"
135
+ except ValueError as e:
136
+ return None, f"path resolution error: {e}"
137
+
138
+ return filepath_resolved, ""
139
+
8
140
 
9
141
  def is_filename_valid(filename: str) -> bool:
10
142
  """
@@ -0,0 +1,304 @@
1
+ Metadata-Version: 2.4
2
+ Name: logtap
3
+ Version: 0.4.1
4
+ Summary: A CLI-first log access tool for Unix systems. Remote log file access without SSH.
5
+ Project-URL: Homepage, https://github.com/cainky/logtap
6
+ Project-URL: Repository, https://github.com/cainky/logtap
7
+ Author-email: cainky <kylecain.me@gmail.com>
8
+ License: GPL-3.0-or-later
9
+ License-File: LICENSE
10
+ Keywords: cli,devops,logs,monitoring,sysadmin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: System :: Logging
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: aiofiles>=23.2.1
26
+ Requires-Dist: fastapi>=0.109.0
27
+ Requires-Dist: google-re2>=1.1
28
+ Requires-Dist: httpx>=0.26.0
29
+ Requires-Dist: pydantic-settings>=2.1.0
30
+ Requires-Dist: pydantic>=2.5.0
31
+ Requires-Dist: python-dotenv>=1.0.1
32
+ Requires-Dist: rich>=13.7.0
33
+ Requires-Dist: typer>=0.9.0
34
+ Requires-Dist: uvicorn[standard]>=0.27.0
35
+ Requires-Dist: websockets>=12.0
36
+ Provides-Extra: dev
37
+ Requires-Dist: pre-commit>=4.5.1; extra == 'dev'
38
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
39
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
40
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
41
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # logtap
45
+
46
+ [![PyPI version](https://badge.fury.io/py/logtap.svg)](https://badge.fury.io/py/logtap)
47
+ [![Tests](https://github.com/cainky/logtap/actions/workflows/tests.yml/badge.svg)](https://github.com/cainky/logtap/actions/workflows/tests.yml)
48
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
49
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
50
+
51
+ **`tail -f` for GPU clouds. Survives disconnects, aggregates multi-node.**
52
+
53
+ > Stop losing your training logs when SSH drops. Watch from anywhere, reconnect seamlessly.
54
+
55
+ ## The Problem
56
+
57
+ You're training a model on RunPod, Vast.ai, or Lambda. You SSH in, start training, and:
58
+
59
+ - Your terminal disconnects after an hour
60
+ - You lose visibility into what's happening
61
+ - You resort to tmux hacks just to keep logs alive
62
+ - Multi-node training means logs scattered across machines
63
+
64
+ ## The Solution
65
+
66
+ ```bash
67
+ # On your GPU instance
68
+ pip install logtap
69
+ logtap collect &
70
+ python train.py 2>&1 | logtap ingest run1
71
+
72
+ # From your laptop (or phone)
73
+ logtap tail run1 --follow
74
+
75
+ # Connection drops... reconnects automatically
76
+ # "reconnected (missed 0 lines)"
77
+ ```
78
+
79
+ ## Quickstart: RunPod / Vast.ai
80
+
81
+ On the GPU instance:
82
+
83
+ ```bash
84
+ pip install logtap
85
+ export LOGTAP_API_KEY=secret
86
+ logtap collect --port 8000
87
+ ```
88
+
89
+ Start training and stream logs:
90
+
91
+ ```bash
92
+ python train.py 2>&1 | logtap ingest run1 --tag node=$(hostname)
93
+ ```
94
+
95
+ From your laptop:
96
+
97
+ ```bash
98
+ export LOGTAP_SERVER=http://<gpu-ip>:8000
99
+ export LOGTAP_API_KEY=secret
100
+ logtap tail run1 --follow
101
+ ```
102
+
103
+ Disconnect, close your terminal, or switch networks.
104
+ Re-run `logtap tail` anytime to resume where you left off.
105
+
106
+ Works the same on RunPod, Vast.ai, Lambda, and any ephemeral GPU cloud.
107
+
108
+ ## Features
109
+
110
+ - **Survives Disconnects** - Resume from where you left off with cursor-based streaming
111
+ - **Pipe-Friendly** - Works with any training script via stdin
112
+ - **Multi-Node Ready** - Tag runs with `node=gpu1` and filter/aggregate
113
+ - **Zero Infra** - No database, no complex setup, just pip install
114
+ - **Lightweight** - <50MB memory, append-only file storage
115
+
116
+ ## Why not tmux / mosh?
117
+
118
+ tmux and mosh help keep SSH sessions alive.
119
+ logtap solves a different problem.
120
+
121
+ - SSH can still drop (web terminals, proxies, idle timeouts)
122
+ - tmux doesn't aggregate logs across machines
123
+ - tmux can't be viewed from another device without SSH
124
+ - tmux sessions die when ephemeral instances stop
125
+
126
+ logtap streams logs over HTTP:
127
+ - survives disconnects
128
+ - resumes without gaps
129
+ - aggregates multi-node training via tags
130
+ - works from anywhere (no SSH required)
131
+
132
+ You can still use tmux. You just don't have to rely on it.
133
+
134
+ ## Quick Start
135
+
136
+ ### 1. Install
137
+
138
+ ```bash
139
+ pip install logtap
140
+ ```
141
+
142
+ ### 2. Start Collector (on GPU instance)
143
+
144
+ ```bash
145
+ logtap collect --api-key secret
146
+ ```
147
+
148
+ ### 3. Pipe Your Training Logs
149
+
150
+ ```bash
151
+ python train.py 2>&1 | logtap ingest run1 --api-key secret
152
+ ```
153
+
154
+ ### 4. Tail From Anywhere
155
+
156
+ ```bash
157
+ export LOGTAP_SERVER=http://your-gpu-ip:8000
158
+ export LOGTAP_API_KEY=secret
159
+
160
+ logtap tail run1 --follow
161
+ ```
162
+
163
+ ## CLI Commands
164
+
165
+ | Command | Description |
166
+ |---------|-------------|
167
+ | `logtap collect` | Start collector server (accepts ingested runs) |
168
+ | `logtap ingest <run>` | Pipe stdin to collector |
169
+ | `logtap tail <run>` | Tail a run with `--follow` for streaming |
170
+ | `logtap runs` | List active runs |
171
+ | `logtap doctor` | Check server connectivity and diagnose issues |
172
+
173
+ ### Ingest Options
174
+
175
+ ```bash
176
+ # Auto-generate run name
177
+ python train.py | logtap ingest
178
+
179
+ # Add tags for multi-node
180
+ python train.py | logtap ingest run1 --tag node=gpu1 --tag rank=0
181
+
182
+ # Quiet mode (no status messages)
183
+ python train.py | logtap ingest run1 --quiet
184
+ ```
185
+
186
+ ### Tail Options
187
+
188
+ ```bash
189
+ # Follow mode (like tail -f)
190
+ logtap tail run1 --follow
191
+
192
+ # Resume from specific cursor (survives disconnects!)
193
+ logtap tail run1 --follow --since 5000
194
+
195
+ # Filter by tag
196
+ logtap tail run1 --tag node=gpu1
197
+
198
+ # Output formats
199
+ logtap tail run1 --output jsonl | jq '.line'
200
+ ```
201
+
202
+ ### Collector Options
203
+
204
+ ```bash
205
+ logtap collect \
206
+ --port 8000 \
207
+ --api-key secret \
208
+ --data-dir ~/.logtap/runs \
209
+ --max-disk-mb 5000 \
210
+ --retention-hours 72
211
+ ```
212
+
213
+ ## Multi-Node Training
214
+
215
+ Tag each node and aggregate:
216
+
217
+ ```bash
218
+ # Node 1
219
+ python train.py | logtap ingest run1 --tag node=gpu1
220
+
221
+ # Node 2
222
+ python train.py | logtap ingest run1 --tag node=gpu2
223
+
224
+ # Watch all nodes
225
+ logtap tail run1 --follow
226
+
227
+ # Watch specific node
228
+ logtap tail run1 --follow --tag node=gpu1
229
+ ```
230
+
231
+ ## Environment Variables
232
+
233
+ | Variable | Default | Description |
234
+ |----------|---------|-------------|
235
+ | `LOGTAP_SERVER` | `http://localhost:8000` | Collector URL |
236
+ | `LOGTAP_API_KEY` | - | API key for auth |
237
+
238
+ Set these to avoid typing `--server` and `--api-key` every time.
239
+
240
+ ## How It Works
241
+
242
+ 1. **Collector** writes logs to append-only files with cursor tracking
243
+ 2. **Ingest** streams stdin over HTTP chunked POST
244
+ 3. **Tail** uses SSE (Server-Sent Events) with resume support
245
+ 4. **Reconnect** passes `?since=<cursor>` to continue without gaps
246
+
247
+ No database. No message queue. Just files and HTTP.
248
+
249
+ ## API Endpoints
250
+
251
+ For scripting or custom integrations:
252
+
253
+ | Endpoint | Description |
254
+ |----------|-------------|
255
+ | `POST /runs/{id}/ingest` | Stream lines (chunked POST) |
256
+ | `GET /runs/{id}/stream` | SSE stream with `?since=&follow=` |
257
+ | `GET /runs/{id}/query` | Query with `?from=&to=&search=` |
258
+ | `GET /runs` | List runs |
259
+ | `GET /health` | Health check with capabilities |
260
+
261
+ ## Legacy: Static File Mode
262
+
263
+ logtap also works as a simple remote log viewer (the original use case):
264
+
265
+ ```bash
266
+ # On server with log files
267
+ logtap serve --log-dir /var/log
268
+
269
+ # From client
270
+ logtap tail syslog --server http://myserver:8000 --follow
271
+ logtap query auth.log --regex "Failed password"
272
+ ```
273
+
274
+ ## Security
275
+
276
+ - **API Key Auth** - Optional but recommended for production
277
+ - **Path Traversal Protection** - Comprehensive defense with symlink-safe containment checks (see [SECURITY.md](SECURITY.md))
278
+ - **ReDoS Protection** - Uses google-re2 for guaranteed linear-time regex matching
279
+ - **Read-Only by Default** - Collector only writes to its data directory
280
+ - **Input Validation** - Rejects control characters, NUL bytes, and malicious path patterns
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ git clone https://github.com/cainky/logtap.git
286
+ cd logtap
287
+
288
+ # Install with uv
289
+ uv sync --extra dev
290
+
291
+ # Run tests
292
+ uv run pytest
293
+
294
+ # Run collector in dev mode
295
+ uv run logtap collect --reload
296
+ ```
297
+
298
+ ## License
299
+
300
+ GPL v3 - see [LICENSE](LICENSE)
301
+
302
+ ## Author
303
+
304
+ Kyle Cain - [@cainky](https://github.com/cainky)
@@ -6,24 +6,25 @@ logtap/api/dependencies.py,sha256=1cx1qrp0O6v1fHXA2JdEhC8P4caG2oUSCfMk2-8zmGs,16
6
6
  logtap/api/routes/__init__.py,sha256=XYvFyTP4zKywRZH0v97k0EZCYgxdL2PSUaNet20znPE,29
7
7
  logtap/api/routes/files.py,sha256=bqZYrX6jrF5-7GzBpUIXXoPVdxUwm6o0LTcJBLtaJUE,991
8
8
  logtap/api/routes/health.py,sha256=s117Hr1E8OcBGPOWq2WwHLZSq35hS7wmLPk6BYq3dq4,1112
9
- logtap/api/routes/logs.py,sha256=XpRAd4fZmVyylz6bHCHm4y0Y2GofSquH6j5WJP3Jyao,8467
10
- logtap/api/routes/parsed.py,sha256=XVvkKBE_hQvfJyrDBBPR_PpVxvof-y4B77xKe9Rr0Qk,3367
11
- logtap/api/routes/runs.py,sha256=Fxb6joJ5FPXPCKTfgD41i0H4UQ4U4fmFxk08SFUxt_s,11355
9
+ logtap/api/routes/logs.py,sha256=J_Sp9-Mx2g-tVdpVJMzMz1ytymtCCMJvDJ74uLeiKw0,8294
10
+ logtap/api/routes/parsed.py,sha256=dXIKy0JMxlvoHAbCCk4zhagHUPaWclaUBPcHXG2ew1I,3442
11
+ logtap/api/routes/runs.py,sha256=njyPZ90eXeAvnDWVq-q3x0slDCjUJ8FotOnl1muCKgI,10571
12
12
  logtap/cli/__init__.py,sha256=U4zaUJ1rm0qHXqeArpzC45S5N-5SBdd8K6foe513msk,31
13
- logtap/cli/main.py,sha256=jfzN-S6dn3bg6yuQ3ovJtaLYb7LnCDg_cl7vqRWTBxw,1230
13
+ logtap/cli/main.py,sha256=7XSeZrvCfiCcMA_sJLIIaT21ORAsYKiD_gKCSLxGmbc,1267
14
14
  logtap/cli/commands/__init__.py,sha256=U4zaUJ1rm0qHXqeArpzC45S5N-5SBdd8K6foe513msk,31
15
15
  logtap/cli/commands/collect.py,sha256=8x6LyMrzI79wYtfLZcbQdgpy5nxPZuQOEillE9IfwwE,3002
16
+ logtap/cli/commands/doctor.py,sha256=fVL6ZhD52zQBqGQRwi0J_m3MaN-0SbwhxM8UZC4nG-Y,4218
16
17
  logtap/cli/commands/files.py,sha256=WFr8kA0SdgQHz3ZyONTaljxHMcD-nQlndp3UIOwZATc,2455
17
18
  logtap/cli/commands/ingest.py,sha256=JaItHHYV3fBmPkseYpubyHryNbuEuxyjRBk-EiiEwyU,4054
18
19
  logtap/cli/commands/query.py,sha256=uD9nH5E-7EqJryLf3hHkDbJSQo4kWFGmzzHgTfAKFwk,3418
19
20
  logtap/cli/commands/runs.py,sha256=Dweswku19Dj2KOFhT0kaega9KSKmUrvya3eLn0-5lXo,3632
20
21
  logtap/cli/commands/serve.py,sha256=9OvfII21q6cel3zZfSsAsiERKwKFt0ZFTXmUd2Psthg,1910
21
- logtap/cli/commands/tail.py,sha256=w7P3_1o0OtVtos3kV8w4goShWXzzUDo4ekSye3VSpGo,10015
22
+ logtap/cli/commands/tail.py,sha256=mOYGwXaXeM_PHI27BAm61CR25YAh9dpLOlq8oy0ciLM,10427
22
23
  logtap/core/__init__.py,sha256=tsoL0XuDrPd5xHEu975WqFHoA7EQgloxrum7CjsWHuk,450
23
24
  logtap/core/reader.py,sha256=BuBrEAbS2naCBTtuBNc0Un6thbekzabaHTBzYE1SwKg,5277
24
- logtap/core/runs.py,sha256=t4JnQvZTi-YB2II8maBIcaJD77gp_CjKVcTGYwHhuU8,13488
25
+ logtap/core/runs.py,sha256=lYOWAWp6RrcaMLkWxqhD0MC5alQe7rv07GtEazL5WWE,14956
25
26
  logtap/core/search.py,sha256=rtq8WP96RYUvRkX_R5x_mdD_dw1syDuNkHx3uP_diOg,4574
26
- logtap/core/validation.py,sha256=Nk86jHqEfI4H96fk-1rjbC5sBwfzls43hyOhnRV6rxI,1359
27
+ logtap/core/validation.py,sha256=kRKRuhSaORslgatIQ2nW3ufDOn0mp_mB38CguhizRBY,6071
27
28
  logtap/core/parsers/__init__.py,sha256=5f3hFxf_DgNScRDchRT8ocFVgi7Md4xuMN-ShvlssBo,575
28
29
  logtap/core/parsers/apache.py,sha256=JjuQ4v-b7HJvTCcjbOMgv5_dSdiNVPX_EUyplc3f5Qw,5332
29
30
  logtap/core/parsers/auto.py,sha256=OLLuX7XIxS0Upnv9FQ-_B0sGAyZmfNxjnMDGdZtUIO4,3565
@@ -34,8 +35,8 @@ logtap/core/parsers/syslog.py,sha256=gBNQ39QXsigOpfnq3cEdmvFa8NLp_wmiSMDlTt0SIbs
34
35
  logtap/models/__init__.py,sha256=tce3Q0QjPhnlAYG8IcwxPedyh1ibBlKIF3CjXe5wwgo,280
35
36
  logtap/models/config.py,sha256=8x6OR_y2ZB8SSoQWQGwDB7DXH30UyMNXUcRWOctjUn8,927
36
37
  logtap/models/responses.py,sha256=xKdKdS85soxMYGNad3WfF0pOG0Pb5Z7XwVrwK-TCnHs,4084
37
- logtap-0.4.0.dist-info/METADATA,sha256=_Y9ZSz2BwIF2SOOHYPQZ8YrLIxmpB31tOaabDty1BDY,7466
38
- logtap-0.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
39
- logtap-0.4.0.dist-info/entry_points.txt,sha256=tuAit8kt97yjtACQKvN35wWozp4KhSju_gfDhSS1IrM,47
40
- logtap-0.4.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
41
- logtap-0.4.0.dist-info/RECORD,,
38
+ logtap-0.4.1.dist-info/METADATA,sha256=Mz7i6uIVOv9mHtQ2oMgKSOVH5oVtpjO7s3N9u7ZSUP8,8203
39
+ logtap-0.4.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
40
+ logtap-0.4.1.dist-info/entry_points.txt,sha256=tuAit8kt97yjtACQKvN35wWozp4KhSju_gfDhSS1IrM,47
41
+ logtap-0.4.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
42
+ logtap-0.4.1.dist-info/RECORD,,