From f3d61b9370b4d95959b8df948c80fab5c70d4e2f Mon Sep 17 00:00:00 2001 From: Jake Pullen Date: Sat, 1 Nov 2025 10:01:56 +0000 Subject: [PATCH] batman --- .gitignore | 14 ++ .python-version | 1 + README.md | 0 batch_nimrod.py | 71 ++++++++ nimrod_3.py | 448 ++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 9 + uv.lock | 40 +++++ 7 files changed, 583 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 batch_nimrod.py create mode 100644 nimrod_3.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef1e330 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +dat_files/* +asc_files/* +*.tar.gz \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/batch_nimrod.py b/batch_nimrod.py new file mode 100644 index 0000000..30be8f3 --- /dev/null +++ b/batch_nimrod.py @@ -0,0 +1,71 @@ +from nimrod_3 import Nimrod +import os +from pathlib import Path +import re +import logging # TODO: Add logging + +""" +import nimrod +a = nimrod.Nimrod(open( + '200802252000_nimrod_ng_radar_rainrate_composite_1km_merged_UK_zip')) +a.query() +a.extract_asc(open('full_raster.asc', 'w')) +a.apply_bbox(279906, 285444, 283130, 290440) +a.query() +a.extract_asc(open('clipped_raster.asc', 'w')) +""" + +BOUNDING_BOX_INFO = { + "BRISCS": (607000, 608000, 217000, 218000), + "WINTSC": (499000, 500000, 416000, 417000), +} +in_top_folder = "./dat_files" +out_top_folder = "./asc_files" + + +def get_datetime(file_name: str) -> str: + # Pattern to match YYYYMMDDHHMM format + pattern = r"(\d{8})(\d{4})" + match = re.search(pattern, file_name) + if match: + date_part = match.group(1) # YYYYMMDD + time_part = match.group(2) # HHMM + return f"{date_part}{time_part}" + else: + return "date_not_found" + + +# read all file names in the folder +area_folders = os.listdir(in_top_folder) + +for area in area_folders: + bounding_box = BOUNDING_BOX_INFO.get(area, (0, 0, 0, 0)) + print(area, bounding_box) + xmin, xmax, ymin, ymax = bounding_box + os.makedirs(Path(out_top_folder, area), exist_ok=True) + for in_file in os.listdir(Path(in_top_folder, area)): + timestamp = get_datetime(in_file) + out_file_name = f"{timestamp}_{area}.asc" + out_file_path = Path(out_top_folder, area, out_file_name) + in_file_full = Path(in_top_folder, area, in_file) + #print(in_file_full) + try: + image = Nimrod(open(in_file_full, 'rb')) + image.apply_bbox(xmin, xmax, ymin, ymax) + # image.query() # prints out file_details + with open(out_file_path, 'w') as outfile: + image.extract_asc(outfile) + except Nimrod.HeaderReadError as e: + print(f'Failed to read file {in_file_full}, is it corrupt?') + print(e) + continue + except Nimrod.PayloadReadError as e: + print(f'Failed to load the raster data in {in_file_full}') + print(e) + continue + except Nimrod.BboxRangeError as e: + print(f'Bounding Box out of range. Given bounding box: {bounding_box}') + print(e) + # Skips the whole area as bounding box will be out of bounds for all files + break + diff --git a/nimrod_3.py b/nimrod_3.py new file mode 100644 index 0000000..574f0fc --- /dev/null +++ b/nimrod_3.py @@ -0,0 +1,448 @@ +#!/usr/bin/python3 +""" +Extract data from UK Met Office Rain Radar NIMROD image files. + +Parse NIMROD format image files, display header data and allow extraction of +raster image to an ESRI ASCII (.asc) format file. A bounding box may be +specified to clip the image to the area of interest. Can be imported as a +Python module or run directly as a command line script. + +Author: Richard Thomas +Version: 1.0 (13 April 2015) +Public Repository: https://github.com/richard-thomas/MetOffice_NIMROD + +Command line usage: + python nimrod.py [-h] [-q] [-x] [-bbox XMIN XMAX YMIN YMAX] [infile] [outfile] + +positional arguments: + infile (Uncompressed) NIMROD input filename + outfile Output raster filename (*.asc) + +optional arguments: + -h, --help show this help message and exit + -q, --query Display metadata + -x, --extract Extract raster file in ASC format + -bbox XMIN XMAX YMIN YMAX + Bounding box to clip raster data to + +Note that any bounding box must be specified in the same units and projection +as the input file. The bounding box does not need to be contained by the input +raster but must intersect it. + +Example command line usage: + python nimrod.py -bbox 279906 285444 283130 290440 + -xq 200802252000_nimrod_ng_radar_rainrate_composite_1km_merged_UK_zip + plynlimon_catchments_rainfall.asc + +Example Python module usage: + import nimrod + a = nimrod.Nimrod(open( + '200802252000_nimrod_ng_radar_rainrate_composite_1km_merged_UK_zip')) + a.query() + a.extract_asc(open('full_raster.asc', 'w')) + a.apply_bbox(279906, 285444, 283130, 290440) + a.query() + a.extract_asc(open('clipped_raster.asc', 'w')) + +Notes: + 1. Valid for v1.7 and v2.6-4 of NIMROD file specification + 2. Assumes image origin is top left (i.e. that header[24] = 0) + 3. Tested on UK composite 1km and 5km data, under Linux and Windows XP + 4. Further details of NIMROD data and software at the NERC BADC website: + http://badc.nerc.ac.uk/browse/badc/ukmo-nimrod/ + +Copyright (c) 2015 Richard Thomas +(Nimrod.__init__() method based on read_nimrod.py by Charles Kilburn Aug 2008) + +This program is free software: you can redistribute it and/or modify +it under the terms of the Artistic License 2.0 as published by the +Open Source Initiative (http://opensource.org/licenses/Artistic-2.0) + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +""" + +import sys +import struct +import array +import argparse + + +class Nimrod: + """Reading, querying and processing of NIMROD format rainfall data files.""" + + class RecordLenError(Exception): + """ + Exception Type: NIMROD record length read from file not as expected. + """ + + def __init__(self, actual, expected, location): + self.message = "Incorrect record length %d bytes (expected %d) at %s." % ( + actual, + expected, + location, + ) + + class HeaderReadError(Exception): + """Exception Type: Read error whilst parsing NIMROD header elements.""" + + pass + + class PayloadReadError(Exception): + """Exception Type: Read error whilst parsing NIMROD raster data.""" + + pass + + class BboxRangeError(Exception): + """ + Exception Type: Bounding box specified out of range of raster image. + """ + + pass + + def __init__(self, infile): + """ + Parse all header and data info from a NIMROD data file into this object. + (This method based on read_nimrod.py by Charles Kilburn Aug 2008) + + Args: + infile: NIMROD file object opened for binary reading + Raises: + RecordLenError: NIMROD record length read from file not as expected + HeaderReadError: Read error whilst parsing NIMROD header elements + PayloadReadError: Read error whilst parsing NIMROD raster data + """ + + def check_record_len(infile, expected, location): + """ + Check record length in C struct is as expected. + + Args: + infile: file to read from + expected: expected value of record length read + location: description of position in file (for reporting) + Raises: + HeaderReadError: Read error whilst reading record length + RecordLenError: Unexpected NIMROD record length read from file + """ + + # Unpack length from C struct (Big Endian, 4-byte long) + try: + (record_length,) = struct.unpack(">l", infile.read(4)) + except Exception: + raise Nimrod.HeaderReadError + if record_length != expected: + raise Nimrod.RecordLenError(record_length, expected, location) + + # Header should always be a fixed length record + check_record_len(infile, 512, "header start") + + try: + # Read first 31 2-byte integers (header fields 1-31) + gen_ints = array.array("h") + data = infile.read(31 * 2) # 31 integers * 2 bytes each + gen_ints.frombytes(data) + gen_ints.byteswap() + + # Read next 28 4-byte floats (header fields 32-59) + gen_reals = array.array("f") + data = infile.read(28 * 4) # 28 floats * 4 bytes each + gen_reals.frombytes(data) + gen_reals.byteswap() + + # Read next 45 4-byte floats (header fields 60-104) + spec_reals = array.array("f") + data = infile.read(45 * 4) # 45 floats * 4 bytes each + spec_reals.frombytes(data) + spec_reals.byteswap() + + # Read next 56 characters (header fields 105-107) + characters = infile.read(56) + + # Read next 51 2-byte integers (header fields 108-) + spec_ints = array.array("h") + data = infile.read(51 * 2) # 51 integers * 2 bytes each + spec_ints.frombytes(data) + spec_ints.byteswap() + + except Exception: + infile.close() + raise Nimrod.HeaderReadError + + check_record_len(infile, 512, "header end") + + # Extract strings and make duplicate entries to give meaningful names + chars = characters.decode("utf-8") + self.units = chars[0:8] + self.data_source = chars[8:32] + self.title = chars[32:55] + + # Store header values in a list so they can be indexed by "element + # number" shown in NIMROD specification (starts at 1) + self.hdr_element = [None] # Dummy value at element 0 + self.hdr_element.extend(gen_ints) + self.hdr_element.extend(gen_reals) + self.hdr_element.extend(spec_reals) + self.hdr_element.extend([self.units]) + self.hdr_element.extend([self.data_source]) + self.hdr_element.extend([self.title]) + self.hdr_element.extend(spec_ints) + + # Duplicate some of values to give more meaningful names + self.nrows = self.hdr_element[16] + self.ncols = self.hdr_element[17] + self.n_data_specific_reals = self.hdr_element[22] + self.n_data_specific_ints = self.hdr_element[23] + 1 + # Note "+ 1" because header value is count from element 109 + self.y_top = self.hdr_element[34] + self.y_pixel_size = self.hdr_element[35] + self.x_left = self.hdr_element[36] + self.x_pixel_size = self.hdr_element[37] + + # Calculate other image bounds (note these are pixel centres) + self.x_right = self.x_left + self.x_pixel_size * (self.ncols - 1) + self.y_bottom = self.y_top - self.y_pixel_size * (self.nrows - 1) + + # Read payload (actual raster data) + array_size = self.ncols * self.nrows + check_record_len(infile, array_size * 2, "data start") + + self.data = array.array("h") + try: + data = infile.read(array_size * 2) + self.data.frombytes(data) + self.data.byteswap() + except Exception: + infile.close() + raise Nimrod.PayloadReadError + + check_record_len(infile, array_size * 2, "data end") + infile.close() + + def query(self): + """Print complete NIMROD file header information.""" + + print("NIMROD file raw header fields listed by element number:") + print("General (Integer) header entries:") + for i in range(1, 32): + print(i, "\t", self.hdr_element[i]) + print("General (Real) header entries:") + for i in range(32, 60): + print(i, "\t", self.hdr_element[i]) + print("Data Specific (Real) header entries (%d):" % self.n_data_specific_reals) + for i in range(60, 60 + self.n_data_specific_reals): + print(i, "\t", self.hdr_element[i]) + print( + "Data Specific (Integer) header entries (%d):" % self.n_data_specific_ints + ) + for i in range(108, 108 + self.n_data_specific_ints): + print(i, "\t", self.hdr_element[i]) + print("Character header entries:") + print(" 105 Units: ", self.units) + print(" 106 Data source: ", self.data_source) + print(" 107 Title of field: ", self.title) + + # Print out info & header fields + # Note that ranges are given to the edge of each pixel + print( + "\nValidity Time: %2.2d:%2.2d on %2.2d/%2.2d/%4.4d" + % ( + self.hdr_element[4], + self.hdr_element[5], + self.hdr_element[3], + self.hdr_element[2], + self.hdr_element[1], + ) + ) + print( + "Easting range: %.1f - %.1f (at pixel steps of %.1f)" + % ( + self.x_left - self.x_pixel_size / 2, + self.x_right + self.x_pixel_size / 2, + self.x_pixel_size, + ) + ) + print( + "Northing range: %.1f - %.1f (at pixel steps of %.1f)" + % ( + self.y_bottom - self.y_pixel_size / 2, + self.y_top + self.y_pixel_size / 2, + self.y_pixel_size, + ) + ) + print("Image size: %d rows x %d cols" % (self.nrows, self.ncols)) + + def apply_bbox(self, xmin, xmax, ymin, ymax): + """ + Clip raster data to all pixels that intersect specified bounding box. + + Note that existing object data is modified and all header values + affected are appropriately adjusted. Because pixels are specified by + their centre points, a bounding box that comes within half a pixel + width of the raster edge will intersect with the pixel. + + Args: + xmin: Most negative easting or longitude of bounding box + xmax: Most positive easting or longitude of bounding box + ymin: Most negative northing or latitude of bounding box + ymax: Most positive northing or latitude of bounding box + Raises: + BboxRangeError: Bounding box specified out of range of raster image + """ + + # Check if there is no overlap of bounding box with raster + if ( + xmin > self.x_right + self.x_pixel_size / 2 + or xmax < self.x_left - self.x_pixel_size / 2 + or ymin > self.y_top + self.y_pixel_size / 2 + or ymax < self.y_bottom - self.x_pixel_size / 2 + ): + raise Nimrod.BboxRangeError + + # Limit bounds to within raster image + xmin = max(xmin, self.x_left) + xmax = min(xmax, self.x_right) + ymin = max(ymin, self.y_bottom) + ymax = min(ymax, self.y_top) + + # Calculate min and max pixel index in each row and column to use + # Note addition of 0.5 as x_left location is centre of pixel + # ('int' truncates floats towards zero) + xMinPixelId = int((xmin - self.x_left) / self.x_pixel_size + 0.5) + xMaxPixelId = int((xmax - self.x_left) / self.x_pixel_size + 0.5) + + # For y (northings), note the first data row stored is most north + yMinPixelId = int((self.y_top - ymax) / self.y_pixel_size + 0.5) + yMaxPixelId = int((self.y_top - ymin) / self.y_pixel_size + 0.5) + + bbox_data = [] + for i in range(yMinPixelId, yMaxPixelId + 1): + bbox_data.extend( + self.data[ + i * self.ncols + xMinPixelId : i * self.ncols + xMaxPixelId + 1 + ] + ) + + # Update object where necessary + self.data = bbox_data + self.x_right = self.x_left + xMaxPixelId * self.x_pixel_size + self.x_left += xMinPixelId * self.x_pixel_size + self.ncols = xMaxPixelId - xMinPixelId + 1 + self.y_bottom = self.y_top - yMaxPixelId * self.y_pixel_size + self.y_top -= yMinPixelId * self.y_pixel_size + self.nrows = yMaxPixelId - yMinPixelId + 1 + self.hdr_element[16] = self.nrows + self.hdr_element[17] = self.ncols + self.hdr_element[34] = self.y_top + self.hdr_element[36] = self.x_left + + def extract_asc(self, outfile): + """ + Write raster data to an ESRI ASCII (.asc) format file. + + Args: + outfile: file object opened for writing text + """ + + # As ESRI ASCII format only supports square pixels, warn if not so + if self.x_pixel_size != self.y_pixel_size: + print( + "Warning: x_pixel_size(%d) != y_pixel_size(%d)" + % (self.x_pixel_size, self.y_pixel_size) + ) + + # Write header to output file. Note that data is valid at the centre + # of each pixel so "xllcenter" rather than "xllcorner" must be used + outfile.write("ncols %d\n" % self.ncols) + outfile.write("nrows %d\n" % self.nrows) + outfile.write("xllcenter %d\n" % self.x_left) + outfile.write("yllcenter %d\n" % self.y_bottom) + outfile.write("cellsize %.1f\n" % self.y_pixel_size) + outfile.write("nodata_value %.1f\n" % self.hdr_element[38]) + + # Write raster data to output file + for i in range(self.nrows): + for j in range(self.ncols - 1): + outfile.write("%d " % self.data[i * self.ncols + j]) + outfile.write("%d\n" % self.data[i * self.ncols + self.ncols - 1]) + outfile.close() + + +# ------------------------------------------------------------------------------- +# Handle if called as a command line script +# (And as an example of how to invoke class methods from an importing module) +# ------------------------------------------------------------------------------- + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Extract information and data from a NIMROD format file", + epilog="""Note that any bounding box must be specified in the same + units and projection as the input file. The bounding box + does not need to be contained by the input raster but + must intersect it.""", + ) + parser.add_argument("-q", "--query", action="store_true", help="Display metadata") + parser.add_argument( + "-x", "--extract", action="store_true", help="Extract raster file in ASC format" + ) + parser.add_argument( + "infile", + nargs="?", + type=argparse.FileType("rb"), + default=sys.stdin, + help="(Uncompressed) NIMROD input filename", + ) + parser.add_argument( + "outfile", + nargs="?", + type=argparse.FileType("w"), + default=sys.stdout, + help="Output raster filename (*.asc)", + ) + parser.add_argument( + "-bbox", + type=float, + nargs=4, + metavar=("XMIN", "XMAX", "YMIN", "YMAX"), + help="Bounding box to clip raster data to", + ) + args = parser.parse_args() + + if not args.query and not args.extract: + parser.print_help() + sys.exit(1) + + # Initialise data object by reading NIMROD file + # (Only trap record length exception as others self-explanatory) + try: + rainfall_data = Nimrod(args.infile) + except Nimrod.RecordLenError as error: + sys.stderr.write("ERROR: %s\n" % error.message) + sys.exit(1) + + if args.bbox: + print("Trimming NIMROD raster to bounding box...") + try: + rainfall_data.apply_bbox(*args.bbox) + except Nimrod.BboxRangeError: + sys.stderr.write("ERROR: bounding box not within raster image.\n") + sys.exit(1) + + # Perform query after any bounding box trimming to allow sanity checking of + # size of resulting image + if args.query: + rainfall_data.query() + + if args.extract: + print("Extracting NIMROD raster to ASC file...") + print( + " Outputting data array (%d rows x %d cols = %d pixels)" + % ( + rainfall_data.nrows, + rainfall_data.ncols, + rainfall_data.nrows * rainfall_data.ncols, + ) + ) + rainfall_data.extract_asc(args.outfile) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..753660f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "met-office" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "ruff>=0.14.3", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fd5045f --- /dev/null +++ b/uv.lock @@ -0,0 +1,40 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "met-office" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "ruff", specifier = ">=0.14.3" }] + +[[package]] +name = "ruff" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +]