acquisition-namespace 1.2.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.
@@ -0,0 +1,71 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ eggs/
12
+ parts/
13
+ var/
14
+ sdist/
15
+ develop-eggs/
16
+ .installed.cfg
17
+ lib/
18
+ lib64/
19
+ wheels/
20
+
21
+ # Virtual environments
22
+ .venv/
23
+ venv/
24
+ env/
25
+ ENV/
26
+ .python-version
27
+
28
+ # uv
29
+ uv.lock
30
+
31
+ # Distribution / packaging
32
+ MANIFEST
33
+
34
+ # Testing
35
+ .pytest_cache/
36
+ .coverage
37
+ .coverage.*
38
+ coverage.xml
39
+ htmlcov/
40
+ *.cover
41
+ .hypothesis/
42
+
43
+ # Type checking
44
+ .mypy_cache/
45
+ .dmypy.json
46
+ dmypy.json
47
+ .pytype/
48
+ .pyre/
49
+
50
+ # Build artifacts (hatch-vcs generated)
51
+ src/*/_version.py
52
+
53
+ # Jupyter
54
+ .ipynb_checkpoints
55
+ *.ipynb
56
+
57
+ # IDEs
58
+ .idea/
59
+ .vscode/
60
+ *.swp
61
+ *.swo
62
+ *~
63
+
64
+ # OS
65
+ .DS_Store
66
+ Thumbs.db
67
+
68
+ # Secrets / local config
69
+ .env
70
+ .env.*
71
+ !.env.example
@@ -0,0 +1,12 @@
1
+ Copyright (c) [[ year ]] [[ author_name ]]
2
+ All rights reserved.
3
+
4
+ No license is granted to use, copy, modify, merge, publish, distribute,
5
+ sublicense, or sell copies of this software or its documentation without
6
+ explicit written permission from the copyright holder.
7
+
8
+ Replace this file with your chosen open-source license before publishing.
9
+ Preferred default for research code: GNU GPL v3.0 (retains authorship rights,
10
+ requires attribution and source disclosure for derivatives).
11
+ Permissive alternatives: MIT or BSD-3-Clause (allow commercial use without
12
+ source disclosure — use only if explicitly intended).
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: acquisition-namespace
3
+ Version: 1.2.0
4
+ Summary: YAML-driven hierarchical path namespace builder for acquisition data pipelines.
5
+ Project-URL: Repository, https://github.com/murineshiftwork/acquisition-namespace
6
+ Project-URL: Issue Tracker, https://github.com/murineshiftwork/acquisition-namespace/issues
7
+ Author-email: "Lars B. Rollik" <L.B.Rollik@protonmail.com>
8
+ License: Copyright (c) [[ year ]] [[ author_name ]]
9
+ All rights reserved.
10
+
11
+ No license is granted to use, copy, modify, merge, publish, distribute,
12
+ sublicense, or sell copies of this software or its documentation without
13
+ explicit written permission from the copyright holder.
14
+
15
+ Replace this file with your chosen open-source license before publishing.
16
+ Preferred default for research code: GNU GPL v3.0 (retains authorship rights,
17
+ requires attribution and source disclosure for derivatives).
18
+ Permissive alternatives: MIT or BSD-3-Clause (allow commercial use without
19
+ source disclosure — use only if explicitly intended).
20
+ License-File: LICENSE
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: pydantic>=2
23
+ Requires-Dist: pyyaml
24
+ Provides-Extra: dev
25
+ Requires-Dist: commitizen; extra == 'dev'
26
+ Requires-Dist: mkdocs-material; extra == 'dev'
27
+ Requires-Dist: mypy; extra == 'dev'
28
+ Requires-Dist: pre-commit; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Acquisition Namespace
34
+
35
+ YAML-driven hierarchical path namespace builder for acquisition data pipelines.
36
+
37
+ Define your session directory layout once in a YAML spec; the library builds,
38
+ parses, and validates paths at every level of the hierarchy — with zero
39
+ hard-coded separators or string constants in your application code.
40
+
41
+ ## Installation
42
+
43
+ ```sh
44
+ pip install acquisition-namespace
45
+ ```
46
+
47
+ Or with uv:
48
+
49
+ ```sh
50
+ uv add acquisition-namespace
51
+ ```
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from acquisition_namespace import NamespaceBuilder
57
+
58
+ builder = NamespaceBuilder.from_yaml("my_namespace.yaml")
59
+
60
+ # Build the session basename from component values
61
+ name = builder.build_path("session", {
62
+ "subject": "mouse_01",
63
+ "datetime": "20260524_143022_123456",
64
+ "task": "sequence",
65
+ })
66
+ # → "mouse_01__20260524_143022_123456__sequence"
67
+
68
+ # Build the full directory path from root to the session level
69
+ path = builder.generate_path("session", {...})
70
+ # → "mouse_01/mouse_01__20260524_143022_123456__sequence"
71
+
72
+ # Parse an existing path back into its fields
73
+ parts = builder.extract_level_values("session", name)
74
+ # → {"subject": "mouse_01", "datetime": "...", "task": "sequence"}
75
+ ```
76
+
77
+ ### Spec YAML format
78
+
79
+ ```yaml
80
+ version: "1.0"
81
+ description: "My acquisition namespace."
82
+ hierarchy:
83
+ - subject
84
+ - session
85
+ - file
86
+ optional_levels: []
87
+ levels:
88
+ subject:
89
+ template: "{subject}"
90
+ regex: "(?P<subject>[\\w\\-]+)"
91
+ optional_fields: []
92
+ session:
93
+ template: "{subject}__{datetime}__{task}"
94
+ regex: "(?P<subject>[\\w\\-]+)__(?P<datetime>\\d{8}_\\d{6}(?:_\\d{6})?)__(?P<task>[\\w\\-]+)"
95
+ optional_fields: []
96
+ file:
97
+ template: "{session}.{suffix}.{extension}"
98
+ regex: "(?P<session>.+)\\.(?P<suffix>\\w+)\\.(?P<extension>\\w+)"
99
+ optional_fields: []
100
+ ```
101
+
102
+ Higher-level templates may reference lower-level names (e.g. `{session}` in
103
+ the `file` template); the builder resolves them automatically.
104
+
105
+ ## Development setup
106
+
107
+ ```sh
108
+ git clone https://github.com/murineshiftwork/acquisition-namespace.git
109
+ cd acquisition-namespace
110
+ uv sync --group dev
111
+ uv run pre-commit install --hook-type pre-commit --hook-type commit-msg
112
+ uv run pytest
113
+ ```
114
+
115
+ ## Release workflow
116
+
117
+ 1. Work on a `feature/` or `fix/` branch, committing with `cz commit`
118
+ 2. Open a PR — CI (lint + tests + secrets scan) must pass before merge
119
+ 3. Merge to main → version bump and tag are created automatically
120
+ 4. Tag triggers release: GitHub release + PyPI publish
121
+
122
+ ## License
123
+
124
+ See [LICENSE](LICENSE).
@@ -0,0 +1,92 @@
1
+ # Acquisition Namespace
2
+
3
+ YAML-driven hierarchical path namespace builder for acquisition data pipelines.
4
+
5
+ Define your session directory layout once in a YAML spec; the library builds,
6
+ parses, and validates paths at every level of the hierarchy — with zero
7
+ hard-coded separators or string constants in your application code.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ pip install acquisition-namespace
13
+ ```
14
+
15
+ Or with uv:
16
+
17
+ ```sh
18
+ uv add acquisition-namespace
19
+ ```
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ from acquisition_namespace import NamespaceBuilder
25
+
26
+ builder = NamespaceBuilder.from_yaml("my_namespace.yaml")
27
+
28
+ # Build the session basename from component values
29
+ name = builder.build_path("session", {
30
+ "subject": "mouse_01",
31
+ "datetime": "20260524_143022_123456",
32
+ "task": "sequence",
33
+ })
34
+ # → "mouse_01__20260524_143022_123456__sequence"
35
+
36
+ # Build the full directory path from root to the session level
37
+ path = builder.generate_path("session", {...})
38
+ # → "mouse_01/mouse_01__20260524_143022_123456__sequence"
39
+
40
+ # Parse an existing path back into its fields
41
+ parts = builder.extract_level_values("session", name)
42
+ # → {"subject": "mouse_01", "datetime": "...", "task": "sequence"}
43
+ ```
44
+
45
+ ### Spec YAML format
46
+
47
+ ```yaml
48
+ version: "1.0"
49
+ description: "My acquisition namespace."
50
+ hierarchy:
51
+ - subject
52
+ - session
53
+ - file
54
+ optional_levels: []
55
+ levels:
56
+ subject:
57
+ template: "{subject}"
58
+ regex: "(?P<subject>[\\w\\-]+)"
59
+ optional_fields: []
60
+ session:
61
+ template: "{subject}__{datetime}__{task}"
62
+ regex: "(?P<subject>[\\w\\-]+)__(?P<datetime>\\d{8}_\\d{6}(?:_\\d{6})?)__(?P<task>[\\w\\-]+)"
63
+ optional_fields: []
64
+ file:
65
+ template: "{session}.{suffix}.{extension}"
66
+ regex: "(?P<session>.+)\\.(?P<suffix>\\w+)\\.(?P<extension>\\w+)"
67
+ optional_fields: []
68
+ ```
69
+
70
+ Higher-level templates may reference lower-level names (e.g. `{session}` in
71
+ the `file` template); the builder resolves them automatically.
72
+
73
+ ## Development setup
74
+
75
+ ```sh
76
+ git clone https://github.com/murineshiftwork/acquisition-namespace.git
77
+ cd acquisition-namespace
78
+ uv sync --group dev
79
+ uv run pre-commit install --hook-type pre-commit --hook-type commit-msg
80
+ uv run pytest
81
+ ```
82
+
83
+ ## Release workflow
84
+
85
+ 1. Work on a `feature/` or `fix/` branch, committing with `cz commit`
86
+ 2. Open a PR — CI (lint + tests + secrets scan) must pass before merge
87
+ 3. Merge to main → version bump and tag are created automatically
88
+ 4. Tag triggers release: GitHub release + PyPI publish
89
+
90
+ ## License
91
+
92
+ See [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ 1.2.0
@@ -0,0 +1,115 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "acquisition-namespace"
7
+ dynamic = ["version"]
8
+ description = "YAML-driven hierarchical path namespace builder for acquisition data pipelines."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ license = { file = "LICENSE" }
11
+ authors = [
12
+ { name = "Lars B. Rollik", email = "L.B.Rollik@protonmail.com" },
13
+ ]
14
+ requires-python = ">=3.11"
15
+ dependencies = [
16
+ "pydantic>=2",
17
+ "pyyaml",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/murineshiftwork/acquisition-namespace"
22
+ "Issue Tracker" = "https://github.com/murineshiftwork/acquisition-namespace/issues"
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Build
26
+
27
+ [tool.hatch.version]
28
+ source = "vcs"
29
+ fallback-version = "1.0.0"
30
+
31
+ [tool.hatch.build.hooks.vcs]
32
+ version-file = "src/acquisition_namespace/_version.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/acquisition_namespace"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/pyproject.toml", "/VERSION"]
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Dependencies
42
+
43
+ [project.optional-dependencies]
44
+ dev = [
45
+ "commitizen",
46
+ "pytest>=8",
47
+ "pytest-cov",
48
+ "pre-commit",
49
+ "mypy",
50
+ "mkdocs-material",
51
+ ]
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Commitizen
55
+
56
+ [tool.commitizen]
57
+ name = "cz_conventional_commits"
58
+ version_provider = "commitizen"
59
+ version = "1.2.0"
60
+ version_files = ["VERSION"]
61
+ tag_format = "v$version"
62
+ update_changelog_on_bump = false
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Pytest
66
+
67
+ [tool.pytest.ini_options]
68
+ testpaths = ["tests"]
69
+ addopts = "--cov=src/acquisition_namespace --cov-report=term-missing --maxfail=5"
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Ruff
73
+
74
+ [tool.ruff]
75
+ line-length = 88
76
+ indent-width = 4
77
+ src = ["src"]
78
+
79
+ [tool.ruff.lint]
80
+ select = [
81
+ "E", "W", # pycodestyle
82
+ "F", # pyflakes
83
+ "I", # isort
84
+ "UP", # pyupgrade
85
+ "B", # flake8-bugbear
86
+ "SIM", # flake8-simplify
87
+ "PTH", # flake8-pathlib
88
+ "TCH", # flake8-type-checking
89
+ "PYI", # flake8-pyi
90
+ "YTT", # flake8-2020
91
+ "N", # pep8-naming
92
+ ]
93
+ ignore = ["E501"]
94
+ fixable = ["ALL"]
95
+
96
+ [tool.ruff.format]
97
+ quote-style = "double"
98
+ indent-style = "space"
99
+ docstring-code-format = true
100
+
101
+ [tool.ruff.lint.pydocstyle]
102
+ convention = "numpy"
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Mypy
106
+
107
+ [tool.mypy]
108
+ python_version = "3.11"
109
+ warn_unused_ignores = true
110
+ ignore_missing_imports = true
111
+
112
+ [[tool.mypy.overrides]]
113
+ module = "yaml"
114
+ ignore_missing_imports = true
115
+ ignore_errors = true
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from acquisition_namespace.spec import (
6
+ NamespaceBuilder,
7
+ NamespaceLevelSpec,
8
+ NamespaceSpec,
9
+ )
10
+
11
+ try:
12
+ __version__ = version("acquisition_namespace")
13
+ except PackageNotFoundError: # pragma: no cover
14
+ __version__ = "unknown"
15
+
16
+ __all__ = [
17
+ "NamespaceBuilder",
18
+ "NamespaceLevelSpec",
19
+ "NamespaceSpec",
20
+ ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,299 @@
1
+ """Namespace spec: Pydantic models + YAML-backed NamespaceBuilder.
2
+
3
+ Load a spec file and build / validate hierarchical acquisition paths:
4
+
5
+ builder = NamespaceBuilder.from_yaml("my_namespace.yaml")
6
+ basename = builder.build_path("session", {"subject": "mouse_01", ...})
7
+ parts = builder.extract_level_values("session", basename)
8
+
9
+ The spec YAML defines a hierarchy of levels, each with a ``template``
10
+ (Python format-string) and a ``regex`` (named capture groups). Higher
11
+ levels may reference lower-level names in their template; the builder
12
+ resolves them automatically.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import re
20
+ import string
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+ from pydantic import BaseModel, field_validator
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Pydantic models
29
+
30
+
31
+ class NamespaceLevelSpec(BaseModel):
32
+ template: str
33
+ regex: str
34
+ optional_fields: list[str] = []
35
+
36
+ @field_validator("regex")
37
+ @classmethod
38
+ def _check_regex(cls, v: str) -> str:
39
+ try:
40
+ re.compile(v)
41
+ except re.error as exc:
42
+ raise ValueError(f"Invalid regex {v!r}: {exc}") from exc
43
+ return v
44
+
45
+
46
+ class NamespaceSpec(BaseModel):
47
+ version: str
48
+ description: str = ""
49
+ hierarchy: list[str]
50
+ optional_levels: list[str] = []
51
+ levels: dict[str, NamespaceLevelSpec]
52
+
53
+ @field_validator("levels")
54
+ @classmethod
55
+ def _all_hierarchy_levels_present(cls, v: dict, info: Any) -> dict:
56
+ if "hierarchy" in (info.data or {}):
57
+ missing = [h for h in info.data["hierarchy"] if h not in v]
58
+ if missing:
59
+ raise ValueError(
60
+ f"Hierarchy level(s) {missing} have no entry in 'levels'"
61
+ )
62
+ return v
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Helper
67
+
68
+
69
+ def _template_fields(template: str) -> list[str]:
70
+ return [t[1] for t in string.Formatter().parse(template) if t[1]]
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # NamespaceBuilder
75
+
76
+
77
+ class NamespaceBuilder:
78
+ """Build and validate hierarchical acquisition paths from a YAML spec.
79
+
80
+ Each level in the hierarchy has a template (for construction) and a regex
81
+ (for parsing/validation). Higher levels may reference lower-level names
82
+ in their template; the builder resolves them recursively.
83
+
84
+ Typical usage::
85
+
86
+ b = NamespaceBuilder.from_yaml("my_namespace.yaml")
87
+
88
+ # Build a path segment for a given level
89
+ name = b.build_path("session", {"subject": "m01", "datetime": "20260101"})
90
+
91
+ # Build the full directory path from root to a level
92
+ path = b.generate_path("session", values)
93
+
94
+ # Parse an existing path back into its component values
95
+ parts = b.validate_path(path, stop_at="session")
96
+
97
+ # Extract values from a single level's string
98
+ parts = b.extract_level_values("session", name)
99
+ """
100
+
101
+ def __init__(self, spec: NamespaceSpec) -> None:
102
+ self.spec = spec
103
+ self.hierarchy: list[str] = spec.hierarchy
104
+ self.optional_levels: list[str] = spec.optional_levels
105
+ self._compiled: dict[str, re.Pattern] = {
106
+ name: re.compile(level.regex) for name, level in spec.levels.items()
107
+ }
108
+
109
+ # ------------------------------------------------------------------
110
+ # Construction
111
+
112
+ @classmethod
113
+ def from_yaml(cls, config_path: str | Path) -> NamespaceBuilder:
114
+ """Load a :class:`NamespaceSpec` from *config_path* and return a builder."""
115
+ path = Path(config_path)
116
+ with path.open() as f:
117
+ data = yaml.safe_load(f)
118
+ spec = NamespaceSpec.model_validate(data)
119
+ logging.debug("Loaded NamespaceSpec v%s from %s", spec.version, path)
120
+ return cls(spec)
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: dict) -> NamespaceBuilder:
124
+ """Build from a plain dict (e.g. after :meth:`to_dict`)."""
125
+ return cls(NamespaceSpec.model_validate(data))
126
+
127
+ # ------------------------------------------------------------------
128
+ # Serialisation
129
+
130
+ def to_dict(self) -> dict[str, Any]:
131
+ return self.spec.model_dump()
132
+
133
+ def __str__(self) -> str:
134
+ return f"NamespaceBuilder({json.dumps(self.to_dict())})"
135
+
136
+ def __repr__(self) -> str:
137
+ return f"NamespaceBuilder({self.to_dict()})"
138
+
139
+ def write_yaml(self, path: str | Path) -> None:
140
+ """Serialise the spec back to a YAML file."""
141
+ with Path(path).open("w") as f:
142
+ yaml.dump(
143
+ self.spec.model_dump(),
144
+ f,
145
+ default_flow_style=False,
146
+ allow_unicode=True,
147
+ sort_keys=False,
148
+ )
149
+ logging.info("NamespaceSpec written to %s", path)
150
+
151
+ # ------------------------------------------------------------------
152
+ # Path building
153
+
154
+ def _build_one(
155
+ self, level_name: str, values: dict[str, str], parts: dict[str, str]
156
+ ) -> str:
157
+ if level_name in parts:
158
+ return parts[level_name]
159
+ level = self.spec.levels[level_name]
160
+ fields = _template_fields(level.template)
161
+ for field in fields:
162
+ if field in self.hierarchy and field not in parts and field != level_name:
163
+ parts[field] = self._build_one(field, values, parts)
164
+ elif field not in values and field not in parts:
165
+ raise ValueError(
166
+ f"Missing value for field '{field}' in level '{level_name}'"
167
+ )
168
+ fmt = {k: parts.get(k, values.get(k, "")) for k in fields}
169
+ result = level.template.format(**fmt)
170
+ parts[level_name] = result
171
+ return result
172
+
173
+ def build_path(self, level: str, values: dict[str, str]) -> str:
174
+ """Return the path segment string for *level* constructed from *values*.
175
+
176
+ Parent levels referenced in the template are resolved automatically.
177
+ """
178
+ if level not in self.spec.levels:
179
+ raise ValueError(f"Unknown level: {level!r}")
180
+ return self._build_one(level, values, {})
181
+
182
+ def generate_path(
183
+ self,
184
+ level: str,
185
+ values: dict[str, str],
186
+ include_optional_levels: bool = True,
187
+ level_overrides: dict[str, str] | None = None,
188
+ ) -> str:
189
+ """Return the full filesystem path from root up to (and including) *level*.
190
+
191
+ Joins each hierarchy level with :func:`pathlib.Path` so the result
192
+ uses the platform separator.
193
+
194
+ Parameters
195
+ ----------
196
+ level_overrides:
197
+ Pre-built segment strings keyed by level name. When a level
198
+ appears here its value is used verbatim instead of being
199
+ constructed from *values*. The segment is still recorded in the
200
+ internal parts dict so higher levels can reference it in their
201
+ templates. Use this when a level's basename comes from an
202
+ external system (e.g. an OE acquisition name) and cannot be
203
+ reconstructed from the current session's values.
204
+ """
205
+ if level not in self.hierarchy:
206
+ raise ValueError(f"Unknown level: {level!r}")
207
+ overrides = level_overrides or {}
208
+ parts: dict[str, str] = {}
209
+ segments: list[str] = []
210
+ for name in self.hierarchy:
211
+ if name in self.optional_levels and not include_optional_levels:
212
+ continue
213
+ if name in overrides:
214
+ segment = overrides[name]
215
+ parts[name] = segment
216
+ else:
217
+ segment = self._build_one(name, values, parts)
218
+ segments.append(segment)
219
+ if name == level:
220
+ break
221
+ return str(Path(*segments))
222
+
223
+ # ------------------------------------------------------------------
224
+ # Parsing / validation
225
+
226
+ def _match_level(
227
+ self, level_name: str, segment: str, known_values: dict[str, str]
228
+ ) -> dict[str, str]:
229
+ level = self.spec.levels[level_name]
230
+ pattern = level.regex
231
+ for k, v in known_values.items():
232
+ if v is not None:
233
+ pattern = pattern.replace("{" + k + "}", re.escape(str(v)))
234
+ m = re.match(pattern, segment.strip())
235
+ if not m:
236
+ raise ValueError(
237
+ f"Segment {segment!r} did not match regex for level {level_name!r}"
238
+ )
239
+ return m.groupdict()
240
+
241
+ def validate_path_level(
242
+ self, level: str, segment: str, known_values: dict[str, str]
243
+ ) -> dict[str, str]:
244
+ """Match *segment* against the regex for *level*, return captured groups."""
245
+ return self._match_level(level, segment, known_values)
246
+
247
+ def validate_path(
248
+ self, path: str | Path, stop_at: str | None = None
249
+ ) -> dict[str, str]:
250
+ """Walk *path* level by level and return all captured values.
251
+
252
+ Parameters
253
+ ----------
254
+ path:
255
+ Filesystem path to validate (may be absolute or relative).
256
+ stop_at:
257
+ Stop after matching this hierarchy level. If ``None``, walks
258
+ the entire hierarchy.
259
+
260
+ Raises
261
+ ------
262
+ ValueError
263
+ If any segment does not match the expected regex.
264
+ """
265
+ if stop_at and stop_at not in self.hierarchy:
266
+ raise ValueError(f"stop_at level {stop_at!r} is not in hierarchy")
267
+ max_depth = (
268
+ self.hierarchy.index(stop_at) + 1 if stop_at else len(self.hierarchy)
269
+ )
270
+ segments = Path(path).parts
271
+ result: dict[str, str] = {}
272
+ for i, (segment, level_name) in enumerate(
273
+ zip(segments, self.hierarchy, strict=False)
274
+ ):
275
+ if i >= max_depth:
276
+ break
277
+ result.update(self._match_level(level_name, segment, result))
278
+ if level_name == stop_at:
279
+ break
280
+ return result
281
+
282
+ def extract_level_values(self, level: str, name: str) -> dict[str, str]:
283
+ """Parse *name* as a *level* segment and return template-field values.
284
+
285
+ Unlike :meth:`validate_path` (which walks a directory path), this
286
+ matches a single string against a single level's regex.
287
+
288
+ Raises
289
+ ------
290
+ ValueError
291
+ If *level* is not in the hierarchy, or *name* does not match.
292
+ """
293
+ if level not in self.hierarchy:
294
+ raise ValueError(f"Unknown level: {level!r}")
295
+ match = self._compiled[level].match(name.strip())
296
+ if not match:
297
+ raise ValueError(f"Name {name!r} does not match regex for level {level!r}")
298
+ fields = _template_fields(self.spec.levels[level].template)
299
+ return {f: match.groupdict().get(f, "") for f in fields}
File without changes
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,21 @@
1
+ version: "1.0"
2
+ description: "Simplified namespace: subject > session > file; no acquisition level."
3
+ hierarchy:
4
+ - subject
5
+ - session
6
+ - file
7
+ optional_levels: []
8
+ levels:
9
+ subject:
10
+ template: "{subject_prefix}{subject_id}_{exp_short_name}_m{mouse_id}_{ear}"
11
+ regex: "(?P<subject_prefix>[a-zA-Z])(?P<subject_id>\\d{3})_(?P<exp_short_name>\\w+)_m(?P<mouse_id>\\d+)_(?:-(?P<ear>[a-z]))?"
12
+ optional_fields:
13
+ - ear
14
+ session:
15
+ template: "{subject}__{paradigm}"
16
+ regex: "(?P<subject>.+)__(?P<paradigm>\\w+)"
17
+ optional_fields: []
18
+ file:
19
+ template: "{session}.{suffix}.{extension}"
20
+ regex: "(?P<session>.+)\\.(?P<suffix>\\w+)\\.(?P<extension>\\w+)"
21
+ optional_fields: []
@@ -0,0 +1,27 @@
1
+ version: "2.0"
2
+ description: "Namespace with subject > acquisition > session > file; acquisition is optional."
3
+ hierarchy:
4
+ - subject
5
+ - acquisition
6
+ - session
7
+ - file
8
+ optional_levels:
9
+ - acquisition
10
+ levels:
11
+ subject:
12
+ template: "{subject_prefix}{subject_id}_{exp_short_name}_m{mouse_id}_{ear}"
13
+ regex: "(?P<subject_prefix>[a-zA-Z])(?P<subject_id>\\d{3})_(?P<exp_short_name>\\w+)_m(?P<mouse_id>\\d+)_(?:-(?P<ear>[a-z]))?"
14
+ optional_fields:
15
+ - ear
16
+ acquisition:
17
+ template: "{subject}__{date}_{time}__{modality}"
18
+ regex: "(?P<subject>.+)__(?P<date>\\d{8})_(?P<time>\\d{6})__(?P<modality>\\w+)"
19
+ optional_fields: []
20
+ session:
21
+ template: "{acquisition}__{paradigm}"
22
+ regex: "(?P<acquisition>.+)__(?P<paradigm>\\w+)"
23
+ optional_fields: []
24
+ file:
25
+ template: "{session}.{suffix}.{extension}"
26
+ regex: "(?P<session>.+)\\.(?P<suffix>\\w+)\\.(?P<extension>\\w+)"
27
+ optional_fields: []
@@ -0,0 +1,26 @@
1
+ version: "3.0"
2
+ description: "Full namespace: subject > acquisition > session > file; no optional levels."
3
+ hierarchy:
4
+ - subject
5
+ - acquisition
6
+ - session
7
+ - file
8
+ optional_levels: []
9
+ levels:
10
+ subject:
11
+ template: "{subject_prefix}{subject_id}_{exp_short_name}_m{mouse_id}_{ear}"
12
+ regex: "(?P<subject_prefix>[a-zA-Z])(?P<subject_id>\\d{3})_(?P<exp_short_name>\\w+)_m(?P<mouse_id>\\d+)_(?:-(?P<ear>[a-z]))?"
13
+ optional_fields:
14
+ - ear
15
+ acquisition:
16
+ template: "{subject}__{date}_{time}__{modality}"
17
+ regex: "(?P<subject>.+)__(?P<date>\\d{8})_(?P<time>\\d{6})__(?P<modality>\\w+)"
18
+ optional_fields: []
19
+ session:
20
+ template: "{acquisition}__{paradigm}"
21
+ regex: "(?P<acquisition>.+)__(?P<paradigm>\\w+)"
22
+ optional_fields: []
23
+ file:
24
+ template: "{session}.{suffix}.{extension}"
25
+ regex: "(?P<session>.+)\\.(?P<suffix>\\w+)\\.(?P<extension>\\w+)"
26
+ optional_fields: []
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def test_placeholder() -> None:
5
+ """Placeholder integration test — replace with real integration tests."""
6
+ assert True
File without changes
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import acquisition_namespace
4
+
5
+
6
+ def test_version() -> None:
7
+ assert acquisition_namespace.__version__ is not None
8
+ assert isinstance(acquisition_namespace.__version__, str)
@@ -0,0 +1,487 @@
1
+ """Tests for NamespaceBuilder — spec loading, path building, parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from pydantic import ValidationError
10
+
11
+ from acquisition_namespace import NamespaceBuilder, NamespaceLevelSpec, NamespaceSpec
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Inline spec fixtures
15
+
16
+ _SIMPLE_SPEC = {
17
+ "version": "1.0",
18
+ "description": "Test spec",
19
+ "hierarchy": ["subject", "session"],
20
+ "optional_levels": [],
21
+ "levels": {
22
+ "subject": {
23
+ "template": "{subject}",
24
+ "regex": r"(?P<subject>[\w\-]+)",
25
+ "optional_fields": [],
26
+ },
27
+ "session": {
28
+ "template": "{subject}__{datetime}__{task}",
29
+ "regex": r"(?P<subject>[\w\-]+)__(?P<datetime>\d{8}_\d{6}(?:_\d{6})?)__(?P<task>[\w\-]+)",
30
+ "optional_fields": [],
31
+ },
32
+ },
33
+ }
34
+
35
+ _V3_SPEC = {
36
+ "version": "3.0",
37
+ "description": "Full hierarchy with file level",
38
+ "hierarchy": ["subject", "session", "file"],
39
+ "optional_levels": [],
40
+ "levels": {
41
+ "subject": {
42
+ "template": "{prefix}{id}_{exp}_m{mouse}",
43
+ "regex": r"(?P<prefix>[a-zA-Z])(?P<id>\d{3})_(?P<exp>\w+)_m(?P<mouse>\d+)",
44
+ "optional_fields": [],
45
+ },
46
+ "session": {
47
+ "template": "{subject}__{date}_{time}__{modality}",
48
+ "regex": r"(?P<subject>.+)__(?P<date>\d{8})_(?P<time>\d{6})__(?P<modality>\w+)",
49
+ "optional_fields": [],
50
+ },
51
+ "file": {
52
+ "template": "{session}.{suffix}.{extension}",
53
+ "regex": r"(?P<session>.+)\.(?P<suffix>\w+)\.(?P<extension>\w+)",
54
+ "optional_fields": [],
55
+ },
56
+ },
57
+ }
58
+
59
+ _V3_VALUES = {
60
+ "prefix": "s",
61
+ "id": "082",
62
+ "exp": "tabfixed",
63
+ "mouse": "1099615",
64
+ "date": "20240502",
65
+ "time": "131422",
66
+ "modality": "recording",
67
+ "suffix": "msw",
68
+ "extension": "pkl",
69
+ }
70
+
71
+ DATA_DIR = Path(__file__).parent.parent / "data"
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # NamespaceLevelSpec validation
76
+
77
+
78
+ def test_level_spec_invalid_regex_raises():
79
+ with pytest.raises(ValidationError):
80
+ NamespaceLevelSpec(template="{x}", regex="(?P<x>[")
81
+
82
+
83
+ def test_level_spec_stores_optional_fields():
84
+ spec = NamespaceLevelSpec(
85
+ template="{subject}__{tag}",
86
+ regex=r"(?P<subject>.+)(?:__(?P<tag>\w+))?",
87
+ optional_fields=["tag"],
88
+ )
89
+ assert "tag" in spec.optional_fields
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # NamespaceSpec validation
94
+
95
+
96
+ def test_spec_missing_hierarchy_level_raises():
97
+ with pytest.raises(ValidationError):
98
+ NamespaceSpec(
99
+ version="0",
100
+ hierarchy=["a", "b"],
101
+ levels={"a": NamespaceLevelSpec(template="{a}", regex=r"(?P<a>.+)")},
102
+ )
103
+
104
+
105
+ def test_spec_optional_levels_stored():
106
+ spec = NamespaceSpec(
107
+ version="1",
108
+ hierarchy=["a", "b"],
109
+ optional_levels=["b"],
110
+ levels={
111
+ "a": NamespaceLevelSpec(template="{a}", regex=r"(?P<a>\w+)"),
112
+ "b": NamespaceLevelSpec(
113
+ template="{a}__{b}", regex=r"(?P<a>.+)__(?P<b>\w+)"
114
+ ),
115
+ },
116
+ )
117
+ assert spec.optional_levels == ["b"]
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # NamespaceBuilder.from_dict
122
+
123
+
124
+ def test_from_dict_simple():
125
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
126
+ assert b.hierarchy == ["subject", "session"]
127
+ assert b.spec.version == "1.0"
128
+
129
+
130
+ def test_from_dict_v3():
131
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
132
+ assert b.hierarchy == ["subject", "session", "file"]
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # NamespaceBuilder.from_yaml / write_yaml
137
+
138
+
139
+ def test_from_yaml_write_and_reload(tmp_path):
140
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
141
+ out = tmp_path / "ns.yaml"
142
+ b.write_yaml(out)
143
+ b2 = NamespaceBuilder.from_yaml(out)
144
+ assert b2.hierarchy == b.hierarchy
145
+ assert b2.spec.version == b.spec.version
146
+
147
+
148
+ def test_yaml_roundtrip_preserves_build(tmp_path):
149
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
150
+ out = tmp_path / "ns.yaml"
151
+ b.write_yaml(out)
152
+ b2 = NamespaceBuilder.from_yaml(out)
153
+ values = {
154
+ "subject": "mouse_01",
155
+ "datetime": "20260524_143022_123456",
156
+ "task": "sequence",
157
+ }
158
+ assert b2.build_path("session", values) == b.build_path("session", values)
159
+
160
+
161
+ def test_write_yaml_preserves_optional_levels(tmp_path):
162
+ spec_dict = {
163
+ "version": "2.0",
164
+ "description": "",
165
+ "hierarchy": ["subject", "acquisition", "session"],
166
+ "optional_levels": ["acquisition"],
167
+ "levels": {
168
+ "subject": {
169
+ "template": "{subject}",
170
+ "regex": r"(?P<subject>\w+)",
171
+ "optional_fields": [],
172
+ },
173
+ "acquisition": {
174
+ "template": "{subject}__{date}",
175
+ "regex": r"(?P<subject>.+)__(?P<date>\d{8})",
176
+ "optional_fields": [],
177
+ },
178
+ "session": {
179
+ "template": "{acquisition}__{task}",
180
+ "regex": r"(?P<acquisition>.+)__(?P<task>\w+)",
181
+ "optional_fields": [],
182
+ },
183
+ },
184
+ }
185
+ b = NamespaceBuilder.from_dict(spec_dict)
186
+ out = tmp_path / "ns.yaml"
187
+ b.write_yaml(out)
188
+ b2 = NamespaceBuilder.from_yaml(out)
189
+ assert b2.optional_levels == ["acquisition"]
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Loading from tests/data real YAML
194
+
195
+
196
+ def test_from_yaml_v3_data_file():
197
+ b = NamespaceBuilder.from_yaml(DATA_DIR / "namespace.v3.yaml")
198
+ assert b.hierarchy == ["subject", "acquisition", "session", "file"]
199
+ assert b.spec.version == "3.0"
200
+
201
+
202
+ def test_v3_data_file_no_optional_levels():
203
+ b = NamespaceBuilder.from_yaml(DATA_DIR / "namespace.v3.yaml")
204
+ assert b.optional_levels == []
205
+
206
+
207
+ def test_v3_data_file_has_four_levels():
208
+ b = NamespaceBuilder.from_yaml(DATA_DIR / "namespace.v3.yaml")
209
+ assert set(b.spec.levels.keys()) == {"subject", "acquisition", "session", "file"}
210
+
211
+
212
+ def test_v3_data_file_subject_optional_fields():
213
+ b = NamespaceBuilder.from_yaml(DATA_DIR / "namespace.v3.yaml")
214
+ assert "ear" in b.spec.levels["subject"].optional_fields
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # build_path
219
+
220
+
221
+ def test_build_path_session():
222
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
223
+ result = b.build_path(
224
+ "session",
225
+ {
226
+ "subject": "mouse_01",
227
+ "datetime": "20260524_143022_123456",
228
+ "task": "sequence",
229
+ },
230
+ )
231
+ assert result == "mouse_01__20260524_143022_123456__sequence"
232
+
233
+
234
+ def test_build_path_subject():
235
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
236
+ result = b.build_path("subject", _V3_VALUES)
237
+ assert result == "s082_tabfixed_m1099615"
238
+
239
+
240
+ def test_build_path_session_v3():
241
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
242
+ result = b.build_path("session", _V3_VALUES)
243
+ assert result == "s082_tabfixed_m1099615__20240502_131422__recording"
244
+
245
+
246
+ def test_build_path_file_resolves_session_automatically():
247
+ """The file level references {session}; builder must resolve it from values."""
248
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
249
+ result = b.build_path("file", _V3_VALUES)
250
+ assert result == "s082_tabfixed_m1099615__20240502_131422__recording.msw.pkl"
251
+
252
+
253
+ def test_build_path_unknown_level_raises():
254
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
255
+ with pytest.raises(ValueError, match="Unknown level"):
256
+ b.build_path("bogus", {})
257
+
258
+
259
+ def test_build_path_missing_field_raises():
260
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
261
+ with pytest.raises(ValueError, match="Missing value"):
262
+ b.build_path("session", {"subject": "m01"}) # missing datetime and task
263
+
264
+
265
+ def test_build_path_idempotent():
266
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
267
+ vals = {
268
+ "subject": "mouse_01",
269
+ "datetime": "20260524_143022_123456",
270
+ "task": "sequence",
271
+ }
272
+ assert b.build_path("session", vals) == b.build_path("session", vals)
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # generate_path
277
+
278
+
279
+ def test_generate_path_to_session():
280
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
281
+ path = b.generate_path("session", _V3_VALUES)
282
+ parts = path.split("/")
283
+ assert len(parts) == 2
284
+ assert parts[0] == "s082_tabfixed_m1099615"
285
+ assert "20240502" in parts[1]
286
+
287
+
288
+ def test_generate_path_to_file():
289
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
290
+ path = b.generate_path("file", _V3_VALUES)
291
+ parts = path.split("/")
292
+ assert len(parts) == 3
293
+ assert parts[-1].endswith(".msw.pkl")
294
+
295
+
296
+ def test_generate_path_unknown_level_raises():
297
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
298
+ with pytest.raises(ValueError, match="Unknown level"):
299
+ b.generate_path("bogus", {})
300
+
301
+
302
+ def test_generate_path_to_subject_single_segment():
303
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
304
+ path = b.generate_path("subject", _V3_VALUES)
305
+ assert "/" not in path
306
+ assert path == "s082_tabfixed_m1099615"
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # extract_level_values
311
+
312
+
313
+ def test_extract_session_values():
314
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
315
+ vals = b.extract_level_values(
316
+ "session", "mouse_01__20260524_143022_123456__sequence"
317
+ )
318
+ assert vals["subject"] == "mouse_01"
319
+ assert vals["datetime"] == "20260524_143022_123456"
320
+ assert vals["task"] == "sequence"
321
+
322
+
323
+ def test_extract_legacy_datetime():
324
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
325
+ vals = b.extract_level_values("session", "mouse_01__20210718_152153__task")
326
+ assert vals["datetime"] == "20210718_152153"
327
+
328
+
329
+ def test_extract_unknown_level_raises():
330
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
331
+ with pytest.raises(ValueError, match="Unknown level"):
332
+ b.extract_level_values("bogus", "anything")
333
+
334
+
335
+ def test_extract_no_match_raises():
336
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
337
+ with pytest.raises(ValueError, match="does not match"):
338
+ b.extract_level_values("session", "this-does-not-match")
339
+
340
+
341
+ def test_extract_roundtrip_with_build():
342
+ """build_path → extract_level_values must recover the original values."""
343
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
344
+ vals = {
345
+ "subject": "mouse_01",
346
+ "datetime": "20260524_143022_123456",
347
+ "task": "sequence",
348
+ }
349
+ name = b.build_path("session", vals)
350
+ recovered = b.extract_level_values("session", name)
351
+ assert recovered["subject"] == vals["subject"]
352
+ assert recovered["datetime"] == vals["datetime"]
353
+ assert recovered["task"] == vals["task"]
354
+
355
+
356
+ def test_extract_file_values():
357
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
358
+ name = b.build_path("file", _V3_VALUES)
359
+ parts = b.extract_level_values("file", name)
360
+ assert parts["suffix"] == "msw"
361
+ assert parts["extension"] == "pkl"
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # validate_path
366
+
367
+
368
+ def test_validate_path_stop_at():
369
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
370
+ path = (
371
+ Path("s082_tabfixed_m1099615")
372
+ / "s082_tabfixed_m1099615__20240502_131422__recording"
373
+ / "s082_tabfixed_m1099615__20240502_131422__recording.msw.pkl"
374
+ )
375
+ result = b.validate_path(path, stop_at="session")
376
+ assert result["date"] == "20240502"
377
+ assert result["modality"] == "recording"
378
+
379
+
380
+ def test_validate_path_bad_stop_at_raises():
381
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
382
+ with pytest.raises(ValueError, match="not in hierarchy"):
383
+ b.validate_path("anything", stop_at="bogus")
384
+
385
+
386
+ def test_validate_path_full_hierarchy():
387
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
388
+ subject = b.build_path("subject", _V3_VALUES)
389
+ session = b.build_path("session", _V3_VALUES)
390
+ file = b.build_path("file", _V3_VALUES)
391
+ path = Path(subject) / session / file
392
+ result = b.validate_path(path)
393
+ assert result["prefix"] == "s"
394
+ assert result["date"] == "20240502"
395
+ assert result["suffix"] == "msw"
396
+
397
+
398
+ def test_validate_path_level_direct():
399
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
400
+ vals = b.validate_path_level(
401
+ "session",
402
+ "mouse_01__20260524_143022__task",
403
+ {},
404
+ )
405
+ assert vals["subject"] == "mouse_01"
406
+ assert vals["task"] == "task"
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # to_dict / from_dict round-trip
411
+
412
+
413
+ def test_to_dict_roundtrip():
414
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
415
+ b2 = NamespaceBuilder.from_dict(b.to_dict())
416
+ assert b2.hierarchy == b.hierarchy
417
+ assert b2.spec.version == b.spec.version
418
+
419
+
420
+ def test_to_dict_roundtrip_v3():
421
+ b = NamespaceBuilder.from_dict(_V3_SPEC)
422
+ b2 = NamespaceBuilder.from_dict(b.to_dict())
423
+ assert b2.hierarchy == b.hierarchy
424
+ assert b.build_path("file", _V3_VALUES) == b2.build_path("file", _V3_VALUES)
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # __repr__ / __str__
429
+
430
+
431
+ def test_repr_contains_hierarchy():
432
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
433
+ r = repr(b)
434
+ assert "hierarchy" in r
435
+
436
+
437
+ def test_str_is_valid_json():
438
+ b = NamespaceBuilder.from_dict(_SIMPLE_SPEC)
439
+ s = str(b)
440
+ # str() wraps the dict in NamespaceBuilder(...); extract JSON part
441
+ assert s.startswith("NamespaceBuilder(")
442
+ inner = s[len("NamespaceBuilder(") : -1]
443
+ parsed = json.loads(inner)
444
+ assert "hierarchy" in parsed
445
+
446
+
447
+ # ---------------------------------------------------------------------------
448
+ # Optional levels
449
+
450
+
451
+ def test_optional_level_skipped_in_generate_path():
452
+ spec = {
453
+ "version": "1.0",
454
+ "description": "",
455
+ "hierarchy": ["subject", "acquisition", "session"],
456
+ "optional_levels": ["acquisition"],
457
+ "levels": {
458
+ "subject": {
459
+ "template": "{subject}",
460
+ "regex": r"(?P<subject>[\w]+)",
461
+ "optional_fields": [],
462
+ },
463
+ "acquisition": {
464
+ "template": "{subject}__{date}",
465
+ "regex": r"(?P<subject>.+)__(?P<date>\d{8})",
466
+ "optional_fields": [],
467
+ },
468
+ "session": {
469
+ "template": "{acquisition}__{paradigm}",
470
+ "regex": r"(?P<acquisition>.+)__(?P<paradigm>\w+)",
471
+ "optional_fields": [],
472
+ },
473
+ },
474
+ }
475
+ b = NamespaceBuilder.from_dict(spec)
476
+ path_with = b.generate_path(
477
+ "session",
478
+ {"subject": "m01", "date": "20260101", "paradigm": "ps"},
479
+ include_optional_levels=True,
480
+ )
481
+ path_without = b.generate_path(
482
+ "session",
483
+ {"subject": "m01", "date": "20260101", "paradigm": "ps"},
484
+ include_optional_levels=False,
485
+ )
486
+ assert path_with.count("/") == 2 # subject / acquisition / session
487
+ assert path_without.count("/") == 1 # subject / session