zlight_csv 0.4.0-aarch64-linux → 0.5.1-aarch64-linux

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.
@@ -0,0 +1,95 @@
1
+ use magnus::{
2
+ scan_args::{get_kwargs, scan_args},
3
+ RHash, RString, Ruby, Symbol, Value,
4
+ };
5
+
6
+ use crate::error::{Result, ZlightError};
7
+
8
+ /// Configuration options for CSV parsing.
9
+ #[derive(Debug, Clone, Copy)]
10
+ pub struct ParseOptions {
11
+ /// Whether the CSV has a header row.
12
+ pub has_headers: bool,
13
+ /// Whether to convert numeric strings to integers/floats.
14
+ pub convert_numeric: bool,
15
+ /// Field delimiter character (defaults to comma).
16
+ pub delimiter: u8,
17
+ /// Quote character (defaults to double quote).
18
+ pub quote: u8,
19
+ /// Whether to allow flexible record lengths.
20
+ pub flexible: bool,
21
+ }
22
+
23
+ impl Default for ParseOptions {
24
+ #[inline]
25
+ fn default() -> Self {
26
+ Self {
27
+ has_headers: true,
28
+ convert_numeric: false,
29
+ delimiter: b',',
30
+ quote: b'"',
31
+ flexible: true,
32
+ }
33
+ }
34
+ }
35
+
36
+ impl ParseOptions {
37
+ /// Scans a `(String, **options)` Ruby argument list.
38
+ ///
39
+ /// Every reader entry point takes the same shape, differing only in what
40
+ /// the leading string means. `argument` names it so that a missing
41
+ /// argument is reported as "path" or "csv_string" as appropriate.
42
+ pub fn scan(ruby: &Ruby, args: &[Value], argument: &'static str) -> Result<(RString, Self)> {
43
+ let parsed = scan_args::<(RString,), (), (), (), RHash, ()>(args)
44
+ .map_err(|_| ZlightError::MissingArgument(argument))?;
45
+
46
+ let (input,) = parsed.required;
47
+
48
+ Ok((input, Self::from_kwargs(ruby, parsed.keywords)?))
49
+ }
50
+
51
+ /// Builds options from a keyword arguments hash, applying the defaults.
52
+ fn from_kwargs(ruby: &Ruby, kwargs: RHash) -> Result<Self> {
53
+ let kw = get_kwargs::<_, (), (Option<bool>, Option<Symbol>, Option<RString>, Option<RString>, Option<bool>), ()>(
54
+ kwargs,
55
+ &[],
56
+ &["headers", "converters", "col_sep", "quote_char", "flexible"],
57
+ )
58
+ .map_err(|e| ZlightError::InvalidOption {
59
+ key: "keywords",
60
+ expected: "valid keyword arguments",
61
+ actual: e.to_string(),
62
+ })?;
63
+
64
+ let (headers, converters, col_sep, quote_char, flexible) = kw.optional;
65
+
66
+ Ok(ParseOptions {
67
+ has_headers: headers.unwrap_or(true),
68
+ convert_numeric: converters.is_some_and(|sym| sym == ruby.to_symbol("numeric")),
69
+ delimiter: single_byte(col_sep, "col_sep")?.unwrap_or(b','),
70
+ quote: single_byte(quote_char, "quote_char")?.unwrap_or(b'"'),
71
+ flexible: flexible.unwrap_or(true),
72
+ })
73
+ }
74
+ }
75
+
76
+ /// Validates that an optional separator/quote option is exactly one byte.
77
+ ///
78
+ /// The underlying `csv` crate only supports single-byte delimiters and quotes,
79
+ /// so a longer value would be truncated to its first byte and then silently
80
+ /// parse or write the wrong thing. Reject it explicitly instead.
81
+ pub fn single_byte(opt: Option<RString>, key: &'static str) -> Result<Option<u8>> {
82
+ let Some(string) = opt else { return Ok(None) };
83
+
84
+ // SAFETY: the slice is only read for its length and first byte before
85
+ // this function returns, with no Ruby allocation in between.
86
+ let bytes = unsafe { string.as_slice() };
87
+ match bytes.len() {
88
+ 1 => Ok(Some(bytes[0])),
89
+ n => Err(ZlightError::InvalidOption {
90
+ key,
91
+ expected: "a single-byte string",
92
+ actual: format!("{}-byte string", n),
93
+ }),
94
+ }
95
+ }
@@ -0,0 +1,77 @@
1
+ use csv::{ByteRecord, Reader, ReaderBuilder};
2
+ use magnus::{encoding::Index, Error as MagnusError, RArray, Ruby};
3
+
4
+ use crate::converter::field_to_value;
5
+ use crate::error::{csv_error, ZlightError};
6
+ use crate::options::ParseOptions;
7
+
8
+ /// Parses CSV data into an array of arrays (no headers mode).
9
+ pub fn parse_as_arrays(
10
+ ruby: &Ruby,
11
+ reader: &mut Reader<&[u8]>,
12
+ convert_numeric: bool,
13
+ encoding: Index,
14
+ ) -> Result<RArray, MagnusError> {
15
+ let result = ruby.ary_new();
16
+ let mut record = ByteRecord::new();
17
+
18
+ while reader.read_byte_record(&mut record).map_err(csv_error)? {
19
+ let row = ruby.ary_new_capa(record.len());
20
+
21
+ for field in record.iter() {
22
+ row.push(field_to_value(ruby, field, convert_numeric, encoding))?;
23
+ }
24
+ result.push(row)?;
25
+ }
26
+
27
+ Ok(result)
28
+ }
29
+
30
+ /// Parses CSV data into an array of hashes (with headers mode).
31
+ pub fn parse_as_hashes(
32
+ ruby: &Ruby,
33
+ reader: &mut Reader<&[u8]>,
34
+ convert_numeric: bool,
35
+ encoding: Index,
36
+ ) -> Result<RArray, MagnusError> {
37
+ // Pre-compute header symbols once
38
+ let byte_headers = reader.byte_headers().map_err(csv_error)?;
39
+
40
+ let headers: Vec<_> = byte_headers
41
+ .iter()
42
+ .map(|h| {
43
+ std::str::from_utf8(h).map(|s| ruby.sym_new(s)).map_err(|_| {
44
+ ZlightError::InvalidHeaderEncoding(String::from_utf8_lossy(h).to_string())
45
+ })
46
+ })
47
+ .collect::<Result<Vec<_>, _>>()?;
48
+
49
+ let result = ruby.ary_new();
50
+ let mut record = ByteRecord::new();
51
+
52
+ while reader.read_byte_record(&mut record).map_err(csv_error)? {
53
+ let hash = ruby.hash_new();
54
+
55
+ // Zip stops at the shorter side, so a row with more fields than there
56
+ // are headers drops the extras, matching Ruby CSV.
57
+ for (header, field) in headers.iter().zip(record.iter()) {
58
+ hash.aset(*header, field_to_value(ruby, field, convert_numeric, encoding))?;
59
+ }
60
+
61
+ result.push(hash)?;
62
+ }
63
+
64
+ Ok(result)
65
+ }
66
+
67
+ /// Builds a CSV reader with the given options.
68
+ #[inline]
69
+ pub fn build_reader<'a>(data: &'a [u8], options: &ParseOptions) -> Reader<&'a [u8]> {
70
+ ReaderBuilder::new()
71
+ .has_headers(options.has_headers)
72
+ .delimiter(options.delimiter)
73
+ .quote(options.quote)
74
+ .flexible(options.flexible)
75
+ .buffer_capacity(64 * 1024) // 64KB buffer for better I/O
76
+ .from_reader(data)
77
+ }
@@ -0,0 +1,410 @@
1
+ //! Streaming CSV parser for lazy/incremental parsing.
2
+ //!
3
+ //! Provides `StreamReader` which allows reading CSV rows one at a time
4
+ //! without loading the entire file into memory.
5
+
6
+ use std::cell::RefCell;
7
+ use std::fs::File;
8
+ use std::io::{BufReader, Cursor, Read};
9
+
10
+ use csv::{ByteRecord, Reader, ReaderBuilder};
11
+ use magnus::{
12
+ encoding::Index, function, method,
13
+ prelude::*,
14
+ typed_data::Obj,
15
+ DataTypeFunctions, Error as MagnusError, RArray, RString, Ruby, StaticSymbol, TypedData, Value,
16
+ };
17
+
18
+ use crate::converter::field_to_value;
19
+ use crate::error::{csv_error, ZlightError};
20
+ use crate::options::ParseOptions;
21
+
22
+ // ============================================================================
23
+ // Type Aliases
24
+ // ============================================================================
25
+
26
+ type BoxedReader = Reader<Box<dyn Read + Send>>;
27
+
28
+ // ============================================================================
29
+ // Reader State
30
+ // ============================================================================
31
+
32
+ /// State of the stream reader.
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+ enum ReaderState {
35
+ /// Reader has not been initialized yet (lazy init).
36
+ Uninitialized,
37
+ /// Reader is active and can read rows.
38
+ Active,
39
+ /// Reader has been exhausted (no more rows).
40
+ Exhausted,
41
+ /// Reader has been closed.
42
+ Closed,
43
+ }
44
+
45
+ // ============================================================================
46
+ // StreamReader
47
+ // ============================================================================
48
+
49
+ /// Internal mutable state for the stream reader.
50
+ struct StreamReaderInner {
51
+ /// The CSV reader (lazily initialized).
52
+ reader: Option<BoxedReader>,
53
+ /// Pending data source before initialization.
54
+ pending_data: Option<Vec<u8>>,
55
+ /// Opened file awaiting initialization.
56
+ pending_file: Option<File>,
57
+ /// Pre-computed header symbols.
58
+ headers: Option<Vec<StaticSymbol>>,
59
+ /// Reusable record buffer.
60
+ record: ByteRecord,
61
+ /// Current state.
62
+ state: ReaderState,
63
+ }
64
+
65
+ /// A streaming CSV reader that yields rows one at a time.
66
+ ///
67
+ /// Exposed to Ruby as `ZLight::StreamReader`. Implements Ruby's Enumerable
68
+ /// interface for seamless integration with Ruby iteration patterns.
69
+ ///
70
+ /// # Example (Ruby)
71
+ ///
72
+ /// ```ruby
73
+ /// reader = ZLight.stream_file("large_file.csv")
74
+ /// reader.each do |row|
75
+ /// puts row[:name]
76
+ /// end
77
+ /// reader.close
78
+ /// ```
79
+ #[derive(TypedData)]
80
+ #[magnus(class = "ZLight::StreamReader", free_immediately)]
81
+ pub struct StreamReader {
82
+ /// Mutable internal state wrapped in RefCell for interior mutability.
83
+ inner: RefCell<StreamReaderInner>,
84
+ /// Parsing options (immutable after creation).
85
+ options: ParseOptions,
86
+ /// Encoding used to tag parsed field strings.
87
+ encoding: Index,
88
+ }
89
+
90
+ // SAFETY: magnus requires `TypedData: Send`, but this struct holds a RefCell
91
+ // and Ruby values (StaticSymbol), neither of which is Send on its own.
92
+ //
93
+ // It is sound because a StreamReader is only ever reached through the Ruby
94
+ // methods defined at the bottom of this file, and those run holding the GVL,
95
+ // so no two threads touch one concurrently. Nothing here may be moved to a
96
+ // Ractor or handed to a Rust thread.
97
+ unsafe impl Send for StreamReader {}
98
+
99
+ // The only Ruby values held are StaticSymbols, which Ruby never collects,
100
+ // so there is nothing for the GC to mark. Add a `mark` implementation (and
101
+ // the `mark` attribute above) if a collectable value is ever stored here.
102
+ impl DataTypeFunctions for StreamReader {}
103
+
104
+ impl StreamReader {
105
+ /// Creates a new StreamReader from in-memory data.
106
+ pub fn from_string(data: Vec<u8>, options: ParseOptions, encoding: Index) -> Self {
107
+ Self {
108
+ inner: RefCell::new(StreamReaderInner {
109
+ reader: None,
110
+ pending_data: Some(data),
111
+ pending_file: None,
112
+ headers: None,
113
+ record: ByteRecord::new(),
114
+ state: ReaderState::Uninitialized,
115
+ }),
116
+ options,
117
+ encoding,
118
+ }
119
+ }
120
+
121
+ /// Creates a new StreamReader from an already-opened file.
122
+ pub fn from_file(file: File, options: ParseOptions, encoding: Index) -> Self {
123
+ Self {
124
+ inner: RefCell::new(StreamReaderInner {
125
+ reader: None,
126
+ pending_data: None,
127
+ pending_file: Some(file),
128
+ headers: None,
129
+ record: ByteRecord::new(),
130
+ state: ReaderState::Uninitialized,
131
+ }),
132
+ options,
133
+ encoding,
134
+ }
135
+ }
136
+
137
+ /// Ensures the CSV reader is initialized.
138
+ fn ensure_initialized(&self, ruby: &Ruby) -> Result<(), ZlightError> {
139
+ let mut inner = self.inner.borrow_mut();
140
+
141
+ if inner.state != ReaderState::Uninitialized {
142
+ return Ok(());
143
+ }
144
+
145
+ // Create the reader from pending data or file
146
+ let boxed_reader: Box<dyn Read + Send> = if let Some(data) = inner.pending_data.take() {
147
+ Box::new(Cursor::new(data))
148
+ } else if let Some(file) = inner.pending_file.take() {
149
+ Box::new(BufReader::with_capacity(64 * 1024, file))
150
+ } else {
151
+ return Err(ZlightError::StreamClosed);
152
+ };
153
+
154
+ let mut csv_reader = ReaderBuilder::new()
155
+ .has_headers(self.options.has_headers)
156
+ .delimiter(self.options.delimiter)
157
+ .quote(self.options.quote)
158
+ .flexible(self.options.flexible)
159
+ .buffer_capacity(64 * 1024)
160
+ .from_reader(boxed_reader);
161
+
162
+ // Extract headers if enabled
163
+ if self.options.has_headers {
164
+ let byte_headers = csv_reader.byte_headers()?;
165
+ let header_symbols: Vec<StaticSymbol> = byte_headers
166
+ .iter()
167
+ .map(|h| {
168
+ std::str::from_utf8(h)
169
+ .map(|name| ruby.sym_new(name))
170
+ .map_err(|_| {
171
+ ZlightError::InvalidHeaderEncoding(
172
+ String::from_utf8_lossy(h).to_string(),
173
+ )
174
+ })
175
+ })
176
+ .collect::<Result<Vec<_>, _>>()?;
177
+ inner.headers = Some(header_symbols);
178
+ }
179
+
180
+ inner.reader = Some(csv_reader);
181
+ inner.state = ReaderState::Active;
182
+
183
+ Ok(())
184
+ }
185
+
186
+ /// Reads the next row from the CSV.
187
+ ///
188
+ /// Returns `Ok(Some(row))` for each row, `Ok(None)` when exhausted.
189
+ pub fn next_row(&self, ruby: &Ruby) -> Result<Option<Value>, MagnusError> {
190
+ self.ensure_initialized(ruby)?;
191
+
192
+ // Read into the reusable buffer, releasing the mutable borrow before
193
+ // any Ruby object is allocated below.
194
+ {
195
+ let mut guard = self.inner.borrow_mut();
196
+ // Reborrow so the reader and the record buffer can be borrowed as
197
+ // the separate fields they are.
198
+ let inner = &mut *guard;
199
+
200
+ match inner.state {
201
+ ReaderState::Closed => return Err(ZlightError::StreamClosed.into()),
202
+ ReaderState::Exhausted => return Ok(None),
203
+ ReaderState::Uninitialized => {
204
+ unreachable!("ensure_initialized leaves no Uninitialized state")
205
+ }
206
+ ReaderState::Active => {}
207
+ }
208
+
209
+ let reader = inner.reader.as_mut().ok_or(ZlightError::StreamClosed)?;
210
+
211
+ if !reader.read_byte_record(&mut inner.record).map_err(csv_error)? {
212
+ inner.state = ReaderState::Exhausted;
213
+ return Ok(None);
214
+ }
215
+ }
216
+
217
+ // Now build the row value with a fresh borrow
218
+ let inner = self.inner.borrow();
219
+ let convert = self.options.convert_numeric;
220
+
221
+ let row = if self.options.has_headers {
222
+ let headers = inner.headers.as_ref().ok_or(ZlightError::StreamClosed)?;
223
+ let hash = ruby.hash_new();
224
+
225
+ // Zip stops at the shorter side, so a row with more fields than
226
+ // there are headers drops the extras, matching Ruby CSV.
227
+ for (header, field) in headers.iter().zip(inner.record.iter()) {
228
+ hash.aset(*header, field_to_value(ruby, field, convert, self.encoding))?;
229
+ }
230
+
231
+ hash.as_value()
232
+ } else {
233
+ let arr = ruby.ary_new_capa(inner.record.len());
234
+
235
+ for field in inner.record.iter() {
236
+ arr.push(field_to_value(ruby, field, convert, self.encoding))?;
237
+ }
238
+
239
+ arr.as_value()
240
+ };
241
+
242
+ Ok(Some(row))
243
+ }
244
+
245
+ /// Returns the headers as a Ruby array of symbols.
246
+ pub fn headers(&self, ruby: &Ruby) -> Result<Option<RArray>, MagnusError> {
247
+ self.ensure_initialized(ruby)?;
248
+
249
+ let inner = self.inner.borrow();
250
+
251
+ match &inner.headers {
252
+ Some(headers) => {
253
+ let arr = ruby.ary_new_capa(headers.len());
254
+ for sym in headers {
255
+ arr.push(*sym)?;
256
+ }
257
+ Ok(Some(arr))
258
+ }
259
+ None => Ok(None),
260
+ }
261
+ }
262
+
263
+ /// Closes the reader and releases all resources.
264
+ pub fn close(&self) {
265
+ let mut inner = self.inner.borrow_mut();
266
+ inner.reader = None;
267
+ inner.headers = None;
268
+ inner.pending_data = None;
269
+ // Dropping the file closes its descriptor.
270
+ inner.pending_file = None;
271
+ inner.state = ReaderState::Closed;
272
+ }
273
+
274
+ /// Returns whether the reader has been closed.
275
+ pub fn is_closed(&self) -> bool {
276
+ self.inner.borrow().state == ReaderState::Closed
277
+ }
278
+
279
+ /// Returns whether there are more rows to read.
280
+ pub fn is_exhausted(&self) -> bool {
281
+ let state = self.inner.borrow().state;
282
+ matches!(state, ReaderState::Exhausted | ReaderState::Closed)
283
+ }
284
+ }
285
+
286
+ // ============================================================================
287
+ // Ruby Method Wrappers
288
+ // ============================================================================
289
+
290
+ /// Ruby: ZLight.stream(csv_string, **options) -> StreamReader
291
+ fn rb_stream_string(ruby: &Ruby, args: &[Value]) -> Result<Obj<StreamReader>, MagnusError> {
292
+ let (input, options) = ParseOptions::scan(ruby, args, "csv_string")?;
293
+ let encoding = input.enc_get();
294
+ // SAFETY: copied immediately, before anything can allocate. The stream
295
+ // outlives this call, so it owns its bytes rather than borrowing Ruby's.
296
+ let bytes = unsafe { input.as_slice().to_vec() };
297
+
298
+ let reader = StreamReader::from_string(bytes, options, encoding);
299
+ Ok(ruby.obj_wrap(reader))
300
+ }
301
+
302
+ /// Ruby: ZLight.stream_file(path, **options) -> StreamReader
303
+ fn rb_stream_file(ruby: &Ruby, args: &[Value]) -> Result<Obj<StreamReader>, MagnusError> {
304
+ let (path_arg, options) = ParseOptions::scan(ruby, args, "path")?;
305
+ let path_str = path_to_string(path_arg)?;
306
+
307
+ // Open now rather than on the first read. Checking for existence and then
308
+ // opening later left a window for the file to change in between, and it
309
+ // reported a missing file from stream_file while an unreadable one only
310
+ // surfaced rows later. Opening here reports both from the same place, and
311
+ // the message comes from the OS instead of being reconstructed.
312
+ let file = File::open(&path_str).map_err(|e| {
313
+ ZlightError::Io(std::io::Error::new(e.kind(), format!("{} - {}", e, path_str)))
314
+ })?;
315
+
316
+ // File streams have no source Ruby string; default to UTF-8 to match
317
+ // `ZLight.read`, which reads files as UTF-8.
318
+ let reader = StreamReader::from_file(file, options, ruby.utf8_encindex());
319
+ Ok(ruby.obj_wrap(reader))
320
+ }
321
+
322
+ /// Converts the path argument to an owned Rust string.
323
+ fn path_to_string(path: RString) -> Result<String, ZlightError> {
324
+ // SAFETY: the slice is copied into an owned String before returning, with
325
+ // no Ruby allocation in between.
326
+ let bytes = unsafe { path.as_slice() };
327
+ std::str::from_utf8(bytes)
328
+ .map(str::to_owned)
329
+ .map_err(|_| ZlightError::InvalidOption {
330
+ key: "path",
331
+ expected: "valid UTF-8 string",
332
+ actual: "invalid encoding".to_string(),
333
+ })
334
+ }
335
+
336
+ /// Ruby: reader.next_row -> Hash/Array or nil
337
+ fn rb_next_row(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
338
+ Ok(rb_self.next_row(ruby)?.unwrap_or_else(|| ruby.qnil().as_value()))
339
+ }
340
+
341
+ /// Ruby: reader.each { |row| ... } -> self
342
+ /// Ruby: reader.each -> Enumerator
343
+ fn rb_each(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
344
+ if !ruby.block_given() {
345
+ // Return Enumerator for lazy iteration
346
+ return rb_self.funcall("to_enum", ("each",));
347
+ }
348
+
349
+ let block = ruby.block_proc()?;
350
+
351
+ while let Some(row) = rb_self.next_row(ruby)? {
352
+ block.call::<_, Value>((row,))?;
353
+ }
354
+
355
+ Ok(rb_self.as_value())
356
+ }
357
+
358
+ /// Ruby: reader.headers -> Array<Symbol> or nil
359
+ fn rb_headers(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Result<Value, MagnusError> {
360
+ Ok(rb_self
361
+ .headers(ruby)?
362
+ .map_or_else(|| ruby.qnil().as_value(), |arr| arr.as_value()))
363
+ }
364
+
365
+ /// Ruby: reader.close -> nil
366
+ fn rb_close(ruby: &Ruby, rb_self: Obj<StreamReader>) -> Value {
367
+ rb_self.close();
368
+ ruby.qnil().as_value()
369
+ }
370
+
371
+ /// Ruby: reader.closed? -> true/false
372
+ fn rb_is_closed(rb_self: Obj<StreamReader>) -> bool {
373
+ rb_self.is_closed()
374
+ }
375
+
376
+ /// Ruby: reader.eof? -> true/false
377
+ fn rb_is_eof(rb_self: Obj<StreamReader>) -> bool {
378
+ rb_self.is_exhausted()
379
+ }
380
+
381
+ // ============================================================================
382
+ // Module Initialization
383
+ // ============================================================================
384
+
385
+ /// Initializes the StreamReader class and streaming methods.
386
+ pub fn init(ruby: &Ruby) -> Result<(), MagnusError> {
387
+ // Get the ZLight module
388
+ let zlight = ruby.define_module("ZLight")?;
389
+
390
+ // Define StreamReader class
391
+ let stream_reader_class = zlight.define_class("StreamReader", ruby.class_object())?;
392
+
393
+ // Include Enumerable for map, select, reduce, etc.
394
+ let enumerable: magnus::RModule = ruby.class_object().funcall("const_get", ("Enumerable",))?;
395
+ stream_reader_class.include_module(enumerable)?;
396
+
397
+ // Instance methods
398
+ stream_reader_class.define_method("next_row", method!(rb_next_row, 0))?;
399
+ stream_reader_class.define_method("each", method!(rb_each, 0))?;
400
+ stream_reader_class.define_method("headers", method!(rb_headers, 0))?;
401
+ stream_reader_class.define_method("close", method!(rb_close, 0))?;
402
+ stream_reader_class.define_method("closed?", method!(rb_is_closed, 0))?;
403
+ stream_reader_class.define_method("eof?", method!(rb_is_eof, 0))?;
404
+
405
+ // Module-level factory methods
406
+ zlight.define_singleton_method("stream", function!(rb_stream_string, -1))?;
407
+ zlight.define_singleton_method("stream_file", function!(rb_stream_file, -1))?;
408
+
409
+ Ok(())
410
+ }