atlas-python 0.16.2__tar.gz → 0.16.4__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 (84) hide show
  1. {atlas_python-0.16.2 → atlas_python-0.16.4}/Cargo.lock +2 -2
  2. {atlas_python-0.16.2 → atlas_python-0.16.4}/Cargo.toml +1 -1
  3. {atlas_python-0.16.2 → atlas_python-0.16.4}/PKG-INFO +1 -1
  4. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/Cargo.toml +1 -1
  5. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/cli.md +27 -4
  6. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/creating.md +61 -6
  7. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/dtypes.md +65 -0
  8. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/writer.rs +1 -1
  9. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_cli.py +31 -0
  10. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_ops.py +268 -0
  11. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/write-path.md +6 -0
  12. {atlas_python-0.16.2 → atlas_python-0.16.4}/pyproject.toml +1 -1
  13. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__init__.pyi +19 -0
  14. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__main__.py +3 -3
  15. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_cli.py +53 -4
  16. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_ops.py +197 -48
  17. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/xarray.py +209 -3
  18. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/writer/mod.rs +42 -14
  19. {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/fixtures/from_python/data.atlas +0 -0
  20. {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/integration.rs +42 -0
  21. {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-python-docs.yaml +0 -0
  22. {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-python-release.yaml +0 -0
  23. {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/atlas-rust-release.yaml +0 -0
  24. {atlas_python-0.16.2 → atlas_python-0.16.4}/.github/workflows/ci.yaml +0 -0
  25. {atlas_python-0.16.2 → atlas_python-0.16.4}/.gitignore +0 -0
  26. {atlas_python-0.16.2 → atlas_python-0.16.4}/CONTRIBUTING.md +0 -0
  27. {atlas_python-0.16.2 → atlas_python-0.16.4}/LICENSE +0 -0
  28. {atlas_python-0.16.2 → atlas_python-0.16.4}/README.md +0 -0
  29. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/.python-version +0 -0
  30. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/LICENSE +0 -0
  31. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/README.md +0 -0
  32. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/examples.md +0 -0
  33. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/cloud-storage.md +0 -0
  34. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/inspecting.md +0 -0
  35. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/reading-data.md +0 -0
  36. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/guides/removing.md +0 -0
  37. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/index.md +0 -0
  38. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/installation.md +0 -0
  39. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/quickstart.md +0 -0
  40. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/reference/api.md +0 -0
  41. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  42. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/examples/01_library.py +0 -0
  43. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/examples/02_object_store.py +0 -0
  44. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/mkdocs.yml +0 -0
  45. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/attr.rs +0 -0
  46. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/dtype.rs +0 -0
  47. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/error.rs +0 -0
  48. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/lib.rs +0 -0
  49. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/logging.rs +0 -0
  50. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/reader.rs +0 -0
  51. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/runtime.rs +0 -0
  52. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/src/source.rs +0 -0
  53. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  54. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/conftest.py +0 -0
  55. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/make_fixture.py +0 -0
  56. {atlas_python-0.16.2 → atlas_python-0.16.4}/atlas-python/tests/test_source.py +0 -0
  57. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/README.md +0 -0
  58. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/architecture.md +0 -0
  59. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/data-model.md +0 -0
  60. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/format.md +0 -0
  61. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/python.md +0 -0
  62. {atlas_python-0.16.2 → atlas_python-0.16.4}/docs/read-path.md +0 -0
  63. {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/lifecycle.rs +0 -0
  64. {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/sensor_fleet.rs +0 -0
  65. {atlas_python-0.16.2 → atlas_python-0.16.4}/examples/weather_store.rs +0 -0
  66. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/__init__.py +0 -0
  67. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_log.py +0 -0
  68. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/_source.py +0 -0
  69. {atlas_python-0.16.2 → atlas_python-0.16.4}/python/atlas/py.typed +0 -0
  70. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/config.rs +0 -0
  71. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/error.rs +0 -0
  72. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/footer.rs +0 -0
  73. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/mask.rs +0 -0
  74. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/mod.rs +0 -0
  75. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/format/segment_store.rs +0 -0
  76. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/lib.rs +0 -0
  77. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/reader/mod.rs +0 -0
  78. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/array.rs +0 -0
  79. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/attr.rs +0 -0
  80. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/dtype.rs +0 -0
  81. {atlas_python-0.16.2 → atlas_python-0.16.4}/src/schema/mod.rs +0 -0
  82. {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/cross_fixture.rs +0 -0
  83. {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/fixtures/golden_v1/data.atlas +0 -0
  84. {atlas_python-0.16.2 → atlas_python-0.16.4}/tests/golden.rs +0 -0
@@ -71,7 +71,7 @@ dependencies = [
71
71
 
72
72
  [[package]]
73
73
  name = "atlas-python"
74
- version = "0.16.2"
74
+ version = "0.16.4"
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.16.2"
89
+ version = "0.16.4"
90
90
  dependencies = [
91
91
  "array-format",
92
92
  "async-trait",
@@ -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.16.2"
8
+ version = "0.16.4"
9
9
  edition = "2024"
10
10
  description = "Single-file immutable store for thousands of N-dimensional datasets, local or on 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.16.2
3
+ Version: 0.16.4
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.16.2"
3
+ version = "0.16.4"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -25,12 +25,20 @@ Every subcommand takes `--json` and `--log-file PATH`, plus the remote flags
25
25
 
26
26
  ## Logging
27
27
 
28
- `--log-file PATH` appends every error and warning to a file, with the reason:
28
+ `--log-file PATH` appends to the file you name. There is no default location,
29
+ and no log at all without the flag. The command prints the absolute path it
30
+ opened, so it is never a guess:
31
+
32
+ ```text
33
+ atlas: logging to /home/you/ingest.log
34
+ ```
35
+
36
+ The file gets every error and warning, with the reason:
29
37
 
30
38
  ```bash
31
39
  $ atlas create /data/nc /data/collection --skip-unsupported --log-file ingest.log
32
40
  $ cat ingest.log
33
- 2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.2: create /data/nc ...
41
+ 2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.4: create /data/nc ...
34
42
  2026-09-01 14:30:41 INFO atlas.ops: ingesting 2 file(s) into /data/collection
35
43
  2026-09-01 14:30:41 WARNING atlas.ops: /data/nc/buoy.nc: skipped array 'flag' of dtype bool: numpy dtype dtype('bool') is not supported by atlas (supported: ...)
36
44
  2026-09-01 14:30:41 INFO atlas.ops: wrote 1 dataset(s); skipped 0 file(s) and 1 array(s)
@@ -66,10 +74,25 @@ A failure part-way leaves no collection, and not a partial one.
66
74
  | `--open-chunks MODE` | How files are read: `auto`, `native`, `none`, or a JSON dict |
67
75
  | `--chunks JSON` | Override the stored chunk shape, `'{"temperature": [64, 64]}'` |
68
76
  | `--skip-errors` | Skip files that fail instead of abandoning the collection |
77
+ | `-j`, `--workers N` | Stage N files at once. About 3x on a many-core machine. Ordinals do not move |
78
+ | `--convert-calendar` | Turn a cftime axis into exact Gregorian timestamps, each keeping its instant |
79
+ | `--no-decode-times` | Keep a time axis as raw numbers, for a calendar that decodes to cftime |
69
80
  | `--skip-unsupported` | Leave out an array of an unsupported dtype, and keep the rest of the dataset |
70
81
  | `-q`, `--quiet` | Do not list a file as it lands |
71
82
 
72
- Progress goes to stderr, so a pipe still reads stdout.
83
+ Progress goes to stderr, so a pipe still reads stdout. Each line counts the
84
+ files and says how many remain:
85
+
86
+ ```text
87
+ Writing /data/collection from 3 file(s)
88
+ [1/3] 2024-01.nc (2 left)
89
+ [2/3] 2024-02.nc (1 left)
90
+ [3/3] 2024-03.nc (0 left)
91
+ 3 dataset(s) written to /data/collection
92
+ ```
93
+
94
+ `-q` turns the per-file lines off. The same counter goes to `--log-file`, for
95
+ a run nobody watched.
73
96
 
74
97
  ### Unsupported dtypes
75
98
 
@@ -109,7 +132,7 @@ size. `--open-chunks` picks another strategy:
109
132
 
110
133
  | Mode | Reads | Stored chunk shape |
111
134
  |---|---|---|
112
- | `auto` *(default)* | blocks sized to `--chunk-size` | those blocks |
135
+ | `auto` *(default)* | whole when the file is small, else blocks sized to `--chunk-size` | those blocks |
113
136
  | `native` | the file's own chunk encoding | that encoding |
114
137
  | `none` | each variable whole | one full-shape chunk |
115
138
  | JSON dict | as given, per dimension | as given |
@@ -140,8 +140,10 @@ that stream to stderr. See [Installation](../installation.md).
140
140
  These are one decision. The blocks a file *reads* in are the chunks it
141
141
  *stores* in.
142
142
 
143
- Each file opens with dask chunking. `open_chunks="auto"` is the default. dask
144
- then sizes the blocks to `chunk_size`, which is 128 MiB. Three results follow:
143
+ `open_chunks="auto"` is the default, and it picks a strategy per file. A file
144
+ well inside the block budget opens whole. The dask graph costs about 40 ms
145
+ per file, and a small variable lands as one chunk either way. A larger
146
+ file streams through dask, so memory stays bounded. Three results follow:
145
147
 
146
148
  - A file far larger than memory streams block by block, and does not read
147
149
  whole.
@@ -158,13 +160,14 @@ atlas.create("/data/nc", dest, chunk_size="64MiB")
158
160
  ```
159
161
 
160
162
  `chunk_size` is about the memory ceiling per variable. Lower it on a small
161
- machine. Raise it for larger stored chunks.
163
+ machine. Raise it for larger stored chunks. It also moves the threshold at
164
+ which `"auto"` stops opening a file whole.
162
165
 
163
166
  ### How files are opened
164
167
 
165
168
  | `open_chunks` | Reads | Stored chunk shape |
166
169
  |---|---|---|
167
- | `"auto"` *(default)* | blocks sized to `chunk_size` | those blocks |
170
+ | `"auto"` *(default)* | whole when small, else blocks sized to `chunk_size` | those blocks |
168
171
  | `"native"` | the file's own chunk encoding | that encoding |
169
172
  | `None` | each variable whole | one full-shape chunk |
170
173
  | `{"time": 100}` | as given, per dimension | as given |
@@ -206,6 +209,51 @@ arrays["temperature"]["chunk_shape"]
206
209
  atlas show dest 2024-01.nc | grep _ChunkShape
207
210
  ```
208
211
 
212
+ ### Speed on many small files
213
+
214
+ A directory of small files spends most of its time per file, not per byte. On
215
+ 213 KiB profile files the default runs at about 30 files per second, or five
216
+ minutes for ten thousand.
217
+
218
+ **`--workers N` stages N files at once.** It is the largest single win:
219
+
220
+ ```bash
221
+ atlas create /data/nc /data/collection --workers 4
222
+ ```
223
+
224
+ ```text
225
+ workers=1 : 32 files/s 1.00x
226
+ workers=2 : 84 files/s 2.65x
227
+ workers=4 : 120 files/s 3.77x
228
+ workers=8 : 118 files/s 3.72x <- plateau
229
+ ```
230
+
231
+ The commit is the costly half of an ingest, about two thirds of it. It is pure
232
+ Rust, holds no lock until its append, and releases the GIL, so it overlaps.
233
+ The reads stay on the calling thread, and they are what flattens the curve
234
+ near four.
235
+
236
+ The reads have to stay there. netCDF4 sits on HDF5, which is not thread safe,
237
+ and xarray locks an array read but not a variable open. Two threads inside
238
+ `open_dataset` crash the process.
239
+
240
+ Nothing else changes. `add_dataset` runs in file order, so every ordinal
241
+ matches a sequential build. The summary sorts back into file order too. Only
242
+ `progress` reports in completion order.
243
+
244
+ Two more settings matter when that is still too slow:
245
+
246
+ - **`--open-chunks native`** forces dask on every file. That costs about twice
247
+ as long on a small file. Use it only when the file's own chunking is what
248
+ you want stored.
249
+ - **`--codec none`** trades size for speed. It saved about 20 percent on the
250
+ same files.
251
+
252
+ Past the plateau, run several `atlas create` commands at once, one per part of
253
+ the tree. Each has its own writer, so nothing serialises between them. That
254
+ reached 3.7x on eight processes where threads reached 2.9x. The cost is one
255
+ collection per part.
256
+
209
257
  ### The writer's own memory
210
258
 
211
259
  Staging runs on local disk. `array-format` spills each compressed chunk to a
@@ -233,8 +281,15 @@ used.
233
281
  atlas.create("/data/nc", dest, progress=lambda name: print(name))
234
282
  ```
235
283
 
236
- The CLI does this by default, to stderr, so a pipe still reads stdout. `-q`
237
- turns it off.
284
+ The CLI does this by default, to stderr, so a pipe still reads stdout. Each
285
+ line counts the files and says how many remain:
286
+
287
+ ```text
288
+ [ 12/10000] 000043_CFPOINT_3593_V0.nc (9988 left)
289
+ ```
290
+
291
+ `-q` turns the per-file lines off. Pass `--log-file PATH` to keep the same
292
+ counter in a file. The command prints the absolute path it opened.
238
293
 
239
294
  ## What can go wrong
240
295
 
@@ -17,6 +17,71 @@ What a NetCDF variable becomes when `atlas create` ingests it.
17
17
  `atlas show` prints the atlas name, so a `datetime64[ns]` variable appears as
18
18
  `timestamp_nanoseconds`.
19
19
 
20
+ ### Calendars that decode to cftime
21
+
22
+ xarray decodes a time axis to `datetime64[ns]` only when the calendar allows
23
+ it. A Julian, `360_day`, or `noleap` calendar, or a date outside the
24
+ `datetime64[ns]` range, decodes to a `cftime` object instead. Those arrive as
25
+ a numpy `object` array, and atlas cannot store one:
26
+
27
+ ```text
28
+ atlas: profile.nc: variable 'JULD' holds cftime objects (DatetimeJulian),
29
+ which atlas cannot store...
30
+ ```
31
+
32
+ Atlas refuses by default rather than guess, because two conversions exist and
33
+ they differ by 13 days. Pick one.
34
+
35
+ **Convert to the exact instant.** This is almost always what you want:
36
+
37
+ ```bash
38
+ atlas create /data/nc /data/collection --convert-calendar
39
+ ```
40
+
41
+ ```python
42
+ atlas.create("/data/nc", dest, convert_calendar=True)
43
+ ```
44
+
45
+ The axis becomes `timestamp_nanoseconds`, and every value keeps the moment it
46
+ named. A Julian `1973-02-25 18:15` becomes the Gregorian `1973-03-10 18:15`.
47
+ Those are one instant under two calendars. cftime maps between them through
48
+ the Julian Day, so nothing is approximated.
49
+
50
+ **Keep the raw numbers.** The axis stores as an integer, with its `units` and
51
+ `calendar` attributes beside it. A reader decodes it later with
52
+ `cftime.num2date`:
53
+
54
+ ```bash
55
+ atlas create /data/nc /data/collection --no-decode-times
56
+ ```
57
+
58
+ **Drop the axis.** `--skip-unsupported` leaves the time array out, and keeps
59
+ the rest of the dataset.
60
+
61
+ #### Two conversions, 13 days apart
62
+
63
+ The Julian calendar takes a leap year every four years. The Gregorian calendar
64
+ drops three of those every four centuries. The gap grows by about three days
65
+ per 400 years, and holds at 13 days from 1900 to 2100.
66
+
67
+ | Conversion | Julian 2024-01-01 becomes | It keeps |
68
+ |---|---|---|
69
+ | `--convert-calendar` | 2024-01-14 | the instant |
70
+ | `xr.Dataset.convert_calendar("standard")` | 2024-01-01 | the labels |
71
+
72
+ The xarray method keeps the year, month, and day, which moves the moment by 13
73
+ days. Use it only when the labels matter more than the instant.
74
+
75
+ #### Two limits
76
+
77
+ **An artificial calendar names no instant.** A `360_day`, `noleap`, or
78
+ `all_leap` year is a model convention with no place on a real timeline.
79
+ `--convert-calendar` raises for one. Use `--no-decode-times` instead.
80
+
81
+ **A nanosecond timestamp spans 1677-09-21 to 2262-04-11.** A date outside that
82
+ raises. numpy wraps such a date in silence, so atlas checks every value before
83
+ it stores one.
84
+
20
85
  ### datetime and timedelta
21
86
 
22
87
  Atlas supports the `[ns]` resolution of `datetime64` alone. It rejects every
@@ -322,7 +322,7 @@ impl PyDatasetWriter {
322
322
  String::from_utf8_lossy(&b).into_owned()
323
323
  } else {
324
324
  return Err(PyTypeError::new_err(format!(
325
- "string array element must be str or bytes, got {:?}",
325
+ "array {name:?}: a string element must be str or bytes, got {:?}",
326
326
  bound.get_type().name()?
327
327
  )));
328
328
  };
@@ -186,6 +186,37 @@ def test_create_descends_by_default_and_no_recursive_opts_out(capsys, tmp_path):
186
186
  assert code == 0, err
187
187
 
188
188
 
189
+ def test_create_counts_progress_and_what_is_left(capsys, netcdf_dir, tmp_path):
190
+ code, _, err = run(capsys, "create", str(netcdf_dir), str(tmp_path / "c"))
191
+ assert code == 0
192
+ assert "from 3 file(s)" in err
193
+ assert "[1/3] 2024-01.nc (2 left)" in err
194
+ assert "[3/3] 2024-03.nc (0 left)" in err
195
+
196
+
197
+ def test_log_file_says_where_it_writes(capsys, netcdf_dir, tmp_path):
198
+ log = tmp_path / "run.log"
199
+ code, _, err = run(
200
+ capsys, "create", str(netcdf_dir), str(tmp_path / "c"), "-q",
201
+ "--log-file", str(log),
202
+ )
203
+ assert code == 0
204
+ assert f"logging to {log}" in err
205
+ # The log carries the same counter, for a run nobody watched.
206
+ text = log.read_text()
207
+ assert "[1/3] wrote 2024-01.nc" in text
208
+ assert "[3/3] wrote 2024-03.nc" in text
209
+
210
+
211
+ def test_workers_flag_builds_the_same_collection(capsys, netcdf_dir, tmp_path):
212
+ run(capsys, "create", str(netcdf_dir), str(tmp_path / "one"), "-q")
213
+ run(capsys, "create", str(netcdf_dir), str(tmp_path / "many"), "-q", "-j", "4")
214
+
215
+ code, a, _ = run(capsys, "ls", str(tmp_path / "one"))
216
+ code, b, _ = run(capsys, "ls", str(tmp_path / "many"))
217
+ assert a.split() == b.split() == ["2024-01.nc", "2024-02.nc", "2024-03.nc"]
218
+
219
+
189
220
  # ── ls ───────────────────────────────────────────────────────────────
190
221
 
191
222
 
@@ -148,6 +148,105 @@ def test_every_codec_round_trips(netcdf_dir, tmp_path, codec):
148
148
  assert len(atlas.list_datasets(str(dest))) == 3
149
149
 
150
150
 
151
+ # ── parallel staging ─────────────────────────────────────────────────
152
+
153
+
154
+ @pytest.fixture
155
+ def many_netcdf_files(tmp_path):
156
+ """Enough files that several workers actually overlap."""
157
+ d = tmp_path / "many"
158
+ d.mkdir()
159
+ for i in range(12):
160
+ make_dataset((i % 12) + 1).to_netcdf(d / f"m{i:03d}.nc")
161
+ return d
162
+
163
+
164
+ @pytest.mark.parametrize("workers", [2, 4])
165
+ def test_workers_keep_the_ordinals_of_a_sequential_build(
166
+ many_netcdf_files, tmp_path, workers
167
+ ):
168
+ """add_dataset runs in file order, so a worker cannot move an ordinal."""
169
+ one = tmp_path / "one"
170
+ many = tmp_path / f"many{workers}"
171
+ atlas.create(many_netcdf_files, str(one))
172
+ atlas.create(many_netcdf_files, str(many), workers=workers)
173
+
174
+ assert atlas.list_datasets(str(one)) == atlas.list_datasets(str(many))
175
+ for name in atlas.list_datasets(str(one)):
176
+ assert (
177
+ atlas.describe(str(one), name)["ordinal"]
178
+ == atlas.describe(str(many), name)["ordinal"]
179
+ )
180
+
181
+
182
+ def test_workers_produce_the_same_collection(many_netcdf_files, tmp_path):
183
+ one = tmp_path / "one"
184
+ many = tmp_path / "many"
185
+ r1 = atlas.create(many_netcdf_files, str(one))
186
+ r4 = atlas.create(many_netcdf_files, str(many), workers=4)
187
+
188
+ assert r1["written"] == r4["written"]
189
+ a, b = atlas.info(str(one)), atlas.info(str(many))
190
+ assert a["array_stats"] == b["array_stats"]
191
+ assert a["dataset_count"] == b["dataset_count"]
192
+ assert a["interned_schemas"] == b["interned_schemas"]
193
+
194
+ # Every dataset still reads back with its own values.
195
+ for name in atlas.list_datasets(str(many)):
196
+ assert atlas.describe(str(many), name)["attributes"] == atlas.describe(
197
+ str(one), name
198
+ )["attributes"]
199
+
200
+
201
+ def test_workers_still_stop_on_a_bad_file(many_netcdf_files, tmp_path):
202
+ xr.Dataset({"flag": xr.DataArray(np.array([True]), dims=["x"])}).to_netcdf(
203
+ many_netcdf_files / "bad.nc"
204
+ )
205
+ with pytest.raises(atlas.AtlasError):
206
+ atlas.create(many_netcdf_files, str(tmp_path / "c"), workers=4)
207
+
208
+
209
+ def test_workers_can_skip_a_bad_file(many_netcdf_files, tmp_path):
210
+ xr.Dataset({"flag": xr.DataArray(np.array([True]), dims=["x"])}).to_netcdf(
211
+ many_netcdf_files / "bad.nc"
212
+ )
213
+ result = atlas.create(
214
+ many_netcdf_files, str(tmp_path / "c"), workers=4, on_error="skip"
215
+ )
216
+ assert [s["file"].endswith("bad.nc") for s in result["skipped"]] == [True]
217
+ assert len(result["written"]) == 12
218
+ # The order of what landed still follows the input.
219
+ assert atlas.list_datasets(str(tmp_path / "c")) == sorted(result["written"])
220
+
221
+
222
+ def test_every_netcdf_read_stays_on_one_thread(many_netcdf_files, tmp_path, monkeypatch):
223
+ """netCDF4 sits on HDF5, which is not thread safe.
224
+
225
+ xarray locks an array read but not a variable open, so two threads inside
226
+ `open_dataset` segfault. Reads therefore stay on the calling thread, and
227
+ only the commit goes to the pool.
228
+ """
229
+ import threading
230
+
231
+ seen: set[int] = set()
232
+ original = xr.open_dataset
233
+
234
+ def spy(*args, **kwargs):
235
+ seen.add(threading.get_ident())
236
+ return original(*args, **kwargs)
237
+
238
+ monkeypatch.setattr(xr, "open_dataset", spy)
239
+ atlas.create(many_netcdf_files, str(tmp_path / "c"), workers=4)
240
+
241
+ assert len(seen) == 1, f"netCDF was read from {len(seen)} threads"
242
+ assert seen == {threading.get_ident()}, "a read left the calling thread"
243
+
244
+
245
+ def test_workers_below_one_is_refused(netcdf_dir, tmp_path):
246
+ with pytest.raises(atlas.AtlasError, match="workers must be at least 1"):
247
+ atlas.create(netcdf_dir, str(tmp_path / "c"), workers=0)
248
+
249
+
151
250
  # ── unsupported dtypes ───────────────────────────────────────────────
152
251
 
153
252
 
@@ -215,6 +314,151 @@ def test_a_clean_ingest_reports_no_skipped_arrays(netcdf_dir, tmp_path):
215
314
  assert result["skipped_arrays"] == []
216
315
 
217
316
 
317
+ # ── cftime calendars ─────────────────────────────────────────────────
318
+
319
+
320
+ @pytest.fixture
321
+ def netcdf_dir_with_a_julian_calendar(tmp_path):
322
+ """A time axis xarray decodes to cftime, not to datetime64."""
323
+ import cftime
324
+
325
+ d = tmp_path / "nc"
326
+ d.mkdir()
327
+ times = [cftime.DatetimeJulian(2024, 1, i + 1) for i in range(4)]
328
+ xr.Dataset(
329
+ {"temp": ("time", np.arange(4, dtype=np.float32))},
330
+ coords={"time": ("time", times)},
331
+ ).to_netcdf(d / "julian.nc")
332
+ return d
333
+
334
+
335
+ def test_a_cftime_axis_reports_the_variable_and_the_remedy(
336
+ netcdf_dir_with_a_julian_calendar, tmp_path
337
+ ):
338
+ with pytest.raises(atlas.AtlasError) as exc:
339
+ atlas.create(netcdf_dir_with_a_julian_calendar, str(tmp_path / "c"))
340
+
341
+ message = str(exc.value)
342
+ assert "'time'" in message, "the message must name the variable"
343
+ assert "DatetimeJulian" in message
344
+ assert "decode_times=False" in message
345
+
346
+
347
+ def test_decode_times_false_stores_a_cftime_axis_as_raw_numbers(
348
+ netcdf_dir_with_a_julian_calendar, tmp_path
349
+ ):
350
+ dest = tmp_path / "c"
351
+ atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), decode_times=False)
352
+
353
+ arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
354
+ assert arrays["time"]["dtype"] == "int64"
355
+ # The units and the calendar survive, so a reader can decode the numbers.
356
+ assert arrays["time"]["attributes"]["calendar"] == "julian"
357
+ assert "days since" in arrays["time"]["attributes"]["units"]
358
+ assert arrays["temp"]["dtype"] == "float32"
359
+
360
+
361
+ def test_a_cftime_axis_can_be_skipped_per_array(
362
+ netcdf_dir_with_a_julian_calendar, tmp_path
363
+ ):
364
+ dest = tmp_path / "c"
365
+ result = atlas.create(
366
+ netcdf_dir_with_a_julian_calendar, str(dest), on_unsupported="skip"
367
+ )
368
+
369
+ assert [s["array"] for s in result["skipped_arrays"]] == ["time"]
370
+ assert [a["name"] for a in atlas.describe(str(dest), "julian.nc")["arrays"]] == [
371
+ "temp"
372
+ ]
373
+
374
+
375
+ def test_convert_calendar_keeps_the_exact_instant(
376
+ netcdf_dir_with_a_julian_calendar, tmp_path
377
+ ):
378
+ """A Julian label and its Gregorian label name one moment, 13 days apart."""
379
+ dest = tmp_path / "c"
380
+ atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), convert_calendar=True)
381
+
382
+ arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
383
+ assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
384
+
385
+ # The fixture holds Julian 2024-01-01 through 2024-01-04. The same instants
386
+ # carry the Gregorian labels 2024-01-14 through 2024-01-17.
387
+ first = np.datetime64(arrays["time"]["stats"]["min"], "ns")
388
+ last = np.datetime64(arrays["time"]["stats"]["max"], "ns")
389
+ assert first == np.datetime64("2024-01-14T00:00:00")
390
+ assert last == np.datetime64("2024-01-17T00:00:00")
391
+
392
+
393
+ def test_convert_calendar_matches_a_per_element_conversion(
394
+ netcdf_dir_with_a_julian_calendar,
395
+ ):
396
+ """The array-at-once conversion must equal the obvious slow one."""
397
+ from atlas.xarray import _cftime_to_datetime64
398
+
399
+ ds = xr.open_dataset(netcdf_dir_with_a_julian_calendar / "julian.nc")
400
+ values = np.asarray(ds["time"].values)
401
+
402
+ fast = _cftime_to_datetime64("time", values)
403
+ slow = np.array(
404
+ [
405
+ np.datetime64(v.change_calendar("proleptic_gregorian").isoformat(), "ns")
406
+ for v in values
407
+ ]
408
+ )
409
+ assert np.array_equal(fast, slow)
410
+
411
+
412
+ def test_an_artificial_calendar_has_no_exact_gregorian_date(tmp_path):
413
+ """A 360_day year names no real instant, so no conversion exists."""
414
+ import cftime
415
+
416
+ from atlas.xarray import _cftime_to_datetime64
417
+
418
+ values = np.array([cftime.Datetime360Day(2024, 2, 30)], dtype=object)
419
+ with pytest.raises(NotImplementedError, match="360_day"):
420
+ _cftime_to_datetime64("time", values)
421
+
422
+
423
+ def test_a_date_outside_the_nanosecond_range_is_refused_not_wrapped(tmp_path):
424
+ """numpy wraps such a date in silence. Atlas must not pass one on."""
425
+ import cftime
426
+
427
+ from atlas.xarray import _cftime_to_datetime64
428
+
429
+ for year in (1600, 2300):
430
+ values = np.array([cftime.DatetimeJulian(year, 1, 1)], dtype=object)
431
+ with pytest.raises(NotImplementedError, match="outside the range"):
432
+ _cftime_to_datetime64("time", values)
433
+
434
+
435
+ def test_convert_calendar_leaves_a_normal_time_axis_alone(netcdf_dir, tmp_path):
436
+ dest = tmp_path / "c"
437
+ atlas.create(netcdf_dir, str(dest), convert_calendar=True)
438
+ arrays = {a["name"]: a for a in atlas.describe(str(dest), "2024-01.nc")["arrays"]}
439
+ assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
440
+
441
+
442
+ def test_an_object_array_of_something_else_is_refused_clearly(tmp_path):
443
+ """numpy reports `object` for both a string array and this one."""
444
+ from atlas.xarray import _reject_unstorable_object_array
445
+
446
+ var = xr.DataArray(np.array([{"a": 1}, {"b": 2}], dtype=object), dims=["x"])
447
+ with pytest.raises(NotImplementedError, match="object array of dict"):
448
+ _reject_unstorable_object_array("payload", var)
449
+
450
+
451
+ def test_a_real_string_array_still_passes_the_object_check(tmp_path):
452
+ from atlas.xarray import _reject_unstorable_object_array
453
+
454
+ # A missing cell must not be mistaken for an unsupported element.
455
+ var = xr.DataArray(np.array([None, "b", "c"], dtype=object), dims=["x"])
456
+ _reject_unstorable_object_array("label", var)
457
+
458
+ empty = xr.DataArray(np.array([], dtype=object), dims=["x"])
459
+ _reject_unstorable_object_array("empty", empty)
460
+
461
+
218
462
  # ── the log file ─────────────────────────────────────────────────────
219
463
 
220
464
 
@@ -329,6 +573,30 @@ def test_chunk_size_controls_the_stored_chunk_shape(tmp_path):
329
573
  assert large == [1024, 1024]
330
574
 
331
575
 
576
+ def test_auto_opens_a_small_file_whole_and_a_large_one_in_blocks(tmp_path):
577
+ """`auto` picks a strategy per file. dask costs more than it saves on a
578
+ small one, and the stored layout comes out the same either way."""
579
+ from atlas._ops import _open_kwargs_for
580
+
581
+ budget = 128 * 1024 * 1024
582
+ base = {"chunks": "auto"}
583
+
584
+ small = tmp_path / "small.nc"
585
+ small.write_bytes(b"x" * 1024)
586
+ assert "chunks" not in _open_kwargs_for(small, "auto", base, budget)
587
+
588
+ large = tmp_path / "large.nc"
589
+ large.write_bytes(b"x" * (64 * 1024 * 1024))
590
+ assert _open_kwargs_for(large, "auto", base, budget) == base
591
+
592
+ # Every other mode is an explicit choice, and passes through untouched.
593
+ assert _open_kwargs_for(small, "native", {"chunks": {}}, budget) == {"chunks": {}}
594
+ assert _open_kwargs_for(small, None, {}, budget) == {}
595
+
596
+ # A smaller budget pulls the threshold down with it.
597
+ assert _open_kwargs_for(small, "auto", base, 1024) == base
598
+
599
+
332
600
  def test_small_files_still_land_as_a_single_chunk(netcdf_dir, tmp_path):
333
601
  """Auto chunking must not split an array that fits with room to spare."""
334
602
  atlas.create(netcdf_dir, str(tmp_path / "c"))
@@ -97,6 +97,12 @@ Concurrent datasets therefore land in finish order, and never interleave their
97
97
  bytes. `tests/integration.rs` asserts that the segments still tile the
98
98
  container without a gap under concurrent staging.
99
99
 
100
+ **Ordinals do not follow that order.** Each dataset carries the number of the
101
+ `add_dataset` call that opened it. `AtlasWriter::finish` then sorts the footer
102
+ entries on that number. Stage a directory twice and every dataset lands at the same
103
+ ordinal, however many threads did the work. Each entry holds its own byte
104
+ range, so the segments need no matching order on disk.
105
+
100
106
  ## Failure
101
107
 
102
108
  | What happens | Result |
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "atlas-python"
7
- version = "0.16.2"
7
+ version = "0.16.4"
8
8
  description = "Python bindings for the ATLAS array store"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -62,6 +62,9 @@ def create(
62
62
  chunks: Optional[dict[str, Sequence[int]]] = None,
63
63
  open_chunks: Union[str, dict[str, int], None] = "auto",
64
64
  chunk_size: str = "128MiB",
65
+ decode_times: bool = True,
66
+ convert_calendar: bool = False,
67
+ workers: int = 1,
65
68
  on_error: str = "stop",
66
69
  on_unsupported: str = "stop",
67
70
  progress: Optional[Callable[[str], None]] = None,
@@ -114,6 +117,22 @@ def create(
114
117
  chunk_size: The block size ``"auto"`` aims at, as a dask size string.
115
118
  It is about the memory ceiling per variable during ingest. It
116
119
  defaults to ``"128MiB"``.
120
+ decode_times: How xarray reads a time axis. Under the default, a
121
+ calendar it cannot map to ``datetime64[ns]``, such as a Julian
122
+ one, decodes to cftime objects, which atlas cannot store. Set it
123
+ false to keep the raw numbers, with their ``units`` and
124
+ ``calendar`` attributes.
125
+ convert_calendar: Turn a cftime axis into exact Gregorian timestamps.
126
+ Each one keeps its instant, so a Julian ``1973-02-25`` becomes the
127
+ Gregorian ``1973-03-10`` that names the same moment. A calendar
128
+ with no real instant, such as ``360_day``, and a date outside the
129
+ nanosecond range both raise instead.
130
+ workers: Commit this many datasets at once. The commit is the costly
131
+ half of an ingest. It is pure Rust and releases the GIL, so it
132
+ scales to about four times on a many-core machine. Every netCDF
133
+ read stays on the calling thread, because HDF5 is not thread safe.
134
+ Ordinals do not move, because ``add_dataset`` runs in file order
135
+ whatever the workers do.
117
136
  on_error: ``"stop"`` is the default. It abandons the whole collection
118
137
  on the first bad file. ``"skip"`` records that file and continues.
119
138
  on_unsupported: What one array of an unsupported dtype costs.