Skip to content

Commit aebaa87

Browse files
committed
refactor a bit, introduce setuptools-scm, liniting, preparing to publish package
1 parent 28c3d24 commit aebaa87

5 files changed

Lines changed: 152 additions & 54 deletions

File tree

.github/workflows/pypi.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
name: "Publish PyPi package when a new tag is pushed"
3+
4+
on: # yamllint disable-line rule:truthy
5+
push:
6+
tags:
7+
- 'v*'
8+
9+
# https://docs.pypi.org/trusted-publishers/using-a-publisher/
10+
jobs:
11+
pypi-publish:
12+
name: "upload release to PyPI"
13+
runs-on: "ubuntu-latest"
14+
environment: "pypi-publish"
15+
permissions:
16+
id-token: "write"
17+
steps:
18+
# https://github.com/pypa/sampleproject/blob/main/.github/workflows/release.yml
19+
- name: "Checkout"
20+
uses: "actions/checkout@v3"
21+
- name: "Set up Python"
22+
uses: "actions/setup-python@v4"
23+
with:
24+
python-version: '3.11'
25+
- name: "Install build dependencies"
26+
run: "python -m pip install -U setuptools wheel build"
27+
- name: "Build"
28+
run: "python -m build ."
29+
- name: "Publish package distributions to PyPI"
30+
uses: "pypa/gh-action-pypi-publish@release/v1"
31+
...

.pre-commit-config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
default_language_version:
3+
python: "python3"
4+
repos:
5+
- repo: "https://github.com/astral-sh/ruff-pre-commit"
6+
rev: "v0.7.3"
7+
hooks:
8+
- id: "ruff"
9+
args: ["--fix"]
10+
- id: "ruff-format"
11+
- repo: "https://github.com/pre-commit/mirrors-mypy"
12+
rev: 'v1.13.0'
13+
hooks:
14+
- id: "mypy"
15+
additional_dependencies:
16+
- "exifread"
17+
- "httpx"
18+
- "pillow"
19+
...

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
[build-system]
2-
requires = ["setuptools"]
2+
requires = ["setuptools>=64", "setuptools_scm>=8"]
33
build-backend = "setuptools.build_meta"
44

