Skip to content

Commit 2309ebd

Browse files
Copilots3rius
andauthored
Add test coverage and docstrings for counters module (#31)
Co-authored-by: s3rius <18153319+s3rius@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Pavel Kirilin <win10@list.ru>
1 parent 28049be commit 2309ebd

3 files changed

Lines changed: 289 additions & 6 deletions

File tree

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/tests/test_counters.py

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

src/js/managers/counters.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
use pyo3::{Bound, PyAny, Python};
88
use tokio::sync::RwLock;
99

10-
use crate::{exceptions::rust_err::NatsrpyResult, js::stream::StreamConfig, utils::natsrpy_future};
10+
use crate::{exceptions::rust_err::NatsrpyResult, utils::natsrpy_future};
1111

1212
#[pyo3::pyclass]
1313
pub struct CountersManager {
@@ -87,7 +87,7 @@ impl CountersManager {
8787
pub fn update<'py>(
8888
&self,
8989
py: Python<'py>,
90-
config: StreamConfig,
90+
config: CountersConfig,
9191
) -> NatsrpyResult<Bound<'py, PyAny>> {
9292
let ctx = self.ctx.clone();
9393
natsrpy_future(py, async move {

0 commit comments

Comments
 (0)