Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change: Only embed specs/namespaces for types that are included in NWB file on export #615

Open
wants to merge 44 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a458d62
Only include namespaces for types that are included in NWB file on ex…
ehennestad Nov 4, 2024
13822a8
Merge branch 'master' into 607-fix-namespace-embedding-in-file
ehennestad Nov 23, 2024
35150e9
Add functionality for installing extensions
ehennestad Nov 23, 2024
bd40894
Minor fixes
ehennestad Nov 23, 2024
132aa67
Update comment
ehennestad Nov 23, 2024
781f303
Add comment + print message when extension has been installed
ehennestad Nov 23, 2024
eab998e
Update installExtension.m
ehennestad Nov 23, 2024
be4be3d
Fix changed variable name
ehennestad Nov 23, 2024
650ceec
Update matnwb_createNwbInstallExtension.m
ehennestad Nov 23, 2024
8e18448
Create listNwbTypeHierarchy.m
ehennestad Nov 25, 2024
7c41ca6
Add private method for embedding specifications to file on export
ehennestad Nov 25, 2024
a467c46
Fix variable name
ehennestad Nov 25, 2024
ff95e98
Add workflow for updating nwbInstallExtension
ehennestad Nov 28, 2024
bb3514b
Add option to save extension in custom location
ehennestad Nov 28, 2024
184fa81
Create InstallExtensionTest.m
ehennestad Nov 28, 2024
ddbe9dc
Update docstring
ehennestad Dec 6, 2024
44a6a20
Merge branch 'master' into add-nwb-install-extension
ehennestad Dec 12, 2024
24c3899
Merge branch 'add-nwb-install-extension' of https://github.com/Neurod…
ehennestad Dec 12, 2024
da00cea
Change dispExtensionInfo to return info instead of displaying + add test
ehennestad Dec 12, 2024
40b7703
Reorganize code into separate functions and add tests
ehennestad Dec 12, 2024
eeb2006
Merge branch 'master' into 607-fix-namespace-embedding-in-file
ehennestad Dec 12, 2024
e3b4906
Merge branch 'master' into add-nwb-install-extension
ehennestad Dec 12, 2024
6faba21
Minor changes to improve test coverage
ehennestad Dec 12, 2024
a74a2d2
add nwbInstallExtension to docs
ehennestad Dec 12, 2024
16877f9
Update update_extension_list.yml
ehennestad Dec 12, 2024
0bc735f
Update downloadExtensionRepository.m
ehennestad Dec 12, 2024
67680c2
Update docstring for nwbInstallExtension
ehennestad Jan 2, 2025
b2e679a
Fix docstring indentation in nwbInstallExtension
ehennestad Jan 2, 2025
69f07d9
Add doc pages describing how to use (ndx) extensions
ehennestad Jan 2, 2025
07d5162
Fix typo
ehennestad Jan 2, 2025
13b0d1b
Update +tests/+unit/InstallExtensionTest.m
ehennestad Jan 9, 2025
32342ed
Update docs/source/pages/getting_started/using_extensions/generating_…
ehennestad Jan 9, 2025
81a2259
Merge branch 'master' into add-nwb-install-extension
bendichter Jan 14, 2025
b9f8f2c
Add docstrings for functions to retrieve and list extension info
ehennestad Jan 14, 2025
2b0e820
Fix docstring formatting/whitespace
ehennestad Jan 14, 2025
bcb2584
Update listExtensions.m
ehennestad Jan 14, 2025
691ef81
Move static test methods into io.internal.h5 namespace
ehennestad Jan 16, 2025
5958136
Update writeEmbeddedSpecifications.m
ehennestad Jan 16, 2025
f5af434
Add validateEmbeddedSpecifications
ehennestad Jan 16, 2025
9de778b
Update NwbFile.m
ehennestad Jan 16, 2025
258b8dc
Create listEmbeddedSpecNamespaces.m
ehennestad Jan 16, 2025
c694d10
Update nwbExportTest.m
ehennestad Jan 16, 2025
8f0ec28
Update test for spec/namespace embedding
ehennestad Jan 16, 2025
7e9aac6
Merge branch 'add-nwb-install-extension' into 607-fix-namespace-embed…
ehennestad Jan 16, 2025
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
20 changes: 20 additions & 0 deletions +io/+internal/+h5/deleteAttribute.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
function deleteAttribute(fileReference, objectLocation, attributeName)
% deleteAttribute - Delete the specified attribute from an NWB file

