|
| 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