Skip to content

Commit c37165b

Browse files
Copilots3rius
andauthored
Add comprehensive tests and docstrings for counters module, fix CountersManager.update stub type
Co-authored-by: s3rius <18153319+s3rius@users.noreply.github.com> Agent-Logs-Url: https://github.com/taskiq-python/natsrpy/sessions/2733a833-233b-4952-805d-165167131405
1 parent ff1ca72 commit c37165b

File tree

3 files changed

+290
-5
lines changed

3 files changed

+290
-5
lines changed

python/natsrpy/_natsrpy_rs/js/counters.pyi

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,31 +155,89 @@ class CountersConfig:
155155

156156
@final
157157
class CounterEntry:
158+
"""A single counter entry retrieved from a counters stream.
159+
160+
Holds the current aggregated value for a counter subject along
161+
with metadata about cross-stream sources and the last increment.
162+
163+
Attributes:
164+
subject: the subject this counter entry belongs to.
165+
value: the current aggregated counter value.
166+
sources: mapping of source stream names to their per-subject
167+
counter contributions.
168+
increment: the value of the last increment applied, or ``None``
169+
when the entry was retrieved via ``Counters.get``.
170+
"""
171+
158172
subject: str
159173
value: int
160174
sources: dict[str, dict[str, int]]
161175
increment: int | None
162176

163177
@final
164178
class Counters:
179+
"""Handle for a JetStream counters stream.
180+
181+
Provides atomic increment, decrement, and retrieval operations
182+
on CRDT counters backed by a JetStream stream with
183+
``allow_message_counter`` enabled.
184+
"""
185+
165186
async def add(
166187
self,
167188
key: str,
168189
value: int,
169190
timeout: float | timedelta | None = None,
170-
) -> int: ...
191+
) -> int:
192+
"""Add an arbitrary value to a counter.
193+
194+
:param key: subject key identifying the counter.
195+
:param value: integer amount to add (may be negative).
196+
:param timeout: optional operation timeout in seconds or as
197+
a timedelta.
198+
:return: the new counter value after the addition.
199+
"""
200+
171201
async def incr(
172202
self,
173203
key: str,
174204
timeout: float | timedelta | None = None,
175-
) -> int: ...
205+
) -> int:
206+
"""Increment a counter by one.
207+
208+
Shorthand for ``add(key, 1)``.
209+
210+
:param key: subject key identifying the counter.
211+
:param timeout: optional operation timeout in seconds or as
212+
a timedelta.
213+
:return: the new counter value after the increment.
214+
"""
215+
176216
async def decr(
177217
self,
178218
key: str,
179219
timeout: float | timedelta | None = None,
180-
) -> int: ...
220+
) -> int:
221+
"""Decrement a counter by one.
222+
223+
Shorthand for ``add(key, -1)``.
224+
225+
:param key: subject key identifying the counter.
226+
:param timeout: optional operation timeout in seconds or as
227+
a timedelta.
228+
:return: the new counter value after the decrement.
229+
"""
230+
181231
async def get(
182232
self,
183233
key: str,
184234
timeout: float | timedelta | None = None,
185-
) -> CounterEntry: ...
235+
) -> CounterEntry:
236+
"""Retrieve the current value of a counter.
237+
238+
:param key: subject key identifying the counter.
239+
:param timeout: optional operation timeout in seconds or as
240+
a timedelta.
241+
:return: counter entry with the current value and metadata.
242+
:raises Exception: if no counter entry exists for the key.
243+
"""

python/natsrpy/_natsrpy_rs/js/managers.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class CountersManager:
9191
:return: True if the stream was deleted.
9292
"""
9393

94-
async def update(self, config: CountersConfig) -> Counters:
94+
async def update(self, config: StreamConfig) -> Counters:
9595
"""Update an existing counters stream configuration.
9696
9797
:param config: new stream configuration.

