atlas-python 0.13.0__tar.gz → 0.13.1__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 (98) hide show
  1. {atlas_python-0.13.0 → atlas_python-0.13.1}/Cargo.lock +2 -2
  2. {atlas_python-0.13.0 → atlas_python-0.13.1}/Cargo.toml +1 -1
  3. {atlas_python-0.13.0 → atlas_python-0.13.1}/PKG-INFO +1 -1
  4. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/Cargo.toml +1 -1
  5. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/tests/test_xarray.py +64 -0
  6. {atlas_python-0.13.0 → atlas_python-0.13.1}/pyproject.toml +1 -1
  7. {atlas_python-0.13.0 → atlas_python-0.13.1}/python/atlas/xarray.py +81 -9
  8. {atlas_python-0.13.0 → atlas_python-0.13.1}/.github/workflows/atlas-python-docs.yaml +0 -0
  9. {atlas_python-0.13.0 → atlas_python-0.13.1}/.github/workflows/atlas-python-release.yaml +0 -0
  10. {atlas_python-0.13.0 → atlas_python-0.13.1}/.github/workflows/atlas-rust-release.yaml +0 -0
  11. {atlas_python-0.13.0 → atlas_python-0.13.1}/.github/workflows/ci.yaml +0 -0
  12. {atlas_python-0.13.0 → atlas_python-0.13.1}/.gitignore +0 -0
  13. {atlas_python-0.13.0 → atlas_python-0.13.1}/CONTRIBUTING.md +0 -0
  14. {atlas_python-0.13.0 → atlas_python-0.13.1}/LICENSE +0 -0
  15. {atlas_python-0.13.0 → atlas_python-0.13.1}/README.md +0 -0
  16. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/.python-version +0 -0
  17. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/LICENSE +0 -0
  18. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/README.md +0 -0
  19. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/benchmarks/README.md +0 -0
  20. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/benchmarks/_common.py +0 -0
  21. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/benchmarks/bench_collection.py +0 -0
  22. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/benchmarks/generate_bench_charts.py +0 -0
  23. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/assets/bench_gridded.svg +0 -0
  24. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/assets/bench_profile.svg +0 -0
  25. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/benchmarks.md +0 -0
  26. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/examples.md +0 -0
  27. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/attributes.md +0 -0
  28. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/bulk-reads.md +0 -0
  29. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/cloud-storage.md +0 -0
  30. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/codecs-and-meta.md +0 -0
  31. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/dask.md +0 -0
  32. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/datasets-and-arrays.md +0 -0
  33. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/dtypes.md +0 -0
  34. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/durability.md +0 -0
  35. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/shared-arrays.md +0 -0
  36. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/stats.md +0 -0
  37. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/guides/xarray.md +0 -0
  38. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/index.md +0 -0
  39. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/installation.md +0 -0
  40. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/quickstart.md +0 -0
  41. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/reference/atlas.md +0 -0
  42. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/reference/dataset-view.md +0 -0
  43. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/reference/xarray-accessor.md +0 -0
  44. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  45. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/01_basics.py +0 -0
  46. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/02_xarray.py +0 -0
  47. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/03_dask_streaming.py +0 -0
  48. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/04_meta_formats.py +0 -0
  49. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/05_codecs.py +0 -0
  50. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/06_stats_scan.py +0 -0
  51. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/07_shared_arrays.py +0 -0
  52. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/08_object_store.py +0 -0
  53. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/examples/09_missing_data.py +0 -0
  54. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/mkdocs.yml +0 -0
  55. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/attr.rs +0 -0
  56. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/dataset.rs +0 -0
  57. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/dtype.rs +0 -0
  58. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/error.rs +0 -0
  59. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/lib.rs +0 -0
  60. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/logging.rs +0 -0
  61. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/runtime.rs +0 -0
  62. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/src/store.rs +0 -0
  63. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  64. {atlas_python-0.13.0 → atlas_python-0.13.1}/atlas-python/tests/test_smoke.py +0 -0
  65. {atlas_python-0.13.0 → atlas_python-0.13.1}/examples/lifecycle.rs +0 -0
  66. {atlas_python-0.13.0 → atlas_python-0.13.1}/examples/sensor_fleet.rs +0 -0
  67. {atlas_python-0.13.0 → atlas_python-0.13.1}/examples/weather_store.rs +0 -0
  68. {atlas_python-0.13.0 → atlas_python-0.13.1}/python/atlas/__init__.py +0 -0
  69. {atlas_python-0.13.0 → atlas_python-0.13.1}/python/atlas/__init__.pyi +0 -0
  70. {atlas_python-0.13.0 → atlas_python-0.13.1}/python/atlas/py.typed +0 -0
  71. {atlas_python-0.13.0 → atlas_python-0.13.1}/python/atlas/store.py +0 -0
  72. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/array.rs +0 -0
  73. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/config.rs +0 -0
  74. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/dataset/cache.rs +0 -0
  75. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/dataset/mod.rs +0 -0
  76. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/dataset/pending_attrs.rs +0 -0
  77. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/dataset/view.rs +0 -0
  78. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/error.rs +0 -0
  79. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/lib.rs +0 -0
  80. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/meta/mod.rs +0 -0
  81. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/meta/persist.rs +0 -0
  82. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/meta/schema.rs +0 -0
  83. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/meta/store_meta.rs +0 -0
  84. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/meta/type_index.rs +0 -0
  85. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/bitmap.rs +0 -0
  86. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/column.rs +0 -0
  87. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/format.rs +0 -0
  88. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/io.rs +0 -0
  89. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/mod.rs +0 -0
  90. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/pruning/value.rs +0 -0
  91. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/schema/array.rs +0 -0
  92. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/schema/attr.rs +0 -0
  93. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/schema/dtype.rs +0 -0
  94. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/schema/mod.rs +0 -0
  95. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/store/bulk_read.rs +0 -0
  96. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/store/durability.rs +0 -0
  97. {atlas_python-0.13.0 → atlas_python-0.13.1}/src/store/mod.rs +0 -0
  98. {atlas_python-0.13.0 → atlas_python-0.13.1}/tests/integration.rs +0 -0
