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 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
+ )