Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
de9eeaf
Replaced numpy.str_ with numpy.bytes_ to support numpy 2
sstucker Dec 26, 2024
7233502
path fiddling in test.py
sstucker Dec 26, 2024
2e025c9
Upgrade to Python >3.9
sstucker Dec 26, 2024
ced4c87
README
sstucker Dec 26, 2024
85da3fc
Removed < 3.9 python versions from test
sstucker Dec 26, 2024
4a1b35e
Merge branch 'main' into main
sstucker Dec 26, 2024
6e40d60
1.2-draft gen
sstucker Dec 26, 2024
76151eb
added option to download spec even when local copy is present
sstucker Dec 26, 2024
228fdbc
gen from 1.2-draft
sstucker Dec 26, 2024
774ca8f
Merge branch 'main' of https://github.com/BUNPC/pysnirf2
sstucker Dec 26, 2024
e00393a
Added verbose test exceptions, began support for measurementLists
sstucker Dec 27, 2024
2080abd
Towards measurementLists, dataOffset support
sstucker Dec 27, 2024
3588268
Added check for alignment of ingested schema table and descriptions
sstucker Dec 27, 2024
5d9d8f5
On write, pysnirf2 now attempts to correct arrays with erroneous sing…
sstucker Dec 27, 2024
8c901f5
measurementList/measurementLists are manually covalidated for presence
sstucker Dec 27, 2024
7ddcefd
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 27, 2024
12f1bd1
CI: Automated docs update
github-actions[bot] Dec 27, 2024
2e43ef6
updated test files: removed moduleIndex
sstucker Dec 27, 2024
e709a29
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 27, 2024
6a36799
Support for dataOffset; switched memory only SNIRF representations fr…
sstucker Dec 29, 2024
36829ab
Fix to tests; docstring
sstucker Dec 30, 2024
53fc38c
CI: Automated docs update
github-actions[bot] Dec 30, 2024
1d6a8a2
"'
sstucker Dec 30, 2024
69a9a4d
Template changes
sstucker Dec 30, 2024
2dd8011
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 30, 2024
682fb1d
Fixed validation of probe vs. measurementList(s)
sstucker Dec 30, 2024
faa5053
CI: Automated docs update
github-actions[bot] Dec 30, 2024
b44061f
Delete tests/data/v120dev-Simple_Probe_measLists.snirf
sstucker Dec 30, 2024
11060ed
Converting to numpy upon assignment of array; accidentally a logical
sstucker Dec 30, 2024
9d0b525
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 30, 2024
3f23d10
CI: Automated docs update
github-actions[bot] Dec 30, 2024
97c12c7
Better input sanitization
sstucker Dec 30, 2024
d9f654b
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 30, 2024
98f4b96
CI: Automated docs update
github-actions[bot] Dec 30, 2024
54b0842
Conversion interface
sstucker Dec 31, 2024
295417b
Groups are now deleted from disk files with save; validation of measu…
sstucker Dec 31, 2024
feef6ac
Fix index > 0 validation
sstucker Dec 31, 2024
e0db231
Accidentally a :
sstucker Dec 31, 2024
2dc3e0c
tests of measurementList(s) validation and conversion utility functions
sstucker Dec 31, 2024
86b928e
CI: Automated docs update
github-actions[bot] Dec 31, 2024
751c489
Generator support for collecting recognized aux names, datatypes and …
sstucker Dec 31, 2024
09562f1
dataType and dataTypeLabel validation
sstucker Dec 31, 2024
d5ac9a6
Fix to type/typeLabel validation for measurementLists
sstucker Dec 31, 2024
5ca9a7e
Merge branch 'main' of https://github.com/sstucker/pysnirf2
sstucker Dec 31, 2024
7dd14f8
CI: Automated docs update
github-actions[bot] Dec 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
strategy:
max-parallel: 5
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
python-version: ['3.9', '3.10', '3.11', '3.12']
defaults:
run:
shell: bash -el {0}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Developed and maintained by the [Boston University Neurophotonics Center](https:
## Installation
`pip install snirf`

pysnirf2 requires Python > 3.6.
pysnirf2 requires Python > 3.9.

# Features

Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
- [`pysnirf2.IndexedGroup`](./pysnirf2.md#class-indexedgroup)
- [`pysnirf2.MeasurementList`](./pysnirf2.md#class-measurementlist): Interface for indexed group `MeasurementList`.
- [`pysnirf2.MeasurementListElement`](./pysnirf2.md#class-measurementlistelement): Wrapper for an element of indexed group `MeasurementList`.
- [`pysnirf2.MeasurementLists`](./pysnirf2.md#class-measurementlists)
- [`pysnirf2.MetaDataTags`](./pysnirf2.md#class-metadatatags)
- [`pysnirf2.Nirs`](./pysnirf2.md#class-nirs): Interface for indexed group `Nirs`.
- [`pysnirf2.NirsElement`](./pysnirf2.md#class-nirselement): Wrapper for an element of indexed group `Nirs`.
- [`pysnirf2.Probe`](./pysnirf2.md#class-probe)
- [`pysnirf2.Snirf`](./pysnirf2.md#class-snirf)
- [`pysnirf2.SnirfConfig`](./pysnirf2.md#class-snirfconfig): Structure containing Snirf-wide data and settings.
- [`pysnirf2.SnirfFormatError`](./pysnirf2.md#class-snirfformaterror): Raised when SNIRF-specific error prevents file from loading properly.
- [`pysnirf2.SnirfFormatError`](./pysnirf2.md#class-snirfformaterror): Raised when SNIRF-specific error prevents file from loading or saving properly.
- [`pysnirf2.Stim`](./pysnirf2.md#class-stim)
- [`pysnirf2.StimElement`](./pysnirf2.md#class-stimelement)
- [`pysnirf2.ValidationIssue`](./pysnirf2.md#class-validationissue): Information about the validity of a given SNIRF file location.
Expand Down
568 changes: 397 additions & 171 deletions docs/pysnirf2.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion gen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ This ensures easy maintenance of the project as the specification develops.

1. Ensure that [data.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/data.py) contains correct data to parse the latest spec. Make sure `SPEC_SRC` and `VERSION` are up to date.
2. IMPORTANT! Back up or commit local changes to the code via git. The generation process may delete your changes.
3. Using a Python > 3.6 environment equipped with [gen/requirements.txt](https://github.com/BUNPC/pysnirf2/blob/main/gen/requirements.txt), run [gen.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/gen.py) from the project root
3. Using a Python > 3.9 environment equipped with [gen/requirements.txt](https://github.com/BUNPC/pysnirf2/blob/main/gen/requirements.txt), run [gen.py](https://github.com/BUNPC/pysnirf2/blob/main/gen/gen.py) from the project root
4. Test the resulting library
13 changes: 11 additions & 2 deletions gen/data.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SPEC_SRC = 'https://raw.githubusercontent.com/fNIRS/snirf/v1.1/snirf_specification.md'
SPEC_VERSION = 'v1.1' # Version of the spec linked above
SPEC_SRC = 'https://raw.githubusercontent.com/sstucker/snirf/refs/heads/master/snirf_specification.md'
SPEC_VERSION = '1.2-development' # Version of the spec linked above

"""
These types are fragments of the string codes used to describe the types of
Expand Down Expand Up @@ -38,6 +38,15 @@
DEFINITIONS_DELIM_START = '### SNIRF data container definitions'
DEFINITIONS_DELIM_END = '## Appendix'

DATA_TYPE_DELIM_START = '### Supported `measurementList(k).dataType` values in `dataTimeSeries`'
DATA_TYPE_DELIM_END = '### Supported `measurementList(k).dataTypeLabel` values in `dataTimeSeries`'

DATA_TYPE_LABEL_TABLE_START = '### Supported `measurementList(k).dataTypeLabel` values in `dataTimeSeries`'
DATA_TYPE_LABEL_TABLE_END = '### Supported `/nirs(i)/aux(j)/name` values'

AUX_NAME_TABLE_START = '### Supported `/nirs(i)/aux(j)/name` values'
AUX_NAME_TABLE_END = '### Examples of stimulus waveforms'

# -- BIDS Probe name identifiers ---------------------------------------------

BIDS_PROBE_NAMES = ['ICBM452AirSpace',
Expand Down
68 changes: 48 additions & 20 deletions gen/gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
import getpass
import os
import sys
import warnings
import re
from pylint import lint

"""
Generates SNIRF interface and validator from the summary table of the specification
hosted at SPEC_SRC.
"""

LIB_VERSION = '0.8.2' # Version for this script
LIB_VERSION = '0.9.2' # Version for this script

if __name__ == '__main__':

Expand All @@ -38,7 +38,7 @@

local_spec = SPEC_SRC.split('/')[-1].split('.')[0] + '_retrieved_' + datetime.now().strftime('%d_%m_%y') + '.txt'

if os.path.exists(local_spec):
if os.path.exists(local_spec) and input('Use local specification document ' + local_spec + '? y/n\n') == 'y':
print('Loading specification from local document', local_spec, '...')
with open(local_spec, 'r') as f:
text = f.read()
Expand Down Expand Up @@ -87,7 +87,7 @@
# Get name: format pairs for each name
if len(name) > 1: # Skip the empty row
type_code = delim[-2].replace(' ', '').replace('`', '')
type_codes.append(type_code)
type_codes.append((type_code, name))

print('Found', len(type_codes), 'types in the table...')

Expand Down Expand Up @@ -128,8 +128,16 @@
f.write(location.replace('(i)', '').replace('(j)', '').replace('(k)', '') + '\n')
print('Wrote to locations.txt')

errf = False
for (type_code, location) in zip(type_codes, locations):
if type_code[1] not in location:
errf = True
print('Specification format issue: location {} aligned to name/type {} from schema table'.format(location, type_code))
if errf:
sys.exit('pysnirf2 generation aborted.')

if len(locations) != len(type_codes) or len(locations) != len(descriptions):
sys.exit('Parsed ' + str(len(type_codes)) + ' type codes from the summary table but '
sys.exit('Parsed ' + str(len(type_codes[0])) + ' type codes from the summary table but '
+ str(len(locations)) + ' names from the definitions and ' + str(len(descriptions))
+ ' descriptions: the specification hosted at ' + SPEC_SRC +' was parsed incorrectly. Try adjusting the delimiters and then debug the parsing code (gen.py).')

Expand All @@ -144,7 +152,7 @@
})

for i, (location, description) in enumerate(zip(locations, descriptions)):
type_code = type_codes[i]
type_code = type_codes[i][0]
name = location.split('/')[-1].split('(')[0] # Remove (i), (j)
parent = location.split('/')[-2].split('(')[0] # Remove (i), (j)
print('Found', location, 'with type', type_code)
Expand All @@ -163,18 +171,38 @@
'required': required
})

ans = input('Proceed? y/n\n')
if ans not in ['y', 'Y']:
if input('Proceed? y/n\n') not in ['y', 'Y']:
sys.exit('pysnirf2 generation aborted.')

print('Loading BIDS-specified Probe names from gen/data.py...')
for name in BIDS_PROBE_NAMES:
print('Found', name)

ans = input('Proceed? y/n\n')
if ans not in ['y', 'Y']:

print('\nParsing specification for supported data type integer values...')
data_type_table = unidecode(text).split(DATA_TYPE_DELIM_START)[1].split(DATA_TYPE_DELIM_END)[0]
data_types = re.findall(r'(?<=\s)-\s(\d+)\s-', data_type_table)
data_types = [int(i) for i in data_types]
for i in data_types:
print('Found', i)
if input('Proceed? y/n\n') not in ['y', 'Y']:
sys.exit('pysnirf2 generation aborted.')


print('\nParsing specification for supported aux names...')
aux_name_table = unidecode(text).split(AUX_NAME_TABLE_START)[1].split(AUX_NAME_TABLE_END)[0]
aux_names = re.findall(r'"(.*?)"', aux_name_table)
for name in aux_names:
print('Found', name)
if input('Proceed? y/n\n') not in ['y', 'Y']:
sys.exit('pysnirf2 generation aborted.')

print('\nParsing specification for supported data type labels...')
data_type_label_table = unidecode(text).split(DATA_TYPE_LABEL_TABLE_START)[1].split(DATA_TYPE_LABEL_TABLE_END)[0]
data_type_labels = re.findall(r'"(.*?)"', data_type_label_table)
for name in data_type_labels:
print('Found', name)
if input('Proceed? y/n\n') not in ['y', 'Y']:
sys.exit('pysnirf2 generation aborted.')

# Generate data for template
SNIRF = {
'VERSION': SPEC_VERSION,
Expand All @@ -188,7 +216,10 @@
'INDEXED_GROUPS': [],
'GROUPS': [],
'UNSPECIFIED_DATASETS_OK': UNSPECIFIED_DATASETS_OK,
'BIDS_COORDINATE_SYSTEM_NAMES': BIDS_PROBE_NAMES
'BIDS_COORDINATE_SYSTEM_NAMES': BIDS_PROBE_NAMES,
'AUX_NAMES': aux_names,
'DATA_TYPES': data_types,
'DATA_TYPE_LABELS': data_type_labels
}

# Build list of groups and indexed groups
Expand Down Expand Up @@ -223,8 +254,7 @@
SNIRF['FOOTER'] = TEMPLATE_INSERT_END_STR + b.split(TEMPLATE_INSERT_END_STR, 1)[1]
print('Loaded footer code, {} lines'.format(len(SNIRF['FOOTER'].split('\n'))))

ans = input('Proceed? LOCAL CHANGES MAY BE OVERWRITTEN OR LOST! y/n\n')
if ans not in ['y', 'Y']:
if input('Proceed? LOCAL CHANGES MAY BE OVERWRITTEN OR LOST! y/n\n') not in ['y', 'Y']:
sys.exit('pysnirf2 generation aborted.')
try:
os.remove(library_path)
Expand All @@ -248,12 +278,10 @@
if errors == 0:
print('pysnirf2.py generated with', errors, 'errors.')

ans = input('Format the generated code? y/n\n')
if ans in ['y', 'Y']:
if input('Format the generated code? y/n\n') in ['y', 'Y']:
FormatFile(library_path, in_place=True)[:2]

ans = input('Lint the generated code? y/n\n')
if ans in ['y', 'Y']:

if input('Lint the generated code? y/n\n') in ['y', 'Y']:
lint.Run(['--errors-only', library_path])

print('\npysnirf2 generation complete.')
Expand Down
35 changes: 23 additions & 12 deletions gen/pysnirf2.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,11 @@
{% if TYPES.INDEXED_GROUP in CHILD.type %}
return self._{{ CHILD.name }}
{% elif TYPES.GROUP in CHILD.type %}
if type(self._{{ CHILD.name }}) is type(_AbsentGroup):
if self._{{ CHILD.name }} is _AbsentGroup:
return None
return self._{{ CHILD.name }}
{% else %}
if type(self._{{ CHILD.name }}) is type(_AbsentDataset):
if self._{{ CHILD.name }} is _AbsentDataset:
return None
if type(self._{{ CHILD.name }}) is type(_PresentDataset):
{% if (TYPES.ARRAY_1D in CHILD.type) or (TYPES.ARRAY_2D in CHILD.type) %}
Expand Down Expand Up @@ -117,6 +117,9 @@
self._{{ CHILD.name }} = _recursive_hdf5_copy(self._{{ CHILD.name }}, value)
else:
raise ValueError("Only a Group of type {{ sentencecase(CHILD.name) }} can be assigned to {{ CHILD.name }}.")
{% elif TYPES.ARRAY_1D in CHILD.type or TYPES.ARRAY_2D in CHILD.type %}
if value is not None and any([v is not None for v in value]):
self._{{ CHILD.name }} = np.array(value)
{% else %}
self._{{ CHILD.name }} = value
{% endif %}
Expand Down Expand Up @@ -152,18 +155,18 @@
else:
raise ValueError('Cannot save an anonymous ' + self.__class__.__name__ + ' instance without a filename')
{% for CHILD in NODE.children %}
name = self.location + '/{{ CHILD.name }}'
{% if TYPES.INDEXED_GROUP in CHILD.type %}
self.{{ CHILD.name }}._save(*args)
{% elif TYPES.GROUP in CHILD.type %}
if type(self._{{ CHILD.name }}) is type(_AbsentGroup) or self._{{ CHILD.name }}.is_empty():
if '{{ CHILD.name }}' in file:
del file['{{ CHILD.name }}']
if self._{{ CHILD.name }} is _AbsentGroup or self._{{ CHILD.name }}.is_empty():
if name in file:
del file[name]
self._cfg.logger.info('Deleted Group %s/{{ CHILD.name }} from %s', self.location, file)
else:
self.{{ CHILD.name }}._save(*args)
{% else %}
name = self.location + '/{{ CHILD.name }}'
if type(self._{{ CHILD.name }}) not in [type(_AbsentDataset), type(None)]:
if not self._{{ CHILD.name }} is _AbsentDataset:
data = self.{{ CHILD.name }} # Use loader function via getter
if name in file:
del file[name]
Expand Down Expand Up @@ -222,7 +225,7 @@
{% macro gen_validator(NODE) %}
def _validate(self, result: ValidationResult):
# Validate unwritten datasets after writing them to this tempfile
with h5py.File(TemporaryFile(), 'w') as tmp:
with h5py.File(str(uuid.uuid4()), 'w', driver='core', backing_store=False) as tmp:
{% for CHILD in NODE.children %}
name = self.location + '/{{ CHILD.name }}'
{% if TYPES.INDEXED_GROUP in CHILD.type %}
Expand All @@ -237,7 +240,7 @@
self.{{ CHILD.name }}._validate(result)
{% elif TYPES.GROUP in CHILD.type %}
# If Group is not present in file and empty in the wrapper, it is missing
if type(self._{{ CHILD.name }}) in [type(_AbsentGroup), type(None)] or ('{{ CHILD.name }}' not in self._h and self._{{ CHILD.name }}.is_empty()):
if self._{{ CHILD.name }} is _AbsentGroup or ('{{ CHILD.name }}' not in self._h and self._{{ CHILD.name }}.is_empty()):
{% if TYPES.REQUIRED in CHILD.type %}
result._add(name, 'REQUIRED_GROUP_MISSING')
{% else %}
Expand All @@ -246,7 +249,7 @@
else:
self._{{ CHILD.name }}._validate(result)
{% else %}
if type(self._{{ CHILD.name }}) in [type(_AbsentDataset), type(None)]:
if self._{{ CHILD.name }} is _AbsentDataset:
{% if TYPES.REQUIRED in CHILD.type %}
result._add(name, 'REQUIRED_DATASET_MISSING')
{% else %}
Expand Down Expand Up @@ -379,6 +382,7 @@ class Snirf(Group):
self._cfg = SnirfConfig()
self._cfg.dynamic_loading = dynamic_loading
self._cfg.fmode = ''
self._f = None # handle for filelikes and temporary files
if len(args) > 0:
path = args[0]
if enable_logging:
Expand Down Expand Up @@ -416,7 +420,8 @@ class Snirf(Group):
self._cfg.logger.info('Loading from filelike object')
if self._cfg.fmode == '':
self._cfg.fmode = 'r'
self._h = h5py.File(path, 'r')
self._f = args[0]
self._h = h5py.File(self._f, 'r', backing_store=False)
else:
raise TypeError(str(path) + ' is not a valid filename')
else:
Expand All @@ -427,7 +432,7 @@ class Snirf(Group):
else:
self._cfg.logger = _create_logger('', None) # Do not log to file
self._cfg.fmode = 'w'
self._h = h5py.File(TemporaryFile(), 'w')
self._h = h5py.File(str(uuid.uuid4()), 'w', driver='core', backing_store=False)
{{ declare_members(ROOT) | indent }}
{{ init_members(ROOT) | indent }}
{{ gen_properties(ROOT) }}
Expand All @@ -436,4 +441,10 @@ class Snirf(Group):

_RECOGNIZED_COORDINATE_SYSTEM_NAMES = [{% for NAME in BIDS_COORDINATE_SYSTEM_NAMES %}'{{ NAME }}', {% endfor -%}]

_RECOGNIZED_AUX_NAMES = [{% for NAME in AUX_NAMES %}'{{ NAME }}', {% endfor -%}]

_RECOGNIZED_DATA_TYPES = [{% for VALUE in DATA_TYPES %}{{ VALUE }}, {% endfor -%}]

_RECOGNIZED_DATA_TYPE_LABELS = [{% for NAME in DATA_TYPE_LABELS %}'{{ NAME }}', {% endfor -%}]

{{ FOOTER }}
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
h5py
numpy
numpy>=2.0.0
setuptools
pip
termcolor
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@ def run(self):
long_description=long_description,
long_description_content_type='text/markdown',
author_email='sstucker@bu.edu',
python_requires='>=3.6.0',
python_requires='>=3.9.0',
install_requires=[
'h5py>=3.1.0',
'numpy',
'numpy>2.0.0',
'setuptools',
'pip',
'termcolor',
Expand Down
Loading