Skip to content

Commit 406704f

Browse files
committed
early days, basic uploading and job handling mostly works
1 parent 2cfdf64 commit 406704f

File tree

2 files changed

+278
-0
lines changed

2 files changed

+278
-0
lines changed

pyproject.toml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
authors = [
7+
{email = "[email protected]"},
8+
{name = "Thomas Steen Rasmussen"}
9+
]
10+
classifiers = [
11+
"Programming Language :: Python :: 3",
12+
"Operating System :: OS Independent",
13+
]
14+
dependencies = [
15+
"typer-slim==0.12.5",
16+
"bma-client",
17+
]
18+
description = "BornHack Media Archive CLI Tool"
19+
name = "bma-cli"
20+
version = "0.1"
21+
readme = "README.md"
22+
requires-python = ">=3.10"
23+
24+
[project.scripts]
25+
"bma" = "bma_cli:app"
26+
27+
[project.optional-dependencies]
28+
dev = [
29+
"pre-commit==4.0.0",
30+
]
31+
32+
[project.urls]
33+
homepage = "https://github.com/bornhack/bma-cli-python"
34+
35+
[tool.setuptools]
36+
package-dir = {"" = "src"}
37+
38+
[tool.setuptools.packages.find]
39+
where = ["src"]
40+
41+
[tool.ruff]
42+
target-version = "py311"
43+
extend-exclude = [
44+
".git",
45+
"__pycache__",
46+
]
47+
lint.select = ["ALL"]
48+
lint.ignore = [
49+
"G004", # https://docs.astral.sh/ruff/rules/logging-f-string/
50+
"ANN101", # https://docs.astral.sh/ruff/rules/missing-type-self/
51+
"ANN102", # https://docs.astral.sh/ruff/rules/missing-type-cls/
52+
"EM101", # https://docs.astral.sh/ruff/rules/raw-string-in-exception/
53+
"EM102", # https://docs.astral.sh/ruff/rules/f-string-in-exception/
54+
"COM812", # missing-trailing-comma (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
55+
"ISC001", # single-line-implicit-string-concatenation (https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
56+
"ARG001", # https://docs.astral.sh/ruff/rules/unused-function-argument/
57+
"ARG002", # https://docs.astral.sh/ruff/rules/unused-method-argument/
58+
"ARG004", # https://docs.astral.sh/ruff/rules/unused-static-method-argument/
59+
]
60+
line-length = 120
61+
62+
[tool.ruff.lint.pydocstyle]
63+
convention = "google"

