carray-netcdf 2.0.0 → 2.1.0

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.
@@ -19,16 +19,16 @@ module CA
19
19
  class File < Group
20
20
 
21
21
  # Kwargs on {.create} that become file-wide defaults for every
22
- # subsequent {Group#def_var} call. See docs/writing.md for details.
22
+ # subsequent {Group#def_var} call. See docs/03_writing.md for details.
23
23
  # @return [Array<Symbol>]
24
- FILE_VAR_DEFAULTS = %i[chunks deflate shuffle fletcher32 endian no_fill].freeze
24
+ FILE_VAR_DEFAULTS = VAR_OPTION_KEYS
25
25
 
26
26
  class << self
27
27
  # Open an existing NetCDF file.
28
28
  #
29
29
  # @param path [String]
30
30
  # @param mode [:read, :write, :append]
31
- # @param codec [Symbol, Array, Object] see docs/codec.md
31
+ # @param codec [Symbol, Array, Object] see docs/04_codec.md
32
32
  # @yieldparam file [File]
33
33
  # @return [File] the file (or block's return value if a block is
34
34
  # given; the file is closed after the block)
@@ -45,16 +45,19 @@ module CA
45
45
  # Create a new NetCDF file.
46
46
  #
47
47
  # @param path [String] final file path
48
- # @param codec [Symbol, Array, Object] see docs/codec.md
48
+ # @param codec [Symbol, Array, Object] see docs/04_codec.md
49
49
  # @param clobber [true, false, :skip] overwrite policy when `path`
50
50
  # already exists. `true` (default) overwrites; `false` raises
51
51
  # `Errno::EEXIST`; `:skip` returns `nil` without calling the
52
52
  # block — handy for resumable batches (`Dir[...]` picks up
53
53
  # completed outputs unchanged).
54
- # @param netcdf4 [Boolean] use the NetCDF-4 (HDF5) format. When a
55
- # `template:` File / Group is provided, this defaults to
56
- # whatever the template's format was, so a NetCDF-4 template
57
- # produces a NetCDF-4 output unless explicitly overridden.
54
+ # @param netcdf4 [Boolean, nil] use the NetCDF-4 (HDF5) format.
55
+ # Left nil, it follows a `template:` File / Group's format, so a
56
+ # NetCDF-4 template produces a NetCDF-4 output; `false` asks for
57
+ # the classic format whatever the template.
58
+ # @param format [Symbol, nil] `:classic`, `:offset64` (CDF-2),
59
+ # `:cdf5`, `:netcdf4` or `:netcdf4_classic`; instead of
60
+ # `netcdf4:`. Left nil, as for `netcdf4:`.
58
61
  # @param atomic [Boolean] write to a same-dir tmp file, rename to
59
62
  # `path` only on successful close; unlink the tmp on any
60
63
  # exception from the block form. Rename is atomic on POSIX.
@@ -73,7 +76,7 @@ module CA
73
76
  # template's atts merged with these overrides (last-writer
74
77
  # wins).
75
78
  # @param vars [Array<String>] template variable names to copy in
76
- # full (definition AND data, bit-identical via raw + layer 1).
79
+ # full (definition AND data, bit-identical via raw + the low-level API).
77
80
  # Typical use: copy coordinate vars like `%w[time lat lon]` once
78
81
  # per batch iteration.
79
82
  # @param var_defaults [Hash] file-wide defaults picked up by every
@@ -82,7 +85,7 @@ module CA
82
85
  # @yieldparam file [File]
83
86
  # @return [File, nil] the file (or block's return value); `nil`
84
87
  # when `clobber: :skip` and `path` already exists.
