sftp-helper 2.2.3__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,57 @@
1
+ """
2
+ SFTP Helper — public API surface.
3
+
4
+ Re-exports the utility functions from :mod:`sftp_helper.main` so that
5
+ downstream code can simply write ``import sftp_helper as sftph`` and reach
6
+ every supported operation (credentials loading, connection context manager,
7
+ upload, download, exists, delete, mkdir -p, remote temp file with
8
+ auto-cleanup) without knowing about the module layout.
9
+
10
+ Backed by paramiko with strict host-key verification. See the module docs
11
+ for the full policy — there is no flag to disable verification.
12
+
13
+ Usage Example
14
+ -------------
15
+ >>> import sftp_helper as sftph
16
+ >>> cred = sftph.credentials("sftp_config.json")
17
+ >>> sftph.upload("local.txt", cred, "/remote/base/local.txt")
18
+ >>> assert sftph.remote_file_exists("/remote/base/local.txt", cred)
19
+ >>> sftph.download("/remote/base/local.txt", cred, "roundtrip.txt")
20
+ >>> sftph.delete("/remote/base/local.txt", cred)
21
+
22
+ Author
23
+ ------
24
+ Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
25
+ """
26
+
27
+ __author__ = "Warith Harchaoui, Ph.D."
28
+ __email__ = "warithmetics@deraison.ai"
29
+
30
+ # Specify the public API of this module using __all__
31
+ __all__ = [
32
+ "credentials",
33
+ "get_client_sftp",
34
+ "normalize_path",
35
+ "strip_sftp_path",
36
+ "remote_file_exists",
37
+ "delete",
38
+ "upload",
39
+ "download",
40
+ "remote_dir_exist",
41
+ "make_remote_directory",
42
+ "remote_tempfile",
43
+ ]
44
+
45
+ from .main import (
46
+ credentials,
47
+ delete,
48
+ download,
49
+ get_client_sftp,
50
+ make_remote_directory,
51
+ normalize_path,
52
+ remote_dir_exist,
53
+ remote_file_exists,
54
+ remote_tempfile,
55
+ strip_sftp_path,
56
+ upload,
57
+ )
sftp_helper/api.py ADDED
@@ -0,0 +1,386 @@
1
+ """
2
+ SFTP Helper — FastAPI HTTP surface.
3
+
4
+ Exposes every public function in :mod:`sftp_helper.main` as an HTTP
5
+ endpoint so ``sftp-helper`` can be dropped behind any reverse proxy and
6
+ consumed by other services. Kept intentionally minimal:
7
+
8
+ - Multipart uploads for local-to-remote transfers (``UploadFile``),
9
+ streamed straight to a temporary file so large payloads do not blow
10
+ up memory.
11
+ - ``FileResponse`` for downloads.
12
+ - JSON responses for the exists / delete / mkdir / show-credentials
13
+ queries.
14
+ - ``BackgroundTasks`` cleans temp files after the response has been
15
+ streamed — no leftover garbage on disk after a request.
16
+
17
+ Credentials
18
+ -----------
19
+ The credentials are loaded **once at import time** from the environment
20
+ (``SFTP_HOST`` / ``SFTP_LOGIN`` / …) or from the file pointed at by
21
+ ``SFTP_HELPER_CONFIG``. We never accept credentials in a request body —
22
+ the API is meant to be the trusted server-side view of a single SFTP
23
+ target, not a multitenant relay.
24
+
25
+ Install the extra to get the runtime dependencies::
26
+
27
+ pip install 'sftp-helper[api]'
28
+
29
+ Then run the app with any ASGI server::
30
+
31
+ uvicorn sftp_helper.api:app --host 0.0.0.0 --port 8000
32
+
33
+ Usage Example
34
+ -------------
35
+ >>> # Start the server (env-configured credentials):
36
+ >>> # SFTP_HELPER_CONFIG=./sftp_config.json uvicorn sftp_helper.api:app --reload
37
+ >>> # Upload a file:
38
+ >>> # curl -F 'file=@local.txt' -F 'remote=/uploads/local.txt' \\
39
+ >>> # http://localhost:8000/upload
40
+ >>> # Check existence:
41
+ >>> # curl "http://localhost:8000/exists?remote=/uploads/local.txt"
42
+ >>> # Full OpenAPI docs at http://localhost:8000/docs
43
+
44
+ Author
45
+ ------
46
+ Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import os
52
+ import shutil
53
+ import tempfile
54
+ from pathlib import Path
55
+
56
+ try:
57
+ from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Query, UploadFile
58
+ from fastapi.responses import FileResponse, JSONResponse
59
+ except ImportError as exc: # pragma: no cover
60
+ raise ImportError(
61
+ "The FastAPI HTTP surface requires the [api] extra. "
62
+ "Install with: pip install 'sftp-helper[api]'"
63
+ ) from exc
64
+
65
+ from . import (
66
+ credentials,
67
+ delete,
68
+ download,
69
+ make_remote_directory,
70
+ normalize_path,
71
+ remote_dir_exist,
72
+ remote_file_exists,
73
+ remote_tempfile,
74
+ strip_sftp_path,
75
+ upload,
76
+ )
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # App factory + shared plumbing
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ app = FastAPI(
84
+ title="SFTP Helper API",
85
+ description=(
86
+ "HTTP surface for the sftp-helper utilities: upload, download, delete, "
87
+ "exists, dir-exists, mkdir, normalize-path, strip-path, tempfile, "
88
+ "show-credentials. Strict host-key verification is always on."
89
+ ),
90
+ version="2.2.2",
91
+ docs_url="/docs",
92
+ redoc_url="/redoc",
93
+ )
94
+
95
+
96
+ def _load_server_cred() -> dict:
97
+ """
98
+ Load credentials once at server-side, from ``SFTP_HELPER_CONFIG`` or env.
99
+
100
+ Returns
101
+ -------
102
+ dict
103
+ The resolved credentials, or an empty ``dict`` if the loader failed
104
+ (so unit tests / smoke tests can still import the app without a
105
+ real config on disk). Endpoints that need credentials will raise
106
+ HTTP 503 in that case.
107
+ """
108
+ cfg = os.environ.get("SFTP_HELPER_CONFIG")
109
+ try:
110
+ # ``credentials`` accepts ``None`` and falls back to env / .env.
111
+ return credentials(cfg) if cfg else credentials(None)
112
+ except Exception:
113
+ # Import-time failure must not crash uvicorn — the smoke tests need
114
+ # to be able to import the app even when there's no real SFTP
115
+ # target on the host. Endpoints will surface a 503 lazily.
116
+ return {}
117
+
118
+
119
+ # Snapshot the resolved credentials at import time. Reloading requires a
120
+ # server restart, which matches operational reality (creds live in env /
121
+ # mounted config file, both of which need a restart to change anyway).
122
+ _SERVER_CRED: dict = _load_server_cred()
123
+
124
+
125
+ def _cred_or_503() -> dict:
126
+ """Return the server credentials or raise HTTP 503 if none were loaded.
127
+
128
+ Returns
129
+ -------
130
+ dict
131
+ The non-empty credentials snapshot resolved at import time.
132
+
133
+ Raises
134
+ ------
135
+ HTTPException
136
+ With status 503 when the server was started without credentials, so
137
+ clients get a clean, documented failure instead of an opaque 500.
138
+ """
139
+ # Guard: every network-touching endpoint calls this to guarantee we
140
+ # emit a clean 503 when the server was started without credentials.
141
+ if not _SERVER_CRED:
142
+ raise HTTPException(
143
+ status_code=503,
144
+ detail=(
145
+ "No SFTP credentials on the server. Set SFTP_HELPER_CONFIG or "
146
+ "the SFTP_HOST/SFTP_LOGIN/SFTP_PASSWD/SFTP_DESTINATION_PATH/"
147
+ "SFTP_HTTPS environment variables."
148
+ ),
149
+ )
150
+ return _SERVER_CRED
151
+
152
+
153
+ def _spool(uploaded: UploadFile, dest_dir: Path, suffix_hint: str | None = None) -> Path:
154
+ """
155
+ Persist an ``UploadFile`` to a temp path on disk.
156
+
157
+ We copy the stream instead of holding the bytes in memory so a
158
+ multi-hundred-MB clip does not OOM the worker. The file inherits
159
+ the caller's suffix when available so downstream tools can pick the
160
+ right handler.
161
+
162
+ Parameters
163
+ ----------
164
+ uploaded : UploadFile
165
+ The FastAPI upload object.
166
+ dest_dir : Path
167
+ Temp directory that will hold the spooled file.
168
+ suffix_hint : str, optional
169
+ Extension override (with or without the leading dot). Falls back
170
+ to the client-provided filename's suffix.
171
+
172
+ Returns
173
+ -------
174
+ Path
175
+ Path to the spooled file on disk.
176
+ """
177
+ ext = suffix_hint or (Path(uploaded.filename or "").suffix or ".bin")
178
+ if not ext.startswith("."):
179
+ ext = "." + ext
180
+ out = dest_dir / (f"upload{ext}")
181
+ with out.open("wb") as fp:
182
+ shutil.copyfileobj(uploaded.file, fp)
183
+ return out
184
+
185
+
186
+ def _cleanup(*paths: object) -> None:
187
+ """Best-effort removal of temp files/dirs — a tidy-up failure never kills a response.
188
+
189
+ Parameters
190
+ ----------
191
+ *paths : object
192
+ One or more path-like objects (``str`` or ``Path``) to remove.
193
+ Directories are removed recursively; missing paths are ignored.
194
+ """
195
+ for p in paths:
196
+ try:
197
+ path = Path(p)
198
+ if path.is_dir():
199
+ shutil.rmtree(path, ignore_errors=True)
200
+ elif path.exists():
201
+ path.unlink(missing_ok=True)
202
+ except Exception:
203
+ pass
204
+
205
+
206
+ def _new_tmpdir() -> Path:
207
+ """Create a request-scoped temp directory under the system temp root.
208
+
209
+ Returns
210
+ -------
211
+ Path
212
+ A fresh, uniquely-named directory (prefix ``sftp-helper-``) that the
213
+ caller is responsible for cleaning up (see :func:`_cleanup`).
214
+ """
215
+ return Path(tempfile.mkdtemp(prefix="sftp-helper-"))
216
+
217
+
218
+ def _mask(cred: dict) -> dict:
219
+ """Return a copy of ``cred`` with the SFTP password redacted.
220
+
221
+ Parameters
222
+ ----------
223
+ cred : dict
224
+ Resolved credentials, potentially containing ``sftp_passwd``.
225
+
226
+ Returns
227
+ -------
228
+ dict
229
+ A shallow copy whose ``sftp_passwd`` (when set) is replaced by
230
+ ``"***"`` so it never leaks into a response body.
231
+ """
232
+ masked = dict(cred)
233
+ if masked.get("sftp_passwd"):
234
+ masked["sftp_passwd"] = "***"
235
+ return masked
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Meta
240
+ # ---------------------------------------------------------------------------
241
+
242
+
243
+ @app.get("/health", tags=["meta"])
244
+ def health() -> dict:
245
+ """Simple liveness probe — no dependency check, just proves the app is up."""
246
+ return {"status": "ok"}
247
+
248
+
249
+ @app.get("/show-credentials", tags=["meta"])
250
+ def show_credentials() -> JSONResponse:
251
+ """Return the resolved credentials (password masked)."""
252
+ cred = _cred_or_503()
253
+ return JSONResponse(_mask(cred))
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Pure helpers (no SFTP round-trip needed)
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ @app.get("/normalize-path", tags=["reads"])
262
+ def normalize_path_endpoint(
263
+ path: str = Query(..., description="Path to normalize."),
264
+ ) -> JSONResponse:
265
+ """Normalize a remote path (single leading '/', no trailing '/')."""
266
+ return JSONResponse({"path": normalize_path(path)})
267
+
268
+
269
+ @app.get("/strip-path", tags=["reads"])
270
+ def strip_path_endpoint(
271
+ address: str = Query(..., description="Full sftp:// address."),
272
+ ) -> JSONResponse:
273
+ """Strip 'sftp://<host>' prefix from an SFTP address."""
274
+ cred = _cred_or_503()
275
+ return JSONResponse({"path": strip_sftp_path(address, cred)})
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Read endpoints
280
+ # ---------------------------------------------------------------------------
281
+
282
+
283
+ @app.get("/exists", tags=["reads"])
284
+ def exists_endpoint(
285
+ remote: str = Query(..., description="Full sftp:// address or plain remote path."),
286
+ ) -> JSONResponse:
287
+ """Return whether a remote file exists."""
288
+ cred = _cred_or_503()
289
+ return JSONResponse({"exists": bool(remote_file_exists(remote, cred)), "remote": remote})
290
+
291
+
292
+ @app.get("/dir-exists", tags=["reads"])
293
+ def dir_exists_endpoint(
294
+ remote: str = Query(..., description="Remote directory path."),
295
+ ) -> JSONResponse:
296
+ """Return whether a remote directory exists."""
297
+ cred = _cred_or_503()
298
+ return JSONResponse({"exists": bool(remote_dir_exist(remote, cred)), "remote": remote})
299
+
300
+
301
+ # ---------------------------------------------------------------------------
302
+ # Action endpoints
303
+ # ---------------------------------------------------------------------------
304
+
305
+
306
+ @app.post("/upload", tags=["actions"])
307
+ def upload_endpoint(
308
+ background: BackgroundTasks,
309
+ file: UploadFile = File(...),
310
+ remote: str = Form(
311
+ "", description="Full sftp:// address or plain remote path (auto if empty)."
312
+ ),
313
+ ) -> JSONResponse:
314
+ """Upload the multipart file to the SFTP server. Returns the remote address."""
315
+ cred = _cred_or_503()
316
+ tmp = _new_tmpdir()
317
+ src = _spool(file, tmp, suffix_hint=Path(file.filename or "").suffix)
318
+ try:
319
+ addr = upload(str(src), cred, remote)
320
+ finally:
321
+ # Clean synchronously here so a slow client cannot leave the temp
322
+ # file lying around after the response has already returned.
323
+ background.add_task(_cleanup, tmp)
324
+ return JSONResponse({"sftp_address": addr})
325
+
326
+
327
+ @app.get("/download", tags=["actions"])
328
+ def download_endpoint(
329
+ background: BackgroundTasks,
330
+ remote: str = Query(..., description="Full sftp:// address or plain remote path."),
331
+ filename: str | None = Query(
332
+ None, description="Suggested filename in the Content-Disposition header."
333
+ ),
334
+ ):
335
+ """Download a remote file. The response body is the file bytes."""
336
+ cred = _cred_or_503()
337
+ tmp = _new_tmpdir()
338
+ # Pick a sensible default local name — either from the query string or
339
+ # the remote basename.
340
+ base = filename or Path(remote.rstrip("/")).name or "download.bin"
341
+ local = tmp / base
342
+ try:
343
+ download(remote, cred, str(local))
344
+ except Exception:
345
+ _cleanup(tmp)
346
+ raise
347
+ # Clean the whole temp dir after the response has been sent.
348
+ background.add_task(_cleanup, tmp)
349
+ return FileResponse(str(local), filename=base, media_type="application/octet-stream")
350
+
351
+
352
+ @app.delete("/delete", tags=["actions"])
353
+ def delete_endpoint(
354
+ remote: str = Query(..., description="Full sftp:// address or plain remote path."),
355
+ ) -> JSONResponse:
356
+ """Delete a remote file. Idempotent — deleting a missing file returns True."""
357
+ cred = _cred_or_503()
358
+ ok = delete(remote, cred)
359
+ return JSONResponse({"deleted": bool(ok), "remote": remote})
360
+
361
+
362
+ @app.post("/mkdir", tags=["actions"])
363
+ def mkdir_endpoint(
364
+ remote: str = Form(..., description="Remote directory path to create."),
365
+ ) -> JSONResponse:
366
+ """Create a remote directory (mkdir -p semantics)."""
367
+ cred = _cred_or_503()
368
+ make_remote_directory(remote, cred)
369
+ return JSONResponse({"created": True, "remote": remote})
370
+
371
+
372
+ @app.post("/tempfile", tags=["actions"])
373
+ def tempfile_endpoint(
374
+ ext: str = Form("", description="Optional file extension (without leading dot)."),
375
+ subdir: str = Form("", description="Optional subdirectory under sftp_destination_path."),
376
+ ) -> JSONResponse:
377
+ """Reserve a unique remote path (context immediately closed). Deletes on exit."""
378
+ # NB: the library's ``remote_tempfile`` deletes the reserved address on
379
+ # exit. For this stateless HTTP call the caller cannot hold the context
380
+ # open across requests — so we return the coordinates *and* leave the
381
+ # remote deleted on exit. That matches the CLI's behaviour and is the
382
+ # honest translation of the semantics.
383
+ cred = _cred_or_503()
384
+ with remote_tempfile(cred, ext=ext, subdir=subdir) as (addr, url):
385
+ payload = {"sftp_address": addr, "url": url}
386
+ return JSONResponse(payload)