chore(pling): bootstrap uv project and env template

This commit is contained in:
2026-04-13 17:44:33 +00:00
parent cb5c44a0b1
commit 80e4147686
7 changed files with 1143 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""Dry-run Pling/OpenDesktop uploader probe.
Phase 1 intentionally supports dry-run validation only:
- authenticate
- open product edit page
- parse required upload endpoint attributes
It does not upload, update, or delete files.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from dataclasses import dataclass
from html.parser import HTMLParser
from typing import Dict, Iterable, Optional
from urllib.parse import urljoin, urlparse
try:
import niquests
except ImportError as exc: # pragma: no cover - import guard
print(
"ERROR: missing dependency 'niquests'. Install it first, e.g. 'pip install niquests'.",
file=sys.stderr,
)
raise SystemExit(2) from exc
DEFAULT_BASE_URL = "https://www.opendesktop.org"
LOGIN_PATH = "/login/"
EDIT_PATH_TEMPLATE = "/p/{project_id}/edit"
DEFAULT_TIMEOUT_SECONDS = 30.0
DEFAULT_MAX_RETRIES = 2
REQUIRED_EDIT_DATA_ATTRS = (
"data-addpploadfile-uri",
"data-updatepploadfile-uri",
"data-deletepploadfile-uri",
"data-deletepploadfiles-uri",
"data-product-id",
"data-ppload-collection-id",
)
SENSITIVE_KEYS = {
"password",
"csrf",
"cookie",
"cookies",
"authorization",
"token",
"session",
}
class PlingDryRunError(RuntimeError):
"""A non-secret, user-facing dry-run failure."""
@dataclass(frozen=True)
class LoginForm:
action_url: str
hidden_fields: Dict[str, str]
class _LoginFormParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._inside_form = False
self._form_action: Optional[str] = None
self._form_has_auth_fields = False
self._form_hidden_inputs: Dict[str, str] = {}
self.login_form: Optional[LoginForm] = None
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
attrs_dict = {k: (v or "") for k, v in attrs}
if tag == "form":
self._inside_form = True
self._form_action = attrs_dict.get("action") or LOGIN_PATH
self._form_has_auth_fields = False
self._form_hidden_inputs = {}
return
if not self._inside_form or tag != "input":
return
name = (attrs_dict.get("name") or "").strip()
input_type = (attrs_dict.get("type") or "").strip().lower()
value = attrs_dict.get("value") or ""
if name in {"email", "password"}:
self._form_has_auth_fields = True
if input_type == "hidden" and name:
self._form_hidden_inputs[name] = value
def handle_endtag(self, tag: str) -> None:
if tag != "form" or not self._inside_form:
return
if self._form_has_auth_fields:
self.login_form = LoginForm(
action_url=self._form_action or LOGIN_PATH,
hidden_fields=dict(self._form_hidden_inputs),
)
self._inside_form = False
self._form_action = None
self._form_has_auth_fields = False
self._form_hidden_inputs = {}
def _redact(key: str, value: object) -> object:
lowered = key.lower()
if any(secret in lowered for secret in SENSITIVE_KEYS):
return "***"
return value
def log_event(level: str, event: str, **fields: object) -> None:
payload = {
"level": level.upper(),
"event": event,
}
payload.update({k: _redact(k, v) for k, v in fields.items()})
print(json.dumps(payload, ensure_ascii=True, sort_keys=True))
def normalize_base_url(base_url: str) -> str:
normalized = base_url.strip().rstrip("/")
parsed = urlparse(normalized)
if not parsed.scheme or not parsed.netloc:
raise PlingDryRunError(f"Invalid --base-url: {base_url!r}")
return normalized
def _safe_response_context(response: object) -> dict[str, object]:
status_code = getattr(response, "status_code", "unknown")
url = getattr(response, "url", "unknown")
return {"status_code": status_code, "url": str(url)}
def request_with_retries(
session: "niquests.Session",
method: str,
url: str,
*,
timeout: float,
max_retries: int,
retry_on_statuses: Iterable[int] = (429, 500, 502, 503, 504),
**kwargs: object,
):
attempt = 0
last_error: Optional[Exception] = None
while attempt <= max_retries:
attempt += 1
try:
response = session.request(method=method, url=url, timeout=timeout, **kwargs)
except Exception as exc:
last_error = exc
log_event(
"warning",
"http_request_exception",
method=method,
url=url,
attempt=attempt,
max_retries=max_retries,
error_type=type(exc).__name__,
)
if attempt > max_retries:
break
time.sleep(min(0.5 * attempt, 2.0))
continue
if response.status_code in retry_on_statuses and attempt <= max_retries:
log_event(
"warning",
"http_retryable_status",
method=method,
url=url,
attempt=attempt,
status_code=response.status_code,
)
time.sleep(min(0.5 * attempt, 2.0))
continue
return response
if last_error is not None:
raise PlingDryRunError(
f"HTTP request failed after retries: {method} {url} ({type(last_error).__name__})"
) from last_error
raise PlingDryRunError(f"HTTP request failed after retries: {method} {url}")
def parse_login_form(login_html: str, login_url: str) -> LoginForm:
parser = _LoginFormParser()
parser.feed(login_html)
if parser.login_form is None:
raise PlingDryRunError("Could not find login form with email/password fields.")
action_url = urljoin(login_url, parser.login_form.action_url)
hidden_fields = parser.login_form.hidden_fields
# csrf is required for current flow. redirect_url/twit can be empty but should exist.
missing_keys = [k for k in ("csrf", "redirect_url", "twit") if k not in hidden_fields]
if missing_keys:
raise PlingDryRunError(
f"Login form is missing expected hidden fields: {', '.join(missing_keys)}"
)
return LoginForm(action_url=action_url, hidden_fields=hidden_fields)
def extract_required_data_attrs(edit_html: str, project_id: str) -> dict[str, str]:
attrs: dict[str, str] = {}
for attr in REQUIRED_EDIT_DATA_ATTRS:
match = re.search(rf"\b{re.escape(attr)}=[\"']([^\"']*)[\"']", edit_html)
if match:
attrs[attr] = match.group(1).strip()
missing = [attr for attr in REQUIRED_EDIT_DATA_ATTRS if attr not in attrs]
if missing:
raise PlingDryRunError(
"Edit page is missing required upload data attributes: " + ", ".join(missing)
)
# Some edit pages leave data-product-id blank even when the page is valid.
if attrs.get("data-product-id", "") == "":
attrs["data-product-id"] = project_id
return attrs
def looks_like_login_page(html: str) -> bool:
lowered = html.lower()
return (
'name="email"' in lowered
and 'name="password"' in lowered
and "login" in lowered
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Dry-run checker for Pling/OpenDesktop product upload endpoints."
)
parser.add_argument(
"--project-id",
help="Pling/OpenDesktop project id (fallback: env PLING_PROJECT_ID)",
)
parser.add_argument(
"--base-url",
help=f"Base URL (fallback: env PLING_BASE_URL, then {DEFAULT_BASE_URL})",
)
parser.add_argument(
"--username",
help="Login username/email (fallback: env PLING_USERNAME)",
)
parser.add_argument(
"--password",
help="Login password (fallback: env PLING_PASSWORD)",
)
parser.add_argument(
"--timeout",
type=float,
help=(
"Per-request timeout seconds "
f"(fallback: env PLING_TIMEOUT, then {DEFAULT_TIMEOUT_SECONDS})"
),
)
parser.add_argument(
"--max-retries",
type=int,
help=(
"Retry count for transient errors "
f"(fallback: env PLING_MAX_RETRIES, then {DEFAULT_MAX_RETRIES})"
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Required in Phase 1. Validates login + endpoint discovery only.",
)
return parser
def resolve_cli_or_env(
*,
cli_value: Optional[str],
env_key: str,
default: Optional[str] = None,
) -> Optional[str]:
if cli_value is not None and cli_value.strip() != "":
return cli_value.strip()
env_value = os.getenv(env_key)
if env_value is not None and env_value.strip() != "":
return env_value.strip()
return default
def resolve_float_cli_or_env(
*,
cli_value: Optional[float],
env_key: str,
default: float,
) -> float:
if cli_value is not None:
return cli_value
env_value = os.getenv(env_key)
if env_value is None or env_value.strip() == "":
return default
try:
return float(env_value)
except ValueError as exc:
raise PlingDryRunError(
f"Invalid {env_key} value {env_value!r}; expected a number."
) from exc
def resolve_int_cli_or_env(
*,
cli_value: Optional[int],
env_key: str,
default: int,
) -> int:
if cli_value is not None:
return cli_value
env_value = os.getenv(env_key)
if env_value is None or env_value.strip() == "":
return default
try:
return int(env_value)
except ValueError as exc:
raise PlingDryRunError(
f"Invalid {env_key} value {env_value!r}; expected an integer."
) from exc
def run_dry_run(args: argparse.Namespace) -> int:
if not args.dry_run:
raise PlingDryRunError(
"Phase 1 supports dry-run only. Re-run with --dry-run."
)
project_id = resolve_cli_or_env(cli_value=args.project_id, env_key="PLING_PROJECT_ID")
if not project_id:
raise PlingDryRunError(
"Missing project id. Set --project-id or env PLING_PROJECT_ID."
)
username = resolve_cli_or_env(cli_value=args.username, env_key="PLING_USERNAME")
password = resolve_cli_or_env(cli_value=args.password, env_key="PLING_PASSWORD")
if not username or not password:
raise PlingDryRunError(
"Missing credentials. Set --username/--password or env PLING_USERNAME/PLING_PASSWORD."
)
timeout = resolve_float_cli_or_env(
cli_value=args.timeout,
env_key="PLING_TIMEOUT",
default=DEFAULT_TIMEOUT_SECONDS,
)
max_retries = resolve_int_cli_or_env(
cli_value=args.max_retries,
env_key="PLING_MAX_RETRIES",
default=DEFAULT_MAX_RETRIES,
)
if max_retries < 0:
raise PlingDryRunError("--max-retries must be >= 0")
if timeout <= 0:
raise PlingDryRunError("--timeout must be > 0")
base_url = normalize_base_url(
resolve_cli_or_env(
cli_value=args.base_url,
env_key="PLING_BASE_URL",
default=DEFAULT_BASE_URL,
)
or DEFAULT_BASE_URL
)
login_url = urljoin(base_url + "/", LOGIN_PATH.lstrip("/"))
edit_url = urljoin(
base_url + "/",
EDIT_PATH_TEMPLATE.format(project_id=project_id).lstrip("/"),
)
session = niquests.Session()
session.headers.update(
{
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
)
# Seen in current browser-side checks; harmless if ignored server-side.
session.cookies.set("verified", "1", domain=urlparse(base_url).hostname)
log_event("info", "dry_run_start", base_url=base_url, project_id=project_id)
login_page = request_with_retries(
session,
"GET",
login_url,
timeout=timeout,
max_retries=max_retries,
)
log_event("info", "login_page_fetched", **_safe_response_context(login_page))
login_form = parse_login_form(login_page.text, login_url)
form_payload = dict(login_form.hidden_fields)
form_payload.update({"email": username, "password": password})
auth_response = request_with_retries(
session,
"POST",
login_form.action_url,
data=form_payload,
timeout=timeout,
max_retries=max_retries,
)
log_event("info", "login_submitted", **_safe_response_context(auth_response))
# Treat clear auth failure content as terminal immediately.
if "incorrect login" in auth_response.text.lower():
raise PlingDryRunError("Authentication failed: incorrect username or password.")
edit_page = request_with_retries(
session,
"GET",
edit_url,
timeout=timeout,
max_retries=max_retries,
)
log_event("info", "edit_page_fetched", **_safe_response_context(edit_page))
if looks_like_login_page(edit_page.text):
raise PlingDryRunError(
"Authentication appears unsuccessful: redirected back to login page."
)
if str(edit_page.url).rstrip("/") == normalize_base_url(login_url).rstrip("/"):
raise PlingDryRunError("Unable to access edit page; received login page URL.")
attrs = extract_required_data_attrs(edit_page.text, project_id=project_id)
normalized_attrs: dict[str, str] = {}
for key, value in attrs.items():
resolved_value = value.replace("@@project_id@@", project_id)
if key.endswith("-uri"):
normalized_attrs[key] = urljoin(base_url + "/", resolved_value.lstrip("/"))
else:
normalized_attrs[key] = resolved_value
# Endpoint inventory only (safe to log); no mutation calls are performed in Phase 1.
for key in REQUIRED_EDIT_DATA_ATTRS:
log_event("info", "endpoint_discovered", name=key, value=normalized_attrs[key])
log_event(
"info",
"dry_run_success",
message="Dry-run completed. No upload/update/delete actions were performed.",
)
return 0
def main() -> int:
parser = build_parser()
args = parser.parse_args()
try:
return run_dry_run(args)
except PlingDryRunError as exc:
log_event("error", "dry_run_failed", message=str(exc))
return 1
if __name__ == "__main__":
raise SystemExit(main())