diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg index a6d761ea..d259430f 100644 --- a/.github/badges/code_issues.svg +++ b/.github/badges/code_issues.svg @@ -1 +1 @@ -code issuescode issues17971797 \ No newline at end of file +code issuescode issues17791779 \ No newline at end of file diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index ed4f6ca1..41b70f07 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests112 passed112 passed \ No newline at end of file +teststests148 passed148 passed \ No newline at end of file diff --git a/.github/workflows/generate-requirements-txt.yml b/.github/workflows/generate-requirements-txt.yml new file mode 100644 index 00000000..9eee6818 --- /dev/null +++ b/.github/workflows/generate-requirements-txt.yml @@ -0,0 +1,52 @@ +name: Generate requirements.txt from manifest + +on: + push: + paths: + - 'code/dependencies.nansen.json' + branches: + - main + - dev + workflow_dispatch: + +jobs: + generate-requirements: + name: Regenerate requirements.txt + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + + - name: Add toolbox code to path and generate requirements.txt + uses: matlab-actions/run-command@v2 + with: + command: | + addpath(genpath('code')); + addpath(genpath('tools')); + nansentools.generateRequirementsTxt( ... + fullfile(pwd, 'dependencies.nansen.json'), ... + fullfile(pwd, 'requirements.txt')); + + - name: Check for changes + id: check-changes + run: | + if git diff --quiet requirements.txt; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Commit and push updated requirements.txt + if: steps.check-changes.outputs.changed == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add requirements.txt + git commit -m "Auto-generate requirements.txt from manifest [skip ci]" + git push diff --git a/code/+nansen/+app/+setup/SetupWizard.mlapp b/code/+nansen/+app/+setup/SetupWizard.mlapp index 2e17fdc9..90fcd786 100644 Binary files a/code/+nansen/+app/+setup/SetupWizard.mlapp and b/code/+nansen/+app/+setup/SetupWizard.mlapp differ diff --git a/code/+nansen/+app/+tutorial/loadProject.m b/code/+nansen/+app/+tutorial/loadProject.m index 8a14e676..26c24750 100644 --- a/code/+nansen/+app/+tutorial/loadProject.m +++ b/code/+nansen/+app/+tutorial/loadProject.m @@ -35,22 +35,26 @@ error('User canceled.') end +% We need the addonmanager to ensure all tutorial dependencies are installed +addonManager = nansen.AddonManager(); + if startsWith(S(selection), 'Allen Brain Observatory') - addonManager = nansen.AddonManager(); names = {addonManager.AddonList.Name}; S = addonManager.AddonList(strcmp(names, "Brain Observatory Toolbox")); if ~S.IsInstalled fprintf('Downloading %s...', S.Name) addonManager.downloadAddon(S.Name) - addonManager.addAddonToMatlabPath(S.Name) fprintf('Finished.\n') end + elseif startsWith(S(selection), 'Nansen - Two-photon Quickstart') warnState = warning('off', 'MATLAB:RMDIR:RemovedFromPath'); warnCleanup = onCleanup(@() warning(warnState)); + disp('Installing two-photon addons...') - nansen.internal.setup.installAddons() + addonManager.installMissingAddons('nansen.module.ophys.twophoton') + % Some users had problems where Yaml was not added to java path nansen.internal.setup.addYamlJarToJavaClassPath end diff --git a/code/+nansen/+common/suppressWarning.m b/code/+nansen/+common/suppressWarning.m new file mode 100644 index 00000000..314533f9 --- /dev/null +++ b/code/+nansen/+common/suppressWarning.m @@ -0,0 +1,14 @@ +function warningCleanupObj = suppressWarning(warningId) +% suppressWarning - Temporarily suppress warning with given warning id +% +% Example usage: +% +% warningIdentifier = 'MATLAB:callback:error'; +% warningCleanup = nansen.common.suppressWarning(warningIdentifier); %#ok + + arguments + warningId (1,:) char + end + warnState = warning('off', warningId); + warningCleanupObj = onCleanup(@() warning(warnState)); +end diff --git a/code/+nansen/+config/+addons/@AddonManager/AddonManager.m b/code/+nansen/+config/+addons/@AddonManager/AddonManager.m index c0179a3f..a433b9a9 100644 --- a/code/+nansen/+config/+addons/@AddonManager/AddonManager.m +++ b/code/+nansen/+config/+addons/@AddonManager/AddonManager.m @@ -1,633 +1,753 @@ classdef AddonManager < handle -%AddonManager A simple addon manager for managing a custom list of addons - % Provides an interface for downloading and adding addons/toolboxes - % to the matlab path. Addons are specified in a separate function - % and this class provides several methods for managing these addons. - - % TODOS: - % [X] Save addon list - % [x] System for adding addons to path on startup - % [ ] Make another class for addons. - % [v] Rename to addon manager - % [ ] Add option for setting a custom installation dir - % [ ] File with list of addons should be saved based on which software - % it belongs to. Either use subclassing, or make a way to use access - % a settings file by a keyword or something similar. - % [ ] Better warning/resolving when addons are duplicated... - - % [v] Use matlab.addons.install(filename) for matlab toolbox files. - % [ ] Provide table with addons to install as input... - - % QUESTIONS: - % - Use gitmodules?? - % - Implement better git functionality, i.e version tracking - - % NOTES: - % addons = matlab.addons.installedAddons - % S = matlab.addons.toolbox.installedToolboxes Does not show Mathworks toolboxes - - properties % Preferences - InstallationDir char = '' % Path where addons should be installed - UseGit logical = false % Whether to use git for downloads and updates - end +%AddonManager Manages community toolbox dependencies for NANSEN. +% Tracks installed addons, delegates download/install to matbox, +% and manages MATLAB path additions per session. properties - AddonList struct = struct() % List of addons (table or struct array) + % InstallationFolder - Folder to install Add-Ons (dependencies) for NANSEN + InstallationFolder (1,1) string = missing end properties (SetAccess = private) - AddonDefinitionsPath + % ManifestFilePath - Filepath for JSON manifest storing info and + % installation status about Add-Ons managed by NANSEN + ManifestFilePath (1,1) string + end + + properties (Dependent) + % ManagedAddons - Table listing information about managed Add-Ons + ManagedAddons table + end + + properties (SetAccess=private, Hidden) + AddonList struct = struct() % ManagedAddons end properties (Hidden) IsDirty = false end - + properties (Constant, Hidden) - - % A list of fields that are relevant for each addon entry - % Todo: make into a separate class... - addonFields = {... - 'Name', ... % Name of addon - 'IsRequired', ... % Whether addon is required or optional - 'IsInstalled', ... - 'DateInstalled', ... - 'FilePath', ... - 'WebSource', ... - 'WebUrl', ... - 'DownloadUrl', ... - 'HasSetupFile', ... - 'SetupFileName', ... - 'FunctionName', ... - 'AddToPathOnInit', ... - 'IsDoubleInstalled'} + DefaultAddonEntry = struct( ... + 'Name', '', ... + 'Description', "", ... + 'IsRequired', false, ... + 'IsInstalled', false, ... + 'IsOnPath', false, ... + 'DateInstalled', NaT, ... + 'FilePath', '', ... + 'Source', '', ... + 'DocsSource', '', ... + 'SetupFunctionName', '', ... + 'InstallCheck', '', ... + 'InstallationType', '', ... + 'ToolboxIdentifier', '', ... + 'AddToPathOnInit', false, ... + 'HasMultipleInstancesOnPath', false) end - - methods (Access = ?nansen.internal.user.NansenUserSession) - - function obj = AddonManager(preferenceDirectory) - %AddonManager Construct an instance of this class - - % Create a addon manager instance. Provide methods for - % installing addons - if nargin < 1; preferenceDirectory = ''; end - - % Assign the path to the directory where addons are saved - obj.InstallationDir = obj.getDefaultInstallationDir(); - - % Get path where list of previously installed addons are saved - obj.AddonDefinitionsPath = obj.getPathForAddonList(preferenceDirectory); - - % Load addon list (list is initialized if it does not exist) - obj.loadAddonList() - % Add previously installed addons to path if they are not already there. - obj.updateSearchPath() - - % Check if there are multiple versions of addons on the matlab - % search path. + methods (Access = private) % Constructor (private -> singleton) + function obj = AddonManager(installationFolder, manifestFilePath) + arguments + installationFolder (1,1) string + manifestFilePath (1,1) string + end + obj.InstallationFolder = installationFolder; + obj.ManifestFilePath = manifestFilePath; + obj.loadAddonManifest() + obj.ensureAddonDependenciesOnPath() obj.checkAddonDuplication() end end - - methods - - function listAddons(obj) - %listAddons Display a table of addons - - T = struct2table(obj.AddonList); - - % Add a column with index numbers. - numberColumn = table((1:size(T,1))', 'VariableNames', {'Num'}); - T = [numberColumn T]; - - % Display table - disp(T) - end - - function loadAddonList(obj) - % loadAddonList Load list (csv or xml) with required/supported addons - - % Load list - if isfile(obj.AddonDefinitionsPath) - S = load(obj.AddonDefinitionsPath); - addonList = S.AddonList; - else - addonList = obj.initializeAddonList(); % init to empty struct + + methods (Static, Hidden) % Get singleton instance + function obj = instance(mode, installationFolder, manifestFilePath) + %instance Get the singleton AddonManager instance. + % + % obj = AddonManager.instance() returns the singleton instance, + % creating it with default paths if needed. + % + % obj = AddonManager.instance("reset") resets the singleton. + % + % obj = AddonManager.instance("reset", installationFolder, manifestFilePath) + % resets the singleton with custom paths (useful for testing). + arguments + mode (1,1) string {mustBeMember(mode, ["normal", "reset"])} = "normal" + installationFolder (1,1) string = ... + nansen.config.addons.AddonManager.getDefaultInstallationDir() + manifestFilePath (1,1) string = ... + nansen.config.addons.AddonManager.getPathForAddonManifest() end - - addonList = obj.updateAddonList(addonList); - - % Assign to AddonList property - obj.AddonList = addonList; + persistent singletonInstance + if isempty(singletonInstance) || ~isvalid(singletonInstance) || mode == "reset" + singletonInstance = nansen.config.addons.AddonManager( ... + installationFolder, manifestFilePath); + end + obj = singletonInstance; end - - function saveAddonList(obj) - %saveAddonList Sve the list of addons to file. - - S = struct; - S.type = 'Nansen Configuration: List of Installed Addons'; - S.description = 'This file lists all the addons that have been installed through NANSEN'; - - S.AddonList = obj.AddonList; - save(obj.AddonDefinitionsPath, '-struct', 'S') - - jsonFilePath = strrep(obj.AddonDefinitionsPath, '.mat', '.json'); - utility.filewrite(jsonFilePath, jsonencode(S, 'PrettyPrint', true)) + end + + methods % Public + + % Open the Add-On Manifest in MATLABs editor + function openManifest(obj) + edit( obj.ManifestFilePath ) end - - function S = updateAddonList(~, S) - %updateAddonList Compare current with default - % (in case defaults have been updated) - - % %todo: rename - - % Get package list - defaultAddonList = nansen.config.addons.getDefaultAddonList(); - - %numAddons = numel(defaultAddonList); - - defaultAddonNames = {defaultAddonList.Name}; - currentAddonNames = {S.Name}; - - isNew = ~ismember(defaultAddonNames, currentAddonNames); - - newAddons = find(isNew); - fieldNames = fieldnames(defaultAddonList); - - % If some addons are present in default addon list and not in - % current addon list, add from default to current. - for iAddon = newAddons - appendIdx = numel(S) + 1; - - for jField = 1:numel(fieldNames) - thisField = fieldNames{jField}; - S(appendIdx).(thisField) = defaultAddonList(iAddon).(thisField); + + % Change current directory to the Add-On installation folder + function cdInstallationFolder(obj) + cd(obj.InstallationFolder) + end + + function numAddonsInstalled = installMissingAddons(obj, modules) + % installMissingAddons - Install add-ons that are not installed. + arguments + obj (1,1) nansen.config.addons.AddonManager + modules (1,:) string = string.empty + end + + obj.refreshManagedAddons("SelectedModules", modules); + addonEntries = obj.getManagedAddonsForModules(modules); + + numAddonsInstalled = 0; + for i = 1:numel(addonEntries) + addonEntry = addonEntries(i); + if ~addonEntry.IsInstalled + obj.downloadAddon(addonEntry.Name) + numAddonsInstalled = numAddonsInstalled + 1; end - - % Check if addon is found on matlab's path and update - % IsInstalled flag - if ismember(exist(S(appendIdx).FunctionName), [2,8]) - S(appendIdx).IsInstalled = true; - S(appendIdx).DateInstalled = char(datetime("now")); %todo: format? - else - S(appendIdx).IsInstalled = false; - S(appendIdx).DateInstalled = 'N/A'; + end + obj.saveAddonList() + + if ~nargout + clear numAddonsInstalled + end + end + + function updateAddons(obj, modules) + % updateAddons - Update tracked installed addons. + arguments + obj (1,1) nansen.config.addons.AddonManager + modules (1,:) string = string.empty + end + + obj.refreshManagedAddons("SelectedModules", modules); + addonEntries = obj.getManagedAddonsForModules(modules); + + for i = 1:numel(addonEntries) + addonEntry = addonEntries(i); + if addonEntry.IsInstalled + obj.downloadAddon(addonEntry.Name, true) end - - % Set this flag to false. This should change if an addon is - % installed, but not saved to the matlab search path. - S(appendIdx).AddToPathOnInit = false; end - - % Update package and download url links - for i = 1:numel(S) - thisName = S(i).Name; - isMatch = strcmp(thisName, defaultAddonNames); - if ~any(isMatch); return; end - - S(i).DownloadUrl = defaultAddonList(isMatch).DownloadUrl; - S(i).WebUrl = defaultAddonList(isMatch).WebUrl; - S(i).SetupFileName = defaultAddonList(isMatch).SetupFileName; + obj.saveAddonList() + end + + function saveAddonList(obj) + %saveAddonList Save the addon list as JSON. + savedData = struct(); + savedData.type = 'Nansen Configuration: List of Installed Addons'; + savedData.description = 'Installed addons tracked by NANSEN AddonManager'; + savedData.AddonList = obj.AddonList; + jsonText = jsonencode(savedData, 'PrettyPrint', true); + nansenDirectory = fileparts(obj.ManifestFilePath); + if ~isfolder(nansenDirectory); mkdir(nansenDirectory); end + fileIdentifier = fopen(obj.ManifestFilePath, 'w'); + assert(fileIdentifier ~= -1, ... + 'NANSEN:AddonManager:SaveFailed', ... + 'Could not open addon manifest for writing: %s', ... + obj.ManifestFilePath) + closeFile = onCleanup(@() fclose(fileIdentifier)); + fwrite(fileIdentifier, jsonText, 'char'); + + obj.markClean() + end + + function refreshManagedAddons(obj, options) + %refreshManagedAddons Sync addon list with resolved dependencies. + arguments + obj + options.SelectedModules (1,:) string = string.empty + options.ManifestPaths (1,:) string = string.empty end + + resolveOptions = { ... + "DependencyTypes", "community-toolbox", ... + "SelectedModules", options.SelectedModules, ... + "TrackedAddons", obj.AddonList}; + if ~isempty(options.ManifestPaths) + resolveOptions = [resolveOptions, ... + {"ManifestPaths"}, {options.ManifestPaths}]; + end + resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ... + resolveOptions{:}); + + obj.mergeRequirementsIntoAddonList(resolvedRequirements); + obj.checkAddonDuplication() end - - function tf = browseAddonPath(obj, addonName) - + + % Open UI dialog for user to locate Add-On + function tf = locateAddonPath(obj, addonName) + %locateAddonPath Manually locate an addon folder on disk. tf = false; addonIdx = obj.getAddonIndex(addonName); - - % Open path dialog to locate folderpath for addon pkgInstallationDir = uigetdir(); - if pkgInstallationDir == 0 return end - obj.AddonList(addonIdx).IsInstalled = true; - obj.AddonList(addonIdx).DateInstalled = string(datetime("now")); + obj.AddonList(addonIdx).DateInstalled = char(datetime("now")); obj.AddonList(addonIdx).FilePath = pkgInstallationDir; - - % Addon is added using this addon manager. Addon should - % therefore be added to the Matlab search path when this - % class is initialized. (assume it should not permanently be - % saved to the search path) + obj.AddonList(addonIdx).InstallationType = 'folder'; + obj.AddonList(addonIdx).ToolboxIdentifier = ''; obj.AddonList(addonIdx).AddToPathOnInit = true; - + obj.addAddonToMatlabPath(addonIdx) + obj.AddonList(addonIdx).IsOnPath = true; + obj.saveAddonList(); tf = true; end - + + % Download (and install) specified Add-On function downloadAddon(obj, addonIdx, updateFlag, throwErrorIfFails) - %downloadAddon Download addon to a specified addon folder - + %downloadAddon Install an addon via matbox. if nargin < 3; updateFlag = false; end if nargin < 4; throwErrorIfFails = false; end - - if isa(updateFlag, 'char') && strcmp(updateFlag, 'update') + if ischar(updateFlag) && strcmp(updateFlag, 'update') updateFlag = true; end - - % Get addon entry from the given addon index + addonIdx = obj.getAddonIndex(addonIdx); addonEntry = obj.AddonList(addonIdx); - - % Create a temporary path for storing the downloaded file. - fileType = obj.getFileTypeFromUrl(addonEntry); - tempFilepath = [tempname, fileType]; - - % Download the file containing the addon toolbox + sourceUri = string(addonEntry.Source); + try - tempFilepath = websave(tempFilepath, addonEntry.DownloadUrl); - fileCleanupObj = onCleanup( @(fname) delete(tempFilepath) ); + installResult = obj.installViaMatbox( ... + sourceUri, updateFlag); catch ME if throwErrorIfFails rethrow(ME) + else + warning('NANSEN:AddonManager:InstallFailed', ... + 'Failed to install %s: %s', addonEntry.Name, ME.message) + return end end - - if updateFlag && ~isempty(addonEntry.FilePath) - pkgInstallationDir = addonEntry.FilePath; - %rootDir = utility.path.getAncestorDir(pkgInstallationDir); - - % Delete current version - if isfolder(pkgInstallationDir) - if contains(path, pkgInstallationDir) - rmpath(genpath(pkgInstallationDir)) - end - try - rmdir(pkgInstallationDir, 's') - catch - warning('Could not remove old installation... Please report') - end - end - else - - switch addonEntry.Type - case 'General' - subfolderPath = 'general_toolboxes'; - case 'Neuroscience' - subfolderPath = 'neuroscience_toolboxes'; - end - - % Create a pathstring for the installation directory - rootDir = fullfile(obj.InstallationDir, subfolderPath); - pkgInstallationDir = fullfile(rootDir, addonEntry.Name); - end - - switch fileType - case '.zip' - unzip(tempFilepath, pkgInstallationDir); - case '.mltbx' - obj.installMatlabToolbox(tempFilepath) % Todo: pass updateFlag - end - - % Delete the temp zip file - clear fileCleanupObj - - % Fix github unzipped directory... - if strcmp(addonEntry.Source, 'Github') - renamedDir = obj.restructureUnzippedGithubRepo(pkgInstallationDir); - pkgInstallationDir = renamedDir; - end - obj.AddonList(addonIdx).FilePath = pkgInstallationDir; - - % Addon is added using this addon manager. Addon should - % therefore be added to the Matlab search path when this - % class is initialized. (assume it should not permanently be - % saved to the search path) + obj.AddonList(addonIdx).FilePath = obj.getCharOrEmpty(installResult.FilePath); + obj.AddonList(addonIdx).InstallationType = char(installResult.InstallationType); + obj.AddonList(addonIdx).ToolboxIdentifier = char(installResult.ToolboxIdentifier); obj.AddonList(addonIdx).AddToPathOnInit = true; + obj.AddonList(addonIdx).IsInstalled = true; + obj.AddonList(addonIdx).IsOnPath = true; + obj.AddonList(addonIdx).DateInstalled = char(datetime("now")); + obj.addAddonToMatlabPath(addonIdx) obj.markDirty() - addpath(genpath(pkgInstallationDir)) + % Run named setup function if specified (matbox only runs setup.m) try - % Run setup of package if it has a setup function. - if ~isempty(obj.AddonList(addonIdx).SetupFileName) - setupFcn = str2func(obj.AddonList(addonIdx).SetupFileName); - setupFcn() + if ~isempty(addonEntry.SetupFunctionName) + feval(addonEntry.SetupFunctionName) end catch MECause - rmpath(genpath(pkgInstallationDir)) - rmdir(pkgInstallationDir, "s") if throwErrorIfFails - ME = MException("Nansen:AddonInstallFailed", 'Setup of the toolbox %s failed.', addonEntry.Name); + ME = MException("Nansen:AddonInstallFailed", ... + 'Setup of the toolbox %s failed.', addonEntry.Name); ME = ME.addCause(MECause); - disp(getReport(MECause, 'extended')) throw(ME) else warning('Setup of the toolbox %s failed with the following error:', addonEntry.Name) disp(getReport(MECause, 'extended')) end end - - obj.AddonList(addonIdx).IsInstalled = true; - obj.AddonList(addonIdx).DateInstalled = char(datetime("now")); end - - function updateSearchPath(obj) - %updateSearchPath Add addons to the search path in the current matlab session - - for i = 1:numel(obj.AddonList) - % Only add those who have filepath assigned (those are added from this interface) - if obj.AddonList(i).AddToPathOnInit - obj.addAddonToMatlabPath(i) - end + + % Check if specified Add-On is installed + function tf = isAddonInstalled(obj, addonName) + %isAddonInstalled Check if addon is tracked as installed. + + tf = false; + if any(strcmp({obj.AddonList.Name}, addonName)) + addonIndex = obj.getAddonIndex(addonName); + tf = obj.AddonList(addonIndex).IsInstalled; end end - - function addAddonToMatlabPath(obj, addonIdx) - - addonIdx = obj.getAddonIndex(addonIdx); - pathList = genpath(obj.AddonList(addonIdx).FilePath); - - % Remove all .git subfolders from this list - pathListCell = strsplit(pathList, pathsep); - keep = ~contains(pathListCell, '.git'); - pathListCell = pathListCell(keep); - pathListNoGit = strjoin(pathListCell, pathsep); - % Add all remaining folders to path. - addpath(pathListNoGit); - end - - function addAllToMatlabPath(obj) - + function ensureAddonDependenciesOnPath(obj) + %ensureAddonDependenciesOnPath Activate all tracked installed addons. for i = 1:numel(obj.AddonList) - - % Only add those who have filepath assigned (those are added from this interface) - if ~isempty(obj.AddonList(i).FilePath) + if obj.AddonList(i).IsInstalled obj.addAddonToMatlabPath(i) + obj.AddonList(i).IsOnPath = obj.isAddonOnPath(obj.AddonList(i)); end end end - + function restoreAddToPathOnInitFlags(obj) - + %restoreAddToPathOnInitFlags Clear session path flags after permanent save. for i = 1:numel(obj.AddonList) - - % Only add those who have filepath assigned (those are added from this interface) if obj.AddonList(i).IsInstalled if obj.AddonList(i).AddToPathOnInit obj.AddonList(i).AddToPathOnInit = false; end end end - obj.saveAddonList() end - - function checkAddonDuplication(obj) - for i = 1:numel(obj.AddonList) - - pathStr = which( obj.AddonList(i).FunctionName, '-all'); - - if isa(pathStr, 'cell') && numel(pathStr) > 1 - obj.AddonList(i).IsDoubleInstalled = true; - end - end - end - - % Not implemented yet. Future todo - function runAddonSetup(obj, addonIdx) - - end - - function TF = isAddonInstalled(obj, addonName) - % isAddonInstalled - Check if addon is installed - TF = any(strcmp({obj.AddonList.Name}, addonName)); - - % Todo/Question: - % Should we look for whether name of package is present? - % Or, look for a function in the package and check if it is - % on path...? - end - - % Not implemented (Not urgent): - function TF = isAddonUpToDate(obj) - %isAddonRecent - % Check if version is latest...? - end - function markDirty(obj) obj.IsDirty = true; end - + function markClean(obj) obj.IsDirty = false; end end - methods (Access = protected) - - function addonIdx = getAddonIndex(obj, addonIdx) - %getAddonIndex Get index (number) of addon in list given addon name - - if isa(addonIdx, 'char') - addonIdx = strcmpi({obj.AddonList.Name}, addonIdx); + methods % Set/get + function managedAddons = get.ManagedAddons(obj) + T = struct2table(obj.AddonList); + numberColumn = table((1:size(T,1))', 'VariableNames', {'Num'}); + managedAddons = [numberColumn T]; + + % Enrich name with link to online documentation if present + for i = 1:height(managedAddons) + if ~isempty(managedAddons{i, 'DocsSource'}{1}) + name = nansen.internal.utility.createCommandWindowWebLink(... + managedAddons{i, 'DocsSource'}{1}, managedAddons{i, 'Name'}{1}); + managedAddons{i, 'Name'} = {name}; + end end - - if isempty(addonIdx) - error('Something went wrong, addon was not found in list.') + + managedAddons = removevars(managedAddons, ... + ["Source", "DocsSource", "SetupFunctionName", "InstallCheck", ... + "ToolboxIdentifier","AddToPathOnInit", "HasMultipleInstancesOnPath"]); + managedAddons.Name = string(managedAddons.Name); + managedAddons.Description = string(managedAddons.Description); + managedAddons = movevars(managedAddons, "IsOnPath", "After", "IsInstalled"); + managedAddons = movevars(managedAddons, "Description", "After", "IsOnPath"); + end + + function addonEntries = getManagedAddonsForModules(obj, modules) + %getManagedAddonsForModules Return managed addons relevant for selected modules. + arguments + obj (1,1) nansen.config.addons.AddonManager + modules (1,:) string = string.empty end + + resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ... + "DependencyTypes", "community-toolbox", ... + "SelectedModules", modules, ... + "TrackedAddons", obj.AddonList); + addonNames = string({resolvedRequirements.Name}); + isMatch = ismember(string({obj.AddonList.Name}), addonNames); + addonEntries = obj.AddonList(isMatch); end end - - methods (Hidden, Access = protected) - - function pathStr = getPathForAddonList(obj, prefDir) - %getPathForAddonList Get path where local addon list is saved. - - if nargin < 2 || isempty(prefDir) - prefDir = fullfile(nansen.prefdir, 'settings'); + + methods (Hidden) + function downloadAndInstallMatBox(obj) + installResult = obj.createInstallResult("", "folder", ""); + wasInstalledNow = false; + + if ~exist('+matbox/installRequirements', 'file') + sourceFile = 'https://raw.githubusercontent.com/ehennestad/matbox-actions/refs/heads/main/install-matbox/installMatBox.m'; + filePath = websave('installMatBox.m', sourceFile); + [installationFolder, installationMethod] = installMatBox('commit'); + installResult = obj.createInstallResult(installationFolder, installationMethod, ""); + wasInstalledNow = true; + rehash() + delete(filePath); + else + matboxFunctionPath = which('matbox.VersionNumber'); + if ~isempty(matboxFunctionPath) + matboxRootFolder = fileparts(fileparts(matboxFunctionPath)); + installResult = obj.createInstallResult(matboxRootFolder, "folder", ""); + end + end + + addonIdx = obj.getAddonIndex('MatBox'); + addonEntry = obj.AddonList(addonIdx); + + hasChanges = false; + filePath = obj.getCharOrEmpty(installResult.FilePath); + installationType = obj.getCharOrEmpty(installResult.InstallationType); + + if ~addonEntry.IsInstalled + obj.AddonList(addonIdx).IsInstalled = true; + hasChanges = true; + end + if ~addonEntry.IsOnPath + obj.AddonList(addonIdx).IsOnPath = true; + hasChanges = true; + end + if ~isempty(filePath) && ~strcmp(addonEntry.FilePath, filePath) + obj.AddonList(addonIdx).FilePath = filePath; + hasChanges = true; + end + if ~isempty(installationType) && ~strcmp(addonEntry.InstallationType, installationType) + obj.AddonList(addonIdx).InstallationType = installationType; + hasChanges = true; + end + if ~strcmp(addonEntry.ToolboxIdentifier, '') + obj.AddonList(addonIdx).ToolboxIdentifier = ''; + hasChanges = true; + end + if wasInstalledNow && ~addonEntry.AddToPathOnInit + obj.AddonList(addonIdx).AddToPathOnInit = true; + hasChanges = true; + end + if ~addonEntry.IsInstalled || ... + (ischar(addonEntry.DateInstalled) && strcmp(addonEntry.DateInstalled, 'N/A')) + obj.AddonList(addonIdx).DateInstalled = char(datetime("now")); + hasChanges = true; end - if ~isfolder(prefDir); mkdir(prefDir); end - pathStr = fullfile(prefDir, 'installed_addons.mat'); + if hasChanges + obj.markDirty() + obj.saveAddonList() + end end - - function fileType = getFileTypeFromUrl(obj, addonEntry) - %getFileTypeFromUrl Get filetype from the url download entry. - downloadUrl = addonEntry.DownloadUrl; - - % Todo: Does this generalize well? - switch addonEntry.Source - - case 'FileExchange' - [~, fileType, ~] = fileparts(downloadUrl); - fileType = strcat('.', fileType); - case 'Github' - [~, ~, fileType] = fileparts(downloadUrl); + + function viewFullAddonListAsTable(obj) + disp( struct2table(obj.AddonList) ) + end + end + + methods (Access = private) + + function loadAddonManifest(obj) + %loadAddonManifest - Load addon manifest from file. + wasMigrated = false; + if isfile(obj.ManifestFilePath) + jsonText = fileread(obj.ManifestFilePath); + savedData = jsondecode(jsonText); + addonList = savedData.AddonList; + if iscell(addonList) + addonList = [addonList{:}]; + end + else + [addonList, wasMigrated] = obj.tryMigrateFromLegacyLocation(); + end + obj.AddonList = addonList; + + if wasMigrated + obj.saveAddonList() end end - - % Following functions are not implemented - function downloadGithubAddon(obj, addonName) - + + function mergeRequirementsIntoAddonList(obj, resolvedRequirements) + % mergeRequirementsIntoAddonList - Sync addon list with resolved dependencies. + if nargin < 2 + resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ... + "DependencyTypes", "community-toolbox"); + end + + if isempty(resolvedRequirements) + return + end + + resolvedNames = string({resolvedRequirements.Name}); + currentAddonNames = string({obj.AddonList.Name}); + + for idx = 1:numel(resolvedRequirements) + entry = resolvedRequirements(idx); + addonIdx = find(currentAddonNames == resolvedNames(idx), 1); + + if isempty(addonIdx) + addonIdx = numel(obj.AddonList) + 1; + obj.AddonList(addonIdx) = obj.createAddonEntry(entry); + currentAddonNames(end+1) = resolvedNames(idx); %#ok + end + + obj.AddonList(addonIdx).Name = char(entry.Name); + obj.AddonList(addonIdx).IsRequired = entry.RequirementLevel == "required"; + obj.AddonList(addonIdx).Description = char(entry.Description); + obj.AddonList(addonIdx).Source = char(entry.Source); + obj.AddonList(addonIdx).DocsSource = char(entry.DocsSource); + obj.AddonList(addonIdx).SetupFunctionName = char(entry.SetupHook); + obj.AddonList(addonIdx).InstallCheck = char(entry.InstallCheck); + obj.AddonList(addonIdx).IsInstalled = entry.IsInstalled; + obj.AddonList(addonIdx).IsOnPath = entry.IsOnPath; + + if obj.AddonList(addonIdx).IsInstalled && strcmp(obj.AddonList(addonIdx).DateInstalled, 'N/A') + obj.AddonList(addonIdx).DateInstalled = char(datetime("now")); + end + end end - - function downloadMatlabAddon(obj, addonName) - + + function checkAddonDuplication(obj) + %checkAddonDuplication Detect addons with multiple path locations. + for i = 1:numel(obj.AddonList) + probeName = obj.AddonList(i).InstallCheck; + obj.AddonList(i).HasMultipleInstancesOnPath = false; + if isempty(probeName); continue; end + pathStr = which(probeName, '-all'); + if isa(pathStr, 'cell') && numel(pathStr) > 1 + obj.AddonList(i).HasMultipleInstancesOnPath = true; + end + end end - function installGithubAddon(obj, addonName) - + function addAddonToMatlabPath(obj, addonIdx) + %addAddonToMatlabPath Activate an installed addon for this MATLAB session. + addonIdx = obj.getAddonIndex(addonIdx); + addonEntry = obj.AddonList(addonIdx); + installationType = string(addonEntry.InstallationType); + + if installationType == "mltbx" + obj.enableToolboxAddon(addonEntry) + return + end + + addonFilePath = addonEntry.FilePath; + if isempty(addonFilePath) || ~isfolder(addonFilePath) + return + end + pathList = genpath(addonFilePath); + pathListCell = strsplit(pathList, pathsep); + keep = ~contains(pathListCell, '.git'); + pathListCell = pathListCell(keep); + pathListNoGit = strjoin(pathListCell, pathsep); + addpath(pathListNoGit); end - - function installMatlabAddon(obj, addonName) - + + function addonIdx = getAddonIndex(obj, addonIdx) + %getAddonIndex Get index of addon by name or pass through numeric index. + if ischar(addonIdx) || isstring(addonIdx) + addonIdx = find(strcmpi({obj.AddonList.Name}, addonIdx)); + end + if isempty(addonIdx) + error('NANSEN:AddonManager:NotFound', ... + 'Addon was not found in list.') + end end - - function installMatlabToolbox(obj, fileName) - - % Will install to the default matlab toolbox/addon directory. - newAddon = matlab.addons.install(fileName); - -% NEWADDON is a table of strings with these fields: -% Name - Name of the installed add-on -% Version - Version of the installed add-on -% Enabled - Whether the add-on is enabled -% Identifier - Unique identifier of the installed add-on - + + function installResult = installViaMatbox(obj, sourceUri, doUpdate) + %installViaMatbox Delegate installation to matbox based on URI type. + + installResult = matbox.setup.installFromSourceUri( ... + sourceUri, ... + "InstallationLocation", obj.InstallationFolder, ... + "AddToPath", true, ... + "Update", doUpdate, ... + "Verbose", true, ... + "AgreeToLicense", true); + + assert( isfield(installResult, 'FilePath') && ... + isfield(installResult, 'InstallationType') && ... + isfield(installResult, 'ToolboxIdentifier')) end end - - methods (Hidden) - - function showAddonFiletype(obj) - %showAddonFiletype Show the filetype of the downloaded addon files - % - % Method for testing/verification - - for i = 1:numel(obj.AddonList) - thisAddon = obj.AddonList(i); - fileType = obj.getFileTypeFromUrl(thisAddon); - - fprintf('%s : %s\n', thisAddon.Name, fileType) + + methods (Access = private) + function addonEntry = createAddonEntry(obj, entry) + addonEntry = obj.getDefaultAddonEntry(); + + addonEntry.Name = char(entry.Name); + addonEntry.IsRequired = entry.RequirementLevel == "required"; + addonEntry.IsInstalled = entry.IsInstalled; + addonEntry.IsOnPath = entry.IsOnPath; + addonEntry.DateInstalled = 'N/A'; + addonEntry.FilePath = ''; + addonEntry.Description = char(entry.Description); + addonEntry.Source = char(entry.Source); + addonEntry.DocsSource = char(entry.DocsSource); + addonEntry.SetupFunctionName = char(entry.SetupHook); + addonEntry.InstallCheck = char(entry.InstallCheck); + addonEntry.InstallationType = ''; + addonEntry.ToolboxIdentifier = ''; + addonEntry.AddToPathOnInit = false; + addonEntry.HasMultipleInstancesOnPath = false; + + if entry.IsInstalled + addonEntry.DateInstalled = char(datetime("now")); + end + end + + function tf = isAddonOnPath(~, addonEntry) + tf = false; + if ~isempty(addonEntry.InstallCheck) + tf = ~isempty(which(addonEntry.InstallCheck)); + end + end + + function enableToolboxAddon(~, addonEntry) + if ~isempty(addonEntry.InstallCheck) && ~isempty(which(addonEntry.InstallCheck)) + return + end + if ~isempty(addonEntry.ToolboxIdentifier) + try + matlab.addons.enableAddon(addonEntry.ToolboxIdentifier) + return + catch + end + end + if ~isempty(addonEntry.Name) + matlab.addons.enableAddon(addonEntry.Name) end end end - methods (Static) - function checkIfAddonsAreOnPath() - - import nansen.config.addons.AddonManager + methods (Static, Hidden) + function pathStr = getDefaultInstallationDir() + %getDefaultInstallationDir Get default addon installation directory. + nansen.config.addons.AddonManager.ensureUserpathAvailable() + pathStr = fullfile(userpath, 'Nansen', 'Add-Ons'); + end + function checkIfAddonsAreOnPath() % Todo: Needs improvement, not urgent + %checkIfAddonsAreOnPath Prompt user to add missing addon folders to path. + import nansen.config.addons.AddonManager addonDir = AddonManager.getDefaultInstallationDir(); - - % Get all subfolders two levels down subfolders = utility.path.listSubDir(addonDir, '', {}, 2); - isOnPath = true(size(subfolders)); - if ~isempty(subfolders) for i = 1:numel(subfolders) if ~contains(path, subfolders{i}) - isOnPath(i)=false; + isOnPath(i) = false; end end end - if any(~isOnPath) subfoldersNotOnPath = subfolders(~isOnPath); [~, addonNames] = fileparts(subfoldersNotOnPath); - - msg = sprintf("The following add-ons where not present on the MATLAB path: \n\n%s \n\nDo you want to add them now?", strjoin(addonNames, newline)); + msg = sprintf( ... + "The following add-ons were not present on the MATLAB path:\n\n%s\n\nDo you want to add them now?", ... + strjoin(addonNames, newline)); answer = questdlg(msg, 'Update MATLAB path?'); - - switch answer - case 'Yes' - for i = 1:numel(subfoldersNotOnPath) - addpath(genpath(subfoldersNotOnPath{i})) - end - savepath() + if strcmp(answer, 'Yes') + for i = 1:numel(subfoldersNotOnPath) + addpath(genpath(subfoldersNotOnPath{i})) + end + savepath() end end end end - - methods (Static) + + methods (Static, Access = private) + function addonEntry = getDefaultAddonEntry() + addonEntry = nansen.config.addons.AddonManager.DefaultAddonEntry; + end - function S = initializeAddonList() + function addonEntry = initializeAddonList() %initializeAddonList Create an empty struct with addon fields. - - names = nansen.config.addons.AddonManager.addonFields; - values = repmat({{}}, size(names)); - - structInit = [names; values]; - - S = struct(structInit{:}); + addonEntry = nansen.config.addons.AddonManager.DefaultAddonEntry; + addonEntry(1) = []; end - function pathStr = getDefaultInstallationDir() - %getDefaultInstallationDir Get path to default directory for - % installing addons - - % Assign installation directory. - % QTodo: get "userpath" from preferences? - pathStr = fullfile(userpath, 'Nansen', 'Add-Ons'); + function ensureUserpathAvailable() + if isempty(userpath) + nansen.internal.setup.resolveEmptyUserpath() + end end - function folderPath = restructureUnzippedGithubRepo(folderPath) - %restructureUnzippedGithubRepo Move the folder of a github addon. - % - - % Github packages unzips to a new folder within the created - % folder. Move it up one level. Also, remove the '-master' from - % foldername. - - rootDir = fileparts(folderPath); - - % Find the repository folder - L = dir(folderPath); - L = L(~strncmp({L.name}, '.', 1)); + function newAddonList = migrateLegacyAddonList(oldAddonList) + %migrateLegacyAddonList Remap legacy addon struct fields to new names. + % Handles old addon lists that have DownloadUrl, WebUrl, + % SetupFileName, FunctionName fields. - if numel(L) > 1 - % This is unexpected, there should only be one folder. - return - end + import nansen.config.addons.AddonManager - % Move folder up one level - oldDir = fullfile(folderPath, L.name); - newDir = fullfile(rootDir, L.name); - movefile(oldDir, newDir) - rmdir(folderPath) + newAddonList = AddonManager.initializeAddonList(); + if isempty(oldAddonList); return; end + + if isfield(oldAddonList, 'DownloadUrl') && ~isfield(oldAddonList, 'Source') - % Remove the master postfix from foldername - if contains(L.name, '-master') - newName = strrep(L.name, '-master', ''); - elseif contains(L.name, '-main') - newName = strrep(L.name, '-main', ''); + for i = 1:numel(oldAddonList) + newAddonList(i) = AddonManager.getDefaultAddonEntry(); + + newAddonList(i).Name = oldAddonList(i).Name; + newAddonList(i).Description = oldAddonList(i).Description; + newAddonList(i).Source = oldAddonList(i).DownloadUrl; + newAddonList(i).DocsSource = oldAddonList(i).WebUrl; + newAddonList(i).SetupFunctionName = oldAddonList(i).SetupFileName; + newAddonList(i).InstallCheck = oldAddonList(i).FunctionName; + newAddonList(i).IsInstalled = oldAddonList(i).IsInstalled; + newAddonList(i).DateInstalled = oldAddonList(i).DateInstalled; + newAddonList(i).FilePath = oldAddonList(i).FilePath; + + if ~isempty(oldAddonList(i).FilePath) && isfolder(oldAddonList(i).FilePath) + newAddonList(i).InstallationType = 'folder'; + end + end else - folderPath = fullfile(rootDir, L.name); - return + newAddonList = oldAddonList; end - - % Rename folder to remove main/master tag - renamedDir = fullfile(rootDir, newName); - if isfolder(renamedDir) - rmdir(renamedDir, 's') + end + + function [addonList, wasMigrated] = tryMigrateFromLegacyLocation() + %tryMigrateFromLegacyLocation Migrate addon list from legacy .mat location. + wasMigrated = false; + legacyMatPath = fullfile(prefdir, 'Nansen', 'default', 'settings', 'installed_addons.mat'); + if isfile(legacyMatPath) + loadedData = load(legacyMatPath); + addonList = loadedData.AddonList; + addonList = nansen.config.addons.AddonManager.migrateLegacyAddonList(addonList); + wasMigrated = true; + else + addonList = nansen.config.addons.AddonManager.initializeAddonList(); end - movefile(newDir, renamedDir) - folderPath = renamedDir; end - end - methods (Static, Access = private) function pathStr = getDefaultInstallationDirLegacy() - % Note: This method will be removed in a future version (todo). + % Note: This method will be removed in a future version. pathStr = fullfile(nansen.rootpath, 'external'); end end - methods (Access = ?nansen.internal.user.NansenUserSession) - % Note: This method will be removed in a future version (todo). + methods (Static, Access = private) + + function pathStr = getPathForAddonManifest() + %getPathForAddonManifest Get path where local addon list is saved. + nansen.config.addons.AddonManager.ensureUserpathAvailable() + nansenDirectory = fullfile(userpath, 'Nansen'); + if ~isfolder(nansenDirectory); mkdir(nansenDirectory); end + pathStr = fullfile(nansenDirectory, 'installed_addons.json'); + end + + function installResult = createInstallResult(filePath, installationType, toolboxIdentifier) + if nargin < 3 + toolboxIdentifier = ""; + end + + filePath = string(filePath); + installationType = string(installationType); + toolboxIdentifier = string(toolboxIdentifier); + + if isempty(filePath) || any(ismissing(filePath)) + filePath = ""; + end + if isempty(installationType) || any(ismissing(installationType)) + installationType = "folder"; + end + if isempty(toolboxIdentifier) || any(ismissing(toolboxIdentifier)) + toolboxIdentifier = ""; + end + + installResult = struct( ... + 'FilePath', filePath, ... + 'InstallationType', installationType, ... + 'ToolboxIdentifier', toolboxIdentifier); + end + + function value = getCharOrEmpty(stringValue) + stringValue = string(stringValue); + if isempty(stringValue) || any(ismissing(stringValue)) + value = ''; + else + value = char(stringValue); + end + end + end + + methods (Hidden) % For backwards compatibility. Will be removed in future moveExternalToolboxes(obj) % Method in separate file end - methods (Static, Access = ?nansen.internal.user.NansenUserSession) + methods (Static, Hidden) % For backwards compatibility. Will be removed in future function tf = existExternalToolboxInRepository() - % Note: This method will be removed in a future version (todo). + % Note: This method will be removed in a future version. rootDir = fullfile(nansen.rootpath, 'external'); tf = isfolder(fullfile(rootDir, 'general_toolboxes')) || ... isfolder(fullfile(rootDir, 'neuroscience_toolboxes')); diff --git a/code/+nansen/+config/+addons/Contents.m b/code/+nansen/+config/+addons/Contents.m new file mode 100644 index 00000000..fc972a40 --- /dev/null +++ b/code/+nansen/+config/+addons/Contents.m @@ -0,0 +1,16 @@ +% Overview of nansen.config.addons namespace +% Managed addon installation and configuration for NANSEN. +% +% This package contains the stateful addon-management layer. It tracks +% managed addons, persists enriched addon records, installs and updates +% community toolboxes, and provides addon-management UI components. +% +% Files +% AddonPreferences - Preferences for addon management. +% +% Classes +% AddonManager - Track, install, update, and activate managed addons. +% AddonManagerUI - UI table for addon management. +% AddonManagerApp - App wrapper for the addon manager UI. +% +% See also nansen.internal.dependencies diff --git a/code/+nansen/+config/+addons/README.md b/code/+nansen/+config/+addons/README.md new file mode 100644 index 00000000..b662aab9 --- /dev/null +++ b/code/+nansen/+config/+addons/README.md @@ -0,0 +1,20 @@ +# `nansen.config.addons` + +This namespace contains the stateful addon-management layer for NANSEN. + +Its responsibilities are: + +- persist enriched addon records in `AddonList` +- install, update, and locate managed addons +- activate addons for the current MATLAB session +- provide addon-management UI + +It consumes resolved dependency information from +`nansen.internal.dependencies`, but it owns installation side effects and +persisted addon state. + +Main entry points: + +- `AddonManager` +- `AddonManagerUI` +- `AddonManagerApp` diff --git a/code/+nansen/+config/+addons/getDefaultAddonList.m b/code/+nansen/+config/+addons/getDefaultAddonList.m deleted file mode 100644 index c6ffc5ba..00000000 --- a/code/+nansen/+config/+addons/getDefaultAddonList.m +++ /dev/null @@ -1,170 +0,0 @@ -function S = getDefaultAddonList() -%getAddonList Return a list of addons that are needed - -% Name : Name of addon toolbox -% Source : Where to download addon from (FileExchange or GitHub) -% WebUrl : Web Url for addon download -% HasSetupFile : Is there a setup file that should be run? -% SetupName : Name of setup file if there are any -% FunctionName : Name of function in repository (used to check if repository already exists on matlab's path) - -% Todo: Add git commit id, and use it for checking if latest version is -% downloaded... -% Use git instead of downloading zipped versions of repositories... - - i = 1; - - S(i).Name = 'YAML-Matlab'; - S(i).Description = 'Reading in and writing out a yaml file.'; - S(i).IsRequired = true; - S(i).Type = 'General'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/ewiger/yamlmatlab'; - S(i).DownloadUrl = 'https://github.com/ehennestad/yamlmatlab/archive/refs/heads/master.zip'; % Fixed some bugs with original - S(i).HasSetupFile = false; - S(i).SetupFileName = 'nansen.internal.setup.addYamlJarToJavaClassPath'; - S(i).FunctionName = 'yaml.WriteYaml'; - - i = i + 1; - S(i).Name = 'TIFFStack'; - S(i).Description = 'Package for creating virtual tiff stack (Used for ScanImage tiff files).'; - S(i).IsRequired = false; - S(i).Type = 'General'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/DylanMuir/TIFFStack'; - S(i).DownloadUrl = 'https://github.com/DylanMuir/TIFFStack/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'TIFFStack'; - - i = i + 1; - S(i).Name = 'CaImAn-Matlab'; - S(i).Description = 'A Computational toolbox for large scale Calcium Imaging data Analysis'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/flatironinstitute/CaImAn-MATLAB'; - S(i).DownloadUrl = 'https://github.com/flatironinstitute/CaImAn-MATLAB/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'CNMFSetParms'; - i = i + 1; - - S(i).Name = 'suite2P-Matlab'; - S(i).Description = 'Fast, accurate and complete two-photon pipeline'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/cortex-lab/Suite2P'; - S(i).DownloadUrl = 'https://github.com/cortex-lab/Suite2P/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'build_ops3'; - - i = i + 1; - S(i).Name = 'EXTRACT'; - S(i).Description = 'Tractable and Robust Automated Cell extraction Tool for calcium imaging'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/schnitzer-lab/EXTRACT-public'; - S(i).DownloadUrl = 'https://github.com/schnitzer-lab/EXTRACT-public/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'run_extract'; - S(i).RequiredToolboxes = { ... - 'Bioinformatics Toolbox', ... - 'Econometrics Toolbox', ... - 'Image Processing Toolbox', ... - 'Parallel Computing Toolbox', ... - 'Signal Processing Toolbox', ... - 'Statistics and Machine Learning Toolbox', ... - 'Wavelet Toolbox' }; - - i = i + 1; - S(i).Name = 'NoRMCorre'; - S(i).Description = ' Non-Rigid Motion Correction for calcium imaging data'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/flatironinstitute/NoRMCorre'; - %S(i).DownloadUrl = 'https://github.com/flatironinstitute/NoRMCorre/archive/refs/heads/master.zip'; - S(i).DownloadUrl = 'https://github.com/ehennestad/NoRMCorre/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'normcorre_batch'; - - i = i + 1; - S(i).Name = 'Flow Registration'; - S(i).Description = ' Optical Flow based correction for calcium imaging data'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/phflot/flow_registration'; - S(i).DownloadUrl = 'https://github.com/phflot/flow_registration/archive/refs/heads/master.zip'; - S(i).HasSetupFile = true; - S(i).SetupFileName = 'nansen.wrapper.flowreg.install'; %'set_path'; - S(i).FunctionName = 'OF_Options'; - - i = i + 1; - S(i).Name = 'SEUDO'; - S(i).Description = 'Calcium signal decontamination'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/adamshch/SEUDO'; - S(i).DownloadUrl = 'https://github.com/adamshch/SEUDO/archive/refs/heads/master.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'globalSEUDOWrapper.m'; - - i = i + 1; - S(i).Name = 'Neurodata Without Borders'; - S(i).Description = 'A Matlab interface for reading and writing NWB files'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/NeurodataWithoutBorders/matnwb'; - %S(i).DownloadUrl = 'https://github.com/NeurodataWithoutBorders/matnwb/archive/refs/heads/master.zip'; - S(i).DownloadUrl = 'https://github.com/ehennestad/matnwb/archive/refs/heads/master.zip'; - S(i).HasSetupFile = true; - S(i).SetupFileName = ''; - S(i).FunctionName = 'nwbRead.m'; - - i = i + 1; - S(i).Name = 'Brain Observatory Toolbox'; - S(i).Description = 'A MATLAB toolbox for interacting with the Allen Brain Observatory'; - S(i).IsRequired = false; - S(i).Type = 'Neuroscience'; - S(i).Source = 'Github'; - S(i).WebUrl = 'https://github.com/emeyers/Brain-Observatory-Toolbox'; - S(i).DownloadUrl = 'https://github.com/emeyers/Brain-Observatory-Toolbox/archive/refs/heads/main.zip'; - S(i).HasSetupFile = false; - S(i).SetupFileName = ''; - S(i).FunctionName = 'EphysQuickstart.mlx'; - -% % i = i + 1 % Not implemented yet -% % S(i).Name = 'PatchWarp'; -% % S(i).Description = 'Image processing pipeline to correct motion artifacts and complex image distortions in neuronal calcium imaging data.'; -% % S(i).IsRequired = false; -% % S(i).Type = 'Neuroscience'; -% % S(i).Source = 'Github'; -% % S(i).WebUrl = 'https://github.com/ryhattori/PatchWarp'; -% % S(i).DownloadUrl = 'https://github.com/ryhattori/PatchWarp/archive/refs/heads/main.zip'; -% % S(i).HasSetupFile = false; -% % S(i).SetupFileName = ''; -% % S(i).FunctionName = 'patchwarp.m'; - -end - -% % i = i + 1 % Not implemented yet -% % S(i).Name = 'DABEST'; -% % S(i).Description = ''; -% % S(i).IsRequired = false; -% % S(i).Type = 'Statistics'; -% % S(i).Source = 'Github'; -% % S(i).WebUrl = 'https://github.com/ACCLAB/DABEST-Matlab'; -% % S(i).DownloadUrl = 'https://github.com/ACCLAB/DABEST-Matlab/archive/refs/heads/master.zip'; -% % S(i).HasSetupFile = false; -% % S(i).SetupFileName = ''; -% % S(i).FunctionName = 'dabest.m'; diff --git a/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m b/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m index 569c87a4..74e11ade 100644 --- a/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m +++ b/code/+nansen/+config/+module/@ModuleManager/ModuleManager.m @@ -108,6 +108,12 @@ function getModuleList(obj) modules{i}.PackageName = modulePackageName; modules{i}.isCoreModule = strcmp(modules{i}.ModuleCategory, 'general'); modules{i}.FolderPath = fileparts( moduleSpecFiles{i}); + requirementManifestPath = fullfile(modules{i}.FolderPath, 'dependencies.nansen.json'); + if isfile(requirementManifestPath) + modules{i}.RequirementManifestPath = requirementManifestPath; + else + modules{i}.RequirementManifestPath = ""; + end end obj.ModuleList = cat(1, modules{:}); diff --git a/code/+nansen/+internal/+dependencies/Contents.m b/code/+nansen/+internal/+dependencies/Contents.m new file mode 100644 index 00000000..ed23ec6e --- /dev/null +++ b/code/+nansen/+internal/+dependencies/Contents.m @@ -0,0 +1,13 @@ +% Overview for nansen.internal.dependencies namespace. +% Dependency manifest utilities for NANSEN. +% +% This package is the stateless dependency layer. It reads dependency +% manifests, resolves dependencies across scopes, and computes dependency +% status for the current MATLAB session. +% +% Functions +% readManifest - Parse a dependencies.nansen.json file. +% resolveRequirements - Resolve, deduplicate, and filter dependencies. +% checkInstallationStatus - Compute IsInstalled and IsOnPath status. +% getRequiredMathworksProducts - List required MathWorks products. +% checkRequiredMathworksProducts - Warn or error on missing MathWorks products. diff --git a/code/+nansen/+internal/+dependencies/README.md b/code/+nansen/+internal/+dependencies/README.md new file mode 100644 index 00000000..c9df8c87 --- /dev/null +++ b/code/+nansen/+internal/+dependencies/README.md @@ -0,0 +1,20 @@ +# `nansen.internal.dependencies` + +This namespace contains the stateless dependency layer for NANSEN. + +Its responsibilities are: + +- read dependency manifests +- resolve dependencies across core and module scopes +- deduplicate and filter dependency requirements +- compute dependency status for the current MATLAB session + +It should not own installation side effects, UI, or persisted addon state. + +Functions: + +- `nansen.internal.dependencies.readManifest` +- `nansen.internal.dependencies.resolveRequirements` +- `nansen.internal.dependencies.checkInstallationStatus` +- `nansen.internal.dependencies.getRequiredMathworksProducts` +- `nansen.internal.dependencies.checkRequiredMathworksProducts` diff --git a/code/+nansen/+internal/+dependencies/checkInstallationStatus.m b/code/+nansen/+internal/+dependencies/checkInstallationStatus.m new file mode 100644 index 00000000..f7aa9fd4 --- /dev/null +++ b/code/+nansen/+internal/+dependencies/checkInstallationStatus.m @@ -0,0 +1,154 @@ +function statusResults = checkInstallationStatus(resolvedRequirements, trackedAddons) +%checkInstallationStatus Check install and path state of resolved requirements. +% +% statusResults = nansen.internal.dependencies.checkInstallationStatus(resolvedRequirements) +% populates the IsInstalled and IsOnPath fields for each dependency. +% +% statusResults = nansen.internal.dependencies.checkInstallationStatus( ... +% resolvedRequirements, trackedAddons) +% uses tracked addon records from AddonManager for richer status checks. +% +% Input: +% resolvedRequirements - struct array from readManifest or +% resolveRequirements. +% trackedAddons - optional AddonManager addon records. +% +% Output: +% statusResults - same struct array with fields populated: +% - IsInstalled: dependency is installed/tracked +% - IsOnPath: dependency is available on MATLAB's search path + + arguments + resolvedRequirements (1,:) struct + trackedAddons (1,:) struct = struct.empty(1, 0) + end + + statusResults = resolvedRequirements; + + if isempty(statusResults) + return + end + + versionInfo = ver(); + installedProductNames = string({versionInfo.Name}); + installedAddonTable = getInstalledAddonsTable(); + + for i = 1:numel(statusResults) + entry = statusResults(i); + trackedEntry = getTrackedAddonEntry(entry, trackedAddons); + + if entry.DependencyType == "mathworks-product" + isInstalled = any(entry.Name == installedProductNames); + isOnPath = isInstalled; + else + isOnPath = isDependencyOnPath(entry); + isInstalled = isOnPath || isTrackedAddonInstalled(trackedEntry, installedAddonTable); + end + + statusResults(i).IsInstalled = isInstalled; + statusResults(i).IsOnPath = isOnPath; + end +end + +function tf = isDependencyOnPath(entry) +%isDependencyOnPath Check if a dependency is available in the current session. + tf = false; + if isfield(entry, 'InstallCheck') && strlength(string(entry.InstallCheck)) > 0 + checkName = char(entry.InstallCheck); + try + answer = which(checkName); + catch ME + warning('NANSEN:Dependencies:InstallCheckFailed', ... + 'Failed to evaluate InstallCheck "%s": %s', checkName, ME.message) + return + end + + if isempty(answer) || strcmp(answer, 'Not on MATLAB path') + tf = false; + elseif isfile(answer) + tf = true; + else + % Our install check should be pointing to a file + tf = false; + warning('NANSEN:Dependencies:InstallCheckUnexpectedWhichOutput', ... + 'InstallCheck "%s" resolved to unexpected output: %s', ... + checkName, answer) + end + end +end + +function trackedEntry = getTrackedAddonEntry(entry, trackedAddons) +%getTrackedAddonEntry Find a matching tracked addon entry by dependency name. + trackedEntry = struct.empty(1, 0); + + if isempty(trackedAddons) || ~isfield(entry, 'Name') || ~isfield(trackedAddons, 'Name') + return + end + + trackedNames = string({trackedAddons.Name}); + matchIndex = find(trackedNames == string(entry.Name), 1, 'first'); + if ~isempty(matchIndex) + trackedEntry = trackedAddons(matchIndex); + end +end + +function tf = isTrackedAddonInstalled(trackedEntry, installedAddonTable) +%isTrackedAddonInstalled Determine whether a tracked addon is installed. + tf = false; + if isempty(trackedEntry) + return + end + + installationType = string(getStructFieldOrDefault(trackedEntry, 'InstallationType', "")); + filePath = string(getStructFieldOrDefault(trackedEntry, 'FilePath', "")); + addonName = string(getStructFieldOrDefault(trackedEntry, 'Name', "")); + toolboxIdentifier = string(getStructFieldOrDefault(trackedEntry, 'ToolboxIdentifier', "")); + + switch installationType + case "folder" + tf = strlength(filePath) > 0 && isfolder(filePath); + case "mltbx" + tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable); + otherwise + if strlength(filePath) > 0 + tf = isfolder(filePath); + elseif strlength(toolboxIdentifier) > 0 || strlength(addonName) > 0 + tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable); + elseif isfield(trackedEntry, 'IsInstalled') + tf = logical(trackedEntry.IsInstalled); + end + end +end + +function tf = isInstalledToolboxAddon(addonName, toolboxIdentifier, installedAddonTable) +%isInstalledToolboxAddon Check if an addon is installed through MATLAB's addon system. + tf = false; + if isempty(installedAddonTable) + return + end + + variableNames = string(installedAddonTable.Properties.VariableNames); + if strlength(toolboxIdentifier) > 0 && ismember("Identifier", variableNames) + tf = any(string(installedAddonTable.Identifier) == toolboxIdentifier); + elseif strlength(addonName) > 0 && ismember("Name", variableNames) + tf = any(string(installedAddonTable.Name) == addonName); + end +end + +function addonsTable = getInstalledAddonsTable() +%getInstalledAddonsTable Return installed MATLAB addons or an empty table. + try + addonsTable = matlab.addons.installedAddons(); + catch + addonsTable = table(); + end +end + +function value = getStructFieldOrDefault(S, fieldName, defaultValue) +%getStructFieldOrDefault Read struct field if present, otherwise return default. + if isfield(S, fieldName) + value = S.(fieldName); + else + value = defaultValue; + end +end diff --git a/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m b/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m new file mode 100644 index 00000000..37d9945c --- /dev/null +++ b/code/+nansen/+internal/+dependencies/checkRequiredMathworksProducts.m @@ -0,0 +1,43 @@ +function checkRequiredMathworksProducts(mode, options) +%checkRequiredMathworksProducts Check for required MathWorks products. +% +% nansen.internal.dependencies.checkRequiredMathworksProducts() checks if the +% required MathWorks products for core NANSEN are installed. +% +% nansen.internal.dependencies.checkRequiredMathworksProducts(mode) uses the +% specified mode ("warning" or "error") for reporting missing products. +% +% nansen.internal.dependencies.checkRequiredMathworksProducts(mode, Name, Value) +% accepts additional options for module-aware checking. +% +% Name-Value Arguments: +% ModuleNames (string array) - Module scope IDs to include in the +% check. Default: string.empty (core only). + + arguments + mode (1,1) string {mustBeMember(mode, ["warning", "error"])} = "warning" + options.ModuleNames (1,:) string = string.empty + end + + % Use the resolver to get missing required MathWorks products + missingProducts = nansen.internal.dependencies.resolveRequirements( ... + "SelectedModules", options.ModuleNames, ... + "DependencyTypes", "mathworks-product", ... + "RequirementLevels", "required", ... + "MissingOnly", true); + + if isempty(missingProducts) + return + end + + missingNames = " - " + [missingProducts.Name]; + + message = sprintf( ... + "The following required MathWorks products are needed for NANSEN " + ... + "to work reliably:\n%s\n\nYou can install these from MATLAB's " + ... + "Add-On Explorer.", ... + strjoin(missingNames, newline)); + + reportFunction = str2func(mode); + reportFunction("NANSEN:MathworksProductCheck:MissingRequiredProducts", message) +end diff --git a/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m b/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m new file mode 100644 index 00000000..5c9efbfc --- /dev/null +++ b/code/+nansen/+internal/+dependencies/getRequiredMathworksProducts.m @@ -0,0 +1,41 @@ +function requiredToolboxNames = getRequiredMathworksProducts(options) +%getRequiredMathworksProducts Get names of required MathWorks toolboxes. +% +% requiredToolboxNames = nansen.internal.dependencies.getRequiredMathworksProducts() +% returns the names of required MathWorks toolboxes for core NANSEN. +% +% requiredToolboxNames = nansen.internal.dependencies.getRequiredMathworksProducts(Name, Value) +% accepts additional options for module-aware checking. +% +% Name-Value Arguments: +% ModuleNames (string array) - Module scope IDs to include. +% Default: string.empty (core only). +% RequirementLevel (string) - Filter by level: "all", "required", +% "optional". Default: "required". +% +% Output: +% requiredToolboxNames (string array) - Names of the required +% MathWorks toolboxes. + + arguments + options.ModuleNames (1,:) string = string.empty + options.RequirementLevel (1,1) string ... + {mustBeMember(options.RequirementLevel, ... + ["all", "required", "optional"])} = "required" + end + + requirementLevels = string.empty; + if options.RequirementLevel ~= "all" + requirementLevels = options.RequirementLevel; + end + resolvedRequirements = nansen.internal.dependencies.resolveRequirements( ... + "SelectedModules", options.ModuleNames, ... + "DependencyTypes", "mathworks-product", ... + "RequirementLevels", requirementLevels); + + if isempty(resolvedRequirements) + requiredToolboxNames = string.empty; + else + requiredToolboxNames = [resolvedRequirements.Name]; + end +end diff --git a/code/+nansen/+internal/+dependencies/readManifest.m b/code/+nansen/+internal/+dependencies/readManifest.m new file mode 100644 index 00000000..40d90e4f --- /dev/null +++ b/code/+nansen/+internal/+dependencies/readManifest.m @@ -0,0 +1,126 @@ +function dependencies = readManifest(manifestFilePath) +%readManifest Parse a dependencies.nansen.json manifest file. +% +% dependencies = nansen.internal.dependencies.readManifest(manifestFilePath) +% reads the manifest at the given path and returns a struct array of +% dependency entries, validated against the schema. +% +% Input: +% manifestFilePath (1,1) string - Absolute path to a +% dependencies.nansen.json file. +% +% Output: +% dependencies - struct array where each element has all schema +% fields. Missing optional fields are filled with defaults. +% +% Errors: +% Throws if the file does not exist, is not valid JSON, or if +% required fields (name, dependencyType, requirementLevel) are +% missing from any entry. + + arguments + manifestFilePath (1,1) string {mustBeFile} = fullfile(nansen.toolboxdir, 'dependencies.nansen.json') + end + + jsonText = fileread(manifestFilePath); + manifestData = jsondecode(jsonText); + + validateManifestStructure(manifestData); + + manifestScope = string(manifestData.scope); + manifestScopeId = string(manifestData.scopeId); + + rawDependencies = manifestData.dependencies; + if isstruct(rawDependencies) + numDependencies = numel(rawDependencies); + else + numDependencies = length(rawDependencies); + end + + dependencies = repmat(getDefaultEntry(), numDependencies, 1); + + for i = 1:numDependencies + if iscell(rawDependencies) + entry = rawDependencies{i}; + else + entry = rawDependencies(i); + end + dependencies(i) = populateEntry(entry, manifestScope, manifestScopeId); + end +end + +function validateManifestStructure(manifestData) +%validateManifestStructure Verify top-level manifest fields exist. + manifestFields = string(fieldnames(manifestData)); + % jsondecode mangles leading underscores, so _schema_version becomes + % x_schema_version in the resulting struct. + requiredPayloadFields = ["scope", "scopeId", "dependencies"]; + hasPayloadFields = all(ismember(requiredPayloadFields, manifestFields)); + hasVersionField = ismember("x_schema_version", manifestFields); + if ~hasPayloadFields || ~hasVersionField + error("NANSEN:Dependencies:InvalidManifest", ... + "Manifest is missing required top-level fields."); + end +end + +function entry = populateEntry(rawEntry, manifestScope, manifestScopeId) +%populateEntry Convert a raw JSON entry to a normalized dependency struct. + entry = getDefaultEntry(); + + % Required fields + requiredFields = ["name", "dependencyType", "requirementLevel"]; + entryFields = string(fieldnames(rawEntry)); + missingFields = setdiff(requiredFields, entryFields); + if ~isempty(missingFields) + entryName = "(unknown)"; + if ismember("name", entryFields) + entryName = rawEntry.name; + end + error("NANSEN:Dependencies:InvalidEntry", ... + "Dependency entry '%s' is missing required fields: %s", ... + entryName, strjoin(missingFields, ", ")); + end + + % Map all camelCase JSON fields to PascalCase struct fields + defaultFieldNames = string(fieldnames(entry)); + for j = 1:numel(entryFields) + pascalName = camelToPascal(entryFields(j)); + if ismember(pascalName, defaultFieldNames) && isfield(rawEntry, entryFields(j)) + entry.(pascalName) = string(rawEntry.(entryFields(j))); + end + end + + % Scope: inherit from manifest if not overridden at entry level + if ~ismember("scope", entryFields) + entry.Scope = manifestScope; + end + if ~ismember("scopeId", entryFields) + entry.ScopeId = manifestScopeId; + end +end + +function pascalName = camelToPascal(camelName) +%camelToPascal Convert camelCase to PascalCase by uppercasing the first letter. + camelName = char(camelName); + pascalName = string([upper(camelName(1)), camelName(2:end)]); +end + +function entry = getDefaultEntry() +%getDefaultEntry Return a dependency struct with all fields set to defaults. + entry = struct( ... + 'Name', "", ... + 'DependencyType', "", ... + 'Scope', "", ... + 'ScopeId', "", ... + 'RequirementLevel', "", ... + 'Source', "", ... + 'DocsSource', "", ... + 'Description', "", ... + 'Reason', "", ... + 'WorkflowNotes', "", ... + 'SetupHook', "", ... + 'StartupHook', "", ... + 'InstallCheck', "", ... + 'VersionConstraint', "" ... + ); +end diff --git a/code/+nansen/+internal/+dependencies/resolveRequirements.m b/code/+nansen/+internal/+dependencies/resolveRequirements.m new file mode 100644 index 00000000..23deb316 --- /dev/null +++ b/code/+nansen/+internal/+dependencies/resolveRequirements.m @@ -0,0 +1,230 @@ +function resolvedRequirements = resolveRequirements(options) +%resolveRequirements Aggregate, deduplicate and check dependencies. +% +% resolvedRequirements = nansen.internal.dependencies.resolveRequirements() +% returns the full resolved requirement set for core NANSEN. +% +% resolvedRequirements = nansen.internal.dependencies.resolveRequirements(Name, Value) +% accepts filtering options. +% +% Name-Value Arguments: +% IncludeCore (logical) - Include core dependencies. Default: true. +% SelectedModules (string array) - Module package names to include. +% SelectedWorkflows (string array) - Workflow ScopeIds to include. +% DependencyTypes (string array) - Filter by DependencyType. +% Empty means all types. +% RequirementLevels (string array) - Filter by RequirementLevel. +% Empty means all levels. +% MissingOnly (logical) - Return only missing dependencies. +% Default: false. +% TrackedAddons (struct array) - Optional tracked addon records from +% AddonManager. Used for richer install/path status checks. +% ManifestPaths (string array) - Explicit manifest file paths. +% When provided, skips automatic manifest discovery and uses +% only the given paths. Useful for testing. +% +% Output: +% resolvedRequirements - struct array with all schema fields plus: +% .IsInstalled (logical) - whether the dependency is tracked as +% installed or otherwise known to be present +% .IsOnPath (logical) - whether the dependency is currently +% available on MATLAB's search path + + arguments + options.IncludeCore (1,1) logical = true + options.SelectedModules (1,:) string = string.empty + options.SelectedWorkflows (1,:) string = string.empty + options.DependencyTypes (1,:) string = string.empty + options.RequirementLevels (1,:) string = string.empty + options.MissingOnly (1,1) logical = false + options.TrackedAddons (1,:) struct = struct.empty(1, 0) + options.ManifestPaths (1,:) string = string.empty + end + + if isempty(options.ManifestPaths) + manifestPaths = collectManifestPaths(options.IncludeCore, options.SelectedModules); + else + manifestPaths = options.ManifestPaths; + end + allDependencies = readAllDependencies(manifestPaths); + + % Filter by scope inclusion + allDependencies = filterByScope(allDependencies, ... + options.IncludeCore, options.SelectedModules, options.SelectedWorkflows); + + % Deduplicate + resolvedRequirements = deduplicateDependencies(allDependencies); + + % Check installation status + resolvedRequirements = nansen.internal.dependencies.checkInstallationStatus( ... + resolvedRequirements, options.TrackedAddons); + + % Apply filters + resolvedRequirements = applyFilters(resolvedRequirements, ... + options.DependencyTypes, options.RequirementLevels, options.MissingOnly); +end + +function manifestPaths = collectManifestPaths(includeCore, selectedModules) +%collectManifestPaths Get manifest file paths from core and selected modules. + manifestPaths = string.empty(1, 0); + + if includeCore + coreManifestPath = fullfile(nansen.toolboxdir, 'dependencies.nansen.json'); + if isfile(coreManifestPath) + manifestPaths(end+1) = string(coreManifestPath); + end + end + + % Module manifests + if ~isempty(selectedModules) + + for i = 1:numel(selectedModules) + + currentModulePath = utility.path.packagename2pathstr(selectedModules(i)); + + moduleInfo = what(currentModulePath); + if isempty(moduleInfo) + warning('NANSEN:Dependencies:ModuleNotFound', ... + 'No module with name "%s" on MATLAB''s search path', selectedModules(i)) + continue + elseif numel(moduleInfo) > 1 + warning('NANSEN:Dependencies:MultipleModulesFound', ... + 'Multiple modules with name "%s" was found on MATLAB''s search path', selectedModules(i)) + end + currentModuleManifestPath = fullfile(moduleInfo(1).path, 'dependencies.nansen.json'); + if isfile(currentModuleManifestPath) + manifestPaths = [manifestPaths, currentModuleManifestPath]; %#ok + end + end + end + + manifestPaths = unique(manifestPaths, "stable"); +end + +function allDependencies = readAllDependencies(manifestPaths) +%readAllDependencies Read and concatenate dependencies from all manifests. + allDependencies = struct([]); + for i = 1:numel(manifestPaths) + if ~isfile(manifestPaths(i)); continue; end + dependencies = nansen.internal.dependencies.readManifest(manifestPaths(i)); + for j = 1:numel(dependencies) + if isempty(allDependencies) + allDependencies = dependencies(j); + else + allDependencies(end+1) = dependencies(j); %#ok + end + end + end +end + +function dependencies = filterByScope(dependencies, includeCore, selectedModules, selectedWorkflows) +%filterByScope Keep only dependencies matching the requested scopes. + if isempty(dependencies) + return + end + keepMask = false(1, numel(dependencies)); + for i = 1:numel(dependencies) + switch dependencies(i).Scope + case "core" + keepMask(i) = includeCore; + case "module" + keepMask(i) = ~isempty(selectedModules) && ... + any(dependencies(i).ScopeId == selectedModules); + case "workflow" + keepMask(i) = ~isempty(selectedWorkflows) && ... + any(dependencies(i).ScopeId == selectedWorkflows); + end + end + dependencies = dependencies(keepMask); +end + +function resolved = deduplicateDependencies(dependencies) +%deduplicateDependencies Merge duplicate entries across scopes. +% Entries with the same Name + DependencyType are merged. When merging: +% - "required" wins over "optional" +% - Reason and WorkflowNotes are concatenated (unique values only) + if isempty(dependencies) + resolved = dependencies; + return + end + + numDependencies = numel(dependencies); + keys = strings(1, numDependencies); + for i = 1:numDependencies + keys(i) = dependencies(i).Name + "|" + dependencies(i).DependencyType; + end + + [~, ~, groupIndices] = unique(keys, 'stable'); + numUnique = max(groupIndices); + resolved = repmat(dependencies(1), 1, numUnique); + + for i = 1:numUnique + groupEntries = dependencies(groupIndices' == i); + merged = groupEntries(1); + for j = 2:numel(groupEntries) + other = groupEntries(j); + if other.RequirementLevel == "required" + merged.RequirementLevel = "required"; + end + merged.Reason = mergeStringField(merged.Reason, other.Reason); + merged.WorkflowNotes = mergeStringField(merged.WorkflowNotes, other.WorkflowNotes); + merged = preferNonEmpty(merged, other, "Source"); + merged = preferNonEmpty(merged, other, "DocsSource"); + merged = preferNonEmpty(merged, other, "Description"); + merged = preferNonEmpty(merged, other, "SetupHook"); + merged = preferNonEmpty(merged, other, "StartupHook"); + merged = preferNonEmpty(merged, other, "InstallCheck"); + end + resolved(i) = merged; + end +end + +function result = mergeStringField(existing, incoming) +%mergeStringField Concatenate two string values, keeping only unique parts. + if existing == "" && incoming == "" + result = ""; + elseif existing == "" + result = incoming; + elseif incoming == "" || existing == incoming + result = existing; + else + result = existing + "; " + incoming; + end +end + +function merged = preferNonEmpty(merged, other, fieldName) +%preferNonEmpty Use the other entry's value if the merged entry's is empty. + if merged.(fieldName) == "" && other.(fieldName) ~= "" + merged.(fieldName) = other.(fieldName); + end +end + +function dependencies = applyFilters(dependencies, dependencyTypes, requirementLevels, missingOnly) +%applyFilters Filter resolved dependencies by type, level, and install state. + if isempty(dependencies) + return + end + keepMask = true(1, numel(dependencies)); + if ~isempty(dependencyTypes) + for i = 1:numel(dependencies) + if ~any(dependencies(i).DependencyType == dependencyTypes) + keepMask(i) = false; + end + end + end + if ~isempty(requirementLevels) + for i = 1:numel(dependencies) + if ~any(dependencies(i).RequirementLevel == requirementLevels) + keepMask(i) = false; + end + end + end + if missingOnly + for i = 1:numel(dependencies) + if dependencies(i).IsInstalled + keepMask(i) = false; + end + end + end + dependencies = dependencies(keepMask); +end diff --git a/code/+nansen/+internal/+setup/Contents.m b/code/+nansen/+internal/+setup/Contents.m new file mode 100644 index 00000000..d29f39f9 --- /dev/null +++ b/code/+nansen/+internal/+setup/Contents.m @@ -0,0 +1,12 @@ +% Overview of nansen.internal.setup namespace +% Environment setup utilities for NANSEN. +% +% This package contains setup-time helpers for preparing the MATLAB +% environment, userpath, Java class path, and other runtime prerequisites. +% +% Functions +% resolveEmptyUserpath - Resolve missing MATLAB userpath. +% addYamlJarToJavaClassPath - Add YAML jar to Java class path. +% addUiwidgetsJarToJavaClassPath - Add Widgets Toolbox jar to Java path. +% isUiwidgetsOnJavapath - Check Widgets Toolbox Java path setup. +% checkWidgetsToolboxVersion - Check Widgets Toolbox version. diff --git a/code/+nansen/+internal/+setup/README.md b/code/+nansen/+internal/+setup/README.md new file mode 100644 index 00000000..90d9127c --- /dev/null +++ b/code/+nansen/+internal/+setup/README.md @@ -0,0 +1,9 @@ +# `nansen.internal.setup` + +This namespace environment setup helpers for NANSEN. + +Its responsibilities are: + +- resolve setup prerequisites such as `userpath` +- configure Java class path dependencies +- host setup-time environment utilities diff --git a/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m b/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m deleted file mode 100644 index f1cbfdc2..00000000 --- a/code/+nansen/+internal/+setup/checkRequiredMathworksProducts.m +++ /dev/null @@ -1,31 +0,0 @@ -function checkRequiredMathworksProducts(mode) -% checkRequiredMathworksProducts - Checks for required Mathworks products. -% -% Syntax: -% nansen.internal.setup.checkRequiredMathworksProducts() Checks if the -% required Mathworks products for NANSEN are installed. - - arguments - mode (1,1) string {mustBeMember(mode, ["warning", "error"])} = "warning" - end - - versionInfo = ver(); - installedToolboxNames = {versionInfo.Name}; - - requiredToolboxNames = nansen.internal.setup.getRequiredMatlabToolboxes(); - - missingToolboxNames = setdiff(requiredToolboxNames, installedToolboxNames); - missingToolboxNames = string(missingToolboxNames); - - if ~isempty(missingToolboxNames) - missingToolboxNames = " - " + missingToolboxNames; - - message = sprintf(... - "The following required Mathworks products are needed for NANSEN " + ... - "to work reliably:\n%s\n\nYou can install these from MATLAB's Add-On Manager.", ... - strjoin(missingToolboxNames, newline)); - - fcn = str2func(mode); - fcn("NANSEN:MathworksProductCheck:MissingRequiredProducts", message) - end -end diff --git a/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m b/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m deleted file mode 100644 index b20e6296..00000000 --- a/code/+nansen/+internal/+setup/getRequiredMatlabToolboxes.m +++ /dev/null @@ -1,6 +0,0 @@ -function requiredToolboxes = getRequiredMatlabToolboxes() -%getRequiredMatlabToolboxes - Get names of required Mathworks Toolboxes - filePath = fullfile(nansen.toolboxdir, 'resources', 'requirements.txt'); - requiredToolboxes = strsplit( fileread(filePath), newline); - requiredToolboxes = requiredToolboxes(cellfun(@(c) ~isempty(c), requiredToolboxes)); -end diff --git a/code/+nansen/+internal/+setup/installAddons.m b/code/+nansen/+internal/+setup/installAddons.m deleted file mode 100644 index 2d002fc1..00000000 --- a/code/+nansen/+internal/+setup/installAddons.m +++ /dev/null @@ -1,17 +0,0 @@ -function installAddons() -% installAddons - Install addons using nansen's addon manager - - addonManager = nansen.AddonManager(); - - for i = 1:numel(addonManager.AddonList) - S = addonManager.AddonList(i); - if ~S.IsInstalled - fprintf('Downloading %s...', S.Name) - addonManager.downloadAddon(S.Name) - addonManager.addAddonToMatlabPath(S.Name) - fprintf('Finished.\n') - end - end - - addonManager.saveAddonList() -end diff --git a/code/+nansen/+internal/+setup/listDependencies.m b/code/+nansen/+internal/+setup/listDependencies.m deleted file mode 100644 index 73b623ee..00000000 --- a/code/+nansen/+internal/+setup/listDependencies.m +++ /dev/null @@ -1,15 +0,0 @@ -function dependencies = listDependencies(flag) -%listDependencies - List the dependencies for NANSEN - - % if nargin < 1; flag = 'required'; end - % flag = validatestring(flag, {'required', 'all'}, 1); - - % Todo: - % Deprecate? - % Generate this table from the requirements.txt file - - rootPath = fullfile(nansen.toolboxdir, 'resources', 'dependencies'); - jsonStr = fileread( fullfile(rootPath, 'fex_submissions.json') ); - - dependencies = struct2table( jsondecode(jsonStr) ); -end diff --git a/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m b/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m index 8492cb5e..69b44ef5 100644 --- a/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m +++ b/code/+nansen/+internal/+user/@NansenUserSession/NansenUserSession.m @@ -89,8 +89,11 @@ function reset() end methods - function am = getAddonManager(obj) - am = obj.AddonManager; + function am = getAddonManager(~) + %getAddonManager Get the AddonManager singleton. + warning("NANSEN:UserSession:DeprecatedMethod", ... + "Deprecated - use nansen.AddonManager() directly") + am = nansen.AddonManager(); end function pm = getProjectManager(obj) @@ -115,7 +118,6 @@ function assertProjectsAvailable(obj) function obj = NansenUserSession(userName, skipProjectCheck) % NansenUserSession - Constructor method - import nansen.config.addons.AddonManager import nansen.config.project.ProjectManager obj.CurrentUserName = userName; obj.SkipProjectCheck = skipProjectCheck; @@ -125,7 +127,7 @@ function assertProjectsAvailable(obj) obj.preStartup() - obj.AddonManager = AddonManager(preferenceDirectory); + obj.AddonManager = nansen.AddonManager(); obj.ProjectManager = ProjectManager.instance(preferenceDirectory, 'reset'); obj.postStartup() diff --git a/code/+nansen/+internal/+utility/createCommandWindowWebLink.m b/code/+nansen/+internal/+utility/createCommandWindowWebLink.m new file mode 100644 index 00000000..21ac1ce5 --- /dev/null +++ b/code/+nansen/+internal/+utility/createCommandWindowWebLink.m @@ -0,0 +1,9 @@ +function linkStr = createCommandWindowWebLink(url, title) + + arguments + url (1,:) char + title (1,:) char + end + + linkStr = sprintf('%s', url, title); +end diff --git a/code/dependencies.nansen.json b/code/dependencies.nansen.json new file mode 100644 index 00000000..97955b94 --- /dev/null +++ b/code/dependencies.nansen.json @@ -0,0 +1,158 @@ +{ + "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json", + "_schema_version": "1.0", + "_type": "NANSEN Dependency Manifest", + "scope": "core", + "scopeId": "core", + "dependencies": [ + { + "name": "Image Processing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "required", + "reason": "Needed for basic NANSEN image handling" + }, + { + "name": "Parallel Computing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Enables parallel batch processing of sessions" + }, + { + "name": "Widgets Toolbox - Compatibility Support", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://66235-widgets-toolbox-compatibility-support/1.3.330", + "description": "UI widget components used throughout NANSEN", + "installCheck": "uiw.widget.Table" + }, + { + "name": "Recursively list files and folders", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://160058-recursively-list-files-and-folders", + "description": "Recursive directory listing utility", + "installCheck": "recursiveDir" + }, + { + "name": "Scalebar for images and plots", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://109114-scalebar-for-images-and-plots", + "description": "Adds scalebars to image and plot axes", + "installCheck": "scalebar" + }, + { + "name": "Limit figure size", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://38527-limit-figure-size", + "description": "Constrains figure window dimensions", + "installCheck": "limitFigSize" + }, + { + "name": "cmocean - Perceptually-uniform colormaps", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://57773-cmocean-perceptually-uniform-colormaps", + "description": "Perceptually-uniform colormaps for scientific visualization", + "installCheck": "cmocean" + }, + { + "name": "Spectral and XYZ Color Functions", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://7021-spectral-and-xyz-color-functions", + "description": "Spectral color conversion functions", + "installCheck": "spectrumRGB" + }, + { + "name": "Drag & Drop functionality for Java GUI components", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://53511-drag-drop-functionality-for-java-gui-components", + "description": "Drag and drop support for MATLAB Java-based UIs", + "installCheck": "dndcontrol" + }, + { + "name": "Get structure field names in recursive manner", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://33262-get-structure-field-names-in-recursive-manner", + "description": "Recursively retrieves field names from nested structs", + "installCheck": "fieldnamesr" + }, + { + "name": "plotboxpos", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://9615-plotboxpos", + "description": "Returns the visible position of a plot box", + "installCheck": "plotboxpos" + }, + { + "name": "findjobj - Find java handles of Matlab graphic objects", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://14317-findjobj-find-java-handles-of-matlab-graphic-objects", + "description": "Finds underlying Java objects for MATLAB graphics", + "installCheck": "findjobj" + }, + { + "name": "getjframe - Retrieves a figure's underlying Java frame", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://15830-getjframe-retrieves-a-figure-s-underlying-java-frame", + "description": "Retrieves the Java frame for a MATLAB figure", + "installCheck": "getjframe" + }, + { + "name": "undecorateFig", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://50111-undecoratefig-remove-restore-figure-border-and-title-bar", + "description": "Removes or restores figure border and title bar", + "installCheck": "undecorateFig" + }, + { + "name": "Catalog", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://158241-catalog", + "description": "Collection for unique, named and ordered items", + "installCheck": "PersistentCatalog" + }, + { + "name": "File downloader/uploader with progress monitor", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://118460-file-downloader-uploader-with-progress-monitor", + "description": "Downloads and uploads files with progress indication", + "installCheck": "downloadFile" + }, + { + "name": "MatBox", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "fex://180185-matbox", + "description": "MATLAB package manager for requirements installation", + "installCheck": "matbox.VersionNumber" + }, + { + "name": "Hierarchical Data Tree", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "https://github.com/ehennestad/hierarchical-data-tree-matlab", + "description": "Tree data structure for hierarchical data", + "installCheck": "datatree.model.TreeNodeProvider" + }, + { + "name": "YAML-Matlab", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "https://github.com/ehennestad/yamlmatlab", + "description": "Reading and writing YAML files in MATLAB", + "setupHook": "nansen.internal.setup.addYamlJarToJavaClassPath", + "installCheck": "yaml.WriteYaml" + } + ] +} diff --git a/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json b/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json new file mode 100644 index 00000000..03bc79dd --- /dev/null +++ b/code/modules/+nansen/+module/+ophys/+twophoton/dependencies.nansen.json @@ -0,0 +1,155 @@ +{ + "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json", + "_schema_version": "1.0", + "_type": "NANSEN Dependency Manifest", + "scope": "module", + "scopeId": "nansen.module.ophys.twophoton", + "dependencies": [ + { + "name": "Image Processing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "required", + "reason": "Needed for two-photon image processing and ROI analysis" + }, + { + "name": "Signal Processing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Used by some motion correction and signal extraction workflows" + }, + { + "name": "Statistics and Machine Learning Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Used by EXTRACT for signal decontamination" + }, + { + "name": "Parallel Computing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Speeds up motion correction and ROI extraction" + }, + { + "name": "TIFFStack", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "https://github.com/DylanMuir/TIFFStack", + "description": "Virtual TIFF stack for efficient reading of ScanImage TIFF files", + "installCheck": "TIFFStack" + }, + { + "name": "CaImAn-MATLAB", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.caiman", + "requirementLevel": "optional", + "source": "https://github.com/flatironinstitute/CaImAn-MATLAB", + "description": "Computational toolbox for large scale calcium imaging data analysis", + "workflowNotes": "Needed for the CaImAn ROI extraction workflow", + "installCheck": "CNMFSetParms" + }, + { + "name": "Suite2P-MATLAB", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.suite2p", + "requirementLevel": "optional", + "source": "https://github.com/cortex-lab/Suite2P", + "description": "Fast, accurate and complete two-photon pipeline", + "workflowNotes": "Needed for the Suite2P ROI extraction workflow", + "installCheck": "build_ops3" + }, + { + "name": "EXTRACT", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.extract", + "requirementLevel": "optional", + "source": "https://github.com/schnitzer-lab/EXTRACT-public", + "description": "Tractable and robust automated cell extraction tool for calcium imaging", + "workflowNotes": "Needed for the EXTRACT ROI extraction workflow", + "installCheck": "run_extract" + }, + { + "name": "Bioinformatics Toolbox", + "dependencyType": "mathworks-product", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.extract", + "requirementLevel": "required", + "reason": "Required by the EXTRACT algorithm", + "workflowNotes": "Required when using the EXTRACT workflow" + }, + { + "name": "Econometrics Toolbox", + "dependencyType": "mathworks-product", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.extract", + "requirementLevel": "required", + "reason": "Required by the EXTRACT algorithm", + "workflowNotes": "Required when using the EXTRACT workflow" + }, + { + "name": "Wavelet Toolbox", + "dependencyType": "mathworks-product", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.extract", + "requirementLevel": "required", + "reason": "Required by the EXTRACT algorithm", + "workflowNotes": "Required when using the EXTRACT workflow" + }, + { + "name": "NoRMCorre", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.normcorre", + "requirementLevel": "optional", + "source": "https://github.com/ehennestad/NoRMCorre", + "docsSource": "https://github.com/flatironinstitute/NoRMCorre", + "description": "Non-rigid motion correction for calcium imaging data", + "installCheck": "normcorre_batch" + }, + { + "name": "Flow Registration", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.flowreg", + "requirementLevel": "optional", + "source": "https://github.com/phflot/flow_registration", + "docsSource": "https://github.com/phflot/flow_registration", + "description": "Optical flow based motion correction for calcium imaging data", + "setupHook": "nansen.wrapper.flowreg.install", + "installCheck": "OF_Options" + }, + { + "name": "SEUDO", + "dependencyType": "community-toolbox", + "scope": "workflow", + "scopeId": "nansen.module.ophys.twophoton.workflow.seudo", + "requirementLevel": "optional", + "source": "https://github.com/adamshch/SEUDO", + "docsSource": "https://github.com/adamshch/SEUDO", + "description": "Calcium signal decontamination", + "workflowNotes": "Needed only for the SEUDO signal decontamination workflow", + "installCheck": "globalSEUDOWrapper" + }, + { + "name": "Neurodata Without Borders", + "dependencyType": "community-toolbox", + "requirementLevel": "optional", + "source": "https://github.com/ehennestad/matnwb", + "docsSource": "https://matnwb.readthedocs.io/en/latest/", + "description": "MATLAB interface for reading and writing NWB files", + "reason": "Required for NWB file export", + "installCheck": "nwbRead" + }, + { + "name": "Brain Observatory Toolbox", + "dependencyType": "community-toolbox", + "requirementLevel": "optional", + "source": "https://github.com/emeyers/Brain-Observatory-Toolbox", + "description": "MATLAB toolbox for interacting with the Allen Brain Observatory", + "reason": "Required for Allen Brain Observatory data access", + "installCheck": "bot.getSessions" + } + ] +} diff --git a/code/resources/requirements.txt b/code/resources/requirements.txt deleted file mode 100644 index 0407f8e6..00000000 --- a/code/resources/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Statistics and Machine Learning Toolbox -Image Processing Toolbox -Parallel Computing Toolbox -Signal Processing Toolbox \ No newline at end of file diff --git a/code/shortcuts/+nansen/AddonManager.m b/code/shortcuts/+nansen/AddonManager.m index 265c1f8f..dc9d6ce1 100644 --- a/code/shortcuts/+nansen/AddonManager.m +++ b/code/shortcuts/+nansen/AddonManager.m @@ -5,8 +5,7 @@ % % See also nansen.config.addons.AddonManager - userSession = nansen.internal.user.NansenUserSession.instance(); - addonManager = userSession.getAddonManager(); + addonManager = nansen.config.addons.AddonManager.instance(); if ~nargout nansen.config.addons.AddonManagerApp(addonManager) diff --git a/code/wrappers/+nansen/+wrapper/+flowreg/install.m b/code/wrappers/+nansen/+wrapper/+flowreg/install.m index 27c0601a..8d82b6c2 100644 --- a/code/wrappers/+nansen/+wrapper/+flowreg/install.m +++ b/code/wrappers/+nansen/+wrapper/+flowreg/install.m @@ -3,7 +3,7 @@ flowregdir = fileparts( which('set_path') ); [~, dirname] = fileparts(flowregdir); -assert( strcmp(dirname, 'flow_registration'), ... +assert( contains(dirname, 'flow_registration'), ... 'Could not locate the directory containing the flow_registration toolbox') addpath(fullfile(flowregdir, 'core')); diff --git a/nansen_install.m b/nansen_install.m index 41a694eb..30b3704b 100644 --- a/nansen_install.m +++ b/nansen_install.m @@ -10,6 +10,8 @@ function nansen_install(options) arguments options.SavePath (1,1) logical = true + options.Modules (1,:) string = string.empty + options.Update (1,1) logical = false end nansenProjectFolder = fileparts(mfilename('fullpath')); % Path to nansen codebase @@ -20,38 +22,46 @@ function nansen_install(options) error('NANSEN:Setup:CodeFolderNotFound', ... 'Could not find folder with code for Nansen') end - + % Check that userpath is not empty (can happen on linux platforms) if isempty(userpath) nansen.internal.setup.resolveEmptyUserpath() end - - warnState = warning('off', 'MATLAB:javaclasspath:jarAlreadySpecified'); - warningCleanup = onCleanup(@(state) warning(warnState)); + + % Supress a warning which is not relevant for users + warningIdentifier = 'MATLAB:javaclasspath:jarAlreadySpecified'; + warningCleanup = nansen.common.suppressWarning(warningIdentifier); %#ok + + % Get the AddonManager singleton + addonManager = nansen.AddonManager(); - % Use MatBox to install dependencies/requirements - downloadAndInstallMatBox(); + % We need MatBox first to install other dependencies + addonManager.downloadAndInstallMatBox() + + % Install core requirements + addonManager.installMissingAddons(); - requirementsInstallationFolder = fullfile(userpath, 'NANSEN', 'Requirements'); - matbox.installRequirements(nansenProjectFolder, 'u', ... - 'InstallationLocation', requirementsInstallationFolder) + if options.Update + addonManager.updateAddons(options.Modules); + else + numAddonsInstalled = addonManager.installMissingAddons(options.Modules); + if numAddonsInstalled == 0 + disp([ ... + 'All dependencies are installed. ', ... + 'Use nansen_install(Update=true) to update dependencies.']) + end + end - % Add NANSEN toolbox folder to path if it was not added already + % Add NANSEN toolbox folder to path if it was not added already if ~contains(path(), nansenToolboxFolder) addpath(genpath(nansenToolboxFolder)) - savepath() end if options.SavePath - savepath() - end -end - -function downloadAndInstallMatBox() - if ~exist('+matbox/installRequirements', 'file') - sourceFile = 'https://raw.githubusercontent.com/ehennestad/matbox-actions/refs/heads/main/install-matbox/installMatBox.m'; - filePath = websave('installMatBox.m', sourceFile); - installMatBox('commit') - rehash() - delete(filePath); + status = savepath(); + if status ~= 0 + warning('NANSEN:Setup:SavePathFailed', ... + ['Could not save the MATLAB path. NANSEN is available for this session, ', ... + 'but you may need to save the path manually.']) + end end end diff --git a/schemas/dependencies.nansen.json b/schemas/dependencies.nansen.json new file mode 100644 index 00000000..b100c8fa --- /dev/null +++ b/schemas/dependencies.nansen.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json", + "title": "NANSEN Dependency Manifest", + "description": "Schema for dependencies.nansen.json files that declare dependencies for NANSEN core and modules.", + "type": "object", + "required": ["_schema_version", "_type", "scope", "scopeId", "dependencies"], + "properties": { + "_schema_id": { + "type": "string", + "description": "Canonical URI of the schema that validates this manifest. Used for runtime type-checking by MATLAB and Python." + }, + "_schema_version": { + "type": "string", + "description": "Version of this schema format.", + "const": "1.0" + }, + "_type": { + "type": "string", + "description": "Type of this manifest.", + "const": "NANSEN Dependency Manifest" + }, + "scope": { + "type": "string", + "enum": ["core", "module"], + "description": "Whether this manifest belongs to core NANSEN or a module." + }, + "scopeId": { + "type": "string", + "description": "Unique identifier for the scope. 'core' for core NANSEN, or a dot-separated module package name (e.g. 'nansen.module.ophys.twophoton')." + }, + "dependencies": { + "type": "array", + "description": "List of dependency entries.", + "items": { + "$ref": "#/$defs/dependency" + } + } + }, + "additionalProperties": false, + "$defs": { + "dependency": { + "type": "object", + "required": ["name", "dependencyType", "requirementLevel"], + "properties": { + "name": { + "type": "string", + "description": "Display name of the dependency." + }, + "dependencyType": { + "type": "string", + "enum": ["mathworks-product", "community-toolbox"], + "description": "Whether this is a MathWorks product or a community toolbox." + }, + "requirementLevel": { + "type": "string", + "enum": ["required", "optional"], + "description": "'required' means NANSEN or the module will not work without it. 'optional' means only specific workflows need it." + }, + "scope": { + "type": "string", + "enum": ["core", "module", "workflow"], + "description": "Overrides the manifest-level scope for this entry. Use 'workflow' for dependencies that belong to a specific workflow within a module. If omitted, inherits from the manifest." + }, + "scopeId": { + "type": "string", + "description": "Overrides the manifest-level scopeId for this entry. Use a workflow-specific identifier like 'nansen.module.ophys.twophoton.workflow.suite2p'. If omitted, inherits from the manifest." + }, + "source": { + "type": "string", + "description": "Install URI for community toolboxes. Supports 'fex://' for FileExchange and 'https://github.com/...' for GitHub. Used to generate requirements.txt." + }, + "docsSource": { + "type": "string", + "description": "URL pointing to installation or usage documentation." + }, + "description": { + "type": "string", + "description": "Human-readable summary of what the dependency provides." + }, + "reason": { + "type": "string", + "description": "Why this dependency is needed. Shown to users for optional dependencies." + }, + "workflowNotes": { + "type": "string", + "description": "Extra context shown when scope is 'workflow'. Explains which workflow requires this dependency." + }, + "setupHook": { + "type": "string", + "description": "Callable to invoke once after installation (e.g. a MATLAB function name or Python entry point). Run during nansen_install or equivalent." + }, + "startupHook": { + "type": "string", + "description": "Callable to invoke on NANSEN startup after the dependency is installed and registered." + }, + "installCheck": { + "type": "string", + "description": "Symbol name used to verify the dependency is installed (e.g. a function, class, or module name). Checked via language-appropriate introspection." + }, + "versionConstraint": { + "type": "string", + "description": "Version requirement string. Reserved for future use." + } + }, + "if": { + "properties": { "dependencyType": { "const": "community-toolbox" } } + }, + "then": { + "required": ["source"] + }, + "additionalProperties": false + } + } +} diff --git a/tests/+nansen/+fixture/AddonManagerFixture.m b/tests/+nansen/+fixture/AddonManagerFixture.m new file mode 100644 index 00000000..8cd09a80 --- /dev/null +++ b/tests/+nansen/+fixture/AddonManagerFixture.m @@ -0,0 +1,89 @@ +classdef AddonManagerFixture < matlab.unittest.fixtures.Fixture +%AddonManagerFixture Fixture for creating an isolated AddonManager for testing. +% +% Creates a singleton AddonManager backed by a temporary installation +% folder and manifest file. The constructor only loads persisted state +% (no manifest discovery), then the fixture calls refreshManagedAddons +% with explicit test manifest paths. +% +% On teardown, resets the singleton so subsequent tests get a fresh +% instance. +% +% By default, the fixture resolves dependencies from the test manifests +% shipped in tests/+nansen/+fixture/manifests/. Pass custom +% DependencyManifestPaths to override. +% +% Example: +% fixture = testCase.applyFixture(nansen.fixture.AddonManagerFixture); +% manager = fixture.AddonManager; + + properties (SetAccess = private) + % InstallationFolder - Temporary folder for addon installation + InstallationFolder (1,1) string + + % ManifestFilePath - Temporary path for the installed_addons.json + ManifestFilePath (1,1) string + + % AddonManager - The singleton instance created by this fixture + AddonManager + end + + properties (Access = private) + DependencyManifestPaths (1,:) string + end + + methods + function fixture = AddonManagerFixture(options) + %AddonManagerFixture Create a fixture with optional custom manifests. + arguments + options.DependencyManifestPaths (1,:) string = string.empty + end + fixture.DependencyManifestPaths = options.DependencyManifestPaths; + end + end + + methods + function setup(fixture) + import matlab.unittest.fixtures.TemporaryFolderFixture + temporaryFolderFixture = fixture.applyFixture(TemporaryFolderFixture); + fixture.InstallationFolder = fullfile(temporaryFolderFixture.Folder, 'Add-Ons'); + mkdir(fixture.InstallationFolder); + fixture.ManifestFilePath = fullfile( ... + temporaryFolderFixture.Folder, 'installed_addons.json'); + + dependencyManifestPaths = fixture.DependencyManifestPaths; + if isempty(dependencyManifestPaths) + dependencyManifestPaths = fixture.getDefaultTestManifestPaths(); + end + + % Create singleton with clean state (no manifest discovery) + fixture.AddonManager = nansen.config.addons.AddonManager.instance( ... + "reset", fixture.InstallationFolder, fixture.ManifestFilePath); + + % Populate from test manifests only + fixture.AddonManager.refreshManagedAddons( ... + "ManifestPaths", dependencyManifestPaths); + + fixture.addTeardown(@() nansen.config.addons.AddonManager.instance("reset")); + end + end + + methods (Access = protected) + function isCompatibleResult = isCompatible(fixture, other) + %isCompatible Two fixtures are compatible if they use the same manifest paths. + isCompatibleResult = isequal( ... + fixture.DependencyManifestPaths, other.DependencyManifestPaths); + end + end + + methods (Static, Access = private) + function manifestPaths = getDefaultTestManifestPaths() + %getDefaultTestManifestPaths Return paths to the bundled test manifests. + manifestDirectory = fullfile( ... + fileparts(mfilename('fullpath')), 'manifests'); + manifestPaths = string({ ... + fullfile(manifestDirectory, 'test_core_dependencies.nansen.json'), ... + fullfile(manifestDirectory, 'test_module_dependencies.nansen.json') }); + end + end +end diff --git a/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json b/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json new file mode 100644 index 00000000..86efd05f --- /dev/null +++ b/tests/+nansen/+fixture/manifests/test_core_dependencies.nansen.json @@ -0,0 +1,38 @@ +{ + "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json", + "_schema_version": "1.0", + "_type": "NANSEN Dependency Manifest", + "scope": "core", + "scopeId": "core", + "dependencies": [ + { + "name": "TestToolboxA", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "https://github.com/test-org/TestToolboxA", + "description": "Required core community toolbox for testing", + "installCheck": "TestToolboxACheck" + }, + { + "name": "TestToolboxB", + "dependencyType": "community-toolbox", + "requirementLevel": "optional", + "source": "fex://99999-test-toolbox-b", + "description": "Optional core community toolbox for testing", + "installCheck": "TestToolboxBCheck", + "reason": "Enables optional test feature" + }, + { + "name": "Image Processing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "required", + "reason": "Required MathWorks product for testing" + }, + { + "name": "Parallel Computing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Optional MathWorks product for testing" + } + ] +} diff --git a/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json b/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json new file mode 100644 index 00000000..c7b67bbb --- /dev/null +++ b/tests/+nansen/+fixture/manifests/test_module_dependencies.nansen.json @@ -0,0 +1,42 @@ +{ + "_schema_id": "https://raw.githubusercontent.com/VervaekeLab/NANSEN/dev/schemas/dependencies.nansen.json", + "_schema_version": "1.0", + "_type": "NANSEN Dependency Manifest", + "scope": "module", + "scopeId": "test.module.alpha", + "dependencies": [ + { + "name": "TestToolboxC", + "dependencyType": "community-toolbox", + "requirementLevel": "required", + "source": "https://github.com/test-org/TestToolboxC", + "description": "Required module community toolbox for testing", + "installCheck": "TestToolboxCCheck" + }, + { + "name": "TestToolboxA", + "dependencyType": "community-toolbox", + "requirementLevel": "optional", + "source": "https://github.com/test-org/TestToolboxA", + "description": "Duplicate of core dependency at module scope (optional here)", + "installCheck": "TestToolboxACheck" + }, + { + "name": "TestWorkflowTool", + "dependencyType": "community-toolbox", + "requirementLevel": "optional", + "scope": "workflow", + "scopeId": "test.module.alpha.workflow.beta", + "source": "https://github.com/test-org/TestWorkflowTool", + "description": "Workflow-scoped community toolbox for testing", + "installCheck": "TestWorkflowToolCheck", + "reason": "Needed for beta workflow" + }, + { + "name": "Signal Processing Toolbox", + "dependencyType": "mathworks-product", + "requirementLevel": "optional", + "reason": "Optional MathWorks product at module scope" + } + ] +} diff --git a/tests/+nansen/+unittest/AddonManagerTest.m b/tests/+nansen/+unittest/AddonManagerTest.m new file mode 100644 index 00000000..d447d61a --- /dev/null +++ b/tests/+nansen/+unittest/AddonManagerTest.m @@ -0,0 +1,331 @@ +classdef AddonManagerTest < matlab.unittest.TestCase +%AddonManagerTest Tests for the AddonManager singleton. +% +% Tests initialization, manifest loading, dependency refresh, +% installation status tracking, merge/deduplication, and edge cases +% using isolated test manifests. + + properties (Access = private) + Fixture + end + + properties (Constant, Access = private) + TestManifestDirectory = fullfile( ... + fileparts(fileparts(mfilename('fullpath'))), ... + '+fixture', 'manifests') + CoreManifestPath (1,1) string = fullfile( ... + fileparts(fileparts(mfilename('fullpath'))), ... + '+fixture', 'manifests', 'test_core_dependencies.nansen.json') + ModuleManifestPath (1,1) string = fullfile( ... + fileparts(fileparts(mfilename('fullpath'))), ... + '+fixture', 'manifests', 'test_module_dependencies.nansen.json') + end + + methods (TestMethodSetup) + function applyAddonManagerFixture(testCase) + testCase.Fixture = testCase.applyFixture( ... + nansen.fixture.AddonManagerFixture); + end + end + + % --- Initialization & manifest loading --- + + methods (Test) + function createWithEmptyManifest(testCase) + %createWithEmptyManifest Singleton starts with addon list from test manifests. + manager = testCase.Fixture.AddonManager; + testCase.verifyNotEmpty(manager, ... + 'AddonManager instance should be created') + testCase.verifyClass(manager, ... + 'nansen.config.addons.AddonManager') + end + + function installationFolderIsTemporary(testCase) + %installationFolderIsTemporary Installation folder points to temp dir. + manager = testCase.Fixture.AddonManager; + testCase.verifyTrue( ... + contains(manager.InstallationFolder, tempdir) || ... + contains(manager.InstallationFolder, "tmp"), ... + 'Installation folder should be in a temporary location') + end + + function persistAndReloadManifest(testCase) + %persistAndReloadManifest Save, reset, and reload round-trip. + manager = testCase.Fixture.AddonManager; + manager.saveAddonList(); + testCase.verifyTrue(isfile(testCase.Fixture.ManifestFilePath), ... + 'Manifest file should exist after save') + + originalAddonNames = string({manager.AddonList.Name}); + + % Reset with same storage paths to reload persisted data + reloadedManager = nansen.config.addons.AddonManager.instance( ... + "reset", ... + testCase.Fixture.InstallationFolder, ... + testCase.Fixture.ManifestFilePath); + % Refresh from same test manifests + reloadedManager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath]); + reloadedAddonNames = string({reloadedManager.AddonList.Name}); + + testCase.verifyEqual(sort(reloadedAddonNames), sort(originalAddonNames), ... + 'Addon names should survive save/reload round-trip') + end + end + + % --- Refresh from test manifests --- + + methods (Test) + function refreshFromCoreManifest(testCase) + %refreshFromCoreManifest Core test manifest resolves expected entries. + manager = testCase.Fixture.AddonManager; + addonNames = string({manager.AddonList.Name}); + + testCase.verifyTrue(ismember("TestToolboxA", addonNames), ... + 'TestToolboxA should be in addon list') + testCase.verifyTrue(ismember("TestToolboxB", addonNames), ... + 'TestToolboxB should be in addon list') + end + + function coreAddonHasCorrectSource(testCase) + %coreAddonHasCorrectSource Community toolbox source is preserved. + manager = testCase.Fixture.AddonManager; + addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA')); + testCase.assumeNotEmpty(addonIndex, 'TestToolboxA not found') + + testCase.verifyEqual(string(manager.AddonList(addonIndex).Source), ... + "https://github.com/test-org/TestToolboxA") + end + + function requiredFlagIsSet(testCase) + %requiredFlagIsSet Required addon has IsRequired=true. + manager = testCase.Fixture.AddonManager; + addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA')); + testCase.assumeNotEmpty(addonIndex) + + testCase.verifyTrue(manager.AddonList(addonIndex).IsRequired, ... + 'TestToolboxA should be marked as required') + end + + function optionalFlagIsSet(testCase) + %optionalFlagIsSet Optional addon has IsRequired=false. + manager = testCase.Fixture.AddonManager; + addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxB')); + testCase.assumeNotEmpty(addonIndex) + + testCase.verifyFalse(manager.AddonList(addonIndex).IsRequired, ... + 'TestToolboxB should be marked as optional') + end + + function refreshWithModuleDependencies(testCase) + %refreshWithModuleDependencies Module deps appear after refresh with module selection. + manager = testCase.Fixture.AddonManager; + manager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ... + "SelectedModules", "test.module.alpha"); + + addonNames = string({manager.AddonList.Name}); + testCase.verifyTrue(ismember("TestToolboxC", addonNames), ... + 'TestToolboxC (module dep) should appear after refresh with module') + end + + function mathworksProductsExcludedFromAddonList(testCase) + %mathworksProductsExcludedFromAddonList MathWorks products are not in AddonList. + % refreshManagedAddons filters for community-toolbox only. + manager = testCase.Fixture.AddonManager; + addonNames = string({manager.AddonList.Name}); + + testCase.verifyFalse(ismember("Image Processing Toolbox", addonNames), ... + 'MathWorks products should not appear in AddonList') + testCase.verifyFalse(ismember("Parallel Computing Toolbox", addonNames), ... + 'MathWorks products should not appear in AddonList') + end + end + + % --- Deduplication and merge --- + + methods (Test) + function duplicateAcrossScopesMergesCorrectly(testCase) + %duplicateAcrossScopesMergesCorrectly Same dependency from core+module deduplicates. + manager = testCase.Fixture.AddonManager; + manager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ... + "SelectedModules", "test.module.alpha"); + + % TestToolboxA is in both core (required) and module (optional) + matchIndices = find(strcmp({manager.AddonList.Name}, 'TestToolboxA')); + testCase.verifyNumElements(matchIndices, 1, ... + 'TestToolboxA should appear exactly once after deduplication') + end + + function requiredWinsOverOptional(testCase) + %requiredWinsOverOptional Required level wins when same dep appears in multiple scopes. + manager = testCase.Fixture.AddonManager; + manager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ... + "SelectedModules", "test.module.alpha"); + + addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA')); + testCase.assumeNotEmpty(addonIndex) + + testCase.verifyTrue(manager.AddonList(addonIndex).IsRequired, ... + 'TestToolboxA should be required (core=required wins over module=optional)') + end + + function existingAddonPreservesInstallState(testCase) + %existingAddonPreservesInstallState Installed addon stays installed after refresh. + % Seeds a manifest file with one addon marked as installed, + % creates a fresh singleton, and verifies the install state + % survives a refreshManagedAddons call. + import matlab.unittest.fixtures.TemporaryFolderFixture + temporaryFolderFixture = testCase.applyFixture(TemporaryFolderFixture); + installationFolder = fullfile(temporaryFolderFixture.Folder, 'Add-Ons'); + mkdir(installationFolder); + manifestFilePath = fullfile(temporaryFolderFixture.Folder, 'installed_addons.json'); + + % Create a fake installed addon folder + fakeAddonFolder = fullfile(installationFolder, 'TestToolboxA'); + mkdir(fakeAddonFolder); + testCase.addTeardown(@() rmpath(fakeAddonFolder)); + + % Write a pre-seeded manifest with one installed addon + defaultEntry = nansen.config.addons.AddonManager.DefaultAddonEntry; + addonEntry = defaultEntry; + addonEntry.Name = 'TestToolboxA'; + addonEntry.IsInstalled = true; + addonEntry.FilePath = char(fakeAddonFolder); + addonEntry.InstallationType = 'folder'; + addonEntry.Source = 'https://github.com/test-org/TestToolboxA'; + addonEntry.InstallCheck = 'TestToolboxACheck'; + savedData = struct( ... + 'type', 'Nansen Configuration: List of Installed Addons', ... + 'description', 'Test manifest', ... + 'AddonList', addonEntry); + jsonText = jsonencode(savedData, 'PrettyPrint', true); + fileIdentifier = fopen(manifestFilePath, 'w'); + fwrite(fileIdentifier, jsonText, 'char'); + fclose(fileIdentifier); + + % Create singleton from pre-seeded manifest (no discovery) + manager = nansen.config.addons.AddonManager.instance( ... + "reset", installationFolder, manifestFilePath); + % Refresh from test manifest — should preserve install state + manager.refreshManagedAddons( ... + "ManifestPaths", testCase.CoreManifestPath); + addonIndex = find(strcmp({manager.AddonList.Name}, 'TestToolboxA')); + testCase.assumeNotEmpty(addonIndex, 'TestToolboxA should be in list') + testCase.verifyTrue(manager.AddonList(addonIndex).IsInstalled, ... + 'Pre-seeded installation state should be preserved after refresh') + end + + function newDependencyAddedOnRefresh(testCase) + %newDependencyAddedOnRefresh A dependency not previously tracked appears after refresh. + manager = testCase.Fixture.AddonManager; + + % Initially only core manifest was used (default fixture) + addonNamesBefore = string({manager.AddonList.Name}); + testCase.verifyFalse(ismember("TestToolboxC", addonNamesBefore), ... + 'TestToolboxC should not be present before module refresh') + + % Refresh with module manifest + manager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ... + "SelectedModules", "test.module.alpha"); + + addonNamesAfter = string({manager.AddonList.Name}); + testCase.verifyTrue(ismember("TestToolboxC", addonNamesAfter), ... + 'TestToolboxC should appear after module refresh') + end + end + + % --- Installation status --- + + methods (Test) + function uninstalledAddonReportsFalse(testCase) + %uninstalledAddonReportsFalse isAddonInstalled returns false for missing addon. + manager = testCase.Fixture.AddonManager; + testCase.verifyFalse(manager.isAddonInstalled('TestToolboxA'), ... + 'TestToolboxA should not be installed in test environment') + end + + function isAddonInstalledReturnsFalseForUnknown(testCase) + %isAddonInstalledReturnsFalseForUnknown Unknown addon name returns false. + manager = testCase.Fixture.AddonManager; + testCase.verifyFalse(manager.isAddonInstalled('NonExistentToolbox'), ... + 'Unknown addon should return false') + end + + function saveAddonListCreatesValidJson(testCase) + %saveAddonListCreatesValidJson Saved manifest is valid JSON with expected structure. + manager = testCase.Fixture.AddonManager; + manager.saveAddonList(); + + jsonText = fileread(testCase.Fixture.ManifestFilePath); + savedData = jsondecode(jsonText); + + testCase.verifyTrue(isfield(savedData, 'AddonList'), ... + 'Saved data should have AddonList field') + testCase.verifyTrue(isfield(savedData, 'type'), ... + 'Saved data should have type field') + end + + function markDirtyAndClean(testCase) + %markDirtyAndClean IsDirty flag tracks unsaved changes. + manager = testCase.Fixture.AddonManager; + manager.markDirty(); + testCase.verifyTrue(manager.IsDirty, ... + 'Manager should be dirty after markDirty') + + manager.saveAddonList(); + testCase.verifyFalse(manager.IsDirty, ... + 'Manager should be clean after save') + end + end + + % --- Edge cases --- + + methods (Test) + function isAddonInstalledCaseInsensitive(testCase) + %isAddonInstalledCaseInsensitive Addon lookup by name is case-insensitive. + % isAddonInstalled uses case-sensitive strcmp for the name check, + % so this test documents the current behavior. + manager = testCase.Fixture.AddonManager; + testCase.assumeTrue(any(strcmp({manager.AddonList.Name}, 'TestToolboxA'))) + + % isAddonInstalled checks exact name match first, then uses + % getAddonIndex (case-insensitive). With exact name, lookup works. + resultExact = manager.isAddonInstalled('TestToolboxA'); + testCase.verifyFalse(resultExact, ... + 'TestToolboxA is not installed but name should be found') + end + + function downloadUnknownAddonThrowsError(testCase) + %downloadUnknownAddonThrowsError Unknown addon name causes error. + manager = testCase.Fixture.AddonManager; + testCase.verifyError( ... + @() manager.downloadAddon('CompletelyUnknownToolbox', false, true), ... + 'NANSEN:AddonManager:NotFound') + end + + function managedAddonsReturnsTable(testCase) + %managedAddonsReturnsTable ManagedAddons property returns a table. + manager = testCase.Fixture.AddonManager; + managedAddons = manager.ManagedAddons; + testCase.verifyClass(managedAddons, 'table') + testCase.verifyTrue(ismember('Name', managedAddons.Properties.VariableNames), ... + 'ManagedAddons table should have Name column') + end + + function workflowScopedDepsRequireWorkflowSelection(testCase) + %workflowScopedDepsRequireWorkflowSelection Workflow deps excluded without selection. + manager = testCase.Fixture.AddonManager; + manager.refreshManagedAddons( ... + "ManifestPaths", [testCase.CoreManifestPath, testCase.ModuleManifestPath], ... + "SelectedModules", "test.module.alpha"); + + addonNames = string({manager.AddonList.Name}); + testCase.verifyFalse(ismember("TestWorkflowTool", addonNames), ... + 'Workflow-scoped deps should not appear without SelectedWorkflows') + end + end +end diff --git a/tests/+nansen/+unittest/TestDependencyManagement.m b/tests/+nansen/+unittest/TestDependencyManagement.m new file mode 100644 index 00000000..e24f15cb --- /dev/null +++ b/tests/+nansen/+unittest/TestDependencyManagement.m @@ -0,0 +1,270 @@ +classdef TestDependencyManagement < matlab.unittest.TestCase +%TestDependencyManagement Tests for the dependency manifest system. +% +% Tests the real-world workflows: reading manifests, resolving +% dependencies across scopes, checking installation status, and +% verifying schema/manifest consistency. + + properties (Constant, Access = private) + CoreManifestPath = fullfile(nansen.toolboxdir, 'dependencies.nansen.json') + SchemaPath = fullfile(nansen.toolboxdir, '..', 'schemas', 'dependencies.nansen.json') + end + + % --- Manifest reading --- + + methods (Test) + function readCoreManifest(testCase) + %readCoreManifest Verify the core manifest loads and has expected structure. + testCase.assumeTrue(isfile(testCase.CoreManifestPath), ... + 'Core manifest file not found') + + dependencies = nansen.internal.dependencies.readManifest( ... + testCase.CoreManifestPath); + + testCase.verifyNotEmpty(dependencies, ... + 'Core manifest should contain at least one dependency') + testCase.verifyTrue(isstruct(dependencies), ... + 'readManifest should return a struct array') + + expectedFields = ["Name", "DependencyType", "Scope", "ScopeId", ... + "RequirementLevel", "Source", "DocsSource", "Description", ... + "Reason", "WorkflowNotes", "SetupHook", "StartupHook", ... + "InstallCheck", "VersionConstraint"]; + actualFields = string(fieldnames(dependencies)); + for i = 1:numel(expectedFields) + testCase.verifyTrue(ismember(expectedFields(i), actualFields), ... + sprintf('Missing field: %s', expectedFields(i))) + end + end + + function coreManifestScopeInheritance(testCase) + %coreManifestScopeInheritance Verify scope inherits from manifest level. + testCase.assumeTrue(isfile(testCase.CoreManifestPath)) + + dependencies = nansen.internal.dependencies.readManifest( ... + testCase.CoreManifestPath); + + for i = 1:numel(dependencies) + testCase.verifyEqual(string(dependencies(i).Scope), "core", ... + sprintf('Dependency "%s" should inherit scope "core"', ... + dependencies(i).Name)) + end + end + + function communityToolboxesHaveSource(testCase) + %communityToolboxesHaveSource Every community toolbox must have a source URI. + testCase.assumeTrue(isfile(testCase.CoreManifestPath)) + + dependencies = nansen.internal.dependencies.readManifest( ... + testCase.CoreManifestPath); + + for i = 1:numel(dependencies) + if dependencies(i).DependencyType == "community-toolbox" + testCase.verifyTrue( ... + strlength(dependencies(i).Source) > 0, ... + sprintf('Community toolbox "%s" has no source', ... + dependencies(i).Name)) + end + end + end + + function mathworksProductsHaveNoSource(testCase) + %mathworksProductsHaveNoSource MathWorks products should not have source URIs. + testCase.assumeTrue(isfile(testCase.CoreManifestPath)) + + dependencies = nansen.internal.dependencies.readManifest( ... + testCase.CoreManifestPath); + + for i = 1:numel(dependencies) + if dependencies(i).DependencyType == "mathworks-product" + testCase.verifyEqual(dependencies(i).Source, "", ... + sprintf('MathWorks product "%s" should not have a source URI', ... + dependencies(i).Name)) + end + end + end + end + + % --- Module manifest with scope overrides --- + + methods (Test) + function readModuleManifestWithWorkflowScopes(testCase) + %readModuleManifestWithWorkflowScopes Verify per-entry scope overrides. + moduleManifestPath = fullfile(nansen.toolboxdir, 'modules', ... + '+nansen', '+module', '+ophys', '+twophoton', ... + 'dependencies.nansen.json'); + testCase.assumeTrue(isfile(moduleManifestPath), ... + 'Two-photon module manifest not found') + + dependencies = nansen.internal.dependencies.readManifest( ... + moduleManifestPath); + + % Find a workflow-scoped entry (e.g. EXTRACT) + names = string({dependencies.Name}); + extractIdx = find(names == "EXTRACT", 1); + testCase.assumeNotEmpty(extractIdx, 'EXTRACT dependency not found') + + testCase.verifyEqual(string(dependencies(extractIdx).Scope), "workflow") + testCase.verifyTrue(contains(dependencies(extractIdx).ScopeId, "extract")) + + % Entries without scope override should inherit "module" + iptIdx = find(names == "Image Processing Toolbox", 1); + testCase.assumeNotEmpty(iptIdx) + testCase.verifyEqual(string(dependencies(iptIdx).Scope), "module") + end + end + + % --- Resolve requirements --- + + methods (Test) + function resolveCoreOnly(testCase) + %resolveCoreOnly Resolve core dependencies and verify filtering works. + resolved = nansen.internal.dependencies.resolveRequirements(); + + testCase.verifyNotEmpty(resolved, ... + 'Resolving core dependencies should return results') + + % All should be core scope + scopes = string({resolved.Scope}); + testCase.verifyTrue(all(scopes == "core"), ... + 'Core-only resolution should only contain core-scoped entries') + end + + function filterByDependencyType(testCase) + %filterByDependencyType Verify filtering by mathworks-product. + resolved = nansen.internal.dependencies.resolveRequirements( ... + "DependencyTypes", "mathworks-product"); + + types = string({resolved.DependencyType}); + testCase.verifyTrue(all(types == "mathworks-product"), ... + 'All results should be mathworks-product') + end + + function filterByRequirementLevel(testCase) + %filterByRequirementLevel Verify filtering by required level. + resolved = nansen.internal.dependencies.resolveRequirements( ... + "RequirementLevels", "required"); + + levels = string({resolved.RequirementLevel}); + testCase.verifyTrue(all(levels == "required"), ... + 'All results should be required') + end + + function resolvedHasIsInstalledField(testCase) + %resolvedHasIsInstalledField Verify installation status is annotated. + resolved = nansen.internal.dependencies.resolveRequirements(); + testCase.assumeNotEmpty(resolved) + + testCase.verifyTrue(isfield(resolved, 'IsInstalled'), ... + 'Resolved requirements should have IsInstalled field') + testCase.verifyTrue(islogical([resolved.IsInstalled]), ... + 'IsInstalled should be logical') + end + + function missingOnlyFilter(testCase) + %missingOnlyFilter Verify MissingOnly returns only uninstalled deps. + resolved = nansen.internal.dependencies.resolveRequirements( ... + "MissingOnly", true); + + if isempty(resolved); return; end + + installedFlags = [resolved.IsInstalled]; + testCase.verifyTrue(~any(installedFlags), ... + 'MissingOnly should only return uninstalled dependencies') + end + end + + % --- Check installation status --- + + methods (Test) + function imageProcessingToolboxDetection(testCase) + %imageProcessingToolboxDetection Verify MathWorks product detection works. + entry = struct( ... + 'Name', "Image Processing Toolbox", ... + 'DependencyType', "mathworks-product", ... + 'InstallCheck', ""); + + result = nansen.internal.dependencies.checkInstallationStatus(entry); + + % If IPT is installed, IsInstalled should be true + versionInfo = ver(); + expectedInstalled = any(string({versionInfo.Name}) == "Image Processing Toolbox"); + testCase.verifyEqual(result.IsInstalled, expectedInstalled) + end + + function communityToolboxCheckDetection(testCase) + %communityToolboxCheckDetection Verify InstallCheck-based detection. + % Test with a function we know exists (part of MATLAB itself) + entry = struct( ... + 'Name', "Test Check", ... + 'DependencyType', "community-toolbox", ... + 'InstallCheck', "nansen"); + + result = nansen.internal.dependencies.checkInstallationStatus(entry); + testCase.verifyTrue(result.IsInstalled, ... + '"nansen" should always be found via exist()') + end + + function missingCheckReportsNotInstalled(testCase) + %missingCheckReportsNotInstalled Empty InstallCheck → not installed. + entry = struct( ... + 'Name', "No Check", ... + 'DependencyType', "community-toolbox", ... + 'InstallCheck', ""); + + result = nansen.internal.dependencies.checkInstallationStatus(entry); + testCase.verifyFalse(result.IsInstalled, ... + 'Empty InstallCheck should report not installed') + end + end + + % --- Schema consistency --- + + methods (Test) + function schemaAndManifestConsistency(testCase) + %schemaAndManifestConsistency Verify manifest validates against schema. + testCase.assumeTrue(isfile(testCase.SchemaPath), ... + 'Schema file not found') + testCase.assumeTrue(isfile(testCase.CoreManifestPath), ... + 'Core manifest not found') + + schemaData = jsondecode(fileread(testCase.SchemaPath)); + manifestData = jsondecode(fileread(testCase.CoreManifestPath)); + + % Check that manifest has all required top-level fields + % (jsondecode mangles leading _ to x_, so _schema_id → x_schema_id) + manifestFields = string(fieldnames(manifestData)); + testCase.verifyTrue(ismember("x_schema_id", manifestFields)) + testCase.verifyTrue(ismember("x_schema_version", manifestFields)) + testCase.verifyTrue(ismember("x_type", manifestFields)) + testCase.verifyTrue(ismember("scope", manifestFields)) + testCase.verifyTrue(ismember("scopeId", manifestFields)) + testCase.verifyTrue(ismember("dependencies", manifestFields)) + + % Verify _schema_id matches schema $id + testCase.verifyEqual( ... + string(manifestData.x_schema_id), ... + string(schemaData.x_id), ... + 'Manifest _schema_id should match schema $id') + end + + function camelToPascalMappingRoundTrip(testCase) + %camelToPascalMappingRoundTrip Verify all schema fields map correctly. + testCase.assumeTrue(isfile(testCase.CoreManifestPath)) + + dependencies = nansen.internal.dependencies.readManifest( ... + testCase.CoreManifestPath); + + % These PascalCase fields should exist from camelCase JSON keys + expectedPascalFields = ["Name", "DependencyType", "RequirementLevel", ... + "Source", "Description", "Reason", "SetupHook"]; + actualFields = string(fieldnames(dependencies)); + + for i = 1:numel(expectedPascalFields) + testCase.verifyTrue(ismember(expectedPascalFields(i), actualFields), ... + sprintf('camelToPascal should produce field: %s', ... + expectedPascalFields(i))) + end + end + end +end diff --git a/tools/+nansentools/generateRequirementsTxt.m b/tools/+nansentools/generateRequirementsTxt.m new file mode 100644 index 00000000..54f34bb3 --- /dev/null +++ b/tools/+nansentools/generateRequirementsTxt.m @@ -0,0 +1,49 @@ +function generateRequirementsTxt(manifestFilePath, outputFilePath) +%generateRequirementsTxt Generate requirements.txt from a manifest file. +% +% generateRequirementsTxt(manifestFilePath, outputFilePath) reads a +% dependencies.nansen.json manifest and writes a requirements.txt +% containing only community-toolbox entries with their Source URIs. +% +% MathWorks products are excluded. Entries without a Source field are +% skipped with a warning. +% +% Input: +% manifestFilePath (1,1) string - Path to dependencies.nansen.json +% outputFilePath (1,1) string - Path to write requirements.txt + + arguments + manifestFilePath (1,1) string {mustBeFile} = fullfile(nansentools.projectdir, 'code', 'dependencies.nansen.json') + outputFilePath (1,1) string = fullfile(nansentools.projectdir, 'requirements.txt') + end + + dependencies = nansen.internal.dependencies.readManifest(manifestFilePath); + + sourceLines = string.empty; + for i = 1:numel(dependencies) + entry = dependencies(i); + + if entry.DependencyType ~= "community-toolbox" + continue + end + + if entry.Source == "" + warning("NANSEN:Dependencies:MissingSource", ... + "Community toolbox '%s' has no Source field — skipping.", ... + entry.Name); + continue + end + + sourceLines(end+1) = entry.Source; %#ok + end + + % Write output file + fileContent = strjoin(sourceLines, newline) + newline; + fileId = fopen(outputFilePath, 'w'); + cleanupObj = onCleanup(@() fclose(fileId)); + if fileId == -1 + error("NANSEN:Dependencies:FileWriteError", ... + "Could not open '%s' for writing.", outputFilePath); + end + fprintf(fileId, '%s', fileContent); +end