diff --git a/CONTRIB.md b/CONTRIB.md index 7529ed4b1..49cc1abf5 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -14,16 +14,16 @@ To create a new driver scaffold, you can use the `create_driver.sh` script in th From the root directory of the project, run the following command: ```shell -$ ./contrib/__templates__/create_driver.sh shell Shell "Miguel Angel Ajo" miguelangel@ajo.es +$ ./__templates__/create_driver.sh yepkit Ykush "Miguel Angel Ajo" "miguelangel@ajo.es" -Creating: contrib/drivers/shell/jumpstarter_driver_shell/__init__.py -Creating: contrib/drivers/shell/jumpstarter_driver_shell/client.py -Creating: contrib/drivers/shell/jumpstarter_driver_shell/driver_test.py -Creating: contrib/drivers/shell/jumpstarter_driver_shell/driver.py -Creating: contrib/drivers/shell/.gitignore -Creating: contrib/drivers/shell/pyproject.toml -Creating: contrib/drivers/shell/README.md -Creating: contrib/drivers/shell/examples/exporter.yaml +Creating: packages/jumpstarter_driver_yepkit/jumpstarter_driver_yepkit/__init__.py +Creating: packages/jumpstarter_driver_yepkit/jumpstarter_driver_yepkit/client.py +Creating: packages/jumpstarter_driver_yepkit/jumpstarter_driver_yepkit/driver_test.py +Creating: packages/jumpstarter_driver_yepkit/jumpstarter_driver_yepkit/driver.py +Creating: packages/jumpstarter_driver_yepkit/.gitignore +Creating: packages/jumpstarter_driver_yepkit/pyproject.toml +Creating: packages/jumpstarter_driver_yepkit/README.md +Creating: packages/jumpstarter_driver_yepkit/examples/exporter.yaml $ make sync uv sync --all-packages --all-extras diff --git a/__templates__/create_driver.sh b/__templates__/create_driver.sh index ea0040e23..0f3475d66 100755 --- a/__templates__/create_driver.sh +++ b/__templates__/create_driver.sh @@ -21,7 +21,7 @@ export AUTHOR_NAME=$3 export AUTHOR_EMAIL=$4 # create the driver directory -DRIVER_DIRECTORY=packages/jumpstarter_driver_${DRIVER_NAME} +DRIVER_DIRECTORY=packages/jumpstarter-driver-${DRIVER_NAME} MODULE_DIRECTORY=${DRIVER_DIRECTORY}/jumpstarter_driver_${DRIVER_NAME} # create the module directories mkdir -p ${MODULE_DIRECTORY} @@ -30,10 +30,10 @@ mkdir -p ${DRIVER_DIRECTORY}/examples for f in __init__.py client.py driver_test.py driver.py; do echo "Creating: ${MODULE_DIRECTORY}/${f}" - envsubst < contrib/__templates__/driver/jumpstarter_driver/${f}.tmpl > ${MODULE_DIRECTORY}/${f} + envsubst < __templates__/driver/jumpstarter_driver/${f}.tmpl > ${MODULE_DIRECTORY}/${f} done for f in .gitignore pyproject.toml README.md examples/exporter.yaml; do echo "Creating: ${DRIVER_DIRECTORY}/${f}" - envsubst < packages/__templates__/driver/${f}.tmpl > ${DRIVER_DIRECTORY}/${f} + envsubst < __templates__/driver/${f}.tmpl > ${DRIVER_DIRECTORY}/${f} done diff --git a/docs/source/api-reference/drivers/yepkit.md b/docs/source/api-reference/drivers/yepkit.md new file mode 100644 index 000000000..2d42f34be --- /dev/null +++ b/docs/source/api-reference/drivers/yepkit.md @@ -0,0 +1,74 @@ +# Yepkit + +Drivers for yepkit products. + +## Ykush driver + +This driver provides a client for the [Ykush USB switch](https://www.yepkit.com/products/ykush). +**driver**: `jumpstarter_driver_yepkit.driver.Ykush` + +### Driver configuration +```yaml +export: + power: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + serial: "YK25838" + port: "1" + + power2: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + serial: "YK25838" + port: "2" + +``` +### Config parameters + +| Parameter | Description | Type | Required | Default | +|-----------|-------------|------|----------|---------| +| serial | The serial number of the ykush hub, empty means auto-detection | no | None | | +| port | The port number to be managed, "0", "1", "2", "a" which means all | str | yes | "a" | + + +### PowerClient API + +The yepkit ykush driver provides a `PowerClient` with the following API: + +```{eval-rst} +.. autoclass:: jumpstarter_driver_power.client.PowerClient + :members: on, off +``` + +### Examples +Powering on and off a device +```{testcode} +client.power.on() +time.sleep(1) +client.power.off() +``` + +### CLI access +```bash +$ sudo ~/.cargo/bin/uv run jmp exporter shell -c ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml +WARNING:Ykush:No serial number provided for ykush, using the first one found: YK25838 +INFO:Ykush:Power OFF for Ykush YK25838 on port 1 +INFO:Ykush:Power OFF for Ykush YK25838 on port 2 + +$$ j +Usage: j [OPTIONS] COMMAND [ARGS]... + + Generic composite device + +Options: + --help Show this message and exit. + +Commands: + power Generic power + power2 Generic power + +$$ j power on +INFO:Ykush:Power ON for Ykush YK25838 on port 1 + +$$ exit +``` diff --git a/packages/jumpstarter-driver-yepkit/README.md b/packages/jumpstarter-driver-yepkit/README.md new file mode 100644 index 000000000..6e6806d26 --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/README.md @@ -0,0 +1,11 @@ +# Jumpstarter Driver for the ykush USB Hub from Yepkit + +This driver is for the ykush USB Hub from Yepkit. It allows you to control the power of each port of the hub. + +If you want to test this locally, you can use the following commands from the root of the repository: + +```bash +sudo $(which uv) run jmp exporter shell --config ./packages/jumpstarter-driver-yepkit/examples/exporter.yaml +``` + +Please note that sudo is necessary to gain access to the raw USB interfaces. \ No newline at end of file diff --git a/packages/jumpstarter-driver-yepkit/examples/exporter.yaml b/packages/jumpstarter-driver-yepkit/examples/exporter.yaml new file mode 100644 index 000000000..e29d54643 --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/examples/exporter.yaml @@ -0,0 +1,29 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + name: exporter + namespace: default +endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082 +token: "" +export: + power: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + port: "1" + + power2: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + serial: "YK25838" + port: "2" + + power3: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + port: "3" + + all: + type: jumpstarter_driver_yepkit.driver.Ykush + config: + port: "all" + diff --git a/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/__init__.py b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/conftest.py b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/conftest.py new file mode 100644 index 000000000..b1dac6b37 --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/conftest.py @@ -0,0 +1,13 @@ +import pytest +import usb + + +def pytest_runtest_call(item): + try: + item.runtest() + except FileNotFoundError: + pytest.skip("yepkit not available") + except usb.core.USBError: + pytest.skip("USB not available, could need root permissions") + except usb.core.NoBackendError: + pytest.skip("No USB backend") diff --git a/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py new file mode 100644 index 000000000..8d16820b9 --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver.py @@ -0,0 +1,145 @@ +import threading +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field + +import usb.core +import usb.util +from jumpstarter_driver_power.driver import PowerInterface, PowerReading + +from jumpstarter.driver import Driver, export + +VID = 0x04D8 +PID = 0xF2F7 + +PORT_UP_COMMANDS = { + '1': 0x11, + '2': 0x12, + '3': 0x13, + 'all': 0x1A +} + +PORT_DOWN_COMMANDS = { + '1': 0x01, + '2': 0x02, + '3': 0x03, + 'all': 0x0A +} + +PORT_STATUS_COMMANDS = { + '1': 0x21, + '2': 0x22, + '3': 0x23 +} + +VALID_DEFAULTS = ["on", "off", "keep"] + +# static shared array of usb devices, interfaces on same device cannot be claimed multiple times +_USB_DEVS = {} +_USB_DEVS_LOCK = threading.Lock() # Lock for synchronizing access, we don't do multithread, but just in case.. + +@dataclass(kw_only=True) +class Ykush(PowerInterface, Driver): + """ driver for Yepkit Ykush USB Hub with Power control """ + serial: str | None = field(default=None) + default: str = "off" + port: str = "all" + + dev: usb.core.Device = field(init=False) + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + keys = PORT_UP_COMMANDS.keys() + if self.port not in keys: + raise ValueError( + f"The ykush driver port must be any of the following values: {keys}") + + if self.default not in VALID_DEFAULTS: + raise ValueError( + f"The ykush driver default must be any of the following values: {VALID_DEFAULTS}") + + with _USB_DEVS_LOCK: + # another instance already claimed this device? + if self.serial is None and len(_USB_DEVS.keys()) > 0: + self.serial = list(_USB_DEVS.keys())[0] + self.dev = _USB_DEVS[self.serial] + return + + if self.serial in _USB_DEVS: + self.dev = _USB_DEVS[self.serial] + return + + for dev in usb.core.find(idVendor=VID, idProduct=PID, find_all=True): + serial = usb.util.get_string(dev, dev.iSerialNumber, 0) + if serial == self.serial or self.serial is None: + _USB_DEVS[serial] = dev + if self.serial is None: + self.logger.warning( + f"No serial number provided for ykush, using the first one found: {serial}") + self.serial = serial + self.dev = dev + return + + raise FileNotFoundError("failed to find ykush device") + + def _send_cmd(self, cmd, report_size=64): + out_ep, in_ep = self._get_endpoints(self.dev) + out_buf = [0x00] * report_size + out_buf[0] = cmd # YKUSH command + + # Write to the OUT endpoint + out_ep.write(out_buf) + + # Read from the IN endpoint + in_buf = in_ep.read(report_size, timeout=2000) + return list(in_buf) + + def _get_endpoints(self, dev): + """ + From the active configuration, find the first IN and OUT endpoints. + """ + cfg = self.dev.get_active_configuration() + interface = cfg[(0, 0)] + + out_endpoint = usb.util.find_descriptor( + interface, + custom_match=lambda e: \ + usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT + ) + + in_endpoint = usb.util.find_descriptor( + interface, + custom_match=lambda e: \ + usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN + ) + + if not out_endpoint or not in_endpoint: + raise RuntimeError("Could not find both IN and OUT endpoints for ykush.") + + return out_endpoint, in_endpoint + + # reset function is called by the exporter to setup the default state + def reset(self): + if self.default == "on": + self.on() + elif self.default == "off": + self.off() + + @export + def on(self): + self.logger.info(f"Power ON for Ykush {self.serial} on port {self.port}") + cmd = PORT_UP_COMMANDS.get(self.port) + _ = self._send_cmd(cmd) + return + + @export + def off(self): + self.logger.info(f"Power OFF for Ykush {self.serial} on port {self.port}") + cmd = PORT_DOWN_COMMANDS.get(self.port) + _ = self._send_cmd(cmd) + return + + @export + def read(self) -> AsyncGenerator[PowerReading, None]: + raise NotImplementedError diff --git a/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver_test.py b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver_test.py new file mode 100644 index 000000000..9b280134e --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/jumpstarter_driver_yepkit/driver_test.py @@ -0,0 +1,10 @@ +from .driver import Ykush +from jumpstarter.common.utils import serve + + +def test_drivers_yepkit(): + instance = Ykush() + + with serve(instance) as client: + client.on() + client.off() diff --git a/packages/jumpstarter-driver-yepkit/pyproject.toml b/packages/jumpstarter-driver-yepkit/pyproject.toml new file mode 100644 index 000000000..cd8c69d56 --- /dev/null +++ b/packages/jumpstarter-driver-yepkit/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "jumpstarter-driver-yepkit" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Miguel Angel Ajo", email = "miguelangel@ajo.es" } +] +requires-python = ">=3.11" +dependencies = [ + "anyio>=4.6.2.post1", + "pyusb>=1.2.1", + "jumpstarter_driver_power", + "jumpstarter", +] + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../'} + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_yepkit"] +asyncio_mode = "auto" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", +] diff --git a/uv.lock b/uv.lock index 36c6fb3b1..fd6e98ecf 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,7 @@ members = [ "jumpstarter-driver-sdwire", "jumpstarter-driver-tftp", "jumpstarter-driver-ustreamer", + "jumpstarter-driver-yepkit", "jumpstarter-example-automotive", "jumpstarter-example-soc-pytest", "jumpstarter-imagehash", @@ -1292,6 +1293,37 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-yepkit" +version = "0.1.0" +source = { editable = "packages/jumpstarter-driver-yepkit" } +dependencies = [ + { name = "anyio" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-power" }, + { name = "pyusb" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.6.2.post1" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "pyusb", specifier = ">=1.2.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] + [[package]] name = "jumpstarter-example-automotive" version = "0.1.0" @@ -2244,27 +2276,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.9.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/7f/60fda2eec81f23f8aa7cbbfdf6ec2ca11eb11c273827933fb2541c2ce9d8/ruff-0.9.3.tar.gz", hash = "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", size = 3586740 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/77/4fb790596d5d52c87fd55b7160c557c400e90f6116a56d82d76e95d9374a/ruff-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", size = 11656815 }, - { url = "https://files.pythonhosted.org/packages/a2/a8/3338ecb97573eafe74505f28431df3842c1933c5f8eae615427c1de32858/ruff-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", size = 11594821 }, - { url = "https://files.pythonhosted.org/packages/8e/89/320223c3421962762531a6b2dd58579b858ca9916fb2674874df5e97d628/ruff-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", size = 11040475 }, - { url = "https://files.pythonhosted.org/packages/b2/bd/1d775eac5e51409535804a3a888a9623e87a8f4b53e2491580858a083692/ruff-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", size = 11856207 }, - { url = "https://files.pythonhosted.org/packages/7f/c6/3e14e09be29587393d188454064a4aa85174910d16644051a80444e4fd88/ruff-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", size = 11420460 }, - { url = "https://files.pythonhosted.org/packages/ef/42/b7ca38ffd568ae9b128a2fa76353e9a9a3c80ef19746408d4ce99217ecc1/ruff-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", size = 12605472 }, - { url = "https://files.pythonhosted.org/packages/a6/a1/3167023f23e3530fde899497ccfe239e4523854cb874458ac082992d206c/ruff-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", size = 13243123 }, - { url = "https://files.pythonhosted.org/packages/d0/b4/3c600758e320f5bf7de16858502e849f4216cb0151f819fa0d1154874802/ruff-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", size = 12744650 }, - { url = "https://files.pythonhosted.org/packages/be/38/266fbcbb3d0088862c9bafa8b1b99486691d2945a90b9a7316336a0d9a1b/ruff-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", size = 14458585 }, - { url = "https://files.pythonhosted.org/packages/63/a6/47fd0e96990ee9b7a4abda62de26d291bd3f7647218d05b7d6d38af47c30/ruff-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", size = 12419624 }, - { url = "https://files.pythonhosted.org/packages/84/5d/de0b7652e09f7dda49e1a3825a164a65f4998175b6486603c7601279baad/ruff-0.9.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", size = 11843238 }, - { url = "https://files.pythonhosted.org/packages/9e/be/3f341ceb1c62b565ec1fb6fd2139cc40b60ae6eff4b6fb8f94b1bb37c7a9/ruff-0.9.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", size = 11484012 }, - { url = "https://files.pythonhosted.org/packages/a3/c8/ff8acbd33addc7e797e702cf00bfde352ab469723720c5607b964491d5cf/ruff-0.9.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", size = 12038494 }, - { url = "https://files.pythonhosted.org/packages/73/b1/8d9a2c0efbbabe848b55f877bc10c5001a37ab10aca13c711431673414e5/ruff-0.9.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", size = 12473639 }, - { url = "https://files.pythonhosted.org/packages/cb/44/a673647105b1ba6da9824a928634fe23186ab19f9d526d7bdf278cd27bc3/ruff-0.9.3-py3-none-win32.whl", hash = "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c", size = 9834353 }, - { url = "https://files.pythonhosted.org/packages/c3/01/65cadb59bf8d4fbe33d1a750103e6883d9ef302f60c28b73b773092fbde5/ruff-0.9.3-py3-none-win_amd64.whl", hash = "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", size = 10821444 }, - { url = "https://files.pythonhosted.org/packages/69/cb/b3fe58a136a27d981911cba2f18e4b29f15010623b79f0f2510fd0d31fd3/ruff-0.9.3-py3-none-win_arm64.whl", hash = "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", size = 10038168 }, +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/63/77ecca9d21177600f551d1c58ab0e5a0b260940ea7312195bd2a4798f8a8/ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", size = 3553799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b9/0e168e4e7fb3af851f739e8f07889b91d1a33a30fca8c29fa3149d6b03ec/ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", size = 11652408 }, + { url = "https://files.pythonhosted.org/packages/2c/22/08ede5db17cf701372a461d1cb8fdde037da1d4fa622b69ac21960e6237e/ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", size = 11587553 }, + { url = "https://files.pythonhosted.org/packages/42/05/dedfc70f0bf010230229e33dec6e7b2235b2a1b8cbb2a991c710743e343f/ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4", size = 11020755 }, + { url = "https://files.pythonhosted.org/packages/df/9b/65d87ad9b2e3def67342830bd1af98803af731243da1255537ddb8f22209/ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", size = 11826502 }, + { url = "https://files.pythonhosted.org/packages/93/02/f2239f56786479e1a89c3da9bc9391120057fc6f4a8266a5b091314e72ce/ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", size = 11390562 }, + { url = "https://files.pythonhosted.org/packages/c9/37/d3a854dba9931f8cb1b2a19509bfe59e00875f48ade632e95aefcb7a0aee/ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", size = 12548968 }, + { url = "https://files.pythonhosted.org/packages/fa/c3/c7b812bb256c7a1d5553433e95980934ffa85396d332401f6b391d3c4569/ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", size = 13187155 }, + { url = "https://files.pythonhosted.org/packages/bd/5a/3c7f9696a7875522b66aa9bba9e326e4e5894b4366bd1dc32aa6791cb1ff/ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", size = 12704674 }, + { url = "https://files.pythonhosted.org/packages/be/d6/d908762257a96ce5912187ae9ae86792e677ca4f3dc973b71e7508ff6282/ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", size = 14529328 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/049f1e6755d12d9cd8823242fa105968f34ee4c669d04cac8cea51a50407/ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", size = 12385955 }, + { url = "https://files.pythonhosted.org/packages/91/5a/a9bdb50e39810bd9627074e42743b00e6dc4009d42ae9f9351bc3dbc28e7/ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", size = 11810149 }, + { url = "https://files.pythonhosted.org/packages/e5/fd/57df1a0543182f79a1236e82a79c68ce210efb00e97c30657d5bdb12b478/ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", size = 11479141 }, + { url = "https://files.pythonhosted.org/packages/dc/16/bc3fd1d38974f6775fc152a0554f8c210ff80f2764b43777163c3c45d61b/ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", size = 12014073 }, + { url = "https://files.pythonhosted.org/packages/47/6b/e4ca048a8f2047eb652e1e8c755f384d1b7944f69ed69066a37acd4118b0/ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", size = 12435758 }, + { url = "https://files.pythonhosted.org/packages/c2/40/4d3d6c979c67ba24cf183d29f706051a53c36d78358036a9cd21421582ab/ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", size = 9796916 }, + { url = "https://files.pythonhosted.org/packages/c3/ef/7f548752bdb6867e6939489c87fe4da489ab36191525fadc5cede2a6e8e2/ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", size = 10773080 }, + { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] [[package]]