Repository: bids-standard/pybids

Issue:
``build_path`` should check entity values
From the tutorial:
```Python
>>> entities = {
>>>     'subject': '01',
>>>     'run': 2,
>>>     'task': 'n-back',
>>>     'extension': 'tsv.gz',
>>>     'suffix': 'stim'
>>> }
>>> layout.build_path(entities)
'sub-01/func/sub-01_task-n-back_run-2_stim.tsv.gz'
```
(should break for the hyphen in the task value)
``build_path`` should check entity values
From the tutorial:
```Python
>>> entities = {
>>>     'subject': '01',
>>>     'run': 2,
>>>     'task': 'n-back',
>>>     'extension': 'tsv.gz',
>>>     'suffix': 'stim'
>>> }
>>> layout.build_path(entities)
'sub-01/func/sub-01_task-n-back_run-2_stim.tsv.gz'
```
(should break for the hyphen in the task value)


Discussion:

Resolving patch:
diff --git a/bids/layout/layout.py b/bids/layout/layout.py
--- a/bids/layout/layout.py
+++ b/bids/layout/layout.py
@@ -12,6 +12,7 @@
 
 import sqlalchemy as sa
 from sqlalchemy.orm import joinedload
+from bids_validator import BIDSValidator
 
 from ..utils import listify, natural_sort, make_bidsfile
 from ..external import inflect, six
@@ -1136,7 +1137,7 @@ def get_tr(self, derivatives=False, **filters):
         return all_trs.pop()
 
     def build_path(self, source, path_patterns=None, strict=False,
-                   scope='all'):
+                   scope='all', validate=True):
         """Construct a target filename for a file or dictionary of entities.
 
         Args:
@@ -1169,6 +1170,9 @@ def build_path(self, source, path_patterns=None, strict=False,
                 two or more values are provided, the order determines the
                 precedence of path patterns (i.e., earlier layouts will have
                 higher precedence).
+            validate (bool): If True, built path must pass BIDS validator. If
+                False, no validation is attempted, and an invalid path may be
+                returned (e.g., if an entity value contains a hyphen).
         """
         # 'is_file' is a crude check for Path objects
         if isinstance(source, six.string_types) or hasattr(source, 'is_file'):
