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.
- {atlas_python-0.16.3 → atlas_python-0.16.4}/Cargo.lock +2 -2
- {atlas_python-0.16.3 → atlas_python-0.16.4}/Cargo.toml +1 -1
- {atlas_python-0.16.3 → atlas_python-0.16.4}/PKG-INFO +1 -1
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/Cargo.toml +1 -1
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/cli.md +26 -4
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/creating.md +61 -6
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_cli.py +31 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_ops.py +123 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/write-path.md +6 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/pyproject.toml +1 -1
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__init__.pyi +7 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_cli.py +34 -4
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_ops.py +182 -54
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/xarray.py +49 -2
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/writer/mod.rs +42 -14
- {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/integration.rs +42 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-python-docs.yaml +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-python-release.yaml +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/atlas-rust-release.yaml +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/.github/workflows/ci.yaml +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/.gitignore +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/CONTRIBUTING.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/LICENSE +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/README.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/.python-version +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/LICENSE +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/README.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/examples.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/cloud-storage.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/dtypes.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/inspecting.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/reading-data.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/guides/removing.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/index.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/installation.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/quickstart.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/reference/api.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/examples/01_library.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/examples/02_object_store.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/mkdocs.yml +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/attr.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/dtype.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/error.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/lib.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/logging.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/reader.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/runtime.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/source.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/src/writer.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/conftest.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/make_fixture.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/atlas-python/tests/test_source.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/README.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/architecture.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/data-model.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/format.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/python.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/docs/read-path.md +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/lifecycle.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/sensor_fleet.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/examples/weather_store.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__init__.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/__main__.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_log.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/_source.py +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/python/atlas/py.typed +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/config.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/error.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/footer.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/mask.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/mod.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/format/segment_store.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/lib.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/reader/mod.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/array.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/attr.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/dtype.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/src/schema/mod.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/cross_fixture.rs +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/fixtures/from_python/data.atlas +0 -0
- {atlas_python-0.16.3 → atlas_python-0.16.4}/tests/fixtures/golden_v1/data.atlas +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
144
|
-
|
|
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.
|
|
237
|
-
|
|
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 |
|
|
@@ -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
|
-
|
|
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(
|
|
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=
|
|
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)
|