diff --git a/.claude/command-specification-template.md b/.claude/command-specification-template.md new file mode 100644 index 0000000000..029a741c42 --- /dev/null +++ b/.claude/command-specification-template.md @@ -0,0 +1,29 @@ +# $COMMAND_NAME command specification + +## Supported version + +Add supported Redis version here. For example: Redis >= 6.2.0 + +## Command description + +Add a description of the command here. + +## Command API + +Specify an API for the command in the format that official docs uses. For example: + +``` +$COMMAND_NAME $key $member [NX|XX] [CH] [INCR] +``` + +## Redis-CLI examples + +Add relevant Redis-CLI examples here. + +## Test plan + +Specify how you want to test the command in terms of integration testing. For example: + +- Test only with required arguments, assert that single value returned +- Test with required arguments and optional XX modifier, ensure that 1 returned +- ... diff --git a/.claude/commands/add-new-command.md b/.claude/commands/add-new-command.md new file mode 100644 index 0000000000..f79a51ce1c --- /dev/null +++ b/.claude/commands/add-new-command.md @@ -0,0 +1,87 @@ +--- +description: Adds support for a new Redis command from a given specification. Check command-specification-template.md. +argument-hint: [path-to-specification] +--- + +# Execute: Add new Redis command support + +## Plan to Execute + +Read specification file: `$ARGUMENTS` + +## Execution Instructions + +### 1. Preparations + +- Follow instructions from `.agent/instructions.md` +- Go through the guide `specs/redis_commands_guide.md` + +### 2. Read and Understand + +- Read the ENTIRE specification carefully +- Go through Command Description and identify command type (string, list, set, etc.) +- Go through the Command API: + - Identify required and optional arguments + - Identify how to match Redis command arguments type to Python types + - Identify return value and possible response types +- Check relevant Redis-Cli examples, if provided +- Review the Test Plan + +### 3. Execute Tasks in Order + +#### a. Navigate to the task +- Identify the files and action required +- Read existing related files if modifying + +#### b. Implement the command +- Add new command method within matching trait object (for example: `redis/commands/core.py` for core Redis commands) +- Ensure overloading is implemented for sync and async API +- Follow Arguments definition section from `specs/redis_commands_guide.md` before defining command arguments +- Ensure arguments and response types consider bytes representation +- Ensure response RESP2 and RESP3 compatibility via `response_callbacks` + +#### c. Verify as you go +- After each file change, check syntax +- Ensure imports are correct +- Verify types are properly defined +- Verify that response schema is similar for RESP2 and RESP3 + +### 4. Implement Testing Plan + +After completing implementation tasks: + +- Identify matching test file or create new one if needed +- Implement all test cases as separate test methods +- Ensure adding version constraint if specified in the specification +- Ensure tests cover edge cases + +### 5. Run tests + +- Run newly added test cases using `pytest` with RESP2 and RESP3 protocol specified via `--protocol` option +- Ensure that the same test cases passed with both protocols +- Get back to the Implementation stage if any test failed + +### 6. Final Verification + +Before completing: + +- ✅ All tasks from plan completed +- ✅ All tests created and passing +- ✅ Code follows project conventions +- ✅ Documentation added/updated as needed + +## Output Report + +Provide summary: + +### Completed Tasks +- List of all tasks completed +- Files created (with paths) +- Files modified (with paths) + +### Tests Added +- Test files created +- Test cases implemented +- Test results + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15dfd71229..a67a189954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,26 @@ If you don't know where to start, consider improving documentation, bug triaging, and writing tutorials are all examples of helpful contributions that mean less work for you. +## AI-driven contributions + +Redis-py defines a list of custom Claude commands (skills) that may simplify your contribution by enriching the agent's +context with necessary information and repository best practices. Commands are well-structured instructions for common +recurring tasks (e.g., adding support for a new Redis command API). + +The list of available commands is available via: + +**Claude CLI** + +``` +claude /skills +``` + +**Augment CLI** + +``` +auggie command list +``` + ## Your First Contribution Unsure where to begin contributing? You can start by looking through diff --git a/redis/commands/core.py b/redis/commands/core.py index 70b694dca3..cc1347c472 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -6801,6 +6801,78 @@ def xlen(self, name: KeyT) -> int | Awaitable[int]: """ return self.execute_command("XLEN", name, keys=[name]) + @overload + def xnack( + self: SyncClientProtocol, + name: KeyT, + groupname: GroupT, + mode: Literal["SILENT", "FAIL", "FATAL"], + *ids: StreamIdT, + retrycount: int | None = None, + force: bool = False, + ) -> int: ... + + @overload + def xnack( + self: AsyncClientProtocol, + name: KeyT, + groupname: GroupT, + mode: Literal["SILENT", "FAIL", "FATAL"], + *ids: StreamIdT, + retrycount: int | None = None, + force: bool = False, + ) -> Awaitable[int]: ... + + def xnack( + self, + name: KeyT, + groupname: GroupT, + mode: Literal["SILENT", "FAIL", "FATAL"], + *ids: StreamIdT, + retrycount: int | None = None, + force: bool = False, + ) -> int | Awaitable[int]: + """ + Negatively acknowledges one or more messages in a consumer group's + Pending Entries List (PEL). + + Args: + name: name of the stream. + groupname: name of the consumer group. + mode: the nacking mode. One of SILENT, FAIL, or FATAL. + SILENT: consumer shutting down; decrements delivery counter. + FAIL: consumer unable to process; delivery counter unchanged. + FATAL: invalid/malicious message; delivery counter set to max. + *ids: one or more message IDs to NACK. + retrycount: optional integer >= 0. Overrides the mode's implicit + delivery counter adjustment with an exact value. + force: if True, creates a new unowned PEL entry for any ID not + already in the group's PEL. + + Returns: + The number of messages successfully NACKed. + + For more information, see https://redis.io/commands/xnack + """ + if not ids: + raise DataError("XNACK requires at least one message ID") + + if mode not in {"SILENT", "FAIL", "FATAL"}: + raise DataError("XNACK mode must be one of: SILENT, FAIL, FATAL") + + pieces: list = [name, groupname, mode, "IDS", len(ids)] + pieces.extend(ids) + + if retrycount is not None: + if retrycount < 0: + raise DataError("XNACK retrycount must be >= 0") + pieces.extend([b"RETRYCOUNT", retrycount]) + + if force: + pieces.append(b"FORCE") + + return self.execute_command("XNACK", *pieces) + @overload def xpending( self: SyncClientProtocol, name: KeyT, groupname: GroupT diff --git a/specs/redis_commands_guide.md b/specs/redis_commands_guide.md new file mode 100644 index 0000000000..42fbb4c2e3 --- /dev/null +++ b/specs/redis_commands_guide.md @@ -0,0 +1,91 @@ +# Redis commands guide + +## Commands API specification + +The Redis API is specified in the [Redis documentation](https://redis.io/commands). This is the source of truth +for all command-related information. However, Redis is a living project and new commands are added all the time. + +A new command may not yet be available in the documentation. In this case, the developer needs to create a new +command specification from the `.claude/command-specification-template.md` template. + +## Files structure + +``` +redis/ +├── commands/ # Base directory +│ ├── module/ # Module commands directory +│ │ ├── commands.py # Module commands public API +│ │ └── file.py # Custom helpers +│ │... # Other modules +│ ├── __init__.py # Module exports +│ ├── cluster.py # Cluster commands public API +│ ├── core.py # Core commands public API +│ ├── helpers.py # Helpers for commands modules +│ ├── redismodules.py # Trait for all Redis modules +│ └── sentinel.py # Sentinel commands public API +``` + +## Commands Public API + +Commands public API exposed through the different types of clients (Standalone, Cluster, Sentinel), inheriting trait +objects defined in the directories according to the Files structure section above. Each command implementation at the +end calls generic `execute_command(*args, **kwargs)` defined by `CommandProtocol`. + +SDK expose sync and async commands API through methods overloading, see `.agent/sync_async_type_hints_overload_guide.md` +for more information. + +### Binary representation + +Our SDK can be configured with `decode_response=False` option which means that command response will be returned +as bytes, as well as we allow to pass bytes arguments instead of strings. For this purpose, we define a custom types +like `KeyT`, `EncodableT`, etc. in `redis/typing.py`. + +## Protocols compatibility + +SDK supports two types of Redis wire protocol: RESP2 and RESP3. And aims to provide a compatibility between them +for seamless user experience. However, there are some differences in types that defined by these protocols. + +Because, RESP3 introduce new types that wasn't previously supported by RESP2, we're aiming for forward compatibility +and ensure that all new types supported by RESP3 can be used with RESP2. In most cases the semantic of RESP3 can be +easily recognized and converting existing RESP2 response in RESP3 is a matter of parsing strategy. + +All new commands should be added with RESP3 in mind and by default should hide protocol incompatibility from user. + +To understand how does Redis types defined by RESP2 and RESP3 protocol maps to Python types, +see `redis/_parsers/resp2.py` and `redis/_parsers/resp3.py`. + +Parsers are responsible for RESP protocol parsing. However, protocols compatibility is achieved by 2nd layer +parsing as `response_callbacks` defined in `redis/_parsers/helpers.py` and can be extended/updated on client level. + +## Arguments definition + +Some of the Redis commands may have a complex API structure, so we need to make it user-friendly and apply +some transformation within specific command method. For example, some commands may need to have +a COUNT argument for aggregated types followed by number of arguments and arguments itself. In this case +we hide this complexity from user and instead expose argument as list in public API and transform it +to Redis-friendly format. + +```python +def scan( + self, + cursor: int = 0, + count: int | None = None, + _type: str | None = None, + **kwargs, +): + pieces: list = [cursor] + + if count is not None: + pieces.extend([b"COUNT", count]) +``` + +In terms of required and optional arguments we follow the specification and trying to reflect it as close +as possible. + +### Testing + +Command tests are located in `tests/test_*command_type*.py` and `tests/test_asyncio/test_*command_type*.py` for async +commands. So it's important to identify command type upfront to resolve correct test file. + +We usually provide only integration testing for commands with defined version constraint (if required). It's controlled +by custom annotations `@skip_if_server_version_lt()` and `@skip_if_server_version_gte()` defined in `tests/conftest.py`. diff --git a/tests/test_asyncio/test_commands.py b/tests/test_asyncio/test_commands.py index d73edbcfaf..7af96a3390 100644 --- a/tests/test_asyncio/test_commands.py +++ b/tests/test_asyncio/test_commands.py @@ -4301,6 +4301,114 @@ async def test_xlen(self, r: redis.Redis): await r.xadd(stream, {"foo": "bar"}) assert await r.xlen(stream) == 2 + @skip_if_server_version_lt("8.8.0") + async def test_xnack_silent(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + m2 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "SILENT", m1, m2) + assert result == 2 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_fail(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "FAIL", m1) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_fatal(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "FATAL", m1) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_multiple_ids(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + m2 = await r.xadd(stream, {"foo": "bar"}) + m3 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "FAIL", m1, m2, m3) + assert result == 3 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_some_ids_not_in_pel(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + m2 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "FAIL", m1, m2, "999999-0") + assert result == 2 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_retrycount(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + result = await r.xnack(stream, group, "FAIL", m1, retrycount=5) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_force(self, r: redis.Redis): + stream = "stream" + group = "group" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + result = await r.xnack(stream, group, "FAIL", m1, force=True) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_invalid_mode(self, r: redis.Redis): + stream = "stream" + group = "group" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + with pytest.raises(redis.DataError): + await r.xnack(stream, group, "INVALID", m1) + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_no_ids(self, r: redis.Redis): + stream = "stream" + group = "group" + await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + with pytest.raises(redis.DataError): + await r.xnack(stream, group, "FAIL") + + @skip_if_server_version_lt("8.8.0") + async def test_xnack_negative_retrycount(self, r: redis.Redis): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = await r.xadd(stream, {"foo": "bar"}) + await r.xgroup_create(stream, group, 0) + await r.xreadgroup(group, consumer, streams={stream: ">"}) + with pytest.raises(redis.DataError): + await r.xnack(stream, group, "FAIL", m1, retrycount=-1) + @skip_if_server_version_lt("5.0.0") async def test_xpending(self, r: redis.Redis): stream = "stream" diff --git a/tests/test_commands.py b/tests/test_commands.py index 0d5125e5bf..4b7f75848b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5986,6 +5986,120 @@ def test_xlen(self, r): r.xadd(stream, {"foo": "bar"}) assert r.xlen(stream) == 2 + @skip_if_server_version_lt("8.8.0") + def test_xnack_silent(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + # SILENT mode returns count of NACKed messages + result = r.xnack(stream, group, "SILENT", m1, m2) + assert result == 2 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_fail(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + # FAIL mode returns count of NACKed messages + result = r.xnack(stream, group, "FAIL", m1) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_fatal(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + # FATAL mode returns count of NACKed messages + result = r.xnack(stream, group, "FATAL", m1) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_multiple_ids(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + m3 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + result = r.xnack(stream, group, "FAIL", m1, m2, m3) + assert result == 3 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_some_ids_not_in_pel(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + m2 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + # Only m1 and m2 are in PEL; "999999-0" is not + result = r.xnack(stream, group, "FAIL", m1, m2, "999999-0") + assert result == 2 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_retrycount(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + # Explicit retrycount overrides mode's counter adjustment + result = r.xnack(stream, group, "FAIL", m1, retrycount=5) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_force(self, r): + stream = "stream" + group = "group" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + # FORCE creates unowned PEL entries for IDs not already in PEL + result = r.xnack(stream, group, "FAIL", m1, force=True) + assert result == 1 + + @skip_if_server_version_lt("8.8.0") + def test_xnack_invalid_mode(self, r): + stream = "stream" + group = "group" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + with pytest.raises(redis.DataError): + r.xnack(stream, group, "INVALID", m1) + + @skip_if_server_version_lt("8.8.0") + def test_xnack_no_ids(self, r): + stream = "stream" + group = "group" + r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + with pytest.raises(redis.DataError): + r.xnack(stream, group, "FAIL") + + @skip_if_server_version_lt("8.8.0") + def test_xnack_negative_retrycount(self, r): + stream = "stream" + group = "group" + consumer = "consumer" + m1 = r.xadd(stream, {"foo": "bar"}) + r.xgroup_create(stream, group, 0) + r.xreadgroup(group, consumer, streams={stream: ">"}) + with pytest.raises(redis.DataError): + r.xnack(stream, group, "FAIL", m1, retrycount=-1) + @skip_if_server_version_lt("5.0.0") def test_xpending(self, r): stream = "stream"