lenslet 0.2.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.
@@ -0,0 +1,591 @@
1
+ """In-memory dataset storage for programmatic API."""
2
+ from __future__ import annotations
3
+ import os
4
+ import struct
5
+ import sys
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from io import BytesIO
11
+ from urllib.parse import urlparse
12
+ from PIL import Image
13
+
14
+
15
+ @dataclass
16
+ class CachedItem:
17
+ """In-memory cached metadata for an image."""
18
+ path: str
19
+ name: str
20
+ mime: str
21
+ width: int
22
+ height: int
23
+ size: int
24
+ mtime: float
25
+ url: str | None = None # For S3 images, this is the presigned URL
26
+
27
+
28
+ @dataclass
29
+ class CachedIndex:
30
+ """In-memory cached folder index."""
31
+ path: str
32
+ generated_at: str
33
+ items: list[CachedItem] = field(default_factory=list)
34
+ dirs: list[str] = field(default_factory=list)
35
+
36
+
37
+ class DatasetStorage:
38
+ """
39
+ In-memory storage for programmatic datasets.
40
+ Supports both local file paths and S3 URIs.
41
+ """
42
+
43
+ IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp")
44
+ REMOTE_HEADER_BYTES = 65536
45
+ REMOTE_DIM_WORKERS = 16
46
+
47
+ def __init__(
48
+ self,
49
+ datasets: dict[str, list[str]],
50
+ thumb_size: int = 256,
51
+ thumb_quality: int = 70,
52
+ ):
53
+ """
54
+ Initialize with datasets.
55
+
56
+ Args:
57
+ datasets: Dict of {dataset_name: [list of paths/URIs]}
58
+ thumb_size: Thumbnail short edge size
59
+ thumb_quality: WebP quality for thumbnails
60
+ """
61
+ self.datasets = datasets
62
+ self.thumb_size = thumb_size
63
+ self.thumb_quality = thumb_quality
64
+
65
+ # Build flat path structure: /dataset_name/image_name
66
+ self._items: dict[str, CachedItem] = {} # path -> item
67
+ self._indexes: dict[str, CachedIndex] = {}
68
+ self._thumbnails: dict[str, bytes] = {}
69
+ self._metadata: dict[str, dict] = {}
70
+ self._dimensions: dict[str, tuple[int, int]] = {}
71
+
72
+ # Build initial index
73
+ self._build_all_indexes()
74
+
75
+ def _is_s3_uri(self, path: str) -> bool:
76
+ """Check if path is an S3 URI."""
77
+ return path.startswith("s3://")
78
+
79
+ def _is_http_url(self, path: str) -> bool:
80
+ """Check if path is an HTTP/HTTPS URL."""
81
+ return path.startswith("http://") or path.startswith("https://")
82
+
83
+ def _get_presigned_url(self, s3_uri: str, expires_in: int = 3600) -> str:
84
+ """Convert S3 URI to a presigned HTTPS URL using boto3."""
85
+ try:
86
+ import boto3
87
+ from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
88
+ except ImportError as exc: # pragma: no cover - optional dependency
89
+ raise ImportError(
90
+ "boto3 package required for S3 support. Install with: pip install lenslet[s3]"
91
+ ) from exc
92
+
93
+ parsed = urlparse(s3_uri)
94
+ bucket = parsed.netloc
95
+ key = parsed.path.lstrip("/")
96
+ if not bucket or not key:
97
+ raise ValueError(f"Invalid S3 URI: {s3_uri}")
98
+
99
+ try:
100
+ s3_client = boto3.client("s3")
101
+ return s3_client.generate_presigned_url(
102
+ "get_object",
103
+ Params={"Bucket": bucket, "Key": key},
104
+ ExpiresIn=expires_in,
105
+ )
106
+ except (BotoCoreError, ClientError, NoCredentialsError) as e:
107
+ raise RuntimeError(f"Failed to presign S3 URI: {e}") from e
108
+
109
+ def _is_supported_image(self, name: str) -> bool:
110
+ """Check if file is a supported image."""
111
+ return name.lower().endswith(self.IMAGE_EXTS)
112
+
113
+ def _extract_name(self, path: str) -> str:
114
+ """Extract filename from local path or S3 URI."""
115
+ if self._is_s3_uri(path) or self._is_http_url(path):
116
+ parsed = urlparse(path)
117
+ return os.path.basename(parsed.path)
118
+ return os.path.basename(path)
119
+
120
+ def _guess_mime(self, name: str) -> str:
121
+ """Guess MIME type from filename."""
122
+ n = name.lower()
123
+ if n.endswith(".webp"):
124
+ return "image/webp"
125
+ if n.endswith(".png"):
126
+ return "image/png"
127
+ return "image/jpeg"
128
+
129
+ def _read_dimensions_from_bytes(self, data: bytes, ext: str | None) -> tuple[int, int] | None:
130
+ """Read image dimensions from in-memory bytes."""
131
+ if not data:
132
+ return None
133
+
134
+ kind = None
135
+ if ext in ("jpg", "jpeg"):
136
+ kind = "jpeg"
137
+ elif ext == "png":
138
+ kind = "png"
139
+ elif ext == "webp":
140
+ kind = "webp"
141
+ else:
142
+ if data.startswith(b"\xff\xd8"):
143
+ kind = "jpeg"
144
+ elif data.startswith(b"\x89PNG\r\n\x1a\n"):
145
+ kind = "png"
146
+ elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
147
+ kind = "webp"
148
+
149
+ try:
150
+ buf = BytesIO(data)
151
+ if kind == "jpeg":
152
+ return self._jpeg_dimensions(buf)
153
+ if kind == "png":
154
+ return self._png_dimensions(buf)
155
+ if kind == "webp":
156
+ return self._webp_dimensions(buf)
157
+ except Exception:
158
+ return None
159
+ return None
160
+
161
+ def _get_remote_header_bytes(self, url: str, max_bytes: int | None = None) -> bytes | None:
162
+ """Fetch the first N bytes of a remote image via Range request."""
163
+ max_bytes = max_bytes or self.REMOTE_HEADER_BYTES
164
+ try:
165
+ import urllib.request
166
+ req = urllib.request.Request(
167
+ url,
168
+ headers={"Range": f"bytes=0-{max_bytes - 1}"},
169
+ )
170
+ with urllib.request.urlopen(req) as response:
171
+ return response.read(max_bytes)
172
+ except Exception:
173
+ return None
174
+
175
+ def _get_remote_dimensions(self, url: str, name: str) -> tuple[int, int] | None:
176
+ """Try to read dimensions from a ranged remote request."""
177
+ header = self._get_remote_header_bytes(url)
178
+ if not header:
179
+ return None
180
+ ext = os.path.splitext(name)[1].lower().lstrip(".") or None
181
+ return self._read_dimensions_from_bytes(header, ext)
182
+
183
+ def _progress(self, done: int, total: int, label: str) -> None:
184
+ if total <= 0:
185
+ return
186
+ bar_len = 24
187
+ filled = int(bar_len * done / total)
188
+ bar = "#" * filled + "-" * (bar_len - filled)
189
+ pct = (done / total) * 100
190
+ label_part = f" ({label})" if label else ""
191
+ msg = f"[lenslet] Indexing{label_part}: [{bar}] {done}/{total} ({pct:5.1f}%)"
192
+ end = "\n" if done >= total else "\r"
193
+ print(msg, end=end, file=sys.stderr, flush=True)
194
+
195
+ def _effective_remote_workers(self, total: int) -> int:
196
+ if total <= 0:
197
+ return 0
198
+ cpu = os.cpu_count() or 1
199
+ return max(1, min(self.REMOTE_DIM_WORKERS, cpu, total))
200
+
201
+ def _probe_remote_dimensions(self, tasks: list[tuple[str, CachedItem, str, str]], label: str) -> None:
202
+ """Fetch remote dimensions in parallel and update cached items."""
203
+ total = len(tasks)
204
+ if total == 0:
205
+ return
206
+ workers = self._effective_remote_workers(total)
207
+ done = 0
208
+ last_print = 0.0
209
+ progress_label = label
210
+
211
+ def _work(task: tuple[str, CachedItem, str, str]):
212
+ logical_path, item, url, name = task
213
+ dims = self._get_remote_dimensions(url, name)
214
+ return logical_path, item, dims
215
+
216
+ with ThreadPoolExecutor(max_workers=workers) as executor:
217
+ futures = [executor.submit(_work, task) for task in tasks]
218
+ for future in as_completed(futures):
219
+ logical_path, item, dims = future.result()
220
+ if dims:
221
+ self._dimensions[logical_path] = dims
222
+ item.width, item.height = dims
223
+ done += 1
224
+ now = time.monotonic()
225
+ if now - last_print > 0.1 or done == total:
226
+ self._progress(done, total, progress_label)
227
+ last_print = now
228
+
229
+ def _build_all_indexes(self):
230
+ """Build indexes for all datasets."""
231
+ # Root index contains dataset folders
232
+ root_dirs = list(self.datasets.keys())
233
+ self._indexes["/"] = CachedIndex(
234
+ path="/",
235
+ generated_at=datetime.now(timezone.utc).isoformat(),
236
+ items=[],
237
+ dirs=root_dirs,
238
+ )
239
+
240
+ # Build index for each dataset
241
+ for dataset_name, paths in self.datasets.items():
242
+ items = []
243
+ remote_tasks: list[tuple[str, CachedItem, str, str]] = []
244
+ for source_path in paths:
245
+ name = self._extract_name(source_path)
246
+ if not self._is_supported_image(name):
247
+ continue
248
+
249
+ # Create logical path: /dataset_name/filename
250
+ logical_path = f"/{dataset_name}/{name}"
251
+
252
+ # Determine source type
253
+ is_s3 = self._is_s3_uri(source_path)
254
+ is_http = self._is_http_url(source_path)
255
+ url = None
256
+ size = 0
257
+
258
+ if is_s3:
259
+ # For S3, get presigned URL
260
+ try:
261
+ url = self._get_presigned_url(source_path)
262
+ # We can't easily get size without fetching, use 0 for now
263
+ size = 0
264
+ except Exception as e:
265
+ print(f"[lenslet] Warning: Failed to presign {source_path}: {e}")
266
+ continue
267
+ elif is_http:
268
+ # Plain HTTP/HTTPS URL — use as-is
269
+ url = source_path
270
+ size = 0
271
+ else:
272
+ # For local, validate and get size
273
+ if not os.path.exists(source_path):
274
+ print(f"[lenslet] Warning: File not found: {source_path}")
275
+ continue
276
+ try:
277
+ size = os.path.getsize(source_path)
278
+ except Exception:
279
+ size = 0
280
+
281
+ mime = self._guess_mime(name)
282
+ mtime = time.time()
283
+ width, height = 0, 0
284
+
285
+ # Create cached item
286
+ item = CachedItem(
287
+ path=logical_path,
288
+ name=name,
289
+ mime=mime,
290
+ width=width,
291
+ height=height,
292
+ size=size,
293
+ mtime=mtime,
294
+ url=url, # For S3 images
295
+ )
296
+
297
+ items.append(item)
298
+ self._items[logical_path] = item
299
+ if url:
300
+ remote_tasks.append((logical_path, item, url, name))
301
+
302
+ # Store source path mapping
303
+ if not hasattr(self, '_source_paths'):
304
+ self._source_paths = {}
305
+ self._source_paths[logical_path] = source_path
306
+
307
+ if remote_tasks:
308
+ self._probe_remote_dimensions(remote_tasks, f"remote headers: {dataset_name}")
309
+
310
+ # Create dataset index
311
+ dataset_path = f"/{dataset_name}"
312
+ self._indexes[dataset_path] = CachedIndex(
313
+ path=dataset_path,
314
+ generated_at=datetime.now(timezone.utc).isoformat(),
315
+ items=items,
316
+ dirs=[],
317
+ )
318
+
319
+ def _normalize_path(self, path: str) -> str:
320
+ """Normalize path for consistent cache keys."""
321
+ p = path.strip("/")
322
+ return "/" + p if p else "/"
323
+
324
+ def get_index(self, path: str) -> CachedIndex:
325
+ """Get cached index for a folder."""
326
+ norm = self._normalize_path(path)
327
+ if norm in self._indexes:
328
+ return self._indexes[norm]
329
+ raise FileNotFoundError(f"Dataset not found: {path}")
330
+
331
+ def validate_image_path(self, path: str) -> None:
332
+ """Ensure path is valid and exists in our dataset."""
333
+ if not path or path not in self._items:
334
+ raise FileNotFoundError(path)
335
+
336
+ def get_source_path(self, logical_path: str) -> str:
337
+ """Get original source path/URI for a logical path."""
338
+ if not hasattr(self, '_source_paths'):
339
+ raise FileNotFoundError(logical_path)
340
+ if logical_path not in self._source_paths:
341
+ raise FileNotFoundError(logical_path)
342
+ return self._source_paths[logical_path]
343
+
344
+ def read_bytes(self, path: str) -> bytes:
345
+ """Read file contents. For S3, downloads from presigned URL. For local, reads file."""
346
+ source_path = self.get_source_path(path)
347
+ item = self._items[path]
348
+
349
+ if item.url: # S3 image
350
+ import urllib.request
351
+ try:
352
+ with urllib.request.urlopen(item.url) as response:
353
+ return response.read()
354
+ except Exception as e:
355
+ raise RuntimeError(f"Failed to download from S3: {e}")
356
+ else: # Local file
357
+ with open(source_path, "rb") as f:
358
+ return f.read()
359
+
360
+ def exists(self, path: str) -> bool:
361
+ """Check if path exists in dataset."""
362
+ return path in self._items
363
+
364
+ def size(self, path: str) -> int:
365
+ """Get file size."""
366
+ if path in self._items:
367
+ return self._items[path].size
368
+ return 0
369
+
370
+ def join(self, *parts: str) -> str:
371
+ """Join path parts."""
372
+ return "/" + "/".join(p.strip("/") for p in parts if p.strip("/"))
373
+
374
+ def etag(self, path: str) -> str | None:
375
+ """Get ETag for caching."""
376
+ if path in self._items:
377
+ item = self._items[path]
378
+ return f"{int(item.mtime)}-{item.size}"
379
+ return None
380
+
381
+ def get_thumbnail(self, path: str) -> bytes | None:
382
+ """Get thumbnail, generating if needed."""
383
+ if path in self._thumbnails:
384
+ return self._thumbnails[path]
385
+
386
+ try:
387
+ raw = self.read_bytes(path)
388
+ thumb, dims = self._make_thumbnail(raw)
389
+ self._thumbnails[path] = thumb
390
+ if dims:
391
+ self._dimensions[path] = dims
392
+ return thumb
393
+ except Exception as e:
394
+ print(f"[lenslet] Failed to generate thumbnail for {path}: {e}")
395
+ return None
396
+
397
+ def _make_thumbnail(self, img_bytes: bytes) -> tuple[bytes, tuple[int, int] | None]:
398
+ """Generate a WebP thumbnail. Returns (thumb_bytes, (w, h))."""
399
+ with Image.open(BytesIO(img_bytes)) as im:
400
+ w, h = im.size
401
+ short = min(w, h)
402
+ if short > self.thumb_size:
403
+ scale = self.thumb_size / short
404
+ new_w = max(1, int(w * scale))
405
+ new_h = max(1, int(h * scale))
406
+ im = im.convert("RGB").resize((new_w, new_h), Image.LANCZOS)
407
+ else:
408
+ im = im.convert("RGB")
409
+ out = BytesIO()
410
+ im.save(out, format="WEBP", quality=self.thumb_quality, method=6)
411
+ return out.getvalue(), (w, h)
412
+
413
+ def get_dimensions(self, path: str) -> tuple[int, int]:
414
+ """Get image dimensions, loading lazily if needed."""
415
+ if path in self._dimensions:
416
+ return self._dimensions[path]
417
+
418
+ item = self._items.get(path)
419
+ if item and item.url:
420
+ dims = self._get_remote_dimensions(item.url, item.name)
421
+ if dims:
422
+ self._dimensions[path] = dims
423
+ self._items[path].width = dims[0]
424
+ self._items[path].height = dims[1]
425
+ return dims
426
+
427
+ try:
428
+ raw = self.read_bytes(path)
429
+ with Image.open(BytesIO(raw)) as im:
430
+ w, h = im.size
431
+ self._dimensions[path] = (w, h)
432
+
433
+ # Update item dimensions
434
+ if path in self._items:
435
+ self._items[path].width = w
436
+ self._items[path].height = h
437
+
438
+ return w, h
439
+ except Exception:
440
+ return 0, 0
441
+
442
+ def get_metadata(self, path: str) -> dict:
443
+ """Get metadata for an image."""
444
+ if path in self._metadata:
445
+ return self._metadata[path]
446
+
447
+ # Get dimensions if available
448
+ w, h = self._dimensions.get(path, (0, 0))
449
+ if (w == 0 or h == 0) and path in self._items:
450
+ w = self._items[path].width
451
+ h = self._items[path].height
452
+
453
+ meta = {
454
+ "width": w,
455
+ "height": h,
456
+ "tags": [],
457
+ "notes": "",
458
+ "star": None,
459
+ }
460
+ self._metadata[path] = meta
461
+ return meta
462
+
463
+ def set_metadata(self, path: str, meta: dict) -> None:
464
+ """Update in-memory metadata (session-only)."""
465
+ self._metadata[path] = meta
466
+
467
+ def search(self, query: str = "", path: str = "/", limit: int = 100) -> list[CachedItem]:
468
+ """Simple in-memory search."""
469
+ q = (query or "").lower()
470
+ norm = self._normalize_path(path)
471
+
472
+ results = []
473
+ for item in self._items.values():
474
+ # Filter by path scope
475
+ if norm != "/" and not item.path.startswith(norm + "/"):
476
+ continue
477
+
478
+ # Search in name and metadata
479
+ meta = self.get_metadata(item.path)
480
+ haystack = " ".join([
481
+ item.name,
482
+ " ".join(meta.get("tags", [])),
483
+ meta.get("notes", ""),
484
+ ]).lower()
485
+
486
+ if q in haystack:
487
+ results.append(item)
488
+ if len(results) >= limit:
489
+ break
490
+
491
+ return results
492
+
493
+ def _jpeg_dimensions(self, f) -> tuple[int, int] | None:
494
+ """Read JPEG dimensions from SOF marker."""
495
+ f.seek(0)
496
+ if f.read(2) != b"\xff\xd8":
497
+ return None
498
+ while True:
499
+ marker = f.read(2)
500
+ if len(marker) < 2 or marker[0] != 0xff:
501
+ return None
502
+ if marker[1] == 0xd9: # EOI
503
+ return None
504
+ if 0xc0 <= marker[1] <= 0xcf and marker[1] not in (0xc4, 0xc8, 0xcc):
505
+ length_bytes = f.read(2)
506
+ if len(length_bytes) < 2:
507
+ return None
508
+ _ = struct.unpack(">H", length_bytes)[0]
509
+ f.read(1) # precision
510
+ size = f.read(4)
511
+ if len(size) < 4:
512
+ return None
513
+ h, w = struct.unpack(">HH", size)
514
+ return w, h
515
+ length_bytes = f.read(2)
516
+ if len(length_bytes) < 2:
517
+ return None
518
+ length = struct.unpack(">H", length_bytes)[0]
519
+ if length < 2:
520
+ return None
521
+ f.seek(length - 2, 1)
522
+
523
+ def _png_dimensions(self, f) -> tuple[int, int] | None:
524
+ """Read PNG dimensions from IHDR chunk."""
525
+ f.seek(0)
526
+ if f.read(8) != b"\x89PNG\r\n\x1a\n":
527
+ return None
528
+ if len(f.read(4)) < 4:
529
+ return None
530
+ if f.read(4) != b"IHDR":
531
+ return None
532
+ data = f.read(8)
533
+ if len(data) < 8:
534
+ return None
535
+ w, h = struct.unpack(">II", data)
536
+ return w, h
537
+
538
+ def _webp_dimensions(self, f) -> tuple[int, int] | None:
539
+ """Read WebP dimensions from header."""
540
+ f.seek(0)
541
+ if f.read(4) != b"RIFF":
542
+ return None
543
+ if len(f.read(4)) < 4:
544
+ return None
545
+ if f.read(4) != b"WEBP":
546
+ return None
547
+ chunk = f.read(4)
548
+ if chunk == b"VP8 ":
549
+ if len(f.read(4)) < 4:
550
+ return None
551
+ f.read(3)
552
+ if f.read(3) != b"\x9d\x01\x2a":
553
+ return None
554
+ data = f.read(4)
555
+ if len(data) < 4:
556
+ return None
557
+ w = (data[0] | (data[1] << 8)) & 0x3FFF
558
+ h = (data[2] | (data[3] << 8)) & 0x3FFF
559
+ return w, h
560
+ if chunk == b"VP8L":
561
+ if len(f.read(4)) < 4:
562
+ return None
563
+ if f.read(1) != b"\x2f":
564
+ return None
565
+ data = f.read(4)
566
+ if len(data) < 4:
567
+ return None
568
+ val = struct.unpack("<I", data)[0]
569
+ w = (val & 0x3FFF) + 1
570
+ h = ((val >> 14) & 0x3FFF) + 1
571
+ return w, h
572
+ if chunk == b"VP8X":
573
+ if len(f.read(4)) < 4:
574
+ return None
575
+ f.read(4)
576
+ data = f.read(6)
577
+ if len(data) < 6:
578
+ return None
579
+ w = (data[0] | (data[1] << 8) | (data[2] << 16)) + 1
580
+ h = (data[3] | (data[4] << 8) | (data[5] << 16)) + 1
581
+ return w, h
582
+ return None
583
+
584
+ def _guess_mime(self, name: str) -> str:
585
+ """Guess MIME type from filename."""
586
+ n = name.lower()
587
+ if n.endswith(".webp"):
588
+ return "image/webp"
589
+ if n.endswith(".png"):
590
+ return "image/png"
591
+ return "image/jpeg"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from .base import Storage
4
+
5
+
6
+ class LocalStorage(Storage):
7
+ """Read-only local filesystem storage (does not write sidecars/indexes)."""
8
+
9
+ def __init__(self, root: str):
10
+ self.root = os.path.abspath(root)
11
+ self._root_real = os.path.realpath(self.root)
12
+
13
+ @property
14
+ def root_real(self) -> str:
15
+ """Real path to the configured root (exposed for fast stat calls)."""
16
+ return self._root_real
17
+
18
+ def resolve_path(self, path: str) -> str:
19
+ """Convert relative path to absolute, with security checks."""
20
+ candidate = os.path.abspath(os.path.join(self._root_real, path.lstrip("/")))
21
+ real = os.path.realpath(candidate)
22
+ try:
23
+ common = os.path.commonpath([self._root_real, real])
24
+ except Exception:
25
+ raise ValueError("invalid path")
26
+ if common != self._root_real:
27
+ raise ValueError("invalid path")
28
+ return real
29
+
30
+ def list_dir(self, path: str) -> tuple[list[str], list[str]]:
31
+ p = self.resolve_path(path)
32
+ files, dirs = [], []
33
+ for name in os.listdir(p):
34
+ # Skip hidden files and our own metadata files
35
+ if name.startswith("."):
36
+ continue
37
+ full = os.path.join(p, name)
38
+ if os.path.isdir(full):
39
+ dirs.append(name)
40
+ else:
41
+ files.append(name)
42
+ return files, dirs
43
+
44
+ def read_bytes(self, path: str) -> bytes:
45
+ with open(self.resolve_path(path), "rb") as f:
46
+ return f.read()
47
+
48
+ def write_bytes(self, path: str, data: bytes) -> None:
49
+ # No-op in clean mode - we don't write to the source directory
50
+ pass
51
+
52
+ def exists(self, path: str) -> bool:
53
+ try:
54
+ return os.path.exists(self.resolve_path(path))
55
+ except ValueError:
56
+ return False
57
+
58
+ def size(self, path: str) -> int:
59
+ return os.path.getsize(self.resolve_path(path))
60
+
61
+ def join(self, *parts: str) -> str:
62
+ return "/".join([p.strip("/") for p in parts if p])
63
+
64
+ def etag(self, path: str) -> str | None:
65
+ try:
66
+ st = os.stat(self.resolve_path(path))
67
+ return f"{st.st_mtime_ns}-{st.st_size}"
68
+ except FileNotFoundError:
69
+ return None