diff --git a/.env.example b/.env.example index a1b2fff..40a9ef5 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,12 @@ # Pling/OpenDesktop uploader config # 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_PROJECT_ID=123456 PLING_USERNAME=your-email@example.com PLING_PASSWORD=your-password -PLING_ARTIFACT=build/org.kde.plasma.starter.plasmoid +PLING_FILES=build/org.kde.plasma.starter.plasmoid # Optional runtime tuning # PLING_TIMEOUT=30 diff --git a/README.md b/README.md index 87deac4..cc26948 100644 --- a/README.md +++ b/README.md @@ -95,15 +95,23 @@ Live mode is the default when `--dry-run` is omitted: ```bash 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 -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`): `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 diff --git a/scripts/pling_upload.py b/scripts/pling_upload.py index 44771c0..cb7fb72 100755 --- a/scripts/pling_upload.py +++ b/scripts/pling_upload.py @@ -2,7 +2,7 @@ """Pling/OpenDesktop uploader. 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 """ @@ -75,7 +75,7 @@ class RuntimeConfig: timeout: float max_retries: int dry_run: bool - artifact_path: Optional[Path] + artifact_paths: list[Path] @dataclass(frozen=True) @@ -299,12 +299,16 @@ def looks_like_login_page(html: str) -> bool: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( 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( - "--artifact", - help="Artifact path for upload mode (fallback: env PLING_ARTIFACT)", + "-f", + "--file", + dest="files", + action="append", + default=[], + help="File to upload in live mode. Repeat -f multiple times to upload multiple files.", ) parser.add_argument( "--project-id", @@ -441,16 +445,24 @@ def resolve_runtime_config(args: argparse.Namespace) -> RuntimeConfig: or DEFAULT_BASE_URL ) - artifact_path: Optional[Path] = None + artifact_paths: list[Path] = [] if not args.dry_run: - artifact = resolve_cli_or_env(cli_value=args.artifact, env_key="PLING_ARTIFACT") - if not artifact: + cli_files = [entry.strip() for entry in args.files if entry and entry.strip()] + 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( - "Missing artifact path for upload mode. Set --artifact or env PLING_ARTIFACT." + "Missing upload files. Pass -f (repeatable) or set env PLING_FILES as comma-separated values." ) - artifact_path = Path(artifact) - 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}") + + for file_entry in selected_files: + artifact_path = Path(file_entry) + if not artifact_path.exists() or not artifact_path.is_file(): + raise PlingUploaderError( + f"Upload file does not exist or is not a file: {artifact_path}" + ) + artifact_paths.append(artifact_path) return RuntimeConfig( project_id=project_id, @@ -460,7 +472,7 @@ def resolve_runtime_config(args: argparse.Namespace) -> RuntimeConfig: timeout=timeout, max_retries=max_retries, 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", config: RuntimeConfig, context: EditContext, + artifact_path: Path, ) -> dict[str, object]: - if config.artifact_path is None: - raise PlingUploaderError("Internal error: artifact path missing in upload mode.") + log_event("info", "file_server_upload_start", artifact=str(artifact_path)) - log_event("info", "file_server_upload_start", artifact=str(config.artifact_path)) - - with config.artifact_path.open("rb") as artifact_file: + with artifact_path.open("rb") as artifact_file: response = request_with_retries( session, "POST", @@ -656,7 +666,7 @@ def upload_to_file_server( "method": "post", }, files={ - "file": (config.artifact_path.name, artifact_file), + "file": (artifact_path.name, artifact_file), }, timeout=max(config.timeout, 120.0), max_retries=config.max_retries, @@ -729,15 +739,21 @@ def run_upload_mode(config: RuntimeConfig) -> int: edit_url, context = discover_edit_context(session, config) delete_all_existing_files(session, config, edit_url, context) - file_payload = upload_to_file_server(session, config, context) - registered_file = register_uploaded_file(session, config, edit_url, context, file_payload) + 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) + 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( + "info", + "file_registered", + uploaded_file_id=uploaded_file_id, + uploaded_file_name=uploaded_file_name, + ) - log_event( - "info", - "upload_success", - uploaded_file_id=registered_file.get("id", "unknown"), - uploaded_file_name=registered_file.get("name", "unknown"), - ) + log_event("info", "upload_success", files_uploaded=len(uploaded_file_ids)) return 0 diff --git a/tests/test_pling_upload.py b/tests/test_pling_upload.py index 25d36a6..5974fac 100644 --- a/tests/test_pling_upload.py +++ b/tests/test_pling_upload.py @@ -2,7 +2,6 @@ import argparse import importlib.util import sys import tempfile -import types import unittest from pathlib import Path @@ -49,14 +48,14 @@ class PlingUploadTests(unittest.TestCase): attrs = MOD.extract_required_data_attrs(html, project_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) try: MOD.os.environ["PLING_PROJECT_ID"] = "1" MOD.os.environ["PLING_USERNAME"] = "u" MOD.os.environ["PLING_PASSWORD"] = "p" args = argparse.Namespace( - artifact=None, + files=[], project_id=None, base_url=None, username=None, @@ -81,7 +80,7 @@ class PlingUploadTests(unittest.TestCase): timeout=1.0, max_retries=0, dry_run=False, - artifact_path=Path(tmp.name), + artifact_paths=[Path(tmp.name)], ) context = MOD.EditContext( add_file_url="https://example.com/add", @@ -110,7 +109,7 @@ class PlingUploadTests(unittest.TestCase): def fake_delete(_s, _cfg, _edit, _ctx): calls.append("delete") - def fake_upload(_s, _cfg, _ctx): + def fake_upload(_s, _cfg, _ctx, _file): calls.append("upload") return {"id": "x"} @@ -141,7 +140,7 @@ class PlingUploadTests(unittest.TestCase): timeout=1.0, max_retries=0, dry_run=False, - artifact_path=Path("/tmp/does-not-matter"), + artifact_paths=[Path("/tmp/does-not-matter")], ) context = MOD.EditContext( add_file_url="https://example.com/add", @@ -163,6 +162,74 @@ class PlingUploadTests(unittest.TestCase): finally: 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__": unittest.main()