feat(pling): support repeatable -f multi-file uploads

This commit is contained in:
2026-04-13 18:00:17 +00:00
parent c88a49c771
commit c108ccc8cf
4 changed files with 129 additions and 38 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
# Pling/OpenDesktop uploader config # Pling/OpenDesktop uploader config
# CLI args take precedence over these env vars. # CLI args take precedence over these env vars.
# Default script mode is upload (delete all existing files, then upload artifact). # Default script mode is upload (delete all existing files, then upload file(s)).
PLING_BASE_URL=https://www.opendesktop.org PLING_BASE_URL=https://www.opendesktop.org
PLING_PROJECT_ID=123456 PLING_PROJECT_ID=123456
PLING_USERNAME=your-email@example.com PLING_USERNAME=your-email@example.com
PLING_PASSWORD=your-password PLING_PASSWORD=your-password
PLING_ARTIFACT=build/org.kde.plasma.starter.plasmoid PLING_FILES=build/org.kde.plasma.starter.plasmoid
# Optional runtime tuning # Optional runtime tuning
# PLING_TIMEOUT=30 # PLING_TIMEOUT=30
+11 -3
View File
@@ -95,15 +95,23 @@ Live mode is the default when `--dry-run` is omitted:
```bash ```bash
uv run --env-file .env python scripts/pling_upload.py \ uv run --env-file .env python scripts/pling_upload.py \
--artifact build/org.kde.plasma.starter.plasmoid -f build/org.kde.plasma.starter.plasmoid
``` ```
Important: live mode is destructive by design right now. It first deletes all Important: live mode is destructive by design right now. It first deletes all
existing files on the target project, then uploads/registers the new artifact. existing files on the target project, then uploads/registers the provided file(s).
You can pass multiple files by repeating `-f`:
```bash
uv run --env-file .env python scripts/pling_upload.py \
-f test2.md \
-f test3.md
```
Config can be provided by args or environment variables (`.env.example`): Config can be provided by args or environment variables (`.env.example`):
`PLING_BASE_URL`, `PLING_PROJECT_ID`, `PLING_USERNAME`, `PLING_PASSWORD`, `PLING_BASE_URL`, `PLING_PROJECT_ID`, `PLING_USERNAME`, `PLING_PASSWORD`,
`PLING_ARTIFACT`, `PLING_TIMEOUT`, `PLING_MAX_RETRIES`. `PLING_FILES`, `PLING_TIMEOUT`, `PLING_MAX_RETRIES`.
## License ## License
+39 -23
View File
@@ -2,7 +2,7 @@
"""Pling/OpenDesktop uploader. """Pling/OpenDesktop uploader.
Modes: Modes:
- default: destructive overwrite upload (delete all product files, then upload one) - default: destructive overwrite upload (delete all product files, then upload files)
- --dry-run: authenticate and validate upload endpoint discovery only - --dry-run: authenticate and validate upload endpoint discovery only
""" """
@@ -75,7 +75,7 @@ class RuntimeConfig:
timeout: float timeout: float
max_retries: int max_retries: int
dry_run: bool dry_run: bool
artifact_path: Optional[Path] artifact_paths: list[Path]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -299,12 +299,16 @@ def looks_like_login_page(html: str) -> bool:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
"Pling/OpenDesktop uploader. Default mode deletes all files then uploads artifact." "Pling/OpenDesktop uploader. Default mode deletes all files then uploads files."
) )
) )
parser.add_argument( parser.add_argument(
"--artifact", "-f",
help="Artifact path for upload mode (fallback: env PLING_ARTIFACT)", "--file",
dest="files",
action="append",
default=[],
help="File to upload in live mode. Repeat -f multiple times to upload multiple files.",
) )
parser.add_argument( parser.add_argument(
"--project-id", "--project-id",
@@ -441,16 +445,24 @@ def resolve_runtime_config(args: argparse.Namespace) -> RuntimeConfig:
or DEFAULT_BASE_URL or DEFAULT_BASE_URL
) )
artifact_path: Optional[Path] = None artifact_paths: list[Path] = []
if not args.dry_run: if not args.dry_run:
artifact = resolve_cli_or_env(cli_value=args.artifact, env_key="PLING_ARTIFACT") cli_files = [entry.strip() for entry in args.files if entry and entry.strip()]
if not artifact: env_files_raw = os.getenv("PLING_FILES", "")
env_files = [entry.strip() for entry in env_files_raw.split(",") if entry.strip()]
selected_files = cli_files if cli_files else env_files
if not selected_files:
raise PlingUploaderError( raise PlingUploaderError(
"Missing artifact path for upload mode. Set --artifact or env PLING_ARTIFACT." "Missing upload files. Pass -f <file> (repeatable) or set env PLING_FILES as comma-separated values."
) )
artifact_path = Path(artifact)
for file_entry in selected_files:
artifact_path = Path(file_entry)
if not artifact_path.exists() or not artifact_path.is_file(): if not artifact_path.exists() or not artifact_path.is_file():
raise PlingUploaderError(f"Artifact does not exist or is not a file: {artifact_path}") raise PlingUploaderError(
f"Upload file does not exist or is not a file: {artifact_path}"
)
artifact_paths.append(artifact_path)
return RuntimeConfig( return RuntimeConfig(
project_id=project_id, project_id=project_id,
@@ -460,7 +472,7 @@ def resolve_runtime_config(args: argparse.Namespace) -> RuntimeConfig:
timeout=timeout, timeout=timeout,
max_retries=max_retries, max_retries=max_retries,
dry_run=args.dry_run, dry_run=args.dry_run,
artifact_path=artifact_path, artifact_paths=artifact_paths,
) )
@@ -637,13 +649,11 @@ def upload_to_file_server(
session: "niquests.Session", session: "niquests.Session",
config: RuntimeConfig, config: RuntimeConfig,
context: EditContext, context: EditContext,
artifact_path: Path,
) -> dict[str, object]: ) -> dict[str, object]:
if config.artifact_path is None: log_event("info", "file_server_upload_start", artifact=str(artifact_path))
raise PlingUploaderError("Internal error: artifact path missing in upload mode.")
log_event("info", "file_server_upload_start", artifact=str(config.artifact_path)) with artifact_path.open("rb") as artifact_file:
with config.artifact_path.open("rb") as artifact_file:
response = request_with_retries( response = request_with_retries(
session, session,
"POST", "POST",
@@ -656,7 +666,7 @@ def upload_to_file_server(
"method": "post", "method": "post",
}, },
files={ files={
"file": (config.artifact_path.name, artifact_file), "file": (artifact_path.name, artifact_file),
}, },
timeout=max(config.timeout, 120.0), timeout=max(config.timeout, 120.0),
max_retries=config.max_retries, max_retries=config.max_retries,
@@ -729,15 +739,21 @@ def run_upload_mode(config: RuntimeConfig) -> int:
edit_url, context = discover_edit_context(session, config) edit_url, context = discover_edit_context(session, config)
delete_all_existing_files(session, config, edit_url, context) delete_all_existing_files(session, config, edit_url, context)
file_payload = upload_to_file_server(session, config, context) uploaded_file_ids: list[str] = []
for artifact_path in config.artifact_paths:
file_payload = upload_to_file_server(session, config, context, artifact_path)
registered_file = register_uploaded_file(session, config, edit_url, context, file_payload) registered_file = register_uploaded_file(session, config, edit_url, context, file_payload)
uploaded_file_id = str(registered_file.get("id", "unknown"))
uploaded_file_name = str(registered_file.get("name", artifact_path.name))
uploaded_file_ids.append(uploaded_file_id)
log_event( log_event(
"info", "info",
"upload_success", "file_registered",
uploaded_file_id=registered_file.get("id", "unknown"), uploaded_file_id=uploaded_file_id,
uploaded_file_name=registered_file.get("name", "unknown"), uploaded_file_name=uploaded_file_name,
) )
log_event("info", "upload_success", files_uploaded=len(uploaded_file_ids))
return 0 return 0
+73 -6
View File
@@ -2,7 +2,6 @@ import argparse
import importlib.util import importlib.util
import sys import sys
import tempfile import tempfile
import types
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -49,14 +48,14 @@ class PlingUploadTests(unittest.TestCase):
attrs = MOD.extract_required_data_attrs(html, project_id="42") attrs = MOD.extract_required_data_attrs(html, project_id="42")
self.assertEqual(attrs["data-product-id"], "42") self.assertEqual(attrs["data-product-id"], "42")
def test_resolve_runtime_config_requires_artifact_in_upload_mode(self): def test_resolve_runtime_config_requires_files_in_upload_mode(self):
old_env = dict(MOD.os.environ) old_env = dict(MOD.os.environ)
try: try:
MOD.os.environ["PLING_PROJECT_ID"] = "1" MOD.os.environ["PLING_PROJECT_ID"] = "1"
MOD.os.environ["PLING_USERNAME"] = "u" MOD.os.environ["PLING_USERNAME"] = "u"
MOD.os.environ["PLING_PASSWORD"] = "p" MOD.os.environ["PLING_PASSWORD"] = "p"
args = argparse.Namespace( args = argparse.Namespace(
artifact=None, files=[],
project_id=None, project_id=None,
base_url=None, base_url=None,
username=None, username=None,
@@ -81,7 +80,7 @@ class PlingUploadTests(unittest.TestCase):
timeout=1.0, timeout=1.0,
max_retries=0, max_retries=0,
dry_run=False, dry_run=False,
artifact_path=Path(tmp.name), artifact_paths=[Path(tmp.name)],
) )
context = MOD.EditContext( context = MOD.EditContext(
add_file_url="https://example.com/add", add_file_url="https://example.com/add",
@@ -110,7 +109,7 @@ class PlingUploadTests(unittest.TestCase):
def fake_delete(_s, _cfg, _edit, _ctx): def fake_delete(_s, _cfg, _edit, _ctx):
calls.append("delete") calls.append("delete")
def fake_upload(_s, _cfg, _ctx): def fake_upload(_s, _cfg, _ctx, _file):
calls.append("upload") calls.append("upload")
return {"id": "x"} return {"id": "x"}
@@ -141,7 +140,7 @@ class PlingUploadTests(unittest.TestCase):
timeout=1.0, timeout=1.0,
max_retries=0, max_retries=0,
dry_run=False, dry_run=False,
artifact_path=Path("/tmp/does-not-matter"), artifact_paths=[Path("/tmp/does-not-matter")],
) )
context = MOD.EditContext( context = MOD.EditContext(
add_file_url="https://example.com/add", add_file_url="https://example.com/add",
@@ -163,6 +162,74 @@ class PlingUploadTests(unittest.TestCase):
finally: finally:
MOD.request_with_retries = old_request MOD.request_with_retries = old_request
def test_run_upload_mode_with_multiple_files(self):
with tempfile.NamedTemporaryFile(suffix=".md") as tmp1, tempfile.NamedTemporaryFile(
suffix=".md"
) as tmp2:
config = MOD.RuntimeConfig(
project_id="1",
base_url="https://example.com",
username="u",
password="p",
timeout=1.0,
max_retries=0,
dry_run=False,
artifact_paths=[Path(tmp1.name), Path(tmp2.name)],
)
context = MOD.EditContext(
add_file_url="https://example.com/add",
update_file_url="https://example.com/update",
delete_file_url="https://example.com/delete",
delete_all_files_url="https://example.com/delete-all",
product_id="1",
collection_id="2",
file_server_upload_url="https://files.example/upload",
file_server_client_id="3",
file_server_owner_id="4",
)
calls = []
create_session_old = MOD.create_session
discover_old = MOD.discover_edit_context
delete_old = MOD.delete_all_existing_files
upload_old = MOD.upload_to_file_server
register_old = MOD.register_uploaded_file
try:
MOD.create_session = lambda _base: object()
MOD.discover_edit_context = lambda _s, _c: ("https://example.com/edit", context)
MOD.delete_all_existing_files = lambda *_a, **_kw: calls.append("delete")
def fake_upload(_s, _cfg, _ctx, file_path):
calls.append(f"upload:{Path(file_path).name}")
return {"id": Path(file_path).name}
def fake_register(_s, _cfg, _edit, _ctx, file_payload):
calls.append(f"register:{file_payload['id']}")
return {"id": file_payload["id"], "name": file_payload["id"]}
MOD.upload_to_file_server = fake_upload
MOD.register_uploaded_file = fake_register
rc = MOD.run_upload_mode(config)
self.assertEqual(rc, 0)
self.assertEqual(
calls,
[
"delete",
f"upload:{Path(tmp1.name).name}",
f"register:{Path(tmp1.name).name}",
f"upload:{Path(tmp2.name).name}",
f"register:{Path(tmp2.name).name}",
],
)
finally:
MOD.create_session = create_session_old
MOD.discover_edit_context = discover_old
MOD.delete_all_existing_files = delete_old
MOD.upload_to_file_server = upload_old
MOD.register_uploaded_file = register_old
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()