meta-memcache-socket 0.2.0b2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/.github/workflows/CI.yml +5 -5
  2. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/.github/workflows/tests.yml +1 -1
  3. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/Cargo.lock +1 -1
  4. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/Cargo.toml +1 -1
  5. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/PKG-INFO +1 -1
  6. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/README.md +20 -3
  7. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/meta_memcache_socket.pyi +45 -22
  8. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/encode_key.rs +7 -4
  9. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/request_flags.rs +101 -23
  10. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/request_flags_tests.rs +167 -0
  11. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/tests/test_memcache_socket.py +49 -0
  12. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/.github/pull_request_template.md +0 -0
  13. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/.gitignore +0 -0
  14. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/LICENSE +0 -0
  15. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/pyproject.toml +0 -0
  16. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/constants.rs +0 -0
  17. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/impl_build_cmd.rs +0 -0
  18. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/impl_build_cmd_tests.rs +0 -0
  19. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/impl_parse_header.rs +0 -0
  20. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/impl_parse_header_tests.rs +0 -0
  21. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/lib.rs +0 -0
  22. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/memcache_socket.rs +0 -0
  23. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/response_flags.rs +0 -0
  24. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/response_flags_tests.rs +0 -0
  25. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/src/response_types.rs +0 -0
  26. {meta_memcache_socket-0.2.0b2 → meta_memcache_socket-0.3.0}/tests/test_response_types.py +0 -0
@@ -42,7 +42,7 @@ jobs:
42
42
  with:
43
43
  python-version: 3.x
44
44
  - name: Build wheels
45
- uses: PyO3/maturin-action@v1
45
+ uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
46
46
  with:
47
47
  target: ${{ matrix.platform.target }}
48
48
  args: --release --out dist --find-interpreter
@@ -73,7 +73,7 @@ jobs:
73
73
  with:
74
74
  python-version: 3.x
75
75
  - name: Build wheels
76
- uses: PyO3/maturin-action@v1
76
+ uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
77
77
  with:
78
78
  target: ${{ matrix.platform.target }}
79
79
  args: --release --out dist --find-interpreter
@@ -100,7 +100,7 @@ jobs:
100
100
  with:
101
101
  python-version: 3.x
102
102
  - name: Build wheels
103
- uses: PyO3/maturin-action@v1
103
+ uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
104
104
  with:
105
105
  target: ${{ matrix.platform.target }}
106
106
  args: --release --out dist --find-interpreter
@@ -116,7 +116,7 @@ jobs:
116
116
  steps:
117
117
  - uses: actions/checkout@v6
118
118
  - name: Build sdist
119
- uses: PyO3/maturin-action@v1
119
+ uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
120
120
  with:
121
121
  command: sdist
122
122
  args: --out dist
@@ -146,7 +146,7 @@ jobs:
146
146
  subject-path: 'wheels-*/*'
147
147
  - name: Install uv
148
148
  if: ${{ startsWith(github.ref, 'refs/tags/') }}
149
- uses: astral-sh/setup-uv@v7
149
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
150
150
  - name: Publish to PyPI
151
151
  if: ${{ startsWith(github.ref, 'refs/tags/') }}
152
152
  run: uv publish 'wheels-*/*'
@@ -26,7 +26,7 @@ jobs:
26
26
  - uses: actions/setup-python@v5
27
27
  with:
28
28
  python-version: "3.x"
29
- - uses: astral-sh/setup-uv@v4
29
+ - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
30
30
  - name: Build and install extension
31
31
  run: uv run --with maturin maturin develop
32
32
  - name: Run Python tests
@@ -113,7 +113,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
113
113
 
114
114
  [[package]]
115
115
  name = "meta-memcache-socket"
