diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2ca5eacd9a4eb429380c3f645237f2ec3fb69e..cb3cbaa424f9dad204ef996641aa02298873e9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # `fsl_add_module` changelog +## 0.5.0 (Monday 5th June 2023) + + - New `fsl_generate_module_manifest` command, to generate a JSON manifest + for a set of files. + - Added ability to refer to 'built-in' manifests by aliases - the default + manifest is referred to as `fslcourse`, and the Graduate Programme manifest + as `gradcourse`, e.g. `fsl_add_module -m gradcourse`. + - Added support for a `size` field in manifest entries, specifying the file + size in bytes. + + ## 0.4.0 (Tuesday 24th January 2023) - Changed the default behaviour so that only files in the `fsl_course_data` diff --git a/fsl/add_module/__init__.py b/fsl/add_module/__init__.py index 83da9f81721da6fa8bad53e6120e0ac6ea9c0de5..62c9a92f7ec757c1e831e5ce0d244524ed5050a9 100644 --- a/fsl/add_module/__init__.py +++ b/fsl/add_module/__init__.py @@ -9,5 +9,5 @@ """ -__version__ = '0.4.0' +__version__ = '0.5.0' """``fsl_add_module`` version number.""" diff --git a/fsl/add_module/plugin_manifest.py b/fsl/add_module/manifest.py similarity index 76% rename from fsl/add_module/plugin_manifest.py rename to fsl/add_module/manifest.py index 1da82cad645f2e314b0aa53765eabb84d098cd57..d32db16f9f20909cecdedb279236862ce28f3147 100644 --- a/fsl/add_module/plugin_manifest.py +++ b/fsl/add_module/manifest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -# plugin_manifest.py - Classes and functions for working with FSL plugin -# manifest files. +# manifest.py - Classes and functions for working with FSL plugin +# manifest files. # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -15,6 +15,7 @@ list of FSL plugin definitions, e.g.:: "category" : "fsl_course_data", "url" : "http://.../UnixIntro.zip", "checksum" : "f8222...", + "size" : "15677939", "description" : "Data for the unix introduction", "version" : "1.2.3", "destination" : "~/" @@ -24,6 +25,7 @@ list of FSL plugin definitions, e.g.:: "category" : "fsl_atlases", "url" : "http://localhost:8000/abcd123_mouse_atlas.tar.gz", "checksum" : "658701ff9a49b06f0d0ee38bce36a1c370fdfd0751ab17c2fd7b7b561d3b92e0", + "size" : "123841532", "description" : "ABCD123 FSL mouse atlas derived from hand segmentations of 3813 small chocolate mice.", "terms_of_use" : "https://chocolate-mouse.com/terms-of-use", "destination" : "$FSLDIR/data/atlases/" @@ -39,6 +41,7 @@ optional but recommended: - ``category``: The plugin category, used to organise plugins. - ``checksum``: A SHA256 checksum of the plugin archive file, used to verify that it has been successfully downloaded. + - ``size``: Size of the file in bytes - ``description``: An extended description of the plugin - ``version``: A version identifier for the plugin (used solely for descriptive purposes) @@ -98,12 +101,15 @@ class Plugin: """SHA256 checksum of the plugin archive file, used to verify downloads. """ + size : Optional[int] = None + """Size of the archive file in bytes. """ + version : Optional[str] = None """Version identifier for the plugin - displayed to the user, but not used for any other purpose. """ - termsOfUse : Optional[str] = None + terms_of_use : Optional[str] = None """Terms of use for the plugin - displayed to the user during installation. """ @@ -137,13 +143,21 @@ class Plugin: @property def fileName(self): """Return a suitable name to use for the downloaded plugin file. + The file name may be prefixed with the plugin file category. """ - fname = op.basename(urlparse.urlparse(self.url).path) + fname = self.rawFileName if self.category not in (None, UNCATEGORISED): fname = f'{self.category}_{fname}' return routines.sanitiseFileName(fname) + @property + def rawFileName(self): + """Return the file name of the plugin file as it appears in te URL. + """ + return op.basename(urlparse.urlparse(self.url).path) + + class Manifest(dict): """The ``Manifest``class simply a container for :class:`Plugin` instances. A ``Manifest`` is a dict containing ``{name : Plugin}`` mappings. @@ -154,27 +168,29 @@ class Manifest(dict): def addPlugin(self, - url : Union[str, pathlib.Path], - name : Optional[str] = None, - description : Optional[str] = None, - category : Optional[str] = None, - checksum : Optional[str] = None, - version : Optional[str] = None, - termsOfUse : Optional[str] = None, - destination : Optional[str] = None) -> str: + url : Union[str, pathlib.Path], + name : Optional[str] = None, + description : Optional[str] = None, + category : Optional[str] = None, + checksum : Optional[str] = None, + size : Optional[int] = None, + version : Optional[str] = None, + terms_of_use : Optional[str] = None, + destination : Optional[str] = None) -> str: """Add a new :class:`Plugin` to the manifest. - :arg url: URL of plugin archive file. - :arg name: Plugin name. Defaults to filename component of - ``url``. - :arg description: Extended description of plugin - :arg category: Plugin category - :arg checksum: SHA256 checksum of archive file. - :arg version: Version identifier for the plugin. - :arg termsOfUse: Terms of use for the plugin. - :arg destination: Default installation directory. - :returns: The registered plugin name (equivalent to ``name``, - if provided). + :arg url: URL of plugin archive file. + :arg name: Plugin name. Defaults to filename component of + ``url``. + :arg description: Extended description of plugin + :arg category: Plugin category + :arg checksum: SHA256 checksum of archive file. + :arg size: Size of archive file in bytes. + :arg version: Version identifier for the plugin. + :arg terms_of_use: Terms of use for the plugin. + :arg destination: Default installation directory. + :returns: The registered plugin name (equivalent to ``name``, + if provided). """ # support paths to local files @@ -213,8 +229,9 @@ class Manifest(dict): category=category, description=description, checksum=checksum, + size=size, version=version, - termsOfUse=termsOfUse, + terms_of_use=terms_of_use, destination=destination, origDestination=origDestination, archiveFile=archiveFile, @@ -264,7 +281,7 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest: with tempfile.TemporaryDirectory() as d: dest = op.join(d, 'manifest.txt') routines.downloadFile(url, dest, progress=False) - rawmanifest = open(dest, 'rt').read() + rawmanifest = open(dest, 'rt', encoding='utf-8').read() try: rawmanifest = json.loads(rawmanifest) @@ -276,14 +293,17 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest: # plugins must at least contain a URL try: + try: size = int(plugin.get('size')) + except Exception: size = None manifest.addPlugin( url=plugin['url'], name=plugin.get('name'), description=plugin.get('description'), category=plugin.get('category'), checksum=plugin.get('checksum'), + size=size, version=plugin.get('version'), - termsOfUse=plugin.get('terms_of_use'), + terms_of_use=plugin.get('terms_of_use'), destination=plugin.get('destination')) except KeyError as e: raise ManifestInvalid(f'The manifest file {url} ' @@ -291,3 +311,15 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest: 'definition: {plugin}') from e return manifest + + +def genManifest(manifest : Manifest) -> str: + """Converts the given manifest to JSON. """ + + fields = ['name', 'url', 'category', 'checksum', 'size', 'description', + 'version', 'destination', 'terms_of_use'] + plugins = list(manifest.values()) + plugins = [dataclasses.asdict(p) for p in plugins] + plugins = [{f : p[f] for f in fields} for p in plugins] + plugins = [{k : v for k, v in p.items() if v is not None } for p in plugins] + return json.dumps(plugins, indent=4) diff --git a/fsl/add_module/tests/test_fsl_add_module.py b/fsl/add_module/tests/test_fsl_add_module.py index 66a45b3d8d2404afbe8ac0c3fc9c18709e80d4de..c8e68f084c66bf968a6f37f8c0d9fb1fd272a9a6 100644 --- a/fsl/add_module/tests/test_fsl_add_module.py +++ b/fsl/add_module/tests/test_fsl_add_module.py @@ -73,14 +73,14 @@ def test_loadManifest_different_sources(): with pytest.raises(RuntimeError): fam.loadManifest(fam.parseArgs(['-m', 'nomanifest'])) - # otherwise should work ok + # Specify plugin URL m, p = fam.loadManifest( fam.parseArgs(['-m', 'nomanifest', 'http://abc.com/plugin.zip'])) assert p == ['plugin.zip'] assert list(m.keys()) == ['plugin.zip'] # default manifest - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', url): + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', url): m, p = fam.loadManifest(fam.parseArgs([])) assert len(p) == 0 assert list(m.keys()) == ['abc', 'def'] @@ -158,7 +158,7 @@ def test_loadManifest_destination_specified(): def test_selectPlugins_from_filepath_and_url(): - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None): + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None): args = fam.parseArgs('abc.zip def.zip -d dest'.split()) m, p = fam.loadManifest(args) plugins = fam.selectPlugins(args, m, p) @@ -188,7 +188,7 @@ def test_selectPlugins_name_from_manifest(): def test_selectPlugins_no_destination_specified(): - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None): + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None): args = fam.parseArgs('abc.zip def.zip'.split()) m, p = fam.loadManifest(args) @@ -389,9 +389,9 @@ def test_main_noargs(): # patch os.environ in case FSLDIR is set fammod = 'fsl.scripts.fsl_add_module' - with mock.patch(f'{fammod}.DEFAULT_MANIFEST_URL', f'{cwd}/manifest.json'), \ - mock.patch(f'{fammod}.DEFAULT_CATEGORY', None), \ - mock.patch(f'{fammod}.ARCHIVE_DIR', f'{cwd}/archives'), \ + with mock.patch(f'{fammod}.DEFAULT_MANIFEST', f'{cwd}/manifest.json'), \ + mock.patch(f'{fammod}.DEFAULT_CATEGORY', None), \ + mock.patch(f'{fammod}.ARCHIVE_DIR', f'{cwd}/archives'), \ mock.patch.dict(os.environ, clear=True): # user will be asked what plugins they want, @@ -414,7 +414,7 @@ def test_main_list(): with open('manifest.json', 'wt') as f: f.write(json.dumps(manifest)) - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', f'{cwd}/manifest.json'): fam.main(['-l']) fam.main('-l -v'.split()) @@ -511,8 +511,8 @@ def test_main_plugin_paths(): args = f'./abc.zip http://localhost:{srv.port}/def.zip'.split() fammod = 'fsl.scripts.fsl_add_module' - with mock.patch(f'{fammod}.DEFAULT_MANIFEST_URL', None), \ - mock.patch(f'{fammod}.ARCHIVE_DIR', f'{cwd}/archives'), \ + with mock.patch(f'{fammod}.DEFAULT_MANIFEST', None), \ + mock.patch(f'{fammod}.ARCHIVE_DIR', f'{cwd}/archives'), \ mock.patch.dict(os.environ, clear=True): with mock_input(''): @@ -615,7 +615,7 @@ def test_main_skip_already_downloaded(): # direct download - if file exists, it is # assumed to be ok, and not re-downloaded. - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None): + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None): fam.main(f'{url["abc"]} -a {archiveDir} -d {destDir} -f'.split()) assert os.stat(archivePath['abc']).st_mtime_ns == mtime['abc'] check_dir(destDir, ['a/b', 'c/d']) @@ -652,7 +652,7 @@ def test_main_customise_plugin_dir(): os.mkdir(defdest) os.mkdir(ghidest) - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', None), \ + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', None), \ mock_input('c', abcdest, defdest, ghidest): fam.main(f'abc.zip def.zip ghi.zip'.split()) @@ -743,7 +743,7 @@ def test_default_categories(): make_archive('d.zip', 'd/d.txt') # default -> only fsl_course_data downloaded - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', f'{cwd}/manifest.json'): fam.main('-f -a archives -d .'.split()) check_dir('.', @@ -758,7 +758,7 @@ def test_default_categories(): shutil.rmtree('d') # --category all -> all modules downloaded - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', f'{cwd}/manifest.json'): fam.main('-f -a archives -d . -c all'.split()) check_dir('.', @@ -775,7 +775,7 @@ def test_default_categories(): shutil.rmtree('d') # --category <something> -> <something> modules downloaded - with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST_URL', + with mock.patch('fsl.scripts.fsl_add_module.DEFAULT_MANIFEST', f'{cwd}/manifest.json'): fam.main('-f -a archives -d . -c patches'.split()) check_dir('.', diff --git a/fsl/add_module/tests/test_fsl_generate_module_manifest.py b/fsl/add_module/tests/test_fsl_generate_module_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..7752fc44963260650667ec6edc77953f334360fb --- /dev/null +++ b/fsl/add_module/tests/test_fsl_generate_module_manifest.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# +# test_fsl_generate_module_manifest.py - +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# + +import os.path as op +import json +import shlex +import textwrap as tw + +from . import tempdir, touch + +import fsl.add_module.routines as routines + +import fsl.scripts.fsl_generate_module_manifest as fgmm + + +def test_fsl_generate_module_manifest(): + plugins = ['1.zip', '2.zip', '3.zip'] + existing = tw.dedent(""" + [{ + "name": "1.zip", + "url": "http://a.com/1.zip", + "category": "cat1", + "description": "desc1", + "checksum": "baba", + "size": 3 + }, + { + "name": "2.zip", + "url": "http://a.com/2.zip", + "category": "cat2", + "description": "desc2", + "checksum": "abcdf", + "size": 4 + }, + { + "name": "3.zip", + "url": "http://a.com/3.zip", + "category": "cat3", + "description": "desc3", + "checksum": "aew", + "size": 10 + }] + """).strip() + with tempdir(): + for p in plugins: + touch(p) + + fgmm.main(shlex.split('-o manifest.json') + plugins) + + with open('manifest.json', 'rt', encoding='utf-8') as f: + got = json.load(f) + assert len(got) == len(plugins) + for expp, gotp in zip(plugins, got): + assert gotp['name'] == expp + assert gotp['size'] == op.getsize(expp) + assert gotp['checksum'] == routines.calcChecksum(expp) + + with open('existing.json', 'wt', encoding='utf-8') as f: + f.write(existing) + + fgmm.main(shlex.split('-o manifest.json -m existing.json') + plugins) + + with open('manifest.json', 'rt', encoding='utf-8') as f: + got = json.load(f) + + assert len(got) == len(plugins) + for i, (expp, gotp) in enumerate(zip(plugins, got), 1): + assert gotp['name'] == expp + assert gotp['size'] == op.getsize(expp) + assert gotp['checksum'] == routines.calcChecksum(expp) + assert gotp['category'] == f'cat{i}' + assert gotp['description'] == f'desc{i}' + assert gotp['url'] == f'http://a.com/{i}.zip' diff --git a/fsl/add_module/tests/test_plugin_manifest.py b/fsl/add_module/tests/test_manifest.py similarity index 89% rename from fsl/add_module/tests/test_plugin_manifest.py rename to fsl/add_module/tests/test_manifest.py index d04c96b4114fff6843433e9bd0bf0e003843a2d9..61aff61afb5c10f94325a782a52beb8d7c0b7cc6 100644 --- a/fsl/add_module/tests/test_plugin_manifest.py +++ b/fsl/add_module/tests/test_manifest.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# test_plugin_manifest.py - +# test_manifest.py - # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # @@ -14,7 +14,7 @@ from unittest import mock from . import tempdir, touch -import fsl.add_module.plugin_manifest as plgman +import fsl.add_module.manifest as plgman def test_downloadManifest(): @@ -115,3 +115,17 @@ def test_addPlugin(): assert manifest['plugin13'].url == 'http://abc.com/plugin13' manifest.addPlugin('http://abc.com/plugin14', name='plugin13') assert manifest['plugin13'].url == 'http://abc.com/plugin14' + + +def test_genManifest(): + m = plgman.Manifest() + m['plugin1'] = plgman.Plugin(url='http://a.b.c.1', name='plugin1', category='abc') + m['plugin2'] = plgman.Plugin(url='http://a.b.c.2', name='plugin2', description='Second') + + exp = [{'name' : 'plugin1', 'url' : 'http://a.b.c.1', 'category' : 'abc'}, + {'name' : 'plugin2', 'url' : 'http://a.b.c.2', 'category' : 'uncategorised', + 'description' : 'Second'}] + + got = plgman.genManifest(m) + + assert exp == json.loads(got) diff --git a/fsl/add_module/tests/test_ui.py b/fsl/add_module/tests/test_ui.py index da7fdfc4f8134751d6fd15bdc7694585a1b6414a..f476aa4dcd4aa6ea089966496d3b52ad22198514 100644 --- a/fsl/add_module/tests/test_ui.py +++ b/fsl/add_module/tests/test_ui.py @@ -14,9 +14,9 @@ import pathlib as plib import pytest -import fsl.add_module.routines as routines -import fsl.add_module.plugin_manifest as plgman -import fsl.add_module.ui as ui +import fsl.add_module.routines as routines +import fsl.add_module.manifest as plgman +import fsl.add_module.ui as ui from . import tempdir, server, mock_input, make_archive, touch diff --git a/fsl/add_module/ui.py b/fsl/add_module/ui.py index d73af279d9654122f13af43fc29d7d176cf236d8..506648eed9b97fb4a58e79acbe8dca9ad97b24ad 100644 --- a/fsl/add_module/ui.py +++ b/fsl/add_module/ui.py @@ -14,22 +14,22 @@ import pathlib from typing import Tuple, List, Union -from fsl.add_module.routines import (expand, - calcChecksum, - downloadFile, - CorruptArchive, - extractArchive) -from fsl.add_module.plugin_manifest import (Plugin, - Manifest, - downloadManifest) -from fsl.add_module.messages import (info, - important, - question, - warning, - error, - prompt, - EMPHASIS, - UNDERLINE) +from fsl.add_module.routines import (expand, + calcChecksum, + downloadFile, + CorruptArchive, + extractArchive) +from fsl.add_module.manifest import (Plugin, + Manifest, + downloadManifest) +from fsl.add_module.messages import (info, + important, + question, + warning, + error, + prompt, + EMPHASIS, + UNDERLINE) def downloadPluginManifest(url : Union[str, pathlib.Path]) -> Manifest: @@ -62,10 +62,15 @@ def printPlugin(plugin : Plugin, :arg verbose: If ``True``, more information is printed. """ - if index: - info(f'{index:2d} {plugin.name:25s}', EMPHASIS, indent=2) - else: - info(f'{plugin.name:25s}', EMPHASIS, indent=2) + header = plugin.name + + if index is not None: + header = f'{index:2d} {header}' + if plugin.size is not None: + mbytes = plugin.size / 1048576 + header = f'{header} [{mbytes:0.2f} MB]' + + info(header, EMPHASIS, indent=2) if plugin.version is not None: info(f'[version {plugin.version}]', indent=4) @@ -73,11 +78,11 @@ def printPlugin(plugin : Plugin, if plugin.description is not None: info(plugin.description, indent=4, wrap=True) - if plugin.termsOfUse is not None: + if plugin.terms_of_use is not None: info('Installation of this plugin is subject ' 'to the following terms of use:', indent=4) - info(plugin.termsOfUse, indent=6, wrap=True) + info(plugin.terms_of_use, indent=6, wrap=True) if verbose: for item in ('url', 'destination', 'checksum'): diff --git a/fsl/scripts/fsl_add_module.py b/fsl/scripts/fsl_add_module.py index edfb74450207a1c681c422ef658b9876874cffd2..787d3bb508b30801260ba5ecb604741008975066 100755 --- a/fsl/scripts/fsl_add_module.py +++ b/fsl/scripts/fsl_add_module.py @@ -35,9 +35,9 @@ Plugin manifest file The plugin manifest file is a JSON file which contains information about all plugins that are available. An official manifest file is hosted on the FSL -website, but an alternative manifest file may be specified when this script -is invoked. See the :mod:`.plugin_manifest` module for more details on the -format of a plugin manifest file. +website, but an alternative manifest file may be specified when this script is +invoked. See the :mod:`.manifest` module for more details on the format of a +plugin manifest file. Usage examples @@ -102,24 +102,32 @@ import argparse from typing import List, Tuple -import fsl.add_module.ui as ui -import fsl.add_module.routines as routines -import fsl.add_module.plugin_manifest as plgman -from fsl.add_module import __version__ -from fsl.add_module.messages import (info, - important, - warning, - error, - EMPHASIS, - UNDERLINE) - - -DEFAULT_MANIFEST_URL = 'http://fsl.fmrib.ox.ac.uk/fslcourse/downloads/manifest.json' -"""Location of the official FSL plugin manifest file, downloaded if an -alternate manifest file is not specified. +import fsl.add_module.ui as ui +import fsl.add_module.routines as routines +import fsl.add_module.manifest as plgman +from fsl.add_module import __version__ +from fsl.add_module.messages import (info, + important, + warning, + error, + EMPHASIS, + UNDERLINE) + + +OFFICIAL_MANIFEST_URLS = { + 'fslcourse' : 'http://fsl.fmrib.ox.ac.uk/fslcourse/downloads/manifest.json', + 'gradcourse' : 'https://fsl.fmrib.ox.ac.uk/fslcourse/graduate/downloads/manifest.json' +} +"""Locations of the official FSL plugin manifest files, downloaded if an +alternate manifest file is not specified. These 'built-in' manifest URLs +can be referred to by their aliases. """ +DEFAULT_MANIFEST = 'fslcourse' +"""Default manifest to use, if one is not specified.""" + + DEFAULT_CATEGORY = 'fsl_course_data' """Default value for the ``--category`` command-line option, when the user does not specify an alternative ``--manifest`` or ``--category``. @@ -139,9 +147,12 @@ def parseArgs(argv : List[str]) -> argparse.Namespace: 'version' : 'Print version and exit.', 'verbose' : 'Output more information.', 'list' : 'Print all available modules and exit. All other ' - 'options, apart from --manifest, are ignored.', + 'options, apart from --manifest and --verbose, are ' + 'ignored.', 'module' : 'Name or URL of FSL module to download.', - 'manifest' : 'URL to module manifest file.', + 'manifest' : 'Name of official manifest file to download, or ' + 'alternate URL to module manifest file (default: ' + f'{DEFAULT_MANIFEST})', 'archiveDir' : 'Directory to cache downloaded files in.', 'category' : 'Only display available modules from the specified ' 'category. Defaults to "fsl_course_data", unless ' @@ -187,12 +198,23 @@ def parseArgs(argv : List[str]) -> argparse.Namespace: # course data. If we are using a custom manifest, # we don't want to set a default category. if args.manifest is None: - args.manifest = DEFAULT_MANIFEST_URL - if args.manifest == DEFAULT_MANIFEST_URL and args.category is None: + args.manifest = DEFAULT_MANIFEST + if args.manifest == DEFAULT_MANIFEST and args.category is None: args.category = DEFAULT_CATEGORY if args.category == 'all': args.category = None + # Built-in manifest referred to by alias, e.g. "fslcourse"? + if not (args.manifest is None or + args.manifest.startswith('http') or + op.exists(args.manifest)): + try: + args.manifest = OFFICIAL_MANIFEST_URLS[args.manifest] + except KeyError: + warning(f'Unknown manifest: {args.manifest}. Known manifests: ' + + ", ".join(OFFICIAL_MANIFEST_URLS.keys()) + ' (or give a ' + 'URL to an alternative manifest file)') + if args.archiveDir: args.archiveDir = op.abspath(op.expanduser(args.archiveDir)) diff --git a/fsl/scripts/fsl_generate_module_manifest.py b/fsl/scripts/fsl_generate_module_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..c4d89c1e9b5b85a14c0f79530f7f400565a9b2ee --- /dev/null +++ b/fsl/scripts/fsl_generate_module_manifest.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# +# fsl_generate_module_manifest.py - Generate a template manifest.json file for +# use by fsl_add_module. +# +# Author: Paul McCarthy <pauldmccarthy@gmail.com> +# +"""The ``fsl_generate_module_manifest`` script is intended for use by FSL +dataset maintainers. It can be used to generate a JSON manifest file which +describes a set of archive/plugin files that are to be made available for +download. +""" + + +import sys +import argparse +import contextlib as ctxlib +import os.path as op + +from typing import List + +import fsl.add_module.manifest as plgman +import fsl.add_module.routines as routines + + +def parseArgs(argv : List[str]) -> argparse.Namespace: + """Parse command-line arguments. The user is expected to provide a set of + input archive/plugin files and a destination file to save the JSON + manifest. An existing manifest file/URL can optionally be provided - + metadata will be copied over from this file for archive files with + matching names. + """ + + parser = argparse.ArgumentParser( + 'fsl_generate_module_manifest', + usage='fsl_generate_module_manifest [options] infile [infile ...]', + description='Generate a manifest.json file for use by fsl_add_module') + + parser.add_argument('-m', '--manifest', + help='Previous version of manifest to copy metadata ' + 'from. The name, category, url, description, and ' + 'destination fields are all copied across for ' + 'archive files with matching names.') + parser.add_argument('-o', '--outfile', + help='Location to save new manifest (default: stdout)') + parser.add_argument('infile', nargs='+', + help='Archive file to include in new manifest.') + + return parser.parse_args(argv) + + +def main(argv=None): + """Main routine. Generates a JSON manifest describing a set of files. + """ + if argv is None: + argv = sys.argv[1:] + + args = parseArgs(argv) + + if args.manifest is not None: + oldmanifest = plgman.downloadManifest(args.manifest) + oldmanifest = {p.rawFileName : p for p in oldmanifest.values()} + else: + oldmanifest = plgman.Manifest() + + newmanifest = plgman.Manifest() + + for infile in args.infile: + basename = op.basename(infile) + + if basename in oldmanifest: + plugin = oldmanifest[basename] + else: + plugin = plgman.Plugin(url='', name=basename) + + plugin.size = op.getsize(infile) + plugin.checksum = routines.calcChecksum(infile) + newmanifest[plugin.name] = plugin + + newmanifest = plgman.genManifest(newmanifest) + + if args.outfile is None: + print(newmanifest) + else: + with open(args.outfile, 'wt', encoding='utf-8') as f: + f.write(newmanifest) + + +if __name__ == '__main__': + main() diff --git a/setup.py b/setup.py index 366f3113f82872901caceb69d97c7b2be4260e20..e35bd2053ae78c95df46debe6b5212c25a91dfda 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,8 @@ setup( entry_points={ 'console_scripts' : [ - 'fsl_add_module = fsl.scripts.fsl_add_module:main', + 'fsl_add_module = fsl.scripts.fsl_add_module:main', + 'fsl_generate_module_manifest = fsl.scripts.fsl_generate_module_manifest:main', ] } )