Files

558 lines
18 KiB
Python

#!/usr/bin/env python3
"""Populate a running galo instance with test data through its public API.
The script logs in as an existing owner account and adds albums, generated
photos and (optionally) tags, comments, likes, share links and invites. It is
strictly additive: it never deletes or modifies existing data. Repeat runs add
new data that is distinguishable by a run tag baked into album titles.
Core functionality uses only the Python standard library. Pillow is an optional
dependency: when present, photos are JPEG with EXIF (capture time and GPS);
otherwise they are PNG without EXIF.
Configuration:
GALO_BASE_URL base URL of the instance (default: http://localhost:8080)
GALO_EMAIL owner email (one of GALO_EMAIL/GALO_PHONE required)
GALO_PHONE owner phone, E.164 (one of GALO_EMAIL/GALO_PHONE required)
GALO_PASSWORD owner password (required)
Login uses the single-identifier contract: the email is preferred, otherwise the
phone is used.
"""
from __future__ import annotations
import argparse
import io
import json
import os
import random
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zlib
from fractions import Fraction
try:
from PIL import Image, ImageDraw
HAVE_PIL = True
except ImportError: # pragma: no cover - exercised only without Pillow
HAVE_PIL = False
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
DEFAULT_BASE_URL = "http://localhost:8080"
COMMENT_TEXTS = [
"Какое место!",
"Отличный кадр",
"Нужно повторить эту поездку",
"Здесь был потрясающий свет",
"Любимое фото из альбома",
]
TAG_NAMES = ["travel", "summer", "family", "friends", "nature", "city", "sunset"]
class ApiError(Exception):
"""Error returned by the galo API or the transport layer."""
def __init__(self, status: int, code: str, message: str):
super().__init__(f"{status} {code}: {message}")
self.status = status
self.code = code
self.message = message
def encode_multipart(fields, files, boundary):
"""Build a multipart/form-data body.
fields: iterable of (name, value). files: iterable of
(name, filename, content_type, data).
"""
body = bytearray()
for name, value in fields:
body += f"--{boundary}\r\n".encode()
body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
body += str(value).encode()
body += b"\r\n"
for name, filename, content_type, data in files:
body += f"--{boundary}\r\n".encode()
body += (
f'Content-Disposition: form-data; name="{name}"; '
f'filename="{filename}"\r\n'
).encode()
body += f"Content-Type: {content_type}\r\n\r\n".encode()
body += data
body += b"\r\n"
body += f"--{boundary}--\r\n".encode()
return bytes(body)
class Client:
"""Minimal JSON/multipart HTTP client over urllib."""
def __init__(self, base_url, timeout=30):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.token = None
def request(self, method, path, *, json_body=None, files=None, fields=None):
url = self.base_url + path
headers = {"Accept": "application/json"}
data = None
if json_body is not None:
data = json.dumps(json_body).encode("utf-8")
headers["Content-Type"] = "application/json"
elif files is not None:
boundary = "----galoseed" + uuid.uuid4().hex
data = encode_multipart(fields or [], files, boundary)
headers["Content-Type"] = (
"multipart/form-data; boundary=" + boundary
)
if self.token:
headers["Authorization"] = "Bearer " + self.token
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
except urllib.error.HTTPError as exc:
raise _http_error(exc)
except urllib.error.URLError as exc:
raise ApiError(0, "network_error", str(exc.reason))
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return None
def _http_error(exc):
code, message = "http_error", str(exc.reason)
try:
payload = json.loads(exc.read())
err = payload.get("error") or {}
code = err.get("code", code)
message = err.get("message", message)
except Exception:
pass
return ApiError(exc.code, code, message)
# --- image generation -------------------------------------------------------
def _rational(value):
deg = int(value)
minutes = int((value - deg) * 60)
seconds = (value - deg - minutes / 60.0) * 3600.0
return (Fraction(deg), Fraction(minutes), Fraction(round(seconds * 100), 100))
def generate_jpeg(rng):
"""Return JPEG bytes with EXIF capture time (and GPS when accepted)."""
width = rng.randint(640, 1600)
height = rng.randint(480, 1200)
base = (rng.randint(20, 220), rng.randint(20, 220), rng.randint(20, 220))
img = Image.new("RGB", (width, height))
draw = ImageDraw.Draw(img)
for step in range(0, height, 4):
shade = (base[0] + step) % 256
draw.rectangle(
[0, step, width, step + 4],
fill=(shade, (base[1] + step // 2) % 256, base[2]),
)
draw.ellipse(
[
rng.randint(0, width // 2),
rng.randint(0, height // 2),
rng.randint(width // 2, width),
rng.randint(height // 2, height),
],
fill=(rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255)),
)
taken = "20{:02d}:{:02d}:{:02d} {:02d}:{:02d}:{:02d}".format(
rng.randint(10, 24),
rng.randint(1, 12),
rng.randint(1, 28),
rng.randint(0, 23),
rng.randint(0, 59),
rng.randint(0, 59),
)
exif = Image.Exif()
exif[36867] = taken # DateTimeOriginal
exif[36868] = taken # DateTimeDigitized
exif[306] = taken # DateTime
try:
lat = rng.uniform(-60.0, 60.0)
lon = rng.uniform(-120.0, 120.0)
exif[0x8825] = {
1: "N" if lat >= 0 else "S",
2: _rational(abs(lat)),
3: "E" if lon >= 0 else "W",
4: _rational(abs(lon)),
}
except Exception:
pass
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85, exif=exif)
return buf.getvalue()
def _png_chunk(tag, data):
return (
struct.pack(">I", len(data))
+ tag
+ data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
)
def generate_png(rng):
"""Return PNG bytes built with the standard library (no EXIF)."""
width, height = 320, 240
base = (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255))
raw = bytearray()
for y in range(height):
raw.append(0) # filter type: none
row = bytearray()
for x in range(width):
row += bytes(
((base[0] + x) % 256, (base[1] + y) % 256, base[2])
)
raw += row
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return (
b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", ihdr)
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw), 6))
+ _png_chunk(b"IEND", b"")
)
def make_image(rng):
"""Return (filename, content_type, data) for one generated photo."""
if HAVE_PIL:
return "seed-{}.jpg".format(uuid.uuid4().hex[:8]), "image/jpeg", generate_jpeg(rng)
return "seed-{}.png".format(uuid.uuid4().hex[:8]), "image/png", generate_png(rng)
def generate_video(rng):
"""Return (filename, content_type, data) for one generated video.
Uses ffmpeg's synthetic source so the fixture is small and deterministic.
Returns None when ffmpeg is unavailable.
"""
if not HAVE_FFMPEG:
return None
seconds = rng.randint(1, 3)
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "seed.mp4")
cmd = [
"ffmpeg",
"-v", "error",
"-y",
"-f", "lavfi",
"-i", "testsrc=size=320x240:rate=15",
"-t", str(seconds),
"-pix_fmt", "yuv420p",
"-c:v", "libx264",
"-preset", "veryfast",
path,
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except (subprocess.CalledProcessError, OSError):
return None
with open(path, "rb") as fh:
data = fh.read()
return "seed-{}.mp4".format(uuid.uuid4().hex[:8]), "video/mp4", data
# --- seeding ----------------------------------------------------------------
class Stats:
def __init__(self):
self.albums = 0
self.photos = 0
self.videos = 0
self.tags = 0
self.comments = 0
self.likes = 0
self.share_links = 0
self.invites = 0
def report(self):
return (
"создано: альбомов={albums}, фото={photos}, видео={videos}, "
"тегов={tags}, комментариев={comments}, лайков={likes}, "
"share-ссылок={share_links}, инвайтов={invites}".format(
**self.__dict__
)
)
def login(client, identifier, password):
resp = client.request(
"POST",
"/api/auth/login",
json_body={"identifier": identifier, "password": password},
)
client.token = resp["tokens"]["access_token"]
def create_album(client, title):
resp = client.request(
"POST",
"/api/albums",
json_body={"title": title, "description": "Тестовые данные (seed)"},
)
return resp["album"]["id"]
def upload_photo(client, album_id, rng):
filename, content_type, data = make_image(rng)
resp = client.request(
"POST",
"/api/albums/{}/photos".format(album_id),
files=[("files", filename, content_type, data)],
)
return resp["photos"][0]["id"]
def upload_video(client, album_id, rng):
"""Upload one video fixture. Returns the photo id, or None when skipped."""
fixture = generate_video(rng)
if fixture is None:
return None
filename, content_type, data = fixture
resp = client.request(
"POST",
"/api/albums/{}/photos".format(album_id),
files=[("files", filename, content_type, data)],
)
return resp["photos"][0]["id"]
def wait_for_variants(client, photo_id, timeout, poll=1.0):
deadline = time.time() + timeout
while time.time() < deadline:
resp = client.request("GET", "/api/photos/{}".format(photo_id))
photo = resp.get("photo") if resp else None
if photo and photo.get("thumb_url"):
return True
time.sleep(poll)
return False
def add_tag(client, photo_id, name):
client.request(
"POST", "/api/photos/{}/tags".format(photo_id), json_body={"name": name}
)
def add_comment(client, photo_id, text):
client.request(
"POST", "/api/photos/{}/comments".format(photo_id), json_body={"text": text}
)
def add_like(client, photo_id):
client.request("POST", "/api/photos/{}/likes".format(photo_id))
def create_share_link(client, album_id):
client.request("POST", "/api/albums/{}/share".format(album_id), json_body={})
def create_invite(client, album_id, role):
client.request(
"POST",
"/api/albums/{}/invites".format(album_id),
json_body={"role": role},
)
def build_plan(args):
photo_style = "JPEG+EXIF (Pillow)" if HAVE_PIL else "PNG без EXIF (stdlib)"
lines = [
"План наполнения (сухой прогон, запросы не отправляются):",
" альбомов: {}".format(args.albums),
" фото на альбом: {}".format(args.photos_per_album),
" формат фото: {}".format(photo_style),
]
extras = []
if args.tags:
extras.append("теги")
if args.comments:
extras.append("комментарии")
if args.likes:
extras.append("лайки")
if args.share_links:
extras.append("share-ссылки")
if args.invites:
extras.append("инвайты")
lines.append(" сопутствующие сущности: {}".format(", ".join(extras) or "нет"))
lines.append(" ожидание вариантов: {}".format("да" if args.wait_variants else "нет"))
video_state = "нет"
if args.video:
video_state = "да" if HAVE_FFMPEG else "да, но ffmpeg не найден (пропуск)"
lines.append(" видео-фикстуры: {}".format(video_state))
return "\n".join(lines)
def run(args, base_url, identifier, password):
if args.dry_run:
print(build_plan(args))
return 0
rng = random.Random(args.seed)
run_tag = "seed-{}-{}".format(int(time.time()), args.seed)
client = Client(base_url, timeout=args.timeout)
stats = Stats()
login(client, identifier, password)
print("Вошел как {} @ {}".format(identifier, base_url))
if args.video and not HAVE_FFMPEG:
print(
" ! ffmpeg не найден: видео-фикстуры пропускаются",
file=sys.stderr,
)
for album_index in range(1, args.albums + 1):
album_id = create_album(
client, "{} #{}".format(run_tag, album_index)
)
stats.albums += 1
print("Альбом {}: {}".format(album_index, album_id))
for photo_index in range(1, args.photos_per_album + 1):
photo_id = upload_photo(client, album_id, rng)
stats.photos += 1
if args.wait_variants:
if wait_for_variants(client, photo_id, args.timeout):
pass
else:
print(
" ! Таймаут ожидания вариантов для фото {} "
"(продолжаю)".format(photo_id),
file=sys.stderr,
)
if args.tags:
add_tag(client, photo_id, rng.choice(TAG_NAMES))
stats.tags += 1
if args.comments and rng.random() < 0.5:
add_comment(client, photo_id, rng.choice(COMMENT_TEXTS))
stats.comments += 1
if args.likes and rng.random() < 0.5:
add_like(client, photo_id)
stats.likes += 1
if args.share_links:
create_share_link(client, album_id)
stats.share_links += 1
if args.invites:
create_invite(client, album_id, "member")
stats.invites += 1
if args.video and upload_video(client, album_id, rng) is not None:
stats.videos += 1
print(stats.report())
return 0
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="Наполнить инстанс galo тестовыми данными через API (только добавление)."
)
parser.add_argument("--albums", type=int, default=2, help="число альбомов (по умолчанию 2)")
parser.add_argument(
"--photos-per-album",
type=int,
default=10,
help="фото на альбом (по умолчанию 10)",
)
parser.add_argument("--seed", type=int, default=0, help="seed генератора (по умолчанию 0)")
parser.add_argument("--tags", action="store_true", help="добавлять теги")
parser.add_argument("--comments", action="store_true", help="добавлять комментарии")
parser.add_argument("--likes", action="store_true", help="добавлять лайки")
parser.add_argument("--share-links", action="store_true", help="создавать share-ссылки")
parser.add_argument("--invites", action="store_true", help="создавать инвайты (роль member)")
parser.add_argument(
"--video",
action="store_true",
help="загружать видео-фикстуры (требует ffmpeg; иначе пропускаются)",
)
parser.add_argument(
"--wait-variants",
action="store_true",
help="дожидаться генерации превью каждого фото",
)
parser.add_argument(
"--timeout",
type=float,
default=30.0,
help="таймаут ожидания вариантов и сетевых запросов, сек (по умолчанию 30)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="показать план и не отправлять изменяющих запросов",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
base_url = os.environ.get("GALO_BASE_URL", DEFAULT_BASE_URL)
email = os.environ.get("GALO_EMAIL")
phone = os.environ.get("GALO_PHONE")
password = os.environ.get("GALO_PASSWORD")
identifier = email or phone
if not args.dry_run:
if not identifier:
print(
"Не задан идентификатор входа: укажите GALO_EMAIL или GALO_PHONE",
file=sys.stderr,
)
return 2
if not password:
print("Не задана переменная окружения: GALO_PASSWORD", file=sys.stderr)
return 2
try:
return run(args, base_url, identifier or "", password or "")
except ApiError as exc:
if exc.status == 401:
message = (
"Ошибка аутентификации: проверьте GALO_EMAIL/GALO_PHONE и GALO_PASSWORD"
)
else:
message = "Ошибка API: {}".format(exc)
print(message, file=sys.stderr)
return 1
except KeyboardInterrupt:
print("Прервано пользователем", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())