arguments
fileReference {io.internal.h5.mustBeH5FileReference}
objectLocation (1,1) string
attributeName (1,1) string
end

objectLocation = io.internal.h5.validateLocation(objectLocation);

% Open the HDF5 file in read-write mode
[fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference, "w"); %#ok<ASGLU>

% Open the object (dataset or group)
[objectId, objectCleanupObj] = io.internal.h5.openObject(fileId, objectLocation); %#ok<ASGLU>

% Delete the attribute
H5A.delete(objectId, attributeName);
end
16 changes: 16 additions & 0 deletions +io/+internal/+h5/deleteGroup.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
function deleteGroup(fileReference, groupLocation)
% deleteGroup - Delete the specified group from an NWB file

arguments
fileReference {io.internal.h5.mustBeH5FileReference}
groupLocation (1,1) string
end

groupLocation = io.internal.h5.validateLocation(groupLocation);

% Open the HDF5 file in read-write mode
[fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference, "w"); %#ok<ASGLU>

% Delete the group
H5L.delete(fileId, groupLocation, 'H5P_DEFAULT');
end
28 changes: 28 additions & 0 deletions +io/+internal/+h5/listGroupNames.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function groupNames = listGroupNames(fileReference, h5Location)

arguments
fileReference {io.internal.h5.mustBeH5FileReference}
h5Location (1,1) string
end

[fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference); %#ok<ASGLU>

% Open the specified location (group)
[groupId, groupCleanupObj] = io.internal.h5.openGroup(fileId, h5Location); %#ok<ASGLU>

% Use H5L.iterate to iterate over the links
[~, ~, groupNames] = H5L.iterate(...
groupId, "H5_INDEX_NAME", "H5_ITER_INC", 0, @collectGroupNames, {});

% Define iteration function
function [status, groupNames] = collectGroupNames(groupId, name, groupNames)
% Only retrieve name of groups
objId = H5O.open(groupId, name, 'H5P_DEFAULT');
objInfo = H5O.get_info(objId);
if objInfo.type == H5ML.get_constant_value('H5O_TYPE_GROUP')
groupNames{end+1} = name;
end
H5O.close(objId);
status = 0; % Continue iteration
end
end
17 changes: 17 additions & 0 deletions +io/+internal/+h5/mustBeH5File.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
function mustBeH5File(value)
arguments
value {mustBeFile}
end

VALID_FILE_ENDING = ["h5", "nwb"];
validExtensions = "." + VALID_FILE_ENDING;

hasH5Extension = endsWith(value, validExtensions, 'IgnoreCase', true);

if ~hasH5Extension
exception = MException(...
'MatNWB:validators:mustBeH5File', ...
'Expected file "%s" to have .h5 or .nwb file extension', value);
throwAsCaller(exception)

Check warning on line 15 in +io/+internal/+h5/mustBeH5File.m

View check run for this annotation

Codecov / codecov/patch

+io/+internal/+h5/mustBeH5File.m#L12-L15

Added lines #L12 - L15 were not covered by tests
end
end
15 changes: 15 additions & 0 deletions +io/+internal/+h5/mustBeH5FileReference.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
function mustBeH5FileReference(value)
arguments
value {mustBeA(value, ["char", "string", "H5ML.id"])}
end

if isa(value, "char") || isa(value, "string")
try
io.internal.h5.mustBeH5File(value)
catch ME
throwAsCaller(ME)

Check warning on line 10 in +io/+internal/+h5/mustBeH5FileReference.m

View check run for this annotation

Codecov / codecov/patch

