feat(pling): support repeatable -f multi-file uploads
This commit is contained in:
+2
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+43
-27
@@ -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)
|
|
||||||
if not artifact_path.exists() or not artifact_path.is_file():
|
for file_entry in selected_files:
|
||||||
raise PlingUploaderError(f"Artifact does not exist or is not a file: {artifact_path}")
|
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(
|
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] = []
|
||||||
registered_file = register_uploaded_file(session, config, edit_url, context, file_payload)
|
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(
|
log_event("info", "upload_success", files_uploaded=len(uploaded_file_ids))
|
||||||
"info",
|
|
||||||
"upload_success",
|
|
||||||
uploaded_file_id=registered_file.get("id", "unknown"),
|
|
||||||
uploaded_file_name=registered_file.get("name", "unknown"),
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user