qrdrop 0.1.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.
- qrdrop/__init__.py +3 -0
- qrdrop/__main__.py +6 -0
- qrdrop/cli.py +164 -0
- qrdrop/core/__init__.py +1 -0
- qrdrop/core/filesystem.py +331 -0
- qrdrop/core/filetypes.py +367 -0
- qrdrop/core/log_redaction.py +50 -0
- qrdrop/core/network.py +72 -0
- qrdrop/core/password.py +49 -0
- qrdrop/core/qr.py +65 -0
- qrdrop/core/session.py +110 -0
- qrdrop/core/terminal.py +158 -0
- qrdrop/core/wordlist.py +1047 -0
- qrdrop/static/app.js +1098 -0
- qrdrop/static/style.css +1504 -0
- qrdrop/static/vendor/highlight/LICENSE +29 -0
- qrdrop/static/vendor/highlight/highlight.min.js +1213 -0
- qrdrop/static/vendor/highlight/languages/cmake.min.js +7 -0
- qrdrop/static/vendor/highlight/languages/dockerfile.min.js +8 -0
- qrdrop/static/vendor/highlight/languages/fsharp.min.js +47 -0
- qrdrop/static/vendor/highlight/languages/groovy.min.js +21 -0
- qrdrop/static/vendor/highlight/languages/latex.min.js +33 -0
- qrdrop/static/vendor/highlight/languages/powershell.min.js +39 -0
- qrdrop/static/vendor/highlight/languages/properties.min.js +10 -0
- qrdrop/static/vendor/highlight/languages/scala.min.js +28 -0
- qrdrop/static/vendor/highlight/languages/vim.min.js +12 -0
- qrdrop/static/vendor/highlight/styles/github-dark.min.css +10 -0
- qrdrop/static/vendor/highlight/styles/github.min.css +10 -0
- qrdrop/templates/base.html +52 -0
- qrdrop/templates/browse.html +224 -0
- qrdrop/templates/login.html +45 -0
- qrdrop/templates/view_image.html +39 -0
- qrdrop/templates/view_text.html +60 -0
- qrdrop/web/__init__.py +1 -0
- qrdrop/web/app.py +50 -0
- qrdrop/web/handlers/__init__.py +1 -0
- qrdrop/web/handlers/_common.py +104 -0
- qrdrop/web/handlers/archive.py +293 -0
- qrdrop/web/handlers/auth.py +129 -0
- qrdrop/web/handlers/browse.py +160 -0
- qrdrop/web/handlers/files.py +266 -0
- qrdrop/web/handlers/health.py +16 -0
- qrdrop/web/handlers/mutate.py +195 -0
- qrdrop/web/handlers/upload.py +398 -0
- qrdrop/web/middleware.py +180 -0
- qrdrop/web/routes.py +53 -0
- qrdrop-0.1.0.dist-info/METADATA +213 -0
- qrdrop-0.1.0.dist-info/RECORD +51 -0
- qrdrop-0.1.0.dist-info/WHEEL +4 -0
- qrdrop-0.1.0.dist-info/entry_points.txt +2 -0
- qrdrop-0.1.0.dist-info/licenses/LICENSE +21 -0
qrdrop/__init__.py
ADDED
qrdrop/__main__.py
ADDED
qrdrop/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Command-line interface for qrdrop."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from qrdrop import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_args() -> argparse.Namespace:
|
|
10
|
+
"""Parse command line arguments."""
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="qrdrop",
|
|
13
|
+
description="Instant file sharing from your terminal",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"-p",
|
|
17
|
+
"--port",
|
|
18
|
+
type=int,
|
|
19
|
+
default=8000,
|
|
20
|
+
help="Port to serve on (default: 8000)",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-b",
|
|
24
|
+
"--bind",
|
|
25
|
+
type=str,
|
|
26
|
+
default="0.0.0.0",
|
|
27
|
+
help="Address to bind to (default: 0.0.0.0)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--password",
|
|
31
|
+
type=str,
|
|
32
|
+
default=None,
|
|
33
|
+
help="Use specific password instead of generating one",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--hide-dotfiles",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Exclude files starting with '.' from listings",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--upload",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Allow file uploads",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--modify",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Allow uploads, deletions, and directory create/rename (implies --upload)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--timeout",
|
|
52
|
+
type=int,
|
|
53
|
+
default=None,
|
|
54
|
+
help="Expire sessions after this many seconds (default: sessions last until the server stops)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"-q",
|
|
58
|
+
"--quiet",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Suppress startup banner",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--version",
|
|
64
|
+
action="version",
|
|
65
|
+
version=f"qrdrop {__version__}",
|
|
66
|
+
)
|
|
67
|
+
args = parser.parse_args()
|
|
68
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
69
|
+
parser.error("--timeout must be a positive number of seconds")
|
|
70
|
+
return args
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
"""Main entry point for the CLI."""
|
|
75
|
+
import os
|
|
76
|
+
|
|
77
|
+
args = parse_args()
|
|
78
|
+
|
|
79
|
+
# Import here to avoid circular imports and speed up --help/--version
|
|
80
|
+
from qrdrop.core.network import find_available_port, get_local_ip
|
|
81
|
+
from qrdrop.core.password import generate_password
|
|
82
|
+
from qrdrop.core.qr import generate_qr_terminal
|
|
83
|
+
from qrdrop.core.terminal import print_startup_banner
|
|
84
|
+
from qrdrop.web.app import AppConfig, create_app
|
|
85
|
+
|
|
86
|
+
# Get root directory
|
|
87
|
+
root_dir = Path.cwd()
|
|
88
|
+
|
|
89
|
+
# In Docker, use the exact port specified (don't auto-increment)
|
|
90
|
+
# Outside Docker, find an available port
|
|
91
|
+
port = args.port if os.environ.get("DOCKER_CONTAINER") else find_available_port(args.port)
|
|
92
|
+
|
|
93
|
+
# Generate or use provided password
|
|
94
|
+
password = args.password if args.password else generate_password()
|
|
95
|
+
|
|
96
|
+
# Determine permissions (--modify implies --upload)
|
|
97
|
+
allow_upload = args.upload or args.modify
|
|
98
|
+
allow_delete = args.modify
|
|
99
|
+
allow_modify = args.modify
|
|
100
|
+
|
|
101
|
+
# Create configuration
|
|
102
|
+
config = AppConfig(
|
|
103
|
+
root_dir=root_dir,
|
|
104
|
+
password=password,
|
|
105
|
+
port=port,
|
|
106
|
+
bind=args.bind,
|
|
107
|
+
show_hidden=not args.hide_dotfiles,
|
|
108
|
+
session_timeout=args.timeout,
|
|
109
|
+
allow_upload=allow_upload,
|
|
110
|
+
allow_delete=allow_delete,
|
|
111
|
+
allow_modify=allow_modify,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Create app
|
|
115
|
+
app = create_app(config)
|
|
116
|
+
|
|
117
|
+
# Print startup banner
|
|
118
|
+
if not args.quiet:
|
|
119
|
+
from urllib.parse import quote
|
|
120
|
+
|
|
121
|
+
local_ip = get_local_ip()
|
|
122
|
+
local_url = f"http://localhost:{port}"
|
|
123
|
+
network_url = f"http://{local_ip}:{port}"
|
|
124
|
+
# Percent-encode so a --password containing URL-reserved characters
|
|
125
|
+
# (&, #, +, spaces, ...) survives the QR auto-login round trip.
|
|
126
|
+
auth_url = f"http://{local_ip}:{port}/?auth={quote(password, safe='')}"
|
|
127
|
+
|
|
128
|
+
qr_code = generate_qr_terminal(auth_url)
|
|
129
|
+
|
|
130
|
+
print_startup_banner(
|
|
131
|
+
version=__version__,
|
|
132
|
+
local_url=local_url,
|
|
133
|
+
network_url=network_url,
|
|
134
|
+
password=password,
|
|
135
|
+
qr_code=qr_code,
|
|
136
|
+
root_dir=str(root_dir),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Start server
|
|
140
|
+
import copy
|
|
141
|
+
|
|
142
|
+
import uvicorn
|
|
143
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
144
|
+
|
|
145
|
+
# Wire a redaction filter into uvicorn's logging config so the
|
|
146
|
+
# `?auth=<password>` query parameter never leaks into access logs.
|
|
147
|
+
log_config = copy.deepcopy(LOGGING_CONFIG)
|
|
148
|
+
log_config.setdefault("filters", {})["redact_auth"] = {
|
|
149
|
+
"()": "qrdrop.core.log_redaction.RedactAuthFilter",
|
|
150
|
+
}
|
|
151
|
+
for handler in log_config.get("handlers", {}).values():
|
|
152
|
+
handler.setdefault("filters", []).append("redact_auth")
|
|
153
|
+
|
|
154
|
+
uvicorn.run(
|
|
155
|
+
app,
|
|
156
|
+
host=args.bind,
|
|
157
|
+
port=port,
|
|
158
|
+
log_level="warning" if args.quiet else "info",
|
|
159
|
+
log_config=log_config,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
qrdrop/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core business logic modules."""
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Secure filesystem operations with path traversal protection.
|
|
2
|
+
|
|
3
|
+
All file operations must go through these functions to ensure
|
|
4
|
+
proper security boundaries are maintained.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from threading import Lock
|
|
13
|
+
from typing import NamedTuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PathTraversalError(Exception):
|
|
17
|
+
"""Raised when a path attempts to escape the root directory."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SymlinkEscapeError(Exception):
|
|
23
|
+
"""Raised when a symlink resolves to a location outside root."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Size units tuple - module-level to avoid recreation on each call
|
|
29
|
+
_SIZE_UNITS: tuple[tuple[str, int], ...] = (
|
|
30
|
+
("GB", 1024 * 1024 * 1024),
|
|
31
|
+
("MB", 1024 * 1024),
|
|
32
|
+
("KB", 1024),
|
|
33
|
+
("B", 1),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Cache for resolved root paths to avoid repeated resolution.
|
|
37
|
+
# Stores (Path, str) so str(root) doesn't have to be recomputed on every
|
|
38
|
+
# validate_path call.
|
|
39
|
+
_root_cache: dict[str, tuple[Path, str]] = {}
|
|
40
|
+
_root_cache_lock = Lock()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _CacheEntry(NamedTuple):
|
|
44
|
+
"""Cache entry for directory listings."""
|
|
45
|
+
|
|
46
|
+
mtime: float
|
|
47
|
+
entries: tuple # Immutable tuple of FileEntry
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# LRU cache for directory listings
|
|
51
|
+
_dir_cache: dict[tuple[str, bool], _CacheEntry] = {}
|
|
52
|
+
_dir_cache_lock = Lock()
|
|
53
|
+
_DIR_CACHE_MAX_SIZE = 256
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_resolved_root_cached(root: Path) -> tuple[Path, str]:
|
|
57
|
+
"""Get cached (resolved_path, resolved_str) tuple for `root`."""
|
|
58
|
+
key = str(root)
|
|
59
|
+
cached = _root_cache.get(key)
|
|
60
|
+
if cached is not None:
|
|
61
|
+
return cached
|
|
62
|
+
with _root_cache_lock:
|
|
63
|
+
cached = _root_cache.get(key)
|
|
64
|
+
if cached is not None:
|
|
65
|
+
return cached
|
|
66
|
+
resolved = root.resolve()
|
|
67
|
+
cached = (resolved, str(resolved))
|
|
68
|
+
_root_cache[key] = cached
|
|
69
|
+
return cached
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_under_root_str(root_str: str, target_str: str) -> bool:
|
|
73
|
+
"""String-only containment check; avoids re-resolving already-resolved paths.
|
|
74
|
+
|
|
75
|
+
Uses string-prefix comparison rather than `Path.relative_to` so the result
|
|
76
|
+
is consistent across platforms (Windows uses both "/" and "\\" as separators
|
|
77
|
+
in resolved paths).
|
|
78
|
+
"""
|
|
79
|
+
return (
|
|
80
|
+
target_str == root_str
|
|
81
|
+
or target_str.startswith(root_str + "/")
|
|
82
|
+
or target_str.startswith(root_str + "\\")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(slots=True)
|
|
87
|
+
class FileEntry:
|
|
88
|
+
"""Represents a file or directory entry.
|
|
89
|
+
|
|
90
|
+
Uses __slots__ for memory efficiency.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
size_bytes: int
|
|
95
|
+
size_human: str
|
|
96
|
+
mtime: datetime
|
|
97
|
+
is_dir: bool
|
|
98
|
+
is_hidden: bool
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_path(root: Path, requested: str) -> Path:
|
|
102
|
+
"""Validate and resolve a requested path relative to root.
|
|
103
|
+
|
|
104
|
+
Ensures the requested path:
|
|
105
|
+
1. Resolves to a location under the root directory
|
|
106
|
+
2. Does not escape via ".." traversal
|
|
107
|
+
3. Does not escape via symlink targets
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
root: The root directory that bounds all access.
|
|
111
|
+
requested: The user-requested path (may be relative).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Path: The resolved absolute path.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
PathTraversalError: If the path would escape the root directory.
|
|
118
|
+
SymlinkEscapeError: If a symlink target is outside root.
|
|
119
|
+
"""
|
|
120
|
+
root_resolved, root_str = _get_resolved_root_cached(root)
|
|
121
|
+
|
|
122
|
+
if not requested or requested == "/" or requested == ".":
|
|
123
|
+
return root_resolved
|
|
124
|
+
|
|
125
|
+
# On NTFS "file.txt:stream" addresses an alternate data stream of
|
|
126
|
+
# file.txt; its resolved path still sits under root, so the containment
|
|
127
|
+
# check below would let hidden streams be read. No real entry under a
|
|
128
|
+
# relative path contains ":".
|
|
129
|
+
if os.name == "nt" and ":" in requested:
|
|
130
|
+
raise PathTraversalError(f"Path contains invalid character: {requested}")
|
|
131
|
+
|
|
132
|
+
target = root / requested.lstrip("/")
|
|
133
|
+
resolved = target.resolve()
|
|
134
|
+
resolved_str = str(resolved)
|
|
135
|
+
|
|
136
|
+
if not _is_under_root_str(root_str, resolved_str):
|
|
137
|
+
raise PathTraversalError(f"Path escapes root directory: {requested}")
|
|
138
|
+
|
|
139
|
+
# is_symlink() returns False for nonexistent paths, so the prior exists()
|
|
140
|
+
# check is redundant. Only resolve target.parent if we actually have a
|
|
141
|
+
# symlink to inspect, since resolve() is the dominant cost here.
|
|
142
|
+
if target.is_symlink() and not _is_under_root_str(root_str, str(target.parent.resolve())):
|
|
143
|
+
raise SymlinkEscapeError(f"Symlink location escapes root: {requested}")
|
|
144
|
+
|
|
145
|
+
return resolved
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _list_directory_uncached(path: Path, show_hidden: bool) -> list[FileEntry]:
|
|
149
|
+
"""Internal uncached directory listing.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
path: The directory path to list.
|
|
153
|
+
show_hidden: Whether to include files starting with '.'.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
list[FileEntry]: List of file entries in the directory.
|
|
157
|
+
"""
|
|
158
|
+
entries: list[FileEntry] = []
|
|
159
|
+
fromtimestamp = datetime.fromtimestamp
|
|
160
|
+
is_dir_check = stat.S_ISDIR
|
|
161
|
+
|
|
162
|
+
with os.scandir(path) as it:
|
|
163
|
+
for entry_de in it:
|
|
164
|
+
name = entry_de.name
|
|
165
|
+
is_hidden = name.startswith(".")
|
|
166
|
+
|
|
167
|
+
# Skip hidden files if not showing them
|
|
168
|
+
if is_hidden and not show_hidden:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# DirEntry.stat() follows symlinks by default — matches prior behavior.
|
|
173
|
+
# On Windows, stat info is populated from the directory enumeration
|
|
174
|
+
# for non-symlinks, avoiding a per-file syscall.
|
|
175
|
+
stat_info = entry_de.stat()
|
|
176
|
+
is_dir = is_dir_check(stat_info.st_mode)
|
|
177
|
+
size = 0 if is_dir else stat_info.st_size
|
|
178
|
+
|
|
179
|
+
entries.append(
|
|
180
|
+
FileEntry(
|
|
181
|
+
name=name,
|
|
182
|
+
size_bytes=size,
|
|
183
|
+
size_human=humanize_size(size),
|
|
184
|
+
mtime=fromtimestamp(stat_info.st_mtime, tz=UTC),
|
|
185
|
+
is_dir=is_dir,
|
|
186
|
+
is_hidden=is_hidden,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
except OSError:
|
|
190
|
+
# Skip files we can't stat (permission errors, etc.)
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
return entries
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def list_directory(path: Path, show_hidden: bool = True) -> list[FileEntry]:
|
|
197
|
+
"""List contents of a directory with caching.
|
|
198
|
+
|
|
199
|
+
Uses an LRU cache with mtime-based invalidation for performance.
|
|
200
|
+
Cache entries are invalidated when directory mtime changes.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
path: The directory path to list.
|
|
204
|
+
show_hidden: Whether to include files starting with '.'.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
list[FileEntry]: List of file entries in the directory.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
NotADirectoryError: If path is not a directory.
|
|
211
|
+
FileNotFoundError: If path does not exist.
|
|
212
|
+
"""
|
|
213
|
+
if not path.exists():
|
|
214
|
+
raise FileNotFoundError(f"Directory not found: {path}")
|
|
215
|
+
|
|
216
|
+
if not path.is_dir():
|
|
217
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
218
|
+
|
|
219
|
+
# Get directory mtime for cache validation
|
|
220
|
+
try:
|
|
221
|
+
dir_mtime = path.stat().st_mtime
|
|
222
|
+
except OSError:
|
|
223
|
+
# Can't stat - skip cache
|
|
224
|
+
return _list_directory_uncached(path, show_hidden)
|
|
225
|
+
|
|
226
|
+
cache_key = (str(path), show_hidden)
|
|
227
|
+
|
|
228
|
+
cached = _dir_cache.get(cache_key)
|
|
229
|
+
if cached is not None and cached.mtime == dir_mtime:
|
|
230
|
+
return list(cached.entries)
|
|
231
|
+
|
|
232
|
+
entries = _list_directory_uncached(path, show_hidden)
|
|
233
|
+
|
|
234
|
+
# Update cache with lock
|
|
235
|
+
with _dir_cache_lock:
|
|
236
|
+
# Evict oldest entries if cache is full
|
|
237
|
+
if len(_dir_cache) >= _DIR_CACHE_MAX_SIZE:
|
|
238
|
+
# Remove 25% of entries (simple LRU approximation)
|
|
239
|
+
keys_to_remove = list(_dir_cache.keys())[: _DIR_CACHE_MAX_SIZE // 4]
|
|
240
|
+
for key in keys_to_remove:
|
|
241
|
+
del _dir_cache[key]
|
|
242
|
+
|
|
243
|
+
# Store as tuple for immutability
|
|
244
|
+
_dir_cache[cache_key] = _CacheEntry(mtime=dir_mtime, entries=tuple(entries))
|
|
245
|
+
|
|
246
|
+
return entries
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def humanize_size(size_bytes: int) -> str:
|
|
250
|
+
"""Convert byte size to human-readable format.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
size_bytes: Size in bytes.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
str: Human-readable size string (e.g., "1.5 MB").
|
|
257
|
+
"""
|
|
258
|
+
if size_bytes == 0:
|
|
259
|
+
return "—"
|
|
260
|
+
|
|
261
|
+
for unit_name, unit_size in _SIZE_UNITS:
|
|
262
|
+
if size_bytes >= unit_size:
|
|
263
|
+
value = size_bytes / unit_size
|
|
264
|
+
if value >= 100:
|
|
265
|
+
return f"{value:.0f} {unit_name}"
|
|
266
|
+
elif value >= 10:
|
|
267
|
+
return f"{value:.1f} {unit_name}"
|
|
268
|
+
else:
|
|
269
|
+
return f"{value:.2f} {unit_name}"
|
|
270
|
+
|
|
271
|
+
return f"{size_bytes} B"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _key_name(e: FileEntry) -> str:
|
|
275
|
+
"""Sort key for name (case-insensitive)."""
|
|
276
|
+
return e.name.lower()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _key_size(e: FileEntry) -> int:
|
|
280
|
+
"""Sort key for size."""
|
|
281
|
+
return e.size_bytes
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _key_mtime(e: FileEntry) -> datetime:
|
|
285
|
+
"""Sort key for modification time."""
|
|
286
|
+
return e.mtime
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# Pre-defined sort key functions to avoid lambda recreation
|
|
290
|
+
_SORT_KEYS = {
|
|
291
|
+
"name": _key_name,
|
|
292
|
+
"size": _key_size,
|
|
293
|
+
"modified": _key_mtime,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def sort_entries(
|
|
298
|
+
entries: list[FileEntry],
|
|
299
|
+
sort_by: str = "name",
|
|
300
|
+
sort_order: str = "asc",
|
|
301
|
+
) -> list[FileEntry]:
|
|
302
|
+
"""Sort file entries with directories first, then by specified field.
|
|
303
|
+
|
|
304
|
+
Uses single-pass separation and pre-defined key functions for performance.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
entries: List of file entries to sort.
|
|
308
|
+
sort_by: Field to sort by - "name", "size", or "modified".
|
|
309
|
+
sort_order: Sort direction - "asc" or "desc".
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
list[FileEntry]: Sorted list (directories always first, then files sorted).
|
|
313
|
+
"""
|
|
314
|
+
# Single pass to separate directories and files
|
|
315
|
+
dirs: list[FileEntry] = []
|
|
316
|
+
files: list[FileEntry] = []
|
|
317
|
+
for e in entries:
|
|
318
|
+
(dirs if e.is_dir else files).append(e)
|
|
319
|
+
|
|
320
|
+
reverse = sort_order == "desc"
|
|
321
|
+
key_func = _SORT_KEYS.get(sort_by, _key_name)
|
|
322
|
+
|
|
323
|
+
if sort_by == "size":
|
|
324
|
+
# Directories have no meaningful size - always sort alphabetically
|
|
325
|
+
dirs.sort(key=_key_name)
|
|
326
|
+
files.sort(key=key_func, reverse=reverse)
|
|
327
|
+
else:
|
|
328
|
+
dirs.sort(key=key_func, reverse=reverse)
|
|
329
|
+
files.sort(key=key_func, reverse=reverse)
|
|
330
|
+
|
|
331
|
+
return dirs + files
|