+io/+internal/+h5/mustBeH5FileReference.m#L9-L10

Added lines #L9 - L10 were not covered by tests
end
else
% value is a H5ML.id, ok!
end
end
46 changes: 46 additions & 0 deletions +io/+internal/+h5/openFile.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
function [fileId, fileCleanupObj] = openFile(fileName, permission)
% openFile Opens an HDF5 file with the specified permissions and ensures cleanup.
%
% [fileId, fileCleanupObj] = io.internal.h5.openFile(fileName) opens the HDF5
% file specified by fileName in read-only mode ('r') by default.
%
% [fileId, fileCleanupObj] = io.internal.h5.openFile(fileName, permission)
% opens the HDF5 file specified by fileName with the access mode defined by
% permission.
%
% Input Arguments:
% fileName - A string or character vector specifying the path to the
% HDF5 file. This must be a .h5 or .nwb file.
%
% permission - (Optional) A scalar string specifying the file access mode.
% Valid values are "r" for read-only (default) and "w" for
% read-write.
%
% Output Arguments:
% fileId - The file identifier returned by H5F.open, used to
% reference the open file.
%
% fileCleanupObj - A cleanup object (onCleanup) that ensures the file is
% closed automatically when fileCleanupObj goes out of
% scope.
%
% Example:
% [fid, cleanupObj] = io.internal.h5.openFile("data.h5", "w");
% % Use fid for file operations.
% % When cleanupObj is cleared or goes out of scope, the file is
% % automatically closed.

arguments
fileName {io.internal.h5.mustBeH5File}
permission (1,1) string {mustBeMember(permission, ["r", "w"])} = "r"
end

switch permission
case "r"
accessFlag = 'H5F_ACC_RDONLY';
case "w"
accessFlag = 'H5F_ACC_RDWR';
end
fileId = H5F.open(fileName, accessFlag, 'H5P_DEFAULT');
fileCleanupObj = onCleanup(@(fid) H5F.close(fileId));
end
13 changes: 13 additions & 0 deletions +io/+internal/+h5/openGroup.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
function [groupId, groupCleanupObj] = openGroup(fileId, h5Location)
% openGroup Opens an HDF5 group at given location and ensures cleanup.

arguments
fileId {mustBeA(fileId, "H5ML.id")}
h5Location (1,1) string
end

% Open the specified location (group)
groupLocation = io.internal.h5.validateLocation(h5Location);
groupId = H5G.open(fileId, groupLocation);
groupCleanupObj = onCleanup(@(gid) H5G.close(groupId));
end
13 changes: 13 additions & 0 deletions +io/+internal/+h5/openObject.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
function [objectId, objectCleanupObj] = openObject(fileId, objectLocation)
% openObject Opens an HDF5 object at given location and ensures cleanup.

arguments
fileId {mustBeA(fileId, "H5ML.id")}
objectLocation (1,1) string
end

% Open the object (dataset or group)
objectLocation = io.internal.h5.validateLocation(objectLocation);
objectId = H5O.open(fileId, objectLocation, 'H5P_DEFAULT');
objectCleanupObj = onCleanup(@(oid) H5O.close(objectId));
end
30 changes: 30 additions & 0 deletions +io/+internal/+h5/resolveFileReference.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
function [h5FileId, fileCleanupObj] = resolveFileReference(fileReference, permission)
% resolveFileReference - Resolve a file reference to a H5 File ID.
%
% Utility method to resolve a file reference, which can be either a
% filepath or a file id for a h5 file.
%
% The returned value will always be a file ID. This allows functions that
% does operations on h5 files to receive either a file path or a file id
%
% Note: If the file reference is a file ID for an open file, the permission
% might be different than the provided/requested permission.

arguments
fileReference {io.internal.h5.mustBeH5FileReference}
permission (1,1) string {mustBeMember(permission, ["r", "w"])} = "r"
end

