astro-ndslice 0.2__tar.gz → 0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {astro-ndslice-0.2 → astro_ndslice-0.4}/.gitignore +8 -0
  2. astro_ndslice-0.4/CHANGELOG.md +55 -0
  3. astro_ndslice-0.4/PKG-INFO +134 -0
  4. astro_ndslice-0.4/README.md +72 -0
  5. astro_ndslice-0.4/RELEASING.md +94 -0
  6. astro_ndslice-0.4/astro_ndslice/__init__.py +28 -0
  7. astro_ndslice-0.4/astro_ndslice/lists.py +192 -0
  8. astro_ndslice-0.4/astro_ndslice/offset.py +560 -0
  9. astro_ndslice-0.4/astro_ndslice/slices.py +418 -0
  10. {astro-ndslice-0.2 → astro_ndslice-0.4}/astro_ndslice/tests/test_list.py +27 -5
  11. astro_ndslice-0.4/astro_ndslice/tests/test_offset.py +718 -0
  12. astro_ndslice-0.4/astro_ndslice/tests/test_slices.py +248 -0
  13. astro_ndslice-0.4/pyproject.toml +80 -0
  14. astro_ndslice-0.4/uv.lock +587 -0
  15. astro-ndslice-0.2/PKG-INFO +0 -69
  16. astro-ndslice-0.2/README.md +0 -14
  17. astro-ndslice-0.2/astro_ndslice/__init__.py +0 -3
  18. astro-ndslice-0.2/astro_ndslice/lists.py +0 -175
  19. astro-ndslice-0.2/astro_ndslice/offset.py +0 -414
  20. astro-ndslice-0.2/astro_ndslice/slices.py +0 -241
  21. astro-ndslice-0.2/astro_ndslice/tests/test_offset.py +0 -354
  22. astro-ndslice-0.2/astro_ndslice/tests/test_slices.py +0 -183
  23. astro-ndslice-0.2/astro_ndslice.egg-info/PKG-INFO +0 -69
  24. astro-ndslice-0.2/astro_ndslice.egg-info/SOURCES.txt +0 -20
  25. astro-ndslice-0.2/astro_ndslice.egg-info/dependency_links.txt +0 -1
  26. astro-ndslice-0.2/astro_ndslice.egg-info/not-zip-safe +0 -1
  27. astro-ndslice-0.2/astro_ndslice.egg-info/requires.txt +0 -1
  28. astro-ndslice-0.2/astro_ndslice.egg-info/top_level.txt +0 -1
  29. astro-ndslice-0.2/pyproject.toml +0 -5
  30. astro-ndslice-0.2/setup.cfg +0 -47
  31. astro-ndslice-0.2/setup.py +0 -4
  32. {astro-ndslice-0.2 → astro_ndslice-0.4}/LICENSE +0 -0
  33. {astro-ndslice-0.2 → astro_ndslice-0.4}/astro_ndslice/tests/__init__.py +0 -0
@@ -1,3 +1,11 @@
1
+ **/.DS_Store
2
+ .claude/
3
+ .cursor/
4
+ .kiro/
5
+ .handoff/
6
+ .ruff_cache/
7
+ .uv-cache/
8
+
1
9
  # Byte-compiled / optimized / DLL files
2
10
  __pycache__/
