diff --git a/.gitignore b/.gitignore index a8b6431..b60b0de 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,4 @@ scratch # pycharm .idea/ + diff --git a/Dockerfile b/Dockerfile index da209af..ed361a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,39 @@ -FROM python:3.10-slim-bullseye +FROM python:3.13-slim-trixie +COPY --from=ghcr.io/astral-sh/uv:0.8.13 /uv /uvx /bin/ -ENV KAKADU_APPS_LOCATION s3://dlcs-dlcservices-bootstrap-objects/kdu77-apps.tar.gz -ENV APPETISER_DIR /opt/appetiser -ENV TMPDIR $APPETISER_DIR/tmp -ENV OUTPUT_DIR $APPETISER_DIR/out/ -ENV HTTP_TIMEOUT 60 +ENV APPETISER_DIR=/opt/appetiser +ENV TMPDIR=$APPETISER_DIR/tmp +ENV OUTPUT_DIR=$APPETISER_DIR/out/ -RUN apt-get update -y && apt-get install -y cmake \ - netpbm \ - ghostscript \ - libffi-dev \ - libjpeg-turbo-progs \ - libtiff5-dev \ - libjpeg62-turbo-dev \ - zlib1g-dev \ - libfreetype6-dev \ - liblcms2-dev \ - libwebp-dev \ - tcl8.6-dev \ - tk8.6-dev \ - python3-tk \ - libharfbuzz-dev \ - libfribidi-dev +RUN apt-get update -y && apt-get install -y \ + cmake=3.31.6-2 \ + netpbm=2:11.10.02-1 \ + ghostscript=10.05.1~dfsg-1 \ + libffi-dev=3.4.8-2 \ + libjpeg-turbo-progs=1:2.1.5-4 \ + libtiff5-dev=4.7.0-3 \ + libjpeg62-turbo-dev=1:2.1.5-4 \ + zlib1g-dev=1:1.3.dfsg+really1.3.1-1+b1 \ + #libfreetype6-dev \ + liblcms2-dev=2.16-2 \ + libwebp-dev=1.5.0-0.1 \ + tcl8.6-dev=8.6.16+dfsg-1 \ + tk8.6-dev=8.6.16-1 \ + python3-tk=3.13.5-1\ + libharfbuzz-dev=10.2.0-1+b1 \ + libfribidi-dev=1.0.16-1 -COPY requirements.txt $APPETISER_DIR/requirements.txt -RUN pip3 install --no-cache-dir -r $APPETISER_DIR/requirements.txt +COPY ./appetiser/ $APPETISER_DIR/appetiser/ +COPY ./pyproject.toml $APPETISER_DIR +COPY ./uv.lock $APPETISER_DIR +COPY ./run_appetiser.sh $APPETISER_DIR -COPY . $APPETISER_DIR -RUN chmod +x $APPETISER_DIR/run_tests.sh RUN chmod +x $APPETISER_DIR/run_appetiser.sh LABEL org.opencontainers.image.source=https://github.com/dlcs/appetiser RUN mkdir $TMPDIR $OUTPUT_DIR WORKDIR $APPETISER_DIR +RUN uv sync --locked --no-dev -EXPOSE 80 -CMD $APPETISER_DIR/run_appetiser.sh +CMD ["./run_appetiser.sh"] diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 8a6df33..0000000 --- a/Jenkinsfile +++ /dev/null @@ -1,36 +0,0 @@ -node('linux-python') { - container('python-imaging-buildkit') { - def rev - - stage('checkout scm') { - rev = checkout(scm) - } - - stage('Run pre-commit') { - runPreCommit() - } - stage('Build docker-image') { - dockerBuild() - } - - stage('Publish image') { - withCredentials([usernamePassword(credentialsId: "dockerhub-digiratidlcs", usernameVariable: 'registryUsername', passwordVariable: 'registryPassword')]) { - dockerPush(registryUsername, registryPassword, rev.GIT_COMMIT) - } - } - } -} - -def runPreCommit() { - sh "pre-commit run --all-files" -} - -def dockerBuild() { - sh "docker build -t digirati/appetiser:latest ." -} - -def dockerPush(def registryUsername, def registryPassword, def buildNumber) { - sh "docker login --username \"${registryUsername}\" --password \"${registryPassword}\"" - sh "docker tag digirati/appetiser:latest digirati/appetiser:${buildNumber}" - sh "docker push digirati/appetiser:${buildNumber}" -} diff --git a/README.md b/README.md index 4452077..5033b2d 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,57 @@ Replacement for jp2iser/tizer. Converts various image resources into JPEG2000 and a collection of thumbnails. -## Getting Started -The easiest way to get this running is using Docker. +## Dependencies -```bash -# build -docker build -t appetiser:latest . +### Kakadu -# run -docker run -it --rm -p 5080:80 appetiser:latest +Appetiser is dependent on being able to access the compiled binaries for Kakadu as a tarball. In production it is expected that this will be an S3 file location that appetiser has access to at run-time, set with the environment variable: + +```sh +KDU_BINARIES=s3://bucket/path-/to/kdu/binaries.tar ``` -Appetiser needs access to Kakadu binaries. The default location of these binaries is specified by the `KAKADU_APPS_LOCATION` environment variable. Relevant AWS credentials will need to be passed to the docker container. +This is downloaded to the location `/kdu_src/kdu.tar` within the running container. convert -You will also need a volume mount for reading/writing images. Files are not passed as part of the request, instead a `source` parameter points to the file on disk. +For local development using docker compose, the `./local_data/kdu_src` directory is mapped to `/kdu_src` within the appetiser container. A copy of the Kakadu binaries should be placed in the `./local_data/kdu_src/` directory, and symlinked to `./local_data/kdu_src/kdu.tar` -e.g. -```bash -docker run -it --rm -p 5080:80 \ --e KAKADU_APPS_LOCATION=s3://my-s3-bucket/kdu77-apps.tar.gz \ --e AWS_ACCESS_KEY_ID=mykey \ --e AWS_SECRET_ACCESS_KEY=mysecretkey \ --v /path/to/scratch:/scratch \ -appetiser:latest -``` +### Python +Python dependencies for appetiser are managed using [uv](https://docs.astral.sh/uv/). This is the case both for app dependencies, that are installed by uv in the docker image, and development dependencies, that are used for testing and tooling. -### Docker Compose +Development and testing dependencies should be added using the `--dev` flag, e.g. -There is a docker-compose file to ease running above, see .env.dist for example .env file. +```sh +uv add --dev httpx +``` -```bash -docker-compose up +Although the app is containerised, it will be necessary to install the project dependencies for local development via: +```sh +uv sync ``` -## Converting an Image +### OS + +Pillow has a number of OS level dependencies for dealing with various image formats that are installed in the Docker image. As we're installing Pillow through `uv` (previously `pip`) rather than `apt` this is required to ensure support for the required image formats. These seem to correspond with the [External libraries](https://pillow.readthedocs.io/en/stable/installation/building-from-source.html) in the Pillow docs. + +## Running locally -Make a POST to `/convert` to start conversion process. The source image must be in a folder location that is accessible to the appetiser application. +A [docker-compose.yml](./docker-compose.yml) file is provided for local development. This contains a number of volume mappings for local files, so the following must exist in the local directory: -Sample payload (all folder locations are relative to `/opt/appetiser/`): +- `./local_data/kdu_src`: Directory containing the Kakadu binaries as described [above](#kakadu). gitingored. +- `./local_data/output`: Output directory mapped to `/test_output` and used in API examples and tests. gitignored. -```json -{ - "imageId": "test-appetiser", - "jobId": "test-appetiser_job", - "source": "/scratch/test-image.jpg", - "thumbSizes": [ 30, 100, 400 ], - "operation": "ingest", - "optimisation": "kdu_med", - "origin": "my_origin", - "destination": "/scratch/out/the_converted.jp2", - "thumbDir": "/scratch/out/thumbnails/" -} +## Running tests + +Integration tests can be found in [./tests](./tests) and should be run with: +```sh +uv run pytest ``` -> Note that the destination _must_ end in "jp2" or kdu_compress call will fail. \ No newline at end of file +These tests are dependent on `pytest-docker`, and use the [./tests/docker-compose.test.yml](./tests/docker-compose.test.yml) file. + + +## API Documentation + +OpenAPI docs for the appetiser API can be found at [http://localhost:8000/docs](http://localhost:8000/docs) on a locally running instance of appetiser. These provide documentation and examples for the `convert/` endpoint, along with expected types. + diff --git a/app/jp2/__init__.py b/app/jp2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/jp2/convert.py b/app/jp2/convert.py deleted file mode 100644 index 3ea6b9b..0000000 --- a/app/jp2/convert.py +++ /dev/null @@ -1,154 +0,0 @@ -import logging -import shutil -import typing -import pathlib - -from PIL import ( - Image, -) - -from .image import ( - is_tile_optimised_jp2, - get_img_info, - prepare_source_file, - resize_and_save_img -) - -from .kdu import ( - kdu_compress, - kdu_expand_to_image -) - -from .settings import ( - OUTPUT_DIR -) - -logger = logging.getLogger(__name__) - - -def _error(message: str = 'Unknown operation', **kwargs) -> dict: - return { - 'status': 'error', - 'message': message - } - - -def _format_paths(source: str, dest: str = '') -> typing.Tuple[pathlib.Path, pathlib.Path]: - """ Casts provided pair of paths to pathlib.Path, and generates - the output path from the source if not provided. - """ - source_path = pathlib.Path(source) - if dest: - dest_path = pathlib.Path(dest) - else: - dest_path = OUTPUT_DIR.joinpath( - source_path.stem).with_suffix('.jp2') - return source_path, dest_path - - -def _format_derivative_info(img: Image, dest_path: pathlib.Path) -> dict: - logger.debug('%s: formatting thumbnail information', dest_path) - return { - 'path': str(dest_path), - 'width': img.width, - 'height': img.height - } - - -def _make_derivatives(source_path: pathlib.Path, derivative_sizes: [int], output_dir: pathlib.Path) -> list: - """ Manages the creation of derivatives (only thumbnails at present) for a given JPEG200 file, - returning information about the derivatives and where they're located. - """ - src_img = kdu_expand_to_image(source_path) - derivative_info = [] - for size in sorted(derivative_sizes, reverse=True): - dest_path = output_dir / '{}_{}.jpg'.format(source_path.stem, size) - img = resize_and_save_img(src_img, size, dest_path) - derivative_info.append( - _format_derivative_info(img, dest_path) - ) - return derivative_info - - -def _ingest_image(source_path: pathlib.Path, dest_path: pathlib.Path, optimisation: str) -> (pathlib.Path, dict): - """ Manages the initial conversion of the provided source image file to a - tile optimised JPEG2000 file using the provide kdu optimisation type. - """ - logger.debug('%s: Preparing file for ingest.', source_path) - prepared_source_path, image_info = prepare_source_file(source_path) - if is_tile_optimised_jp2(prepared_source_path): - logger.debug('%s: Already a JPEG2000, copying to %s.', - source_path, dest_path) - shutil.copy(source_path, dest_path) - return dest_path, image_info - else: - image_mode = image_info.get('mode') - logger.debug('%s: Being used for conversion to JPEG2000, with colour mode: %s', - prepared_source_path, image_mode) - kdu_compress(prepared_source_path, dest_path, optimisation, image_mode) - return dest_path, image_info - - -def _image_only_operation(source_path: pathlib.Path, - dest_path: pathlib.Path, - kakadu_optimisation: str = 'kdu_med', - **kwargs) -> dict: - ingested_path, image_info = _ingest_image( - source_path, dest_path, kakadu_optimisation) - - return { - 'jp2': str(ingested_path), - 'height': image_info.get('height'), - 'width': image_info.get('width'), - } - - -def _derivatives_operation(source_path: pathlib.Path, - thumbnail_dir: pathlib.Path, # TODO: shouldn't need to provide thumbnail dir - thumbnail_sizes: [int], **kwargs) -> dict: - """ Only - """ - if not is_tile_optimised_jp2(source_path): - return _error( - 'File type must be JPEG2000 for derivatives-only operation.' - ) - else: - _, image_info = get_img_info(source_path) - derivative_info = _make_derivatives( - source_path, thumbnail_sizes, thumbnail_dir) - return { - 'jp2': str(source_path), - 'height': image_info.get('height'), - 'width': image_info.get('width'), - 'thumbs': derivative_info - } - - -def _ingest_operation(source_path: pathlib.Path, - dest_path: pathlib.Path, - thumbnail_dir: pathlib.Path, # TODO: shouldn't need to provide thumbnail_dir - thumbnail_sizes: typing.List = list(), - kakadu_optimisation: str = 'kdu_med', - image_id='ID') -> dict: - ingested_path, image_info = _ingest_image( - source_path, dest_path, kakadu_optimisation) - derivative_info = _make_derivatives( - ingested_path, thumbnail_sizes, thumbnail_dir) - return { - 'jp2': str(ingested_path), - 'height': image_info.get('height'), - 'width': image_info.get('width'), - 'thumbs': derivative_info - } - - -def process(**kwargs) -> dict: - - OPERATIONS = { - 'ingest': _ingest_operation, - 'derivatives-only': _derivatives_operation, - 'image-only': _image_only_operation - } - op_func = OPERATIONS.get(kwargs.pop('operation', 'error'), _error) - result = op_func(**kwargs) - return result diff --git a/app/jp2/image.py b/app/jp2/image.py deleted file mode 100644 index 4be371a..0000000 --- a/app/jp2/image.py +++ /dev/null @@ -1,256 +0,0 @@ -import decimal -import io -import logging -import pathlib - -from PIL import ( - Image, - ImageCms, -) - -from .kdu import image_modes - -logger = logging.getLogger(__name__) - -# Allows images of any size to be processed without raising a -# warning or an error. -Image.MAX_IMAGE_PIXELS = None - - -def is_tile_optimised_jp2(filepath: pathlib.Path) -> bool: - """ Determines if a file is a JPEG2000 and whether it is optimised. - TODO: check for optimisation rather than just based on extension. - """ - return filepath.suffix == '.jp2' - - -def _extract_img_info(img: Image) -> dict: - """ Extract a properties from a PIL Image and store in a dict - for use as part of subsequent processing. - """ - logger.debug('%s: mode - %s, width - %s, height - %s', - img.filename, img.mode, img.width, img.height) - return { - 'mode': img.mode, - 'height': img.height, - 'width': img.width - } - - -def get_img_info(filepath: pathlib.Path) -> (pathlib.Path, dict): - """ For file formats that don't require conversion or other preparation - before being converted to JPEG2000, this will open the file - and get the image height, width and mode for use in the conversion - process. - """ - logger.debug('%s: file does not require preparation', filepath) - with Image.open(filepath) as img: - img_info = _extract_img_info(img) - return filepath, img_info - - -def _convert_tiff_mode(filepath: pathlib.Path, img: Image, img_info: dict) -> (pathlib.Path, dict): - """ - Image is already a TIFF but with a mode that we don't have KDU commands for - Convert it to RGB + ensure XResolution + YResolution tags are set - """ - x_resolution_tag = 282 - y_resolution_tag = 283 - - x_res = img.tag_v2.get(x_resolution_tag, -1) - y_res = img.tag_v2.get(y_resolution_tag, -1) - - img = _convert_img_colour_profile(img, img.filename) - tiff_filepath = filepath.parent / ('raw_' + filepath.name) - - image_data = io.BytesIO() - - # write data to BytesIO and read again to get as PIL.TiffImagePlugin.TiffImageFile - img.save(image_data, format='TIFF', compression=None) - with Image.open(image_data, formats=['TIFF']) as new_img: - if not new_img.tag_v2.get(x_resolution_tag, None): - logger.debug('%s: has no XResolution tag, setting from original %s', filepath, x_res) - new_img.tag_v2[x_resolution_tag] = x_res - - if not new_img.tag_v2.get(y_resolution_tag, None): - logger.debug('%s: has no YResolution tag, setting from original %s', filepath, y_res) - new_img.tag_v2[y_resolution_tag] = y_res - - logger.debug('%s: saving as raw to %s', filepath, tiff_filepath) - new_img.save(tiff_filepath, compression=None) - img_info['mode'] = new_img.mode - - return tiff_filepath, img_info - - -def _uncompress_tiff(filepath: pathlib.Path) -> (pathlib.Path, dict): - """ Checks whether a tiff file has been saved with compression, - and if so, will save an uncompressed version under a new name. - While the image is open gets the image mode for use in the - conversion process. - """ - with Image.open(filepath) as img: - img_info = _extract_img_info(img) - compression = img.info.get('compression') - if compression != 'raw': - logger.debug( - '%s: uses compression method %s', filepath, compression) - tiff_filepath = filepath.parent / ('raw_' + filepath.name) - logger.debug('%s: saving as raw to %s', filepath, tiff_filepath) - img.save(tiff_filepath, compression=None) - filepath = tiff_filepath - - img_mode = img_info.get('mode') - if img_mode not in image_modes: - logger.debug('%s: has unknown image mode: %s', filepath, img_mode) - return _convert_tiff_mode(filepath, img, img_info) - - return filepath, img_info - - -def _correct_img_orientation(img: Image, img_filename: str = '') -> Image: - """ If the image has EXIF data, and the orientation of the image in the EXIF - data is anything - """ - EXIF_ORIENTATION_TAG = 274 - EXIF_TRANSPOSE_OPERATIONS = [ - [], - [], - [Image.FLIP_LEFT_RIGHT], - [Image.ROTATE_180], - [Image.FLIP_TOP_BOTTOM], - [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90], - [Image.ROTATE_270], - [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90], - [Image.ROTATE_90], - ] - orientation = 0 - if hasattr(img, '_getexif'): - exif = img._getexif() - if exif: - logger.debug( - '%s: extracting orientation from EXIF data', img_filename) - orientation = exif.get(EXIF_ORIENTATION_TAG, 0) - logger.debug('%s: EXIF orientation %s', - img_filename, orientation) - - for transpose_operation in EXIF_TRANSPOSE_OPERATIONS[orientation]: - logger.debug('%s: applying transpose operation %s', - img_filename, transpose_operation) - img = img.transpose(transpose_operation) - - return img - - -def _convert_img_colour_profile(img: Image, img_filename: str = '') -> Image: - """ If the image has ICC profile information, apply a transformation to this - image from that ICC colour profile to the sRGB colour profile. - """ - img_colour_profile_bytes = img.info.get('icc_profile') - if img_colour_profile_bytes: - logger.debug( - '%s: extracting embedded colour profile', img_filename) - img_colour_profile = ImageCms.ImageCmsProfile( - io.BytesIO(img_colour_profile_bytes)) - img_colour_profile_name = ImageCms.getProfileName(img_colour_profile) - logger.debug('%s: icc colour profile %s', img_filename, - img_colour_profile_name) - sRGB_profile = ImageCms.createProfile('sRGB') - logger.debug('%s: converting colour profile from %s to %s', - img_filename, - img_colour_profile_name, - sRGB_profile.profile_description - ) - img = ImageCms.profileToProfile( - img, img_colour_profile, sRGB_profile, outputMode='RGB') - - return img - - -def _convert_img_to_tiff(filepath: pathlib.Path) -> (pathlib.Path, dict): - """ Attempts to open a file with Pillow, correct image orienation, - set the colour profile, and save the image as a tiff. - While the image is open gets the image mode for use in the - conversion process. - """ - with Image.open(filepath) as img: - img_filename = img.filename - img_info = _extract_img_info(img) - img = _correct_img_orientation(img, img_filename) - img = _convert_img_colour_profile(img, img_filename) - tiff_filepath = filepath.parent.joinpath( - 'raw_' + filepath.stem).with_suffix('.tiff') - logger.debug('%s: saving as raw to %s', - filepath, tiff_filepath) - img.save(tiff_filepath, compression=None) - return tiff_filepath, img_info - - -def _rasterise_pdf(filepath: pathlib.Path) -> pathlib.Path: - pass - - -def prepare_source_file(filepath: pathlib.Path) -> (pathlib.Path, dict): - """ Prepares a source file for processing by kakadu by establishing the - format and converting as required. - From kdu_compress -usage: - "Currently accepted image file formats are: TIFF (including BigTIFF), - RAW (big-endian), RAWL (little-endian), BMP, PBM, PGM and PPM (including - PNM files with sample precisions from 1 to 16), and PFM (i.e. floating - point files), as determined by the file suffix. - """ - img_file_funcs = { - '.bmp': get_img_info, - '.raw': get_img_info, - '.pbm': get_img_info, - '.pgm': get_img_info, - '.ppm': get_img_info, - '.jp2': get_img_info, # TODO convert to tiff if JP2 not tile-ready. - '.pdf': _rasterise_pdf, - '.tif': _uncompress_tiff, - '.tiff': _uncompress_tiff, - } - f = img_file_funcs.get(filepath.suffix.lower(), _convert_img_to_tiff) - logger.debug('%s() to be applied to %s', f.__name__, filepath) - return f(filepath) - - -def _scale_dimensions_to_fit(width: int, height: int, req_width: int, req_height: int) -> (int, int): - """ For a given width and height, scale these such that they will fit within - the required height and width by reducing them by an appropriate scale - factor. - Setting the precision of the Decimal context _may_ be included to allow - for parity with .net components that use this precision (i.e. no off-by-one - scaling issues). - """ - if width <= req_width and height <= req_height: - logger.debug('(%s, %s): Dimensions do not need scaling.') - return width, height - decimal.getcontext().prec = 17 - dec_width = decimal.Decimal(width) - dec_height = decimal.Decimal(height) - dec_req_width = decimal.Decimal(req_width) - dec_req_height = decimal.Decimal(req_height) - scale = min(dec_req_width/dec_width, dec_req_height/dec_height) - logger.debug('(%s, %s): to fit within (%s, %s) requires a scale factor of %s', - dec_width, dec_height, dec_req_width, dec_req_height, scale) - scaled_int_width = int((dec_width * scale).to_integral_exact()) - scaled_int_height = int((dec_height * scale).to_integral_exact()) - logger.debug('(%s, %s): scaled to (%s, %s)', dec_width, - dec_height, scaled_int_width, scaled_int_height) - return scaled_int_width, scaled_int_height - - -def resize_and_save_img(img: Image, size: int, dest_path: pathlib.Path) -> Image: - """ Resize a PIL Image so that it fits within a square of the provided size, - and saves this file to the provided dest_path. - Returns the Image to allow for progressive scaling down for multiple sizes, - which is significantly faster than scaling down from a full resolution image. - """ - scaled_width, scaled_height = _scale_dimensions_to_fit( - img.width, img.height, size, size) - img = img.resize((scaled_width, scaled_height), resample=Image.LANCZOS) - logger.debug('Image resized to (%s, %s)', scaled_width, scaled_height) - img.save(dest_path, quality=90) - logger.debug('Resized image saved to: %s', dest_path) - return img diff --git a/app/jp2/kdu.py b/app/jp2/kdu.py deleted file mode 100644 index 095bcda..0000000 --- a/app/jp2/kdu.py +++ /dev/null @@ -1,118 +0,0 @@ -import logging -import pathlib -import subprocess -import tempfile - -from PIL import ( - Image, -) - -from .settings import ( - KDU_COMPRESS, - KDU_EXPAND, - KDU_LIB -) - -logger = logging.getLogger(__name__) - -# Allows images of any size to be processed without raising a -# warning or an error. -Image.MAX_IMAGE_PIXELS = None - -image_modes = { - 'L': '-no_palette', - '1': '-no_palette', - 'I;16B': '-no_palette', - 'RGB': '-jp2_space sRGB', - 'RGBA': '-jp2_space sRGB -jp2_alpha' -} - - -def _run_kdu_command(kdu_command: str, env: dict): - try: - logger.debug('Running command: %s', kdu_command) - subprocess.run(kdu_command, - env=env, - shell=True, - check=True, - capture_output=True - ) - except subprocess.CalledProcessError as e: - logger.error(f"kdu command failed. Full output:{e.output}") - raise e - - -def kdu_compress(source_path: pathlib.Path, dest_path: pathlib.Path, - optimisation: str, image_mode: str) -> pathlib.Path: - """ Uses the kdu_compress command to convert a source image - (in BMP, RAW, PBM, PGM, PPM or TIFF formats) to a JPEG2000. - """ - - if image_mode not in image_modes: - logger.warning(f"image_mode '{image_mode}' is not in known list") - - kdu_compress_templates = { - 'kdu_low': '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' - ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 0.5' - ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' - '{{64,64}},{{32,32}},{{16,16}}"', - 'kdu_med': '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' - ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 2' - ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' - '{{64,64}},{{32,32}},{{16,16}}"', - 'kdu_med_layers': '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' - ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" Clayers=6 -rate 2' - ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' - '{{64,64}},{{32,32}},{{16,16}}"', - 'kdu_high': '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' - ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 4' - ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' - '{{64,64}},{{32,32}},{{16,16}}"', - 'kdu_max': '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' - ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate -' - ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' - '{{64,64}},{{32,32}},{{16,16}}"', - } - - compress_env = { - 'LD_LIBRARY_PATH': KDU_LIB, - 'PATH': KDU_COMPRESS - } - - kdu_compress_template = kdu_compress_templates.get( - optimisation, kdu_compress_templates['kdu_med']) - - kdu_compress_command = kdu_compress_template.format( - kdu_compress_path=KDU_COMPRESS, - input_path=source_path, - output_path=dest_path, - image_mode=image_modes.get(image_mode) - ) - _run_kdu_command(kdu_compress_command, compress_env) - - -def kdu_expand_to_image(filepath: pathlib.Path) -> Image: - """ Uses the kdu_expand command to decompress a JPEG2000 image to a PIL - Image. - #TODO - is `-reduce` required in kdu_expand call? - """ - - kdu_expand_template = '{kdu_expand_path} -i {input_path} -o {output_path} -quiet -num_threads 4' - - expand_env = { - 'LD_LIBRARY_PATH': KDU_LIB, - 'PATH': KDU_EXPAND - } - - with tempfile.TemporaryDirectory() as tmpdir: - logger.debug('Created tmpdir: %s', tmpdir) - output_file = pathlib.Path(tmpdir) / filepath.with_suffix('.bmp').name - logger.debug('Temporary output path: %s', output_file) - kdu_expand_command = kdu_expand_template.format( - kdu_expand_path=KDU_EXPAND, - input_path=filepath, - output_path=output_file - ) - _run_kdu_command(kdu_expand_command, expand_env) - logger.debug('%s: Opening file with PIL', output_file) - return Image.open(output_file) diff --git a/app/jp2/settings.py b/app/jp2/settings.py deleted file mode 100644 index 2ff2999..0000000 --- a/app/jp2/settings.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -import os -import pathlib - -logger = logging.getLogger(__name__) - - -def _get_path_from_env_var(env_var_name: str) -> pathlib.Path: - """ Retrieve a path from an environment variable, checking both that it - and the file or directory it points to are present. - """ - env_var = os.environ.get(env_var_name) - if not env_var: - raise NameError( - '{} is not present as an environment variable'.format(env_var_name)) - path = pathlib.Path(env_var) - if not path.exists(): - raise FileNotFoundError( - '{} is not an existing file or directory'.format(env_var)) - logger.info('%s has valid path %s', env_var_name, env_var) - return path - - -KDU_COMPRESS = _get_path_from_env_var('KDU_COMPRESS') -KDU_EXPAND = _get_path_from_env_var('KDU_EXPAND') -KDU_LIB = _get_path_from_env_var('KDU_LIB') -OUTPUT_DIR = _get_path_from_env_var('OUTPUT_DIR') diff --git a/app/json_utils.py b/app/json_utils.py deleted file mode 100644 index a58160b..0000000 --- a/app/json_utils.py +++ /dev/null @@ -1,142 +0,0 @@ -import base64 -import json -import logging -import pathlib -import typing - -logger = logging.getLogger(__name__) - - -def _b64_encode_json(json_data: typing.Any) -> bytes: - """ Dumps a json data structure to a string and encodes it using - Base64. - """ - logger.debug('Base64 encoding: %s', json_data) - return base64.b64encode(json.dumps(json_data).encode('utf-8')).decode('utf-8') - - -def _remap_dict_values(input_dict: dict, mappings: (tuple)) -> dict: - """ Undertakes the mapping of a value found at `from_key` in `input_dict` - to `to_key` in a new dictionary. If `func` is provided then that callable - will be applied to `value` - to be used for casting to a type or sanitising - the value. - `to_key` will not be created in the new dict if no value is found for `from_key` - in `input_dict`. - `mappings` should consist of an iterable of tuples in the form: - (from_key, to_key, func or None) - """ - result_dict = {} - for from_key, to_key, func in mappings: - value = input_dict.get(from_key) - if value: - logger.debug('%s has value "%s"', from_key, value) - if func: - logger.debug('%s being applied to %s', func, value) - value = func(value) - logger.debug('%s assigned value %s', to_key, value) - result_dict[to_key] = value - else: - logger.debug('%s not present in source dict.', from_key) - return result_dict - - -def _get_scale_factors(width: int, height: int, tile_size: int = 256) -> [int]: - """ Derives a set of resolution scaling factors for an image using a - given tile_size. - v. https://iiif.io/api/image/2.0/#image-information - """ - dimension = max(width, height) - logger.debug('Calculating scale factors tile_size %s within %s', - tile_size, dimension) - factors = [1] - while dimension > tile_size: - dimension //= 2 - factors.append(factors[-1] * 2) - logger.debug('Scale factors %s for tile_size %s within %s', - factors, tile_size, dimension) - return factors - - -def _iiif_image_info_json(image_id: str, height: int, width: int, tile_size: int = 256) -> dict: - """ Constructs a IIIF Image Information JSON response using - v. https://iiif.io/api/image/2.0/#image-information - """ - scale_factors = _get_scale_factors(width, height, tile_size) - logger.debug('Generating IIIF Image Information JSON for %s (%s, %s) %s', - image_id, width, height, scale_factors) - return { - '@context': 'http://iiif.io/api/image/2/context.json', - '@id': image_id, - 'protocol': 'http://iiif.io/api/image', - 'width': width, - 'height': height, - 'tiles': [{ - 'width': tile_size, - 'scaleFactors': scale_factors - }], - 'profile': [ - 'http://iiif.io/api/image/2/level1.json', - { - 'formats': ['jpg'], - 'qualities': ['native', 'color', 'gray'], - 'supports': [ - 'regionByPct', - 'sizeByForcedWh', - 'sizeByWh', - 'sizeAboveFull', - 'rotationBy90s', - 'mirroring', - 'gray' - ] - } - ] - } - - -def extract_process_kwargs(convert_request_json: dict) -> dict: - """ Extracts items from the initial response and formats them into - the kwargs required for the process function. - """ - _mappings = ( - ('source', 'source_path', pathlib.Path), - ('destination', 'dest_path', pathlib.Path), - ('thumbDir', 'thumbnail_dir', pathlib.Path), - ('thumbSizes', 'thumbnail_sizes', lambda x: list(map(int, x))), - ('optimisation', 'kakadu_optimisation', None), - ('imageId', 'image_id', None), - ('operation', 'operation', None) - ) - logger.debug('Extracting process kwargs.') - return _remap_dict_values(convert_request_json, _mappings) - - -def extract_response_items(convert_request_json: dict) -> dict: - """ Extracts items from the initial request that are to be included - in the response. - """ - _mappings = ( - ('jobId', 'jobId', None), - ('origin', 'origin', None), - ('optimisation', 'optimisation', None), - ('imageId', 'imageId', None), - ) - logger.debug('Extracting request items for inclusion the response.') - return _remap_dict_values(convert_request_json, _mappings) - - -def add_iiif_info_json(response: dict) -> dict: - """ Adds the IIIF Image Information to the response if there - is sufficient information to construct it. - """ - iiif_image_info_json_args = [ - response.get('imageId'), - response.get('height'), - response.get('width') - ] - if all(iiif_image_info_json_args): - logger.debug('%s: all args present to generate IIIF info json', - iiif_image_info_json_args[0]) - response['infoJson'] = _b64_encode_json( - _iiif_image_info_json(*iiif_image_info_json_args) - ) - return response diff --git a/appetiser.py b/appetiser.py deleted file mode 100644 index 53fa597..0000000 --- a/appetiser.py +++ /dev/null @@ -1,86 +0,0 @@ -import subprocess - -from app.json_utils import ( - extract_process_kwargs, - extract_response_items, - add_iiif_info_json -) -from app.jp2.convert import ( - process -) -import flask -import logging -import logging.config - -logging.config.dictConfig({ - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': {'default': { - 'format': '[%(asctime)s] %(levelname)s in %(name)s: %(message)s', - }}, - 'handlers': {'wsgi': { - 'class': 'logging.StreamHandler', - 'stream': 'ext://flask.logging.wsgi_errors_stream', - 'formatter': 'default' - }}, - 'root': { - 'level': 'DEBUG', - 'handlers': ['wsgi'] - } -}) - -appetiser = flask.Flask(__name__) - -# Import after setup of logging to allow inclusion of -# initialisation logging in wsgi stdout. - - -@appetiser.errorhandler(500) -def server_error(e): - return flask.jsonify( - status='server error', - message=str(e) - ), 500 - - -@appetiser.route('/ping', methods=['GET']) -def ping(): - return flask.jsonify(status='working') - - -@appetiser.route('/convert', methods=['POST']) -def convert(): - json_data = flask.request.get_json() - appetiser.logger.info('Processing request data: %s', json_data) - try: - result = process(**extract_process_kwargs(json_data)) - proto_reponse = { - **result, - **extract_response_items(json_data) - } - response = add_iiif_info_json(proto_reponse) - appetiser.logger.info('Response: %s', json_data) - return flask.jsonify(response) - except FileNotFoundError as fileError: - appetiser.logger.exception('Error: %s', fileError) - return flask.jsonify( - status='file not found', - message=str(fileError) - ), 400 - except subprocess.CalledProcessError as subprocess_error: - appetiser.logger.exception('Error: %s', subprocess_error.stderr) - return flask.jsonify( - status='kakadu error', - message=str(subprocess_error.stderr) - ), 500 - except Exception as general_error: - appetiser.logger.exception('Error: %s', general_error) - - return flask.jsonify( - status='server error', - message=str(general_error) - ), 500 - - -if __name__ == '__main__': - appetiser.run(threaded=True, debug=True) diff --git a/app/__init__.py b/appetiser/__init__.py similarity index 100% rename from app/__init__.py rename to appetiser/__init__.py diff --git a/appetiser/convert/__init__.py b/appetiser/convert/__init__.py new file mode 100644 index 0000000..d35ee4c --- /dev/null +++ b/appetiser/convert/__init__.py @@ -0,0 +1,74 @@ +import logging +from .operations import ( + convert_image_to_jp2, + create_thumbnails, +) + +from .models import ( + ConvertOperation, + ConvertRequest, + ConvertResponse, +) +from .config import ConvertConfig + +logger = logging.getLogger(__name__) + + +def convert_image(config: ConvertConfig, request: ConvertRequest) -> dict: + ingested_path, image_info = convert_image_to_jp2( + config=config, + source=request.source, + destination=request.destination, + optimisation=request.optimisation, + ) + response_kwargs = { + "jp2": ingested_path, + "width": image_info.get("width"), + "height": image_info.get("height"), + } + return response_kwargs + + +def create_derivatives(config: ConvertConfig, request: ConvertRequest) -> dict: + image_info, thumbnail_info = create_thumbnails( + config=config, + source=request.source, + thumb_iiif_size=request.thumbIIIFSizes, + thumb_dir=request.thumbDir, + ) + response_kwargs = { + "width": image_info.get("width"), + "height": image_info.get("height"), + "thumbs": thumbnail_info, + } + return response_kwargs + + +def ingest(config: ConvertConfig, request: ConvertRequest) -> dict: + ingested_path, image_info = convert_image_to_jp2( + config=config, + source=request.source, + destination=request.destination, + optimisation=request.optimisation, + ) + _, derivative_info = create_thumbnails( + config=config, + source=ingested_path, + thumb_iiif_size=request.thumbIIIFSizes, + thumb_dir=request.thumbDir, + ) + response_kwargs = { + "jp2": ingested_path, + "width": image_info.get("width"), + "height": image_info.get("height"), + "thumbs": derivative_info, + } + + return response_kwargs + + +CONVERT_OPERATION_MAPPING = { + ConvertOperation.ingest: ingest, + ConvertOperation.image_only: convert_image, + ConvertOperation.derivatives_only: create_derivatives, +} diff --git a/appetiser/convert/config.py b/appetiser/convert/config.py new file mode 100644 index 0000000..3283fce --- /dev/null +++ b/appetiser/convert/config.py @@ -0,0 +1,10 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from pathlib import Path + + +class ConvertConfig(BaseSettings): + KDU_COMPRESS: Path + KDU_EXPAND: Path + KDU_LIB: Path + OUTPUT_DIR: Path + model_config = SettingsConfigDict(case_sensitive=True) diff --git a/appetiser/convert/image.py b/appetiser/convert/image.py new file mode 100644 index 0000000..bf28116 --- /dev/null +++ b/appetiser/convert/image.py @@ -0,0 +1,292 @@ +import decimal +import io +import logging +from pathlib import Path +from PIL import ( + Image, + ImageCms, +) +from PIL.Image import Image as PILImage + +from .kdu import IMAGE_MODES + +logger = logging.getLogger(__name__) + +# Allows images of any size to be processed without raising a +# warning or an error. +Image.MAX_IMAGE_PIXELS = None + + +def is_tile_optimised_jp2(filepath: Path) -> bool: + """Determines if a file is a JPEG2000 and whether it is optimised.""" + return filepath.suffix == ".jp2" + + +def _extract_img_info(img: PILImage) -> dict: + """Extract a properties from a PIL Image and store in a dict + for use as part of subsequent processing. + """ + logger.debug( + "%s: mode - %s, width - %s, height - %s", + img.filename, + img.mode, + img.width, + img.height, + ) + return {"mode": img.mode, "height": img.height, "width": img.width} + + +def get_img_info(filepath: Path) -> tuple[Path, dict]: + """For file formats that don't require conversion or other preparation + before being converted to JPEG2000, this will open the file + and get the image height, width and mode for use in the conversion + process. + """ + logger.debug("%s: file does not require preparation", filepath) + with Image.open(filepath) as img: + img_info = _extract_img_info(img) + return filepath, img_info + + +def _convert_tiff_mode( + filepath: Path, img: PILImage, img_info: dict +) -> tuple[Path, dict]: + """ + Image is already a TIFF but with a mode that we don't have KDU commands for + Convert it to RGB + ensure XResolution + YResolution tags are set + """ + x_resolution_tag = 282 + y_resolution_tag = 283 + + x_res = img.tag_v2.get(x_resolution_tag, -1) + y_res = img.tag_v2.get(y_resolution_tag, -1) + + img = _convert_img_colour_profile(img, img.filename) + tiff_filepath = filepath.parent / ("raw_" + filepath.name) + + image_data = io.BytesIO() + + # write data to BytesIO and read again to get as PIL.TiffImagePlugin.TiffImageFile + img.save(image_data, format="TIFF", compression=None) + with Image.open(image_data, formats=["TIFF"]) as new_img: + if not new_img.tag_v2.get(x_resolution_tag, None): + logger.debug( + "%s: has no XResolution tag, setting from original %s", filepath, x_res + ) + new_img.tag_v2[x_resolution_tag] = x_res + + if not new_img.tag_v2.get(y_resolution_tag, None): + logger.debug( + "%s: has no YResolution tag, setting from original %s", filepath, y_res + ) + new_img.tag_v2[y_resolution_tag] = y_res + + logger.debug("%s: saving as raw to %s", filepath, tiff_filepath) + new_img.save(tiff_filepath, compression=None) + img_info["mode"] = new_img.mode + + return tiff_filepath, img_info + + +def _uncompress_tiff(filepath: Path) -> tuple[Path, dict]: + """Checks whether a tiff file has been saved with compression, + and if so, will save an uncompressed version under a new name. + While the image is open gets the image mode for use in the + conversion process. + """ + with Image.open(filepath) as img: + img_info = _extract_img_info(img) + compression = img.info.get("compression") + if compression != "raw": + logger.debug("%s: uses compression method %s", filepath, compression) + tiff_filepath = filepath.parent / ("raw_" + filepath.name) + logger.debug("%s: saving as raw to %s", filepath, tiff_filepath) + img.save(tiff_filepath, compression=None) + filepath = tiff_filepath + + img_mode = img_info.get("mode") + if img_mode not in IMAGE_MODES: + logger.debug("%s: has unknown image mode: %s", filepath, img_mode) + return _convert_tiff_mode(filepath, img, img_info) + + return filepath, img_info + + +def _correct_img_orientation(img: PILImage, img_filename: str = "") -> PILImage: + """If the image has EXIF data, and the orientation of the image in the EXIF + data is anything + """ + EXIF_ORIENTATION_TAG = 274 + EXIF_TRANSPOSE_OPERATIONS = [ + [], + [], + [Image.FLIP_LEFT_RIGHT], + [Image.ROTATE_180], + [Image.FLIP_TOP_BOTTOM], + [Image.FLIP_LEFT_RIGHT, Image.ROTATE_90], + [Image.ROTATE_270], + [Image.FLIP_TOP_BOTTOM, Image.ROTATE_90], + [Image.ROTATE_90], + ] + orientation = 0 + if hasattr(img, "_getexif"): + exif = img._getexif() + if exif: + logger.debug("%s: extracting orientation from EXIF data", img_filename) + orientation = exif.get(EXIF_ORIENTATION_TAG, 0) + logger.debug("%s: EXIF orientation %s", img_filename, orientation) + + for transpose_operation in EXIF_TRANSPOSE_OPERATIONS[orientation]: + logger.debug( + "%s: applying transpose operation %s", img_filename, transpose_operation + ) + img = img.transpose(transpose_operation) + + return img + + +def _image_has_transparency(img: PILImage) -> bool: + """ + Checks if an image has transparency by first checking for a transparency property, + second examining the palette for transparent colors, + and third checking for an alpha component on RGBA images. + + https://stackoverflow.com/a/58567453 + """ + if img.info.get("transparency", None) is not None: + return True + if img.mode == "P": + transparent = img.info.get("transparency", -1) + for _, index in img.getcolors(): + if index == transparent: + return True + elif img.mode == "RGBA": + return True + + return False + + +def _convert_img_colour_profile(img: PILImage, img_filename: str = "") -> PILImage: + """If the image has ICC profile information, apply a transformation to this + image from that ICC colour profile to the sRGB colour profile. + """ + img_colour_profile_bytes = img.info.get("icc_profile") + if img_colour_profile_bytes: + logger.debug(f"Extracting embedded colour profile: {img_filename}") + img_colour_profile = ImageCms.ImageCmsProfile( + io.BytesIO(img_colour_profile_bytes) + ) + img_colour_profile_name = ImageCms.getProfileName(img_colour_profile) + logger.debug(f"Using icc colour profile: {img_colour_profile_name}") + sRGB_profile = ImageCms.createProfile("sRGB") + logger.debug( + f"Converting colour profile: {img_colour_profile_name} to {sRGB_profile.profile_description}, {img_filename}" + ) + output_mode = "RGBA" if _image_has_transparency(img) else "RGB" + img = ImageCms.profileToProfile( + img, img_colour_profile, sRGB_profile, outputMode=output_mode + ) + + return img + + +def _convert_img_to_tiff(filepath: Path) -> tuple[Path, dict]: + """Attempts to open a file with Pillow, correct image orienation, + set the colour profile, and save the image as a tiff. + While the image is open gets the image mode for use in the + conversion process. + """ + with Image.open(filepath) as img: + img_filename = img.filename + img_info = _extract_img_info(img) + img = _correct_img_orientation(img, img_filename) + img = _convert_img_colour_profile(img, img_filename) + tiff_filepath = filepath.parent.joinpath("raw_" + filepath.stem).with_suffix( + ".tiff" + ) + logger.debug("%s: saving as raw to %s", filepath, tiff_filepath) + img.save(tiff_filepath, compression=None) + return tiff_filepath, img_info + + +def _rasterise_pdf(filepath: Path) -> Path: + pass + + +def prepare_source_file(filepath: Path) -> tuple[Path, dict]: + """Prepares a source file for processing by kakadu by establishing the + format and converting as required. + From kdu_compress -usage: + "Currently accepted image file formats are: TIFF (including BigTIFF), + RAW (big-endian), RAWL (little-endian), BMP, PBM, PGM and PPM (including + PNM files with sample precisions from 1 to 16), and PFM (i.e. floating + point files), as determined by the file suffix. + """ + img_file_funcs = { + ".bmp": get_img_info, + ".raw": get_img_info, + ".pbm": get_img_info, + ".pgm": get_img_info, + ".ppm": get_img_info, + ".jp2": get_img_info, # TODO convert to tiff if JP2 not tile-ready. + ".pdf": _rasterise_pdf, + ".tif": _uncompress_tiff, + ".tiff": _uncompress_tiff, + } + f = img_file_funcs.get(filepath.suffix.lower(), _convert_img_to_tiff) + logger.debug("%s() to be applied to %s", f.__name__, filepath) + return f(filepath) + + +def scale_dimensions_to_fit( + width: int, height: int, req_width: int | None, req_height: int | None +) -> tuple[int, int]: + """For a given width and height, scale these such that they will fit within + the required height and width by reducing them by an appropriate scale + factor. + Setting the precision of the Decimal context _may_ be included to allow + for parity with .net components that use this precision (i.e. no off-by-one + scaling issues). + """ + decimal.getcontext().prec = 17 + + dec_width = decimal.Decimal(width) + dec_height = decimal.Decimal(height) + width_scale = decimal.Decimal(0.0) + if req_width: + dec_req_width = decimal.Decimal(req_width) + width_scale = dec_req_width / dec_width + + height_scale = decimal.Decimal(0.0) + if req_height: + dec_req_height = decimal.Decimal(req_height) + height_scale = dec_req_height / dec_height + + if not height_scale and width_scale: + height_scale = width_scale + if not width_scale and height_scale: + width_scale = height_scale + logger.debug( + f"Scale factor calculated as: ({width_scale=}, {height_scale=}, {width=}, {height=}, {req_width=}, {req_height=})", + ) + scaled_int_width = int((dec_width * width_scale).to_integral_exact()) + scaled_int_height = int((dec_height * height_scale).to_integral_exact()) + logger.debug( + f"Scaled image dimensions: ({dec_width=}, {dec_height=}, {scaled_int_width=}, {scaled_int_height=})", + ) + return scaled_int_width, scaled_int_height + + +def resize_and_save_img( + img: PILImage, width: int, height: int, dest_path: Path +) -> PILImage: + """Resize a PIL Image so that it fits within a square of the provided size, + and saves this file to the provided dest_path. + Returns the Image to allow for progressive scaling down for multiple sizes, + which is significantly faster than scaling down from a full resolution image. + """ + img = img.resize((width, height), resample=Image.LANCZOS) + logger.debug(f"Image resized: {width=}, {height=})") + img.save(dest_path, quality=90) + logger.debug(f"Resized image saved to: {dest_path}") + return img diff --git a/appetiser/convert/kdu.py b/appetiser/convert/kdu.py new file mode 100644 index 0000000..681222a --- /dev/null +++ b/appetiser/convert/kdu.py @@ -0,0 +1,119 @@ +import logging + +import subprocess +import tempfile + +from pathlib import Path +from PIL import ( + Image, +) +from PIL.Image import Image as PILImage + +from .models import KDUCompressOptimisation +from .config import ConvertConfig + +logger = logging.getLogger(__name__) + +# Allows images of any size to be processed without raising a +# warning or an error. +Image.MAX_IMAGE_PIXELS = None + +IMAGE_MODES = { + "L": "-no_palette", + "1": "-no_palette", + "I;16B": "-no_palette", + "RGB": "-jp2_space sRGB", + "RGBA": "-jp2_space sRGB -jp2_alpha", +} + + +KDU_COMPRESS_TEMPLATES = { + KDUCompressOptimisation.kdu_low: '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' + ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 0.5' + ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' + '{{64,64}},{{32,32}},{{16,16}}"', + KDUCompressOptimisation.kdu_med: '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' + ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 2' + ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' + '{{64,64}},{{32,32}},{{16,16}}"', + KDUCompressOptimisation.kdu_med_layers: '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' + ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" Clayers=6 -rate 2' + ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' + '{{64,64}},{{32,32}},{{16,16}}"', + KDUCompressOptimisation.kdu_high: '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' + ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate 4' + ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' + '{{64,64}},{{32,32}},{{16,16}}"', + KDUCompressOptimisation.kdu_max: '{kdu_compress_path} -i {input_path} -o {output_path} Clevels=7 "Cblk={{64,64}}"' + ' "Cuse_sop=yes" {image_mode} "ORGgen_plt=yes" "ORGtparts=R" "Corder=RPCL" -rate -' + ' "Cprecincts={{256,256}},{{256,256}},{{256,256}},{{128,128}},{{128,128}},{{64,64}},' + '{{64,64}},{{32,32}},{{16,16}}"', +} + + +def _run_kdu_command(kdu_command: str, env: dict): + try: + logger.debug("Running command: %s", kdu_command) + subprocess.run( + kdu_command, env=env, shell=True, check=True, capture_output=True + ) + except subprocess.CalledProcessError as e: + logger.error(f"kdu command failed. Full output:{e.output}") + raise e + + +def kdu_compress( + config: ConvertConfig, + source_path: Path, + dest_path: Path, + optimisation: KDUCompressOptimisation, + image_mode: str, +) -> Path: + """Uses the kdu_compress command to convert a source image + (in BMP, RAW, PBM, PGM, PPM or TIFF formats) to a JPEG2000. + """ + + if image_mode not in IMAGE_MODES: + raise ValueError( + f"image_mode '{image_mode}' is not in known list for kdu_compress" + ) + + compress_env = {"LD_LIBRARY_PATH": config.KDU_LIB, "PATH": config.KDU_COMPRESS} + + kdu_compress_template = KDU_COMPRESS_TEMPLATES.get( + optimisation, KDU_COMPRESS_TEMPLATES[KDUCompressOptimisation.kdu_med] + ) + + kdu_compress_command = kdu_compress_template.format( + kdu_compress_path=config.KDU_COMPRESS, + input_path=source_path, + output_path=dest_path, + image_mode=IMAGE_MODES.get(image_mode), + ) + _run_kdu_command(kdu_compress_command, compress_env) + return dest_path + + +def kdu_expand_to_image(config: ConvertConfig, source_path: Path) -> PILImage: + """Uses the kdu_expand command to decompress a JPEG2000 image to a PIL + Image. + """ + + kdu_expand_template = ( + "{kdu_expand_path} -i {input_path} -o {output_path} -quiet -num_threads 4" + ) + + expand_env = {"LD_LIBRARY_PATH": config.KDU_LIB, "PATH": config.KDU_EXPAND} + + with tempfile.TemporaryDirectory() as tmpdir: + logger.debug(f"Created temporary directory: {tmpdir=}") + output_file = Path(tmpdir) / source_path.with_suffix(".bmp").name + logger.debug(f"Temporary output path: {output_file=}") + kdu_expand_command = kdu_expand_template.format( + kdu_expand_path=config.KDU_EXPAND, + input_path=source_path, + output_path=output_file, + ) + _run_kdu_command(kdu_expand_command, expand_env) + logger.debug(f"Opening file with PIL: {output_file=}") + return Image.open(output_file) diff --git a/appetiser/convert/models.py b/appetiser/convert/models.py new file mode 100644 index 0000000..81a1e40 --- /dev/null +++ b/appetiser/convert/models.py @@ -0,0 +1,64 @@ +import re +from pydantic import ( + BaseModel, + StringConstraints, + FilePath, + DirectoryPath, +) +from enum import Enum +from pathlib import Path +from typing import ( + Annotated, + List, +) + +IIIF_SIZE_STR_PATTERN = re.compile( + r"^!(?P\d+),(?P\d+)$|^\^(?P\d+),(?P\d+)$|^(?P\d+),(?P\d+)$|^\^(?P\d+),$|^(?P\d+),$|^\^,(?P\d+)$|^,(?P\d+)$" +) + + +# Using an enum as the source of truth for the function mapping ensures that invalid operations +# are caught at the stage of model validation. +class ConvertOperation(str, Enum): + ingest = "ingest" + image_only = "image-only" + derivatives_only = "derivatives-only" + + +class KDUCompressOptimisation(str, Enum): + kdu_low = "kdu_low" + kdu_med = "kdu_med" + kdu_med_layers = "kdu_med_layers" + kdu_high = "kdu_high" + kdu_max = "kdu_max" + + +class ThumbInfo(BaseModel): + path: Path + width: int + height: int + + +class ConvertRequest(BaseModel): + jobId: str + imageId: str + origin: str + source: FilePath + operation: ConvertOperation + destination: Path | None = None + thumbDir: DirectoryPath | None = None + thumbIIIFSizes: List[ + Annotated[str, StringConstraints(pattern=IIIF_SIZE_STR_PATTERN)] + ] = [] + optimisation: KDUCompressOptimisation | None = None + + +class ConvertResponse(BaseModel): + jobId: str + imageId: str + origin: str + optimisation: KDUCompressOptimisation | None = None + jp2: FilePath = "" + height: int + width: int + thumbs: List[ThumbInfo] = [] diff --git a/appetiser/convert/operations.py b/appetiser/convert/operations.py new file mode 100644 index 0000000..8eeef97 --- /dev/null +++ b/appetiser/convert/operations.py @@ -0,0 +1,251 @@ +import logging +import shutil +from enum import Enum +from pathlib import Path +from typing import List + +from .kdu import ( + KDUCompressOptimisation, + kdu_compress, + kdu_expand_to_image, +) +from .image import ( + is_tile_optimised_jp2, + prepare_source_file, + scale_dimensions_to_fit, + resize_and_save_img, +) +from .models import ( + IIIF_SIZE_STR_PATTERN, + ThumbInfo, +) + + +from PIL import ( + Image, +) + +from .config import ConvertConfig + +logger = logging.getLogger(__name__) + + +def convert_image_to_jp2( + config: ConvertConfig, + source: Path, + destination: Path, + optimisation: KDUCompressOptimisation, +) -> tuple[Path, dict]: + """Manages the initial conversion of the provided source image file to a + tile optimised JPEG2000 file using the provide kdu optimisation type. + """ + prepared_source, image_info = prepare_source_file(source) + if is_tile_optimised_jp2(prepared_source): + logger.debug("Already a JPEG2000, copying: {source=} -> {destination=}") + shutil.copy(source, destination) + return destination, image_info + else: + image_mode = image_info.get("mode") + logger.debug( + "Converting with colour profile: {prepared_source=}, {image_mode=}" + ) + logger.debug( + "%s: Being used for conversion to JPEG2000, with colour mode: %s", + prepared_source, + image_mode, + ) + kdu_compress( + config=config, + source_path=prepared_source, + dest_path=destination, + optimisation=optimisation, + image_mode=image_mode, + ) + return destination, image_info + + +def _parse_iiif_size_str( + iiif_size_str: str, src_width: int, src_height: int +) -> tuple[(int | None), (int | None)]: + pattern_match = IIIF_SIZE_STR_PATTERN.match(iiif_size_str) + if not pattern_match: + raise ValueError(f"Invalid IIIF Size string: {iiif_size_str=}") + match_groups = pattern_match.groupdict() + logger.debug(f"Parsing: {iiif_size_str=}") + logger.debug(f"Source image size: {src_width}, {src_height}") + # iiif_size_str is like f"!{img_width},{img_height}" + # !w,h + # The extracted region is scaled so that the width and height of the returned image are not greater than w and h, while maintaining the aspect ratio. + # The returned image must be as large as possible but not larger than the extracted region, w or h, or server-imposed limits. + if match_groups["img_width"] and match_groups["img_height"]: + logger.debug("Match like `!w,h`") + img_width = int(match_groups["img_width"]) + img_height = int(match_groups["img_height"]) + logger.debug(f"Matched: {img_width=} {img_height=}") + if img_width >= src_width and img_height >= src_height: + width = None + height = None + else: + if src_width >= src_height: + width = img_width + height = None + else: + width = None + height = img_height + # iiif_size_str is like f"^{upscale_width},{upscale_height}" + # ^w,h + # The width and height of the returned image are exactly w and h. + # The aspect ratio of the returned image may be significantly different than the extracted region, resulting in a distorted image. + # If w and/or h are greater than the corresponding pixel dimensions of the extracted region, the extracted region is upscaled. + elif match_groups["upscale_width"] and match_groups["upscale_height"]: + logger.debug("Match like `^w,h`") + width = int(match_groups["upscale_width"]) + height = int(match_groups["upscale_height"]) + logger.debug(f"Matched: {width=} {height=}") + # iiif_size_str is like f"{width},{height}" + # w,h + # The width and height of the returned image are exactly w and h. + # The aspect ratio of the returned image may be significantly different than the extracted region, resulting in a distorted image. + # The values of w and h must not be greater than the corresponding pixel dimensions of the extracted region. + elif match_groups["width"] and match_groups["height"]: + logger.debug("Match like `w,h`") + width = int(match_groups["width"]) + height = int(match_groups["height"]) + logger.debug(f"Matched: {width=} {height=}") + if width >= src_width and height >= src_height: + width = None + height = None + elif width >= src_width: + width = src_width + elif height >= src_height: + height = src_height + # iiif_size_str is like f"^{upscale_just_width}," + # ^w, + # The extracted region should be scaled so that the width of the returned image is exactly equal to w. + # If w is greater than the pixel width of the extracted region, the extracted region is upscaled. + elif match_groups["upscale_just_width"]: + logger.debug("Match like `^w,`") + width = int(match_groups["upscale_just_width"]) + logger.debug(f"Matched: {width=}") + height = None + # iiif_size_str is like f"{just_width}," + # w, + # The extracted region should be scaled so that the width of the returned image is exactly equal to w. + # The value of w must not be greater than the width of the extracted region. + elif match_groups["just_width"]: + logger.debug("Match like `w,`") + just_width = int(match_groups["just_width"]) + logger.debug(f"Matched: {just_width=}") + width = just_width + if just_width >= src_width: + width = None + height = None + # iiif_size_str is like f"^,{upscale_just_height}" + # ^,h + # The extracted region should be scaled so that the height of the returned image is exactly equal to h. + # If h is greater than the pixel height of the extracted region, the extracted region is upscaled. + elif match_groups["upscale_just_height"]: + logger.debug("Match like `^,h`") + width = None + height = int(match_groups["upscale_just_height"]) + logger.debug(f"Matched: {height=}") + # iiif_size_str is like f",{just_height}" + # ,h + # The extracted region should be scaled so that the height of the returned image is exactly equal to h. + # The value of h must not be greater than the height of the extracted region. + elif match_groups["just_height"]: + logger.debug("Match like `,h`") + width = None + just_height = int(match_groups["just_height"]) + logger.debug(f"Matched: {just_height=}") + height = just_height + if just_height >= src_height: + height = None + else: + raise ValueError(f"Invalid IIIF Size string: {iiif_size_str=}") + + logger.debug(f"Parsed image size: ({height=}, {width=})") + return width, height + + +def _calculate_thumb_info( + iiif_size_str: str, src_width: int, src_height: int, thumb_dir: Path, file_name: str +) -> ThumbInfo | None: + + req_width, req_height = _parse_iiif_size_str( + iiif_size_str=iiif_size_str, src_width=src_width, src_height=src_height + ) + if req_width or req_height: + scaled_width, scaled_height = scale_dimensions_to_fit( + src_width, src_height, req_width, req_height + ) + dest_path = thumb_dir / f"{file_name}_{scaled_width}_{scaled_height}.jpg" + + return ThumbInfo( + path=dest_path, + width=scaled_width, + height=scaled_height, + ) + + else: + return None + + +def create_thumbnails( + config: ConvertConfig, + source: Path, + thumb_iiif_size: List[str], + thumb_dir: Path, +): + """Manages the creation of derivatives (only thumbnails at present) for a given image file, + returning information about the derivatives and where they're located. + """ + if source.suffix.lower() == ".jp2": + logger.debug(f"Converting JP2 image using kdu_expand: {source=}") + src_img = kdu_expand_to_image(config=config, source_path=source) + else: + logger.debug(f"Opening file with PIL: {source=}") + src_img = Image.open(source) + + src_img_info = {"width": src_img.width, "height": src_img.height} + thumbnail_info = [] + + calculated_thumb_info = list( + filter( + None, + [ + _calculate_thumb_info( + iiif_size_str=iiif_size_str, + src_width=src_img.width, + src_height=src_img.height, + thumb_dir=thumb_dir, + file_name=source.stem, + ) + for iiif_size_str in thumb_iiif_size + ], + ) + ) + # Deduplicate using the thumb path as a unique key + calculated_thumb_info = { + calc_thumb_info.path: calc_thumb_info + for calc_thumb_info in calculated_thumb_info + } + calculated_thumb_info = calculated_thumb_info.values() + + for calc_thumb_info in sorted( + calculated_thumb_info, reverse=True, key=lambda x: x.width + ): + img = resize_and_save_img( + img=src_img, + width=calc_thumb_info.width, + height=calc_thumb_info.height, + dest_path=calc_thumb_info.path, + ) + thumbnail_info.append( + ThumbInfo( + path=calc_thumb_info.path, + width=img.width, + height=img.height, + ) + ) + return src_img_info, thumbnail_info diff --git a/appetiser/main.py b/appetiser/main.py new file mode 100644 index 0000000..e4751b2 --- /dev/null +++ b/appetiser/main.py @@ -0,0 +1,133 @@ +import logging +import traceback +import fastapi +from typing import Annotated +from functools import lru_cache + +from .convert import ( + ConvertRequest, + ConvertResponse, + CONVERT_OPERATION_MAPPING, + ConvertConfig, +) + + +logging.basicConfig( + format="%(levelname)s - %(name)s.%(funcName)s - %(filename)s:%(lineno)d - %(message)s", + level=logging.DEBUG, +) + +logger = logging.getLogger(__name__) +app = fastapi.FastAPI(title="appetiser") + + +@lru_cache +def get_convert_config(): + return ConvertConfig() + + +@app.get("/ping") +def ping(): + """Healthcheck endpoint.""" + logger.debug("ping endpoint accessed.") + return {"status": "working"} + + +@app.get("/config") +def appetiser_config( + convert_config: Annotated[ConvertConfig, fastapi.Depends(get_convert_config)], +) -> ConvertConfig: + """Displays the env variables used for app configuration.""" + logger.debug("config endpoint accessed.") + return convert_config + + +@app.post("/convert") +def convert( + convert_config: Annotated[ConvertConfig, fastapi.Depends(get_convert_config)], + convert_request: Annotated[ + ConvertRequest, + fastapi.Body( + openapi_examples={ + "ingest": { + "summary": "Ingest operation", + "description": "Ingest of a jpg, creating a jp2 and thumbnails.", + "value": { + "imageId": "ingest_example_image", + "jobId": "ingest_example_image_job", + "origin": "ingest_example_image_origin", + "source": "/test_fixtures/Ms_W_102/fol_73r_detail.jpg", + "destination": "/test_output/fol_73r_detail.jp2", + "thumbIIIFSizes": ["100,", ",400", "!1000,1000"], + "thumbDir": "/test_output/", + "operation": "ingest", + "optimisation": "kdu_med", + }, + }, + "image-only": { + "summary": "Image-only operation", + "description": "Ingest of a tiff, creating a jp2, using the `kdu_max` optimisation.", + "value": { + "imageId": "image_only_example_image", + "jobId": "image_only_example_image_job", + "origin": "image_only_example_image_origin", + "source": "/test_fixtures/Ms_W_102/fol_73v_detail.tiff", + "destination": "/test_output/fol_73v_detail.tiff", + "operation": "image-only", + "optimisation": "kdu_max", + }, + }, + "derivatives-only": { + "summary": "Derivatives-only operation", + "description": "Ingest of a jpg, creating a thumbnails.", + "value": { + "imageId": "derivatives_only_example_image", + "jobId": "derivatives_only_example_image_job", + "origin": "derivatives_only_example_image_origin", + "source": "/test_fixtures/Ms_W_102/fol_73r_detail.jpg", + "thumbIIIFSizes": [ + "100,", + "250,", + ",500", + "!1000,1000", + "!2000,2000", + ], + "thumbDir": "/test_output/", + "operation": "derivatives-only", + }, + }, + } + ), + ], +) -> ConvertResponse: + logger.debug(f"convert endpoint request: {convert_request=}") + try: + operation_func = CONVERT_OPERATION_MAPPING[convert_request.operation] + logger.info(f"Mapped {convert_request.operation=} to {operation_func}") + response_kwargs = operation_func(config=convert_config, request=convert_request) + + copied_kwargs = { + "jobId": convert_request.jobId, + "origin": convert_request.origin, + "imageId": convert_request.imageId, + } + if convert_request.optimisation: + copied_kwargs["optimisation"] = convert_request.optimisation + + response = ConvertResponse( + **copied_kwargs, + **response_kwargs, + ) + return response + + except fastapi.HTTPException as e: + logger.error(e) + raise e + + except Exception as e: + logger.error(traceback.format_exc()) + logger.error(e) + raise fastapi.HTTPException( + status_code=500, + detail=str(e), + ) diff --git a/docker-compose.yml b/docker-compose.yml index 0f12dd5..8b4ea17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,12 @@ -version: '3' - services: appetiser: build: context: . dockerfile: Dockerfile ports: - - "5080:80" - env_file: - - .env + - "8000:8000" volumes: - - $HOME\Docker\scratch\appetiser:/scratch - #- $HOME\.aws:/root/.aws - # entrypoint: /opt/appetiser/run_tests.sh + - ./local_data/kdu_src:/kdu_src + - ./tests/fixtures:/test_fixtures + - ./local_data/output:/test_output + - ~\.aws:/root/.aws diff --git a/local_data/.gitignore b/local_data/.gitignore new file mode 100644 index 0000000..914143f --- /dev/null +++ b/local_data/.gitignore @@ -0,0 +1,2 @@ +kdu_src +output diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..25f40cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "appetiser" +version = "1.0.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "awscli>=1.42.19", + "fastapi[all,standard]>=0.116.2", + "pillow>=11.3.0", + "pyuwsgi>=2.0.30", +] + +[dependency-groups] +dev = [ + "pytest-docker>=3.2.3", + "pytest>=8.4.2", + "httpx>=0.28.1", +] + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra" +testpaths = [ + "tests", +] +norecursedirs = ["tests/_old_image_tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6a0c3b8..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -awscli==1.31.9 -Flask==2.3.2 -Pillow==10.1.0 -pytest==7.4.3 -pytest-mock==3.12.0 -uwsgi==2.0.23 diff --git a/run_appetiser.sh b/run_appetiser.sh old mode 100644 new mode 100755 index db684eb..455066c --- a/run_appetiser.sh +++ b/run_appetiser.sh @@ -1,12 +1,33 @@ -#!/bin/sh +#!/bin/bash -aws s3 cp $KAKADU_APPS_LOCATION /kdu-apps.tar.gz +export KDU_SRC_DIR=/kdu_src +export KDU_SRC=$KDU_SRC_DIR/kdu.tar -cd / && tar -xzvf /kdu-apps.tar.gz +# Test if KDU tar is already present +if [ ! -f $KDU_SRC ]; then + if [ ! -z $KDU_BINARIES ]; then + uv run aws s3 cp $KDU_BINARIES $KDU_SRC + else + echo "ERROR: KDU_BINARIES env variable not set." + exit 1 + fi +else + echo "Using existing kdu binaries tar file." +fi +tar -xf $KDU_SRC -C $KDU_SRC_DIR + +export KDU_LIB=/usr/local/lib/kdu export KDU_COMPRESS=/usr/local/bin/kdu_compress export KDU_EXPAND=/usr/local/bin/kdu_expand -export KDU_LIB=/usr/local/bin + +mkdir -p $KDU_LIB + +cp $KDU_SRC_DIR/kakadu-[0-9].[0-9].[0-9]/bin/Linux-x86-64-gcc/kdu_compress $KDU_COMPRESS +cp $KDU_SRC_DIR/kakadu-[0-9].[0-9].[0-9]/bin/Linux-x86-64-gcc/kdu_expand $KDU_EXPAND +cp $KDU_SRC_DIR/kakadu-[0-9].[0-9].[0-9]/lib/Linux-x86-64-gcc/* $KDU_LIB + +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$KDU_LIB cd $APPETISER_DIR -uwsgi --http 0.0.0.0:80 --master --need-app --module appetiser:appetiser --processes 5 --http-timeout $HTTP_TIMEOUT +uv run fastapi run appetiser/main.py --proxy-headers --port 8000 diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100644 index e4ead99..0000000 --- a/run_tests.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -aws s3 cp $KAKADU_APPS_LOCATION /kdu-apps.tar.gz - - -cd / && tar -xzvf /kdu-apps.tar.gz - -export KDU_COMPRESS=/usr/local/bin/kdu_compress -export KDU_EXPAND=/usr/local/bin/kdu_expand -export KDU_LIB=/usr/local/bin - -cd $APPETISER_DIR && pytest diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/0.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/0.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/0.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/0.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/1.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/1.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/1.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/1.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/2.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/2.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/2.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/2.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/3.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/3.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/3.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/3.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/4.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/4.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/4.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/4.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/5.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/5.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/5.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/5.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/6.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/6.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/6.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/6.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/7.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/7.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/7.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/7.jpeg diff --git a/tests/jp2/image/fixtures/_correct_img_orientation/8.jpeg b/tests/_old_image_tests/fixtures/_correct_img_orientation/8.jpeg similarity index 100% rename from tests/jp2/image/fixtures/_correct_img_orientation/8.jpeg rename to tests/_old_image_tests/fixtures/_correct_img_orientation/8.jpeg diff --git a/tests/jp2/image/test__correct_img_orientation.py b/tests/_old_image_tests/test__correct_img_orientation.py similarity index 100% rename from tests/jp2/image/test__correct_img_orientation.py rename to tests/_old_image_tests/test__correct_img_orientation.py diff --git a/tests/jp2/image/test__scale_dimensions_to_fit.py b/tests/_old_image_tests/test__scale_dimensions_to_fit.py similarity index 82% rename from tests/jp2/image/test__scale_dimensions_to_fit.py rename to tests/_old_image_tests/test__scale_dimensions_to_fit.py index 32f1fb7..4f2b8db 100644 --- a/tests/jp2/image/test__scale_dimensions_to_fit.py +++ b/tests/_old_image_tests/test__scale_dimensions_to_fit.py @@ -8,7 +8,8 @@ def test_scale_dimensions_to_fit_smaller(): height = 200 required = 200 assert (width, height) == _scale_dimensions_to_fit( - width, height, required, required) + width, height, required, required + ) def test_scale_dimensions_to_fit_half(): @@ -18,7 +19,8 @@ def test_scale_dimensions_to_fit_half(): expected_width = 50 expected_height = 100 assert (expected_width, expected_height) == _scale_dimensions_to_fit( - width, height, required, required) + width, height, required, required + ) def test_scale_dimensions_to_fit_half_width(): @@ -28,7 +30,8 @@ def test_scale_dimensions_to_fit_half_width(): expected_width = 100 expected_height = 50 assert (expected_width, expected_height) == _scale_dimensions_to_fit( - width, height, required, required) + width, height, required, required + ) def test_scale_dimensions_to_fit_third(): @@ -38,7 +41,8 @@ def test_scale_dimensions_to_fit_third(): expected_width = 33 expected_height = 100 assert (expected_width, expected_height) == _scale_dimensions_to_fit( - width, height, required, required) + width, height, required, required + ) def test_scale_dimensions_to_fit_third_round_up(): @@ -48,4 +52,5 @@ def test_scale_dimensions_to_fit_third_round_up(): expected_width = 34 expected_height = 100 assert (expected_width, expected_height) == _scale_dimensions_to_fit( - width, height, required, required) + width, height, required, required + ) diff --git a/tests/conftest.py b/tests/conftest.py index 735c651..41ad307 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,27 +1,65 @@ -import flask -import pathlib import pytest import tempfile +import pathlib +import collections +from collections.abc import ( + Generator, +) + +from .utils import ( + is_responsive_404, +) + +SharedHostContainerDirectory = collections.namedtuple( + "SharedHostContainerDirectory", ["host_path", "container_path"] +) + + +@pytest.fixture(scope="session") +def tests_dir(): + return pathlib.Path(__file__).resolve().parent + + +@pytest.fixture(scope="session") +def docker_compose_file(pytestconfig): + return pathlib.Path(__file__).resolve().parent / "docker-compose.test.yml" -from appetiser import appetiser +@pytest.fixture(scope="session") +def appetiser_service(docker_ip, docker_services): + """ + Ensure that Django service is up and responsive. + """ -@pytest.fixture -def appetiser_client() -> flask.Flask: - appetiser.config['TESTING'] = True - client = appetiser.test_client() - yield client + # `port_for` takes a container port and returns the corresponding host port + port = docker_services.port_for("appetiser", 8000) + url = "http://{}:{}".format(docker_ip, port) + url404 = f"{url}/missing" + docker_services.wait_until_responsive( + timeout=300.0, pause=0.1, check=lambda: is_responsive_404(url404) + ) + return url -@pytest.fixture -def fixtures_dir() -> pathlib.Path: - return pathlib.Path(__file__).parent / 'fixtures' +@pytest.fixture(scope="session") +def fixtures_dir(tests_dir) -> SharedHostContainerDirectory: + """Relies on mounting the local fixtures directory to the `/test_fixtures` location + in the `volumes` block of the docker-compose.test.yml file. + """ + return SharedHostContainerDirectory( + host_path=(tests_dir / "fixtures"), + container_path=pathlib.Path("/test_fixtures"), + ) -@pytest.fixture -def temp_dir() -> pathlib.Path: - """ Specifically to avoid using pytest's `tmpdir` which uses - py.path rather than pathlib. - """ - with tempfile.TemporaryDirectory() as tmpdir: - yield pathlib.Path(tmpdir) +@pytest.fixture(scope="session") +def output_dir(tests_dir) -> Generator[SharedHostContainerDirectory, None, None]: + """Relies on mounting a local output directory to the `/test_output` location + in the `volumes` block of the docker-compose.test.yml file. + """ + with tempfile.TemporaryDirectory(dir=(tests_dir / "output")) as tmpdir: + host_path = pathlib.Path(tmpdir) + yield SharedHostContainerDirectory( + host_path=host_path, + container_path=pathlib.Path(f"/test_output/{host_path.name}"), + ) diff --git a/tests/docker-compose.test.yml b/tests/docker-compose.test.yml new file mode 100644 index 0000000..e357504 --- /dev/null +++ b/tests/docker-compose.test.yml @@ -0,0 +1,11 @@ +services: + appetiser: + build: + context: .. + dockerfile: Dockerfile + ports: + - "8000:8000" + volumes: + - ../local_data/kdu_src:/kdu_src + - ./fixtures:/test_fixtures + - ./output:/test_output diff --git a/tests/fixtures/.gitignore b/tests/fixtures/.gitignore new file mode 100644 index 0000000..60d1371 --- /dev/null +++ b/tests/fixtures/.gitignore @@ -0,0 +1 @@ +raw* diff --git a/tests/fixtures/1280_752_landscape.jpg b/tests/fixtures/1280_752_landscape.jpg new file mode 100644 index 0000000..5afcca8 Binary files /dev/null and b/tests/fixtures/1280_752_landscape.jpg differ diff --git a/tests/fixtures/Ms_W_102/raw_fol_73r_detail.tiff b/tests/fixtures/Ms_W_102/raw_fol_73r_detail.tiff deleted file mode 100644 index c6d916c..0000000 Binary files a/tests/fixtures/Ms_W_102/raw_fol_73r_detail.tiff and /dev/null differ diff --git a/tests/fixtures/sloth.png b/tests/fixtures/sloth.png new file mode 100644 index 0000000..74d166c Binary files /dev/null and b/tests/fixtures/sloth.png differ diff --git a/tests/jp2/__init__.py b/tests/jp2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/jp2/image/__init__.py b/tests/jp2/image/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/jp2/test_convert.py b/tests/jp2/test_convert.py deleted file mode 100644 index 5f99b7b..0000000 --- a/tests/jp2/test_convert.py +++ /dev/null @@ -1,44 +0,0 @@ -import pathlib -from app.jp2.convert import ( - _error, - _format_paths, -) - - -def test_error_default(): - expected_error = { - 'status': 'error', - 'message': 'Unknown operation' - } - - assert _error() == expected_error - - -def test_error_message(): - message = 'A test message' - expected_error = { - 'status': 'error', - 'message': message - } - - assert _error(message) == expected_error - - -def test_format_paths_default(): - source = '/path/to/source.png' - dest = '/opt/appetiser/out/source.jp2' - source_path, dest_path = _format_paths(source) - assert isinstance(source_path, pathlib.Path) - assert isinstance(dest_path, pathlib.Path) - assert str(source_path) == source - assert str(dest_path) == dest - - -def test_format_paths_with_dest(): - source = '/path/to/source.png' - dest = '/path/to/a/dest.jp2' - source_path, dest_path = _format_paths(source, dest) - assert isinstance(source_path, pathlib.Path) - assert isinstance(dest_path, pathlib.Path) - assert str(source_path) == source - assert str(dest_path) == dest diff --git a/tests/jp2/test_kdu.py b/tests/jp2/test_kdu.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/jp2/test_mock_kdu.py b/tests/jp2/test_mock_kdu.py deleted file mode 100644 index 7de732e..0000000 --- a/tests/jp2/test_mock_kdu.py +++ /dev/null @@ -1,194 +0,0 @@ -import pathlib -import pytest -import subprocess - -import app.jp2.kdu -import app.jp2.settings - - -def test__run_kdu_command(mocker): - mocker.patch('subprocess.run') - command = '/path/to/kdu/command --and args' - env = {'ENVIRONMENTAL': 'VARIABLES'} - app.jp2.kdu._run_kdu_command(command, env) - subprocess.run.assert_called_once_with( - command, - env=env, - capture_output=True, - check=True, - shell=True - ) - - -def test__run_kdu_command_exception(mocker): - command = 'false' - env = {'ENVIRONMENTAL': 'VARIABLES'} - with pytest.raises(subprocess.CalledProcessError): - app.jp2.kdu._run_kdu_command(command, env) - - -def test_kdu_compress_kdu_low_rgb(mocker): - mocker.patch('subprocess.run') - mocker.patch('app.jp2.settings.KDU_COMPRESS', '/path/to/kdu_compress') - mocker.patch('app.jp2.settings.KDU_LIB', '/path/to/kdu_lib') - expected_command = '/path/to/kdu_compress -i /path/to/image.tiff -o /path/to/image.jp2'\ - ' Clevels=7 "Cblk={64,64}" "Cuse_sop=yes" -jp2_space sRGB "ORGgen_plt=yes"'\ - ' "ORGtparts=R" "Corder=RPCL" -rate 0.5 "Cprecincts={256,256},{256,256},'\ - '{256,256},{128,128},{128,128},{64,64},{64,64},{32,32},{16,16}"' - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_compress' - } - app.jp2.kdu.kdu_compress( - pathlib.Path('/path/to/image.tiff'), - pathlib.Path('/path/to/image.jp2'), - 'kdu_low', - 'RGB' - ) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) - - -def test_kdu_compress_kdu_med_rgba(mocker): - mocker.patch('subprocess.run') - mocker.patch.dict('os.environ', { - 'KDU_COMPRESS': '/path/to/kdu_compress', - 'KDU_LIB': '/path/to/kdu_lib', - }) - expected_command = '/path/to/kdu_compress -i /path/to/image.tiff -o /path/to/image.jp2'\ - ' Clevels=7 "Cblk={64,64}" "Cuse_sop=yes" -jp2_space sRGB -jp2_alpha "ORGgen_plt=yes"'\ - ' "ORGtparts=R" "Corder=RPCL" -rate 2 "Cprecincts={256,256},{256,256},'\ - '{256,256},{128,128},{128,128},{64,64},{64,64},{32,32},{16,16}"' - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_compress' - } - app.jp2.kdu.kdu_compress( - pathlib.Path('/path/to/image.tiff'), - pathlib.Path('/path/to/image.jp2'), - 'kdu_med', - 'RGBA' - ) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) - - -def test_kdu_compress_kdu_med_layers_rgb(mocker): - mocker.patch('subprocess.run') - mocker.patch.dict('os.environ', { - 'KDU_COMPRESS': '/path/to/kdu_compress', - 'KDU_LIB': '/path/to/kdu_lib', - }) - expected_command = '/path/to/kdu_compress -i /path/to/image.tiff -o /path/to/image.jp2'\ - ' Clevels=7 "Cblk={64,64}" "Cuse_sop=yes" -jp2_space sRGB "ORGgen_plt=yes"'\ - ' "ORGtparts=R" "Corder=RPCL" Clayers=6 -rate 2 "Cprecincts={256,256},{256,256},'\ - '{256,256},{128,128},{128,128},{64,64},{64,64},{32,32},{16,16}"' - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_compress' - } - app.jp2.kdu.kdu_compress( - pathlib.Path('/path/to/image.tiff'), - pathlib.Path('/path/to/image.jp2'), - 'kdu_med_layers', - 'RGB' - ) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) - - -def test_kdu_compress_kdu_high_l(mocker): - mocker.patch('subprocess.run') - mocker.patch.dict('os.environ', { - 'KDU_COMPRESS': '/path/to/kdu_compress', - 'KDU_LIB': '/path/to/kdu_lib', - }) - expected_command = '/path/to/kdu_compress -i /path/to/image.tiff -o /path/to/image.jp2'\ - ' Clevels=7 "Cblk={64,64}" "Cuse_sop=yes" -no_palette "ORGgen_plt=yes"'\ - ' "ORGtparts=R" "Corder=RPCL" -rate 4 "Cprecincts={256,256},{256,256},'\ - '{256,256},{128,128},{128,128},{64,64},{64,64},{32,32},{16,16}"' - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_compress' - } - app.jp2.kdu.kdu_compress( - pathlib.Path('/path/to/image.tiff'), - pathlib.Path('/path/to/image.jp2'), - 'kdu_high', - 'L' - ) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) - - -def test_kdu_compress_kdu_max_1(mocker): - mocker.patch('subprocess.run') - mocker.patch.dict('os.environ', { - 'KDU_COMPRESS': '/path/to/kdu_compress', - 'KDU_LIB': '/path/to/kdu_lib', - }) - expected_command = '/path/to/kdu_compress -i /path/to/image.tiff -o /path/to/image.jp2'\ - ' Clevels=7 "Cblk={64,64}" "Cuse_sop=yes" -no_palette "ORGgen_plt=yes"'\ - ' "ORGtparts=R" "Corder=RPCL" -rate - "Cprecincts={256,256},{256,256},'\ - '{256,256},{128,128},{128,128},{64,64},{64,64},{32,32},{16,16}"' - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_compress' - } - app.jp2.kdu.kdu_compress( - pathlib.Path('/path/to/image.tiff'), - pathlib.Path('/path/to/image.jp2'), - 'kdu_max', - '1' - ) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) - - -def test_kdu_expand_to_image(mocker): - mocker.patch('subprocess.run') - mocker.patch('PIL.Image.open') - mock_tmp = mocker.patch('tempfile.TemporaryDirectory') - mock_tmp.return_value.__enter__ = lambda x: '/tmp/path/to/' - mocker.patch.dict('os.environ', { - 'KDU_EXPAND': '/path/to/kdu_expand', - 'KDU_LIB': '/path/to/kdu_lib', - }) - expected_env = { - 'LD_LIBRARY_PATH': '/path/to/kdu_lib', - 'PATH': '/path/to/kdu_expand' - } - expected_command = '/path/to/kdu_expand -i /path/to/image.jp2 -o /tmp/path/to/image.bmp -quiet -num_threads 4' - app.jp2.kdu.kdu_expand_to_image(pathlib.Path('/path/to/image.jp2')) - subprocess.run.assert_called_once_with( - expected_command, - env=expected_env, - capture_output=True, - check=True, - shell=True - ) diff --git a/tests/test_appetiser.py b/tests/test_appetiser.py deleted file mode 100644 index 351fd75..0000000 --- a/tests/test_appetiser.py +++ /dev/null @@ -1,144 +0,0 @@ -from PIL import ( - Image, -) - - -def test_ping(appetiser_client): - response = appetiser_client.get('/ping') - assert response.json == {'status': 'working'} - - -def test_convert_null(appetiser_client): - post_json = {'dummy': 'data'} - expected_response = { - 'status': 'error', - 'message': 'Unknown operation', - } - - response = appetiser_client.post('/convert', json=post_json) - assert response.json == expected_response - - -def test_convert_invalid_operation(appetiser_client): - post_json = { - 'imageId': 'an_image_id', - 'jobId': 'a_job_id', - 'source': 'path/to/image.jpeg', - 'destination': 'path/to/image.jp2', - 'thumbDir': 'path/to/thumbnails/', - 'operation': 'an_invalid_operation', - 'optimisation': 'an_optimisation', - 'origin': 'an_origin', - } - - expected_response = { - 'status': 'error', - 'message': 'Unknown operation', - 'imageId': 'an_image_id', - 'jobId': 'a_job_id', - 'optimisation': 'an_optimisation', - 'origin': 'an_origin', - } - - response = appetiser_client.post('/convert', json=post_json) - assert response.json == expected_response - - -def _convert_test_runner( - appetiser_client, - temp_dir, - img_src, - optimisation, - thumb_sizes -): - img_name = img_src.stem - img_dest = temp_dir.joinpath(img_name).with_suffix('.jp2') - thumb_dir = temp_dir - post_json = { - 'imageId': img_name, - 'jobId': img_name + '_job', - 'source': str(img_src), - 'destination': str(img_dest), - 'thumbSizes': thumb_sizes, - 'thumbDir': str(thumb_dir), - 'operation': 'ingest', - 'optimisation': optimisation, - 'origin': img_name + '_origin', - } - orig_image = Image.open(img_src) - response = appetiser_client.post('/convert', json=post_json) - print(response.json) - assert response.json['imageId'] == img_name - assert response.json['jobId'] == img_name + '_job' - assert response.json['origin'] == img_name + '_origin' - assert response.json['jp2'] == str(img_dest) - assert response.json['height'] == orig_image.height - assert response.json['width'] == orig_image.width - if thumb_sizes: - for size, thumb_dict in zip(sorted(thumb_sizes), sorted(response.json['thumbs'], - key=lambda x: max(x['height'], x['width']))): - assert size == max(thumb_dict['height'], thumb_dict['width']) - assert str(thumb_dir.joinpath('{}_{}'.format( - img_name, size)).with_suffix('.jpg')) == thumb_dict['path'] - else: - assert response.json['thumbs'] == [] - - -def test_convert_jpg(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_73r_detail.jpg'), - 'kdu_med', - [30, 600, 400, 100] - ) - - -def test_convert_tiff(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_73v_detail.tiff'), - 'kdu_med', - [30, 600, 400, 100] - ) - - -def test_convert_bmp(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_74r_detail.bmp'), - 'kdu_med', - [30, 600, 400, 100] - ) - - -def test_convert_pbm(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_74v_detail.pbm'), - 'kdu_med', - [30, 600, 400, 100] - ) - - -def test_convert_pgm(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_75r_detail.pgm'), - 'kdu_med', - [30, 600, 400, 100] - ) - - -def test_convert_ppm(appetiser_client, fixtures_dir, temp_dir): - _convert_test_runner( - appetiser_client, - temp_dir, - fixtures_dir.joinpath('Ms_W_102/fol_75v_detail.ppm'), - 'kdu_med', - [30, 600, 400, 100] - ) diff --git a/tests/test_convert_derivatives_only.py b/tests/test_convert_derivatives_only.py new file mode 100644 index 0000000..87967ce --- /dev/null +++ b/tests/test_convert_derivatives_only.py @@ -0,0 +1,79 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_derivatives_only_jpg(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_derivatives_only_tiff(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73v_detail.tiff", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 567, + "height": 1000, + } + ], + ) + + +def test_convert_derivatives_only_bmp(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_74r_detail.bmp", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 707, + "height": 1000, + } + ], + ) + + +def test_convert_derivatives_only_png(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_78v_detail.png", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 795, + "height": 1000, + } + ], + ) diff --git a/tests/test_convert_image_only.py b/tests/test_convert_image_only.py new file mode 100644 index 0000000..b7ccf04 --- /dev/null +++ b/tests/test_convert_image_only.py @@ -0,0 +1,59 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_image_only_jpg(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_med", + operation="image-only", + thumb_iiif_sizes=None, + expected_thumb_sizes=None, + ) + + +def test_convert_image_only_tiff(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73v_detail.tiff", + expected_jp2_name="fol_73v_detail.jp2", + optimisation="kdu_med", + operation="image-only", + thumb_iiif_sizes=None, + expected_thumb_sizes=None, + ) + + +def test_convert_image_only_bmp(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_74r_detail.bmp", + expected_jp2_name="fol_74r_detail.jp2", + optimisation="kdu_med", + operation="image-only", + thumb_iiif_sizes=None, + expected_thumb_sizes=None, + ) + + +def test_convert_image_only_png(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_78v_detail.png", + expected_jp2_name="fol_78v_detail.jp2", + optimisation="kdu_med", + operation="image-only", + thumb_iiif_sizes=None, + expected_thumb_sizes=None, + ) diff --git a/tests/test_convert_image_transparency.py b/tests/test_convert_image_transparency.py new file mode 100644 index 0000000..4871a76 --- /dev/null +++ b/tests/test_convert_image_transparency.py @@ -0,0 +1,30 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_image_with_transparency(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="sloth.png", + expected_jp2_name="sloth.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!100,100", "500,", ",1000"], + expected_thumb_sizes=[ + { + "width": 100, + "height": 71, + }, + { + "width": 500, + "height": 353, + }, + { + "width": 1415, + "height": 1000, + }, + ], + ) diff --git a/tests/test_convert_ingest.py b/tests/test_convert_ingest.py new file mode 100644 index 0000000..26353f9 --- /dev/null +++ b/tests/test_convert_ingest.py @@ -0,0 +1,120 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_ingest_jpg(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_tiff(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73v_detail.tiff", + expected_jp2_name="fol_73v_detail.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 567, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_bmp(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_74r_detail.bmp", + expected_jp2_name="fol_74r_detail.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 707, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_png(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_78v_detail.png", + expected_jp2_name="fol_78v_detail.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 795, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_landscape_img(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="1280_752_landscape.jpg", + expected_jp2_name="1280_752_landscape.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=[ + "!100,100", + "!200,200", + "!400,400", + "!1024,1024", + "!1064,1064", + ], + expected_thumb_sizes=[ + { + "width": 1064, + "height": 625, + }, + { + "width": 1024, + "height": 602, + }, + { + "width": 400, + "height": 235, + }, + { + "width": 200, + "height": 118, + }, + { + "width": 100, + "height": 59, + }, + ], + ) diff --git a/tests/test_convert_ingest_optimisations.py b/tests/test_convert_ingest_optimisations.py new file mode 100644 index 0000000..981dba6 --- /dev/null +++ b/tests/test_convert_ingest_optimisations.py @@ -0,0 +1,98 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_ingest_kdu_low(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_low", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_kdu_med(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_med", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_kdu_med_layers(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_med_layers", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_kdu_high(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_high", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_ingest_kdu_max(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name="fol_73r_detail.jp2", + optimisation="kdu_max", + operation="ingest", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) diff --git a/tests/test_convert_thumbnails.py b/tests/test_convert_thumbnails.py new file mode 100644 index 0000000..24a9645 --- /dev/null +++ b/tests/test_convert_thumbnails.py @@ -0,0 +1,271 @@ +from .utils import ( + convert_test_runner, +) + + +def test_convert_thumbnails_height_width(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_thumbnails_width_only(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1000,"], + expected_thumb_sizes=[ + { + "width": 1000, + "height": 1490, + } + ], + ) + + +def test_convert_thumbnails_height_only(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=[",1000"], + expected_thumb_sizes=[ + { + "width": 671, + "height": 1000, + } + ], + ) + + +def test_convert_thumbnails_mix_1000(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["!1000,1000", "1000,", ",1000"], + expected_thumb_sizes=[ + { + "width": 1000, + "height": 1490, + }, + { + "width": 671, + "height": 1000, + }, + ], + ) + + +def test_convert_thumbnails_empty(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=[], + expected_thumb_sizes=[], + ) + + +def test_convert_thumbnails_none_feasible(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["10000,10000", "!5000,5000", "10000,", ",10000"], + expected_thumb_sizes=[], + ) + + +def test_convert_thumbnails_same_size(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1609,2398"], + expected_thumb_sizes=[], + ) + + +def test_convert_thumbnails_marginal_larger( + appetiser_service, fixtures_dir, output_dir +): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1610,2399"], + expected_thumb_sizes=[], + ) + + +def test_convert_thumbnails_marginal_smaller( + appetiser_service, fixtures_dir, output_dir +): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1608,2397"], + expected_thumb_sizes=[ + { + "width": 1608, + "height": 2397, + }, + ], + ) + + +def test_convert_thumbnails_smaller_same_height_width( + appetiser_service, fixtures_dir, output_dir +): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1000,1000", "!1000,1000", "^1000,1000"], + expected_thumb_sizes=[ + { + "width": 1000, + "height": 1000, + }, + { + "width": 671, + "height": 1000, + }, + ], + ) + + +def test_convert_thumbnails_smaller_different_height_width( + appetiser_service, fixtures_dir, output_dir +): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["1000,2000", "!1000,2000", "^1000,2000"], + expected_thumb_sizes=[ + { + "width": 1000, + "height": 2000, + }, + { + "width": 1342, + "height": 2000, + }, + ], + ) + + +def test_convert_thumbnails_upscale_width_height( + appetiser_service, fixtures_dir, output_dir +): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["^3000,3000"], + expected_thumb_sizes=[ + { + "width": 3000, + "height": 3000, + }, + ], + ) + + +def test_convert_thumbnails_upscale_width(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["^3000,"], + expected_thumb_sizes=[ + { + "width": 3000, + "height": 4471, + }, + ], + ) + + +def test_convert_thumbnails_upscale_height(appetiser_service, fixtures_dir, output_dir): + convert_test_runner( + appetiser_service=appetiser_service, + output_dir=output_dir, + fixtures_dir=fixtures_dir, + img_path="Ms_W_102/fol_73r_detail.jpg", + expected_jp2_name=None, + optimisation="kdu_med", + operation="derivatives-only", + thumb_iiif_sizes=["^,3000"], + expected_thumb_sizes=[ + { + "width": 2013, + "height": 3000, + }, + ], + ) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py new file mode 100644 index 0000000..98d52bc --- /dev/null +++ b/tests/test_endpoints.py @@ -0,0 +1,187 @@ +import httpx + + +TEST_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"} + + +def test_ping(appetiser_service): + endpoint = f"{appetiser_service}/ping" + # Setup test params + status = 200 + # Test JSON response + response = httpx.get(endpoint, headers=TEST_HEADERS) + response_json = response.json() + assert response.status_code == status + assert response_json == {"status": "working"} + + +def test_convert_empty_body(appetiser_service): + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 422 + post_json = {} + expected_response_json = { + "detail": [ + { + "type": "missing", + "loc": ["body", "jobId"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "imageId"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "origin"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "source"], + "msg": "Field required", + "input": {}, + }, + { + "type": "missing", + "loc": ["body", "operation"], + "msg": "Field required", + "input": {}, + }, + ] + } + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + assert response_json == expected_response_json + + +def test_convert_invalid_operation(appetiser_service, fixtures_dir): + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 422 + post_json = { + "imageId": "an_image_id", + "jobId": "a_job_id", + "origin": "an_origin", + "source": str(fixtures_dir.container_path / "Ms_W_102/fol_73r_detail.jpg"), + "operation": "fake_operation", + } + + expected_response_json = { + "detail": [ + { + "type": "enum", + "loc": ["body", "operation"], + "msg": "Input should be 'ingest', 'image-only' or 'derivatives-only'", + "input": "fake_operation", + "ctx": {"expected": "'ingest', 'image-only' or 'derivatives-only'"}, + }, + ] + } + + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + assert response_json == expected_response_json + + +def test_convert_invalid_optimisation(appetiser_service, fixtures_dir): + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 422 + post_json = { + "imageId": "an_image_id", + "jobId": "a_job_id", + "origin": "an_origin", + "source": str(fixtures_dir.container_path / "Ms_W_102/fol_73r_detail.jpg"), + "operation": "ingest", + "optimisation": "kdu_fake", + } + + expected_response_json = { + "detail": [ + { + "type": "enum", + "loc": ["body", "optimisation"], + "msg": "Input should be 'kdu_low', 'kdu_med', 'kdu_med_layers', 'kdu_high' or 'kdu_max'", + "input": "kdu_fake", + "ctx": { + "expected": "'kdu_low', 'kdu_med', 'kdu_med_layers', 'kdu_high' or 'kdu_max'" + }, + } + ] + } + + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + assert response_json == expected_response_json + + +def test_convert_non_existant_source(appetiser_service, fixtures_dir): + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 422 + post_json = { + "imageId": "an_image_id", + "jobId": "a_job_id", + "origin": "an_origin", + "source": str(fixtures_dir.container_path / "non_existant.jpg"), + "operation": "ingest", + "optimisation": "kdu_med", + } + + expected_response_json = { + "detail": [ + { + "type": "path_not_file", + "loc": ["body", "source"], + "msg": "Path does not point to a file", + "input": "/test_fixtures/non_existant.jpg", + }, + ] + } + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + assert response_json == expected_response_json + + +def test_convert_non_existant_thumdir(appetiser_service, fixtures_dir): + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 422 + post_json = { + "imageId": "an_image_id", + "jobId": "a_job_id", + "origin": "an_origin", + "source": str(fixtures_dir.container_path / "Ms_W_102/fol_73r_detail.jpg"), + "thumbDir": str(fixtures_dir.container_path / "non_existant_thumbnails_dir/"), + "operation": "ingest", + "optimisation": "kdu_med", + } + + expected_response_json = { + "detail": [ + { + "type": "path_not_directory", + "loc": ["body", "thumbDir"], + "msg": "Path does not point to a directory", + "input": "/test_fixtures/non_existant_thumbnails_dir", + }, + ] + } + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + assert response_json == expected_response_json diff --git a/tests/test_json_utils.py b/tests/test_json_utils.py deleted file mode 100644 index 89ec094..0000000 --- a/tests/test_json_utils.py +++ /dev/null @@ -1,190 +0,0 @@ -import pathlib - -from app.json_utils import ( - _b64_encode_json, - _remap_dict_values, - _get_scale_factors, - _iiif_image_info_json, - extract_process_kwargs, - extract_response_items, - add_iiif_info_json, -) - - -def test__b64_encode_json(): - assert 'e30=' == _b64_encode_json({}) - - -def test__remap_dict_values(): - def plus_one(x): - return x + 1 - - initial_dict = {'a': 1, 'b': 2, 'c': 3} - expected_dict = {'a': 2, 'y': 2, 'z': 4} - mapping = ( - ('a', 'a', lambda x: x + 1), - ('b', 'y', None), - ('c', 'z', plus_one) - ) - assert _remap_dict_values(initial_dict, mapping) == expected_dict - - -def test__get_scale_factors(): - scale_factors = _get_scale_factors(1, 2048) - assert scale_factors == [1, 2, 4, 8] - scale_factors = _get_scale_factors(2048, 1) - assert scale_factors == [1, 2, 4, 8] - scale_factors = _get_scale_factors(1, 2056) - assert scale_factors == [1, 2, 4, 8, 16] - - -def test__iiif_image_info_json(): - expected_image_info = { - '@context': 'http://iiif.io/api/image/2/context.json', - '@id': 'an_identifier', - 'protocol': 'http://iiif.io/api/image', - 'width': 2056, - 'height': 3000, - 'tiles': [{ - 'width': 256, - 'scaleFactors': [1, 2, 4, 8, 16] - }], - 'profile': [ - 'http://iiif.io/api/image/2/level1.json', - { - 'formats': ['jpg'], - 'qualities': ['native', 'color', 'gray'], - 'supports': [ - 'regionByPct', - 'sizeByForcedWh', - 'sizeByWh', - 'sizeAboveFull', - 'rotationBy90s', - 'mirroring', - 'gray' - ] - } - ] - } - image_info = _iiif_image_info_json( - 'an_identifier', - 3000, - 2056 - ) - assert expected_image_info == image_info - - -def test_extract_process_kwargs_none(): - request = { - 'destination': '', - 'imageId': '', - 'jobId': '', - 'operation': '', - 'optimisation': '', - 'origin': '', - 'source': '', - 'thumbDir': '', - 'thumbSizes': '', - } - expected_kwargs = {} - assert extract_process_kwargs(request) == expected_kwargs - assert extract_process_kwargs(expected_kwargs) == expected_kwargs - - -def test_extract_process_kwargs_paths(): - request = { - 'destination': '/path/to/image.jpeg', - 'imageId': '', - 'jobId': '', - 'operation': '', - 'optimisation': '', - 'origin': '', - 'source': '', - 'thumbDir': '/thumb/dir/', - 'thumbSizes': '', - } - expected_kwargs = { - 'dest_path': pathlib.Path('/path/to/image.jpeg'), - 'thumbnail_dir': pathlib.Path('/thumb/dir/') - } - assert extract_process_kwargs(request) == expected_kwargs - - -def test_extract_process_kwargs_sizes(): - request = { - 'thumbSizes': ['1', 2, '3', 4], - } - expected_kwargs = { - 'thumbnail_sizes': [1, 2, 3, 4] - } - assert extract_process_kwargs(request) == expected_kwargs - - -def test_extract_process_kwargs_full(): - request = { - 'destination': '/path/to/image.jp2', - 'imageId': 'an_image_id', - 'jobId': 'a_job_id', - 'operation': 'ingest', - 'optimisation': 'kdu_high', - 'origin': 'an_origin', - 'source': '/path/to/image.jpeg', - 'thumbDir': '/thumb/dir/', - 'thumbSizes': [100, 400, 800], - } - expected_kwargs = { - 'dest_path': pathlib.Path('/path/to/image.jp2'), - 'image_id': 'an_image_id', - 'operation': 'ingest', - 'kakadu_optimisation': 'kdu_high', - 'source_path': pathlib.Path('/path/to/image.jpeg'), - 'thumbnail_dir': pathlib.Path('/thumb/dir/'), - 'thumbnail_sizes': [100, 400, 800] - } - assert extract_process_kwargs(request) == expected_kwargs - - -def test_extract_response_items(): - request = { - 'destination': '/path/to/image.jpeg', - 'imageId': 'an_image_id', - 'jobId': 'a_job_id', - 'optimisation': 'an_optimisation', - 'origin': 'an_origin', - } - expected_response = { - 'imageId': 'an_image_id', - 'jobId': 'a_job_id', - 'optimisation': 'an_optimisation', - 'origin': 'an_origin', - } - assert extract_response_items(request) == expected_response - - -def test_add_iiif_info_json_null(): - response = {'imageId': 'an_identifier'} - expected_response = {'imageId': 'an_identifier'} - assert add_iiif_info_json(response) == expected_response - - -def test_add_iiif_info_json(): - response = { - 'imageId': 'an_identifier', - 'height': 300, - 'width': 400 - } - expected_response = { - 'imageId': 'an_identifier', - 'height': 300, - 'width': 400, - 'infoJson': 'eyJAY29udGV4dCI6ICJodHRwOi8vaWlpZi5pby9hcGkvaW1hZ2UvMi9jb250ZXh0' - 'Lmpzb24iLCAiQGlkIjogImFuX2lkZW50aWZpZXIiLCAicHJvdG9jb2wiOiAiaHR0' - 'cDovL2lpaWYuaW8vYXBpL2ltYWdlIiwgIndpZHRoIjogNDAwLCAiaGVpZ2h0Ijog' - 'MzAwLCAidGlsZXMiOiBbeyJ3aWR0aCI6IDI1NiwgInNjYWxlRmFjdG9ycyI6IFsx' - 'LCAyXX1dLCAicHJvZmlsZSI6IFsiaHR0cDovL2lpaWYuaW8vYXBpL2ltYWdlLzIv' - 'bGV2ZWwxLmpzb24iLCB7ImZvcm1hdHMiOiBbImpwZyJdLCAicXVhbGl0aWVzIjog' - 'WyJuYXRpdmUiLCAiY29sb3IiLCAiZ3JheSJdLCAic3VwcG9ydHMiOiBbInJlZ2lv' - 'bkJ5UGN0IiwgInNpemVCeUZvcmNlZFdoIiwgInNpemVCeVdoIiwgInNpemVBYm92' - 'ZUZ1bGwiLCAicm90YXRpb25CeTkwcyIsICJtaXJyb3JpbmciLCAiZ3JheSJdfV19', - } - assert add_iiif_info_json(response) == expected_response diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9b8177d --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,98 @@ +import httpx +from PIL import ( + Image, +) + + +def is_responsive_404(url): + """ + Hit a non existing url, expect a 404. Used to check the service is up as a fixture. + :param url: + :return: + """ + try: + response = httpx.get(url) + if response.status_code == 404: + return True + except httpx.RequestError: + return False + + +TEST_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"} + + +def convert_test_runner( + appetiser_service, + output_dir, + fixtures_dir, + img_path, + expected_jp2_name, + optimisation, + operation, + thumb_iiif_sizes, + expected_thumb_sizes, +): + + endpoint = f"{appetiser_service}/convert" + # Setup test params + status = 200 + + img_src = fixtures_dir.container_path / img_path + img_dest = None + if expected_jp2_name: + img_dest = output_dir.container_path / expected_jp2_name + + thumb_dir = output_dir.container_path + + post_json = { + "imageId": f"{img_src.name}", + "jobId": f"{img_src.stem}_job", + "origin": f"{img_src.stem}_origin", + "source": f"{img_src}", + "operation": operation, + "optimisation": optimisation, + } + if thumb_iiif_sizes: + post_json["thumbIIIFSizes"] = thumb_iiif_sizes + + post_json["thumbDir"] = f"{thumb_dir}" + if img_dest: + post_json["destination"] = str(img_dest) + orig_img_src = fixtures_dir.host_path / img_path + orig_image = Image.open(orig_img_src) + + expected_response_json = { + "imageId": f"{img_src.name}", + "jobId": f"{img_src.stem}_job", + "origin": f"{img_src.stem}_origin", + "height": orig_image.height, + "width": orig_image.width, + } + if img_dest: + expected_response_json["jp2"] = str(img_dest) + # Test JSON response + response = httpx.post(endpoint, headers=TEST_HEADERS, json=post_json) + response_json = response.json() + assert response.status_code == status + for key, expected_value in expected_response_json.items(): + assert response_json[key] == expected_value + + if thumb_iiif_sizes and expected_thumb_sizes: + sorted_response_thumbs = sorted( + response_json["thumbs"], key=lambda x: x["width"] + ) + sorted_expected_thumbs = sorted(expected_thumb_sizes, key=lambda x: x["width"]) + + assert len(sorted_response_thumbs) == len(sorted_expected_thumbs) + for response_thumb_info, expected_thumb_size in zip( + sorted_response_thumbs, sorted_expected_thumbs + ): + expected_width = expected_thumb_size["width"] + expected_height = expected_thumb_size["height"] + assert response_thumb_info["path"] == str( + thumb_dir / f"{img_src.stem}_{expected_width}_{expected_height}.jpg" + ) + assert response_thumb_info["width"] == expected_width + assert response_thumb_info["height"] == expected_height + else: + assert response_json.get("thumbs") == [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5117d26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1008 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 }, +] + +[[package]] +name = "appetiser" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "awscli" }, + { name = "fastapi", extra = ["all", "standard"] }, + { name = "pillow" }, + { name = "pyuwsgi" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-docker" }, +] + +[package.metadata] +requires-dist = [ + { name = "awscli", specifier = ">=1.42.19" }, + { name = "fastapi", extras = ["all", "standard"], specifier = ">=0.116.2" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "pyuwsgi", specifier = ">=2.0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-docker", specifier = ">=3.2.3" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "awscli" +version = "1.42.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "colorama" }, + { name = "docutils" }, + { name = "pyyaml" }, + { name = "rsa" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/95/d64ee38cf653a22d90fd7567654f7a8a1a09b24b472baae9b990f4e71f43/awscli-1.42.27.tar.gz", hash = "sha256:f9484865d43a67e66df488e0ede2560f5956dad69e3420dcc888277ce618c9dc", size = 1889333 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/22/870bd8bd0cefd9411d5adbcbb917ab14832e3b800124eb9f745501251eb9/awscli-1.42.27-py3-none-any.whl", hash = "sha256:452f3642af0fcfcba63fa3f81688481d95302e30e126c6cf429a0914d42bb6f4", size = 4663620 }, +] + +[[package]] +name = "botocore" +version = "1.40.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a1/c5c8223cb3ff4d7ada79845ed8f2b17108895e5c9c21ad0de31d1d9f3b16/botocore-1.40.27.tar.gz", hash = "sha256:f7cb28668751d85adc7f17929efa684640d8e2739800a86a05d4bc38f8443d1c", size = 14339872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/45/736eea55d9a6343bc1dba7826348655acbfceb47987444409276e8d555fc/botocore-1.40.27-py3-none-any.whl", hash = "sha256:47441c94913740f5c24abf3d7f6fa534f12705e4c6669cd2e8443f3b3ca9d7ca", size = 14012750 }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, +] + +[[package]] +name = "docutils" +version = "0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/330ea8d383eb2ce973df34d1239b3b21e91cd8c865d21ff82902d952f91f/docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", size = 2056383 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/69/e391bd51bc08ed9141ecd899a0ddb61ab6465309f1eb470905c0c8868081/docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc", size = 570472 }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, +] + +[[package]] +name = "fastapi" +version = "0.116.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670 }, +] + +[package.optional-dependencies] +all = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "orjson" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "ujson" }, + { name = "uvicorn", extra = ["standard"] }, +] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/00/db53323ce7cc00a15f07249aefcc8225531bf166156f89b9b7c293e60821/fastapi_cli-0.0.12.tar.gz", hash = "sha256:09cbc2d171639ce9e489c78ad8a439328993a8f4c9005d78d58498327492aa3a", size = 17742 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/30/decb294fbe94de83df7d2801c0dd1fdcc4643ab0ed4fece575362a4e909f/fastapi_cli-0.0.12-py3-none-any.whl", hash = "sha256:ec8f8b12080b5a29a08c7f15ba50649f59b5cd28646cb1737c511b319d0408e4", size = 11107 }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "orjson" +version = "3.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127 }, + { url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494 }, + { url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017 }, + { url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898 }, + { url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742 }, + { url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377 }, + { url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313 }, + { url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908 }, + { url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905 }, + { url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812 }, + { url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277 }, + { url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418 }, + { url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216 }, + { url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362 }, + { url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989 }, + { url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115 }, + { url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493 }, + { url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998 }, + { url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915 }, + { url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907 }, + { url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852 }, + { url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309 }, + { url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424 }, + { url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266 }, + { url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351 }, + { url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pydantic" +version = "2.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855 }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.10.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ba/4178111ec4116c54e1dc7ecd2a1ff8f54256cdbd250e576882911e8f710a/pydantic_extra_types-2.10.5.tar.gz", hash = "sha256:1dcfa2c0cf741a422f088e0dbb4690e7bfadaaf050da3d6f80d6c3cf58a2bad8", size = 138429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/1a/5f4fd9e7285f10c44095a4f9fe17d0f358d1702a7c74a9278c794e8a7537/pydantic_extra_types-2.10.5-py3-none-any.whl", hash = "sha256:b60c4e23d573a69a4f1a16dd92888ecc0ef34fb0e655b4f305530377fa70e7a8", size = 38315 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + +[[package]] +name = "pytest-docker" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/75/285187953062ebe38108e77a7919c75e157943fa3513371c88e27d3df7b2/pytest_docker-3.2.3.tar.gz", hash = "sha256:26a1c711d99ef01e86e7c9c007f69641552c1554df4fccb065b35581cca24206", size = 13452 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/e057e0d1de611ce1bbb26cccf07ddf56eb30a6f6a03aa512a09dac356e03/pytest_docker-3.2.3-py3-none-any.whl", hash = "sha256:f973c35e6f2b674c8fc87e8b3354b02c15866a21994c0841a338c240a05de1eb", size = 8585 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + +[[package]] +name = "pyuwsgi" +version = "2.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1c/f7b48d3c0587c1e70737f2d40c26b98b5b52e9b4179bc6507bc4b7ea7d04/pyuwsgi-2.0.30.tar.gz", hash = "sha256:1c9f226a9278f8a2ecc08d4445a7eb776bda53f44721c565c499131ebe5ff0ce", size = 877731 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/04/ff24d82e418148e30043305c2e17252ace4578c588bb39338c559387418a/pyuwsgi-2.0.30-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed7574812ead2bb6f9d150c4e3d0b57c514a65a9905145dbfabd3e0189a71887", size = 658121 }, + { url = "https://files.pythonhosted.org/packages/83/da/a372334b95c7992d71bfb78cc6fa548ec31555576bfa07497d752cdd2ff3/pyuwsgi-2.0.30-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8444c80c693f7949beea14d5ddaba39c8983d50b3f5c84868fbec65dc2233ef5", size = 589545 }, + { url = "https://files.pythonhosted.org/packages/dd/e9/8f0c2f3055bd00979d9f826037d082dee1c7e83d1e91c9d6d32a84d9cc6b/pyuwsgi-2.0.30-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1fe142fd163c4e03a1f415180cffdc444fd7b912b065bb10517ca44e923ba7e", size = 881781 }, + { url = "https://files.pythonhosted.org/packages/46/a8/31ff97b1bf95f35dba735d6c5efa701455e6ab75b2effddc068260d0e065/pyuwsgi-2.0.30-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92ca2ee6d2463ecdab9e1d548b28afab23c2222349d79520b071ef58431526fd", size = 940438 }, + { url = "https://files.pythonhosted.org/packages/16/75/77cc52a9d1e130ec75ed62330c2c2001d92be4673590ad89052d6ab29f73/pyuwsgi-2.0.30-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b8dfe829bdd5c1a2a8c7571fd4bec0575e335d340a84ec7c039a97c1f41d9a7", size = 889580 }, + { url = "https://files.pythonhosted.org/packages/8b/86/f9f62cb6bfb320500de1b6b38fa8181013e18361906f29680b2dbf100e0e/pyuwsgi-2.0.30-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79a0c37b66c9e4bdde920f016548ae536b1e3c144639bd9e0b65ab5509d27b9f", size = 862031 }, + { url = "https://files.pythonhosted.org/packages/fe/21/ad6f8b9ef59c49a476d39e8321efdf1dd58ab96852785800a5c5b94aee5e/pyuwsgi-2.0.30-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:842cdae10afccf7bc43b61800be2127f1ba1f4a340e29a9b756ed623f0079c62", size = 933329 }, + { url = "https://files.pythonhosted.org/packages/d7/98/f844cbb84303d3110a291f205b0befa854c2fa904ffeacd46d2f6f2c899e/pyuwsgi-2.0.30-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36cc5e86ee31aee0feb1e2f493f14623beb38df194dc74a5cd9634e4ccf0a7bb", size = 871160 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368 }, +] + +[[package]] +name = "rich-toolkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412 }, +] + +[[package]] +name = "rignore" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304 }, + { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799 }, + { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024 }, + { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430 }, + { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559 }, + { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947 }, + { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471 }, + { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912 }, + { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281 }, + { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414 }, + { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330 }, + { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381 }, + { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761 }, + { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876 }, + { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776 }, + { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442 }, + { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844 }, + { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456 }, + { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070 }, + { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598 }, + { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862 }, + { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002 }, + { url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454 }, + { url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663 }, +] + +[[package]] +name = "rsa" +version = "4.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/b5/475c45a58650b0580421746504b680cd2db4e81bc941e94ca53785250269/rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9", size = 39711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/93/0c0f002031f18b53af7a6166103c02b9c0667be528944137cc954ec921b3/rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2", size = 34505 }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712 }, +] + +[[package]] +name = "sentry-sdk" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/22/60fd703b34d94d216b2387e048ac82de3e86b63bc28869fb076f8bb0204a/sentry_sdk-2.38.0.tar.gz", hash = "sha256:792d2af45e167e2f8a3347143f525b9b6bac6f058fb2014720b40b84ccbeb985", size = 348116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/84/bde4c4bbb269b71bc09316af8eb00da91f67814d40337cc12ef9c8742541/sentry_sdk-2.38.0-py2.py3-none-any.whl", hash = "sha256:2324aea8573a3fa1576df7fb4d65c4eb8d9929c8fa5939647397a07179eef8d0", size = 370346 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736 }, +] + +[[package]] +name = "typer" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 }, +] + +[[package]] +name = "ujson" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435 }, + { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193 }, + { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603 }, + { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794 }, + { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363 }, + { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311 }, + { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783 }, + { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822 }, + { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753 }, + { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866 }, + { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363 }, + { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462 }, + { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246 }, + { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631 }, + { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877 }, + { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363 }, + { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394 }, + { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837 }, + { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837 }, + { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022 }, + { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111 }, + { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682 }, + { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759 }, + { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634 }, + { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547 }, + { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489 }, + { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035 }, + { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212 }, + { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500 }, + { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487 }, + { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859 }, + { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183 }, + { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004 }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671 }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772 }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789 }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551 }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420 }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950 }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706 }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814 }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820 }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194 }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349 }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836 }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343 }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916 }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582 }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752 }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436 }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016 }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727 }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864 }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626 }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744 }, + { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114 }, + { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879 }, + { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026 }, + { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917 }, + { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602 }, + { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758 }, + { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601 }, + { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936 }, + { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243 }, + { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073 }, + { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872 }, + { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877 }, + { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645 }, + { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584 }, + { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675 }, + { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363 }, + { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240 }, + { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607 }, + { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315 }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, +]