if isa(fileReference, "char") || isa(fileReference, "string")
% Need to open the file
if isfile(fileReference)
[h5FileId, fileCleanupObj] = io.internal.h5.openFile(fileReference, permission);
else
error('File "%s" does not exist', fileReference)

Check warning on line 23 in +io/+internal/+h5/resolveFileReference.m

View check run for this annotation

Codecov / codecov/patch

+io/+internal/+h5/resolveFileReference.m#L23

Added line #L23 was not covered by tests
end
else
h5FileId = fileReference;
% If the file is already open, we are not responsible for closing it
fileCleanupObj = [];
end
end
9 changes: 9 additions & 0 deletions +io/+internal/+h5/validateLocation.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
function locationName = validateLocation(locationName)
arguments
locationName (1,1) string
end

if ~startsWith(locationName, "/")
locationName = "/" + locationName;
end
end
11 changes: 11 additions & 0 deletions +io/+spec/listEmbeddedSpecNamespaces.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
function namespaceNames = listEmbeddedSpecNamespaces(fileReference)

arguments
fileReference {io.internal.h5.mustBeH5FileReference}
end

[fileId, fileCleanupObj] = io.internal.h5.resolveFileReference(fileReference); %#ok<ASGLU>

specLocation = io.spec.internal.readEmbeddedSpecLocation(fileId);
namespaceNames = io.internal.h5.listGroupNames(fileId, specLocation);
end
48 changes: 48 additions & 0 deletions +io/+spec/validateEmbeddedSpecifications.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
function validateEmbeddedSpecifications(h5_file_id, expectedNamespaceNames)
% validateEmbeddedSpecifications - Validate the embedded specifications
%
% This function does two things:
% 1) Displays a warning if specifications of expected namespaces
% are not embedded in the file.
% E.g if cached namespaces were cleared prior to export.
%
% 2) Deletes specifications for unused namespaces that are embedded.
% - E.g. If neurodata type from an embedded namespace was removed and the
% file was re-exported

% NB: Input h5_file_id must point to a file opened with write access

specLocation = io.spec.internal.readEmbeddedSpecLocation(h5_file_id);
embeddedNamespaceNames = io.internal.h5.listGroupNames(h5_file_id, specLocation);

checkMissingNamespaces(expectedNamespaceNames, embeddedNamespaceNames)

unusedNamespaces = checkUnusedNamespaces(...
expectedNamespaceNames, embeddedNamespaceNames);

if ~isempty(unusedNamespaces)
deleteUnusedNamespaces(h5_file_id, unusedNamespaces, specLocation)
end
end

function checkMissingNamespaces(expectedNamespaceNames, embeddedNamespaceNames)
% checkMissingNamespaces - Check if any namespace specs are missing from the file
missingNamespaces = setdiff(expectedNamespaceNames, embeddedNamespaceNames);
if ~isempty(missingNamespaces)
missingNamespacesStr = strjoin(" " + string(missingNamespaces), newline);
warning('NWB:validators:MissingEmbeddedNamespace', 'Namespace is missing:\n%s', missingNamespacesStr)
end
end

function unusedNamespaces = checkUnusedNamespaces(expectedNamespaceNames, embeddedNamespaceNames)
% checkUnusedNamespaces - Check if any namespace specs in the file are unused
unusedNamespaces = setdiff(embeddedNamespaceNames, expectedNamespaceNames);
end

function deleteUnusedNamespaces(fileId, unusedNamespaces, specRootLocation)
for i = 1:numel(unusedNamespaces)
thisName = unusedNamespaces{i};
namespaceSpecLocation = strjoin( {specRootLocation, thisName}, '/');
io.internal.h5.deleteGroup(fileId, namespaceSpecLocation)
end
end
11 changes: 9 additions & 2 deletions +io/+spec/writeEmbeddedSpecifications.m
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
function writeEmbeddedSpecifications(fid, jsonSpecs)
% writeEmbeddedSpecifications - Write schema specifications to an NWB file

arguments
fid % File id for a h5 file
jsonSpecs % String representation of schema specifications in json format
end

specLocation = io.spec.internal.readEmbeddedSpecLocation(fid);