@@ -1193,7 +1197,15 @@ def build_path(self, source, path_patterns=None, strict=False,
                         path_patterns.extend(c.default_path_patterns)
                     seen_configs.add(c)
 
-        return build_path(source, path_patterns, strict)
+        built = build_path(source, path_patterns, strict)
+        to_check = os.path.join(os.path.sep, built)
+
+        if not validate or BIDSValidator().is_bids(to_check):
+            return built
+
+        raise ValueError("Built path {} is not a valid BIDS filename. Please "
+                         "make sure all provided entity values are "
+                         "spec-compliant.".format(built))
 
     def copy_files(self, files=None, path_patterns=None, symbolic_links=True,
                    root=None, conflicts='fail', **kwargs):
@@ -1232,7 +1244,7 @@ def copy_files(self, files=None, path_patterns=None, symbolic_links=True,
     def write_contents_to_file(self, entities, path_patterns=None,
                                contents=None, link_to=None,
                                content_mode='text', conflicts='fail',
-                               strict=False):
+                               strict=False, validate=True):
         """Write arbitrary data to a file defined by the passed entities and
         path patterns.
 
@@ -1256,8 +1268,12 @@ def write_contents_to_file(self, entities, path_patterns=None,
                     'append': adds  a suffix to each file copy, starting with 1
             strict (bool): If True, all entities must be matched inside a
                 pattern in order to be a valid match. If False, extra entities
+            validate (bool): If True, built path must pass BIDS validator. If
+                False, no validation is attempted, and an invalid path may be
+                returned (e.g., if an entity value contains a hyphen).
         """
-        path = self.build_path(entities, path_patterns, strict)
+        path = self.build_path(entities, path_patterns, strict,
+                               validate=validate)
 
         if path is None:
             raise ValueError("Cannot construct any valid filename for "


Test patch:
diff --git a/bids/layout/tests/test_path_building.py b/bids/layout/tests/test_path_building.py
--- a/bids/layout/tests/test_path_building.py
+++ b/bids/layout/tests/test_path_building.py
@@ -14,4 +14,14 @@ def test_bold_construction(layout):
     ents = dict(subject='01', run=1, task='rest', suffix='bold')
     assert layout.build_path(ents) == "sub-01/func/sub-01_task-rest_run-1_bold.nii.gz"
     ents['acquisition'] = 'random'
-    assert layout.build_path(ents) == "sub-01/func/sub-01_task-rest_acq-random_run-1_bold.nii.gz"
\ No newline at end of file
+    assert layout.build_path(ents) == "sub-01/func/sub-01_task-rest_acq-random_run-1_bold.nii.gz"
+
+
+def test_invalid_file_construction(layout):
+    # no hyphens allowed!
+    ents = dict(subject='01', run=1, task='resting-state', suffix='bold')
+    with pytest.raises(ValueError):
+        layout.build_path(ents)
+    
+    target = "sub-01/func/sub-01_task-resting-state_run-1_bold.nii.gz"
+    assert layout.build_path(ents, validate=False) == target
diff --git a/bids/layout/tests/test_writing.py b/bids/layout/tests/test_writing.py
--- a/bids/layout/tests/test_writing.py
+++ b/bids/layout/tests/test_writing.py
@@ -108,14 +108,14 @@ def test_build_path(self, writable_file):
         assert build_path(writable_file.entities, pats) == target
 
         # Pattern with default value
-        pats = ['sess-{session|A}/r-{run}.nii.gz']
-        assert build_path({'run': 3}, pats) == 'sess-A/r-3.nii.gz'
+        pats = ['ses-{session|A}/r-{run}.nii.gz']
+        assert build_path({'run': 3}, pats) == 'ses-A/r-3.nii.gz'
 
         # Pattern with both valid and default values
-        pats = ['sess-{session<A|B|C>|D}/r-{run}.nii.gz']
-        assert build_path({'session': 1, 'run': 3}, pats) == 'sess-D/r-3.nii.gz'
-        pats = ['sess-{session<A|B|C>|D}/r-{run}.nii.gz']
-        assert build_path({'session': 'B', 'run': 3}, pats) == 'sess-B/r-3.nii.gz'
+        pats = ['ses-{session<A|B|C>|D}/r-{run}.nii.gz']
+        assert build_path({'session': 1, 'run': 3}, pats) == 'ses-D/r-3.nii.gz'
+        pats = ['ses-{session<A|B|C>|D}/r-{run}.nii.gz']
+        assert build_path({'session': 'B', 'run': 3}, pats) == 'ses-B/r-3.nii.gz'
 
     def test_strict_build_path(self):
 
@@ -181,20 +181,20 @@ def test_write_files(self, tmp_bids, layout):
 
         tmpdir = str(tmp_bids)
         pat = join(str(tmpdir), 'sub-{subject<02>}'
-                                '/sess-{session}'
+                                '/ses-{session}'
                                 '/r-{run}'
                                 '/suffix-{suffix}'
                                 '/acq-{acquisition}'
                                 '/task-{task}.nii.gz')
         layout.copy_files(path_patterns=pat)
         example_file = join(str(tmpdir), 'sub-02'
-                                         '/sess-2'
+                                         '/ses-2'
                                          '/r-1'
                                          '/suffix-bold'
                                          '/acq-fullbrain'
                                          '/task-rest.nii.gz')
         example_file2 = join(str(tmpdir), 'sub-01'
-                                          '/sess-2'
+                                          '/ses-2'
                                           '/r-1'
                                           '/suffix-bold'
                                           '/acq-fullbrain'
@@ -204,12 +204,12 @@ def test_write_files(self, tmp_bids, layout):
         assert not exists(example_file2)
 
         pat = join(str(tmpdir), 'sub-{subject<01>}'
-                                '/sess-{session}'
+                                '/ses-{session}'
                                 '/r-{run}'
                                 '/suffix-{suffix}'
                                 '/task-{task}.nii.gz')
         example_file = join(str(tmpdir), 'sub-01'
-                                         '/sess-2'
+                                         '/ses-2'
                                          '/r-1'
                                          '/suffix-bold'
                                          '/task-rest.nii.gz')
@@ -227,10 +227,10 @@ def test_write_files(self, tmp_bids, layout):
     def test_write_contents_to_file(self, tmp_bids, layout):
         contents = 'test'
         entities = {'subject': 'Bob', 'session': '01'}
-        pat = join('sub-{subject}/sess-{session}/desc.txt')
+        pat = join('sub-{subject}/ses-{session}/desc.txt')
         layout.write_contents_to_file(entities, path_patterns=pat,
-                                      contents=contents)
-        target = join(str(tmp_bids), 'bids', 'sub-Bob/sess-01/desc.txt')
+                                      contents=contents, validate=False)
+        target = join(str(tmp_bids), 'bids', 'sub-Bob/ses-01/desc.txt')
         assert exists(target)
         with open(target) as f:
             written = f.read()
@@ -253,13 +253,13 @@ def test_write_contents_to_file_defaults(self, tmp_bids, layout):
     def test_build_file_from_layout(self, tmpdir, layout):
         entities = {'subject': 'Bob', 'session': '01', 'run': '1'}
         pat = join(str(tmpdir), 'sub-{subject}'
-                   '/sess-{session}'
+                   '/ses-{session}'
                    '/r-{run}.nii.gz')
-        path = layout.build_path(entities, path_patterns=pat)
-        assert path == join(str(tmpdir), 'sub-Bob/sess-01/r-1.nii.gz')
+        path = layout.build_path(entities, path_patterns=pat, validate=False)
+        assert path == join(str(tmpdir), 'sub-Bob/ses-01/r-1.nii.gz')
 
         data_dir = join(dirname(__file__), 'data', '7t_trt')
         filename = 'sub-04_ses-1_task-rest_acq-fullbrain_run-1_physio.tsv.gz'
         file = join('sub-04', 'ses-1', 'func', filename)
-        path = layout.build_path(file, path_patterns=pat)
-        assert path.endswith('sub-04/sess-1/r-1.nii.gz')
+        path = layout.build_path(file, path_patterns=pat, validate=False)
+        assert path.endswith('sub-04/ses-1/r-1.nii.gz')