@@ -71,7 +71,7 @@ dependencies = [
71
71
 
72
72
  [[package]]
73
73
  name = "atlas-python"
74
- version = "0.13.0"
74
+ version = "0.13.1"
75
75
  dependencies = [
76
76
  "atlas-rust",
77
77
  "ndarray",
@@ -86,7 +86,7 @@ dependencies = [
86
86
 
87
87
  [[package]]
88
88
  name = "atlas-rust"
89
- version = "0.13.0"
89
+ version = "0.13.1"
90
90
  dependencies = [
91
91
  "array-format",
92
92
  "chrono",
@@ -5,7 +5,7 @@ members = ["atlas-python"]
5
5
  # Registry name on crates.io (`atlas` is taken). The library is still
6
6
  # imported as `atlas` — see `[lib]` below.
7
7
  name = "atlas-rust"
8
- version = "0.13.0"
8
+ version = "0.13.1"
9
9
  edition = "2024"
10
10
  description = "Directory-based store for thousands of N-dimensional datasets local or remote using object storage."
11
11
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlas-python
3
- Version: 0.13.0
3
+ Version: 0.13.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Programming Language :: Python :: 3
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.13.0"
3
+ version = "0.13.1"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -530,6 +530,70 @@ def test_datetime_nat_default_fill():
530
530
  assert "_FillValue" not in ds_back["time_obs"].attrs
531
531
 
532
532
 
533
+ def test_timedelta_roundtrip():
534
+ """timedelta64 has no native atlas type; it's stored as int64 ns + a marker
535
+ attribute and restored to a duration dtype on read."""
536
+ deltas = (
537
+ np.array([1, 2, 3, 4], dtype="timedelta64[s]").astype("timedelta64[ns]")
538
+ )
539
+ arr = xr.DataArray(deltas, dims=["t"], attrs={"long_name": "sampling gap"})
540
+ ds = xr.Dataset({"gap": arr})
541
+
542
+ with tempfile.TemporaryDirectory() as d:
543
+ with atlas.Atlas.create(d) as store:
544
+ store.add_xarray_dataset(ds, "ds")
545
+
546
+ store2 = atlas.Atlas.open(d)
547
+ # Stored as int64 under the hood.
548
+ assert store2.open_dataset("ds").array_meta("gap")["dtype"] == "int64"
549
+
550
+ ds_back = store2.open_as_xarray_dataset("ds")
551
+ assert ds_back["gap"].dtype == np.dtype("timedelta64[ns]")
552
+ np.testing.assert_array_equal(ds_back["gap"].values, deltas)
553
+ # The internal marker never surfaces as a user-visible attribute.
554
+ assert "_pyatlas_timedelta" not in ds_back["gap"].attrs
555
+ assert ds_back["gap"].attrs == {"long_name": "sampling gap"}
556
+
557
+
558
+ def test_timedelta_nat_default_fill():
559
+ """A timedelta var with NaT cells defaults to a NaT fill (recorded as null),
560
+ round-tripping with no spurious `_FillValue`."""
561
+ deltas = np.array([10, "NaT", 30, "NaT"], dtype="timedelta64[ns]")
562
+ ds = xr.Dataset({"gap": xr.DataArray(deltas, dims=["t"])})
563
+
564
+ with tempfile.TemporaryDirectory() as d:
565
+ with atlas.Atlas.create(d) as store:
566
+ store.add_xarray_dataset(ds, "ds")
567
+
568
+ store2 = atlas.Atlas.open(d)
569
+ assert store2.open_dataset("ds").array_stats("gap")["null_count"] == 2
570
+ ds_back = store2.open_as_xarray_dataset("ds")
571
+ assert ds_back["gap"].dtype == np.dtype("timedelta64[ns]")
572
+ np.testing.assert_array_equal(ds_back["gap"].values, deltas)
573
+ assert "_FillValue" not in ds_back["gap"].attrs
574
+
575
+
576
+ def test_failed_insert_rolls_back_dataset():
577
+ """An insert that fails partway (unsupported dtype after a valid var) must
578
+ not leave a half-written dataset that a later flush would persist."""
579
+ ds = xr.Dataset(
580
+ {
581
+ "ok": xr.DataArray(np.arange(4, dtype=np.float32), dims=["x"]),
582
+ "bad": xr.DataArray(np.array([True, False, True, False]), dims=["x"]),
583
+ }
584
+ )
585
+ with tempfile.TemporaryDirectory() as d:
586
+ with atlas.Atlas.create(d) as store:
587
+ with pytest.raises(NotImplementedError):
588
+ store.add_xarray_dataset(ds, "ds")
589
+ # Rolled back: the name is gone from the in-memory store, so the
590
+ # __exit__ flush persists nothing for it.
591
+ assert "ds" not in store.list_datasets()
592
+
593
+ # And nothing was persisted to disk either.
594
+ assert "ds" not in atlas.Atlas.open(d).list_datasets()
595
+
596
+
533
597
  def test_missing_strings_filled_with_warning():
534
598
  """A string var with None/NaN cells is written after substituting the string
535
599
  fill (default ""), and a warning is emitted naming the count."""
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "atlas-python"
7
- version = "0.13.0"
7
+ version = "0.13.1"
8
8
  description = "Python bindings for the ATLAS array store"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,6 +36,13 @@ if TYPE_CHECKING:
36
36
  _COORDS_ATTR = "_pyatlas_coords"
37
37
  _JSON_PREFIX = "json:"
38
38
 
39
+ # Per-array marker attribute for variables that were `timedelta64` on the way in.
40
+ # Atlas has no native duration type, so a timedelta is stored as int64
41
+ # nanoseconds (the same int64 view datetime64 uses) and tagged with this
42
+ # attribute; its value is the unit to reconstruct on read. The read path pops
43
+ # it so it never surfaces as a user-visible attribute.
44
+ _TIMEDELTA_ATTR = "_pyatlas_timedelta"
45
+
39
46
  _NUMPY_TO_ATLAS = {
40
47
  np.dtype("int8"): "int8",
41
48
  np.dtype("int16"): "int16",
@@ -54,6 +61,11 @@ _NUMPY_TO_ATLAS = {
54
61
  def _np_to_atlas_dtype(np_dtype: np.dtype) -> str:
55
62
  if np_dtype in _NUMPY_TO_ATLAS:
56
63
  return _NUMPY_TO_ATLAS[np_dtype]
64
+ # timedelta64 (any unit) has no native atlas type; it's stored as int64
65
+ # nanoseconds and tagged with `_TIMEDELTA_ATTR` so the read path restores
66
+ # the duration dtype — the datetime64 parallel.
67
+ if np_dtype.kind == "m":
68
+ return "int64"
57
69
  # Object (Python str/bytes) and fixed-size byte/unicode strings all
58
70
  # become variable-length atlas strings.
59
71
  if np_dtype.kind in ("O", "S", "U"):
@@ -137,10 +149,17 @@ def _normalize_fill_value(value: Any, np_dtype: np.dtype) -> Any:
137
149
  if value is None:
138
150
  return None
139
151
  if isinstance(value, np.ndarray) and value.ndim == 0:
140
- value = value.item() if np_dtype.kind != "M" else value.view(np.int64).item()
152
+ if np_dtype.kind == "M":
153
+ value = value.view(np.int64).item()
154
+ elif np_dtype.kind == "m": # timedelta64 -> int64 nanoseconds
155
+ value = value.astype("timedelta64[ns]").view(np.int64).item()
156
+ else:
157
+ value = value.item()
141
158
  elif isinstance(value, np.generic):
142
159
  if isinstance(value, np.datetime64):
143
160
  return value.astype("datetime64[ns]").view(np.int64).item()
161
+ if isinstance(value, np.timedelta64):
162
+ return value.astype("timedelta64[ns]").view(np.int64).item()
144
163
  value = value.item()
145
164
  if np_dtype.kind in ("O", "S", "U") and isinstance(value, bytes):
146
165
  return value.decode("utf-8", errors="replace")
@@ -187,7 +206,7 @@ def _resolve_fill_value(
187
206
 
188
207
  if np_dtype.kind == "f":
189
208
  return float("nan")
190
- if np_dtype.kind == "M": # datetime64 -> NaT, stored as the i64::MIN sentinel
209
+ if np_dtype.kind in ("M", "m"): # datetime64/timedelta64 -> NaT (i64::MIN sentinel)
191
210
  return _NAT_INT64
192
211
  if np_dtype.kind in ("O", "S", "U"): # string -> "" sentinel for missing cells
193
212
  return ""
@@ -330,7 +349,8 @@ def _write_xarray_to_view(
330
349
  # makes the on-disk file layout predictable.
331
350
  for var_name in coord_names + [str(n) for n in ds.data_vars.keys()]:
332
351
  var = ds[var_name]
333
- atlas_dtype = _np_to_atlas_dtype(np.dtype(var.dtype))
352
+ np_dtype = np.dtype(var.dtype)
353
+ atlas_dtype = _np_to_atlas_dtype(np_dtype)
334
354
  dims = [str(d) for d in var.dims]
335
355
  shape = [int(s) for s in var.shape]
336
356
 
@@ -353,7 +373,7 @@ def _write_xarray_to_view(
353
373
  var_attrs = dict(var.attrs)
354
374
  resolved_fill = _resolve_fill_value(
355
375
  var_name,
356
- np.dtype(var.dtype),
376
+ np_dtype,
357
377
  var_attrs.pop("_FillValue", None),
358
378
  fill_value,
359
379
  )
@@ -367,6 +387,12 @@ def _write_xarray_to_view(
367
387
  fill_value=resolved_fill,
368
388
  )
369
389
 
390
+ # Tag timedelta64 arrays (stored as int64 ns) so the read path can
391
+ # restore the duration dtype. Values are normalised to ns below, so
392
+ # the recorded unit is always "ns".
393
+ if np_dtype.kind == "m":
394
+ view.set_array_attribute(var_name, _TIMEDELTA_ATTR, "ns")
395
+
370
396
  # For string arrays, missing (None/NaN) cells can't be stored as null,
371
397
  # so they're substituted with the resolved string fill (default "").
372
398
  str_fill = resolved_fill if isinstance(resolved_fill, str) else ""
@@ -379,6 +405,10 @@ def _write_xarray_to_view(
379
405
  # numpy datetime64 view to int64 without copying.
380
406
  if block.dtype.kind == "M":
381
407
  block = block.view(np.int64)
408
+ # timedelta64 -> int64 nanoseconds (normalise the unit first, then
409
+ # a zero-copy view). Restored to a duration on read via the marker.
410
+ elif block.dtype.kind == "m":
411
+ block = block.astype("timedelta64[ns]").view(np.int64)
382
412
  elif atlas_dtype == "string":
383
413
  block, n = _fill_missing_strings(block, str_fill)
384
414
  n_filled_strings += n
@@ -428,6 +458,19 @@ def _atlas_to_numpy_dtype(atlas_dtype: str) -> np.dtype:
428
458
  )
429
459
 
430
460
 
461
+ def _as_timedelta(arr: Any, unit: str) -> Any:
462
+ """View an int64 array (numpy or dask) as ``timedelta64[unit]``.
463
+
464
+ A same-itemsize reinterpretation — the on-disk int64 nanoseconds become a
465
+ duration without copying. Dask-backed reads are transformed lazily so the
466
+ chunk-by-chunk read graph is preserved.
467
+ """
468
+ td_dtype = np.dtype(f"timedelta64[{unit}]")
469
+ if _is_dask_array(arr):
470
+ return arr.map_blocks(lambda b: b.view(td_dtype), dtype=td_dtype)
471
+ return np.asarray(arr).view(td_dtype)
472
+
473
+
431
474
  def _dask_chunks_for(shape: Sequence[int], chunk_shape: Sequence[int]) -> tuple:
432
475
  """Per-dim chunk-length tuples in the form dask expects."""
433
476
  chunks: list[tuple[int, ...]] = []
@@ -573,15 +616,30 @@ def _view_to_xarray(view: "DatasetView", force_lazy: bool = False) -> "xr.Datase
573
616
  ):
574
617
  continue
575
618
  meta = view.array_meta(name)
576
- if (
577
- meta is not None
578
- and meta["dtype"] == "timestamp_nanoseconds"
579
- and fv == _NAT_INT64
619
+ # NaT sentinel (i64::MIN) on a datetime column, or on a timedelta column
620
+ # stored as int64 — self-describing in the data, so skip the attr.
621
+ is_timedelta = _TIMEDELTA_ATTR in per_var_attrs.get(name, {})
622
+ if fv == _NAT_INT64 and (
623
+ (meta is not None and meta["dtype"] == "timestamp_nanoseconds")
624
+ or is_timedelta
580
625
  ):
581
626
  continue
582
627
  per_var_attrs.setdefault(name, {})
583
628
  per_var_attrs[name]["_FillValue"] = fv
584
629
 
630
+ # Restore timedelta64 arrays: stored as int64 ns + the `_TIMEDELTA_ATTR`
631
+ # marker. View the int64 data back as a duration and drop the marker so it
632
+ # doesn't surface as a user-visible attribute (i64::MIN reads back as NaT).
633
+ for name in array_names:
634
+ unit = per_var_attrs.get(name, {}).pop(_TIMEDELTA_ATTR, None)
635
+ if unit is None:
636
+ continue
637
+ target = data_vars if name in data_vars else coords
638
+ if name not in target:
639
+ continue
640
+ dims, arr, extra = target[name]
641
+ target[name] = (dims, _as_timedelta(arr, str(unit)), extra)
642
+
585
643
  # Inject per-var attrs into the (dims, data, attrs) triples
586
644
  def _with_attrs(name: str, triple: tuple) -> tuple:
587
645
  dims, arr, _ = triple
@@ -664,9 +722,23 @@ def _write_xarray_new_dataset(
664
722
 
665
723
  Both `atlas.add_xarray_dataset` and the `ds.atlas.write` accessor route through
666
724
  this function.
725
+
726
+ The write is atomic: if populating the view fails partway (e.g. an
727
+ unsupported dtype), the just-created dataset is rolled back with
728
+ `delete_dataset` so a later `flush()`/`close()` can't persist a half-written
729
+ record. Nothing reaches disk until flush regardless, so this only cleans up
730
+ the in-memory store.
667
731
  """
668
732
  view = atlas.create_dataset(name)
669
- _write_xarray_to_view(view, ds, chunks=chunks, fill_value=fill_value)
733
+ try:
734
+ _write_xarray_to_view(view, ds, chunks=chunks, fill_value=fill_value)
735
+ except BaseException:
736
+ try:
737
+ atlas.delete_dataset(name)
738
+ except Exception:
739
+ # Best-effort rollback; surface the original failure regardless.
740
+ pass
741
+ raise
670
742
 
671
743
 
672
744
  # --- xarray accessor ----------------------------------------------------------
File without changes
File without changes
File without changes
File without changes