85
- def create(path, codec: :auto, clobber: true, netcdf4: false,
88
+ def create(path, codec: :auto, clobber: true, netcdf4: nil, format: nil,
86
89
  atomic: false, template: nil,
87
90
  dims: true, atts: true, vars: [],
88
91
  **var_defaults, &block)
@@ -102,22 +105,33 @@ module CA
102
105
  if clobber == :skip && ::File.exist?(path)
103
106
  return nil
104
107
  end
105
- # Format inheritance from the template unless netcdf4: is set
106
- # explicitly. A NetCDF-4 template silently upgrades the output
107
- # so vars using groups / chunking / deflate stay defineable.
108
- if template && !netcdf4
109
- src_file = template.is_a?(File) ? template : template.file
110
- # nc_inq_format returns the NC_FORMAT_* Integer constant.
111
- fmt = src_file.format
112
- netcdf4 = true if fmt == ::NetCDF::NC_FORMAT_NETCDF4 ||
113
- fmt == ::NetCDF::NC_FORMAT_NETCDF4_CLASSIC
108
+ # The format: `format:`, else `netcdf4:` (true / false), else the
109
+ # template's, else classic.
110
+ unless format.nil? || netcdf4.nil?
111
+ raise ArgumentError, "give format: or netcdf4:, not both"
114
112
  end
115
- # Pre-flight clobber check for atomic: refuse before creating a tmp.
116
- if atomic && clobber == false && ::File.exist?(path)
113
+ format ||= case netcdf4
114
+ when true then :netcdf4
115
+ when false then :classic
116
+ else
117
+ if template
118
+ src_file = template.is_a?(File) ? template : template.file
119
+ FORMAT_NAMES.fetch(src_file.format, :classic)
120
+ else
121
+ :classic
122
+ end
123
+ end
124
+ format_flag = FORMAT_FLAGS.fetch(format) do
125
+ raise ArgumentError,
126
+ "format: expected one of #{FORMAT_FLAGS.keys.inspect}, got #{format.inspect}"
127
+ end
128
+ # Pre-flight clobber check: refuse before creating anything, so an
129
+ # atomic and a direct write raise the same error.
130
+ if clobber == false && ::File.exist?(path)
117
131
  raise Errno::EEXIST, path
118
132
  end
119
133
  flag = clobber ? ::NetCDF::NC_CLOBBER : ::NetCDF::NC_NOCLOBBER
120
- flag |= ::NetCDF::NC_NETCDF4 if netcdf4
134
+ flag |= format_flag
121
135
  write_path = atomic ? atomic_tmp_path_for(path) : path
122
136
  after_new = template ?
123
137
  ->(f) { apply_template(f, template, dims: dims, atts: atts, vars: vars) } :
@@ -131,6 +145,39 @@ module CA
131
145
 
132
146
  private
133
147
 
148
+ # @api private
149
+ NC_64BIT_DATA = ::NetCDF.const_defined?(:NC_64BIT_DATA) ? ::NetCDF::NC_64BIT_DATA : 0x0020
150
+
151
+ # `format:` names -> the `nc_create` flags that make them.
152
+ # @api private
153
+ FORMAT_FLAGS = {
154
+ classic: 0,
155
+ offset64: ::NetCDF::NC_64BIT_OFFSET,
156
+ cdf5: NC_64BIT_DATA,
157
+ netcdf4: ::NetCDF::NC_NETCDF4,
158
+ netcdf4_classic: ::NetCDF::NC_NETCDF4 | ::NetCDF::NC_CLASSIC_MODEL,
159
+ }.freeze
160
+
161
+ # `NC_FORMAT_*` (what {#format} returns) -> the `format:` name.
162
+ # @api private
163
+ FORMAT_NAMES = {
164
+ ::NetCDF::NC_FORMAT_CLASSIC => :classic,
165
+ ::NetCDF::NC_FORMAT_64BIT_OFFSET => :offset64,
166
+ ::NetCDF::NC_FORMAT_64BIT_DATA => :cdf5,
167
+ ::NetCDF::NC_FORMAT_NETCDF4 => :netcdf4,
168
+ ::NetCDF::NC_FORMAT_NETCDF4_CLASSIC => :netcdf4_classic,
169
+ }.freeze
170
+
171
+ # The file an atomic write replaces: the target of a symlink, so
172
+ # the link is kept and the tmp is made beside the real file.
173
+ # @api private
174
+ def atomic_target(path)
175
+ return path unless ::File.symlink?(path)
176
+ ::File.realpath(path)
177
+ rescue SystemCallError
178
+ path # a dangling link is replaced as it is
179
+ end
180
+
134
181
  # libnetcdf takes a "scheme://..." string as a URL, not a path.
135
182
  def url?(path)
136
183
  path.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://})
@@ -142,6 +189,7 @@ module CA
142
189
  # `find … -name '*.tmp'` picks up orphans. PID + random so
143
190
  # parallel writers don't collide.
144
191
  def atomic_tmp_path_for(path)
192
+ path = atomic_target(path)
145
193
  dir = ::File.dirname(path)
146
194
  base = ::File.basename(path)
147
195
  ::File.join(dir, "#{base}.#{Process.pid}.#{rand(2**32).to_s(16)}.tmp")
@@ -149,7 +197,14 @@ module CA
149
197
 
150
198
  def build(ncid, filename, mode:, codec:, defining:,
151
199
  tmp_path: nil, var_defaults: {}, after_new: nil, &block)
152
- f = new(ncid, filename, mode, codec, defining, tmp_path, var_defaults)
200
+ begin
201
+ f = new(ncid, filename, mode, codec, defining, tmp_path, var_defaults)
202
+ rescue Exception
203
+ # Without a File there is nothing to close it later.
204
+ ::NetCDF.nc_close(ncid) rescue nil
205
+ ::File.unlink(tmp_path) if tmp_path && ::File.exist?(tmp_path)
206
+ raise
207
+ end
153
208
  # after_new runs BEFORE the user's block, but still inside the same
154
209
  # try/close so a failure there also aborts an atomic write.
155
210
  begin
@@ -186,15 +241,17 @@ module CA
186
241
  raise ArgumentError,
187
242
  "File.create dims: expected true / false / Array, got #{dims.inspect}"
188
243
  end
244
+ # The template's attributes are copied as stored, keeping their
245
+ # types; Hash overrides are written by value.
189
246
  case atts
190
247
  when true
191
- template.atts.each { |k, v| f.def_att(k, v) }
248
+ template.atts.each_key { |k| f.send(:copy_att_from, template, ::NetCDF::NC_GLOBAL, k) }
192
249
  when false, nil
193
250
  # skip
194
251
  when Hash
195
- template.atts
196
- .merge(atts.transform_keys(&:to_s))
197
- .each { |k, v| f.def_att(k, v) }
252
+ overrides = atts.transform_keys(&:to_s)
253
+ (template.atts.keys - overrides.keys).each { |k| f.send(:copy_att_from, template, ::NetCDF::NC_GLOBAL, k) }
254
+ overrides.each { |k, v| f.def_att(k, v) }
198
255
  else
199
256
  raise ArgumentError,
200
257
  "File.create atts: expected true / false / Hash, got #{atts.inspect}"
@@ -214,28 +271,37 @@ module CA
214
271
  @codec_request = codec
215
272
  @tmp_path = tmp_path
216
273
  @var_defaults = var_defaults.freeze
274
+ @dim_index = {} # dimid -> Dim, file-wide (see Group#dim_by_id)
217
275
  # We are the root: pass self as `file`, nil as `parent`.
218
276
  super(ncid, self, nil)
219
277
  # In create mode the Conventions attribute is not defined yet, so
220
278
  # resolve the codec lazily (on first read/write, once definitions are
221
279
  # in place). In read mode it can be resolved eagerly.
222
280
  @codec = defining ? nil : Codec.resolve(self, codec)
223
- # Safety net: if the user forgets close on an atomic-write File,
224
- # at least unlink the tmp (never publish half-written data via a
225
- # finalizer — rename is close's job, not GC's).
226
- if @tmp_path
227
- ObjectSpace.define_finalizer(self,
228
- self.class.send(:atomic_tmp_cleaner, @tmp_path))
229
- end
281
+ # Safety net for a File never closed, at GC or process exit: close
282
+ # the ncid, or a NetCDF-3 file loses what was written and the ids
283
+ # run out. An atomic write's tmp is unlinked rather than published
284
+ # (rename is close's job, not GC's). `@state` is shared with the
285
+ # finalizer, so a File closed by hand is not closed again — its id
286
+ # may by then name another file.
287
+ @state = [false]
288
+ ObjectSpace.define_finalizer(self,
289
+ self.class.send(:unclosed_file_closer, ncid, @state, @tmp_path))
230
290
  end
231
291
 
232
- # Registers the finalizer as a separate class method so the proc
233
- # does NOT capture `self` (that would pin the File and defeat GC).
292
+ # Builds the finalizer in a class method so the proc does NOT capture
293
+ # `self` (that would pin the File and defeat GC).
234
294
  # @api private
235
- def self.atomic_tmp_cleaner(tmp)
236
- proc { ::File.unlink(tmp) if ::File.exist?(tmp) rescue nil }
295
+ def self.unclosed_file_closer(ncid, state, tmp)
296
+ proc do
297
+ unless state[0]
298
+ state[0] = true
299
+ ::NetCDF.nc_close(ncid) rescue nil
300
+ ::File.unlink(tmp) if tmp && ::File.exist?(tmp) rescue nil
301
+ end
302
+ end
237
303
  end
238
- private_class_method :atomic_tmp_cleaner
304
+ private_class_method :unclosed_file_closer
239
305
 
240
306
  # The final (logical) file path. For an atomic-write File before
241
307
  # close, the bytes are actually at a same-dir tmp path (see
@@ -271,40 +337,94 @@ module CA
271
337
  super.tap { @codec = nil if @codec_request == :auto && name.to_s == "Conventions" }
272
338
  end
273
339
 
340
+ # {Group#reload}, and the codec chosen again from `Conventions`
341
+ # (unless one was given with `codec:`).
342
+ # @return [self]
343
+ def reload
344
+ super
345
+ @codec = nil if @codec_request == :auto
346
+ self
347
+ end
348
+
274
349
  # @return [Integer] `NC_FORMAT_*` constant
275
- def format = nc_inq_format(@ncid)
350
+ def format = (ensure_open; nc_inq_format(@ncid))
351
+
352
+ # Whether the file is in a NetCDF-4 format (NetCDF-4, or NetCDF-4
353
+ # classic model), the formats with chunking and filters.
354
+ # @return [Boolean]
355
+ def netcdf4? = [NC_FORMAT_NETCDF4, NC_FORMAT_NETCDF4_CLASSIC].include?(format)
276
356
 
277
357
  # Close and, for an atomic-write File, atomically rename the tmp to
278
358
  # the final path. Idempotent: a second call is a no-op.
359
+ #
360
+ # The File counts as closed even when `nc_close` raises: libnetcdf
361
+ # has let go of the id by then, and closing it again crashes the
362
+ # process. An atomic write whose close or rename fails discards
363
+ # its tmp.
279
364
  # @return [void]
280
365
  def close
281
366
  return if @closed
282
- nc_close(@ncid)
283
- @closed = true
284
- if @tmp_path
285
- ::File.rename(@tmp_path, @filename)
286
- @tmp_path = nil
367
+ @closed = @state[0] = true
368
+ begin
369
+ nc_close(@ncid)
370
+ publish_tmp if @tmp_path
371
+ rescue Exception
372
+ discard_tmp
373
+ raise
287
374
  end
375
+ @tmp_path = nil
288
376
  end
289
377
 
290
- # Discard a partially-written atomic file. Closes without renaming
291
- # and unlinks the tmp. Used internally by the block-form `ensure`
292
- # clause when the block raises; can be called explicitly to abandon
293
- # a write in progress. Idempotent.
378
+ # Discard what has not been committed, and close. Used by the block
379
+ # form when the block raises; can be called explicitly to abandon a
380
+ # write in progress. Idempotent.
381
+ #
382
+ # It calls `nc_abort`: definitions made since the file last left
383
+ # define mode are rolled back, and a file created here that is still
384
+ # in define mode is removed. Data already written stays. An atomic
385
+ # write's tmp is unlinked.
294
386
  # @return [void]
295
387
  def abort
296
388
  return if @closed
297
- nc_close(@ncid) rescue nil
298
- @closed = true
299
- if @tmp_path
300
- ::File.unlink(@tmp_path) if ::File.exist?(@tmp_path)
301
- @tmp_path = nil
389
+ @closed = @state[0] = true
390
+ nc_abort(@ncid) rescue nil
391
+ discard_tmp
392
+ end
393
+
394
+ # Move an atomic write's tmp into place: over the target of a
395
+ # symlink rather than the link, with the replaced file's permissions.
396
+ # @api private
397
+ private def publish_tmp
398
+ target = self.class.send(:atomic_target, @filename)
399
+ if ::File.exist?(target)
400
+ ::File.chmod(::File.stat(target).mode & 0o7777, @tmp_path)
302
401
  end
402
+ ::File.rename(@tmp_path, target)
403
+ end
404
+
405
+ # Unlink an atomic write's tmp, if there is one.
406
+ # @api private
407
+ private def discard_tmp
408
+ ::File.unlink(@tmp_path) if @tmp_path && ::File.exist?(@tmp_path)
409
+ @tmp_path = nil
410
+ end
411
+
412
+ # Raise if the file has been closed: libnetcdf reuses ids, so a
413
+ # closed File's ncid may already name another open file.
414
+ # @raise [IOError]
415
+ # @api private
416
+ def ensure_open
417
+ raise IOError, "closed NetCDF file #{@filename}" if @closed
303
418
  end
304
419
 
305
420
  # @return [Boolean]
306
421
  def closed? = @closed == true
307
422
 
423
+ # The file's dimensions by id, for {Group#dim_by_id}.
424
+ # @return [Hash{Integer => Dim}]
425
+ # @api private
426
+ attr_reader :dim_index
427
+
308
428
  # The `Conventions` global attribute split into tokens (empty if
309
429
  # the attribute is missing). Provided so a higher-level convenience
310
430
  # layer can dispatch on the same string this gem uses to pick a
@@ -312,8 +432,10 @@ module CA
312
432
  # `"COARDS/CF-1.0"` decompose the way you'd expect.
313
433
  # @return [Array<String>]
314
434
  def conventions
315
- s = att("Conventions") or return []
316
- s.split(/[\s,\/]+/).reject(&:empty?)
435
+ # An NC_STRING array gives an Array; a numeric attribute is ignored.
436
+ Array(att("Conventions")).grep(String)
437
+ .flat_map { |s| s.split(/[\s,\/]+/) }
438
+ .reject(&:empty?)
317
439
  end
318
440
 
319
441
  # @!group Define / data mode management (also used by every Group / Var)
@@ -321,6 +443,7 @@ module CA
321
443
  # Switch to define mode if not already in it.
322
444
  # @return [void]
323
445
  def ensure_define_mode
446
+ ensure_open
324
447
  return if @defining
325
448
  nc_redef(@ncid)
326
449
  @defining = true
@@ -329,6 +452,7 @@ module CA
329
452
  # Switch to data mode if not already in it.
330
453
  # @return [void]
331
454
  def ensure_data_mode
455
+ ensure_open
332
456
  return unless @defining
333
457
  nc_enddef(@ncid)
334
458
  @defining = false
@@ -348,7 +472,10 @@ module CA
348
472
  # @!endgroup
349
473
 
350
474
  # @return [String]
351
- def inspect = "#<#{self.class} #{@filename.inspect} vars=#{vars.keys} groups=#{groups.keys}>"
475
+ def inspect
476
+ return "#<#{self.class} #{@filename.inspect} (closed)>" if closed?
477
+ "#<#{self.class} #{@filename.inspect} vars=#{vars.keys} groups=#{groups.keys}>"
478
+ end
352
479
  end
353
480
  end
354
481
  end