116
- version = "0.2.0-beta.2"
116
+ version = "0.3.0"
117
117
  dependencies = [
118
118
  "atoi",
119
119
  "base64",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "meta-memcache-socket"
3
- version = "0.2.0-beta.2"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-memcache-socket
3
- Version: 0.2.0b2
3
+ Version: 0.3.0
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -29,7 +29,7 @@ meta-memcache-socket-py/
29
29
  │ ├── lib.rs # PyO3 module entry — exports classes, functions, constants
30
30
  │ ├── constants.rs # Protocol constants (response codes, set modes, NOOP, ENDL)
31
31
  │ ├── memcache_socket.rs # MemcacheSocket class — socket I/O, buffering, GIL management
32
- │ ├── request_flags.rs # RequestFlags class — mutable flags for building commands
32
+ │ ├── request_flags.rs # RequestFlags class — immutable flags for building commands
33
33
  │ ├── response_flags.rs # ResponseFlags class — immutable flags parsed from responses
34
34
  │ ├── response_types.rs # Response type classes (Value, Success, Miss, NotStored, Conflict)
35
35
  │ ├── impl_build_cmd.rs # Command builder — key validation, base64, flag encoding
@@ -175,7 +175,7 @@ flags.opaque # Optional[bytes] — echoed opaque data (O)
175
175
 
176
176
  ### RequestFlags
177
177
 
178
- Mutable container for flags sent with commands.
178
+ Immutable container for flags sent with commands.
179
179
 
180
180
  ```python
181
181
  from meta_memcache_socket import RequestFlags
@@ -205,11 +205,28 @@ flags = RequestFlags(
205
205
  opaque=None, # O — opaque data echoed back
206
206
  mode=None, # M — operation mode (set/arithmetic)
207
207
  )
208
+ ```
209
+
210
+ The flags are immutable, so they can be reused safely across threads when
211
+ calling meta commands. Internal layers migth need to mutate flags
212
+ (content id, reduce ttl, etc...) and will mutate them use replace() to create
213
+ modified copies when needed.
214
+
215
+ If you need to change flags on a existing RequestFlags, use the `replace()` method:
208
216
 
209
- flags.copy() # -> RequestFlags (deep copy)
217
+ ```python
218
+ new_flags = flags.replace(return_ttl=True, cache_ttl=600) # -> RequestFlags
219
+ ```
220
+
221
+ You can also encode the flags into a byte string for command building, showing
222
+ exactly what will be sent on the wire:
223
+
224
+ ```python
210
225
  flags.to_bytes() # -> bytes (encoded flag string)
211
226
  ```
212
227
 
228
+ For debugging purposes, stringifying it shows the flags in a human-readable format.
229
+
213
230
  ### Command builders
214
231
 
215
232
  Convenience functions that build meta-protocol command byte strings.
@@ -1,5 +1,5 @@
1
1
  import socket
2
- from typing import Any, Optional, Tuple, Union
2
+ from typing import Any, Final, Optional, Tuple, Union
3
3
 
4
4
  RESPONSE_VALUE: int # 1 - VALUE (VA)
5
5
  RESPONSE_SUCCESS: int # 2 - SUCCESS (OK or HD)
@@ -56,26 +56,26 @@ class RequestFlags:
56
56
  * mode: The mode to use when storing the value in the cache. See SET_MODE_* and MA_MODE_* constants
57
57
  """
58
58
 
59
- no_reply: bool
60
- return_client_flag: bool
61
- return_cas_token: bool
62
- return_value: bool
63
- return_ttl: bool
64
- return_size: bool
65
- return_last_access: bool
66
- return_fetched: bool
67
- return_key: bool
68
- no_update_lru: bool
69
- mark_stale: bool
70
- cache_ttl: Optional[int]
71
- recache_ttl: Optional[int]
72
- vivify_on_miss_ttl: Optional[int]
73
- client_flag: Optional[int]
74
- ma_initial_value: Optional[int]
75
- ma_delta_value: Optional[int]
76
- cas_token: Optional[int]
77
- opaque: Optional[bytes]
78
- mode: Optional[int]
59
+ no_reply: Final[bool]
60
+ return_client_flag: Final[bool]
61
+ return_cas_token: Final[bool]
62
+ return_value: Final[bool]
63
+ return_ttl: Final[bool]
64
+ return_size: Final[bool]
65
+ return_last_access: Final[bool]
66
+ return_fetched: Final[bool]
67
+ return_key: Final[bool]
68
+ no_update_lru: Final[bool]
69
+ mark_stale: Final[bool]
70
+ cache_ttl: Final[Optional[int]]
71
+ recache_ttl: Final[Optional[int]]
72
+ vivify_on_miss_ttl: Final[Optional[int]]
73
+ client_flag: Final[Optional[int]]
74
+ ma_initial_value: Final[Optional[int]]
75
+ ma_delta_value: Final[Optional[int]]
76
+ cas_token: Final[Optional[int]]
77
+ opaque: Final[Optional[bytes]]
78
+ mode: Final[Optional[int]]
79
79
 
80
80
  def __init__(
81
81
  self,
@@ -101,7 +101,30 @@ class RequestFlags:
101
101
  opaque: Optional[bytes] = None,
102
102
  mode: Optional[int] = None,
103
103
  ) -> None: ...
104
- def copy(self) -> "RequestFlags": ...
104
+ def replace(
105
+ self,
106
+ *,
107
+ no_reply: Optional[bool] = None,
108
+ return_client_flag: Optional[bool] = None,
109
+ return_cas_token: Optional[bool] = None,
110
+ return_value: Optional[bool] = None,
111
+ return_ttl: Optional[bool] = None,
112
+ return_size: Optional[bool] = None,
113
+ return_last_access: Optional[bool] = None,
114
+ return_fetched: Optional[bool] = None,
115
+ return_key: Optional[bool] = None,
116
+ no_update_lru: Optional[bool] = None,
117
+ mark_stale: Optional[bool] = None,
118
+ cache_ttl: Optional[int] = None,
119
+ recache_ttl: Optional[int] = None,
120
+ vivify_on_miss_ttl: Optional[int] = None,
121
+ client_flag: Optional[int] = None,
122
+ ma_initial_value: Optional[int] = None,
123
+ ma_delta_value: Optional[int] = None,
124
+ cas_token: Optional[int] = None,
125
+ opaque: Optional[bytes] = None,
126
+ mode: Optional[int] = None,
127
+ ) -> "RequestFlags": ...
105
128
  def to_bytes(self) -> bytes: ...
106
129
  def __str__(self) -> str: ...
107
130
 
@@ -3,6 +3,7 @@ use blake2::Blake2bVar;
3
3
  use blake2::digest::{Update, VariableOutput};
4
4
  use pyo3::exceptions::PyValueError;
5
5
  use pyo3::prelude::*;
6
+ use pyo3::types::{PyBytes, PyString};
6
7
 
7
8
  /// Max raw key size before hashing. Binary keys get base64-encoded (4/3 expansion),
8
9
  /// so the threshold is 250 * 3 / 4 ≈ 187.
@@ -59,10 +60,12 @@ pub fn encode_key(data: &[u8]) -> Option<EncodedKey> {
59
60
 
60
61
  /// Extract a key from a Python object. Accepts str (UTF-8) or bytes.
61
62
  pub fn extract_key<'py>(ob: &'py Bound<'py, PyAny>) -> PyResult<&'py [u8]> {
62
- if let Ok(s) = ob.extract::<&str>() {
63
- Ok(s.as_bytes())
64
- } else if let Ok(b) = ob.extract::<&[u8]>() {
65
- Ok(b)
63
+ // Use `cast` instead of `extract` — turning `PyDowncastError` into `PyErr` is costly,
64
+ // and we only care about the success path here.
65
+ if let Ok(s) = ob.cast::<PyString>() {
66
+ Ok(s.to_str()?.as_bytes())
67
+ } else if let Ok(b) = ob.cast::<PyBytes>() {
68
+ Ok(b.as_bytes())
66
69
  } else {
67
70
  Err(PyValueError::new_err("key must be str or bytes"))
68
71
  }
@@ -3,48 +3,48 @@ use pyo3::types::PyBytes;
3
3
 
4
4
  use crate::{MA_MODE_INC, SET_MODE_SET};
5
5
 
6
- #[pyclass(eq, skip_from_py_object)]
6
+ #[pyclass(eq, skip_from_py_object, frozen)]
7
7
  #[derive(Clone, Debug, PartialEq)]
8
8
  pub struct RequestFlags {
9
- #[pyo3(get, set)]
9
+ #[pyo3(get)]
10
10
  no_reply: bool,
11
- #[pyo3(get, set)]
11
+ #[pyo3(get)]
12
12
  return_client_flag: bool,
13
- #[pyo3(get, set)]
13
+ #[pyo3(get)]
14
14
  return_cas_token: bool,
15
- #[pyo3(get, set)]
15
+ #[pyo3(get)]
16
16
  return_value: bool,
17
- #[pyo3(get, set)]
17
+ #[pyo3(get)]
18
18
  return_ttl: bool,
19
- #[pyo3(get, set)]
19
+ #[pyo3(get)]
20
20
  return_size: bool,
21
- #[pyo3(get, set)]
21
+ #[pyo3(get)]
22
22
  return_last_access: bool,
23
- #[pyo3(get, set)]
23
+ #[pyo3(get)]
24
24
  return_fetched: bool,
25
- #[pyo3(get, set)]
25
+ #[pyo3(get)]
26
26
  return_key: bool,
27
- #[pyo3(get, set)]
27
+ #[pyo3(get)]
28
28
  no_update_lru: bool,
29
- #[pyo3(get, set)]
29
+ #[pyo3(get)]
30
30
  mark_stale: bool,
31
- #[pyo3(get, set)]
31
+ #[pyo3(get)]
32
32
  cache_ttl: Option<u32>,
33
- #[pyo3(get, set)]
33
+ #[pyo3(get)]
34
34
  recache_ttl: Option<u32>,
35
- #[pyo3(get, set)]
35
+ #[pyo3(get)]
36
36
  vivify_on_miss_ttl: Option<u32>,
37
- #[pyo3(get, set)]
37
+ #[pyo3(get)]
38
38
  client_flag: Option<u32>,
39
- #[pyo3(get, set)]
39
+ #[pyo3(get)]
40
40
  ma_initial_value: Option<u64>,
41
- #[pyo3(get, set)]
41
+ #[pyo3(get)]
42
42
  ma_delta_value: Option<u64>,
43
- #[pyo3(get, set)]
43
+ #[pyo3(get)]
44
44
  cas_token: Option<u32>,
45
- #[pyo3(get, set)]
45
+ #[pyo3(get)]
46
46
  opaque: Option<Vec<u8>>,
47
- #[pyo3(get, set)]
47
+ #[pyo3(get)]
48
48
  mode: Option<u8>,
49
49
  }
50
50
 
@@ -251,8 +251,86 @@ impl RequestFlags {
251
251
  }
252
252
  }
253
253
 
254
- pub fn copy(&self) -> Self {
255
- self.clone()
254
+ /// Return a copy of this object with the specified fields replaced.
255
+ ///
256
+ /// Only keyword arguments that are explicitly provided (non-None) override the
257
+ /// corresponding field. Fields not mentioned keep their current value.
258
+ ///
259
+ /// Note: passing `None` explicitly for an optional field (e.g. `cache_ttl=None`)
260
+ /// keeps the existing value rather than unsetting it. To unset an optional field,
261
+ /// construct a new `RequestFlags` directly.
262
+ #[allow(clippy::too_many_arguments)]
263
+ #[pyo3(
264
+ signature = (
265
+ /,
266
+ *,
267
+ no_reply=None,
268
+ return_client_flag=None,
269
+ return_cas_token=None,
270
+ return_value=None,
271
+ return_ttl=None,
272
+ return_size=None,
273
+ return_last_access=None,
274
+ return_fetched=None,
275
+ return_key=None,
276
+ no_update_lru=None,
277
+ mark_stale=None,
278
+ cache_ttl=None,
279
+ recache_ttl=None,
280
+ vivify_on_miss_ttl=None,
281
+ client_flag=None,
282
+ ma_initial_value=None,
283
+ ma_delta_value=None,
284
+ cas_token=None,
285
+ opaque=None,
286
+ mode=None
287
+ )
288
+ )]
289
+ pub fn replace(
290
+ &self,
291
+ no_reply: Option<bool>,
292
+ return_client_flag: Option<bool>,
293
+ return_cas_token: Option<bool>,
294
+ return_value: Option<bool>,
295
+ return_ttl: Option<bool>,
296
+ return_size: Option<bool>,
297
+ return_last_access: Option<bool>,
298
+ return_fetched: Option<bool>,
299
+ return_key: Option<bool>,
300
+ no_update_lru: Option<bool>,
301
+ mark_stale: Option<bool>,
302
+ cache_ttl: Option<u32>,
303
+ recache_ttl: Option<u32>,
304
+ vivify_on_miss_ttl: Option<u32>,
305
+ client_flag: Option<u32>,
306
+ ma_initial_value: Option<u64>,
307
+ ma_delta_value: Option<u64>,
308
+ cas_token: Option<u32>,
309
+ opaque: Option<Vec<u8>>,
310
+ mode: Option<u8>,
311
+ ) -> Self {
312
+ RequestFlags {
313
+ no_reply: no_reply.unwrap_or(self.no_reply),
314
+ return_client_flag: return_client_flag.unwrap_or(self.return_client_flag),
315
+ return_cas_token: return_cas_token.unwrap_or(self.return_cas_token),
316
+ return_value: return_value.unwrap_or(self.return_value),
317
+ return_ttl: return_ttl.unwrap_or(self.return_ttl),
318
+ return_size: return_size.unwrap_or(self.return_size),
319
+ return_last_access: return_last_access.unwrap_or(self.return_last_access),
320
+ return_fetched: return_fetched.unwrap_or(self.return_fetched),
321
+ return_key: return_key.unwrap_or(self.return_key),
322
+ no_update_lru: no_update_lru.unwrap_or(self.no_update_lru),
323
+ mark_stale: mark_stale.unwrap_or(self.mark_stale),
324
+ cache_ttl: cache_ttl.or(self.cache_ttl),
325
+ recache_ttl: recache_ttl.or(self.recache_ttl),
326
+ vivify_on_miss_ttl: vivify_on_miss_ttl.or(self.vivify_on_miss_ttl),
327
+ client_flag: client_flag.or(self.client_flag),
328
+ ma_initial_value: ma_initial_value.or(self.ma_initial_value),
329
+ ma_delta_value: ma_delta_value.or(self.ma_delta_value),
330
+ cas_token: cas_token.or(self.cas_token),
331
+ opaque: opaque.or_else(|| self.opaque.clone()),
332
+ mode: mode.or(self.mode),
333
+ }
256
334
  }
257
335
 
258
336
  pub fn __str__(&self) -> String {
@@ -559,4 +559,171 @@ mod tests {
559
559
  b" q f c v t s l h k u I T1 R2 N3 F4 J5 D6 C7 Oop ME"
560
560
  );
561
561
  }
562
+
563
+ // Helper: all-None replace call (no overrides)
564
+ fn replace_none(flags: &RequestFlags) -> RequestFlags {
565
+ flags.replace(
566
+ None, None, None, None, None, None, None, None, None, None, None, None, None, None,
567
+ None, None, None, None, None, None,
568
+ )
569
+ }
570
+
571
+ #[test]
572
+ fn test_replace_no_args_returns_equal() {
573
+ let base = RequestFlags::new(
574
+ true,
575
+ true,
576
+ false,
577
+ false,
578
+ false,
579
+ false,
580
+ false,
581
+ false,
582
+ false,
583
+ false,
584
+ false,
585
+ Some(300),
586
+ None,
587
+ None,
588
+ None,
589
+ None,
590
+ None,
591
+ None,
592
+ None,
593
+ None,
594
+ );
595
+ assert_eq!(replace_none(&base), base);
596
+ }
597
+
598
+ #[test]
599
+ fn test_replace_bool_flag() {
600
+ let base = default_flags();
601
+ let updated = base.replace(
602
+ Some(true), // no_reply
603
+ None,
604
+ None,
605
+ None,
606
+ None,
607
+ None,
608
+ None,
609
+ None,
610
+ None,
611
+ None,
612
+ None,
613
+ None,
614
+ None,
615
+ None,
616
+ None,
617
+ None,
618
+ None,
619
+ None,
620
+ None,
621
+ None,
622
+ );
623
+ assert_eq!(push_to_vec(&updated), b" q");
624
+ // base is unchanged
625
+ assert_eq!(push_to_vec(&base), b"");
626
+ }
627
+
628
+ #[test]
629
+ fn test_replace_optional_field() {
630
+ let base = default_flags();
631
+ let updated = base.replace(
632
+ None,
633
+ None,
634
+ None,
635
+ None,
636
+ None,
637
+ None,
638
+ None,
639
+ None,
640
+ None,
641
+ None,
642
+ None,
643
+ Some(600), // cache_ttl
644
+ None,
645
+ None,
646
+ None,
647
+ None,
648
+ None,
649
+ None,
650
+ None,
651
+ None,
652
+ );
653
+ assert_eq!(push_to_vec(&updated), b" T600");
654
+ assert_eq!(push_to_vec(&base), b"");
655
+ }
656
+
657
+ #[test]
658
+ fn test_replace_none_keeps_existing_optional() {
659
+ // Passing None for an optional field keeps the existing value, not unsets it
660
+ let base = RequestFlags::new(
661
+ false, false, false, false, false, false, false, false, false, false, false,
662
+ Some(300), // cache_ttl set
663
+ None, None, None, None, None, None, None, None,
664
+ );
665
+ let updated = replace_none(&base);
666
+ assert_eq!(push_to_vec(&updated), b" T300");
667
+ }
668
+
669
+ #[test]
670
+ fn test_replace_multiple_fields() {
671
+ let base = RequestFlags::new(
672
+ false, true, false, true, false, false, false, false, false, false, false, Some(60),
673
+ None, None, None, None, None, None, None, None,
674
+ );
675
+ let updated = base.replace(
676
+ Some(true), // add no_reply
677
+ None, // keep return_client_flag=true
678
+ Some(true), // add return_cas_token
679
+ None, // keep return_value=true
680
+ None,
681
+ None,
682
+ None,
683
+ None,
684
+ None,
685
+ None,
686
+ None,
687
+ None, // keep cache_ttl=60
688
+ Some(120), // add recache_ttl
689
+ None,
690
+ None,
691
+ None,
692
+ None,
693
+ None,
694
+ None,
695
+ None,
696
+ );
697
+ assert_eq!(push_to_vec(&updated), b" q f c v T60 R120");
698
+ }
699
+
700
+ #[test]
701
+ fn test_replace_opaque() {
702
+ let base = default_flags();
703
+ let updated = base.replace(
704
+ None,
705
+ None,
706
+ None,
707
+ None,
708
+ None,
709
+ None,
710
+ None,
711
+ None,
712
+ None,
713
+ None,
714
+ None,
715
+ None,
716
+ None,
717
+ None,
718
+ None,
719
+ None,
720
+ None,
721
+ None,
722
+ Some(b"abc".to_vec()),
723
+ None,
724
+ );
725
+ assert_eq!(push_to_vec(&updated), b" Oabc");
726
+ // base is unchanged
727
+ assert_eq!(push_to_vec(&base), b"");
728
+ }
562
729
  }
@@ -1011,3 +1011,52 @@ class TestVersionConstants:
1011
1011
  """ServerVersion IntEnum values match Rust constants."""
1012
1012
  assert SERVER_VERSION_AWS_1_6_6 == 1
1013
1013
  assert SERVER_VERSION_STABLE == 2
1014
+
1015
+
1016
+ class TestRequestFlagsReplace:
1017
+ def test_replace_no_args_returns_equal(self):
1018
+ base = RequestFlags(return_value=True, cache_ttl=300)
1019
+ assert base.replace() == base
1020
+
1021
+ def test_replace_bool_flag(self):
1022
+ base = RequestFlags(return_value=True)
1023
+ updated = base.replace(return_cas_token=True)
1024
+ assert updated.return_value is True
1025
+ assert updated.return_cas_token is True
1026
+ # base is unchanged
1027
+ assert base.return_cas_token is False
1028
+
1029
+ def test_replace_optional_field(self):
1030
+ base = RequestFlags(return_value=True)
1031
+ updated = base.replace(cache_ttl=600)
1032
+ assert updated.cache_ttl == 600
1033
+ assert updated.return_value is True
1034
+ # base is unchanged
1035
+ assert base.cache_ttl is None
1036
+
1037
+ def test_replace_none_keeps_existing_optional(self):
1038
+ base = RequestFlags(cache_ttl=300)
1039
+ # explicitly passing None keeps the existing value, not unsets it
1040
+ updated = base.replace(cache_ttl=None)
1041
+ assert updated.cache_ttl == 300
1042
+
1043
+ def test_replace_multiple_fields(self):
1044
+ base = RequestFlags(return_client_flag=True, return_value=True, cache_ttl=60)
1045
+ updated = base.replace(no_reply=True, return_cas_token=True, recache_ttl=120)
1046
+ assert updated.no_reply is True
1047
+ assert updated.return_client_flag is True # preserved
1048
+ assert updated.return_cas_token is True
1049
+ assert updated.return_value is True # preserved
1050
+ assert updated.cache_ttl == 60 # preserved
1051
+ assert updated.recache_ttl == 120
1052
+
1053
+ def test_replace_opaque(self):
1054
+ base = RequestFlags(return_value=True)
1055
+ updated = base.replace(opaque=b"abc")
1056
+ assert updated.opaque == b"abc"
1057
+ assert base.opaque is None
1058
+
1059
+ def test_fields_are_readonly(self):
1060
+ flags = RequestFlags(return_value=True)
1061
+ with pytest.raises(AttributeError):
1062
+ flags.return_value = False # type: ignore[misc]