Commit 640dae9c authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'enh/version' into 'master'

Support module versions and terms of use

Closes #1 and #2

See merge request !8
parents 8a921842 4355270a
Pipeline #8493 failed with stages
in 1 minute and 13 seconds
......@@ -5,6 +5,10 @@ set -e
pip install -r requirements.txt
pip install -r requirements-dev.txt
# pytest can have trouble with
# native namespace packages
export PYTHONPATH=$(pwd)
# noroottest tests will fail/be skipped
# if not executed as a non-root user, so
# we run them as nobody
......
......@@ -7,6 +7,7 @@ stages:
- build
- fsl-ci-pre
- fsl-ci-build
- fsl-ci-test
- fsl-ci-deploy
test:3.7:
......
# `fsl_add_module` changelog
## 0.3.0 (Under development)
- Plugin manifest entries can now contain `version` and `terms_of_use` fields
which, if present, are displayed during installation.
- The `fsl` and `fsl.scripts` packages are now defined as native namespace
packages, where they were previously defined as `pkgutil`-style namespace
packages.
## 0.2.0 (Monday 2nd November 2020)
- Initial release of the `fsl_add_module` script.
......
......@@ -24,7 +24,7 @@ requested by the user.
When called like this, the script will download a "manifest" file from a fixed
URL, which is a JSON file containing a list of all modules that are availble,
URL, which is a JSON file containing a list of all modules that are available,
and where they can be downloaded from. Once the manifest file has been
downloaded, `fsl_add_module` will display a list of all available modules, and
will prompt the user to select which modules they would like to download.
......
#!/usr/bin/env python
#
# _init__.py
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
""".. note:: The ``fsl`` namespace is a ``pkgutil``-style *namespace package* -
it can be used across different projects - see
https://packaging.python.org/guides/packaging-namespace-packages/
for details.
"""
__path__ = __import__('pkgutil').extend_path(__path__, __name__) # noqa
......@@ -9,5 +9,5 @@
"""
__version__ = '0.2.0'
__version__ = '0.3.0.dev0'
"""``fsl_add_module`` version number."""
......@@ -63,7 +63,7 @@ def runAsAdmin(routine : str, *argv : str):
def lookup(func : str) -> Callable:
"""Looks up and returns a reference to a function which is named ``func``,
which is defined in the :mod:`fsl.add_module` module.
which is defined in the :mod:`fsl.add_module.routines` module.
"""
import fsl.add_module.routines as routines
......
......@@ -16,31 +16,36 @@ list of FSL plugin definitions, e.g.::
"url" : "http://.../UnixIntro.zip",
"checksum" : "f8222...",
"description" : "Data for the unix introduction",
"version" : "1.2.3",
"destination" : "~/"
},
{
"name" : "Preparatory material",
"category" : "fsl_course_data",
"url" : "http://...preCourse.zip",
"checksum" : "e659f",
"description" : "Data for the preparatory practicals",
"destination" : "~/"
"name" : "ABCD123 Mouse atlas",
"category" : "fsl_atlases",
"url" : "http://localhost:8000/abcd123_mouse_atlas.tar.gz",
"checksum" : "658701ff9a49b06f0d0ee38bce36a1c370fdfd0751ab17c2fd7b7b561d3b92e0",
"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/"
}
]
Each plugin definition must contain the ``url`` field; all other fieids are
optional but recommended:
- ``name``: A unique name for the plugin. Defaults to the filename
component of the ``url``.
- ``url``: URL of the plugin archive file.
- ``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.
- ``description``: An extended description of the plugin
- ``destination``: Default installation directory. May contain environment
variables and ``~``.
"""
- ``name``: A unique name for the plugin. Defaults to the filename
component of the ``url``.
- ``url``: URL of the plugin archive file.
- ``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.
- ``description``: An extended description of the plugin
- ``version``: A version identifier for the plugin (used solely for
descriptive purposes)
- ``tersm_of_use``: Terms of use for the plugin (displayed to the user)
- ``destination``: Default installation directory. May contain environment
variables and ``~``.
""" # noqa: E501
import os.path as op
......@@ -93,6 +98,15 @@ class Plugin:
"""SHA256 checksum of the plugin archive file, used to verify downloads.
"""
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 for the plugin - displayed to the user during installation.
"""
destination : Union[None, str, pathlib.Path] = None
"""Installation/destination directory - where the plugin archive file
will be extracted. May have a default value in the manifest, but may
......@@ -135,6 +149,8 @@ class Manifest(dict):
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:
"""Add a new :class:`Plugin` to the manifest.
......@@ -144,6 +160,8 @@ class Manifest(dict):
: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).
......@@ -185,6 +203,8 @@ class Manifest(dict):
category=category,
description=description,
checksum=checksum,
version=version,
termsOfUse=termsOfUse,
destination=destination,
origDestination=origDestination,
archiveFile=archiveFile,
......@@ -249,6 +269,8 @@ def downloadManifest(url : Union[str, pathlib.Path]) -> Manifest:
description=plugin.get('description'),
category=plugin.get('category'),
checksum=plugin.get('checksum'),
version=plugin.get('version'),
termsOfUse=plugin.get('terms_of_use'),
destination=plugin.get('destination'))
except KeyError as e:
raise ManifestInvalid(f'The manifest file {url} '
......
......@@ -52,6 +52,43 @@ def downloadPluginManifest(url : Union[str, pathlib.Path]) -> Manifest:
return manifest
def printPlugin(plugin : Plugin,
index : int = None,
verbose : bool = False):
"""Prints an overview of ``plugin``. Used by :func:`listPlugins` and
:func:`selectPlugins`.
:arg plugin: The :class:`.Plugin` to print.
:arg index: Plugin index number (optional).
: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)
if plugin.version is not None:
info(f'[version {plugin.version}]', indent=4)
if plugin.description is not None:
info(plugin.description, indent=4, wrap=True)
if plugin.termsOfUse 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)
if verbose:
for item in ('url', 'destination', 'checksum'):
value = getattr(plugin, item)
item = f'{item}:'
if value in (None, ''):
value = 'N/A'
info(f'{item:12s} {value}', indent=4)
def listPlugins(manifest : Manifest, verbose : bool = False):
"""Prints a list of all available plugins.
......@@ -67,18 +104,7 @@ def listPlugins(manifest : Manifest, verbose : bool = False):
plugins = manifest.getCategory(category)
for plugin in plugins:
info(f'{plugin.name:25s}', EMPHASIS, indent=2)
if plugin.description is not None:
info(plugin.description, indent=4, wrap=True)
if verbose:
for item in ('url', 'destination', 'checksum'):
value = getattr(plugin, item)
item = f'{item}:'
if value in (None, ''):
value = 'N/A'
info(f'{item:12s} {value}', indent=4)
printPlugin(plugin, verbose=verbose)
def createArchiveDir(archiveDir : Union[str, pathlib.Path]):
......@@ -95,34 +121,34 @@ def createArchiveDir(archiveDir : Union[str, pathlib.Path]):
f'{archiveDir}!') from e
def _selectOnePlugin(pluginName : str) -> List[str]:
def _selectOnePlugin(plugin : Plugin) -> List[Plugin]:
"""Sub-function of :func:`selectPlugins` called when there is only one
plugin available to install. Asks the user if they would like to install
the plugin.
:arg pluginName: Name of the available plugin
:returns: Either ``[pluginName]``, or ``[]``, depending on whether
the user selected the plugin.
:arg plugin: The available plugin
:returns: Either ``[plugin]``, or ``[]``, depending on whether
the user selected the plugin.
"""
question(f'Do you want to download the [{pluginName}] module?', EMPHASIS)
question(f'Do you want to download the [{plugin.name}] module?', EMPHASIS)
info('Press enter or type Y to confirm the installation. Any other '
'response will cancel the download.', indent=2, wrap=True)
response = prompt(f'Install [{pluginName}]? (Y/n): ')
response = prompt(f'Install [{plugin.name}]? (Y/n): ')
if response.lower() in ('', 'y', 'yes'):
return [pluginName]
return [plugin]
else:
return []
def _selectMultiplePlugins(pluginNames : List[str]) -> List[str]:
def _selectMultiplePlugins(plugins : List[Plugin]) -> List[Plugin]:
"""Sub-function of :func:`selectPlugins` called when there are multiple
plugins available to install. Prompts the user to select which plugins to
install.
:arg pluginNames: List of available plugins.
:returns: List of plugins selected by the user.
:arg plugins: List of available plugins.
:returns: List of plugins selected by the user.
"""
while True:
......@@ -130,29 +156,28 @@ def _selectMultiplePlugins(pluginNames : List[str]) -> List[str]:
info('Type "all" to download all of the modules that are listed. '
'Alternately, enter the numbers of each module you would like '
'to download, separated by spaces. For example, if you would '
f'like to download the [{pluginNames[0]}] and [{pluginNames[1]}] '
'modules, enter "1 2".', indent=2, wrap=True)
f'like to download the [{plugins[0].name}] and '
'[{plugins[1],name}] modules, enter "1 2".', indent=2, wrap=True)
plugins = prompt('Enter module(s) to download: ')
response = prompt('Enter module(s) to download: ')
if plugins.lower() == 'all':
plugins = pluginNames
if response.lower() == 'all':
break
try:
plugins = [int(t) for t in plugins.split()]
response = [int(i) for i in response.split()]
except Exception:
error(f'Specified module(s) {plugins} not understood')
error(f'Specified module(s) {response} not understood')
continue
if len(plugins) == 0:
if len(response) == 0:
warning('No modules specified! Type "all" to '
'download all modules.', wrap=True)
continue
if any([p <= 0 or p > len(pluginNames) for p in plugins]):
error(f'One of the requested modules [{plugins}] does not exist')
if any([i <= 0 or i > len(plugins) for i in response]):
error(f'One of the requested modules [{response}] does not exist')
continue
plugins = [pluginNames[p - 1] for p in plugins]
plugins = [plugins[i - 1] for i in response]
break
return plugins
......@@ -171,25 +196,23 @@ def selectPlugins(manifest : Manifest, category : str = None) -> List[Plugin]:
that the user selected.
"""
plugins = list(manifest.keys())
plugins = list(manifest.values())
if category is not None:
plugins = [n for n in plugins if manifest[n].category == category]
plugins = [p for p in plugins if p.category == category]
if len(plugins) == 0:
error('No modules are available!')
return []
important('Modules available for download:', EMPHASIS)
info('')
if category is not None:
info(f' (Only showing modules in the [{category}] category)')
for i, name in enumerate(plugins, 1):
plugin = manifest[name]
name = f'[{name}]'
info(f' {i} {name:25s}', EMPHASIS)
if plugin.description is not None:
info(plugin.description, indent=4)
for i, plugin in enumerate(plugins, 1):
printPlugin(plugin, i)
info('')
if len(plugins) > 1:
plugins = _selectMultiplePlugins(plugins)
......@@ -202,9 +225,9 @@ def selectPlugins(manifest : Manifest, category : str = None) -> List[Plugin]:
important('Modules selected for installation:', EMPHASIS)
for p in plugins:
info(f' {p}', EMPHASIS)
info(f' {p.name}', EMPHASIS)
return [manifest[p] for p in plugins]
return plugins
def _checkExist(dirname : Union[str, pathlib.Path]):
......
#!/usr/bin/env python
#
# __init__.py - The fsl.scripts package.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""The ``fsl.scripts`` package contains all of the executable scripts provided
by ``fslpy``, and other python-based FSL packages.
.. note:: The ``fsl.scripts`` namespace is a ``pkgutil``-style *namespace
package* - it can be used across different projects - see
https://packaging.python.org/guides/packaging-namespace-packages/ for
details.
"""
__path__ = __import__('pkgutil').extend_path(__path__, __name__) # noqa
......@@ -273,6 +273,11 @@ def selectPlugins(args : argparse.Namespace,
plugins = ui.selectPlugins(manifest, args.category)
else:
plugins = [manifest[p] for p in plugins]
info('Installing the following modules:', EMPHASIS)
info('')
for p in plugins:
ui.printPlugin(p)
info('')
# warn the user if any plugins have a default
# destination that is based on an unset
......
......@@ -5,6 +5,7 @@
"url" : "http://localhost:8000/UnixIntro.zip",
"checksum" : "f82229847c83eee328a547031752e44975a40cdf255afa7bddf520db9742bb8e",
"description" : "Data for the unix introduction",
"version" : "1.2.3",
"destination" : "~/"
},
{
......@@ -72,12 +73,13 @@
"destination" : "~/"
},
{
"name" : "ABCD123 Mouse atlas",
"category" : "fsl_atlases",
"url" : "http://localhost:8000/abcd123_mouse_atlas.tar.gz",
"checksum" : "658701ff9a49b06f0d0ee38bce36a1c370fdfd0751ab17c2fd7b7b561d3b92e0",
"description" : "ABCD123 FSL mouse atlas derived from hand segmentations of 3813 small chocolate mice.",
"destination" : "$FSLDIR/data/atlases/"
"name" : "ABCD123 Mouse atlas",
"category" : "fsl_atlases",
"url" : "http://localhost:8000/abcd123_mouse_atlas.tar.gz",
"checksum" : "658701ff9a49b06f0d0ee38bce36a1c370fdfd0751ab17c2fd7b7b561d3b92e0",
"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/"
},
{
"name" : "Critical FSL patch #1",
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment