atlas-python 0.10.0__tar.gz → 0.12.0__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 (83) hide show
  1. {atlas_python-0.10.0 → atlas_python-0.12.0}/Cargo.lock +4 -4
  2. {atlas_python-0.10.0 → atlas_python-0.12.0}/Cargo.toml +2 -2
  3. {atlas_python-0.10.0 → atlas_python-0.12.0}/PKG-INFO +7 -5
  4. {atlas_python-0.10.0 → atlas_python-0.12.0}/README.md +27 -11
  5. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/Cargo.toml +1 -1
  6. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/README.md +6 -4
  7. atlas_python-0.12.0/atlas-python/docs/guides/attributes.md +115 -0
  8. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/datasets-and-arrays.md +51 -8
  9. atlas_python-0.12.0/atlas-python/src/attr.rs +95 -0
  10. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/dataset.rs +53 -7
  11. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/error.rs +3 -0
  12. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/store.rs +65 -7
  13. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/tests/test_xarray.py +15 -12
  14. {atlas_python-0.10.0 → atlas_python-0.12.0}/examples/lifecycle.rs +4 -4
  15. {atlas_python-0.10.0 → atlas_python-0.12.0}/examples/sensor_fleet.rs +2 -2
  16. {atlas_python-0.10.0 → atlas_python-0.12.0}/examples/weather_store.rs +17 -17
  17. {atlas_python-0.10.0 → atlas_python-0.12.0}/pyproject.toml +1 -1
  18. {atlas_python-0.10.0 → atlas_python-0.12.0}/python/atlas/__init__.pyi +46 -6
  19. {atlas_python-0.10.0 → atlas_python-0.12.0}/python/atlas/xarray.py +17 -15
  20. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/array.rs +29 -0
  21. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/config.rs +28 -0
  22. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/dataset.rs +429 -75
  23. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/error.rs +28 -0
  24. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/lib.rs +25 -10
  25. atlas_python-0.12.0/src/meta.rs +745 -0
  26. atlas_python-0.12.0/src/schema.rs +436 -0
  27. {atlas_python-0.10.0 → atlas_python-0.12.0}/src/store.rs +137 -9
  28. {atlas_python-0.10.0 → atlas_python-0.12.0}/tests/integration.rs +352 -20
  29. atlas_python-0.10.0/atlas-python/docs/guides/attributes.md +0 -103
  30. atlas_python-0.10.0/atlas-python/src/attr.rs +0 -60
  31. atlas_python-0.10.0/src/meta.rs +0 -398
  32. atlas_python-0.10.0/src/schema.rs +0 -164
  33. {atlas_python-0.10.0 → atlas_python-0.12.0}/.github/workflows/atlas-python-docs.yaml +0 -0
  34. {atlas_python-0.10.0 → atlas_python-0.12.0}/.github/workflows/atlas-python-release.yaml +0 -0
  35. {atlas_python-0.10.0 → atlas_python-0.12.0}/.github/workflows/atlas-rust-release.yaml +0 -0
  36. {atlas_python-0.10.0 → atlas_python-0.12.0}/.github/workflows/ci.yaml +0 -0
  37. {atlas_python-0.10.0 → atlas_python-0.12.0}/.gitignore +0 -0
  38. {atlas_python-0.10.0 → atlas_python-0.12.0}/CONTRIBUTING.md +0 -0
  39. {atlas_python-0.10.0 → atlas_python-0.12.0}/LICENSE +0 -0
  40. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/.python-version +0 -0
  41. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/LICENSE +0 -0
  42. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/benchmarks/README.md +0 -0
  43. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/benchmarks/_common.py +0 -0
  44. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/benchmarks/bench_collection.py +0 -0
  45. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/benchmarks/generate_bench_charts.py +0 -0
  46. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/assets/bench_gridded.svg +0 -0
  47. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/assets/bench_profile.svg +0 -0
  48. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/benchmarks.md +0 -0
  49. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/examples.md +0 -0
  50. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/bulk-reads.md +0 -0
  51. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/cloud-storage.md +0 -0
  52. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/codecs-and-meta.md +0 -0
  53. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/dask.md +0 -0
  54. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/dtypes.md +0 -0
  55. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/durability.md +0 -0
  56. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/shared-arrays.md +0 -0
  57. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/stats.md +0 -0
  58. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/guides/xarray.md +0 -0
  59. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/index.md +0 -0
  60. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/installation.md +0 -0
  61. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/quickstart.md +0 -0
  62. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/reference/atlas.md +0 -0
  63. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/reference/dataset-view.md +0 -0
  64. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/reference/xarray-accessor.md +0 -0
  65. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/docs/vs-zarr-netcdf.md +0 -0
  66. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/01_basics.py +0 -0
  67. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/02_xarray.py +0 -0
  68. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/03_dask_streaming.py +0 -0
  69. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/04_meta_formats.py +0 -0
  70. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/05_codecs.py +0 -0
  71. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/06_stats_scan.py +0 -0
  72. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/07_shared_arrays.py +0 -0
  73. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/08_object_store.py +0 -0
  74. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/examples/09_missing_data.py +0 -0
  75. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/mkdocs.yml +0 -0
  76. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/dtype.rs +0 -0
  77. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/lib.rs +0 -0
  78. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/logging.rs +0 -0
  79. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/src/runtime.rs +0 -0
  80. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/tests/GL_PR_BO_JLKU.nc +0 -0
  81. {atlas_python-0.10.0 → atlas_python-0.12.0}/atlas-python/tests/test_smoke.py +0 -0
  82. {atlas_python-0.10.0 → atlas_python-0.12.0}/python/atlas/__init__.py +0 -0
  83. {atlas_python-0.10.0 → atlas_python-0.12.0}/python/atlas/py.typed +0 -0