python/tests/test_counters.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import uuid
2+
3+
import pytest
4+
from natsrpy.js import CounterEntry, Counters, CountersConfig, JetStream, StreamConfig
5+
6+
7+
async def test_counters_create(js: JetStream) -> None:
8+
name = f"test-cnt-create-{uuid.uuid4().hex[:8]}"
9+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
10+
counters = await js.counters.create(config)
11+
try:
12+
assert isinstance(counters, Counters)
13+
finally:
14+
await js.counters.delete(name)
15+
16+
17+
async def test_counters_create_or_update(js: JetStream) -> None:
18+
name = f"test-cnt-cou-{uuid.uuid4().hex[:8]}"
19+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
20+
counters = await js.counters.create_or_update(config)
21+
try:
22+
assert isinstance(counters, Counters)
23+
config.description = "updated"
24+
counters2 = await js.counters.create_or_update(config)
25+
assert isinstance(counters2, Counters)
26+
finally:
27+
await js.counters.delete(name)
28+
29+
30+
async def test_counters_get(js: JetStream) -> None:
31+
name = f"test-cnt-get-{uuid.uuid4().hex[:8]}"
32+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
33+
await js.counters.create(config)
34+
try:
35+
counters = await js.counters.get(name)
36+
assert isinstance(counters, Counters)
37+
finally:
38+
await js.counters.delete(name)
39+
40+
41+
async def test_counters_delete(js: JetStream) -> None:
42+
name = f"test-cnt-del-{uuid.uuid4().hex[:8]}"
43+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
44+
await js.counters.create(config)
45+
result = await js.counters.delete(name)
46+
assert result is True
47+
48+
49+
async def test_counters_update(js: JetStream) -> None:
50+
name = f"test-cnt-upd-{uuid.uuid4().hex[:8]}"
51+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
52+
await js.counters.create(config)
53+
try:
54+
update_cfg = StreamConfig(
55+
name=name,
56+
subjects=[f"{name}.>"],
57+
allow_direct=True,
58+
allow_message_counter=True,
59+
description="updated description",
60+
)
61+
counters = await js.counters.update(update_cfg)
62+
assert isinstance(counters, Counters)
63+
finally:
64+
await js.counters.delete(name)
65+
66+
67+
async def test_counters_incr(js: JetStream) -> None:
68+
name = f"test-cnt-incr-{uuid.uuid4().hex[:8]}"
69+
subj = f"{name}.hits"
70+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
71+
counters = await js.counters.create(config)
72+
try:
73+
value = await counters.incr(subj)
74+
assert value == 1
75+
finally:
76+
await js.counters.delete(name)
77+
78+
79+
async def test_counters_decr(js: JetStream) -> None:
80+
name = f"test-cnt-decr-{uuid.uuid4().hex[:8]}"
81+
subj = f"{name}.hits"
82+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
83+
counters = await js.counters.create(config)
84+
try:
85+
value = await counters.decr(subj)
86+
assert value == -1
87+
finally:
88+
await js.counters.delete(name)
89+
90+
91+
async def test_counters_add(js: JetStream) -> None:
92+
name = f"test-cnt-add-{uuid.uuid4().hex[:8]}"
93+
subj = f"{name}.hits"
94+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
95+
counters = await js.counters.create(config)
96+
try:
97+
value = await counters.add(subj, 10)
98+
assert value == 10
99+
finally:
100+
await js.counters.delete(name)
101+
102+
103+
async def test_counters_add_negative(js: JetStream) -> None:
104+
name = f"test-cnt-addneg-{uuid.uuid4().hex[:8]}"
105+
subj = f"{name}.hits"
106+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
107+
counters = await js.counters.create(config)
108+
try:
109+
value = await counters.add(subj, -5)
110+
assert value == -5
111+
finally:
112+
await js.counters.delete(name)
113+
114+
115+
async def test_counters_get_entry(js: JetStream) -> None:
116+
name = f"test-cnt-gete-{uuid.uuid4().hex[:8]}"
117+
subj = f"{name}.hits"
118+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
119+
counters = await js.counters.create(config)
120+
try:
121+
await counters.incr(subj)
122+
entry = await counters.get(subj)
123+
assert isinstance(entry, CounterEntry)
124+
assert entry.subject == subj
125+
assert entry.value == 1
126+
finally:
127+
await js.counters.delete(name)
128+
129+
130+
async def test_counter_entry_attributes(js: JetStream) -> None:
131+
name = f"test-cnt-attr-{uuid.uuid4().hex[:8]}"
132+
subj = f"{name}.hits"
133+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
134+
counters = await js.counters.create(config)
135+
try:
136+
await counters.add(subj, 5)
137+
entry = await counters.get(subj)
138+
assert isinstance(entry.subject, str)
139+
assert isinstance(entry.value, int)
140+
assert isinstance(entry.sources, dict)
141+
assert entry.increment is None or isinstance(entry.increment, int)
142+
finally:
143+
await js.counters.delete(name)
144+
145+
146+
async def test_counters_multiple_increments(js: JetStream) -> None:
147+
name = f"test-cnt-multi-{uuid.uuid4().hex[:8]}"
148+
subj = f"{name}.hits"
149+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
150+
counters = await js.counters.create(config)
151+
try:
152+
val1 = await counters.incr(subj)
153+
val2 = await counters.incr(subj)
154+
val3 = await counters.incr(subj)
155+
assert val1 == 1
156+
assert val2 == 2
157+
assert val3 == 3
158+
entry = await counters.get(subj)
159+
assert entry.value == 3
160+
finally:
161+
await js.counters.delete(name)
162+
163+
164+
async def test_counters_incr_then_decr(js: JetStream) -> None:
165+
name = f"test-cnt-incdec-{uuid.uuid4().hex[:8]}"
166+
subj = f"{name}.hits"
167+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
168+
counters = await js.counters.create(config)
169+
try:
170+
await counters.incr(subj)
171+
await counters.incr(subj)
172+
await counters.decr(subj)
173+
entry = await counters.get(subj)
174+
assert entry.value == 1
175+
finally:
176+
await js.counters.delete(name)
177+
178+
179+
async def test_counters_separate_subjects(js: JetStream) -> None:
180+
name = f"test-cnt-sep-{uuid.uuid4().hex[:8]}"
181+
subj_a = f"{name}.a"
182+
subj_b = f"{name}.b"
183+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
184+
counters = await js.counters.create(config)
185+
try:
186+
await counters.add(subj_a, 10)
187+
await counters.add(subj_b, 20)
188+
entry_a = await counters.get(subj_a)
189+
entry_b = await counters.get(subj_b)
190+
assert entry_a.value == 10
191+
assert entry_b.value == 20
192+
finally:
193+
await js.counters.delete(name)
194+
195+
196+
async def test_counters_get_nonexistent_key(js: JetStream) -> None:
197+
name = f"test-cnt-nokey-{uuid.uuid4().hex[:8]}"
198+
config = CountersConfig(name=name, subjects=[f"{name}.>"])
199+
counters = await js.counters.create(config)
200+
try:
201+
with pytest.raises(Exception):
202+
await counters.get(f"{name}.nonexistent")
203+
finally:
204+
await js.counters.delete(name)
205+
206+
207+
async def test_counters_config_description(js: JetStream) -> None:
208+
name = f"test-cnt-desc-{uuid.uuid4().hex[:8]}"
209+
config = CountersConfig(
210+
name=name,
211+
subjects=[f"{name}.>"],
212+
description="A test counters stream",
213+
)
214+
counters = await js.counters.create(config)
215+
try:
216+
assert isinstance(counters, Counters)
217+
finally:
218+
await js.counters.delete(name)
219+
220+
221+
async def test_counters_config_defaults() -> None:
222+
config = CountersConfig(name="test", subjects=["test.>"])
223+
assert config.name == "test"
224+
assert config.subjects == ["test.>"]
225+
assert config.description is None
226+
assert config.max_bytes is not None
227+
assert config.max_messages is not None

0 commit comments

Comments
 (0)