carray-netcdf 1.0.13 → 2.0.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.
@@ -0,0 +1,354 @@
1
+ module CA
2
+ module NetCDF
3
+ # A NetCDF file — unified read / write / create.
4
+ #
5
+ # @example
6
+ # File.open("data.nc") # read
7
+ # File.open("data.nc", :write) # modify existing
8
+ # File.create("out.nc") # new file
9
+ # File.create("out.nc", atomic: true) # tmp + rename on success
10
+ #
11
+ # The `File` IS the root {Group}; all Group methods (`dims`, `vars`,
12
+ # `def_dim`, `def_var`, `def_group`, `nc[name]`, `nc[name]=`, …) work
13
+ # directly on it and operate on the root group. Named subgroups are
14
+ # {Group} instances reachable via {#groups} / {Group#group} /
15
+ # {Group#def_group}.
16
+ #
17
+ # Block forms auto-close (and atomic writes commit at that point).
18
+ # Names are Strings throughout.
19
+ class File < Group
20
+
21
+ # Kwargs on {.create} that become file-wide defaults for every
22
+ # subsequent {Group#def_var} call. See docs/writing.md for details.
23
+ # @return [Array<Symbol>]
24
+ FILE_VAR_DEFAULTS = %i[chunks deflate shuffle fletcher32 endian no_fill].freeze
25
+
26
+ class << self
27
+ # Open an existing NetCDF file.
28
+ #
29
+ # @param path [String]
30
+ # @param mode [:read, :write, :append]
31
+ # @param codec [Symbol, Array, Object] see docs/codec.md
32
+ # @yieldparam file [File]
33
+ # @return [File] the file (or block's return value if a block is
34
+ # given; the file is closed after the block)
35
+ def open(path, mode = :read, codec: :auto, &block)
36
+ flag = case mode
37
+ when :read then ::NetCDF::NC_NOWRITE
38
+ when :write, :append then ::NetCDF::NC_WRITE | ::NetCDF::NC_SHARE
39
+ else raise ArgumentError, "unknown mode: #{mode.inspect}"
40
+ end
41
+ build(::NetCDF.nc_open(path, flag), path,
42
+ mode: mode, codec: codec, defining: false, &block)
43
+ end
44
+
45
+ # Create a new NetCDF file.
46
+ #
47
+ # @param path [String] final file path
48
+ # @param codec [Symbol, Array, Object] see docs/codec.md
49
+ # @param clobber [true, false, :skip] overwrite policy when `path`
50
+ # already exists. `true` (default) overwrites; `false` raises
51
+ # `Errno::EEXIST`; `:skip` returns `nil` without calling the
52
+ # block — handy for resumable batches (`Dir[...]` picks up
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.
58
+ # @param atomic [Boolean] write to a same-dir tmp file, rename to
59
+ # `path` only on successful close; unlink the tmp on any
60
+ # exception from the block form. Rename is atomic on POSIX.
61
+ # Consumers polling the target path won't see anything until
62
+ # close — for a long-running unlimited-dim streaming write,
63
+ # prefer `atomic: false`.
64
+ # @param template [File, Group, nil] a reference file / group
65
+ # whose skeleton (dimensions and global attributes) is copied
66
+ # into the new file before the block runs. See `dims:`, `atts:`,
67
+ # `vars:`.
68
+ # @param dims [true, false, Array<String>] which template dims to
69
+ # copy. `true` (default) = all dims; `false` = none; Array =
70
+ # named subset. Ignored when `template:` is nil.
71
+ # @param atts [true, false, Hash] which template global attributes
72
+ # to copy. `true` (default) = all; `false` = none; Hash =
73
+ # template's atts merged with these overrides (last-writer
74
+ # wins).
75
+ # @param vars [Array<String>] template variable names to copy in
76
+ # full (definition AND data, bit-identical via raw + layer 1).
77
+ # Typical use: copy coordinate vars like `%w[time lat lon]` once
78
+ # per batch iteration.
79
+ # @param var_defaults [Hash] file-wide defaults picked up by every
80
+ # subsequent `def_var` (a per-var kwarg on `def_var` overrides
81
+ # the file default). Valid keys: {FILE_VAR_DEFAULTS}.
82
+ # @yieldparam file [File]
83
+ # @return [File, nil] the file (or block's return value); `nil`
84
+ # when `clobber: :skip` and `path` already exists.
85
+ def create(path, codec: :auto, clobber: true, netcdf4: false,
86
+ atomic: false, template: nil,
87
+ dims: true, atts: true, vars: [],
88
+ **var_defaults, &block)
89
+ # Extract file-wide var defaults; anything else is an error.
90
+ unknown = var_defaults.keys - FILE_VAR_DEFAULTS
91
+ unless unknown.empty?
92
+ raise ArgumentError, "unknown File.create kwargs: #{unknown.inspect}"
93
+ end
94
+ # A URL -- a Zarr store, "file:///data/t.zarr#mode=zarr,file" -- is
95
+ # not a path. File.exist? cannot see it, NCZarr does not honour
96
+ # NC_NOCLOBBER, and a tmp name appended to it lands in the "#mode="
97
+ # part, so libnetcdf creates over the target itself.
98
+ if url?(path) && (atomic || clobber != true)
99
+ raise ArgumentError, "atomic: and clobber: take a file path, not a URL"
100
+ end
101
+ # clobber: :skip — path already exists, quietly do nothing.
102
+ if clobber == :skip && ::File.exist?(path)
103
+ return nil
104
+ 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
114
+ end
115
+ # Pre-flight clobber check for atomic: refuse before creating a tmp.
116
+ if atomic && clobber == false && ::File.exist?(path)
117
+ raise Errno::EEXIST, path
118
+ end
119
+ flag = clobber ? ::NetCDF::NC_CLOBBER : ::NetCDF::NC_NOCLOBBER
120
+ flag |= ::NetCDF::NC_NETCDF4 if netcdf4
121
+ write_path = atomic ? atomic_tmp_path_for(path) : path
122
+ after_new = template ?
123
+ ->(f) { apply_template(f, template, dims: dims, atts: atts, vars: vars) } :
124
+ nil
125
+ build(::NetCDF.nc_create(write_path, flag), path,
126
+ mode: :create, codec: codec, defining: true,
127
+ tmp_path: (write_path if atomic),
128
+ var_defaults: var_defaults,
129
+ after_new: after_new, &block)
130
+ end
131
+
132
+ private
133
+
134
+ # libnetcdf takes a "scheme://..." string as a URL, not a path.
135
+ def url?(path)
136
+ path.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://})
137
+ end
138
+
139
+ # Same directory as the target so `File.rename` is same-device
140
+ # (POSIX cross-device rename fails with EXDEV). Visible (no
141
+ # dot-prefix) with `.tmp` as the trailing extension so
142
+ # `find … -name '*.tmp'` picks up orphans. PID + random so
143
+ # parallel writers don't collide.
144
+ def atomic_tmp_path_for(path)
145
+ dir = ::File.dirname(path)
146
+ base = ::File.basename(path)
147
+ ::File.join(dir, "#{base}.#{Process.pid}.#{rand(2**32).to_s(16)}.tmp")
148
+ end
149
+
150
+ def build(ncid, filename, mode:, codec:, defining:,
151
+ tmp_path: nil, var_defaults: {}, after_new: nil, &block)
152
+ f = new(ncid, filename, mode, codec, defining, tmp_path, var_defaults)
153
+ # after_new runs BEFORE the user's block, but still inside the same
154
+ # try/close so a failure there also aborts an atomic write.
155
+ begin
156
+ after_new&.call(f)
157
+ rescue Exception
158
+ f.abort rescue nil
159
+ raise
160
+ end
161
+ return f unless block
162
+ begin
163
+ result = block.call(f)
164
+ f.close # normal close: atomic rename happens here
165
+ result
166
+ rescue Exception
167
+ f.abort rescue nil # abort: unlink tmp, no rename
168
+ raise
169
+ end
170
+ end
171
+
172
+ # Copy dims / atts / whole vars from a template File or Group into
173
+ # a freshly-created File. Called from create() via after_new so
174
+ # the copy happens BEFORE the user's block runs — the block sees
175
+ # a File already populated with the template's skeleton and can
176
+ # start adding new definitions immediately.
177
+ def apply_template(f, template, dims:, atts:, vars:)
178
+ case dims
179
+ when true
180
+ template.dims.each_value { |d| f.copy_dim(d) }
181
+ when false, nil
182
+ # skip
183
+ when Array
184
+ dims.each { |n| f.copy_dim(n.to_s, from: template) }
185
+ else
186
+ raise ArgumentError,
187
+ "File.create dims: expected true / false / Array, got #{dims.inspect}"
188
+ end
189
+ case atts
190
+ when true
191
+ template.atts.each { |k, v| f.def_att(k, v) }
192
+ when false, nil
193
+ # skip
194
+ when Hash
195
+ template.atts
196
+ .merge(atts.transform_keys(&:to_s))
197
+ .each { |k, v| f.def_att(k, v) }
198
+ else
199
+ raise ArgumentError,
200
+ "File.create atts: expected true / false / Hash, got #{atts.inspect}"
201
+ end
202
+ Array(vars).each { |n| f.copy_var(n.to_s, from: template) }
203
+ end
204
+ private :apply_template
205
+ end
206
+
207
+ # @api private
208
+ # Use {.open} / {.create} instead.
209
+ def initialize(ncid, filename, mode, codec, defining,
210
+ tmp_path = nil, var_defaults = {})
211
+ @filename = filename
212
+ @mode = mode
213
+ @defining = defining
214
+ @codec_request = codec
215
+ @tmp_path = tmp_path
216
+ @var_defaults = var_defaults.freeze
217
+ # We are the root: pass self as `file`, nil as `parent`.
218
+ super(ncid, self, nil)
219
+ # In create mode the Conventions attribute is not defined yet, so
220
+ # resolve the codec lazily (on first read/write, once definitions are
221
+ # in place). In read mode it can be resolved eagerly.
222
+ @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
230
+ end
231
+
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).
234
+ # @api private
235
+ def self.atomic_tmp_cleaner(tmp)
236
+ proc { ::File.unlink(tmp) if ::File.exist?(tmp) rescue nil }
237
+ end
238
+ private_class_method :atomic_tmp_cleaner
239
+
240
+ # The final (logical) file path. For an atomic-write File before
241
+ # close, the bytes are actually at a same-dir tmp path (see
242
+ # {#tmp_path}); once close succeeds, they are at {#filename}.
243
+ # (Not to be confused with {Group#path}, which is the NetCDF-4
244
+ # group hierarchy path — always `"/"` for a root group == a File.)
245
+ # @return [String]
246
+ attr_reader :filename
247
+
248
+ # The tmp path an atomic-write File is currently writing to; `nil`
249
+ # otherwise or after a successful close.
250
+ # @return [String, nil]
251
+ attr_reader :tmp_path
252
+
253
+ # File-wide NetCDF-4 var defaults applied to every `def_var` unless
254
+ # overridden per-var.
255
+ # @return [Hash]
256
+ attr_reader :var_defaults
257
+
258
+ # @return [Boolean]
259
+ def atomic? = !@tmp_path.nil?
260
+
261
+ # @return [Object] the codec (resolved lazily on first access)
262
+ def codec = (@codec ||= Codec.resolve(self, @codec_request))
263
+
264
+ # Define a global attribute. Under `codec: :auto`, (re)defining
265
+ # `Conventions` drops the codec resolved so far, so the next read or
266
+ # write picks it by the new value — even if something was already
267
+ # read or written before the attribute was set.
268
+ # @param (see Group#def_att)
269
+ # @return [Object] the value written
270
+ def def_att(name, value)
271
+ super.tap { @codec = nil if @codec_request == :auto && name.to_s == "Conventions" }
272
+ end
273
+
274
+ # @return [Integer] `NC_FORMAT_*` constant
275
+ def format = nc_inq_format(@ncid)
276
+
277
+ # Close and, for an atomic-write File, atomically rename the tmp to
278
+ # the final path. Idempotent: a second call is a no-op.
279
+ # @return [void]
280
+ def close
281
+ return if @closed
282
+ nc_close(@ncid)
283
+ @closed = true
284
+ if @tmp_path
285
+ ::File.rename(@tmp_path, @filename)
286
+ @tmp_path = nil
287
+ end
288
+ end
289
+
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.
294
+ # @return [void]
295
+ def abort
296
+ 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
302
+ end
303
+ end
304
+
305
+ # @return [Boolean]
306
+ def closed? = @closed == true
307
+
308
+ # The `Conventions` global attribute split into tokens (empty if
309
+ # the attribute is missing). Provided so a higher-level convenience
310
+ # layer can dispatch on the same string this gem uses to pick a
311
+ # codec, without re-parsing it. Values like `"CF-1.7 ACDD-1.3"` or
312
+ # `"COARDS/CF-1.0"` decompose the way you'd expect.
313
+ # @return [Array<String>]
314
+ def conventions
315
+ s = att("Conventions") or return []
316
+ s.split(/[\s,\/]+/).reject(&:empty?)
317
+ end
318
+
319
+ # @!group Define / data mode management (also used by every Group / Var)
320
+
321
+ # Switch to define mode if not already in it.
322
+ # @return [void]
323
+ def ensure_define_mode
324
+ return if @defining
325
+ nc_redef(@ncid)
326
+ @defining = true
327
+ end
328
+
329
+ # Switch to data mode if not already in it.
330
+ # @return [void]
331
+ def ensure_data_mode
332
+ return unless @defining
333
+ nc_enddef(@ncid)
334
+ @defining = false
335
+ end
336
+
337
+ # @api private
338
+ # Run a block within a define-mode (or data-mode) transaction.
339
+ # @param define [Boolean] `true` to enter define mode first,
340
+ # `false` for data mode
341
+ # @yield the transactional body
342
+ # @return [Object] the block's return value
343
+ def __transaction__(define: true)
344
+ define ? ensure_define_mode : ensure_data_mode
345
+ yield
346
+ end
347
+
348
+ # @!endgroup
349
+
350
+ # @return [String]
351
+ def inspect = "#<#{self.class} #{@filename.inspect} vars=#{vars.keys} groups=#{groups.keys}>"
352
+ end
353
+ end
354
+ end