@@ -28,9 +28,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
28
28
 
29
29
  [[package]]
30
30
  name = "array-format"
31
- version = "0.10.0"
31
+ version = "0.11.0"
32
32
  source = "registry+https://github.com/rust-lang/crates.io-index"
33
- checksum = "a637a3b5f23ba45eb53f09e643b363163114f0e94d0f6f05f330134cb86d381b"
33
+ checksum = "f894cd59132aa6a00c360fef55469100c39da19c556bbf44a46bd647f7252fae"
34
34
  dependencies = [
35
35
  "bytes",
36
36
  "futures",
@@ -71,7 +71,7 @@ dependencies = [
71
71
 
72
72
  [[package]]
73
73
  name = "atlas-python"
74
- version = "0.10.0"
74
+ version = "0.12.0"
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.10.0"
89
+ version = "0.12.0"
90
90
  dependencies = [
91
91
  "array-format",
92
92
  "chrono",
@@ -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.10.0"
8
+ version = "0.12.0"
9
9
  edition = "2024"
10
10
  description = "Directory-based store for thousands of N-dimensional datasets local or remote using object storage."
11
11
  license = "Apache-2.0"
@@ -22,7 +22,7 @@ all-features = true
22
22
  rustdoc-args = ["--cfg", "docsrs"]
23
23
 
24
24
  [dependencies]
25
- array-format = "0.10.0"
25
+ array-format = "0.11.0"
26
26
  object_store = "0.13"
27
27
  serde = { version = "1", features = ["derive"] }
28
28
  serde_json = "1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlas-python
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Programming Language :: Python :: 3
@@ -179,8 +179,8 @@ picklable, so call `.compute()` before handing off to distributed/multiprocessin
179
179
  | Item | How it's stored |
180
180
  | --- | --- |
181
181
  | Each coord / data variable | A separate array, with `dims` mapped 1:1. |
182
- | Dataset attrs | Dataset attributes, plain keys. |
183
- | Per-variable attrs | Flattened as `{var}.{attr}` at the dataset attr level. |
182
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
183
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
184
184
  | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). See [Missing data](#missing-data). |
185
185
  | Coord vs data_var distinction | JSON list in the internal `_pyatlas_coords` attr. |
186
186
  | Non-scalar attr values (list, ndarray) | JSON-encoded string with a `json:` prefix marker. |
@@ -261,8 +261,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
261
261
  | `delete_array(name)` | Tombstone the array within this dataset. |
262
262
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
263
263
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
264
- | `set_attribute(key, value, dtype=None)` | Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). On disk: bool, int64, float64, string, timestamp_nanoseconds. |
265
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
264
+ | `set_attribute(key, value, dtype=None)` | Set a dataset-level (global) attribute. Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). Values are stored in the `_global` `.af` file. |
265
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
266
+ | `set_array_attribute(array, key, value, dtype=None)` | Set a per-variable attribute on `array` (e.g. `units`); `KeyError` if the array isn't defined. |
267
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
266
268
 
267
269
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
268
270
 
@@ -21,26 +21,30 @@ A directory-based store for thousands of named datasets, each holding N-dimensio
21
21
 
22
22
  `atlas` is designed for workloads where you have a large collection of similarly-shaped datasets — such as one dataset per time step, sensor station, or simulation run — and you want to query a single variable (e.g. `temperature`) across all of them efficiently.
23
23
 
24
- Each dataset is a named group of N-dimensional arrays with typed per-dataset attributes. Datasets that share an array name (e.g. every `jan_2024`, `feb_2024`, … all have `temperature`) are stored together in the same physical file, keyed by dataset name inside the file.
24
+ Each dataset is a named group of N-dimensional arrays with typed attributes — both dataset-level (global) attributes and per-variable attributes. Datasets that share an array name (e.g. every `jan_2024`, `feb_2024`, … all have `temperature`) are stored together in the same physical file, keyed by dataset name inside the file.
25
25
 
26
26
  ```text
27
27
  my_store/
28
- ├── atlas.json ← dataset registry and per-dataset attributes (JSON)
28
+ ├── atlas.json ← interned per-dataset schema + attribute-key namespace (JSON)
29
+ ├── _global/
30
+ │ └── data.af ← dataset-level (global) attribute values, one entry per dataset
29
31
  ├── temperature/
30
- │ └── data.af ← one ArrayFile holding temperature for every dataset
32
+ │ └── data.af ← one ArrayFile: temperature + its per-variable attributes, per dataset
31
33
  ├── pressure/
32
34
  │ └── data.af
33
35
  └── time/
34
36
  └── data.af
35
37
  ```
36
38
 
39
+ `atlas.json` holds only the **schema** of the collection. Attribute **values** live in the `.af` files: per-variable attributes on the variable's own file, and dataset-level attributes in the reserved `_global` file.
40
+
37
41
  ---
38
42
 
39
43
  ## Durability model
40
44
 
41
- `atlas.json` is read **once** when the store is opened or created. Every subsequent mutation — `create_dataset`, `define_array`, `set_attribute`, `delete_array`, `delete_dataset` — only touches the in-memory `StoreMeta`; writes to `ArrayFile`s buffer inside the per-array in-memory layer. **Nothing reaches disk until `Atlas::flush()` (or `Atlas::close()`).** Dropping an `Atlas` without flushing abandons every pending in-memory write.
45
+ `atlas.json` is read **once** when the store is opened or created. Every subsequent mutation — `create_dataset`, `define_array`, `set_attribute`, `set_array_attribute`, `delete_array`, `delete_dataset` — only touches in-memory state; array writes buffer inside the per-array in-memory layer and attribute writes buffer in a pending-attribute map. **Nothing reaches disk until `Atlas::flush()` (or `Atlas::close()`).** Dropping an `Atlas` without flushing abandons every pending in-memory write.
42
46
 
43
- A single `Atlas::flush()` walks every cached `ArrayFile` (writing deltas + stats) and then serialises the in-memory `StoreMeta` to `atlas.json`. This gives one durability boundary for the whole store: N datasets ⇒ one delta file per touched array name (not one per dataset) and one `atlas.json` rewrite (not N).
47
+ A single `Atlas::flush()` drains the buffered attributes into their `.af` files, walks every cached `ArrayFile` (writing deltas + stats), and then serialises the in-memory schema to `atlas.json`. This gives one durability boundary for the whole store: N datasets ⇒ one delta file per touched array name (not one per dataset) and one `atlas.json` rewrite (not N).
44
48
 
45
49
  `DatasetView` is a borrowed handle into the atlas's shared meta — it has no `flush()` of its own.
46
50
 
@@ -50,14 +54,25 @@ A single `Atlas::flush()` walks every cached `ArrayFile` (writing deltas + stats
50
54
 
51
55
  ### `atlas.json`
52
56
 
53
- The registry is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
57
+ The schema is a plain JSON file written on `Atlas::flush()` / `Atlas::close()`. It stores:
58
+
59
+ - **Store version** — for format upgrades. The current version is `2`; stores written by an older atlas are rejected on open (re-export to upgrade).
60
+ - **Interned schema pool** — each *distinct* per-dataset schema is stored once and referenced by index. A dataset's schema is its array schemas (per array: dtype, shape, chunk shape, named dimensions, codec) plus its **attribute-key namespace** (the names of its global and per-variable attribute keys). A collection of thousands of homogeneous datasets stores its schema a single time.
61
+ - **Dataset registry** — a map of dataset name → schema-pool index.
62
+ - **Merged schema** — a collection-wide summary listing every unique array (with its dtype widened across all datasets, its dimensions, and its per-variable attribute types) and every global attribute type. Descriptive only — reads always use each dataset's own schema — but handy for tools that want one view of "what's in here." Also exposed programmatically via `Atlas::merged_schema()`.
63
+
64
+ When the same array name (or attribute key) appears in more than one dataset with different types, the merged type is **widened**. Widening is allowed only within numeric types (e.g. `int16` ∪ `int32` → `int32`; `int32` ∪ `float32` → `float64`) or between `string` and `timestamp` (→ `string`).
65
+
66
+ Any other combination — e.g. an `int32` array in one dataset and a `string` array under the same name in another — can't merge. The dataset is **still stored** under its own type (each dataset keeps the type it declared, and its data reads back normally); the merged schema simply keeps the **first-seen** type. Whether that's reported as a warning or an error is set by `StoreConfig::on_type_mismatch`:
67
+
68
+ | `TypeMismatchPolicy` | Behaviour |
69
+ | --- | --- |
70
+ | `Warn` (default) | Logs a `tracing` warning and carries on — an ingest of many heterogeneous files won't abort because one disagrees. |
71
+ | `Error` | `define_array` / `set_attribute` / `set_array_attribute` return `Error::TypeMismatch`. |
54
72
 
55
- - **Store version** — for future format upgrades.
56
- - **Dataset names** — the complete list of datasets in the store.
57
- - **Per-dataset attributes** — typed key-value pairs serialized as plain JSON values: bool, 64-bit integer, 64-bit float, UTF-8 string, or an RFC 3339 nanosecond-precision timestamp string (e.g. `"2023-11-15T07:33:20.123456789Z"`). Atlas's `Attr` enum has five variants (`Bool`/`Int64`/`Float64`/`String`/`TimestampNanoseconds`); there are no narrow integer/float types on disk.
58
- - **Array schemas** — per array: dtype, shape, chunk shape, named dimensions, and the codec used when the array was first written.
73
+ The policy is a **per-session** choice, not an on-disk property: pass it to `Atlas::create`, or to `Atlas::open_with_config` / `Atlas::open_path_with_config` when opening an existing collection (plain `open` / `open_path` use the default). Note that these open variants honour only `on_type_mismatch` the codec and metadata format are always detected from disk.
59
74
 
60
- Because `atlas.json` is human-readable and self-describing, you can inspect or audit the store contents with any JSON tool without needing the library.
75
+ Attribute **values** are **not** in `atlas.json` — only their key names are (as part of the schema). Values live in the `.af` files and are read via the `array-format` attribute API. Because `atlas.json` is human-readable and self-describing, you can still inspect or audit the collection's schema with any JSON tool without needing the library.
61
76
 
62
77
  ### `<array_name>/data.af`
63
78
 
@@ -67,6 +82,7 @@ Each array variable gets its own subdirectory with a single `data.af` binary fil
67
82
  - **Chunked layout** — arrays are split into chunks of a user-specified shape, so partial reads and writes touch only the relevant blocks.
68
83
  - **Configurable compression** — each block is compressed with the codec set when the store was created (default: Zstd; also LZ4 and uncompressed). The codec is persisted in `atlas.json` and restored automatically on `open` — no need to pass it again. Block target size is 8 MiB.
69
84
  - **Per-array fill value** — `define_array` accepts an optional `FillValue` (one of `Bool`/`Int`/`UInt`/`Float`/`String`). Unwritten cells read back as the fill value, and any *written* cell equal to it is counted as a null in the persisted stats (see below). Fill values are stored in the per-array footer; `atlas.json` is not extended.
85
+ - **Attribute values** — per-variable attributes (e.g. `units`) are stored in the footer of the variable's own `data.af`, keyed by dataset. Dataset-level (global) attributes live the same way in the reserved `_global/data.af`. Atlas's `Attr` type mirrors `array-format`'s `AttributeValue` (bool, sized ints/uints, `f32`/`f64`, string, binary, and typed lists) plus a nanosecond timestamp variant stored as an RFC 3339 string.
70
86
  - **Persisted statistics** — on `Atlas::flush()`, min, max, null count, and row count are computed per array per dataset and stored alongside the data. Cells equal to the array's fill value are tallied in `null_count` and excluded from `min`/`max` (NaN fills match NaN cells by bit pattern). Statistics survive store reopening.
71
87
  - **In-memory caches** — a 256 MiB decoded block cache and a 64 MiB raw I/O cache sit in front of the object store for repeated reads.
72
88
 
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "atlas-python"
3
- version = "0.10.0"
3
+ version = "0.12.0"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -147,8 +147,8 @@ picklable, so call `.compute()` before handing off to distributed/multiprocessin
147
147
  | Item | How it's stored |
148
148
  | --- | --- |
149
149
  | Each coord / data variable | A separate array, with `dims` mapped 1:1. |
150
- | Dataset attrs | Dataset attributes, plain keys. |
151
- | Per-variable attrs | Flattened as `{var}.{attr}` at the dataset attr level. |
150
+ | Dataset attrs | Dataset-level (global) attributes, plain keys. |
151
+ | Per-variable attrs | Real per-variable attributes on each variable's array. |
152
152
  | Per-variable `_FillValue` | Consumed by `define_array` as a typed fill value (source `Dataset.attrs` is not mutated). See [Missing data](#missing-data). |
153
153
  | Coord vs data_var distinction | JSON list in the internal `_pyatlas_coords` attr. |
154
154
  | Non-scalar attr values (list, ndarray) | JSON-encoded string with a `json:` prefix marker. |
@@ -229,8 +229,10 @@ handle instead of a local path. The path-based local-filesystem API works withou
229
229
  | `delete_array(name)` | Tombstone the array within this dataset. |
230
230
  | `array_meta(name) -> dict \| None` | `{"dtype", "shape", "chunk_shape", "dimension_names"}`. |
231
231
  | `array_stats(name) -> dict \| None` | `{"row_count", "null_count", "min", "max"}` — populated after `flush()`. |
232
- | `set_attribute(key, value, dtype=None)` | Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). On disk: bool, int64, float64, string, timestamp_nanoseconds. |
233
- | `get_attribute(key)` / `attributes()` | Single attribute or dict of all. |
232
+ | `set_attribute(key, value, dtype=None)` | Set a dataset-level (global) attribute. Type inferred from the Python value; pass `dtype` to override (e.g. `"int8"`, `"float32"`, `"timestamp_nanoseconds"`). Values are stored in the `_global` `.af` file. |
233
+ | `get_attribute(key)` / `attributes()` | Single global attribute or dict of all. |
234
+ | `set_array_attribute(array, key, value, dtype=None)` | Set a per-variable attribute on `array` (e.g. `units`); `KeyError` if the array isn't defined. |
235
+ | `get_array_attribute(array, key)` / `array_attributes(array)` | Single per-variable attribute or dict of all, for `array`. |
234
236
 
235
237
  `DatasetView` does **not** expose its own `flush` / `compact` — both go through the parent `Atlas`.
236
238
 
@@ -0,0 +1,115 @@
1
+ # Attributes
2
+
3
+ Each [`DatasetView`](../reference/dataset-view.md) carries typed attributes at
4
+ two levels: **dataset-level** (global) attributes and **per-variable**
5
+ attributes on individual arrays. Attribute *values* are stored in the `.af`
6
+ files — global values in a reserved `_global` file, per-variable values on the
7
+ variable's own file. Only the attribute *key names* are recorded in
8
+ `atlas.json` (as part of the schema), so listing which attributes exist is
9
+ cheap and doesn't load any array bytes.
10
+
11
+ ## Dataset-level (global) attributes
12
+
13
+ ```python
14
+ ds = atlas.create_dataset("jan_2024")
15
+
16
+ ds.set_attribute("month", 1) # inferred int
17
+ ds.set_attribute("station", "KNMI") # inferred str
18
+ ds.set_attribute("calibrated", True) # inferred bool
19
+
20
+ ds.get_attribute("month") # -> 1
21
+ ds.get_attribute("missing") # -> None
22
+ ds.attributes() # -> {"month": 1, "station": "KNMI", "calibrated": True}
23
+ ```
24
+
25
+ ## Per-variable attributes
26
+
27
+ Attributes can also attach to a specific array (e.g. `units` on `temperature`):
28
+
29
+ ```python
30
+ ds.define_array("temperature", dtype="float32", dims=["lat", "lon"], shape=[4, 8])
31
+ ds.set_array_attribute("temperature", "units", "degC")
32
+ ds.set_array_attribute("temperature", "valid_range", [-40.0, 60.0], dtype="f64")
33
+
34
+ ds.get_array_attribute("temperature", "units") # -> "degC"
35
+ ds.array_attributes("temperature") # -> {"units": "degC", "valid_range": [...]}
36
+ ```
37
+
38
+ `set_array_attribute` raises `KeyError` if the array isn't defined in the
39
+ dataset.
40
+
41
+ ## Durability
42
+
43
+ Attribute writes are buffered in memory and only reach disk on
44
+ [`atlas.flush()`](durability.md) (or leaving a `with atlas:` block), together
45
+ with the array data. The reserved `_global/data.af` file is created lazily —
46
+ only once a dataset actually sets a global attribute.
47
+
48
+ ## The on-disk type system
49
+
50
+ Atlas's `Attr` type mirrors the underlying `array-format` `AttributeValue`:
51
+
52
+ | On-disk type | Python type returned on read |
53
+ |---|---|
54
+ | `bool` | `bool` |
55
+ | `int8` / `int16` / `int32` / `int64` | `int` |
56
+ | `uint8` / `uint16` / `uint32` / `uint64` | `int` |
57
+ | `float32` / `float64` | `float` |
58
+ | `string` | `str` |
59
+ | `binary` | `bytes` |
60
+ | `timestamp_nanoseconds` | `int` (nanoseconds; stored as an RFC 3339 string) |
61
+ | any of the above as a list | `list` |
62
+
63
+ Type is inferred from the Python value by default — `int` → `int64`,
64
+ `float` → `float64`, `str` → `string`, `bytes` → `binary`, `bool` → `bool`.
65
+
66
+ ## Overriding inferred types
67
+
68
+ Pass `dtype=` to narrow or force a specific type (works on both
69
+ `set_attribute` and `set_array_attribute`):
70
+
71
+ ```python
72
+ ds.set_attribute("sensor_id", 7, dtype="int8") # stored as int8, range-checked
73
+ ds.set_attribute("ratio", 0.5, dtype="float32") # stored as float32
74
+ ds.set_attribute("observed_at",
75
+ np.datetime64("2024-01-15T10:00", "ns").astype("int64").item(),
76
+ dtype="timestamp_nanoseconds")
77
+ ```
78
+
79
+ Unlike earlier versions, width hints now preserve the storage type: `dtype="int8"`
80
+ stores an 8-bit integer, not a widened `int64`. A `timestamp_nanoseconds`
81
+ attribute is stored as an RFC 3339 string and restored to a timestamp on read.
82
+
83
+ ## Per-variable xarray attributes
84
+
85
+ When you write an `xr.Dataset` via `atlas.add_xarray_dataset(ds, name)`, each
86
+ variable's `attrs` are stored as **real per-variable attributes** on that
87
+ variable's array, and the dataset's own `attrs` become dataset-level
88
+ attributes:
89
+
90
+ ```python
91
+ ds = xr.Dataset(
92
+ data_vars={"temperature": xr.DataArray(arr, dims=["lat", "lon"],
93
+ attrs={"units": "C"})},
94
+ attrs={"station": "KNMI"},
95
+ )
96
+ atlas.add_xarray_dataset(ds, "jan_2024")
97
+
98
+ view = atlas.open_dataset("jan_2024")
99
+ view.attributes() # {"station": "KNMI"}
100
+ view.array_attributes("temperature") # {"units": "C"}
101
+ ```
102
+
103
+ On read, `atlas.open_as_xarray_dataset("jan_2024")` puts each variable's attrs
104
+ back on the right `DataArray` and the global attrs back on the Dataset.
105
+
106
+ See [xarray integration](xarray.md) for the full storage convention.
107
+
108
+ ## JSON-encoded "complex" attributes
109
+
110
+ xarray attribute values are sometimes nested dicts or other structures that
111
+ don't map to atlas's on-disk types. The xarray bridge JSON-encodes those and
112
+ prefixes the string with `json:`, decoding transparently on read. You generally
113
+ don't need to think about this, but it's why some values come back as strings
114
+ starting with `json:` if you read them through the raw attribute API. Simple
115
+ lists of scalars are stored natively as typed-list attributes.
@@ -4,18 +4,24 @@
4
4
 
5
5
  An [`Atlas`](../reference/atlas.md) is a directory-backed handle. It owns:
6
6
 
7
- 1. An in-memory **`StoreMeta`** — every dataset, every array schema, every
8
- attribute. Loaded once at `open`/`create`, mutated by every write,
9
- persisted on `flush()`.
7
+ 1. An in-memory **`StoreMeta`** — the collection's schema: every dataset and
8
+ every array schema, plus the attribute-key namespace (which global and
9
+ per-variable attribute keys exist). Distinct dataset schemas are interned,
10
+ so thousands of identical schemas cost one copy. Loaded once at
11
+ `open`/`create`, mutated by every schema write, persisted to `atlas.json`
12
+ on `flush()`.
10
13
  2. A set of **array file caches** — one buffer per array name across the
11
- whole store. Pending writes accumulate here until `flush()`.
14
+ whole store. Pending array writes accumulate here until `flush()`.
15
+ 3. A **pending-attribute buffer** — attribute *values* set via `set_attribute`
16
+ / `set_array_attribute`, drained into the `.af` files on `flush()`.
12
17
 
13
18
  A [`DatasetView`](../reference/dataset-view.md) is a typed handle into a
14
19
  single logical dataset. It exposes the per-dataset array schemas, the
15
- per-array statistics, and the per-dataset attribute dict. Mutations through
16
- the view (`define_array`, `write_array`, `set_attribute`, …) update the
17
- parent atlas's in-memory state; nothing reaches disk until you flush the
18
- *atlas*.
20
+ per-array statistics, dataset-level (global) attributes, and per-variable
21
+ attributes. Mutations through the view (`define_array`, `write_array`,
22
+ `set_attribute`, `set_array_attribute`, …) update the parent atlas's in-memory
23
+ state; attribute *values* live in the `.af` files, and nothing reaches disk
24
+ until you flush the *atlas*.
19
25
 
20
26
  ```text
21
27
  Atlas ── StoreMeta (in-memory) ─┬─ DatasetView "jan_2024"
@@ -142,3 +148,40 @@ jan.delete_array("temperature") # tombstone within this dataset
142
148
 
143
149
  The array's bytes inside the shared physical file are tombstoned; reclaim
144
150
  the space with [`atlas.compact()`](durability.md).
151
+
152
+ ## Merged schema and type widening
153
+
154
+ `atlas.json` also carries a collection-wide **merged schema** — every unique
155
+ array (with its dtype, dimensions, and per-variable attribute types) and every
156
+ global attribute type, folded across all datasets. Read it with
157
+ `atlas.merged_schema()`:
158
+
159
+ ```python
160
+ merged = store.merged_schema()
161
+ merged["arrays"]["temperature"]["dtype"] # widened dtype across datasets
162
+ merged["arrays"]["temperature"]["attributes"] # {attr_key: dtype}
163
+ merged["global_attributes"] # {attr_key: dtype}
164
+ ```
165
+
166
+ When the same array name or attribute key appears in multiple datasets with
167
+ different types, the merged type is **widened** — but only within numeric types
168
+ (`int16` ∪ `int32` → `int32`, `int32` ∪ `float32` → `float64`) or between
169
+ `string` and `timestamp` (→ `string`).
170
+
171
+ Anything else can't merge (e.g. an `int32` array in one dataset and a `string`
172
+ array under the same name in another). The dataset is **still stored** under its
173
+ own type and reads back normally; the merged schema just keeps the
174
+ **first-seen** type. `on_type_mismatch` decides how that's reported:
175
+
176
+ ```python
177
+ # "warn" (default): stored, logs a warning, merged keeps the first type
178
+ store = atlas.Atlas.create("/tmp/store")
179
+ store = atlas.Atlas.open("/tmp/store")
180
+
181
+ # "error": the mismatching define_array / set_attribute raises ValueError
182
+ store = atlas.Atlas.create("/tmp/store", on_type_mismatch="error")
183
+ store = atlas.Atlas.open("/tmp/store", on_type_mismatch="error")
184
+ ```
185
+
186
+ It's a per-session choice — it isn't stored in `atlas.json`, so pass it each
187
+ time you open the collection.
@@ -0,0 +1,95 @@
1
+ use atlas::Attr;
2
+ use pyo3::exceptions::PyValueError;
3
+ use pyo3::prelude::*;
4
+ use pyo3::types::{PyBool, PyBytes, PyFloat, PyInt, PyList, PyString};
5
+
6
+ pub fn py_to_attr(value: &Bound<'_, PyAny>, dtype_hint: Option<&str>) -> PyResult<Attr> {
7
+ if let Some(hint) = dtype_hint {
8
+ return py_to_attr_typed(value, hint);
9
+ }
10
+
11
+ if let Ok(b) = value.downcast::<PyBool>() {
12
+ return Ok(Attr::Bool(b.is_true()));
13
+ }
14
+ if value.downcast::<PyString>().is_ok() {
15
+ return Ok(Attr::String(value.extract::<String>()?));
16
+ }
17
+ if value.downcast::<PyBytes>().is_ok() {
18
+ return Ok(Attr::Binary(value.extract::<Vec<u8>>()?));
19
+ }
20
+ if value.downcast::<PyInt>().is_ok() {
21
+ return Ok(Attr::Int64(value.extract::<i64>()?));
22
+ }
23
+ if value.downcast::<PyFloat>().is_ok() {
24
+ return Ok(Attr::Float64(value.extract::<f64>()?));
25
+ }
26
+ Err(PyValueError::new_err(format!(
27
+ "unsupported attribute type: {:?}",
28
+ value.get_type().name()?
29
+ )))
30
+ }
31
+
32
+ fn py_to_attr_typed(value: &Bound<'_, PyAny>, dtype: &str) -> PyResult<Attr> {
33
+ Ok(match dtype.to_ascii_lowercase().as_str() {
34
+ "bool" => Attr::Bool(value.extract()?),
35
+ // Width-precise integer hints so the on-disk attribute keeps its type.
36
+ // Python `int.extract::<T>()` raises OverflowError on overflow, which
37
+ // surfaces as a PyErr.
38
+ "i8" | "int8" => Attr::Int8(value.extract()?),
39
+ "i16" | "int16" => Attr::Int16(value.extract()?),
40
+ "i32" | "int32" => Attr::Int32(value.extract()?),
41
+ "i64" | "int64" => Attr::Int64(value.extract()?),
42
+ "u8" | "uint8" => Attr::UInt8(value.extract()?),
43
+ "u16" | "uint16" => Attr::UInt16(value.extract()?),
44
+ "u32" | "uint32" => Attr::UInt32(value.extract()?),
45
+ "u64" | "uint64" => Attr::UInt64(value.extract()?),
46
+ "f32" | "float32" => Attr::Float32(value.extract()?),
47
+ "f64" | "float64" => Attr::Float64(value.extract()?),
48
+ "string" | "str" => Attr::String(value.extract()?),
49
+ "binary" | "bytes" => Attr::Binary(value.extract()?),
50
+ "timestamp_ns" | "timestamp_nanoseconds" | "datetime64[ns]" => {
51
+ Attr::TimestampNanoseconds(value.extract()?)
52
+ }
53
+ other => {
54
+ return Err(PyValueError::new_err(format!(
55
+ "unknown attribute dtype: {other:?}"
56
+ )))
57
+ }
58
+ })
59
+ }
60
+
61
+ pub fn attr_to_py(py: Python<'_>, attr: &Attr) -> PyResult<Py<PyAny>> {
62
+ use pyo3::IntoPyObjectExt;
63
+ match attr {
64
+ Attr::Bool(v) => (*v).into_py_any(py),
65
+ Attr::Int8(v) => (*v).into_py_any(py),
66
+ Attr::Int16(v) => (*v).into_py_any(py),
67
+ Attr::Int32(v) => (*v).into_py_any(py),
68
+ Attr::Int64(v) => (*v).into_py_any(py),
69
+ Attr::UInt8(v) => (*v).into_py_any(py),
70
+ Attr::UInt16(v) => (*v).into_py_any(py),
71
+ Attr::UInt32(v) => (*v).into_py_any(py),
72
+ Attr::UInt64(v) => (*v).into_py_any(py),
73
+ Attr::Float32(v) => (*v).into_py_any(py),
74
+ Attr::Float64(v) => (*v).into_py_any(py),
75
+ Attr::String(v) => v.clone().into_py_any(py),
76
+ Attr::Binary(v) => PyBytes::new(py, v).into_py_any(py),
77
+ Attr::TimestampNanoseconds(v) => (*v).into_py_any(py),
78
+ Attr::BoolList(v) => PyList::new(py, v)?.into_py_any(py),
79
+ Attr::Int8List(v) => PyList::new(py, v)?.into_py_any(py),
80
+ Attr::Int16List(v) => PyList::new(py, v)?.into_py_any(py),
81
+ Attr::Int32List(v) => PyList::new(py, v)?.into_py_any(py),
82
+ Attr::Int64List(v) => PyList::new(py, v)?.into_py_any(py),
83
+ Attr::UInt8List(v) => PyList::new(py, v)?.into_py_any(py),
84
+ Attr::UInt16List(v) => PyList::new(py, v)?.into_py_any(py),
85
+ Attr::UInt32List(v) => PyList::new(py, v)?.into_py_any(py),
86
+ Attr::UInt64List(v) => PyList::new(py, v)?.into_py_any(py),
87
+ Attr::Float32List(v) => PyList::new(py, v)?.into_py_any(py),
88
+ Attr::Float64List(v) => PyList::new(py, v)?.into_py_any(py),
89
+ Attr::StringList(v) => PyList::new(py, v)?.into_py_any(py),
90
+ Attr::BinaryList(v) => {
91
+ let items: Vec<Bound<'_, PyBytes>> = v.iter().map(|b| PyBytes::new(py, b)).collect();
92
+ PyList::new(py, items)?.into_py_any(py)
93
+ }
94
+ }
95
+ }
@@ -71,10 +71,13 @@ impl PyDatasetView {
71
71
  .collect()
72
72
  }
73
73
 
74
- /// Returns a dict of attribute name -> Python value.
74
+ /// Returns a dict of dataset-level (global) attribute name -> Python value.
75
75
  fn attributes<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
76
+ let attrs = py
77
+ .detach(|| runtime().block_on(self.inner.attributes()))
78
+ .map_err(to_py_err)?;
76
79
  let dict = PyDict::new(py);
77
- for (k, v) in &self.inner.meta().attributes {
80
+ for (k, v) in &attrs {
78
81
  dict.set_item(k, attr_to_py(py, v)?)?;
79
82
  }
80
83
  Ok(dict)
@@ -88,15 +91,58 @@ impl PyDatasetView {
88
91
  dtype: Option<&str>,
89
92
  ) -> PyResult<()> {
90
93
  let attr = py_to_attr(value, dtype)?;
91
- self.inner.set_attribute(key, attr);
92
- Ok(())
94
+ self.inner.set_attribute(key, attr).map_err(to_py_err)
93
95
  }
94
96
 
95
97
  fn get_attribute(&self, py: Python<'_>, key: &str) -> PyResult<Option<Py<PyAny>>> {
98
+ let attr = py
99
+ .detach(|| runtime().block_on(self.inner.get_attribute(key)))
100
+ .map_err(to_py_err)?;
101
+ attr.map(|attr| attr_to_py(py, &attr)).transpose()
102
+ }
103
+
104
+ /// Set a per-variable (per-array) attribute, e.g. `units` on `temperature`.
105
+ #[pyo3(signature = (array, key, value, dtype=None))]
106
+ fn set_array_attribute(
107
+ &mut self,
108
+ array: &str,
109
+ key: &str,
110
+ value: &Bound<'_, PyAny>,
111
+ dtype: Option<&str>,
112
+ ) -> PyResult<()> {
113
+ let attr = py_to_attr(value, dtype)?;
96
114
  self.inner
97
- .get_attribute(key)
98
- .map(|attr| attr_to_py(py, &attr))
99
- .transpose()
115
+ .set_array_attribute(array, key, attr)
116
+ .map_err(to_py_err)
117
+ }
118
+
119
+ /// Get a per-variable attribute on `array`, or `None` if unset.
120
+ fn get_array_attribute(
121
+ &self,
122
+ py: Python<'_>,
123
+ array: &str,
124
+ key: &str,
125
+ ) -> PyResult<Option<Py<PyAny>>> {
126
+ let attr = py
127
+ .detach(|| runtime().block_on(self.inner.get_array_attribute(array, key)))
128
+ .map_err(to_py_err)?;
129
+ attr.map(|attr| attr_to_py(py, &attr)).transpose()
130
+ }
131
+
132
+ /// Returns a dict of per-variable attribute name -> Python value for `array`.
133
+ fn array_attributes<'py>(
134
+ &self,
135
+ py: Python<'py>,
136
+ array: &str,
137
+ ) -> PyResult<Bound<'py, PyDict>> {
138
+ let attrs = py
139
+ .detach(|| runtime().block_on(self.inner.array_attributes(array)))
140
+ .map_err(to_py_err)?;
141
+ let dict = PyDict::new(py);
142
+ for (k, v) in &attrs {
143
+ dict.set_item(k, attr_to_py(py, v)?)?;
144
+ }
145
+ Ok(dict)
100
146
  }
101
147
 
102
148
  /// Returns `{"dtype", "shape", "chunk_shape", "dimension_names"}` for
@@ -21,6 +21,9 @@ pub fn to_py_err(err: atlas::Error) -> PyErr {
21
21
  PyValueError::new_err(format!("invalid name: {name}"))
22
22
  }
23
23
  atlas::Error::StoreNotFound => PyFileNotFoundError::new_err("store not found at path"),
24
+ e @ (atlas::Error::UnsupportedVersion { .. } | atlas::Error::TypeMismatch { .. }) => {
25
+ PyValueError::new_err(e.to_string())
26
+ }
24
27
  atlas::Error::Io(e) => PyOSError::new_err(e.to_string()),
25
28
  e @ (atlas::Error::ObjectStore(_)
26
29
  | atlas::Error::ArrayFormat(_)