Skip to content

Commit 8aba94a

Browse files
authored
Merge pull request #819 from effigies/enh/padded_runs
ENH: Retain zero-padding in run entities while preserving integer queries and comparisons
2 parents c36c2f5 + dd4e51a commit 8aba94a

File tree

7 files changed

+114
-12
lines changed

7 files changed

+114
-12
lines changed

bids/layout/config/bids.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
},
3535
{
3636
"name": "run",
37-
"pattern": "[_/\\\\]+run-0*(\\d+)",
37+
"pattern": "[_/\\\\]+run-(\\d+)",
3838
"dtype": "int"
3939
},
4040
{

bids/layout/layout.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import sqlalchemy as sa
1414
from sqlalchemy.orm import aliased
15+
from sqlalchemy.sql.expression import cast
1516
from bids_validator import BIDSValidator
1617

1718
from ..utils import listify, natural_sort
@@ -772,10 +773,11 @@ def _build_file_query(self, **kwargs):
772773
else:
773774
val_clause = tag_alias._value.op('REGEXP')(str(val))
774775
else:
775-
if isinstance(val, (list, tuple)):
776-
val_clause = tag_alias._value.in_(val)
776+
vals = listify(val)
777+
if isinstance(vals[0], int):
778+
val_clause = cast(tag_alias._value, sa.Integer).in_(vals)
777779
else:
778-
val_clause = tag_alias._value == val
780+
val_clause = tag_alias._value.in_(vals)
779781

780782
query = join_method(
781783
tag_alias,

bids/layout/models.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ..utils import listify
1919
from .writing import build_path, write_to_file
2020
from ..config import get_option
21-
from .utils import BIDSMetadata
21+
from .utils import BIDSMetadata, PaddedInt
2222

2323
Base = declarative_base()
2424

@@ -71,6 +71,9 @@ def _sanitize_init_args(self, kwargs):
7171

7272
return kwargs
7373

74+
def __repr__(self):
75+
return f"<LayoutInfo {self.root}>"
76+
7477

7578
class Config(Base):
7679
"""Container for BIDS configuration information.
@@ -162,6 +165,9 @@ def load(self, config, session=None):
162165

163166
return Config(session=session, **config)
164167

168+
def __repr__(self):
169+
return f"<Config {self.name}>"
170+
165171

166172
class BIDSFile(Base):
167173
"""Represents a single file or directory in a BIDS dataset.
@@ -531,12 +537,18 @@ def __init__(self, name, pattern=None, mandatory=False, directory=None,
531537

532538
self._init_on_load()
533539

540+
def __repr__(self):
541+
return f"<Entity {self.name} (pattern={self.pattern}, dtype={self.dtype})>"
542+
534543
@reconstructor
535544
def _init_on_load(self):
536545
if self._dtype not in ('str', 'float', 'int', 'bool'):
537546
raise ValueError("Invalid dtype '{}'. Must be one of 'int', "
538547
"'float', 'bool', or 'str'.".format(self._dtype))
539-
self.dtype = eval(self._dtype)
548+
if self._dtype == "int":
549+
self.dtype = PaddedInt
550+
else:
551+
self.dtype = eval(self._dtype)
540552
self.regex = re.compile(self.pattern) if self.pattern is not None else None
541553

542554
def __iter__(self):
@@ -684,6 +696,9 @@ def _init_on_load(self):
684696
if self._dtype == 'json':
685697
self.value = json.loads(self._value)
686698
self.dtype = 'json'
699+
elif self._dtype == 'int':
700+
self.dtype = PaddedInt
701+
self.value = self.dtype(self._value)
687702
else:
688703
self.dtype = eval(self._dtype)
689704
self.value = self.dtype(self._value)

bids/layout/tests/test_layout.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from bids.layout import BIDSLayout, Query
1515
from bids.layout.models import Config
1616
from bids.layout.index import BIDSLayoutIndexer
17+
from bids.layout.utils import PaddedInt
1718
from bids.tests import get_test_data_path
1819
from bids.utils import natural_sort
1920

@@ -711,7 +712,7 @@ def test_get_with_wrong_dtypes(layout_7t_trt):
711712
''' Test automatic dtype sanitization. '''
712713
l = layout_7t_trt
713714
assert (l.get(run=1) == l.get(run='1') == l.get(run=np.int64(1)) ==
714-
l.get(run=[1, '15']))
715+
l.get(run=[1, '15']) == l.get(run='01'))
715716
assert not l.get(run='not_numeric')
716717
assert l.get(session=1) == l.get(session='1')
717718

@@ -807,4 +808,17 @@ def test_load_layout_config_not_overwritten(layout_synthetic_nodb, tmpdir):
807808
for attr in ['root', 'absolute_paths', 'derivatives']:
808809
assert getattr(cm1.layout_info, attr) == getattr(cm2.layout_info, attr)
809810

810-
assert cm1.layout_info.config != cm2.layout_info.config
811+
assert cm1.layout_info.config != cm2.layout_info.config
812+
813+
814+
def test_padded_run_roundtrip(layout_ds005):
815+
for run in (1, "1", "01"):
816+
res = layout_ds005.get(subject="01", task="mixedgamblestask",
817+
run=run, extension=".nii.gz")
818+
assert len(res) == 1
819+
boldfile = res[0]
820+
ents = boldfile.get_entities()
821+
assert isinstance(ents["run"], PaddedInt)
822+
assert ents["run"] == 1
823+
newpath = layout_ds005.build_path(ents, absolute_paths=False)
824+
assert newpath == "sub-01/func/sub-01_task-mixedgamblestask_run-01_bold.nii.gz"

bids/layout/tests/test_models.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from bids.layout.models import (BIDSFile, Entity, Tag, Base, Config,
1515
FileAssociation, BIDSImageFile, LayoutInfo)
16+
from bids.layout.utils import PaddedInt
1617
from bids.tests import get_test_data_path
1718

1819

@@ -129,9 +130,11 @@ def test_tag_dtype(sample_bidsfile, subject_entity):
129130
Tag(f, e, '4', 'int'),
130131
Tag(f, e, '4', int),
131132
Tag(f, e, 4),
132-
Tag(file=f, entity=e, dtype=int, value='4')
133+
Tag(file=f, entity=e, dtype=int, value='4'),
134+
Tag(file=f, entity=e, dtype=int, value='04'),
133135
]
134-
assert all([t.dtype == int for t in tags])
136+
assert all(t.dtype == PaddedInt for t in tags)
137+
assert all(t.value == 4 for t in tags)
135138

136139

137140
def test_entity_add_file(sample_bidsfile):

bids/layout/utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,74 @@ def __getitem__(self, key):
2020
"Metadata term {!r} unavailable for file {}.".format(key, self._source_file))
2121

