os-helper 1.4.2__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.
- os_helper/__init__.py +180 -0
- os_helper/api.py +430 -0
- os_helper/cli_argparse.py +757 -0
- os_helper/cli_click.py +585 -0
- os_helper/config_utils.py +209 -0
- os_helper/hash_utils.py +160 -0
- os_helper/logging_utils.py +339 -0
- os_helper/mcp.py +88 -0
- os_helper/misc_utils.py +434 -0
- os_helper/path_utils.py +460 -0
- os_helper/profile_utils.py +290 -0
- os_helper/string_utils.py +113 -0
- os_helper/system_utils.py +209 -0
- os_helper/temp_utils.py +275 -0
- os_helper-1.4.2.dist-info/METADATA +270 -0
- os_helper-1.4.2.dist-info/RECORD +20 -0
- os_helper-1.4.2.dist-info/WHEEL +5 -0
- os_helper-1.4.2.dist-info/entry_points.txt +4 -0
- os_helper-1.4.2.dist-info/licenses/LICENSE +29 -0
- os_helper-1.4.2.dist-info/top_level.txt +1 -0
os_helper/__init__.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OS Helper
|
|
3
|
+
|
|
4
|
+
A collection of cross-platform utility functions covering:
|
|
5
|
+
- file and directory handling
|
|
6
|
+
- system / OS detection and process helpers
|
|
7
|
+
- string manipulation and ASCII normalization
|
|
8
|
+
- temporary files and folders (including remote staging)
|
|
9
|
+
- hashing of strings, files, and folders
|
|
10
|
+
- configuration loading (JSON / YAML / .env / environment)
|
|
11
|
+
- URL checks, downloads, zipping
|
|
12
|
+
- logging and verbosity helpers
|
|
13
|
+
|
|
14
|
+
Usage Example
|
|
15
|
+
-------------
|
|
16
|
+
>>> import os_helper as osh
|
|
17
|
+
>>> osh.verbosity(1)
|
|
18
|
+
>>> if osh.unix():
|
|
19
|
+
... osh.info("running on a Unix-based OS")
|
|
20
|
+
>>> osh.info(osh.now_string("filename"))
|
|
21
|
+
>>> h = osh.hash_string("hello", size=8)
|
|
22
|
+
|
|
23
|
+
The same helpers are also exposed as an argparse CLI (``os-helper``), a
|
|
24
|
+
click CLI (``os-helper-click``, install ``[cli]`` extra), a FastAPI HTTP
|
|
25
|
+
surface (``os_helper.api``, install ``[api]`` extra) and an MCP tool set
|
|
26
|
+
(``os-helper-mcp``, install ``[api,mcp]`` extras). See the README and
|
|
27
|
+
``GUI.md`` for the multi-surface story.
|
|
28
|
+
|
|
29
|
+
Author
|
|
30
|
+
------
|
|
31
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__author__ = "Warith Harchaoui, Ph.D."
|
|
35
|
+
__email__ = "warithmetics@deraison.ai"
|
|
36
|
+
|
|
37
|
+
from .config_utils import (
|
|
38
|
+
get_config,
|
|
39
|
+
)
|
|
40
|
+
from .hash_utils import (
|
|
41
|
+
hash_string,
|
|
42
|
+
hashfile,
|
|
43
|
+
hashfolder,
|
|
44
|
+
)
|
|
45
|
+
from .logging_utils import (
|
|
46
|
+
check,
|
|
47
|
+
critical,
|
|
48
|
+
debug,
|
|
49
|
+
error,
|
|
50
|
+
info,
|
|
51
|
+
init_logging,
|
|
52
|
+
verbosity,
|
|
53
|
+
warning,
|
|
54
|
+
)
|
|
55
|
+
from .misc_utils import (
|
|
56
|
+
download_file,
|
|
57
|
+
folder_description,
|
|
58
|
+
format_size,
|
|
59
|
+
get_user_ip,
|
|
60
|
+
is_working_url,
|
|
61
|
+
now_string,
|
|
62
|
+
str2time,
|
|
63
|
+
time2str,
|
|
64
|
+
zip_folder,
|
|
65
|
+
)
|
|
66
|
+
from .path_utils import (
|
|
67
|
+
absolute2relative_path,
|
|
68
|
+
checkfile,
|
|
69
|
+
copyfile,
|
|
70
|
+
dir_exists,
|
|
71
|
+
file_exists,
|
|
72
|
+
folder_name_ext,
|
|
73
|
+
join,
|
|
74
|
+
make_directory,
|
|
75
|
+
path_without_home,
|
|
76
|
+
recursive_glob,
|
|
77
|
+
relative2absolute_path,
|
|
78
|
+
remove_directory,
|
|
79
|
+
remove_files,
|
|
80
|
+
size_file,
|
|
81
|
+
)
|
|
82
|
+
from .profile_utils import (
|
|
83
|
+
cpu_timer,
|
|
84
|
+
gpu_timer,
|
|
85
|
+
tic,
|
|
86
|
+
toc,
|
|
87
|
+
wall_timer,
|
|
88
|
+
)
|
|
89
|
+
from .string_utils import (
|
|
90
|
+
asciistring,
|
|
91
|
+
emptystring,
|
|
92
|
+
)
|
|
93
|
+
from .system_utils import (
|
|
94
|
+
get_nb_workers,
|
|
95
|
+
getpid,
|
|
96
|
+
linux,
|
|
97
|
+
macos,
|
|
98
|
+
openfile,
|
|
99
|
+
system,
|
|
100
|
+
unix,
|
|
101
|
+
windows,
|
|
102
|
+
)
|
|
103
|
+
from .temp_utils import (
|
|
104
|
+
temporary_filename,
|
|
105
|
+
temporary_folder,
|
|
106
|
+
temporary_remote_file,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
# system_utils
|
|
111
|
+
"windows",
|
|
112
|
+
"linux",
|
|
113
|
+
"macos",
|
|
114
|
+
"unix",
|
|
115
|
+
"get_nb_workers",
|
|
116
|
+
"system",
|
|
117
|
+
"openfile",
|
|
118
|
+
"getpid",
|
|
119
|
+
|
|
120
|
+
# logging_utils
|
|
121
|
+
"init_logging",
|
|
122
|
+
"debug",
|
|
123
|
+
"info",
|
|
124
|
+
"warning",
|
|
125
|
+
"error",
|
|
126
|
+
"critical",
|
|
127
|
+
"check",
|
|
128
|
+
"verbosity",
|
|
129
|
+
|
|
130
|
+
# temp_utils
|
|
131
|
+
"temporary_filename",
|
|
132
|
+
"temporary_folder",
|
|
133
|
+
"temporary_remote_file",
|
|
134
|
+
|
|
135
|
+
# path_utils
|
|
136
|
+
"file_exists",
|
|
137
|
+
"dir_exists",
|
|
138
|
+
"absolute2relative_path",
|
|
139
|
+
"relative2absolute_path",
|
|
140
|
+
"path_without_home",
|
|
141
|
+
"recursive_glob",
|
|
142
|
+
"size_file",
|
|
143
|
+
"checkfile",
|
|
144
|
+
"copyfile",
|
|
145
|
+
"remove_directory",
|
|
146
|
+
"remove_files",
|
|
147
|
+
"make_directory",
|
|
148
|
+
"join",
|
|
149
|
+
"folder_name_ext",
|
|
150
|
+
|
|
151
|
+
# hash_utils
|
|
152
|
+
"hash_string",
|
|
153
|
+
"hashfile",
|
|
154
|
+
"hashfolder",
|
|
155
|
+
|
|
156
|
+
# config_utils
|
|
157
|
+
"get_config",
|
|
158
|
+
|
|
159
|
+
# string_utils
|
|
160
|
+
"emptystring",
|
|
161
|
+
"asciistring",
|
|
162
|
+
|
|
163
|
+
# misc_utils
|
|
164
|
+
"now_string",
|
|
165
|
+
"format_size",
|
|
166
|
+
"folder_description",
|
|
167
|
+
"is_working_url",
|
|
168
|
+
"zip_folder",
|
|
169
|
+
"download_file",
|
|
170
|
+
"time2str",
|
|
171
|
+
"str2time",
|
|
172
|
+
"get_user_ip",
|
|
173
|
+
|
|
174
|
+
# profile_utils
|
|
175
|
+
"wall_timer",
|
|
176
|
+
"cpu_timer",
|
|
177
|
+
"gpu_timer",
|
|
178
|
+
"tic",
|
|
179
|
+
"toc",
|
|
180
|
+
]
|
os_helper/api.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OS Helper — FastAPI HTTP surface.
|
|
3
|
+
|
|
4
|
+
Exposes the read-and-compute helpers of ``os_helper`` as HTTP endpoints
|
|
5
|
+
so the toolkit can be dropped behind any reverse proxy and consumed by
|
|
6
|
+
other services. Kept intentionally minimal:
|
|
7
|
+
|
|
8
|
+
- **Reads** (JSON in, JSON out) for cheap, side-effect-free helpers:
|
|
9
|
+
OS detection, path predicates, string / hash utilities, misc
|
|
10
|
+
formatting, etc.
|
|
11
|
+
- **Actions** (multipart in, ``FileResponse`` / ``StreamingResponse``
|
|
12
|
+
out) for helpers that produce artifacts (``/hash/file``,
|
|
13
|
+
``/hash/folder``, ``/zip``, ``/describe``, ``/download``).
|
|
14
|
+
- ``BackgroundTasks`` cleans temp files after the response has been
|
|
15
|
+
streamed — no leftover garbage on disk after a request.
|
|
16
|
+
|
|
17
|
+
Install the extra to get the runtime dependencies::
|
|
18
|
+
|
|
19
|
+
pip install 'os-helper[api]'
|
|
20
|
+
|
|
21
|
+
Then run the app with any ASGI server::
|
|
22
|
+
|
|
23
|
+
uvicorn os_helper.api:app --host 0.0.0.0 --port 8000
|
|
24
|
+
|
|
25
|
+
Usage Example
|
|
26
|
+
-------------
|
|
27
|
+
>>> # Start the server:
|
|
28
|
+
>>> # uvicorn os_helper.api:app --reload
|
|
29
|
+
>>> # Hash a string:
|
|
30
|
+
>>> # curl -s -X POST 'http://localhost:8000/hash/string' \\
|
|
31
|
+
>>> # -H 'Content-Type: application/json' \\
|
|
32
|
+
>>> # -d '{"value": "hello", "size": 8}'
|
|
33
|
+
>>> # Zip an uploaded folder (multi-file upload):
|
|
34
|
+
>>> # curl -F 'files=@a.txt' -F 'files=@b.txt' -o out.zip \\
|
|
35
|
+
>>> # http://localhost:8000/zip
|
|
36
|
+
>>> # Full OpenAPI docs at http://localhost:8000/docs
|
|
37
|
+
|
|
38
|
+
Author
|
|
39
|
+
------
|
|
40
|
+
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import io
|
|
46
|
+
import shutil
|
|
47
|
+
import tempfile
|
|
48
|
+
import zipfile
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile
|
|
53
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
54
|
+
from pydantic import BaseModel, Field
|
|
55
|
+
except ImportError as exc: # pragma: no cover
|
|
56
|
+
raise ImportError(
|
|
57
|
+
"The FastAPI HTTP surface requires the [api] extra. "
|
|
58
|
+
"Install with: pip install 'os-helper[api]'"
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
from . import (
|
|
62
|
+
asciistring,
|
|
63
|
+
download_file,
|
|
64
|
+
emptystring,
|
|
65
|
+
folder_description,
|
|
66
|
+
folder_name_ext,
|
|
67
|
+
format_size,
|
|
68
|
+
get_nb_workers,
|
|
69
|
+
get_user_ip,
|
|
70
|
+
getpid,
|
|
71
|
+
hash_string,
|
|
72
|
+
hashfile,
|
|
73
|
+
hashfolder,
|
|
74
|
+
is_working_url,
|
|
75
|
+
join,
|
|
76
|
+
linux,
|
|
77
|
+
macos,
|
|
78
|
+
now_string,
|
|
79
|
+
path_without_home,
|
|
80
|
+
size_file,
|
|
81
|
+
str2time,
|
|
82
|
+
time2str,
|
|
83
|
+
unix,
|
|
84
|
+
windows,
|
|
85
|
+
zip_folder,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# App factory + shared plumbing
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
app = FastAPI(
|
|
94
|
+
title="OS Helper API",
|
|
95
|
+
description=(
|
|
96
|
+
"HTTP surface for the os-helper utilities: OS detection, path "
|
|
97
|
+
"manipulation, hashing, string normalization, misc formatting, "
|
|
98
|
+
"URL checks, zipping."
|
|
99
|
+
),
|
|
100
|
+
version="1.4.2",
|
|
101
|
+
docs_url="/docs",
|
|
102
|
+
redoc_url="/redoc",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cleanup(*paths) -> None:
|
|
107
|
+
"""Best-effort cleanup — never let a tidy-up failure kill a response."""
|
|
108
|
+
for p in paths:
|
|
109
|
+
try:
|
|
110
|
+
path = Path(p)
|
|
111
|
+
if path.is_dir():
|
|
112
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
113
|
+
elif path.exists():
|
|
114
|
+
path.unlink(missing_ok=True)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _new_tmpdir() -> Path:
|
|
120
|
+
"""Create a request-scoped temp directory under the system temp root."""
|
|
121
|
+
return Path(tempfile.mkdtemp(prefix="os-helper-"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _spool(upload: UploadFile, dest: Path) -> Path:
|
|
125
|
+
"""Persist an UploadFile to disk without loading it into memory."""
|
|
126
|
+
with dest.open("wb") as fp:
|
|
127
|
+
shutil.copyfileobj(upload.file, fp)
|
|
128
|
+
return dest
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Meta
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.get("/health", tags=["meta"])
|
|
137
|
+
def health() -> dict:
|
|
138
|
+
"""Simple liveness probe — no dependency check, just proves the app is up."""
|
|
139
|
+
return {"status": "ok"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# OS detection
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.get("/os/system", tags=["os"])
|
|
148
|
+
def api_os_system() -> dict:
|
|
149
|
+
"""Return the current OS name."""
|
|
150
|
+
if windows():
|
|
151
|
+
name = "windows"
|
|
152
|
+
elif macos():
|
|
153
|
+
name = "macos"
|
|
154
|
+
elif linux():
|
|
155
|
+
name = "linux"
|
|
156
|
+
else:
|
|
157
|
+
name = "unknown"
|
|
158
|
+
return {"system": name, "unix": unix()}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.get("/os/flags", tags=["os"])
|
|
162
|
+
def api_os_flags() -> dict:
|
|
163
|
+
"""Return every OS-detection boolean at once."""
|
|
164
|
+
return {
|
|
165
|
+
"windows": windows(),
|
|
166
|
+
"linux": linux(),
|
|
167
|
+
"macos": macos(),
|
|
168
|
+
"unix": unix(),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@app.get("/os/pid", tags=["os"])
|
|
173
|
+
def api_os_pid() -> dict:
|
|
174
|
+
"""Return the current process ID."""
|
|
175
|
+
return {"pid": getpid()}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.get("/os/workers", tags=["os"])
|
|
179
|
+
def api_os_workers(n: int = -1) -> dict:
|
|
180
|
+
"""Resolve a worker count following sklearn's n_jobs convention."""
|
|
181
|
+
return {"workers": get_nb_workers(n)}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# Path helpers (all pure — POST body carries the string paths)
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class PathIn(BaseModel):
|
|
190
|
+
"""Envelope carrying a single filesystem path string."""
|
|
191
|
+
path: str = Field(..., description="Filesystem path (absolute or relative).")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class JoinIn(BaseModel):
|
|
195
|
+
"""Envelope carrying the components to join."""
|
|
196
|
+
parts: list[str] = Field(..., description="Path components to join into a single absolute path.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class RelIn(BaseModel):
|
|
200
|
+
"""Envelope carrying the target path plus an optional base."""
|
|
201
|
+
path: str
|
|
202
|
+
base: str | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.post("/path/join", tags=["path"])
|
|
206
|
+
def api_path_join(body: JoinIn) -> dict:
|
|
207
|
+
"""Join components into an absolute, normalized path."""
|
|
208
|
+
return {"path": join(*body.parts)}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.post("/path/no-home", tags=["path"])
|
|
212
|
+
def api_path_no_home(body: PathIn) -> dict:
|
|
213
|
+
"""Replace the user's home prefix with '~'."""
|
|
214
|
+
return {"path": path_without_home(body.path)}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.post("/path/size", tags=["path"])
|
|
218
|
+
def api_path_size(body: PathIn) -> dict:
|
|
219
|
+
"""Return the size of a file in bytes (-1 when it does not exist)."""
|
|
220
|
+
return {"size": size_file(body.path)}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.post("/path/split", tags=["path"])
|
|
224
|
+
def api_path_split(body: PathIn) -> dict:
|
|
225
|
+
"""Decompose a path into folder / name / ext."""
|
|
226
|
+
folder, name, ext = folder_name_ext(body.path)
|
|
227
|
+
return {"folder": folder, "name": name, "ext": ext}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# String helpers
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class StringIn(BaseModel):
|
|
236
|
+
"""Envelope carrying a single string value."""
|
|
237
|
+
value: str
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class AsciiIn(BaseModel):
|
|
241
|
+
"""Envelope for the asciistring options."""
|
|
242
|
+
value: str
|
|
243
|
+
replacement_char: str = "-"
|
|
244
|
+
lower: bool = True
|
|
245
|
+
allow_digits: bool = True
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.post("/str/empty", tags=["str"])
|
|
249
|
+
def api_str_empty(body: StringIn) -> dict:
|
|
250
|
+
"""Check if VALUE is None / whitespace-only."""
|
|
251
|
+
return {"empty": emptystring(body.value)}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@app.post("/str/ascii", tags=["str"])
|
|
255
|
+
def api_str_ascii(body: AsciiIn) -> dict:
|
|
256
|
+
"""Normalize a string to a filesystem-safe ASCII slug."""
|
|
257
|
+
return {"ascii": asciistring(
|
|
258
|
+
body.value,
|
|
259
|
+
replacement_char=body.replacement_char,
|
|
260
|
+
lower=body.lower,
|
|
261
|
+
allow_digits=body.allow_digits,
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Hashing
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class HashStringIn(BaseModel):
|
|
271
|
+
"""Envelope for the hash_string call."""
|
|
272
|
+
value: str
|
|
273
|
+
size: int = -1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.post("/hash/string", tags=["hash"])
|
|
277
|
+
def api_hash_string(body: HashStringIn) -> dict:
|
|
278
|
+
"""Hash a string; ``size`` truncates the digest to N chars when > 0."""
|
|
279
|
+
return {"hash": hash_string(body.value, size=body.size)}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@app.post("/hash/file", tags=["hash"])
|
|
283
|
+
def api_hash_file(
|
|
284
|
+
background: BackgroundTasks,
|
|
285
|
+
file: UploadFile = File(...),
|
|
286
|
+
date: bool = False,
|
|
287
|
+
) -> JSONResponse:
|
|
288
|
+
"""Hash an uploaded file's content."""
|
|
289
|
+
tmp = _new_tmpdir()
|
|
290
|
+
try:
|
|
291
|
+
src = _spool(file, tmp / (file.filename or "upload.bin"))
|
|
292
|
+
digest = hashfile(str(src), hash_content=True, date=date)
|
|
293
|
+
finally:
|
|
294
|
+
background.add_task(_cleanup, tmp)
|
|
295
|
+
return JSONResponse({"hash": digest})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.post("/hash/folder", tags=["hash"])
|
|
299
|
+
def api_hash_folder(
|
|
300
|
+
background: BackgroundTasks,
|
|
301
|
+
files: list[UploadFile] = File(...),
|
|
302
|
+
include_path: bool = False,
|
|
303
|
+
date: bool = False,
|
|
304
|
+
) -> JSONResponse:
|
|
305
|
+
"""Hash a set of uploaded files as if they lived in the same folder."""
|
|
306
|
+
tmp = _new_tmpdir()
|
|
307
|
+
try:
|
|
308
|
+
for f in files:
|
|
309
|
+
_spool(f, tmp / (f.filename or "upload.bin"))
|
|
310
|
+
digest = hashfolder(str(tmp), hash_content=True, hash_path=include_path, date=date)
|
|
311
|
+
finally:
|
|
312
|
+
background.add_task(_cleanup, tmp)
|
|
313
|
+
return JSONResponse({"hash": digest})
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Misc
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.get("/misc/now", tags=["misc"])
|
|
322
|
+
def api_misc_now(fmt: str = "log") -> dict:
|
|
323
|
+
"""Return the current timestamp using the requested format."""
|
|
324
|
+
return {"now": now_string(fmt)}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@app.get("/misc/format-size", tags=["misc"])
|
|
328
|
+
def api_misc_format_size(nb_bytes: int) -> dict:
|
|
329
|
+
"""Format a byte count as a human-readable string."""
|
|
330
|
+
return {"formatted": format_size(nb_bytes)}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.get("/misc/time2str", tags=["misc"])
|
|
334
|
+
def api_misc_time2str(seconds: float, no_space: bool = False) -> dict:
|
|
335
|
+
"""Convert a duration in seconds to a readable string."""
|
|
336
|
+
return {"text": time2str(seconds, no_space=no_space)}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.post("/misc/str2time", tags=["misc"])
|
|
340
|
+
def api_misc_str2time(body: StringIn) -> dict:
|
|
341
|
+
"""Parse a duration string into seconds."""
|
|
342
|
+
return {"seconds": str2time(body.value)}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@app.get("/misc/url-ok", tags=["misc"])
|
|
346
|
+
def api_misc_url_ok(url: str) -> dict:
|
|
347
|
+
"""Check whether a URL is syntactically valid + reachable."""
|
|
348
|
+
return {"ok": is_working_url(url)}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@app.get("/misc/ip", tags=["misc"])
|
|
352
|
+
def api_misc_ip() -> dict:
|
|
353
|
+
"""Fetch the caller's public IPv4 / IPv6 addresses."""
|
|
354
|
+
return get_user_ip()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.post("/misc/describe", tags=["misc"])
|
|
358
|
+
def api_misc_describe(
|
|
359
|
+
background: BackgroundTasks,
|
|
360
|
+
files: list[UploadFile] = File(...),
|
|
361
|
+
recursive: bool = True,
|
|
362
|
+
) -> JSONResponse:
|
|
363
|
+
"""Describe an uploaded set of files (relative path -> size)."""
|
|
364
|
+
tmp = _new_tmpdir()
|
|
365
|
+
try:
|
|
366
|
+
for f in files:
|
|
367
|
+
_spool(f, tmp / (f.filename or "upload.bin"))
|
|
368
|
+
# No side-effect files here — HTTP callers do not need html/json on disk.
|
|
369
|
+
result = folder_description(str(tmp), recursive=recursive, index_html=False, description_json=False)
|
|
370
|
+
finally:
|
|
371
|
+
background.add_task(_cleanup, tmp)
|
|
372
|
+
return JSONResponse(result)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class DownloadIn(BaseModel):
|
|
376
|
+
"""Envelope for the download endpoint."""
|
|
377
|
+
url: str
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.post("/misc/download", tags=["misc"])
|
|
381
|
+
def api_misc_download(background: BackgroundTasks, body: DownloadIn):
|
|
382
|
+
"""Download a URL and return the resulting file."""
|
|
383
|
+
tmp = _new_tmpdir()
|
|
384
|
+
filename = body.url.rstrip("/").split("/")[-1].split("?")[0] or "download.bin"
|
|
385
|
+
dst = tmp / filename
|
|
386
|
+
try:
|
|
387
|
+
download_file(body.url, file_path=str(dst))
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
_cleanup(tmp)
|
|
390
|
+
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
391
|
+
background.add_task(_cleanup, tmp)
|
|
392
|
+
return FileResponse(str(dst), filename=filename, media_type="application/octet-stream")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ---------------------------------------------------------------------------
|
|
396
|
+
# Zip endpoint — takes a batch of uploads, returns a single .zip archive.
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _zip_folder_to_bytes(folder: Path) -> io.BytesIO:
|
|
401
|
+
"""Bundle ``folder``'s contents into an in-memory ZIP for streaming."""
|
|
402
|
+
buf = io.BytesIO()
|
|
403
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
404
|
+
for p in folder.rglob("*"):
|
|
405
|
+
if p.is_file():
|
|
406
|
+
zf.write(p, arcname=p.relative_to(folder))
|
|
407
|
+
buf.seek(0)
|
|
408
|
+
return buf
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@app.post("/zip", tags=["actions"])
|
|
412
|
+
def api_zip(background: BackgroundTasks, files: list[UploadFile] = File(...)):
|
|
413
|
+
"""Zip a batch of uploaded files and stream the archive back."""
|
|
414
|
+
if not files:
|
|
415
|
+
raise HTTPException(status_code=400, detail="need at least 1 file")
|
|
416
|
+
tmp = _new_tmpdir()
|
|
417
|
+
for f in files:
|
|
418
|
+
_spool(f, tmp / (f.filename or "upload.bin"))
|
|
419
|
+
# We call the library's zip_folder to keep the on-disk behavior identical
|
|
420
|
+
# with the CLI, then stream the resulting archive back.
|
|
421
|
+
zip_path = tmp.with_suffix(".zip")
|
|
422
|
+
zip_folder(str(tmp), zip_file_path=str(zip_path))
|
|
423
|
+
with zip_path.open("rb") as fp:
|
|
424
|
+
payload = io.BytesIO(fp.read())
|
|
425
|
+
background.add_task(_cleanup, tmp, zip_path)
|
|
426
|
+
return StreamingResponse(
|
|
427
|
+
payload,
|
|
428
|
+
media_type="application/zip",
|
|
429
|
+
headers={"Content-Disposition": 'attachment; filename="os-helper.zip"'},
|
|
430
|
+
)
|