From d595bbb4f7822b35780ac4771ce05ad18911f376 Mon Sep 17 00:00:00 2001 From: monkeyman192 Date: Wed, 11 Feb 2026 00:06:09 +1100 Subject: [PATCH] gh-94: Fix issue with single-file mod cache and improve config handling in numerous cases --- docs/docs/change_log.rst | 5 +- docs/docs/libraries/writing_libraries.rst | 33 +++++++++++++ pymhf/__init__.py | 35 +++++--------- pymhf/core/functions.py | 2 +- pymhf/core/importing.py | 6 +++ pymhf/gui/hexview.py | 4 ++ pymhf/injected.py | 24 ++++++++++ pymhf/main.py | 58 +++++++++++++++++++---- pymhf/utils/config.py | 57 +++++++++++++++++++++- pymhf/utils/imports.py | 18 +++++++ pymhf/utils/parse_toml.py | 7 +-- pyproject.toml | 3 +- uv.lock | 46 +++++++++--------- 13 files changed, 239 insertions(+), 59 deletions(-) diff --git a/docs/docs/change_log.rst b/docs/docs/change_log.rst index 9bdc2d5..d14586b 100644 --- a/docs/docs/change_log.rst +++ b/docs/docs/change_log.rst @@ -12,6 +12,9 @@ Current (0.2.2.dev) - If the ``[pymhf].exe`` attribute is specified in the ``pymhf.toml`` as an absolute path, it will be used over the ``steam_gameid``. This is required if one wants to use the ``[pymhf].start_paused`` attribute. - Fixed an issue with partial structs inheritence where, if a base class has a `_total_size_` attribute, then the inheriting class would have the wrong offsets for fields. - Added the :py:class:`~pymhf.extensions.ctypes.c_enum16` type for creating enums whose value is serialized as a 16bit integer. +- Fixed an issue where the pattern cache wouldn't be able to be created under certain circumstances when running a single-file mode (`#94 `_). +- Added the ability to specify runtime callable functions in libraries via an entry-point so that certain functions in the library may be called before mod instantiation occurs. See :doc:`here ` for more details. +- Fixed an issue where single-file mods which imported a library didn't load the specified internal mod directory. 0.2.1 (15/11/2025) ------------------ @@ -82,7 +85,7 @@ Next release set will focus on UI/UX as well as utilities, both in terms of the -------------------- - Added the :py:func:`pymhf.gui.decorators.gui_combobox` decorator (partial work on `#15 `_). -- Added the ability for mods to access each others' attributes and methods. (`#5 `_). See :doc:`this page ` for more details. +- Added the ability for mods to access each others' attributes and methods. (`#5 `_). See :doc:`here ` for more details. - Fixed a few issues regarding running pyMHF. Thanks to `@Foundit3923 `_ for helping to figure out the issues. - Fixed an issue where hooks of imported functions which have ``_result_`` as an argument work. - Added :py:func:`pymhf.core.hooking.NOOP` decorator which indicates that the original game function shouldn't be called. (`#20 `_) diff --git a/docs/docs/libraries/writing_libraries.rst b/docs/docs/libraries/writing_libraries.rst index b244345..bebd2f2 100644 --- a/docs/docs/libraries/writing_libraries.rst +++ b/docs/docs/libraries/writing_libraries.rst @@ -55,3 +55,36 @@ For the above example of checking to see if the patterns are valid, we may want pymhf cmd check --exe="NMS.exe" nmspy The ``check`` function will receive ``['--exe="NMS.exe"']`` as an argument which we can then parse. + +``pymhf_rtfunc`` entry-point +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Because of how python imports code, it is generally recommended that developers do not include any code which will be run when importing which relies on any particular state. +Because of this, we need some way to specify functions in the library which are to be run once the code has been injected into the target process, but before any mods are instantiated so that any data can be accessed as soon as possible in the mods. +To facilitate this, ``pyMHF`` has the ability to pick up functions which are defined as being run at run-time by the library. + +For example, we may have some function which searches the binary for some data and then assigns it to a variable in a class like so: + +.. code-block:: python + :caption: library/data.py + + class Data: + x: int + + def find_variable(self): + # Some code to find the value of x + x = 42 + + data = Data() + +We can now define the following end-point: + +.. code-block:: toml + + [project.entry-points.pymhf_rtfunc] + data = "library.data:data.find_variable" + +The syntax of this entry-point is very similar to that of normal python entry-points, but this function will be found and then run before any mods are instantiated. + +.. note:: + The specified function or method cannot take any arguments. diff --git a/pymhf/__init__.py b/pymhf/__init__.py index 4e36797..dec2c42 100644 --- a/pymhf/__init__.py +++ b/pymhf/__init__.py @@ -5,9 +5,15 @@ import subprocess import sys from enum import Enum -from importlib.metadata import PackageNotFoundError, entry_points, version +from importlib import import_module +from importlib.metadata import PackageNotFoundError, version from typing import Optional +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points + import questionary from .core._types import FUNCDEF # noqa @@ -333,24 +339,11 @@ def run(): initial_config = False if not local: + # Get the entry points irrespective of how we are running the code. + # We do this so that if we are running a single-file mod which also uses a library we can still get + # library configuration settings and merge them with the config settings in the single-file mod. eps = entry_points() - # This check is to ensure compatibility with multiple versions of python as the code 3.10+ isn't - # backward compatible. - if isinstance(eps, dict): - pymhf_entry_points = eps.get("pymhflib", []) - else: - pymhf_entry_points = eps.select(group="pymhflib") - if mode == ModeEnum.COMMAND: - if isinstance(eps, dict): - pymhf_commands = eps.get("pymhfcmd", []) - else: - pymhf_commands = eps.select(group="pymhfcmd") - - if not pymhf_commands: - print( - "No [project.entry-points.pymhfcmd] section found in the pyproject.toml. " - "Please create one to register and run custom commands." - ) + pymhf_entry_points = eps.select(group="pymhflib") required_lib = None resolved_command = None @@ -359,7 +352,7 @@ def run(): if ep.name.lower() == plugin_name.lower(): required_lib = ep if mode == ModeEnum.COMMAND: - for cmd in pymhf_commands: + for cmd in eps.select(group="pymhfcmd") or []: # See if the library has a check command defined. if cmd.name.lower() == cmd_command: resolved_command = cmd.value @@ -377,9 +370,7 @@ def run(): if mode == ModeEnum.COMMAND: if resolved_command is not None: # We can use importlib here since the library has to be installed if we have made it here. - import importlib - - mod = importlib.import_module(plugin_name) + mod = import_module(plugin_name) cmd = getattr(mod, resolved_command, None) if cmd is not None: cmd(extras) diff --git a/pymhf/core/functions.py b/pymhf/core/functions.py index 531d5b4..3dbaf16 100644 --- a/pymhf/core/functions.py +++ b/pymhf/core/functions.py @@ -1,7 +1,7 @@ import ctypes import inspect from functools import lru_cache -from typing import Any, Callable, NamedTuple, Optional, _AnnotatedAlias, get_args, Type +from typing import Any, Callable, NamedTuple, Optional, Type, _AnnotatedAlias, get_args from typing_extensions import get_type_hints diff --git a/pymhf/core/importing.py b/pymhf/core/importing.py index 30ca54d..94d8943 100644 --- a/pymhf/core/importing.py +++ b/pymhf/core/importing.py @@ -43,6 +43,12 @@ def _fully_unpack_ast_attr(obj: ast.Attribute) -> str: return name +def library_path_from_name(name: str) -> Optional[str]: + if (spec := importlib.util.find_spec(name)) is not None: + if spec.origin is not None: + return op.dirname(spec.origin) + + def parse_file_for_mod(data: str) -> bool: """Parse the provided data and determine if there is at least one mod class in it.""" tree = ast.parse(data) diff --git a/pymhf/gui/hexview.py b/pymhf/gui/hexview.py index 835a0df..c49529c 100644 --- a/pymhf/gui/hexview.py +++ b/pymhf/gui/hexview.py @@ -440,6 +440,7 @@ def _select_coord(self, i: int, j: int): "_select_bytes_uint32", str(int.from_bytes(self._selected_bytes, byteorder="little", signed=True)), ) + dpg.set_value("_select_bytes_float32", str(struct.unpack_from(" {value} ({obj})") + try: + obj() + except Exception: + rootLogger.exception(f"There was an issue running the runtime function {name} ({value})") + mod_manager.hook_manager = hook_manager # First, load our internal mod before anything else. if internal_mod_folder is not None: diff --git a/pymhf/main.py b/pymhf/main.py index 7de6a23..9d81b7d 100644 --- a/pymhf/main.py +++ b/pymhf/main.py @@ -1,9 +1,11 @@ import asyncio import concurrent.futures import glob +import importlib.resources as impres import os import os.path as op import subprocess +import sys import time import webbrowser from functools import partial @@ -11,6 +13,11 @@ from threading import Event from typing import Optional +if sys.version_info < (3, 10): + from importlib_metadata import entry_points +else: + from importlib.metadata import entry_points + import psutil import pymem import pymem.exception @@ -20,11 +27,11 @@ from pymhf.core._types import LoadTypeEnum, pymhfConfig from pymhf.core.hashing import hash_bytes_from_file, hash_bytes_from_memory -from pymhf.core.importing import parse_file_for_mod +from pymhf.core.importing import library_path_from_name, parse_file_for_mod from pymhf.core.log_handling import open_log_console from pymhf.core.process import start_process from pymhf.core.protocols import ESCAPE_SEQUENCE, TerminalProtocol -from pymhf.utils.config import canonicalize_setting +from pymhf.utils.config import canonicalize_setting, canonicalize_settings_inline, merge_configs from pymhf.utils.parse_toml import read_pymhf_settings from pymhf.utils.winapi import get_exe_path_from_pid @@ -142,7 +149,7 @@ def get_process_when_ready( return None, None -def load_mod_file(filepath: str, config_overrides: Optional[pymhfConfig] = None): +def load_mod_file(filepath: str, config_overrides: Optional[dict] = None): """Load an individual file as a mod. Parameters @@ -195,13 +202,38 @@ def run_module( config_dir The local config directory. This will only be provided if we are running a library. """ + load_type = LoadTypeEnum.LIBRARY if plugin_name is None: if op.isfile(module_path): load_type = LoadTypeEnum.SINGLE_FILE if op.exists(op.join(module_path, "pymhf.toml")): load_type = LoadTypeEnum.MOD_FOLDER - else: - load_type = LoadTypeEnum.LIBRARY + + # Ensure the module path is absolute so as some later logic will rely on this. + if not op.isabs(module_path): + module_path = op.abspath(module_path) + + # Before parsing the config, check if any of the installed libraires have any extra config info that + # needs to be merged in. + final_config: pymhfConfig = {} + pymhf_libraries = [ep.name for ep in entry_points().select(group="pymhflib")] + for library in pymhf_libraries: + if impres.is_resource(library, "pymhf.toml"): + with impres.path(library, "pymhf.toml") as pymhf_toml: + lib_config = read_pymhf_settings(pymhf_toml) + if (library_path := library_path_from_name(library)) is not None: + canonicalize_settings_inline( + lib_config, + library, + library_path, + None, + None, + ["{CURR_DIR}", "{USER_DIR}"], + ) + merge_configs(lib_config, final_config) + merge_configs(config, final_config) + + config = final_config binary_path = None injected = None @@ -232,7 +264,12 @@ def run_module( start_paused = config.get("start_paused", False) if config_dir is None: - cache_dir = op.join(module_path, ".cache") + # If we have a single-file mod, use the cache_dir as a folder up from the mod. + if load_type == LoadTypeEnum.SINGLE_FILE: + mod_dir, mod_fname = op.split(module_path) + cache_dir = op.join(mod_dir, f".{mod_fname}.cache") + else: + cache_dir = op.join(module_path, ".cache") else: cache_dir = op.join(config_dir, ".cache") @@ -417,6 +454,13 @@ def close_callback(x): assem_name = list(offset_map.keys())[0] binary_base = offset_map[assem_name][0] binary_size = offset_map[assem_name][1] + else: + binary_base = 0 + binary_size = 0 + + # Some data for the injection process. + injected_data_list = [] + sentinel_addr = 0 try: cwd = CWD.replace("\\", "\\\\") @@ -469,8 +513,6 @@ def close_callback(x): break saved_path = [x.replace("\\", "\\\\") for x in _path] - injected_data_list = [] - # Inject the new path sys_path_str = f""" import sys diff --git a/pymhf/utils/config.py b/pymhf/utils/config.py index c6fa2f9..0b13e56 100644 --- a/pymhf/utils/config.py +++ b/pymhf/utils/config.py @@ -4,12 +4,55 @@ from logging import getLogger from typing import Optional +from pymhf.core._types import pymhfConfig + PATH_RE = re.compile(r"^\{(?PEXE_DIR|USER_DIR|CURR_DIR)\}(?P[^{}]*)$") logger = getLogger(__name__) +def canonicalize_settings_inline( + config: pymhfConfig, + plugin_name: Optional[str], + module_dir: str, + exe_dir: Optional[str] = None, + suffix: Optional[str] = None, + filter: Optional[list[str]] = None, +): + """Canonicalize all the settings in a config file. This will mutate the original config object. + + To only modify values containing a particular value, specify this in the `filter` argument.""" + + def allow_value(value: str, filter: Optional[list[str]]): + if filter is not None: + return any(f in value for f in filter) + else: + return True + + for key, value in config.items(): + if isinstance(value, str): + if allow_value(value, filter): + config[key] = canonicalize_setting(value, plugin_name, module_dir, exe_dir, suffix) + elif isinstance(value, dict): + subdata = {} + for subkey, subvalue in value.items(): + if isinstance(subvalue, str): + if allow_value(subvalue, filter): + subdata[subkey] = canonicalize_setting( + subvalue, + plugin_name, + module_dir, + exe_dir, + suffix, + ) + else: + subdata[subkey] = subvalue + config[key] = subdata + else: + config[key] = value + + def canonicalize_setting( value: Optional[str], plugin_name: Optional[str], @@ -27,7 +70,7 @@ def canonicalize_setting( # This can receive None as the value. # In this case we simply return as we don't want to do anything with it. - if value is None: + if not isinstance(value, str): return None # Parse the value to determine what directory type we are asking for. @@ -73,3 +116,15 @@ def canonicalize_setting( raise ValueError("Exe directory cannot be determined") elif tag == "CURR_DIR": return op.realpath(op.join(module_dir, *_suffix)) + + +def merge_configs(src: pymhfConfig, dst: pymhfConfig): + """Merge the source config into the dest config. Overwriting any values.""" + for key, value in src.items(): + if not isinstance(value, dict): + dst[key] = value + else: + for subkey, subvalue in value.items(): + if key not in dst: + dst[key] = {} + dst[key][subkey] = subvalue diff --git a/pymhf/utils/imports.py b/pymhf/utils/imports.py index 1407599..6be42da 100644 --- a/pymhf/utils/imports.py +++ b/pymhf/utils/imports.py @@ -1,7 +1,9 @@ import ctypes +import importlib import os.path as op import sys from logging import getLogger +from typing import Callable import pefile @@ -51,3 +53,19 @@ def get_imports(binary_path: str) -> dict[str, ctypes._CFuncPtr]: funcptrs[_dll] = _funcptrs return funcptrs + + +def get_callable_obj(object_ref: str) -> Callable: + # Get the object referred to in the object reference string. + # This expects a string of the form `importable.module:object.attr`. + # This should be the same as how normal python entrypoints are determined, and the code is in fact taken + # from the python packaging page: + # https://packaging.python.org/en/latest/specifications/entry-points/#data-model + modname, qualname_separator, qualname = object_ref.partition(":") + obj = importlib.import_module(modname) + if qualname_separator: + for attr in qualname.split("."): + obj = getattr(obj, attr) + else: + raise TypeError("") + return obj # type: ignore diff --git a/pymhf/utils/parse_toml.py b/pymhf/utils/parse_toml.py index 6cd4f27..42ad90f 100644 --- a/pymhf/utils/parse_toml.py +++ b/pymhf/utils/parse_toml.py @@ -1,5 +1,6 @@ +import os import re -from typing import Optional, cast +from typing import Optional, Union, cast import tomlkit @@ -27,7 +28,7 @@ def read_inline_metadata(script: str) -> Optional[tomlkit.TOMLDocument]: return None -def _parse_toml(fpath: str, standalone: bool = False) -> Optional[pymhfConfig]: +def _parse_toml(fpath: Union[os.PathLike[str], str], standalone: bool = False) -> Optional[pymhfConfig]: settings = {} with open(fpath, "r") as f: if standalone: @@ -38,7 +39,7 @@ def _parse_toml(fpath: str, standalone: bool = False) -> Optional[pymhfConfig]: return cast(pymhfConfig, settings.value) -def read_pymhf_settings(fpath: str, standalone: bool = False) -> pymhfConfig: +def read_pymhf_settings(fpath: Union[os.PathLike[str], str], standalone: bool = False) -> pymhfConfig: settings = _parse_toml(fpath, standalone) if not settings: return {} diff --git a/pyproject.toml b/pyproject.toml index 486a19e..a4bfb02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ "pefile", "iced_x86", "typing_extensions", - "pyrun-injected==0.2.0" + "pyrun-injected==0.2.0", + "importlib_metadata; python_version < '3.10'" ] dynamic = ["version"] diff --git a/uv.lock b/uv.lock index 887263a..aba3187 100644 --- a/uv.lock +++ b/uv.lock @@ -69,11 +69,11 @@ wheels = [ [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] @@ -279,14 +279,14 @@ wheels = [ [[package]] name = "id" -version = "1.5.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "sys_platform == 'win32'" }, + { name = "urllib3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, ] [[package]] @@ -312,7 +312,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "zipp", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -718,6 +718,7 @@ source = { editable = "." } dependencies = [ { name = "cyminhook", marker = "sys_platform == 'win32'" }, { name = "iced-x86", marker = "sys_platform == 'win32'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, { name = "keyboard", marker = "sys_platform == 'win32'" }, { name = "packaging", marker = "sys_platform == 'win32'" }, { name = "pefile", marker = "sys_platform == 'win32'" }, @@ -758,6 +759,7 @@ requires-dist = [ { name = "cyminhook", specifier = ">=0.1.4" }, { name = "dearpygui", marker = "extra == 'gui'" }, { name = "iced-x86" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "keyboard" }, { name = "packaging" }, { name = "pefile" }, @@ -1022,16 +1024,16 @@ wheels = [ [[package]] name = "rich" -version = "14.3.1" +version = "14.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, { name = "pygments", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" }, + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, ] [[package]] @@ -1045,22 +1047,22 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.14" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, - { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, ] [[package]] name = "setuptools" -version = "80.10.2" +version = "81.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] @@ -1349,11 +1351,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.5.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/6e/62daec357285b927e82263a81f3b4c1790215bc77c42530ce4a69d501a43/wcwidth-0.5.0.tar.gz", hash = "sha256:f89c103c949a693bf563377b2153082bf58e309919dfb7f27b04d862a0089333", size = 246585, upload-time = "2026-01-27T01:31:44.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3e/45583b67c2ff08ad5a582d316fcb2f11d6cf0a50c7707ac09d212d25bc98/wcwidth-0.5.0-py3-none-any.whl", hash = "sha256:1efe1361b83b0ff7877b81ba57c8562c99cf812158b778988ce17ec061095695", size = 93772, upload-time = "2026-01-27T01:31:43.432Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] [[package]]