atlas-python 0.16.3__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.3 → atlas_python-0.16.4}/Cargo.lock +2 -2
  2. {atlas_python-0.16.3 → atlas_python-0.16.4}/Cargo.toml +1 -1
  3. {atlas_python-0.16.3 → atlas_python-0.16.4}/PKG-INFO +1 -1
  4. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/Cargo.toml +1 -1
  5. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/cli.md +26 -4
  6. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/creating.md +61 -6
  7. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_cli.py +31 -0
  8. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_ops.py +123 -0
  9. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/write-path.md +6 -0
  10. {atlas_python-0.16.3 → atlas_python-0.16.4}/pyproject.toml +1 -1
  11. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__init__.pyi +7 -0
  12. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_cli.py +34 -4
  13. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_ops.py +182 -54
  14. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/xarray.py +49 -2
  15. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/writer/mod.rs +42 -14
  16. {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/integration.rs +42 -0
  17. {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-python-docs.yaml +0 -0
  18. {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-python-release.yaml +0 -0
  19. {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-rust-release.yaml +0 -0
  20. {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/ci.yaml +0 -0
  21. {atlas_python-0.16.3 → atlas_python-0.16.4}/.gitignore +0 -0
  22. {atlas_python-0.16.3 → atlas_python-0.16.4}/CONTRIBUTING.md +0 -0
  23. {atlas_python-0.16.3 → atlas_python-0.16.4}/LICENSE +0 -0
  24. {atlas_python-0.16.3 → atlas_python-0.16.4}/README.md +0 -0
  25. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/.python-version +0 -0
  26. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/LICENSE +0 -0
  27. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/README.md +0 -0
  28. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/examples.md +0 -0
  29. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/cloud-storage.md +0 -0
  30. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/dtypes.md +0 -0
  31. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/inspecting.md +0 -0
  32. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/reading-data.md +0 -0
  33. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/removing.md +0 -0
  34. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/index.md +0 -0
  35. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/installation.md +0 -0
  36. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/quickstart.md +0 -0
  37. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/reference/api.md +0 -0
  38. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  39. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/examples/01_library.py +0 -0
  40. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/examples/02_object_store.py +0 -0
  41. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/mkdocs.yml +0 -0
  42. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/attr.rs +0 -0
  43. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/dtype.rs +0 -0
  44. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/error.rs +0 -0
  45. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/lib.rs +0 -0
  46. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/logging.rs +0 -0
  47. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/reader.rs +0 -0
  48. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/runtime.rs +0 -0
  49. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/source.rs +0 -0
  50. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/writer.rs +0 -0
  51. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  52. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/conftest.py +0 -0
  53. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/make_fixture.py +0 -0
  54. {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_source.py +0 -0
  55. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/README.md +0 -0
  56. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/architecture.md +0 -0
  57. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/data-model.md +0 -0
  58. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/format.md +0 -0
  59. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/python.md +0 -0
  60. {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/read-path.md +0 -0
  61. {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/lifecycle.rs +0 -0
  62. {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/sensor_fleet.rs +0 -0
  63. {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/weather_store.rs +0 -0
  64. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__init__.py +0 -0
  65. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__main__.py +0 -0
  66. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_log.py +0 -0
  67. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_source.py +0 -0
  68. {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/py.typed +0 -0
  69. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/config.rs +0 -0
  70. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/error.rs +0 -0
  71. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/footer.rs +0 -0
  72. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/mask.rs +0 -0
  73. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/mod.rs +0 -0
  74. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/segment_store.rs +0 -0
  75. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/lib.rs +0 -0
  76. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/reader/mod.rs +0 -0
  77. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/array.rs +0 -0
  78. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/attr.rs +0 -0
  79. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/dtype.rs +0 -0
  80. {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/mod.rs +0 -0
  81. {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/cross_fixture.rs +0 -0
  82. {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/fixtures/from_python/data.atlas +0 -0
  83. {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/fixtures/golden_v1/data.atlas +0 -0
  84. {atlas_python-0.16.3 → 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.3"
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.3"
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.3"
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.3
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.3"
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.3: 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,11 +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 |
69
79
  | `--no-decode-times` | Keep a time axis as raw numbers, for a calendar that decodes to cftime |
70
80
  | `--skip-unsupported` | Leave out an array of an unsupported dtype, and keep the rest of the dataset |
71
81
  | `-q`, `--quiet` | Do not list a file as it lands |
72
82
 
73
- 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.
74
96
 
75
97
  ### Unsupported dtypes
76
98
 
@@ -110,7 +132,7 @@ size. `--open-chunks` picks another strategy:
110
132
 
111
133
  | Mode | Reads | Stored chunk shape |
112
134
  |---|---|---|
113
- | `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 |
114
136
  | `native` | the file's own chunk encoding | that encoding |
115
137
  | `none` | each variable whole | one full-shape chunk |
116
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
 
@@ -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
 
@@ -474,6 +573,30 @@ def test_chunk_size_controls_the_stored_chunk_shape(tmp_path):
474
573
  assert large == [1024, 1024]
475
574
 
476
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
+
477
600
  def test_small_files_still_land_as_a_single_chunk(netcdf_dir, tmp_path):
478
601
  """Auto chunking must not split an array that fits with room to spare."""
479
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.3"
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"
@@ -64,6 +64,7 @@ def create(
64
64
  chunk_size: str = "128MiB",
65
65
  decode_times: bool = True,
66
66
  convert_calendar: bool = False,
67
+ workers: int = 1,
67
68
  on_error: str = "stop",
68
69
  on_unsupported: str = "stop",
69
70
  progress: Optional[Callable[[str], None]] = None,
@@ -126,6 +127,12 @@ def create(
126
127
  Gregorian ``1973-03-10`` that names the same moment. A calendar
127
128
  with no real instant, such as ``360_day``, and a date outside the
128
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.
129
136
  on_error: ``"stop"`` is the default. It abandons the whole collection
130
137
  on the first bad file. ``"skip"`` records that file and continues.
131
138
  on_unsupported: What one array of an unsupported dtype costs.
@@ -145,24 +145,41 @@ def _parse_open_chunks(value: str) -> Any:
145
145
  def cmd_create(args: argparse.Namespace) -> int:
146
146
  chunks = json.loads(args.chunks) if args.chunks else None
147
147
  open_chunks = _parse_open_chunks(args.open_chunks)
148
+ recursive = not args.no_recursive
149
+
150
+ # A second walk of the tree, so each line can say how many remain. The
151
+ # walk only stats the files, so it costs little beside the ingest.
152
+ total = len(_ops.find_netcdf_files(args.directory, recursive=recursive))
153
+ width = len(str(total))
154
+ done = 0
148
155
 
149
156
  def progress(name: str) -> None:
157
+ nonlocal done
158
+ done += 1
150
159
  if not args.quiet:
151
- print(f" {name}", file=sys.stderr)
160
+ left = total - done
161
+ print(
162
+ f" [{done:>{width}}/{total}] {name} ({left} left)",
163
+ file=sys.stderr,
164
+ )
152
165
 
153
166
  if not args.quiet:
154
- print(f"Writing {args.destination}", file=sys.stderr)
167
+ print(
168
+ f"Writing {args.destination} from {total} file(s)",
169
+ file=sys.stderr,
170
+ )
155
171
 
156
172
  result = _ops.create(
157
173
  args.directory,
158
174
  args.destination,
159
- recursive=not args.no_recursive,
175
+ recursive=recursive,
160
176
  codec=args.codec,
161
177
  chunks=chunks,
162
178
  open_chunks=open_chunks,
163
179
  chunk_size=args.chunk_size,
164
180
  decode_times=not args.no_decode_times,
165
181
  convert_calendar=args.convert_calendar,
182
+ workers=args.workers,
166
183
  on_error="skip" if args.skip_errors else "stop",
167
184
  on_unsupported="skip" if args.skip_unsupported else "stop",
168
185
  progress=progress,
@@ -362,6 +379,17 @@ def build_parser() -> argparse.ArgumentParser:
362
379
  action="store_true",
363
380
  help="skip files that fail instead of abandoning the collection",
364
381
  )
382
+ p.add_argument(
383
+ "-j",
384
+ "--workers",
385
+ type=int,
386
+ default=1,
387
+ metavar="N",
388
+ help=(
389
+ "commit N datasets at once. It scales to about four times on a "
390
+ "many-core machine. Ordinals do not move"
391
+ ),
392
+ )
365
393
  p.add_argument(
366
394
  "--convert-calendar",
367
395
  action="store_true",
@@ -457,10 +485,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
457
485
  args = build_parser().parse_args(argv)
458
486
  if args.log_file:
459
487
  try:
460
- _log.log_to_file(args.log_file)
488
+ handler = _log.log_to_file(args.log_file)
461
489
  except OSError as exc:
462
490
  print(f"atlas: cannot open log file: {exc}", file=sys.stderr)
463
491
  return 1
492
+ # Say where it went, so nobody has to guess.
493
+ print(f"atlas: logging to {handler.baseFilename}", file=sys.stderr)
464
494
  _LOG.info("atlas %s: %s", __version__, " ".join(argv or sys.argv[1:]))
465
495
  try:
466
496
  return args.func(args)