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.
- acquisition_namespace-1.2.0/.gitignore +71 -0
- acquisition_namespace-1.2.0/LICENSE +12 -0
- acquisition_namespace-1.2.0/PKG-INFO +124 -0
- acquisition_namespace-1.2.0/README.md +92 -0
- acquisition_namespace-1.2.0/VERSION +1 -0
- acquisition_namespace-1.2.0/pyproject.toml +115 -0
- acquisition_namespace-1.2.0/src/acquisition_namespace/__init__.py +20 -0
- acquisition_namespace-1.2.0/src/acquisition_namespace/_version.py +24 -0
- acquisition_namespace-1.2.0/src/acquisition_namespace/py.typed +0 -0
- acquisition_namespace-1.2.0/src/acquisition_namespace/spec.py +299 -0
- acquisition_namespace-1.2.0/tests/__init__.py +0 -0
- acquisition_namespace-1.2.0/tests/conftest.py +1 -0
- acquisition_namespace-1.2.0/tests/data/namespace.v1.yaml +21 -0
- acquisition_namespace-1.2.0/tests/data/namespace.v2.yaml +27 -0
- acquisition_namespace-1.2.0/tests/data/namespace.v3.yaml +26 -0
- acquisition_namespace-1.2.0/tests/integration/__init__.py +0 -0
- acquisition_namespace-1.2.0/tests/integration/test_placeholder.py +6 -0
- acquisition_namespace-1.2.0/tests/unit/__init__.py +0 -0
- acquisition_namespace-1.2.0/tests/unit/test_placeholder.py +8 -0
- acquisition_namespace-1.2.0/tests/unit/test_spec.py +487 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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: []
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|