5+
56
[project]
67
authors = [
78
{email = "thomas@gibfest.dk"},
@@ -16,11 +17,11 @@ dependencies = [
1617
"httpx==0.27.2",
1718
"pillow==11.0.0",
1819
]
19-
description = "BornHack Media Archive Python Client Library"
2020
name = "bma-client"
21-
version = "0.1"
21+
description = "BornHack Media Archive Python Client Library"
2222
readme = "README.md"
23-
requires-python = ">=3.10"
23+
requires-python = ">=3.11"
24+
dynamic = ["version"]
2425

2526
[project.optional-dependencies]
2627
dev = [
@@ -30,6 +31,8 @@ dev = [
3031
[project.urls]
3132
homepage = "https://github.com/bornhack/bma-client-python"
3233

34+
[tool.setuptools_scm]
35+
3336
[tool.setuptools]
3437
package-dir = {"" = "src"}
3538

src/.bma_client.py.swp

-24 KB
Binary file not shown.

src/bma_client.py

Lines changed: 95 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import json
44
import logging
5-
import math
65
import time
76
import uuid
87
from fractions import Fraction
98
from http import HTTPStatus
9+
from io import BytesIO
1010
from pathlib import Path
11-
from typing import TYPE_CHECKING
11+
from typing import TYPE_CHECKING, TypeAlias
1212

1313
import exifread
1414
import httpx
@@ -18,10 +18,12 @@
1818
logger = logging.getLogger("bma_client")
1919

2020
if TYPE_CHECKING:
21-
from io import BytesIO
22-
2321
from django.http import HttpRequest
2422

23+
ImageConversionJobResult: TypeAlias = tuple[Image.Image, Image.Exif]
24+
ExifExtractionJobResult: TypeAlias = dict[str, dict[str, str]]
25+
JobResult: TypeAlias = ImageConversionJobResult | ExifExtractionJobResult
26+
2527
# maybe these should come from server settings
2628
SKIP_EXIF_TAGS = ["JPEGThumbnail", "TIFFThumbnail", "Filename"]
2729

@@ -91,20 +93,20 @@ def get_jobs(self, job_filter: str = "?limit=0") -> list[dict[str, str]]:
9193
"""Get a filtered list of the jobs this user has access to."""
9294
r = self.client.get(self.base_url + f"/api/v1/json/jobs/{job_filter}").raise_for_status()
9395
response = r.json()["bma_response"]
94-
logger.debug(f"Returning {len(response)} jobs")
96+
logger.debug(f"Returning {len(response)} jobs with filter {job_filter}")
9597
return response
9698

9799
def get_file_info(self, file_uuid: uuid.UUID) -> dict[str, str]:
98100
"""Get metadata for a file."""
99101
r = self.client.get(self.base_url + f"/api/v1/json/files/{file_uuid}/").raise_for_status()
100102
return r.json()["bma_response"]
101103

102-
def download(self, file_uuid: uuid.UUID) -> bytes:
104+
def download(self, file_uuid: uuid.UUID) -> dict[str, str]:
103105
"""Download a file from BMA."""
104106
info = self.get_file_info(file_uuid=file_uuid)
105107
path = self.path / info["filename"]
106108
if not path.exists():
107-
url = self.base_url + info["links"]["downloads"]["original"]
109+
url = self.base_url + info["links"]["downloads"]["original"] # type: ignore[index]
108110
logger.debug(f"Downloading file {url} ...")
109111
r = self.client.get(url).raise_for_status()
110112
logger.debug(f"Done downloading {len(r.content)} bytes, saving to {path}")
@@ -119,39 +121,44 @@ def get_job_assignment(self, file_uuid: uuid.UUID | None = None) -> list[dict[st
119121
url += f"?file_uuid={file_uuid}"
120122
data = {"client_uuid": self.uuid}
121123
try:
122-
r = self.client.post(url, data=json.dumps(data)).raise_for_status()
124+
r = self.client.post(url, json=data).raise_for_status()
123125
response = r.json()["bma_response"]
124126
except httpx.HTTPStatusError as e:
125127
if e.response.status_code == HTTPStatus.NOT_FOUND:
126128
response = []
127129
else:
128130
raise
129-
logger.debug(f"Returning {len(response)} jobs")
131+
logger.debug(f"Returning {len(response)} assigned jobs")
130132
return response
131133

132134
def upload_file(self, path: Path, attribution: str, file_license: str) -> dict[str, dict[str, str]]:
133135
"""Upload a file."""
134-
# is this an image?
135-
extension = path.suffix[1:]
136-
for extensions in self.settings["filetypes"]["images"].values():
137-
if extension.lower() in extensions:
138-
# this file has the extension of a supported image
139-
logger.debug(f"Extension {extension} is supported...")
136+
# get mimetype
137+
with path.open("rb") as fh:
138+
mimetype = magic.from_buffer(fh.read(2048), mime=True)
139+
140+
# find filetype (image, video, audio or document) from mimetype
141+
for filetype in self.settings["filetypes"]:
142+
if mimetype in self.settings["filetypes"][filetype]:
140143
break
141144
else:
142-
# file type not supported
143-
raise ValueError(f"{path.suffix}")
144-
145-
# get image dimensions
146-
with Image.open(path) as image:
147-
rotated = ImageOps.exif_transpose(image) # creates a copy with rotation normalised
148-
logger.debug(
149-
f"Image has exif rotation info, using post-rotate size {rotated.size} instead of raw size {image.size}"
145+
# unsupported mimetype
146+
logger.error(
147+
f"Mimetype {mimetype} is not supported by this BMA server. Supported types {self.settings['filetypes']}"
150148
)
151-
width, height = rotated.size
152-
153-
with path.open("rb") as fh:
154-
mimetype = magic.from_buffer(fh.read(2048), mime=True)
149+
raise ValueError(mimetype)
150+
151+
if filetype == "image":
152+
# get image dimensions
153+
with Image.open(path) as image:
154+
rotated = ImageOps.exif_transpose(image) # creates a copy with rotation normalised
155+
if rotated is None:
156+
raise ValueError("Rotation")
157+
logger.debug(
158+
f"Image has exif rotation info, using post-rotate size {rotated.size}"
159+
f"instead of raw size {image.size}"
160+
)
161+
width, height = rotated.size
155162

156163
# open file
157164
with path.open("rb") as fh:
@@ -160,10 +167,15 @@ def upload_file(self, path: Path, attribution: str, file_license: str) -> dict[s
160167
data = {
161168
"attribution": attribution,
162169
"license": file_license,
163-
"width": width,
164-
"height": height,
165170
"mimetype": mimetype,
166171
}
172+
if filetype == "image":
173+
data.update(
174+
{
175+
"width": width,
176+
"height": height,
177+
}
178+
)
167179
# doit
168180
r = self.client.post(
169181
self.base_url + "/api/v1/json/files/upload/",
@@ -172,18 +184,47 @@ def upload_file(self, path: Path, attribution: str, file_license: str) -> dict[s
172184
)
173185
return r.json()
174186

175-
def handle_job(self, job: dict[str, str], orig: Path) -> tuple[Image.Image, Image.Exif]:
176-
"""Do the thing and return the result."""
187+
def handle_job(self, job: dict[str, str], orig: Path) -> None:
188+
"""Do the thing and upload the result."""
189+
result: JobResult
190+
# get the result of the job
177191
if job["job_type"] == "ImageConversionJob":
178-
return self.handle_image_conversion_job(job=job, orig=orig)
179-
if job["job_type"] == "ImageExifExtractionJob":
180-
return self.get_exif(orig)
181-
logger.error(f"Unsupported job type {job['job_type']}")
182-
return None
192+
result = self.handle_image_conversion_job(job=job, orig=orig)
193+
filename = job["job_uuid"] + "." + job["filetype"].lower()
194+
elif job["job_type"] == "ImageExifExtractionJob":
195+
result = self.get_exif(fname=orig)
196+
filename = "exif.json"
197+
else:
198+
logger.error(f"Unsupported job type {job['job_type']}")
199+
200+
self.write_and_upload_result(job=job, result=result, filename=filename)
201+
202+
def write_and_upload_result(self, job: dict[str, str], result: JobResult, filename: str) -> None:
203+
"""Encode and write the job result to a buffer, then upload."""
204+
with BytesIO() as buf:
205+
if job["job_type"] == "ImageConversionJob":
206+
image, exif = result
207+
if not isinstance(image, Image.Image) or not isinstance(exif, Image.Exif):
208+
raise ValueError("Fuck")
209+
# apply format specific encoding options
210+
kwargs = {}
211+
if job["mimetype"] in self.settings["encoding"]["images"]:
212+
# this format has custom encoding options, like quality/lossless, apply them
213+
kwargs.update(self.settings["encoding"]["images"][job["mimetype"]])
214+
logger.debug(f"Format {job['mimetype']} has custom encoding settings, kwargs is now: {kwargs}")
215+
else:
216+
logger.debug(f"No custom settings for format {job['mimetype']}")
217+
image.save(buf, format=job["filetype"], exif=exif, **kwargs)
218+
elif job["job_type"] == "ImageExifExtractionJob":
219+
logger.debug(f"Got exif data {result}")
220+
buf.write(json.dumps(result).encode())
221+
else:
222+
logger.error("Unsupported job type")
223+
raise RuntimeError(job["job_type"])
224+
self.upload_job_result(job_uuid=uuid.UUID(job["job_uuid"]), buf=buf, filename=filename)
183225

184-
def handle_image_conversion_job(self, job: dict[str, str], orig: Path) -> tuple[Image.Image, Image.Exif]:
226+
def handle_image_conversion_job(self, job: dict[str, str], orig: Path) -> ImageConversionJobResult:
185227
"""Handle image conversion job."""
186-
# load original image
187228
start = time.time()
188229
logger.debug(f"Opening original image {orig}...")
189230
image = Image.open(orig)
@@ -193,29 +234,33 @@ def handle_image_conversion_job(self, job: dict[str, str], orig: Path) -> tuple[
193234

194235
logger.debug("Rotating image (if needed)...")
195236
start = time.time()
196-
image = ImageOps.exif_transpose(image) # creates a copy with rotation normalised
237+
ImageOps.exif_transpose(image, in_place=True) # creates a copy with rotation normalised
238+
if image is None:
239+
raise ValueError("NoImage")
197240
orig_ar = Fraction(*image.size)
198-
logger.debug(f"Rotating image took {time.time() - start} seconds, image is now {image.size} original AR is {orig_ar}")
241+
logger.debug(
242+
f"Rotating image took {time.time() - start} seconds, image is now {image.size} original AR is {orig_ar}"
243+
)
199244

200245
logger.debug("Getting exif metadata from image...")
201246
start = time.time()
202247
exif = image.getexif()
203248
logger.debug(f"Getting exif data took {time.time() - start} seconds")
204249

205-
size = job["width"], job["height"]
250+
size = int(job["width"]), int(job["height"])
206251
ratio = Fraction(*size)
207252

208-
if job['custom_aspect_ratio']:
209-
orig = "custom"
253+
if job["custom_aspect_ratio"]:
254+
orig_str = "custom"
210255
else:
211-
orig = "original"
256+
orig_str = "original"
212257
if orig_ar != ratio:
213-
orig += "(ish)"
214-
logger.debug(f"Desired image size is {size}, aspect ratio: {ratio} ({orig}), converting image...")
258+
orig_str += "(ish)"
259+
logger.debug(f"Desired image size is {size}, aspect ratio: {ratio} ({orig_str}), converting image...")
215260
start = time.time()
216261
# custom AR or not?
217-
if job['custom_aspect_ratio']:
218-
image = ImageOps.fit(image, size)
262+
if job["custom_aspect_ratio"]:
263+
image = ImageOps.fit(image, size) # type: ignore[assignment]
219264
else:
220265
image.thumbnail(size)
221266
logger.debug(f"Converting image size and AR took {time.time() - start} seconds")
@@ -243,7 +288,7 @@ def upload_job_result(self, job_uuid: uuid.UUID, buf: "BytesIO", filename: str)
243288
logger.debug(f"Done, it took {t} seconds to upload {size} bytes, speed {round(size/t)} bytes/sec")
244289
return r.json()
245290

246-
def get_exif(self, fname: Path) -> dict[str, dict[str, str]]:
291+
def get_exif(self, fname: Path) -> ExifExtractionJobResult:
247292
"""Return a dict with exif data as read by exifread from the file.
248293
249294
exifread returns a flat dict of key: value pairs where the key
@@ -253,7 +298,7 @@ def get_exif(self, fname: Path) -> dict[str, dict[str, str]]:
253298
"""
254299
with fname.open("rb") as f:
255300
tags = exifread.process_file(f, details=True)
256-
grouped = {}
301+
grouped: dict[str, dict[str, str]] = {}
257302
for tag, value in tags.items():
258303
if tag in SKIP_EXIF_TAGS:
259304
logger.debug(f"Skipping exif tag {tag}")

0 commit comments

Comments
 (0)