updated
This commit is contained in:
@@ -506,16 +506,40 @@ class CmdletIntrospection:
|
||||
if normalized_arg == "plugin":
|
||||
canonical_cmd = (cmd_name or "").replace("_", "-").lower()
|
||||
try:
|
||||
from ProviderCore.registry import list_configured_plugin_names_with_capability
|
||||
from ProviderCore.registry import (
|
||||
list_configured_plugin_names_with_capability,
|
||||
list_plugin_names_for_cmdlet,
|
||||
)
|
||||
except Exception:
|
||||
list_configured_plugin_names_with_capability = None # type: ignore
|
||||
list_plugin_names_for_cmdlet = None # type: ignore
|
||||
|
||||
plugin_choices: List[str] = []
|
||||
|
||||
if canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None:
|
||||
return list_configured_plugin_names_with_capability("upload", config) or []
|
||||
|
||||
if list_configured_plugin_names_with_capability is not None:
|
||||
if list_plugin_names_for_cmdlet is not None:
|
||||
configured = list_plugin_names_for_cmdlet(
|
||||
canonical_cmd,
|
||||
config,
|
||||
configured_only=True,
|
||||
) or []
|
||||
available = list_plugin_names_for_cmdlet(
|
||||
canonical_cmd,
|
||||
config,
|
||||
configured_only=False,
|
||||
) or []
|
||||
# Prefer configured plugins first, but still show valid plugin options.
|
||||
seen: Set[str] = set()
|
||||
merged: List[str] = []
|
||||
for entry in [*configured, *available]:
|
||||
key = str(entry or "").strip().lower()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(str(entry))
|
||||
plugin_choices = merged
|
||||
elif canonical_cmd in {"add-file"} and list_configured_plugin_names_with_capability is not None:
|
||||
plugin_choices = list_configured_plugin_names_with_capability("upload", config) or []
|
||||
elif list_configured_plugin_names_with_capability is not None:
|
||||
plugin_choices = list_configured_plugin_names_with_capability("search", config) or []
|
||||
|
||||
if plugin_choices:
|
||||
|
||||
+131
-2
@@ -530,6 +530,53 @@ def get_plugin_class(name: str) -> Optional[Type[Provider]]:
|
||||
return info.plugin_class
|
||||
|
||||
|
||||
def get_plugin_capabilities(
|
||||
name: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a normalized capability summary for a plugin name."""
|
||||
info = REGISTRY.get(name)
|
||||
if info is None:
|
||||
return {
|
||||
"name": str(name or "").strip().lower(),
|
||||
"supported_cmdlets": [],
|
||||
"supports_search": False,
|
||||
"supports_upload": False,
|
||||
"supports_pipe_download": False,
|
||||
"supports_delete_file": False,
|
||||
"is_multi_instance": False,
|
||||
"configured_instances": [],
|
||||
}
|
||||
|
||||
supported_cmdlets = sorted(str(c) for c in info.supported_cmdlets)
|
||||
supports_pipe_download = _class_supports_method(
|
||||
info.plugin_class,
|
||||
"resolve_pipe_result_download",
|
||||
Provider.resolve_pipe_result_download,
|
||||
)
|
||||
delete_method = getattr(info.plugin_class, "delete_file", None)
|
||||
base_delete_method = getattr(Provider, "delete_file", None)
|
||||
supports_delete_file = callable(delete_method) and delete_method is not base_delete_method
|
||||
|
||||
configured_instances: List[str] = []
|
||||
try:
|
||||
plugin_obj = info.plugin_class(config or {})
|
||||
configured_instances = [str(v) for v in (plugin_obj.configured_instances() or []) if str(v).strip()]
|
||||
except Exception:
|
||||
configured_instances = []
|
||||
|
||||
return {
|
||||
"name": info.canonical_name,
|
||||
"supported_cmdlets": supported_cmdlets,
|
||||
"supports_search": bool(info.supports_search),
|
||||
"supports_upload": bool(info.supports_upload),
|
||||
"supports_pipe_download": bool(supports_pipe_download),
|
||||
"supports_delete_file": bool(supports_delete_file),
|
||||
"is_multi_instance": bool(info.is_multi_instance),
|
||||
"configured_instances": configured_instances,
|
||||
}
|
||||
|
||||
|
||||
def selection_auto_stage_for_table(
|
||||
table_type: str,
|
||||
stage_args: Optional[Sequence[str]] = None,
|
||||
@@ -565,12 +612,30 @@ def _supports_upload(provider: Provider) -> bool:
|
||||
return exposed and _class_supports_method(provider.__class__, "upload", Provider.upload)
|
||||
|
||||
|
||||
def _supports_pipe_result_download(provider: Provider) -> bool:
|
||||
return _class_supports_method(
|
||||
provider.__class__,
|
||||
"resolve_pipe_result_download",
|
||||
Provider.resolve_pipe_result_download,
|
||||
)
|
||||
|
||||
|
||||
def _supports_delete_file(provider: Provider) -> bool:
|
||||
method = getattr(provider.__class__, "delete_file", None)
|
||||
base_method = getattr(Provider, "delete_file", None)
|
||||
return callable(method) and method is not base_method
|
||||
|
||||
|
||||
def _supports_capability(provider: Provider, capability: str) -> bool:
|
||||
capability_key = str(capability or "").strip().lower()
|
||||
if capability_key == "search":
|
||||
return _supports_search(provider)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return _supports_upload(provider)
|
||||
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
|
||||
return _supports_pipe_result_download(provider)
|
||||
if capability_key in {"delete-file", "delete_file", "delete"}:
|
||||
return _supports_delete_file(provider)
|
||||
if capability_key in {"pipe-item-context", "pipe-context"}:
|
||||
return _class_supports_method(
|
||||
provider.__class__,
|
||||
@@ -592,6 +657,16 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
|
||||
return bool(info.supports_search)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return bool(info.supports_upload)
|
||||
if capability_key in {"pipe-download", "pipe_result_download", "pipe-result-download"}:
|
||||
return _class_supports_method(
|
||||
info.plugin_class,
|
||||
"resolve_pipe_result_download",
|
||||
Provider.resolve_pipe_result_download,
|
||||
)
|
||||
if capability_key in {"delete-file", "delete_file", "delete"}:
|
||||
method = getattr(info.plugin_class, "delete_file", None)
|
||||
base_method = getattr(Provider, "delete_file", None)
|
||||
return callable(method) and method is not base_method
|
||||
if capability_key in {"pipe-item-context", "pipe-context"}:
|
||||
return _class_supports_method(
|
||||
info.plugin_class,
|
||||
@@ -762,8 +837,11 @@ def list_configured_plugin_names_with_capability(
|
||||
name = info.canonical_name
|
||||
if info.is_multi_instance:
|
||||
try:
|
||||
instances = info.plugin_class(cfg).configured_instances()
|
||||
if instances:
|
||||
plugin_obj = info.plugin_class(cfg)
|
||||
instances = plugin_obj.configured_instances()
|
||||
# Treat explicit multi-instance names as configured, but also allow
|
||||
# a default/single config block for multi-instance plugins.
|
||||
if instances or bool(plugin_obj.plugin_config_root()):
|
||||
result.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -774,6 +852,55 @@ def list_configured_plugin_names_with_capability(
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def list_plugin_names_for_cmdlet(
|
||||
cmdlet_name: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
configured_only: bool = False,
|
||||
) -> List[str]:
|
||||
"""Return plugin names suitable for a cmdlet.
|
||||
|
||||
Priority:
|
||||
1) Plugins that explicitly declare the cmdlet in SUPPORTED_CMDLETS.
|
||||
2) Capability fallback for legacy plugins that do not yet declare cmdlets.
|
||||
"""
|
||||
cmd = str(cmdlet_name or "").strip().lower()
|
||||
if not cmd:
|
||||
return []
|
||||
|
||||
supported = {
|
||||
info.canonical_name for info in REGISTRY.get_plugins_for_cmdlet(cmd)
|
||||
}
|
||||
|
||||
fallback_capability = {
|
||||
"search-file": "search",
|
||||
"add-file": "upload",
|
||||
"download-file": "search",
|
||||
"delete-file": "delete-file",
|
||||
}.get(cmd)
|
||||
|
||||
if fallback_capability:
|
||||
supported.update(list_plugin_names_with_capability(fallback_capability))
|
||||
|
||||
if not configured_only:
|
||||
return sorted(supported)
|
||||
|
||||
cfg = config or {}
|
||||
configured: set[str] = set()
|
||||
if fallback_capability:
|
||||
configured.update(list_configured_plugin_names_with_capability(fallback_capability, cfg))
|
||||
|
||||
# Keep cmdlet-declared plugins if they appear configured in plugin/provider sections.
|
||||
plugin_section: Dict[str, Any] = cfg.get("plugin") or {} # type: ignore[assignment]
|
||||
provider_section: Dict[str, Any] = cfg.get("provider") or {} # type: ignore[assignment]
|
||||
for name in supported:
|
||||
key = str(name or "").strip().lower()
|
||||
if isinstance(plugin_section.get(key), dict) or isinstance(provider_section.get(key), dict):
|
||||
configured.add(name)
|
||||
|
||||
return sorted(configured)
|
||||
|
||||
|
||||
def match_plugin_name_for_url(url: str) -> Optional[str]:
|
||||
raw_url = str(url or "").strip()
|
||||
raw_url_lower = raw_url.lower()
|
||||
@@ -990,11 +1117,13 @@ __all__ = [
|
||||
"get_plugin_with_capability",
|
||||
"list_plugins_with_capability",
|
||||
"list_plugin_names_with_capability",
|
||||
"list_plugin_names_for_cmdlet",
|
||||
"list_configured_plugin_names_with_capability",
|
||||
"match_plugin_name_for_url",
|
||||
"get_plugin_for_url",
|
||||
"list_selection_url_prefixes",
|
||||
"get_plugin_class",
|
||||
"get_plugin_capabilities",
|
||||
"selection_auto_stage_for_table",
|
||||
"plugin_inline_query_choices",
|
||||
"is_known_plugin_name",
|
||||
|
||||
@@ -33,7 +33,7 @@ from SYS.plugin_config import (
|
||||
get_item_schema_map,
|
||||
get_required_config_keys,
|
||||
)
|
||||
from ProviderCore.registry import get_plugin, get_plugin_class
|
||||
from ProviderCore.registry import get_plugin, get_plugin_class, get_plugin_capabilities
|
||||
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
|
||||
from TUI.modalscreen.selection_modal import SelectionModal
|
||||
import logging
|
||||
@@ -459,6 +459,21 @@ class ConfigModal(ModalScreen):
|
||||
)
|
||||
container.mount(row)
|
||||
|
||||
def _plugin_capability_summary(self, plugin_name: str) -> str:
|
||||
caps = get_plugin_capabilities(plugin_name, self.config_data)
|
||||
cmdlets = [str(v) for v in (caps.get("supported_cmdlets") or []) if str(v).strip()]
|
||||
if caps.get("supports_pipe_download") and "pipe-download" not in cmdlets:
|
||||
cmdlets.append("pipe-download")
|
||||
if not cmdlets:
|
||||
return ""
|
||||
return ", ".join(sorted(set(cmdlets)))
|
||||
|
||||
def _plugin_label_with_capabilities(self, base_label: str, plugin_name: str) -> str:
|
||||
summary = self._plugin_capability_summary(plugin_name)
|
||||
if not summary:
|
||||
return base_label
|
||||
return f"{base_label} [caps: {summary}]"
|
||||
|
||||
def render_providers(self, container: ScrollableContainer) -> None:
|
||||
container.mount(Label("Configured Plugins", classes="config-label"))
|
||||
providers = self.config_data.get("provider", {})
|
||||
@@ -482,8 +497,12 @@ class ConfigModal(ModalScreen):
|
||||
del_id = f"del-provider-{idx}"
|
||||
self._button_id_map[edit_id] = ("edit", f"plugin-{plugin_name}", instance_name)
|
||||
self._button_id_map[del_id] = ("del", f"plugin-{plugin_name}", instance_name)
|
||||
row_label = self._plugin_label_with_capabilities(
|
||||
f"{display_name} ({plugin_name})",
|
||||
str(plugin_name),
|
||||
)
|
||||
row = Horizontal(
|
||||
Static(f"{display_name} ({plugin_name})", classes="item-label"),
|
||||
Static(row_label, classes="item-label"),
|
||||
Button("Edit", id=edit_id),
|
||||
Button("Delete", variant="error", id=del_id),
|
||||
classes="item-row"
|
||||
@@ -496,8 +515,9 @@ class ConfigModal(ModalScreen):
|
||||
del_id = f"del-provider-{idx}"
|
||||
self._button_id_map[edit_id] = ("edit", "plugin", plugin_name)
|
||||
self._button_id_map[del_id] = ("del", "plugin", plugin_name)
|
||||
row_label = self._plugin_label_with_capabilities(str(plugin_name), str(plugin_name))
|
||||
row = Horizontal(
|
||||
Static(plugin_name, classes="item-label"),
|
||||
Static(row_label, classes="item-label"),
|
||||
Button("Edit", id=edit_id),
|
||||
Button("Delete", variant="error", id=del_id),
|
||||
classes="item-row"
|
||||
@@ -530,16 +550,20 @@ class ConfigModal(ModalScreen):
|
||||
item_name = str(self.editing_item_name or "")
|
||||
item_schema_map = get_item_schema_map(item_type, item_name)
|
||||
render_state = {"group": None, "mounted_any": False}
|
||||
provider_for_caps: Optional[str] = None
|
||||
|
||||
# Parse item_type: plugin-{ptype} (multi-instance) or flat type
|
||||
if item_type.startswith("plugin-"):
|
||||
ptype = item_type[len("plugin-"):]
|
||||
container.mount(Label(f"Editing {ptype}: {item_name}", classes="config-label"))
|
||||
provider_for_caps = str(ptype)
|
||||
plugin_block = self.config_data.get("plugin") or self.config_data.get("provider") or {}
|
||||
plugin_instances = plugin_block.get(ptype, {}) if isinstance(plugin_block, dict) else {}
|
||||
section = plugin_instances.get(item_name, {}) if isinstance(plugin_instances, dict) else {}
|
||||
else:
|
||||
container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label"))
|
||||
if item_type in ("plugin", "provider"):
|
||||
provider_for_caps = str(item_name)
|
||||
section = self.config_data.get(item_type, {}).get(item_name, {})
|
||||
|
||||
# Use columns for better layout of inputs with paste buttons
|
||||
@@ -549,6 +573,11 @@ class ConfigModal(ModalScreen):
|
||||
# actually we don't need to do anything else here because refresh_view calls render_item_editor
|
||||
# which now handles the paste buttons.
|
||||
|
||||
if provider_for_caps:
|
||||
caps_summary = self._plugin_capability_summary(provider_for_caps)
|
||||
if caps_summary:
|
||||
container.mount(Static(f"Capabilities: {caps_summary}", classes="config-help"))
|
||||
|
||||
# Show all existing keys
|
||||
existing_keys_upper = set()
|
||||
idx = 0
|
||||
|
||||
+31
-3
@@ -98,7 +98,7 @@ DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256
|
||||
# Used by multiple methods in this file to guard against URL strings being
|
||||
# treated as local file paths.
|
||||
_REMOTE_URL_PREFIXES: tuple[str, ...] = (
|
||||
"http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:",
|
||||
"http://", "https://", "ftp://", "ftps://", "magnet:", "torrent:", "tidal:", "hydrus:",
|
||||
)
|
||||
|
||||
|
||||
@@ -1241,6 +1241,11 @@ class Add_File(Cmdlet):
|
||||
return None, None
|
||||
if not url_text.lower().startswith(_REMOTE_URL_PREFIXES):
|
||||
return None, None
|
||||
# This helper performs generic HTTP downloads only.
|
||||
# Non-HTTP schemes (e.g. hydrus://, tidal:) should be handled by
|
||||
# plugin-specific resolvers via _maybe_download_plugin_result.
|
||||
if not url_text.lower().startswith(("http://", "https://")):
|
||||
return None, None
|
||||
|
||||
tmp_dir: Optional[Path] = None
|
||||
try:
|
||||
@@ -1480,8 +1485,31 @@ class Add_File(Cmdlet):
|
||||
if candidate:
|
||||
s = str(candidate).lower()
|
||||
if s.startswith(_REMOTE_URL_PREFIXES):
|
||||
log("add-file ingests local files only. Use download-file first.", file=sys.stderr)
|
||||
return None, None, None
|
||||
# For remote sources, prefer plugin-specific resolvers first
|
||||
# (e.g. hydrus://), then generic HTTP fallback.
|
||||
downloaded_path, hash_hint, tmp_dir = Add_File._maybe_download_plugin_result(
|
||||
result,
|
||||
pipe_obj,
|
||||
config,
|
||||
deps=deps,
|
||||
)
|
||||
if downloaded_path:
|
||||
pipe_obj.path = str(downloaded_path)
|
||||
return downloaded_path, hash_hint, tmp_dir
|
||||
|
||||
dl_path, tmp_dir = Add_File._download_remote_backend_url(
|
||||
str(candidate),
|
||||
pipe_obj,
|
||||
file_hash=get_field(result, "hash") or get_field(result, "file_hash"),
|
||||
output_dir=export_destination,
|
||||
)
|
||||
if dl_path:
|
||||
pipe_obj.path = str(dl_path)
|
||||
hash_hint = get_field(result, "hash") or get_field(result, "file_hash")
|
||||
return dl_path, hash_hint, tmp_dir
|
||||
|
||||
log("add-file could not auto-fetch remote source. Use download-file first.", file=sys.stderr)
|
||||
return None, None, None
|
||||
|
||||
pipe_obj.path = str(candidate)
|
||||
# Retain hash from input if available to avoid re-hashing
|
||||
|
||||
+63
-4
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Sequence
|
||||
import posixpath
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -128,6 +129,27 @@ class Delete_File(sh.Cmdlet):
|
||||
else:
|
||||
store = sh.get_field(item, "store")
|
||||
|
||||
# Extract plugin/provider identity and full metadata for plugin-level dispatch
|
||||
provider_name = None
|
||||
full_metadata: Dict[str, Any] = {}
|
||||
if isinstance(item, dict):
|
||||
provider_name = item.get("provider") or item.get("table")
|
||||
raw_meta = item.get("full_metadata") or item.get("metadata")
|
||||
if isinstance(raw_meta, dict):
|
||||
full_metadata = raw_meta
|
||||
else:
|
||||
try:
|
||||
provider_name = sh.get_field(item, "provider") or sh.get_field(item, "table")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
raw_meta = sh.get_field(item, "full_metadata") or sh.get_field(item, "metadata")
|
||||
if isinstance(raw_meta, dict):
|
||||
full_metadata = raw_meta
|
||||
except Exception:
|
||||
pass
|
||||
provider_name = str(provider_name or "").strip().lower() or None
|
||||
|
||||
store_lower = str(store).lower() if store else ""
|
||||
hydrus_provider = get_plugin("hydrusnetwork", config)
|
||||
|
||||
@@ -169,14 +191,51 @@ class Delete_File(sh.Cmdlet):
|
||||
)
|
||||
|
||||
local_deleted = False
|
||||
_target_str = str(target).strip().lower() if isinstance(target, str) else ""
|
||||
local_target = (
|
||||
isinstance(target,
|
||||
str) and target.strip()
|
||||
and not str(target).lower().startswith(("http://",
|
||||
"https://"))
|
||||
isinstance(target, str) and target.strip()
|
||||
and not _target_str.startswith(("http://", "https://", "ftp://", "ftps://"))
|
||||
)
|
||||
deleted_rows: List[Dict[str, Any]] = []
|
||||
|
||||
# --- Plugin-level delete dispatch ---
|
||||
# When the item originates from a plugin (e.g. FTP), and that plugin exposes
|
||||
# a delete_file() method, delegate to it instead of attempting a local unlink.
|
||||
if conserve != "local" and provider_name and not is_hydrus_store:
|
||||
try:
|
||||
candidate_plugin = get_plugin(provider_name, config)
|
||||
plugin_deleter = getattr(candidate_plugin, "delete_file", None) if candidate_plugin else None
|
||||
if callable(plugin_deleter):
|
||||
# Prefer ftp_path from full_metadata; fall back to the path/url field
|
||||
remote = (
|
||||
full_metadata.get("ftp_path")
|
||||
or full_metadata.get("selection_url")
|
||||
or full_metadata.get("ftp_url")
|
||||
or (str(target).strip() if isinstance(target, str) else "")
|
||||
)
|
||||
instance_hint = full_metadata.get("instance") or None
|
||||
if remote:
|
||||
plugin_ok = bool(plugin_deleter(remote, instance=instance_hint))
|
||||
if plugin_ok:
|
||||
local_deleted = True
|
||||
size_hint = (
|
||||
full_metadata.get("size")
|
||||
or (item.get("size_bytes") if isinstance(item, dict) else None)
|
||||
or sh.get_field(item, "size_bytes")
|
||||
)
|
||||
deleted_rows.append(
|
||||
{
|
||||
"title": str(title_val).strip() if title_val else posixpath.basename(str(remote).rstrip("/")),
|
||||
"store": instance_hint or provider_name,
|
||||
"hash": hash_hex or "",
|
||||
"size_bytes": size_hint,
|
||||
"ext": _get_ext_from_item(),
|
||||
}
|
||||
)
|
||||
return deleted_rows
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If this item references a configured non-Hydrus store backend, prefer deleting
|
||||
# via the backend API. This supports store items where `path`/`target` is the hash.
|
||||
if conserve != "local" and store and (not is_hydrus_store):
|
||||
|
||||
+36
-1
@@ -73,7 +73,7 @@ class FTP(Provider):
|
||||
PLUGIN_NAME = "ftp"
|
||||
URL = ("ftp://", "ftps://")
|
||||
MULTI_INSTANCE = True
|
||||
SUPPORTED_CMDLETS = frozenset({"add-file", "get-file", "search-file"})
|
||||
SUPPORTED_CMDLETS = frozenset({"add-file", "delete-file", "get-file", "search-file"})
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
@@ -576,6 +576,41 @@ class FTP(Provider):
|
||||
|
||||
return self._build_url(remote_path, settings=settings)
|
||||
|
||||
def delete_file(self, remote_path_or_url: str, **kwargs: Any) -> bool:
|
||||
"""Delete a file from the FTP server.
|
||||
|
||||
Accepts either a full FTP URL (ftp://host/path/file) or a raw remote
|
||||
path (/path/to/file). Returns True on success, False on failure.
|
||||
"""
|
||||
path_text = str(remote_path_or_url or "").strip()
|
||||
if not path_text:
|
||||
return False
|
||||
|
||||
settings = self._resolve_settings(
|
||||
instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None,
|
||||
require_explicit=bool(kwargs.get("instance") or kwargs.get("store")),
|
||||
)
|
||||
if not settings.get("host"):
|
||||
requested = str(kwargs.get("instance") or kwargs.get("store") or "").strip()
|
||||
if requested:
|
||||
raise RuntimeError(f"FTP instance '{requested}' is unavailable")
|
||||
raise RuntimeError("No configured FTP instance is available")
|
||||
|
||||
# Accept full FTP URL or raw path
|
||||
if path_text.startswith(("ftp://", "ftps://")):
|
||||
remote_path = self._normalize_remote_path(path_text, default=self._base_path)
|
||||
else:
|
||||
remote_path = self._normalize_remote_path(path_text, default=str(settings.get("base_path") or "/"))
|
||||
|
||||
ftp = self._connect(settings=settings)
|
||||
try:
|
||||
ftp.delete(remote_path)
|
||||
return True
|
||||
except ftplib.error_perm:
|
||||
return False
|
||||
finally:
|
||||
self._close(ftp)
|
||||
|
||||
def _connect(
|
||||
self,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user