if isempty(specLocation)
Expand Down Expand Up @@ -37,8 +44,8 @@ function writeEmbeddedSpecifications(fid, jsonSpecs)
function versionNames = getVersionNames(namespaceGroupId)
[~, ~, versionNames] = H5L.iterate(namespaceGroupId,...
'H5_INDEX_NAME', 'H5_ITER_NATIVE',...
0, @removeGroups, {});
function [status, versionNames] = removeGroups(~, name, versionNames)
0, @appendName, {});
function [status, versionNames] = appendName(~, name, versionNames)
versionNames{end+1} = name;
status = 0;
end
Expand Down
25 changes: 25 additions & 0 deletions +matnwb/+extension/+internal/buildRepoDownloadUrl.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function downloadUrl = buildRepoDownloadUrl(repositoryUrl, branchName)
% buildRepoDownloadUrl - Build a download URL for a given repository and branch
arguments
repositoryUrl (1,1) string
branchName (1,1) string
end

if endsWith(repositoryUrl, '/')
repositoryUrl = extractBefore(repositoryUrl, strlength(repositoryUrl));
end

if contains(repositoryUrl, 'github.com')
downloadUrl = sprintf( '%s/archive/refs/heads/%s.zip', repositoryUrl, branchName );

elseif contains(repositoryUrl, 'gitlab.com')
repoPathSegments = strsplit(repositoryUrl, '/');
repoName = repoPathSegments{end};
downloadUrl = sprintf( '%s/-/archive/%s/%s-%s.zip', ...
repositoryUrl, branchName, repoName, branchName);

else
error('NWB:BuildRepoDownloadUrl:UnsupportedRepository', ...
'Expected repository URL to point to a GitHub or a GitLab repository')
end
end
46 changes: 46 additions & 0 deletions +matnwb/+extension/+internal/downloadExtensionRepository.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
function [wasDownloaded, repoTargetFolder] = downloadExtensionRepository(...
repositoryUrl, repoTargetFolder, extensionName)
% downloadExtensionRepository - Download the repository (source) for an extension
%
% The metadata for a neurodata extension only provides the url to the
% repository containing the extension, not the full download url. This
% function tries to download a zipped version of the repository from
% either the "main" or the "master" branch.
%
% Works for repositories located on GitHub or GitLab
%
% As of Dec. 2024, this approach works for all registered extensions

arguments
repositoryUrl (1,1) string
repoTargetFolder (1,1) string
extensionName (1,1) string
end

import matnwb.extension.internal.downloadZippedRepo
import matnwb.extension.internal.buildRepoDownloadUrl

defaultBranchNames = ["main", "master"];

wasDownloaded = false;
for i = 1:2
try
branchName = defaultBranchNames(i);
downloadUrl = buildRepoDownloadUrl(repositoryUrl, branchName);
repoTargetFolder = downloadZippedRepo(downloadUrl, repoTargetFolder);
wasDownloaded = true;
break
catch ME
if strcmp(ME.identifier, 'MATLAB:webservices:HTTP404StatusCodeError')
continue

Check warning on line 35 in +matnwb/+extension/+internal/downloadExtensionRepository.m

View check run for this annotation

Codecov / codecov/patch

+matnwb/+extension/+internal/downloadExtensionRepository.m#L35

Added line #L35 was not covered by tests
elseif strcmp(ME.identifier, 'NWB:BuildRepoDownloadUrl:UnsupportedRepository')
error('NWB:InstallExtension:UnsupportedRepository', ...
['Extension "%s" is located in an unsupported repository ', ...
'/ source location. \nPlease create an issue on MatNWB''s ', ...
'github page'], extensionName)
else
rethrow(ME)

Check warning on line 42 in +matnwb/+extension/+internal/downloadExtensionRepository.m

View check run for this annotation

Codecov / codecov/patch

+matnwb/+extension/+internal/downloadExtensionRepository.m#L42

Added line #L42 was not covered by tests
end
end
end
end
Loading
Loading