From 88b58efcab98b921e9139f830a5d2738937dd0d5 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 4 Jun 2026 15:39:36 -0300 Subject: [PATCH] feat: make bufflength optional on write and search methods bufflength now defaults to None on write_process_memory, search_by_value, search_by_value_between and search_by_addresses, so the value/addresses can be passed by keyword without a placeholder None. - numeric types fall back to their default width (int->4, float->8, bool->1) - str/bytes infer the width from the value on search_by_value/_between (search_by_addresses still needs an explicit size: no value to measure) - a new UNSET sentinel keeps value/start/end/addresses required while bufflength has a default; a clear error is raised when they are omitted - positional calls keep working unchanged Docs and tests updated accordingly. --- PyMemoryEditor/linux/process.py | 31 +++--- PyMemoryEditor/macos/process.py | 39 ++++--- PyMemoryEditor/process/abstract.py | 61 +++++++---- PyMemoryEditor/util/__init__.py | 2 + PyMemoryEditor/util/convert.py | 59 +++++++++++ PyMemoryEditor/win32/process.py | 31 +++--- docs/api/openprocess.md | 27 +++-- docs/guide/read-write.md | 21 ++-- docs/guide/searching.md | 24 +++-- docs/index.md | 5 +- tests/test_bufflength_inference.py | 153 +++++++++++++++++++++++++++- tests/test_write_str_bytes_width.py | 46 ++++++++- 12 files changed, 412 insertions(+), 87 deletions(-) diff --git a/PyMemoryEditor/linux/process.py b/PyMemoryEditor/linux/process.py index 1e38be5..c8bb3e0 100644 --- a/PyMemoryEditor/linux/process.py +++ b/PyMemoryEditor/linux/process.py @@ -6,7 +6,12 @@ from ..enums import ScanTypesEnum from ..process import AbstractProcess from ..process.errors import ClosedProcess -from ..util import prepare_write, resolve_bufflength +from ..util import ( + UNSET, + prepare_write, + resolve_bufflength, + resolve_bufflength_for_value, +) from ..process.module_info import ModuleInfo from ..process.region import MemoryRegion from ..process.thread_info import ThreadInfo @@ -110,13 +115,15 @@ def read_process_memory( def search_by_addresses( self, pytype: Type[T], - bufflength: Optional[int], - addresses: Sequence[int], + bufflength: Optional[int] = None, + addresses: Sequence[int] = UNSET, *, raise_error: bool = False, memory_regions: Optional[Sequence[MemoryRegion]] = None, ) -> Generator[Tuple[int, Optional[T]], None, None]: self.__require_open() + if addresses is UNSET: + raise TypeError("addresses is required.") return search_values_by_addresses( self.pid, pytype, @@ -129,8 +136,8 @@ def search_by_addresses( def search_by_value( self, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, scan_type: ScanTypesEnum = ScanTypesEnum.EXACT_VALUE, *, progress_information: bool = False, @@ -147,7 +154,7 @@ def search_by_value( return search_addresses_by_value( self.pid, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, value), value, scan_type, progress_information, @@ -175,9 +182,9 @@ def search_by_pattern( def search_by_value_between( self, pytype: Type[T], - bufflength: Optional[int], - start: Union[bool, int, float, str, bytes], - end: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + start: Union[bool, int, float, str, bytes] = UNSET, + end: Union[bool, int, float, str, bytes] = UNSET, *, not_between: bool = False, progress_information: bool = False, @@ -194,7 +201,7 @@ def search_by_value_between( return search_addresses_by_value( self.pid, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, start, end), (start, end), scan_type, progress_information, @@ -206,8 +213,8 @@ def write_process_memory( self, address: int, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, ) -> Union[bool, int, float, str, bytes]: self.__require_open() w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value) diff --git a/PyMemoryEditor/macos/process.py b/PyMemoryEditor/macos/process.py index 666f45c..fdb816a 100644 --- a/PyMemoryEditor/macos/process.py +++ b/PyMemoryEditor/macos/process.py @@ -9,7 +9,12 @@ from ..process.module_info import ModuleInfo from ..process.region import MemoryRegion from ..process.thread_info import ThreadInfo -from ..util import prepare_write, resolve_bufflength +from ..util import ( + UNSET, + prepare_write, + resolve_bufflength, + resolve_bufflength_for_value, +) from .functions import ( allocate_memory, @@ -177,13 +182,15 @@ def _static_image_ranges(self): def search_by_addresses( self, pytype: Type[T], - bufflength: Optional[int], - addresses: Sequence[int], + bufflength: Optional[int] = None, + addresses: Sequence[int] = UNSET, *, raise_error: bool = False, memory_regions: Optional[Sequence[MemoryRegion]] = None, ) -> Generator[Tuple[int, Optional[T]], None, None]: self.__require_open() + if addresses is UNSET: + raise TypeError("addresses is required.") return search_values_by_addresses( self.__task, pytype, @@ -196,8 +203,8 @@ def search_by_addresses( def search_by_value( self, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, scan_type: ScanTypesEnum = ScanTypesEnum.EXACT_VALUE, *, progress_information: bool = False, @@ -214,7 +221,7 @@ def search_by_value( return search_addresses_by_value( self.__task, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, value), value, scan_type, progress_information, @@ -242,9 +249,9 @@ def search_by_pattern( def search_by_value_between( self, pytype: Type[T], - bufflength: Optional[int], - start: Union[bool, int, float, str, bytes], - end: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + start: Union[bool, int, float, str, bytes] = UNSET, + end: Union[bool, int, float, str, bytes] = UNSET, *, not_between: bool = False, progress_information: bool = False, @@ -261,7 +268,7 @@ def search_by_value_between( return search_addresses_by_value( self.__task, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, start, end), (start, end), scan_type, progress_information, @@ -284,8 +291,8 @@ def write_process_memory( self, address: int, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, ) -> Union[bool, int, float, str, bytes]: """ Write a value to a memory address. @@ -303,9 +310,11 @@ def write_process_memory( :param address: target memory address. :param pytype: type of value to be written (bool, int, float, str, bytes). - :param bufflength: value size in bytes. ``None`` uses the default for - numeric types (int→4, float→8, bool→1); ``str``/``bytes`` require - an explicit size. + :param bufflength: value size in bytes. Optional — defaults to ``None``, + which uses the default width for numeric types (int→4, float→8, + bool→1) and writes the exact encoded length for ``str`` / ``bytes``. + Since it is optional, pass ``value`` by keyword when omitting it + (``write_process_memory(addr, str, value="hi")``). :param value: value to be written. """ self.__require_open() diff --git a/PyMemoryEditor/process/abstract.py b/PyMemoryEditor/process/abstract.py index a3ec5bb..d81623f 100644 --- a/PyMemoryEditor/process/abstract.py +++ b/PyMemoryEditor/process/abstract.py @@ -17,6 +17,7 @@ ) from ..enums import ScanTypesEnum +from ..util import UNSET from .info import ProcessInfo from .module_info import ModuleInfo from .region import MemoryRegion, MemoryRegionSnapshot @@ -224,8 +225,8 @@ def snapshot_memory_regions(self) -> MemoryRegionSnapshot: def search_by_addresses( self, pytype: Type[T], - bufflength: Optional[int], - addresses: Sequence[int], + bufflength: Optional[int] = None, + addresses: Sequence[int] = UNSET, *, raise_error: bool = False, memory_regions: Optional[Sequence[MemoryRegion]] = None, @@ -234,6 +235,14 @@ def search_by_addresses( Search the whole memory space, accessible to the process, for the provided list of addresses, returning their values. + :param bufflength: value size in bytes. Optional — defaults to ``None``, + which uses the default width for numeric types (int→4, float→8, + bool→1). ``str`` / ``bytes`` still require an explicit size here: + unlike a search by value, there is no value to infer the width + from — only addresses to read. Since ``bufflength`` is optional, + pass ``addresses`` by keyword when omitting it: + ``search_by_addresses(int, addresses=[0x1000, 0x1004])``. + :param addresses: the addresses to read. Required. :param memory_regions: optional snapshot returned by `snapshot_memory_regions()`. Pass it to skip the region enumeration on hot iterative workflows. """ @@ -243,8 +252,8 @@ def search_by_addresses( def search_by_value( self, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, scan_type: ScanTypesEnum = ScanTypesEnum.EXACT_VALUE, *, progress_information: bool = False, @@ -256,10 +265,13 @@ def search_by_value( for the provided value, returning the found addresses. :param pytype: type of value to be queried (bool, int, float, str or bytes). - :param bufflength: value size in bytes (1, 2, 4, 8). For numeric types - (int, float, bool) you may pass None to use the default - (int→4, float→8, bool→1). str and bytes require an explicit value. + :param bufflength: value size in bytes (1, 2, 4, 8). Optional — defaults + to ``None``: numeric types (int, float, bool) use their default + width (int→4, float→8, bool→1) and ``str`` / ``bytes`` infer it from + the encoded length of ``value``. Since it is optional, pass ``value`` + by keyword when omitting it: ``search_by_value(int, value=100)``. :param value: value to be queried (bool, int, float, str or bytes). + Required. :param scan_type: the way to compare the values. :param progress_information: if True, a dictionary with the progress information will be returned. :param writeable_only: if True, search only at writeable memory regions. @@ -300,9 +312,9 @@ def search_by_pattern( def search_by_value_between( self, pytype: Type[T], - bufflength: Optional[int], - start: Union[bool, int, float, str, bytes], - end: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + start: Union[bool, int, float, str, bytes] = UNSET, + end: Union[bool, int, float, str, bytes] = UNSET, *, not_between: bool = False, progress_information: bool = False, @@ -313,7 +325,11 @@ def search_by_value_between( Search the whole memory space, accessible to the process, for a value within the provided range, returning the found addresses. - See `search_by_value` for parameter semantics. + See `search_by_value` for parameter semantics. ``bufflength`` is + likewise optional (defaults to ``None``): for ``str`` / ``bytes`` it is + inferred from the longest of ``start`` / ``end`` (the shorter endpoint + is NUL-padded to that width). Pass ``start`` / ``end`` by keyword when + omitting ``bufflength``: ``search_by_value_between(int, start=10, end=20)``. """ raise NotImplementedError() @@ -348,28 +364,35 @@ def write_process_memory( self, address: int, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, ) -> Union[bool, int, float, str, bytes]: """ Write a value to a memory address. :param address: target memory address (ex: 0x006A9EC0). :param pytype: type of value to be written into memory (bool, int, float, str or bytes). - :param bufflength: value size in bytes. + :param bufflength: value size in bytes. Optional — defaults to ``None``. * For numeric types (int, float, bool) it is the exact write width; - pass ``None`` to use the default — int→4, float→8, bool→1. + leave it as ``None`` to use the default — int→4, float→8, bool→1. * For ``str`` / ``bytes`` it is a *minimum* field width, not a hard cap. The whole value is always written: if its encoded form is longer than ``bufflength`` every byte is still written (so ``write(addr, str, 3, "olá")`` writes all 4 UTF-8 bytes instead of raising — you may count characters, not bytes). If it is shorter, the field is NUL-padded up to ``bufflength`` (handy to - clear a fixed-size buffer). ``None`` writes exactly the encoded - length. ``str`` is encoded as UTF-8; no NUL terminator is - appended. - :param value: value to be written. + clear a fixed-size buffer). ``None`` (the default) writes exactly + the encoded length. ``str`` is encoded as UTF-8; no NUL terminator + is appended. + :param value: value to be written. Required — since ``bufflength`` is + now optional, pass it by keyword when omitting ``bufflength``:: + + write_process_memory(address, str, value="hi") + write_process_memory(address, int, value=99) + + Positional calls keep working unchanged + (``write_process_memory(address, int, 4, 99)``). :return: the original ``value`` passed in. """ raise NotImplementedError() diff --git a/PyMemoryEditor/util/__init__.py b/PyMemoryEditor/util/__init__.py index 03bd953..aa93f97 100644 --- a/PyMemoryEditor/util/__init__.py +++ b/PyMemoryEditor/util/__init__.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- from .convert import ( + UNSET, _validate_pytype, convert_from_byte_array, get_c_type_of, prepare_write, resolve_bufflength, + resolve_bufflength_for_value, value_to_bytes, values_to_bytes, ) diff --git a/PyMemoryEditor/util/convert.py b/PyMemoryEditor/util/convert.py index ae4e665..702a38e 100644 --- a/PyMemoryEditor/util/convert.py +++ b/PyMemoryEditor/util/convert.py @@ -13,6 +13,23 @@ _SUPPORTED_PYTYPES = (bool, int, float, str, bytes) +# Sentinel marking a `value` argument that the caller never supplied. It lets +# `write_process_memory(address, pytype, value=...)` keep `bufflength` optional +# (defaulting to None) while still leaving `value` required — a plain `None` +# default would be ambiguous since None can't be told apart from "not passed", +# and a required positional can't follow an optional one. +class _Unset: + def __repr__(self) -> str: # pragma: no cover - cosmetic + return "" + + +# Typed as ``Any`` so it can stand in as the default for parameters whose real +# type is ``Sequence[int]`` / ``bool | int | ...`` without mypy flagging an +# incompatible default — the sentinel is swapped out (or rejected) before the +# value is ever used as its declared type. +UNSET: Any = _Unset() + + def _validate_pytype(pytype: Type) -> None: """ Raise ``ValueError`` when ``pytype`` is not one of the five supported @@ -49,6 +66,43 @@ def resolve_bufflength(pytype: Type, bufflength: Optional[int]) -> int: ) +def resolve_bufflength_for_value(pytype: Type, bufflength: Optional[int], *values) -> int: + """ + Like :func:`resolve_bufflength`, but for operations that already carry the + value(s) being matched (the search methods). When ``bufflength`` is ``None``: + + * **numeric / bool** — fall back to the default width (int→4, float→8, + bool→1), exactly like :func:`resolve_bufflength`; + * **str / bytes** — infer the width from the longest encoded value instead + of raising, so ``search_by_value(str, value="hi")`` works without the + caller counting bytes. ``str`` is encoded as UTF-8. For a range search the + shorter endpoint is NUL-padded up to this width (the fixed-width + comparison the backend performs). + + A read can't infer this (it has no value to measure), which is why + :func:`resolve_bufflength` still requires an explicit size there. + """ + if any(isinstance(v, _Unset) for v in values): + raise TypeError("a search value is required (none was provided).") + + if bufflength is not None: + return bufflength + + if pytype is str or pytype is bytes: + lengths = [] + for v in values: + raw = v.encode("utf-8") if isinstance(v, str) else v + if not isinstance(raw, (bytes, bytearray)): + raise TypeError( + "value must be str or bytes when pytype is str/bytes, got %s." + % type(v).__name__ + ) + lengths.append(len(raw)) + return max(lengths) if lengths else 0 + + return resolve_bufflength(pytype, bufflength) + + def prepare_write( pytype: Type, bufflength: Optional[int], value ) -> Tuple[Type, int, Any]: @@ -76,6 +130,11 @@ def prepare_write( The caller is expected to return its *original* ``value`` to the user, so this routing through ``bytes`` stays invisible at the public API. """ + if isinstance(value, _Unset): + raise TypeError( + "write_process_memory() missing required argument: 'value'." + ) + _validate_pytype(pytype) if pytype is str or pytype is bytes: diff --git a/PyMemoryEditor/win32/process.py b/PyMemoryEditor/win32/process.py index 87595da..bf01c5f 100644 --- a/PyMemoryEditor/win32/process.py +++ b/PyMemoryEditor/win32/process.py @@ -3,7 +3,12 @@ import ctypes from typing import Dict, Generator, Optional, Sequence, Tuple, Type, TypeVar, Union -from ..util import prepare_write, resolve_bufflength +from ..util import ( + UNSET, + prepare_write, + resolve_bufflength, + resolve_bufflength_for_value, +) from ..enums import ScanTypesEnum from ..process import AbstractProcess @@ -193,14 +198,16 @@ def get_modules(self) -> Generator[ModuleInfo, None, None]: def search_by_addresses( self, pytype: Type[T], - bufflength: Optional[int], - addresses: Sequence[int], + bufflength: Optional[int] = None, + addresses: Sequence[int] = UNSET, *, raise_error: bool = False, memory_regions: Optional[Sequence[MemoryRegion]] = None, ) -> Generator[Tuple[int, Optional[T]], None, None]: self.__require_open() self.__require_read() + if addresses is UNSET: + raise TypeError("addresses is required.") return SearchValuesByAddresses( self.__process_handle, pytype, @@ -213,8 +220,8 @@ def search_by_addresses( def search_by_value( self, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, scan_type: ScanTypesEnum = ScanTypesEnum.EXACT_VALUE, *, progress_information: bool = False, @@ -232,7 +239,7 @@ def search_by_value( return SearchAddressesByValue( self.__process_handle, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, value), value, scan_type, progress_information, @@ -261,9 +268,9 @@ def search_by_pattern( def search_by_value_between( self, pytype: Type[T], - bufflength: Optional[int], - start: Union[bool, int, float, str, bytes], - end: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + start: Union[bool, int, float, str, bytes] = UNSET, + end: Union[bool, int, float, str, bytes] = UNSET, *, not_between: bool = False, progress_information: bool = False, @@ -281,7 +288,7 @@ def search_by_value_between( return SearchAddressesByValue( self.__process_handle, pytype, - resolve_bufflength(pytype, bufflength), + resolve_bufflength_for_value(pytype, bufflength, start, end), (start, end), scan_type, progress_information, @@ -308,8 +315,8 @@ def write_process_memory( self, address: int, pytype: Type[T], - bufflength: Optional[int], - value: Union[bool, int, float, str, bytes], + bufflength: Optional[int] = None, + value: Union[bool, int, float, str, bytes] = UNSET, ) -> Union[bool, int, float, str, bytes]: self.__require_open() self.__require_write() diff --git a/docs/api/openprocess.md b/docs/api/openprocess.md index abc7baa..7256b27 100644 --- a/docs/api/openprocess.md +++ b/docs/api/openprocess.md @@ -103,15 +103,19 @@ with OpenProcess( :param int bufflength: value size in bytes (optional for numeric types). :returns: the decoded value. -.. py:method:: write_process_memory(address, pytype, bufflength, value) +.. py:method:: write_process_memory(address, pytype, bufflength=None, value=...) Write a value to memory. :param int address: target memory address. :param Type pytype: one of the five supported types. - :param int bufflength: value size in bytes (``None`` for numeric defaults). - For ``str`` / ``bytes`` it is a *minimum* width — the whole value is - always written, and a larger size zero-pads the field. + :param int bufflength: value size in bytes. **Optional** (defaults to + ``None``): numeric types fall back to their default width and ``str`` / + ``bytes`` write the exact encoded length. For ``str`` / ``bytes`` a value + *larger* than the data is a *minimum* width — the whole value is always + written, and the extra space zero-pads the field. Because it is optional, + pass ``value`` by keyword when omitting it (``write_process_memory(addr, + int, value=9999)``). :param value: the value to write. :returns: the written value. ``` @@ -185,20 +189,25 @@ identical on every platform. ### Searching ```{eval-rst} -.. py:method:: search_by_value(pytype, bufflength, value, scan_type=ScanTypesEnum.EXACT_VALUE, *, progress_information=False, writeable_only=False, memory_regions=None) +.. py:method:: search_by_value(pytype, bufflength=None, value=..., scan_type=ScanTypesEnum.EXACT_VALUE, *, progress_information=False, writeable_only=False, memory_regions=None) Yield every address holding ``value`` (compared per ``scan_type``). - See :doc:`../guide/searching` for a full walkthrough. + ``bufflength`` is optional (numeric types use their default width; ``str`` / + ``bytes`` infer it from ``value``) — pass ``value`` by keyword when omitting + it. See :doc:`../guide/searching` for a full walkthrough. -.. py:method:: search_by_value_between(pytype, bufflength, start, end, *, not_between=False, progress_information=False, writeable_only=False, memory_regions=None) +.. py:method:: search_by_value_between(pytype, bufflength=None, start=..., end=..., *, not_between=False, progress_information=False, writeable_only=False, memory_regions=None) Yield every address whose value is in ``[start, end]`` (or outside, with ``not_between=True``). -.. py:method:: search_by_addresses(pytype, bufflength, addresses, *, raise_error=False, memory_regions=None) +.. py:method:: search_by_addresses(pytype, bufflength=None, addresses=..., *, raise_error=False, memory_regions=None) Read each address in ``addresses`` once, yielding ``(address, value)``. - Far faster than looping over :py:meth:`read_process_memory`. + Far faster than looping over :py:meth:`read_process_memory`. ``bufflength`` + is optional for numeric types; ``str`` / ``bytes`` still need an explicit + size (no value to infer from) — pass ``addresses`` by keyword when omitting + it. .. py:method:: search_by_pattern(pattern, *, byte_length=0, progress_information=False, memory_regions=None) diff --git a/docs/guide/read-write.md b/docs/guide/read-write.md index 6219a9e..ca749c5 100644 --- a/docs/guide/read-write.md +++ b/docs/guide/read-write.md @@ -80,17 +80,17 @@ If you need the bytes verbatim, pass `pytype=bytes`. with OpenProcess(process_name="notepad.exe") as process: address = 0x0005000C - # Write an int (None = default size of 4 bytes) - process.write_process_memory(address, int, None, 9999) + # Write an int — bufflength is optional, so pass value by keyword. + process.write_process_memory(address, int, value=9999) - # Write a 2-byte int explicitly + # Write a 2-byte int explicitly (positional bufflength still works). process.write_process_memory(address, int, 2, 42) - # Write a string — the size is optional; None just stores your text as-is - process.write_process_memory(address, str, None, "Hello!") + # Write a string — no size needed; your text is stored as-is. + process.write_process_memory(address, str, value="Hello!") # Write raw bytes - process.write_process_memory(address, bytes, 4, b"\xDE\xAD\xBE\xEF") + process.write_process_memory(address, bytes, value=b"\xDE\xAD\xBE\xEF") ``` ```{admonition} Writing text? Count characters, not bytes. @@ -106,13 +106,16 @@ size to clear a fixed-size field (the extra space is zero-filled). ### Method signature ```{eval-rst} -.. py:method:: write_process_memory(address, pytype, bufflength, value) +.. py:method:: write_process_memory(address, pytype, bufflength=None, value=...) :no-index: :param int address: target memory address. :param Type pytype: one of ``bool``, ``int``, ``float``, ``str``, ``bytes``. - :param int bufflength: value size in bytes (``None`` for numeric types to use - the default). + :param int bufflength: value size in bytes. **Optional** — defaults to + ``None``, which uses the default width for numeric types and writes the + exact encoded length for ``str`` / ``bytes``. Since it is optional, pass + ``value`` by keyword when you omit it: ``write_process_memory(addr, int, + value=9999)``. :param value: the value to write. :return: the written value. ``` diff --git a/docs/guide/searching.md b/docs/guide/searching.md index c0971bf..0aedcb8 100644 --- a/docs/guide/searching.md +++ b/docs/guide/searching.md @@ -32,12 +32,15 @@ memory. ### Method signature ```{eval-rst} -.. py:method:: search_by_value(pytype, bufflength, value, scan_type=ScanTypesEnum.EXACT_VALUE, *, progress_information=False, writeable_only=False, memory_regions=None) +.. py:method:: search_by_value(pytype, bufflength=None, value=..., scan_type=ScanTypesEnum.EXACT_VALUE, *, progress_information=False, writeable_only=False, memory_regions=None) :no-index: :param Type pytype: ``bool``, ``int``, ``float``, ``str`` or ``bytes``. - :param int bufflength: value size in bytes (1, 2, 4, 8). Pass ``None`` for - numeric types to use the default. + :param int bufflength: value size in bytes (1, 2, 4, 8). **Optional** — + defaults to ``None``: numeric types use their default width and ``str`` / + ``bytes`` infer it from the encoded length of ``value``. Since it is + optional, pass ``value`` by keyword when omitting it + (``search_by_value(int, value=100)``). :param value: the value to look for. :param ScanTypesEnum scan_type: comparison mode — see below. :param bool progress_information: when ``True``, yields ``(address, info)`` tuples @@ -108,7 +111,7 @@ for address in process.search_by_value_between( ### Method signature ```{eval-rst} -.. py:method:: search_by_value_between(pytype, bufflength, start, end, *, not_between=False, progress_information=False, writeable_only=False, memory_regions=None) +.. py:method:: search_by_value_between(pytype, bufflength=None, start=..., end=..., *, not_between=False, progress_information=False, writeable_only=False, memory_regions=None) :no-index: ``` @@ -117,6 +120,9 @@ Same parameters as `search_by_value`, plus: - `start`, `end` — the range boundaries (inclusive). - `not_between` — when `True`, returns values **outside** the range. +`bufflength` is optional here too (defaults to `None`); pass `start` / `end` by +keyword when you omit it: `search_by_value_between(int, start=100, end=200)`. + ## Search by addresses When you already know **which addresses to check** (typically because you @@ -136,9 +142,13 @@ If an address falls in an unmapped page, the value is `None` (unless ### Method signature ```{eval-rst} -.. py:method:: search_by_addresses(pytype, bufflength, addresses, *, raise_error=False, memory_regions=None) +.. py:method:: search_by_addresses(pytype, bufflength=None, addresses=..., *, raise_error=False, memory_regions=None) :no-index: + :param int bufflength: value size in bytes. **Optional** for numeric types + (defaults to ``None`` → int→4, float→8, bool→1). ``str`` / ``bytes`` still + require an explicit size — there is no value to infer it from, only + addresses to read. Pass ``addresses`` by keyword when omitting it. :param Sequence[int] addresses: addresses to inspect. :param bool raise_error: when ``True``, raises ``OSError`` instead of yielding ``None`` for an unreadable address. @@ -161,12 +171,12 @@ with OpenProcess(pid=1234) as process: regions = process.snapshot_memory_regions() # First pass — every address holding 100. - candidates = list(process.search_by_value(int, None, 100, memory_regions=regions)) + candidates = list(process.search_by_value(int, value=100, memory_regions=regions)) # Refine — keep only those that now hold 95. refined = [ addr - for addr, value in process.search_by_addresses(int, None, candidates, memory_regions=regions) + for addr, value in process.search_by_addresses(int, addresses=candidates, memory_regions=regions) if value == 95 ] ``` diff --git a/docs/index.md b/docs/index.md index 09b039a..4ac6f74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,8 +43,9 @@ with OpenProcess(process_name="game.exe") as process: for address in process.search_by_value(int, 4, 100): print(f"Found at 0x{address:X}") - # Write a new value at a known address. - process.write_process_memory(address, int, 4, 9999) + # Read the current value, then write a new one back. + current = process.read_int(address) + process.write_int(address, current + 500) ``` ```{admonition} Enjoying PyMemoryEditor? diff --git a/tests/test_bufflength_inference.py b/tests/test_bufflength_inference.py index 4cf7d6a..0e96cee 100644 --- a/tests/test_bufflength_inference.py +++ b/tests/test_bufflength_inference.py @@ -16,7 +16,11 @@ from PyMemoryEditor import OpenProcess # noqa: E402 -from PyMemoryEditor.util import resolve_bufflength # noqa: E402 +from PyMemoryEditor.util import ( # noqa: E402 + UNSET, + resolve_bufflength, + resolve_bufflength_for_value, +) def test_resolve_bufflength_defaults(): @@ -78,3 +82,150 @@ def test_read_process_memory_str_requires_bufflength(): process.read_process_memory(address, str) finally: process.close() + + +# --- resolve_bufflength_for_value (the search helper) ------------------- # + + +def test_resolve_for_value_numeric_uses_defaults(): + assert resolve_bufflength_for_value(int, None, 100) == 4 + assert resolve_bufflength_for_value(float, None, 1.0) == 8 + assert resolve_bufflength_for_value(bool, None, True) == 1 + + +def test_resolve_for_value_honors_explicit(): + assert resolve_bufflength_for_value(int, 2, 100) == 2 + assert resolve_bufflength_for_value(str, 16, "hi") == 16 + + +def test_resolve_for_value_infers_str_bytes_from_value(): + """Unlike a read, a search carries the value, so str/bytes infer the width.""" + assert resolve_bufflength_for_value(str, None, "hello") == 5 + assert resolve_bufflength_for_value(str, None, "olá") == 4 # UTF-8 + assert resolve_bufflength_for_value(bytes, None, b"\x01\x02\x03") == 3 + + +def test_resolve_for_value_between_uses_longest_endpoint(): + assert resolve_bufflength_for_value(str, None, "a", "abcd") == 4 + assert resolve_bufflength_for_value(bytes, None, b"\x01", b"\x01\x02") == 2 + + +def test_resolve_for_value_missing_value_raises(): + with pytest.raises(TypeError, match="a search value is required"): + resolve_bufflength_for_value(int, None, UNSET) + with pytest.raises(TypeError, match="a search value is required"): + resolve_bufflength_for_value(str, None, "ok", UNSET) + + +# --- search_by_value / _between: bufflength is optional ----------------- # + + +def test_search_by_value_numeric_without_bufflength(): + """search_by_value(int, value=...) — no bufflength needed.""" + target = ctypes.c_int32(0x51A2B3C4) + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + found = list(process.search_by_value(int, value=0x51A2B3C4)) + assert address in found + finally: + process.close() + + +def test_search_by_value_str_infers_width(): + target = ctypes.create_string_buffer(b"PYMEMSEARCHMARKER") + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + found = list(process.search_by_value(str, value="PYMEMSEARCHMARKER")) + assert address in found + finally: + process.close() + + +def test_search_by_value_between_without_bufflength(): + target = ctypes.c_int32(0x4242) + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + found = list(process.search_by_value_between(int, start=0x4241, end=0x4243)) + assert address in found + finally: + process.close() + + +def test_search_positional_form_still_works(): + target = ctypes.c_int32(0x1357) + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + assert address in list(process.search_by_value(int, 4, 0x1357)) + assert address in list(process.search_by_value_between(int, 4, 0x1356, 0x1358)) + finally: + process.close() + + +def test_search_missing_value_raises(): + process = OpenProcess(pid=os.getpid()) + try: + with pytest.raises(TypeError, match="a search value is required"): + next(process.search_by_value(int)) + with pytest.raises(TypeError, match="a search value is required"): + next(process.search_by_value_between(int, start=1)) + finally: + process.close() + + +# --- search_by_addresses: bufflength optional for numeric --------------- # + + +def test_search_by_addresses_numeric_without_bufflength(): + a = ctypes.c_int32(111) + b = ctypes.c_int32(222) + addr_a, addr_b = ctypes.addressof(a), ctypes.addressof(b) + + process = OpenProcess(pid=os.getpid()) + try: + result = dict(process.search_by_addresses(int, addresses=[addr_a, addr_b])) + assert result[addr_a] == 111 + assert result[addr_b] == 222 + finally: + process.close() + + +def test_search_by_addresses_positional_form_still_works(): + target = ctypes.c_int32(0x2468) + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + result = dict(process.search_by_addresses(int, 4, [address])) + assert result[address] == 0x2468 + finally: + process.close() + + +def test_search_by_addresses_str_still_requires_bufflength(): + """No value to infer from here — only addresses — so str/bytes still need it.""" + target = ctypes.c_int32(0) + address = ctypes.addressof(target) + + process = OpenProcess(pid=os.getpid()) + try: + with pytest.raises(ValueError, match="bufflength is required"): + list(process.search_by_addresses(str, addresses=[address])) + finally: + process.close() + + +def test_search_by_addresses_missing_addresses_raises(): + process = OpenProcess(pid=os.getpid()) + try: + with pytest.raises(TypeError, match="addresses is required"): + list(process.search_by_addresses(int)) + finally: + process.close() diff --git a/tests/test_write_str_bytes_width.py b/tests/test_write_str_bytes_width.py index 6556e24..1df42ea 100644 --- a/tests/test_write_str_bytes_width.py +++ b/tests/test_write_str_bytes_width.py @@ -21,7 +21,7 @@ from PyMemoryEditor import OpenProcess # noqa: E402 -from PyMemoryEditor.util import prepare_write # noqa: E402 +from PyMemoryEditor.util import UNSET, prepare_write # noqa: E402 @pytest.fixture @@ -76,6 +76,12 @@ def test_prepare_write_rejects_non_str_bytes_value(): prepare_write(bytes, 4, 1234) +def test_prepare_write_rejects_missing_value(): + """The UNSET sentinel (caller never passed ``value``) raises a clear error.""" + with pytest.raises(TypeError, match="missing required argument: 'value'"): + prepare_write(str, None, UNSET) + + # --- end-to-end writes against our own memory --------------------------- # @@ -106,3 +112,41 @@ def test_write_bytes_round_trip_grows(process): buffer = (ctypes.c_uint8 * 4)() process.write_process_memory(ctypes.addressof(buffer), bytes, 2, b"\xde\xad\xbe\xef") assert process.read_bytes(ctypes.addressof(buffer), 4) == b"\xde\xad\xbe\xef" + + +# --- bufflength is now optional: value may be passed by keyword ---------- # + + +def test_write_str_value_by_keyword_without_bufflength(process): + """write_process_memory(addr, str, value="hi") — no bufflength needed.""" + buffer = ctypes.create_string_buffer(8) + result = process.write_process_memory(ctypes.addressof(buffer), str, value="hi") + assert result == "hi" + assert process.read_string(ctypes.addressof(buffer), 8) == "hi" + + +def test_write_int_value_by_keyword_without_bufflength(process): + """Numeric writes default to their natural width (int→4) when omitted.""" + buffer = (ctypes.c_uint8 * 4)() + assert process.write_process_memory(ctypes.addressof(buffer), int, value=99) == 99 + assert process.read_process_memory(ctypes.addressof(buffer), int, 4) == 99 + + +def test_write_bytes_value_by_keyword_without_bufflength(process): + buffer = (ctypes.c_uint8 * 2)() + process.write_process_memory(ctypes.addressof(buffer), bytes, value=b"\x01\x02") + assert process.read_bytes(ctypes.addressof(buffer), 2) == b"\x01\x02" + + +def test_write_positional_call_still_works(process): + """The original 4-positional-arg form is unchanged.""" + buffer = (ctypes.c_uint8 * 4)() + assert process.write_process_memory(ctypes.addressof(buffer), int, 4, 1234) == 1234 + assert process.read_process_memory(ctypes.addressof(buffer), int, 4) == 1234 + + +def test_write_missing_value_raises(process): + """Omitting value entirely is still an error, with a clear message.""" + buffer = (ctypes.c_uint8 * 4)() + with pytest.raises(TypeError, match="missing required argument: 'value'"): + process.write_process_memory(ctypes.addressof(buffer), int)