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.
Files changed (84) hide show
  1. {atlas_python-0.16.1 → atlas_python-0.16.3}/Cargo.lock +2 -2
  2. {atlas_python-0.16.1 → atlas_python-0.16.3}/Cargo.toml +1 -1
  3. {atlas_python-0.16.1 → atlas_python-0.16.3}/PKG-INFO +7 -3
  4. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/Cargo.toml +1 -1
  5. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/README.md +6 -2
  6. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/cli.md +14 -6
  7. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/creating.md +23 -6
  8. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/dtypes.md +65 -0
  9. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/installation.md +41 -0
  10. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/writer.rs +1 -1
  11. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_cli.py +56 -0
  12. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_ops.py +180 -17
  13. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/python.md +9 -4
  14. {atlas_python-0.16.1 → atlas_python-0.16.3}/pyproject.toml +1 -1
  15. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/__init__.pyi +24 -5
  16. atlas_python-0.16.3/python/atlas/__main__.py +14 -0
  17. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_cli.py +31 -2
  18. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_ops.py +38 -7
  19. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/xarray.py +160 -1
  20. {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/fixtures/from_python/data.atlas +0 -0
  21. {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-python-docs.yaml +0 -0
  22. {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-python-release.yaml +0 -0
  23. {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/atlas-rust-release.yaml +0 -0
  24. {atlas_python-0.16.1 → atlas_python-0.16.3}/.github/workflows/ci.yaml +0 -0
  25. {atlas_python-0.16.1 → atlas_python-0.16.3}/.gitignore +0 -0
  26. {atlas_python-0.16.1 → atlas_python-0.16.3}/CONTRIBUTING.md +0 -0
  27. {atlas_python-0.16.1 → atlas_python-0.16.3}/LICENSE +0 -0
  28. {atlas_python-0.16.1 → atlas_python-0.16.3}/README.md +0 -0
  29. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/.python-version +0 -0
  30. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/LICENSE +0 -0
  31. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/examples.md +0 -0
  32. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/cloud-storage.md +0 -0
  33. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/inspecting.md +0 -0
  34. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/reading-data.md +0 -0
  35. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/guides/removing.md +0 -0
  36. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/index.md +0 -0
  37. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/quickstart.md +0 -0
  38. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/reference/api.md +0 -0
  39. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  40. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/examples/01_library.py +0 -0
  41. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/examples/02_object_store.py +0 -0
  42. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/mkdocs.yml +0 -0
  43. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/attr.rs +0 -0
  44. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/dtype.rs +0 -0
  45. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/error.rs +0 -0
  46. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/lib.rs +0 -0
  47. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/logging.rs +0 -0
  48. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/reader.rs +0 -0
  49. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/runtime.rs +0 -0
  50. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/src/source.rs +0 -0
  51. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  52. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/conftest.py +0 -0
  53. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/make_fixture.py +0 -0
  54. {atlas_python-0.16.1 → atlas_python-0.16.3}/atlas-python/tests/test_source.py +0 -0
  55. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/README.md +0 -0
  56. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/architecture.md +0 -0
  57. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/data-model.md +0 -0
  58. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/format.md +0 -0
  59. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/read-path.md +0 -0
  60. {atlas_python-0.16.1 → atlas_python-0.16.3}/docs/write-path.md +0 -0
  61. {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/lifecycle.rs +0 -0
  62. {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/sensor_fleet.rs +0 -0
  63. {atlas_python-0.16.1 → atlas_python-0.16.3}/examples/weather_store.rs +0 -0
  64. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/__init__.py +0 -0
  65. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_log.py +0 -0
  66. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/_source.py +0 -0
  67. {atlas_python-0.16.1 → atlas_python-0.16.3}/python/atlas/py.typed +0 -0
  68. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/config.rs +0 -0
  69. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/error.rs +0 -0
  70. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/footer.rs +0 -0
  71. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/mask.rs +0 -0
  72. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/mod.rs +0 -0
  73. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/format/segment_store.rs +0 -0
  74. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/lib.rs +0 -0
  75. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/reader/mod.rs +0 -0
  76. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/array.rs +0 -0
  77. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/attr.rs +0 -0
  78. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/dtype.rs +0 -0
  79. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/schema/mod.rs +0 -0
  80. {atlas_python-0.16.1 → atlas_python-0.16.3}/src/writer/mod.rs +0 -0
  81. {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/cross_fixture.rs +0 -0
  82. {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/fixtures/golden_v1/data.atlas +0 -0
  83. {atlas_python-0.16.1 → atlas_python-0.16.3}/tests/golden.rs +0 -0
  84. {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.1"
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.1"
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.1"
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.1
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`. It sorts
124
- them, and writes one dataset per file, named after the file. Each coordinate
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
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.16.1"
3
+ version = "0.16.3"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -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`. It sorts
96
- them, and writes one dataset per file, named after the file. Each coordinate
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.1: create /data/nc ...
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
- Each NetCDF file becomes one dataset, named after the file. `2024-01.nc`
48
- becomes `2024-01.nc`, suffix and all. The files land in sorted order, which
49
- makes the ordinals of a collection reproducible.
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
- | `-r`, `--recursive` | Descend into subdirectories |
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 --recursive --skip-errors
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
- It **sorts** them, and writes one dataset per file, named after the file.
19
- `2024-01.nc` becomes the dataset `2024-01.nc`. The suffix is part of the name,
20
- so `jan.nc` and `jan.nc4` are two datasets.
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") # sorted list of paths
30
- atlas.find_netcdf_files("/data/nc", recursive=True)
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`. Rename one, or ingest them into two collections.
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 array element must be str or bytes, got {:?}",
325
+ "array {name:?}: a string element must be str or bytes, got {:?}",
326
326
  bound.get_type().name()?
327
327
  )));
328
328
  };
@@ -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 test_create_finds_files_recursively_when_asked(tmp_path):
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
- flat = atlas.find_netcdf_files(tmp_path / "nc")
57
- assert flat == []
57
+ found = atlas.find_netcdf_files(tmp_path / "nc")
58
+ assert [p.name for p in found] == ["jan.nc", "top.nc"]
58
59
 
59
- found = atlas.find_netcdf_files(tmp_path / "nc", recursive=True)
60
- assert [p.name for p in found] == ["jan.nc"]
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
- def test_an_empty_directory_is_an_error(tmp_path):
67
- (tmp_path / "empty").mkdir()
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
- def test_a_missing_directory_is_an_error(tmp_path):
73
- with pytest.raises(atlas.AtlasError, match="not a directory"):
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 test_two_files_with_the_same_name_collide(tmp_path):
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"), recursive=True)
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. It
50
- sorts them, and writes one dataset per file, named after the file. `2024-01.nc`
51
- becomes `2024-01.nc`, suffix and all. The sort makes the ordinals reproducible.
52
- Build the same directory twice, and every dataset lands at the same position.
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
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "atlas-python"
7
- version = "0.16.1"
7
+ version = "0.16.3"
8
8
  description = "Python bindings for the ATLAS array store"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"