atlas-python 0.16.1__tar.gz → 0.16.3__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.1 → atlas_python-0.16.3}/Cargo.lock +2 -2
- {atlas_python-0.16.1 → atlas_python-0.16.3}/Cargo.toml +1 -1
- {atlas_python-0.16.1 → atlas_python-0.16.3}/PKG-INFO +7 -3
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/Cargo.toml +1 -1
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/README.md +6 -2
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/cli.md +14 -6
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/creating.md +23 -6
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/dtypes.md +65 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/installation.md +41 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/writer.rs +1 -1
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_cli.py +56 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_ops.py +180 -17
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/python.md +9 -4
- {atlas_python-0.16.1 → atlas_python-0.16.3}/pyproject.toml +1 -1
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/__init__.pyi +24 -5
- atlas_python-0.16.3/python/atlas/__main__.py +14 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_cli.py +31 -2
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_ops.py +38 -7
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/xarray.py +160 -1
- {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/fixtures/from_python/data.atlas +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-python-docs.yaml +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-python-release.yaml +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-rust-release.yaml +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/ci.yaml +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/.gitignore +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/CONTRIBUTING.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/LICENSE +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/README.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/.python-version +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/LICENSE +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/examples.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/cloud-storage.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/inspecting.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/reading-data.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/removing.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/index.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/quickstart.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/reference/api.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/examples/01_library.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/examples/02_object_store.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/mkdocs.yml +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/attr.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/dtype.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/error.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/lib.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/logging.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/reader.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/runtime.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/source.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/conftest.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/make_fixture.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_source.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/README.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/architecture.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/data-model.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/format.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/read-path.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/write-path.md +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/lifecycle.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/sensor_fleet.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/weather_store.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/__init__.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_log.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_source.py +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/py.typed +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/config.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/error.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/footer.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/mask.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/mod.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/segment_store.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/lib.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/reader/mod.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/array.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/attr.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/dtype.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/mod.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/src/writer/mod.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/cross_fixture.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/fixtures/golden_v1/data.atlas +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/golden.rs +0 -0
- {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/integration.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.3"
|
|
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.3"
|
|
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.3"
|
|
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
|
+
Version: 0.16.3
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Science/Research
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -44,6 +44,9 @@ atlas info /data/collection
|
|
|
44
44
|
atlas rm /data/collection 2024-02.nc 2024-03.nc
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
`python -m atlas` runs the same command without a PATH lookup, for a shell that
|
|
48
|
+
cannot find `atlas`.
|
|
49
|
+
|
|
47
50
|
| Extra | Install | Adds |
|
|
48
51
|
|---|---|---|
|
|
49
52
|
| cloud | `pip install "atlas-python[cloud]"` | S3 / GCS / Azure / HTTP via [obstore](https://github.com/developmentseed/obstore) |
|
|
@@ -120,8 +123,9 @@ command gives the same content as a structure.
|
|
|
120
123
|
|
|
121
124
|
## Ingest
|
|
122
125
|
|
|
123
|
-
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf
|
|
124
|
-
them, and writes one dataset per
|
|
126
|
+
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf`, and
|
|
127
|
+
descends into every subdirectory. It sorts them, and writes one dataset per
|
|
128
|
+
file, named after the file. Each coordinate
|
|
125
129
|
and data variable becomes an array. Each variable attribute becomes a per-array
|
|
126
130
|
attribute. `_FillValue` becomes the fill of the array.
|
|
127
131
|
|
|
@@ -16,6 +16,9 @@ atlas info /data/collection
|
|
|
16
16
|
atlas rm /data/collection 2024-02.nc 2024-03.nc
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
`python -m atlas` runs the same command without a PATH lookup, for a shell that
|
|
20
|
+
cannot find `atlas`.
|
|
21
|
+
|
|
19
22
|
| Extra | Install | Adds |
|
|
20
23
|
|---|---|---|
|
|
21
24
|
| cloud | `pip install "atlas-python[cloud]"` | S3 / GCS / Azure / HTTP via [obstore](https://github.com/developmentseed/obstore) |
|
|
@@ -92,8 +95,9 @@ command gives the same content as a structure.
|
|
|
92
95
|
|
|
93
96
|
## Ingest
|
|
94
97
|
|
|
95
|
-
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf
|
|
96
|
-
them, and writes one dataset per
|
|
98
|
+
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf`, and
|
|
99
|
+
descends into every subdirectory. It sorts them, and writes one dataset per
|
|
100
|
+
file, named after the file. Each coordinate
|
|
97
101
|
and data variable becomes an array. Each variable attribute becomes a per-array
|
|
98
102
|
attribute. `_FillValue` becomes the fill of the array.
|
|
99
103
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
`pip install atlas-python` puts `atlas` on your PATH. Five subcommands, one per
|
|
4
4
|
operation.
|
|
5
5
|
|
|
6
|
+
`python -m atlas` runs the same command without a PATH lookup. Use it in a
|
|
7
|
+
container, in a CI job, or when the shell cannot find `atlas`. See
|
|
8
|
+
[Installation](installation.md#the-atlas-command-is-not-found).
|
|
9
|
+
|
|
6
10
|
```text
|
|
7
11
|
atlas create <netcdf-dir> <collection> build a collection
|
|
8
12
|
atlas rm <collection> <name>... remove datasets
|
|
@@ -26,7 +30,7 @@ Every subcommand takes `--json` and `--log-file PATH`, plus the remote flags
|
|
|
26
30
|
```bash
|
|
27
31
|
$ atlas create /data/nc /data/collection --skip-unsupported --log-file ingest.log
|
|
28
32
|
$ cat ingest.log
|
|
29
|
-
2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.
|
|
33
|
+
2026-09-01 14:30:41 INFO atlas.cli: atlas 0.16.3: create /data/nc ...
|
|
30
34
|
2026-09-01 14:30:41 INFO atlas.ops: ingesting 2 file(s) into /data/collection
|
|
31
35
|
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: ...)
|
|
32
36
|
2026-09-01 14:30:41 INFO atlas.ops: wrote 1 dataset(s); skipped 0 file(s) and 1 array(s)
|
|
@@ -44,21 +48,25 @@ stderr.
|
|
|
44
48
|
atlas create /data/nc /data/collection
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
becomes `2024-01.nc`, suffix and
|
|
49
|
-
|
|
51
|
+
The scan descends into every subdirectory. Each NetCDF file becomes one
|
|
52
|
+
dataset, named after the file. `2024-01.nc` becomes `2024-01.nc`, suffix and
|
|
53
|
+
all. A name carries no directory, so two files of one name in two
|
|
54
|
+
subdirectories collide. The files land in sorted order, which makes the
|
|
55
|
+
ordinals of a collection reproducible.
|
|
50
56
|
|
|
51
57
|
Nothing at the destination is readable until every file lands, with the footer.
|
|
52
58
|
A failure part-way leaves no collection, and not a partial one.
|
|
53
59
|
|
|
54
60
|
| Flag | Effect |
|
|
55
61
|
|---|---|
|
|
56
|
-
|
|
|
62
|
+
| `--no-recursive` | Scan the top directory alone. The scan descends by default |
|
|
63
|
+
| `-r`, `--recursive` | Accepted for compatibility. The scan already descends |
|
|
57
64
|
| `--codec {zstd,lz4,none}` | Block compression. Default `zstd` |
|
|
58
65
|
| `--chunk-size SIZE` | Block size to aim for. Default `128MiB` |
|
|
59
66
|
| `--open-chunks MODE` | How files are read: `auto`, `native`, `none`, or a JSON dict |
|
|
60
67
|
| `--chunks JSON` | Override the stored chunk shape, `'{"temperature": [64, 64]}'` |
|
|
61
68
|
| `--skip-errors` | Skip files that fail instead of abandoning the collection |
|
|
69
|
+
| `--no-decode-times` | Keep a time axis as raw numbers, for a calendar that decodes to cftime |
|
|
62
70
|
| `--skip-unsupported` | Leave out an array of an unsupported dtype, and keep the rest of the dataset |
|
|
63
71
|
| `-q`, `--quiet` | Do not list a file as it lands |
|
|
64
72
|
|
|
@@ -121,7 +129,7 @@ reads. Each misaligned block then costs a read-modify-write. Use
|
|
|
121
129
|
|
|
122
130
|
```bash
|
|
123
131
|
# One collection from a tree of monthly directories, tolerating bad files
|
|
124
|
-
atlas create /data/nc /data/collection --
|
|
132
|
+
atlas create /data/nc /data/collection --skip-errors
|
|
125
133
|
|
|
126
134
|
# A big grid, chunked for selective reads, straight to a bucket
|
|
127
135
|
atlas create /data/nc s3://bucket/2024 --chunk-size 64MiB --region eu-west-1
|
|
@@ -15,9 +15,20 @@ atlas create /data/nc /data/collection
|
|
|
15
15
|
## What happens
|
|
16
16
|
|
|
17
17
|
`create` collects every file that matches `.nc`, `.nc4`, `.cdf`, or `.netcdf`.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
The scan **descends into every subdirectory**. It **sorts** the result, and
|
|
19
|
+
writes one dataset per file, named after the file. `2024-01.nc` becomes the
|
|
20
|
+
dataset `2024-01.nc`. The suffix is part of the name, so `jan.nc` and
|
|
21
|
+
`jan.nc4` are two datasets.
|
|
22
|
+
|
|
23
|
+
Pass `recursive=False`, or `--no-recursive`, to scan the top directory alone:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
atlas.create("/data/nc", dest, recursive=False)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
atlas create /data/nc /data/collection --no-recursive
|
|
31
|
+
```
|
|
21
32
|
|
|
22
33
|
The sort matters. An ordinal comes from the write order, so a sorted ingest
|
|
23
34
|
makes it reproducible. Rebuild the same directory, and every dataset lands at
|
|
@@ -26,8 +37,8 @@ the same position.
|
|
|
26
37
|
Check what the call picks up before you run it:
|
|
27
38
|
|
|
28
39
|
```python
|
|
29
|
-
atlas.find_netcdf_files("/data/nc")
|
|
30
|
-
atlas.find_netcdf_files("/data/nc", recursive=
|
|
40
|
+
atlas.find_netcdf_files("/data/nc") # sorted, and recursive
|
|
41
|
+
atlas.find_netcdf_files("/data/nc", recursive=False) # the top level alone
|
|
31
42
|
```
|
|
32
43
|
|
|
33
44
|
## All or nothing
|
|
@@ -236,7 +247,13 @@ turns it off.
|
|
|
236
247
|
| Destination URL cannot be resolved | `SourceError` |
|
|
237
248
|
|
|
238
249
|
Two files with one name surprise people. `a/jan.nc` and `b/jan.nc` both want
|
|
239
|
-
the name `jan.nc`.
|
|
250
|
+
the name `jan.nc`. The scan descends by default, so a tree of monthly
|
|
251
|
+
directories hits this often. A dataset name carries no directory, because a
|
|
252
|
+
name may hold no `/`.
|
|
253
|
+
|
|
254
|
+
Three ways out. Rename the files. Ingest each subdirectory into its own
|
|
255
|
+
collection. Or pass `on_error="skip"`, which keeps the first file and reports
|
|
256
|
+
the second.
|
|
240
257
|
|
|
241
258
|
For the dtype rules, see [Supported dtypes](dtypes.md).
|
|
242
259
|
|
|
@@ -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
|
|
@@ -47,6 +47,47 @@ To run the test suite:
|
|
|
47
47
|
pytest atlas-python/tests/ -v
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## The `atlas` command is not found
|
|
51
|
+
|
|
52
|
+
`pip install` puts the `atlas` script in the script directory of the
|
|
53
|
+
environment it installed into. The shell finds it only when that directory is
|
|
54
|
+
on `PATH`.
|
|
55
|
+
|
|
56
|
+
Run the command through Python instead. This form needs no `PATH` entry, so it
|
|
57
|
+
works everywhere the package imports:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m atlas --version
|
|
61
|
+
python -m atlas ls /data/collection
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To fix the `PATH` itself, first find where the script landed:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip show -f atlas-python | grep -i bin
|
|
68
|
+
python -c "import sysconfig; print(sysconfig.get_path('scripts'))"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Three causes cover almost every case:
|
|
72
|
+
|
|
73
|
+
- **`pip install --user`.** The script goes to `~/.local/bin`. Many
|
|
74
|
+
distributions leave that off `PATH`. Add it:
|
|
75
|
+
`export PATH="$HOME/.local/bin:$PATH"`.
|
|
76
|
+
- **A virtual environment nobody activated.** Run `source .venv/bin/activate`,
|
|
77
|
+
or call `.venv/bin/atlas` directly.
|
|
78
|
+
- **Two Pythons.** `pip` and `python` resolve to different environments. Use
|
|
79
|
+
`python -m pip install atlas-python` to install into the interpreter you
|
|
80
|
+
actually run.
|
|
81
|
+
|
|
82
|
+
Confirm the package itself is sound:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python -c "import atlas; print(atlas.__version__, atlas.__file__)"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A version and a path mean the install worked, and only the script lookup
|
|
89
|
+
failed.
|
|
90
|
+
|
|
50
91
|
## Optional: cloud storage (S3, GCS, Azure)
|
|
51
92
|
|
|
52
93
|
Install the `cloud` extra to open or create an atlas store on S3, GCS, Azure
|
|
@@ -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
|
|
325
|
+
"array {name:?}: a string element must be str or bytes, got {:?}",
|
|
326
326
|
bound.get_type().name()?
|
|
327
327
|
)));
|
|
328
328
|
};
|
|
@@ -165,6 +165,27 @@ def test_log_file_records_a_failure(capsys, tmp_path):
|
|
|
165
165
|
assert "ERROR" in log.read_text()
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def test_create_descends_by_default_and_no_recursive_opts_out(capsys, tmp_path):
|
|
169
|
+
from conftest import make_dataset
|
|
170
|
+
|
|
171
|
+
src = tmp_path / "nc"
|
|
172
|
+
(src / "sub").mkdir(parents=True)
|
|
173
|
+
make_dataset(1).to_netcdf(src / "sub" / "deep.nc")
|
|
174
|
+
make_dataset(2).to_netcdf(src / "top.nc")
|
|
175
|
+
|
|
176
|
+
run(capsys, "create", str(src), str(tmp_path / "all"), "-q")
|
|
177
|
+
code, out, _ = run(capsys, "ls", str(tmp_path / "all"))
|
|
178
|
+
assert out.split() == ["deep.nc", "top.nc"]
|
|
179
|
+
|
|
180
|
+
run(capsys, "create", str(src), str(tmp_path / "flat"), "-q", "--no-recursive")
|
|
181
|
+
code, out, _ = run(capsys, "ls", str(tmp_path / "flat"))
|
|
182
|
+
assert out.split() == ["top.nc"]
|
|
183
|
+
|
|
184
|
+
# -r still parses, so an existing script keeps working.
|
|
185
|
+
code, _, err = run(capsys, "create", str(src), str(tmp_path / "r"), "-q", "-r")
|
|
186
|
+
assert code == 0, err
|
|
187
|
+
|
|
188
|
+
|
|
168
189
|
# ── ls ───────────────────────────────────────────────────────────────
|
|
169
190
|
|
|
170
191
|
|
|
@@ -307,6 +328,41 @@ def test_info_json(capsys, collection):
|
|
|
307
328
|
assert i["format_version"] == 1
|
|
308
329
|
|
|
309
330
|
|
|
331
|
+
# ── entry points ─────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_python_dash_m_atlas_runs_the_cli(netcdf_dir, tmp_path):
|
|
335
|
+
"""`python -m atlas` needs no directory on PATH, so it always works."""
|
|
336
|
+
import subprocess
|
|
337
|
+
import sys
|
|
338
|
+
|
|
339
|
+
dest = tmp_path / "c"
|
|
340
|
+
done = subprocess.run(
|
|
341
|
+
[sys.executable, "-m", "atlas", "create", str(netcdf_dir), str(dest), "-q"],
|
|
342
|
+
capture_output=True,
|
|
343
|
+
text=True,
|
|
344
|
+
)
|
|
345
|
+
assert done.returncode == 0, done.stderr
|
|
346
|
+
|
|
347
|
+
done = subprocess.run(
|
|
348
|
+
[sys.executable, "-m", "atlas", "ls", str(dest)],
|
|
349
|
+
capture_output=True,
|
|
350
|
+
text=True,
|
|
351
|
+
)
|
|
352
|
+
assert done.returncode == 0, done.stderr
|
|
353
|
+
assert done.stdout.split() == ["2024-01.nc", "2024-02.nc", "2024-03.nc"]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_the_console_script_is_declared():
|
|
357
|
+
"""The wheel must carry the `atlas` console script."""
|
|
358
|
+
from importlib.metadata import entry_points
|
|
359
|
+
|
|
360
|
+
scripts = {
|
|
361
|
+
e.name: e.value for e in entry_points(group="console_scripts")
|
|
362
|
+
}
|
|
363
|
+
assert scripts.get("atlas") == "atlas._cli:main"
|
|
364
|
+
|
|
365
|
+
|
|
310
366
|
# ── parsing ──────────────────────────────────────────────────────────
|
|
311
367
|
|
|
312
368
|
|
|
@@ -48,33 +48,34 @@ def test_create_is_ordered_by_filename(netcdf_dir, tmp_path):
|
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def test_create_descends_into_subdirectories_by_default(tmp_path):
|
|
52
52
|
nested = tmp_path / "nc" / "2024" / "q1"
|
|
53
53
|
nested.mkdir(parents=True)
|
|
54
54
|
make_dataset(1).to_netcdf(nested / "jan.nc")
|
|
55
|
+
make_dataset(2).to_netcdf(tmp_path / "nc" / "top.nc")
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
assert
|
|
57
|
+
found = atlas.find_netcdf_files(tmp_path / "nc")
|
|
58
|
+
assert [p.name for p in found] == ["jan.nc", "top.nc"]
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
assert
|
|
60
|
+
atlas.create(tmp_path / "nc", str(tmp_path / "c"))
|
|
61
|
+
assert atlas.list_datasets(str(tmp_path / "c")) == ["jan.nc", "top.nc"]
|
|
61
62
|
|
|
62
|
-
atlas.create(tmp_path / "nc", str(tmp_path / "c"), recursive=True)
|
|
63
|
-
assert atlas.list_datasets(str(tmp_path / "c")) == ["jan.nc"]
|
|
64
63
|
|
|
64
|
+
def test_recursive_false_scans_the_top_directory_alone(tmp_path):
|
|
65
|
+
nested = tmp_path / "nc" / "2024" / "q1"
|
|
66
|
+
nested.mkdir(parents=True)
|
|
67
|
+
make_dataset(1).to_netcdf(nested / "jan.nc")
|
|
68
|
+
make_dataset(2).to_netcdf(tmp_path / "nc" / "top.nc")
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
with pytest.raises(atlas.AtlasError, match="no NetCDF files"):
|
|
69
|
-
atlas.create(tmp_path / "empty", str(tmp_path / "c"))
|
|
70
|
-
|
|
70
|
+
found = atlas.find_netcdf_files(tmp_path / "nc", recursive=False)
|
|
71
|
+
assert [p.name for p in found] == ["top.nc"]
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
atlas.create(tmp_path / "nope", str(tmp_path / "c"))
|
|
73
|
+
atlas.create(tmp_path / "nc", str(tmp_path / "c"), recursive=False)
|
|
74
|
+
assert atlas.list_datasets(str(tmp_path / "c")) == ["top.nc"]
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def test_a_name_repeated_across_subdirectories_collides(tmp_path):
|
|
78
|
+
"""A dataset name carries no directory, so two `jan.nc` files clash."""
|
|
78
79
|
src = tmp_path / "nc"
|
|
79
80
|
(src / "a").mkdir(parents=True)
|
|
80
81
|
(src / "b").mkdir()
|
|
@@ -82,7 +83,24 @@ def test_two_files_with_the_same_name_collide(tmp_path):
|
|
|
82
83
|
make_dataset(2).to_netcdf(src / "b" / "jan.nc")
|
|
83
84
|
|
|
84
85
|
with pytest.raises(atlas.AtlasError, match="duplicate dataset name"):
|
|
85
|
-
atlas.create(src, str(tmp_path / "c")
|
|
86
|
+
atlas.create(src, str(tmp_path / "c"))
|
|
87
|
+
|
|
88
|
+
# on_error="skip" keeps the first and reports the second.
|
|
89
|
+
result = atlas.create(src, str(tmp_path / "c2"), on_error="skip")
|
|
90
|
+
assert result["written"] == ["jan.nc"]
|
|
91
|
+
assert len(result["skipped"]) == 1
|
|
92
|
+
assert "duplicate dataset name" in result["skipped"][0]["error"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_an_empty_directory_is_an_error(tmp_path):
|
|
96
|
+
(tmp_path / "empty").mkdir()
|
|
97
|
+
with pytest.raises(atlas.AtlasError, match="no NetCDF files"):
|
|
98
|
+
atlas.create(tmp_path / "empty", str(tmp_path / "c"))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_a_missing_directory_is_an_error(tmp_path):
|
|
102
|
+
with pytest.raises(atlas.AtlasError, match="not a directory"):
|
|
103
|
+
atlas.create(tmp_path / "nope", str(tmp_path / "c"))
|
|
86
104
|
|
|
87
105
|
|
|
88
106
|
def test_a_bad_file_abandons_the_collection_by_default(tmp_path):
|
|
@@ -197,6 +215,151 @@ def test_a_clean_ingest_reports_no_skipped_arrays(netcdf_dir, tmp_path):
|
|
|
197
215
|
assert result["skipped_arrays"] == []
|
|
198
216
|
|
|
199
217
|
|
|
218
|
+
# ── cftime calendars ─────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@pytest.fixture
|
|
222
|
+
def netcdf_dir_with_a_julian_calendar(tmp_path):
|
|
223
|
+
"""A time axis xarray decodes to cftime, not to datetime64."""
|
|
224
|
+
import cftime
|
|
225
|
+
|
|
226
|
+
d = tmp_path / "nc"
|
|
227
|
+
d.mkdir()
|
|
228
|
+
times = [cftime.DatetimeJulian(2024, 1, i + 1) for i in range(4)]
|
|
229
|
+
xr.Dataset(
|
|
230
|
+
{"temp": ("time", np.arange(4, dtype=np.float32))},
|
|
231
|
+
coords={"time": ("time", times)},
|
|
232
|
+
).to_netcdf(d / "julian.nc")
|
|
233
|
+
return d
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_a_cftime_axis_reports_the_variable_and_the_remedy(
|
|
237
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
238
|
+
):
|
|
239
|
+
with pytest.raises(atlas.AtlasError) as exc:
|
|
240
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(tmp_path / "c"))
|
|
241
|
+
|
|
242
|
+
message = str(exc.value)
|
|
243
|
+
assert "'time'" in message, "the message must name the variable"
|
|
244
|
+
assert "DatetimeJulian" in message
|
|
245
|
+
assert "decode_times=False" in message
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_decode_times_false_stores_a_cftime_axis_as_raw_numbers(
|
|
249
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
250
|
+
):
|
|
251
|
+
dest = tmp_path / "c"
|
|
252
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), decode_times=False)
|
|
253
|
+
|
|
254
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
|
|
255
|
+
assert arrays["time"]["dtype"] == "int64"
|
|
256
|
+
# The units and the calendar survive, so a reader can decode the numbers.
|
|
257
|
+
assert arrays["time"]["attributes"]["calendar"] == "julian"
|
|
258
|
+
assert "days since" in arrays["time"]["attributes"]["units"]
|
|
259
|
+
assert arrays["temp"]["dtype"] == "float32"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_a_cftime_axis_can_be_skipped_per_array(
|
|
263
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
264
|
+
):
|
|
265
|
+
dest = tmp_path / "c"
|
|
266
|
+
result = atlas.create(
|
|
267
|
+
netcdf_dir_with_a_julian_calendar, str(dest), on_unsupported="skip"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
assert [s["array"] for s in result["skipped_arrays"]] == ["time"]
|
|
271
|
+
assert [a["name"] for a in atlas.describe(str(dest), "julian.nc")["arrays"]] == [
|
|
272
|
+
"temp"
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_convert_calendar_keeps_the_exact_instant(
|
|
277
|
+
netcdf_dir_with_a_julian_calendar, tmp_path
|
|
278
|
+
):
|
|
279
|
+
"""A Julian label and its Gregorian label name one moment, 13 days apart."""
|
|
280
|
+
dest = tmp_path / "c"
|
|
281
|
+
atlas.create(netcdf_dir_with_a_julian_calendar, str(dest), convert_calendar=True)
|
|
282
|
+
|
|
283
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "julian.nc")["arrays"]}
|
|
284
|
+
assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
|
|
285
|
+
|
|
286
|
+
# The fixture holds Julian 2024-01-01 through 2024-01-04. The same instants
|
|
287
|
+
# carry the Gregorian labels 2024-01-14 through 2024-01-17.
|
|
288
|
+
first = np.datetime64(arrays["time"]["stats"]["min"], "ns")
|
|
289
|
+
last = np.datetime64(arrays["time"]["stats"]["max"], "ns")
|
|
290
|
+
assert first == np.datetime64("2024-01-14T00:00:00")
|
|
291
|
+
assert last == np.datetime64("2024-01-17T00:00:00")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_convert_calendar_matches_a_per_element_conversion(
|
|
295
|
+
netcdf_dir_with_a_julian_calendar,
|
|
296
|
+
):
|
|
297
|
+
"""The array-at-once conversion must equal the obvious slow one."""
|
|
298
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
299
|
+
|
|
300
|
+
ds = xr.open_dataset(netcdf_dir_with_a_julian_calendar / "julian.nc")
|
|
301
|
+
values = np.asarray(ds["time"].values)
|
|
302
|
+
|
|
303
|
+
fast = _cftime_to_datetime64("time", values)
|
|
304
|
+
slow = np.array(
|
|
305
|
+
[
|
|
306
|
+
np.datetime64(v.change_calendar("proleptic_gregorian").isoformat(), "ns")
|
|
307
|
+
for v in values
|
|
308
|
+
]
|
|
309
|
+
)
|
|
310
|
+
assert np.array_equal(fast, slow)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_an_artificial_calendar_has_no_exact_gregorian_date(tmp_path):
|
|
314
|
+
"""A 360_day year names no real instant, so no conversion exists."""
|
|
315
|
+
import cftime
|
|
316
|
+
|
|
317
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
318
|
+
|
|
319
|
+
values = np.array([cftime.Datetime360Day(2024, 2, 30)], dtype=object)
|
|
320
|
+
with pytest.raises(NotImplementedError, match="360_day"):
|
|
321
|
+
_cftime_to_datetime64("time", values)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_a_date_outside_the_nanosecond_range_is_refused_not_wrapped(tmp_path):
|
|
325
|
+
"""numpy wraps such a date in silence. Atlas must not pass one on."""
|
|
326
|
+
import cftime
|
|
327
|
+
|
|
328
|
+
from atlas.xarray import _cftime_to_datetime64
|
|
329
|
+
|
|
330
|
+
for year in (1600, 2300):
|
|
331
|
+
values = np.array([cftime.DatetimeJulian(year, 1, 1)], dtype=object)
|
|
332
|
+
with pytest.raises(NotImplementedError, match="outside the range"):
|
|
333
|
+
_cftime_to_datetime64("time", values)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_convert_calendar_leaves_a_normal_time_axis_alone(netcdf_dir, tmp_path):
|
|
337
|
+
dest = tmp_path / "c"
|
|
338
|
+
atlas.create(netcdf_dir, str(dest), convert_calendar=True)
|
|
339
|
+
arrays = {a["name"]: a for a in atlas.describe(str(dest), "2024-01.nc")["arrays"]}
|
|
340
|
+
assert arrays["time"]["dtype"] == "timestamp_nanoseconds"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_an_object_array_of_something_else_is_refused_clearly(tmp_path):
|
|
344
|
+
"""numpy reports `object` for both a string array and this one."""
|
|
345
|
+
from atlas.xarray import _reject_unstorable_object_array
|
|
346
|
+
|
|
347
|
+
var = xr.DataArray(np.array([{"a": 1}, {"b": 2}], dtype=object), dims=["x"])
|
|
348
|
+
with pytest.raises(NotImplementedError, match="object array of dict"):
|
|
349
|
+
_reject_unstorable_object_array("payload", var)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_a_real_string_array_still_passes_the_object_check(tmp_path):
|
|
353
|
+
from atlas.xarray import _reject_unstorable_object_array
|
|
354
|
+
|
|
355
|
+
# A missing cell must not be mistaken for an unsupported element.
|
|
356
|
+
var = xr.DataArray(np.array([None, "b", "c"], dtype=object), dims=["x"])
|
|
357
|
+
_reject_unstorable_object_array("label", var)
|
|
358
|
+
|
|
359
|
+
empty = xr.DataArray(np.array([], dtype=object), dims=["x"])
|
|
360
|
+
_reject_unstorable_object_array("empty", empty)
|
|
361
|
+
|
|
362
|
+
|
|
200
363
|
# ── the log file ─────────────────────────────────────────────────────
|
|
201
364
|
|
|
202
365
|
|
|
@@ -46,10 +46,15 @@ for `ATLS`, and you get nothing.
|
|
|
46
46
|
|
|
47
47
|
## Ingest
|
|
48
48
|
|
|
49
|
-
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf` files
|
|
50
|
-
sorts them, and writes one dataset per
|
|
51
|
-
becomes `2024-01.nc`, suffix and all.
|
|
52
|
-
Build the same directory twice, and
|
|
49
|
+
`create` scans a directory for `.nc`, `.nc4`, `.cdf`, and `.netcdf` files, and
|
|
50
|
+
descends into every subdirectory. It sorts them, and writes one dataset per
|
|
51
|
+
file, named after the file. `2024-01.nc` becomes `2024-01.nc`, suffix and all.
|
|
52
|
+
The sort makes the ordinals reproducible. Build the same directory twice, and
|
|
53
|
+
every dataset lands at the same position.
|
|
54
|
+
|
|
55
|
+
A dataset name carries no directory, because a name may hold no `/`. Two files
|
|
56
|
+
of one name in two subdirectories therefore collide. `recursive=False` scans
|
|
57
|
+
the top directory alone.
|
|
53
58
|
|
|
54
59
|
One writer does all of it. Nothing at the destination is readable until the
|
|
55
60
|
last file lands, with the footer. A failure part-way leaves no collection. That
|