2222

23+
class PaddedInt(int):
24+
""" Integer type that preserves zero-padding
25+
26+
Acts like an int in almost all ways except that string formatting
27+
will keep the original zero-padding. Numeric format specifiers
28+
29+
>>> PaddedInt(1)
30+
1
31+
>>> p2 = PaddedInt("02")
32+
>>> p2
33+
02
34+
>>> str(p2)
35+
'02'
36+
>>> p2 == 2
37+
True
38+
>>> p2 in range(3)
39+
True
40+
>>> f"{p2}"
41+
'02'
42+
>>> f"{p2:s}"
43+
'02'
44+
>>> f"{p2!s}"
45+
'02'
46+
>>> f"{p2!r}"
47+
'02'
48+
>>> f"{p2:d}"
49+
'2'
50+
>>> f"{p2:03d}"
51+
'002'
52+
>>> f"{p2:f}"
53+
'2.000000'
54+
>>> {2: "val"}.get(p2)
55+
'val'
56+
>>> {p2: "val"}.get(2)
57+
'val'
58+
59+
Note that arithmetic will break the padding.
60+
61+
>>> str(p2 + 1)
62+
'3'
63+
"""
64+
def __init__(self, val):
65+
self.sval = str(val)
66+
67+
def __eq__(self, val):
68+
return val == self.sval or super().__eq__(val)
69+
70+
def __str__(self):
71+
return self.sval
72+
73+
def __repr__(self):
74+
return self.sval
75+
76+
def __format__(self, format_spec):
77+
""" Format a padded integer
78+
79+
If a format spec can be used on a string, apply it to the zero-padded string.
80+
Otherwise format as an integer.
81+
"""
82+
try:
83+
return format(self.sval, format_spec)
84+
except:
85+
return super().__format__(format_spec)
86+
87+
def __hash__(self):
88+
return super().__hash__()
89+
90+
2391
def parse_file_entities(filename, entities=None, config=None,
2492
include_unmatched=False):
2593
"""Parse the passed filename for entity/value pairs.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"run": 1
3-
}
2+
"run": "01"
3+
}

0 commit comments

Comments
 (0)