src/bma_cli.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
"""The BMA CLI wrapper."""
2+
3+
import json
4+
import logging
5+
import sys
6+
import time
7+
import uuid
8+
from io import BytesIO
9+
from pathlib import Path
10+
from typing import TypedDict
11+
12+
import click
13+
import typer
14+
from bma_client import BmaClient
15+
16+
APP_NAME = "bma-cli"
17+
app = typer.Typer()
18+
app_dir = typer.get_app_dir(APP_NAME)
19+
config_path = Path(app_dir) / "bma_cli_config.json"
20+
21+
logger = logging.getLogger("bma_cli")
22+
23+
# configure loglevel
24+
logging.basicConfig(
25+
level=logging.INFO,
26+
format="%(asctime)s %(levelname)s %(name)s.%(funcName)s():%(lineno)i: %(message)s",
27+
datefmt="%Y-%m-%d %H:%M:%S %z",
28+
)
29+
logging.getLogger("bma_cli").setLevel(logging.DEBUG)
30+
logging.getLogger("bma_client").setLevel(logging.DEBUG)
31+
32+
33+
class BaseJob(TypedDict):
34+
"""Base class inherited by ImageConversionJob and ImageExifExtractionJob."""
35+
36+
job_type: str
37+
uuid: uuid.UUID
38+
basefile_uuid: uuid.UUID
39+
user_uuid: uuid.UUID
40+
client_uuid: uuid.UUID
41+
useragent: str
42+
finished: bool
43+
44+
45+
class ImageConversionJob(BaseJob):
46+
"""Represent an ImageConversionJob."""
47+
48+
filetype: str
49+
width: int
50+
aspect_ratio_numerator: int | None = None
51+
aspect_ratio_denominator: int | None = None
52+
53+
54+
class ImageExifExtractionJob(BaseJob):
55+
"""Represent an ImageExifExtractionJob."""
56+
57+
58+
@app.command()
59+
def fileinfo(file_uuid: uuid.UUID) -> None:
60+
"""Get info for a file."""
61+
client, config = init()
62+
info = client.get_file_info(file_uuid=file_uuid)
63+
click.echo(json.dumps(info))
64+
65+
66+
@app.command()
67+
def download(file_uuid: uuid.UUID) -> None:
68+
"""Download a file."""
69+
client, config = init()
70+
fileinfo = client.download(file_uuid=file_uuid)
71+
path = Path(config["path"], fileinfo["filename"])
72+
click.echo(f"File downloaded to {path}")
73+
74+
75+
@app.command()
76+
def grind() -> None:
77+
"""Get jobs from the server and handle them."""
78+
client, config = init()
79+
80+
# get any unfinished jobs already assigned to this client
81+
jobs = client.get_jobs(job_filter=f"?limit=0&finished=false&client_uuid={client.uuid}")
82+
if not jobs:
83+
# no unfinished jobs assigned to this client, ask for new assignment
84+
jobs = client.get_job_assignment()
85+
86+
if not jobs:
87+
click.echo("Nothing to do.")
88+
return
89+
90+
# loop over jobs and handle each
91+
for job in jobs:
92+
# make sure we have the original file locally
93+
fileinfo = client.download(file_uuid=job["basefile_uuid"])
94+
path = Path(config["path"], fileinfo["filename"])
95+
handle_job(f=path, job=job, client=client, config=config)
96+
click.echo("Done!")
97+
98+
99+
@app.command()
100+
def upload(files: list[str]) -> None:
101+
"""Loop over files and upload each."""
102+
client, config = init()
103+
for f in files:
104+
pf = Path(f)
105+
click.echo(f"Uploading file {f}...")
106+
result = client.upload_file(path=pf, license=config["license"], attribution=config["attribution"])
107+
metadata = result["bma_response"]
108+
click.echo(f"File {metadata['uuid']} uploaded OK!")
109+
# check for jobs
110+
if metadata["jobs_unfinished"] == 0:
111+
continue
112+
113+
# it seems there is work to do! ask for assignment
114+
jobs = client.get_job_assignment(file_uuid=metadata["uuid"])
115+
if not jobs:
116+
click.echo("No unassigned unfinished jobs found for this file.")
117+
continue
118+
119+
# the grind
120+
click.echo(f"Handling {len(jobs)} jobs for file {f} ...")
121+
for j in jobs:
122+
# load job in a typeddict, but why?
123+
klass = getattr(sys.modules[__name__], j["job_type"])
124+
job = klass(**j)
125+
handle_job(f=f, job=job, client=client, config=config)
126+
click.echo("Done!")
127+
128+
129+
@app.command()
130+
def exif(path: Path) -> None:
131+
"""Get and return exif for a file."""
132+
client, config = init()
133+
click.echo(json.dumps(client.get_exif(fname=path)))
134+
135+
136+
@app.command()
137+
def settings() -> None:
138+
"""Get and return settings from the BMA server."""
139+
client, config = init()
140+
click.echo(json.dumps(client.get_server_settings()))
141+
142+
143+
def handle_job(f: Path, job: ImageConversionJob | ImageExifExtractionJob, client: BmaClient, config: dict) -> None:
144+
"""Handle a job and upload the result."""
145+
click.echo("======================================================")
146+
click.echo(f"Handling job {job['job_type']} {job['job_uuid']} ...")
147+
start = time.time()
148+
result = client.handle_job(job=job, orig=f)
149+
logger.debug(f"Getting result took {time.time() - start} seconds")
150+
if not result:
151+
click.echo(f"No result returned for job {job['job_type']} {job['uuid']} - skipping ...")
152+
return
153+
154+
# got job result, do whatever is needed depending on job_type
155+
if job["job_type"] == "ImageConversionJob":
156+
image, exif = result
157+
filename = job["job_uuid"] + "." + job["filetype"].lower()
158+
logger.debug(f"Encoding result as {job['filetype']} ...")
159+
start = time.time()
160+
with BytesIO() as buf:
161+
image.save(buf, format=job["filetype"], exif=exif, lossless=False, quality=90)
162+
logger.debug(f"Encoding result took {time.time() - start} seconds")
163+
client.upload_job_result(job_uuid=job["job_uuid"], buf=buf, filename=filename)
164+
elif job["job_type"] == "ImageExifExtractionJob":
165+
logger.debug(f"Got exif data {result}")
166+
with BytesIO() as buf:
167+
buf.write(json.dumps(result).encode())
168+
client.upload_job_result(job_uuid=job["job_uuid"], buf=buf, filename="exif.json")
169+
else:
170+
logger.error("Unsupported job type")
171+
raise typer.Exit(1)
172+
173+
174+
def load_config() -> dict[str, str]:
175+
"""Load config file."""
176+
# bail out on missing config
177+
if not config_path.is_file():
178+
click.echo(f"Config file {config_path} not found")
179+
raise typer.Exit(1)
180+
181+
# read config file
182+
with config_path.open() as f:
183+
config = f.read()
184+
185+
# parse json and return config dict
186+
return json.loads(config)
187+
188+
189+
def get_client(config: dict[str, str]) -> BmaClient:
190+
"""Initialise client."""
191+
return BmaClient(
192+
oauth_client_id=config["oauth_client_id"],
193+
refresh_token=config["refresh_token"],
194+
path=Path(config["path"]),
195+
base_url=config["bma_url"],
196+
client_uuid=config["client_uuid"],
197+
)
198+
199+
200+
def init() -> tuple[BmaClient, dict[str, str]]:
201+
"""Load config file and get client."""
202+
config = load_config()
203+
logger.debug(f"loaded config: {config}")
204+
client = get_client(config=config)
205+
206+
# save refresh token to config
207+
config["refresh_token"] = client.refresh_token
208+
logger.debug(f"Wrote updated refresh_token to config: {config}")
209+
with config_path.open("w") as f:
210+
f.write(json.dumps(config))
211+
return client, config
212+
213+
214+
if __name__ == "__main__":
215+
app()

0 commit comments

Comments
 (0)