3
11
  *.py[cod]
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## v0.4 (2026-11-12)
4
+
5
+ ### Fixes
6
+ - **Image placement:** slice tuples work directly as NumPy indices. Canvas and
7
+ overlap sizes use the same rounded offsets, including half-pixel shifts.
8
+ - `offsets2slice(..., fits_convention=False)` returned lists of slices, causing
9
+ `array[indices[i]]` to raise `IndexError`. Now returns usable tuples.
10
+ - `offseted_shape(shapes=[(3,), (3,)], offsets=[(0,), (1.5,)])` returned `(4,)`.
11
+ Now returns `(5,)`, matching pixel placement.
12
+ - `offseted_shape(..., method="inner")` returned `(0,)` for touching images.
13
+ Now raises `ValueError` when there are no shared pixels.
14
+ - `offseted_shape(shapes=[(2.5,)], offsets=[(0,)])` silently rounded to `(2,)`.
15
+ Now raises `ValueError`.
16
+ - **FITS conversion:** reverse slices retain their selected pixels;
17
+ unrepresentable slices raise `ValueError`.
18
+ - `slice(None, None, -1)` became `[:1]`, selecting only the first pixel.
19
+ Now becomes `[:1:-1]`, preserving the full reversal.
20
+ - `slice(2, 2)` became `[3:2]`, selecting two pixels instead of none.
21
+ Now raises `ValueError` when converting to FITS.
22
+ - **WCS and headers:** coordinate arrays work. The default `ignore_ltm=False` applies
23
+ LTM scaling with a matrix solve; singular or invalid transforms raise
24
+ `ValueError`, including under `python -O`.
25
+ - `loc_target=np.array([2, 3])` raised an ambiguous-truth `ValueError`.
26
+ Now accepts coordinate arrays.
27
+ - `loc_target="center"` with unknown `WCS.pixel_shape` used zero dimensions.
28
+ Now raises `ValueError`.
29
+ - `LTV1=6, LTM1_1=2` returned `[6]` by default, ignoring scaling. Now returns
30
+ `[3.]`; `ignore_ltm=True` retains the raw value. Nonidentity LTM matrices
31
+ rejected by the earlier validation-only fix are now supported.
32
+ - **Input helpers:** `listify()` broadcasts multiple inputs consistently;
33
+ `slicefy()` accepts generators; `bezel2slice(None)` returns full slices.
34
+ - **Development:** explicit exports preserve public names. Development
35
+ dependencies include Astropy and isort.
36
+ - **Releases:** Hatchling builds explicit package contents. CI tests installed
37
+ distributions. A matching `v*` tag push publishes the tested files to PyPI
38
+ after version, tag, changelog, lint, and test checks pass, then creates the
39
+ GitHub release from this changelog.
40
+
41
+ ### Migration checklist
42
+
43
+ 1. **LTM:** add `ignore_ltm=True` to retain raw LTV differences. The new default
44
+ applies LTM correction and rejects invalid transforms.
45
+ 2. **Image indices:** replace whole tuples from `offsets2slice()`; their elements
46
+ are no longer mutable.
47
+ 3. **Geometry:** use matching shapes/offsets, nonnegative integer sizes, and finite
48
+ offsets. Empty overlap raises `ValueError`; half-pixel canvas sizes may change.
49
+ 4. **FITS slices:** keep negative bounds and empty slices in Python form with
50
+ `fits_convention=False`.
51
+ 5. **WCS centers:** set `WCS.pixel_shape`, pass coordinates, or use `"origin"`.
52
+
53
+ Also: `listify(..., scalar2list=False)` affects only single-input calls;
54
+ `slicefy()` rejects empty iterable rules.
55
+ Details: [offsets](astro_ndslice/offset.py), [slices](astro_ndslice/slices.py).
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: astro-ndslice
3
+ Version: 0.4
4
+ Summary: Convenience tools for slicing images & calculating offsets before combinations (focusing on converting astronomical FITS/IRAF and python conventions)
5
+ Project-URL: Source, https://github.com/ysBach/astro-ndslice
6
+ Project-URL: Tracker, https://github.com/ysBach/astro-ndslice/issues
7
+ Author-email: "Yoonsoo P. Bach" <ysbach20766@gmail.com>
8
+ License: BSD 3-Clause License
9
+
10
+ Copyright (c) 2023, Yoonsoo P. Bach
11
+
12
+ Redistribution and use in source and binary forms, with or without
13
+ modification, are permitted provided that the following conditions are met:
14
+
15
+ 1. Redistributions of source code must retain the above copyright notice, this
16
+ list of conditions and the following disclaimer.
17
+
18
+ 2. Redistributions in binary form must reproduce the above copyright notice,
19
+ this list of conditions and the following disclaimer in the documentation
20
+ and/or other materials provided with the distribution.
21
+
22
+ 3. Neither the name of the copyright holder nor the names of its
23
+ contributors may be used to endorse or promote products derived from
24
+ this software without specific prior written permission.
25
+
26
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
27
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
28
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
29
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
30
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
31
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
32
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
33
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
34
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
35
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36
+ License-File: LICENSE
37
+ Keywords: astronomy,data reduction,image analysis
38
+ Classifier: Development Status :: 4 - Beta
39
+ Classifier: Intended Audience :: Education
40
+ Classifier: Intended Audience :: Science/Research
41
+ Classifier: License :: OSI Approved :: BSD License
42
+ Classifier: Natural Language :: English
43
+ Classifier: Operating System :: OS Independent
44
+ Classifier: Programming Language :: Python :: 3.10
45
+ Classifier: Programming Language :: Python :: 3.11
46
+ Classifier: Programming Language :: Python :: 3.12
47
+ Classifier: Programming Language :: Python :: 3.13
48
+ Classifier: Programming Language :: Python :: 3.14
49
+ Classifier: Topic :: Education
50
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
51
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
52
+ Requires-Python: >=3.10
53
+ Requires-Dist: numpy>=1.23
54
+ Provides-Extra: dev
55
+ Requires-Dist: astropy>=5.3.3; extra == 'dev'
56
+ Requires-Dist: isort; extra == 'dev'
57
+ Requires-Dist: pytest; extra == 'dev'
58
+ Requires-Dist: ruff; extra == 'dev'
59
+ Provides-Extra: full
60
+ Requires-Dist: astropy>=5.3.3; extra == 'full'
61
+ Description-Content-Type: text/markdown
62
+
63
+ # astro-ndslice
64
+
65
+ [![DOI](https://zenodo.org/badge/601666411.svg)](https://zenodo.org/badge/latestdoi/601666411)
66
+
67
+ Slice NumPy arrays, convert FITS/IRAF sections, and align images by pixel offsets.
68
+
69
+ ## Install
70
+
71
+ ```bash
72
+ pip install astro-ndslice
73
+ ```
74
+
75
+ Python >= 3.10.
76
+
77
+ For astronomers who need WCS/header offset helpers:
78
+
79
+ ```bash
80
+ pip install "astro-ndslice[full]"
81
+ ```
82
+
83
+ ## Slice an image
84
+
85
+ ```python
86
+ import numpy as np
87
+ from astro_ndslice import slicefy
88
+
89
+ data = np.arange(100).reshape(10, 10)
90
+ cutout = data[slicefy("[2:5,3:7]")] # Same as data[2:7, 1:5].
91
+ trimmed = data[slicefy(1)] # Trim one pixel from each edge - bezeling.
92
+ ```
93
+
94
+ * **FITS sections** use **1-based, inclusive bounds in x, y order**.
95
+ * **NumPy** uses **0-based, exclusive stops in row, column order**.
96
+ * `slicefy()` assumes FITS strings
97
+ * `slice_from_string()` assumes Python strings unless you pass `fits_convention=True`.
98
+
99
+ ## Align images
100
+
101
+ ```python
102
+ import numpy as np
103
+ from astro_ndslice import offseted_shape, offsets2slice
104
+
105
+ images = [np.ones((3, 4)), np.full((2, 3), 2)]
106
+ shapes = [image.shape for image in images] # NumPy axis order.
107
+ offsets = [(0, 0), (2, 1)] # x, y offsets.
108
+ _, shape = offseted_shape(shapes, offsets)
109
+ indices = offsets2slice(shapes, offsets)
110
+ stack = np.full((len(images), *shape), np.nan)
111
+ for image, index in zip(images, indices):
112
+ stack[index] = image
113
+ ```
114
+
115
+ Both helpers round relative offsets to pixel positions. Use `method="inner"`
116
+ to find the region shared by every image; no shared pixels raises `ValueError`.
117
+
118
+ ## Function guide
119
+
120
+ | Task | Functions |
121
+ | --- | --- |
122
+ | Normalize scalars and iterables | `is_list_like`, `listify`, `ndfy` |
123
+ | Parse sections and trim edges | `slicefy`, `slice_from_string`, `slice_to_string`, `bezel2slice` |
124
+ | Place images and find overlap | `regularize_offsets`, `offseted_shape`, `offsets2slice` |
125
+ | Read WCS or physical offsets | `calc_offset_wcs`, `calc_offset_physical` (requires Astropy) |
126
+
127
+ `calc_offset_physical()` corrects LTV translation terms
128
+ for LTM scaling in shared physical-coordinate units. For example,
129
+ `LTV1=6, LTM1_1=2` gives `3` along x; `ignore_ltm=True` gives the raw value `6`.
130
+ This preserves the LTV sign; differing LTM matrices require resampling for
131
+ image alignment.
132
+
133
+ See [CHANGELOG.md](CHANGELOG.md) for return-type changes and stricter validation.
134
+ For version bumps and PyPI publishing, see [RELEASING.md](RELEASING.md).
@@ -0,0 +1,72 @@
1
+ # astro-ndslice
2
+
3
+ [![DOI](https://zenodo.org/badge/601666411.svg)](https://zenodo.org/badge/latestdoi/601666411)
4
+
5
+ Slice NumPy arrays, convert FITS/IRAF sections, and align images by pixel offsets.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install astro-ndslice
11
+ ```
12
+
13
+ Python >= 3.10.
14
+
15
+ For astronomers who need WCS/header offset helpers:
16
+
17
+ ```bash
18
+ pip install "astro-ndslice[full]"
19
+ ```
20
+
21
+ ## Slice an image
22
+
23
+ ```python
24
+ import numpy as np
25
+ from astro_ndslice import slicefy
26
+
27
+ data = np.arange(100).reshape(10, 10)
28
+ cutout = data[slicefy("[2:5,3:7]")] # Same as data[2:7, 1:5].
29
+ trimmed = data[slicefy(1)] # Trim one pixel from each edge - bezeling.
30
+ ```
31
+
32
+ * **FITS sections** use **1-based, inclusive bounds in x, y order**.
33
+ * **NumPy** uses **0-based, exclusive stops in row, column order**.
34
+ * `slicefy()` assumes FITS strings
35
+ * `slice_from_string()` assumes Python strings unless you pass `fits_convention=True`.
36
+
37
+ ## Align images
38
+
39
+ ```python
40
+ import numpy as np
41
+ from astro_ndslice import offseted_shape, offsets2slice
42
+
43
+ images = [np.ones((3, 4)), np.full((2, 3), 2)]
44
+ shapes = [image.shape for image in images] # NumPy axis order.
45
+ offsets = [(0, 0), (2, 1)] # x, y offsets.
46
+ _, shape = offseted_shape(shapes, offsets)
47
+ indices = offsets2slice(shapes, offsets)
48
+ stack = np.full((len(images), *shape), np.nan)
49
+ for image, index in zip(images, indices):
50
+ stack[index] = image
51
+ ```
52
+
53
+ Both helpers round relative offsets to pixel positions. Use `method="inner"`
54
+ to find the region shared by every image; no shared pixels raises `ValueError`.
55
+
56
+ ## Function guide
57
+
58
+ | Task | Functions |
59
+ | --- | --- |
60
+ | Normalize scalars and iterables | `is_list_like`, `listify`, `ndfy` |
61
+ | Parse sections and trim edges | `slicefy`, `slice_from_string`, `slice_to_string`, `bezel2slice` |
62
+ | Place images and find overlap | `regularize_offsets`, `offseted_shape`, `offsets2slice` |
63
+ | Read WCS or physical offsets | `calc_offset_wcs`, `calc_offset_physical` (requires Astropy) |
64
+
65
+ `calc_offset_physical()` corrects LTV translation terms
66
+ for LTM scaling in shared physical-coordinate units. For example,
67
+ `LTV1=6, LTM1_1=2` gives `3` along x; `ignore_ltm=True` gives the raw value `6`.
68
+ This preserves the LTV sign; differing LTM matrices require resampling for
69
+ image alignment.
70
+
71
+ See [CHANGELOG.md](CHANGELOG.md) for return-type changes and stricter validation.
72
+ For version bumps and PyPI publishing, see [RELEASING.md](RELEASING.md).
@@ -0,0 +1,94 @@
1
+ # Release astro-ndslice
2
+
3
+ **Push a matching `vX.Y.Z` tag to validate, publish to PyPI, and create the
4
+ GitHub release.** Manual workflow runs validate only and never upload.
5
+
6
+ | Event | Checks | Publication |
7
+ | --- | --- | --- |
8
+ | Push to `main` or pull request | CI and installed distributions | None |
9
+ | Manual **Release → Run workflow** | Same checks, plus dated release notes | None |
10
+ | Matching `v{version}` tag push | Same checks, plus tag match | PyPI, then GitHub release |
11
+
12
+ Do not create the GitHub release by hand. The workflow creates it after the
13
+ PyPI upload succeeds, with the matching `CHANGELOG.md` section as its notes.
14
+
15
+ ## One-time setup
16
+
17
+ 1. In GitHub **Settings → Environments**, create an environment named `pypi`.
18
+ Restrict its deployment to tags that match `v*`.
19
+ 2. In [PyPI Publishing settings](https://pypi.org/manage/project/astro-ndslice/settings/publishing/),
20
+ add (or edit) the trusted publisher:
21
+
22
+ | Setting | Value |
23
+ | --- | --- |
24
+ | Owner / repository | `ysBach` / `astro-ndslice` |
25
+ | Workflow | `release.yml` |
26
+ | Environment | `pypi` |
27
+
28
+ Only the upload job receives `id-token: write`; no API token is needed.
29
+ See [PyPI's setup guide](https://docs.pypi.org/trusted-publishers/adding-a-publisher/).
30
+
31
+ ## Each release
32
+
33
+ Use one Bash/zsh session in the repository root, with `uv` and authenticated `gh`.
34
+ Commit or merge the intended changes onto `main` first.
35
+
36
+ 1. **Set the version.** Replace `1.2.3` with the intended release version.
37
+
38
+ ```bash
39
+ release_version=1.2.3
40
+ git switch main &&
41
+ uv version "$release_version" --no-sync
42
+ ```
43
+
44
+ `pyproject.toml` is the version source; this also updates `uv.lock` without
45
+ syncing any environment. No separate Python version constant is maintained.
46
+
47
+ 2. **Date the release notes.** Rename `## Unreleased` in `CHANGELOG.md` to
48
+ `## v1.2.3 (YYYY-MM-DD)`, using the version above and actual release date.
49
+ Keep this release's bullets below it. Validate with the project `.venv`:
50
+
51
+ ```bash
52
+ uv sync --locked --python 3.13 &&
53
+ uv run --no-sync python .github/scripts/check_release.py \
54
+ --release --tag "v$release_version"
55
+ ```
56
+
57
+ 3. **Commit and push.**
58
+
59
+ ```bash
60
+ git add pyproject.toml uv.lock CHANGELOG.md &&
61
+ git commit -m "chore: prepare release $release_version" &&
62
+ git push origin main
63
+ ```
64
+
65
+ 4. **Wait for CI** on that commit in
66
+ [GitHub Actions](https://github.com/ysBach/astro-ndslice/actions).
67
+ Optional rehearsal: `gh workflow run release.yml --ref main`. It runs the
68
+ release checks but never uploads.
69
+
70
+ 5. **Tag that commit and push the tag.** The tag must be exactly
71
+ `v{version}`, for example `v1.2.3`. The older `v.0.2` style fails validation.
72
+
73
+ ```bash
74
+ git tag -a "v$release_version" -m "Release $release_version" &&
75
+ git push origin "v$release_version"
76
+ ```
77
+
78
+ Check the **Release** run in Actions, the version on
79
+ [PyPI](https://pypi.org/project/astro-ndslice/), and the new GitHub release.
80
+
81
+ ## Validation
82
+
83
+ - Package and lockfile versions must match. Releases also require a matching
84
+ `v{version}` tag and a dated, nonempty changelog entry.
85
+ - Hatchling builds one universal wheel and one sdist with `uv build --no-sources`.
86
+ - CI tests installed wheels on Python 3.10–3.14 and the sdist on Python 3.13,
87
+ outside the checkout. Astropy tests, docstrings, formatting, and lint must pass.
88
+ - Publishing uploads those tested artifacts, without rebuilding. The sdist
89
+ includes tests and public docs; local files and workflows are excluded.
90
+
91
+ If nothing uploaded, fix the cause and rerun failed jobs. `uv publish
92
+ --check-url` skips files that PyPI already has, so a rerun after a partial
93
+ upload sends only the missing files. Published files cannot be replaced;
94
+ source fixes need a new version and tag. Do not move a published tag.
@@ -0,0 +1,28 @@
1
+ from . import lists, offset, slices
2
+ from .lists import is_list_like, listify, ndfy
3
+ from .offset import (
4
+ calc_offset_physical,
5
+ calc_offset_wcs,
6
+ offseted_shape,
7
+ offsets2slice,
8
+ regularize_offsets,
9
+ )
10
+ from .slices import bezel2slice, slice_from_string, slice_to_string, slicefy
11
+
12
+ __all__ = [
13
+ "lists",
14
+ "offset",
15
+ "slices",
16
+ "is_list_like",
17
+ "listify",
18
+ "ndfy",
19
+ "regularize_offsets",
20
+ "offseted_shape",
21
+ "offsets2slice",
22
+ "calc_offset_wcs",
23
+ "calc_offset_physical",
24
+ "slice_from_string",
25
+ "slice_to_string",
26
+ "slicefy",
27
+ "bezel2slice",
28
+ ]
@@ -0,0 +1,192 @@
1
+ """Normalize scalars and iterables to lists."""
2
+
3
+ from collections import abc
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ __all__ = [
10
+ "is_list_like",
11
+ "listify",
12
+ "ndfy",
13
+ ]
14
+
15
+
16
+ def is_list_like(*objs, allow_sets: bool = True, func: Callable = all) -> bool:
17
+ """Check whether inputs are iterable, excluding strings and scalar arrays.
18
+
19
+ Parameters
20
+ ----------
21
+ *objs : object
22
+ Objects to check.
23
+ allow_sets : bool, optional
24
+ Count sets as list-like. Default: `True`.
25
+ func : callable, optional
26
+ Combine results with `all` (default), `any`, or another callable.
27
+
28
+ Returns
29
+ -------
30
+ bool or object
31
+ Result from `func`; `all` and `any` return a boolean. Strings, bytes,
32
+ and zero-dimensional arrays are not list-like. Other iterables,
33
+ including dictionaries, are.
34
+
35
+ Notes
36
+ -----
37
+ Adapted from pandas to accept multiple inputs and a combining function.
38
+ https://github.com/pandas-dev/pandas/blob/bdb00f2d5a12f813e93bc55cdcd56dcb1aae776e/pandas/_libs/lib.pyx#L1026
39
+
40
+ Timing on MBP 14" [2024, macOS 26.6, M4Pro(8P+4E/G20c/N16c/48G)]
41
+ (2026-09-07; CPython 3.13.11, NumPy 2.5.2)::
42
+
43
+ is_list_like("asdfaer.fits") 0.188 +/- 0.001 us
44
+
45
+ Mean +/- std. dev. per call (`timeit`, 7 runs, 2,000,000 loops each).
46
+ """
47
+
48
+ def _is_list_like(obj):
49
+ return (
50
+ isinstance(obj, abc.Iterable)
51
+ # we do not count strings/unicode/bytes as list-like
52
+ and not isinstance(obj, (str, bytes))
53
+ # exclude zero-dimensional numpy arrays, effectively scalars
54
+ and not (isinstance(obj, np.ndarray) and obj.ndim == 0)
55
+ # exclude sets if allow_sets is False
56
+ and not (allow_sets is False and isinstance(obj, abc.Set))
57
+ )
58
+
59
+ if func is all:
60
+ for obj in objs:
61
+ if not _is_list_like(obj):
62
+ return False
63
+ return True
64
+ if func is any:
65
+ for obj in objs:
66
+ if _is_list_like(obj):
67
+ return True
68
+ return False
69
+
70
+ return func(_is_list_like(obj) for obj in objs)
71
+
72
+
73
+ def listify(*objs: Any, scalar2list: bool = True, none2list: bool = False) -> Any:
74
+ """Convert inputs to lists of the same length.
75
+
76
+ Parameters
77
+ ----------
78
+ *objs : object
79
+ One or more inputs. Iterables become lists; strings count as scalars.
80
+ With multiple inputs, length-one lists and scalars are repeated to
81
+ match the longest input.
82
+ scalar2list : bool, optional
83
+ Wrap a single scalar in a list. Ignored for multiple inputs and
84
+ iterable inputs. Default: `True`.
85
+ none2list : bool, optional
86
+ Return ``[None]`` for a single `None`, instead of ``[]``.
87
+ With multiple inputs, `None` always becomes a length-one list before
88
+ broadcasting. Default: `False`.
89
+
90
+ Returns
91
+ -------
92
+ list or object
93
+ Normalized lists, or the original single scalar when `scalar2list`
94
+ is `False`.
95
+
96
+ Raises
97
+ ------
98
+ ValueError
99
+ If no inputs are given or input lengths cannot be broadcast.
100
+
101
+ Notes
102
+ -----
103
+ Timing on MBP 14" [2024, macOS 26.6, M4Pro(8P+4E/G20c/N16c/48G)]
104
+ (2026-09-07; CPython 3.13.11, NumPy 2.5.2)::
105
+
106
+ listify([12]) 0.331 +/- 0.003 us
107
+ listify("asdf") 0.249 +/- 0.001 us
108
+ listify("asdf", scalar2list=False) 0.238 +/- 0.001 us
109
+
110
+ Mean +/- std. dev. per call (`timeit`, 7 runs, 1,000,000 loops each).
111
+
112
+ Examples
113
+ --------
114
+ >>> listify([1, 2], "x", None)
115
+ [[1, 2], ['x', 'x'], [None, None]]
116
+ >>> listify(3, scalar2list=False)
117
+ 3
118
+ """
119
+
120
+ if len(objs) == 1:
121
+ obj = objs[0]
122
+ if obj is None:
123
+ return [None] if none2list else []
124
+ if is_list_like(obj):
125
+ return list(obj)
126
+ return [obj] if scalar2list else obj
127
+
128
+ objlists = [list(obj) if is_list_like(obj) else [obj] for obj in objs]
129
+ lengths = [len(obj) for obj in objlists]
130
+ length = max(lengths)
131
+ for objl in objlists:
132
+ if len(objl) not in [1, length]:
133
+ raise ValueError(f"Each input must be 1 or max(lengths)={length}.")
134
+
135
+ return [obj * length if len(obj) == 1 else obj for obj in objlists]
136
+
137
+
138
+ def ndfy(item, length: int | None = None, default: Any = None) -> list:
139
+ """Normalize a list, replace `None`, and repeat a single item as needed.
140
+
141
+ Parameters
142
+ ----------
143
+ item : object
144
+ Scalar or iterable to normalize.
145
+ length : int, optional
146
+ Target length; a single item repeats, other lengths must match.
147
+ `None` keeps the input length, or one for a scalar.
148
+ default : object, optional
149
+ Replacement for a `None` input or top-level element. Default: `None`.
150
+
151
+ Returns
152
+ -------
153
+ list
154
+ Normalized values. Nested inputs are preserved, not transposed.
155
+
156
+ Raises
157
+ ------
158
+ ValueError
159
+ If the input length is neither one nor the requested length.
160
+
161
+ Notes
162
+ -----
163
+ Repetition reuses references; nested mutable values are not copied.
164
+
165
+ Timing on MBP 14" [2024, macOS 26.6, M4Pro(8P+4E/G20c/N16c/48G)]
166
+ (2026-09-07; CPython 3.13.11, NumPy 2.4.6)::
167
+
168
+ ndfy(1, length=2) 0.356 +/- 0.004 us
169
+ ndfy([1, None], default=0) 0.426 +/- 0.010 us
170
+
171
+ Mean +/- std. dev. per call (`timeit`, 7 runs;
172
+ 1,000,000/500,000 loops in row order).
173
+
174
+ Examples
175
+ --------
176
+ >>> ndfy(None, length=2, default=0)
177
+ [0, 0]
178
+ >>> ndfy([[1, 2]], length=2)
179
+ [[1, 2], [1, 2]]
180
+ """
181
+ item = [default if i is None else i for i in listify(item, none2list=True)]
182
+ item_length = len(item)
183
+
184
+ if (length is None) or (item_length == length):
185
+ return item
186
+ elif item_length != 1:
187
+ raise ValueError(
188
+ f"`len(item)` must be 1 or `length`(={length}). Now it is {item_length}."
189
+ )
190
+
191
+ # Now, item_length == 1
192
+ return item * length