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.
- sftp_helper/__init__.py +57 -0
- sftp_helper/api.py +386 -0
- sftp_helper/cli_argparse.py +531 -0
- sftp_helper/cli_click.py +324 -0
- sftp_helper/main.py +564 -0
- sftp_helper/mcp.py +89 -0
- sftp_helper-2.2.3.dist-info/METADATA +258 -0
- sftp_helper-2.2.3.dist-info/RECORD +12 -0
- sftp_helper-2.2.3.dist-info/WHEEL +5 -0
- sftp_helper-2.2.3.dist-info/entry_points.txt +4 -0
- sftp_helper-2.2.3.dist-info/licenses/LICENSE +29 -0
- sftp_helper-2.2.3.dist-info/top_level.txt +1 -0
sftp_helper/__init__.py
ADDED
|
@@ -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)
|