diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f482431d3..b68d1ee16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,5 @@ -# Install pre-commit hooks via +# # Install pre-commit hooks via # pre-commit install - -# modernizer: make sure our code-base is Python 3 ready -- repo: https://github.com/python-modernize/python-modernize.git - sha: a234ce4e185cf77a55632888f1811d83b4ad9ef2 - hooks: - - id: python-modernize - exclude: ^docs/ - args: - - --write - - --nobackups - repo: local hooks: diff --git a/.prospector.yaml b/.prospector.yaml new file mode 100644 index 000000000..30673782e --- /dev/null +++ b/.prospector.yaml @@ -0,0 +1,19 @@ +max-line-length: 120 + +ignore-paths: + - doc + - examples + - test + - utils + +pylint: + run: true + +pyflakes: + run: false + +pep8: + run: false + +mccabe: + run: false diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..87a432065 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,422 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore= + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=bad-continuation,locally-disabled,useless-suppression,django-not-available,bad-option-value + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[BASIC] + +# Naming hint for argument names +argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct argument names +argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Naming hint for attribute names +attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct attribute names +attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=5 + +# Naming hint for function names +function-name-hint=(([a-z][a-z0-9_]{2,40})|(_[a-z0-9_]*))$ + +# Regular expression matching correct function names +function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_,pk + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for method names +method-name-hint=(([a-z][a-z0-9_]{2,40})|(_[a-z0-9_]*))$ + +# Regular expression matching correct method names +method-rgx=(([a-z][a-z0-9_]{2,40})|(_[a-z0-9_]*)|(setUp)|(tearDown))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_,setUp,tearDown + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming hint for variable names +variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +# This is a pylint issue https://github.com/PyCQA/pylint/issues/73 +ignored-modules=distutils + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=6 + +# Maximum number of attributes for a class (see R0902). +max-attributes=12 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=20 + +# Maximum number of parents for a class (see R0901). +max-parents=20 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=1 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 000000000..b3d849f2d --- /dev/null +++ b/.style.yapf @@ -0,0 +1,3 @@ +[style] +based_on_style = google +column_limit = 120 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..2fe860ecf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: +- "3.7" + +before_install: + # Upgrade pip setuptools and wheel + - pip install -U wheel setuptools coveralls + +install: +- pip install -e .[testing,pre-commit] +- reentry scan -r aiida + +script: + - pre-commit install; pre-commit run --all-files || ( git status --short; git diff ; exit 1 ); diff --git a/README.md b/README.md index 88eaa334d..a92810cf5 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ pip install aiidalab-widgets-base Install the corresponding `aiidalab-widgets-base` AiiDA lab application via the app manager as usual. +### Optional dependencies + +* The `SmilesWidget` widget requires the [OpenBabel](http://openbabel.org/) library. + ## Usage Using the widgets usually just involves importing and displaying them. diff --git a/aiida_datatypes.ipynb b/aiida_datatypes_viewers.ipynb similarity index 61% rename from aiida_datatypes.ipynb rename to aiida_datatypes_viewers.ipynb index 2f5a0dcd5..cf38f5ac4 100644 --- a/aiida_datatypes.ipynb +++ b/aiida_datatypes_viewers.ipynb @@ -1,5 +1,16 @@ { "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "%aiida" + ] + }, { "cell_type": "code", "execution_count": null, @@ -7,11 +18,16 @@ "outputs": [], "source": [ "from os import path\n", - "from aiida import load_dbenv, is_dbenv_loaded\n", - "from aiida.backends import settings\n", - "if not is_dbenv_loaded():\n", - " load_dbenv(profile=settings.AIIDADB_PROFILE)\n", - "from aiida.orm import DataFactory" + "import numpy\n", + "from aiida.plugins import DataFactory\n", + "from aiidalab_widgets_base import viewer" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## TrajectoryData" ] }, { @@ -20,14 +36,69 @@ "metadata": {}, "outputs": [], "source": [ - "from aiidalab_widgets_base import aiidalab_display" + "# visualize TrajectoryData\n", + "TrajectoryData = DataFactory('array.trajectory')\n", + "\n", + "traj = TrajectoryData()\n", + "\n", + "stepids = numpy.array([60, 70])\n", + "times = stepids * 0.01\n", + "cells = numpy.array([\n", + " [\n", + " [2., 0., 0.,], \n", + " [0., 2., 0.,],\n", + " [0., 0., 2.,]\n", + " ],\n", + " [\n", + " [3., 0., 0.,],\n", + " [0., 3., 0.,],\n", + " [0., 0., 3.,]\n", + " ]\n", + "])\n", + "symbols = ['H', 'O', 'H']\n", + "positions = numpy.array([\n", + " [\n", + " [0., 0., 0.],\n", + " [0.5, 0.5, 0.5],\n", + " [1.5, 1.5, 1.5]\n", + " ],\n", + " [\n", + " [0., 0., 0.],\n", + " [0.5, 0.5, 0.5],\n", + " [1.5, 1.5, 1.5]],\n", + "])\n", + "velocities = numpy.array([\n", + " [\n", + " [0., 0., 0.],\n", + " [0., 0., 0.],\n", + " [0., 0., 0.]\n", + " ],\n", + " [\n", + " [0.5, 0.5, 0.5],\n", + " [0.5, 0.5, 0.5],\n", + " [-0.5, -0.5, -0.5]\n", + " ]\n", + "])\n", + "\n", + "energy = numpy.array([1., 2.])\n", + "# I set the node\n", + "traj.set_trajectory(\n", + " stepids=stepids, cells=cells, symbols=symbols, positions=positions, times=times,\n", + " velocities=velocities\n", + ")\n", + "traj.set_array('energy', energy)\n", + "\n", + "traj.store()\n", + "vwr = viewer(traj)\n", + "#vwr = viewer(load_node('9bfc006b'))\n", + "display(vwr)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## ParameterData" + "## Dict" ] }, { @@ -36,15 +107,15 @@ "metadata": {}, "outputs": [], "source": [ - "# visualize ParameterData\n", - "ParameterData = DataFactory('parameter')\n", - "p = ParameterData(dict={\n", + "Dict = DataFactory('dict')\n", + "p = Dict(dict={\n", " 'Parameter' :'super long string '*4,\n", " 'parameter 2' :'value 2',\n", " 'parameter 3' : 1,\n", " 'parameter 4' : 2,\n", "})\n", - "aiidalab_display(p.store(), downloadable=True)" + "vwr = viewer(p.store(), downloadable=True)\n", + "display(vwr)" ] }, { @@ -72,10 +143,10 @@ "metadata": {}, "outputs": [], "source": [ - "# visualize CifData\n", "CifData = DataFactory('cif')\n", "s = CifData(ase=m)\n", - "aiidalab_display(s.store(), downloadable=True)" + "vwr = viewer(s.store(), downloadable=True)\n", + "display(vwr)" ] }, { @@ -91,10 +162,10 @@ "metadata": {}, "outputs": [], "source": [ - "# visualize StructureData\n", "StructureData = DataFactory('structure')\n", "s = StructureData(ase=m)\n", - "aiidalab_display(s.store(), downloadable=True)" + "vwr = viewer(s.store(), downloadable=True)\n", + "display(vwr)" ] }, { @@ -145,16 +216,9 @@ " (5, u'X'),\n", " (6, u'Z'),\n", " (11, u'U')]\n", - "bs.labels = labels" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "aiidalab_display(bs.store()) # to visualize the bands" + "bs.labels = labels\n", + "vwr = viewer(bs.store(), downloadable=True)\n", + "display(vwr)" ] }, { @@ -172,33 +236,34 @@ "source": [ "FolderData = DataFactory('folder')\n", "fd = FolderData()\n", - "with fd.folder.open(path.join('path','test1.txt'), 'w') as fobj:\n", - " fobj.write('content of test1 file')\n", - "with fd.folder.open(path.join('path','test2.txt'), 'w') as fobj:\n", - " fobj.write('content of test2\\nfile')\n", - "with fd.folder.open(path.join('path','test_long.txt'), 'w') as fobj:\n", - " fobj.write('content of test_long file'*1000)\n", - "aiidalab_display(fd.store(), downloadable=True)" + "with fd.open('test1.txt', 'w') as fobj:\n", + " fobj.write(u'content of test1 file')\n", + "with fd.open('test2.txt', 'w') as fobj:\n", + " fobj.write(u'content of test2\\nfile')\n", + "with fd.open('test_long.txt', 'w') as fobj:\n", + " fobj.write(u'content of test_long file'*1000)\n", + "vwr = viewer(fd.store(), downloadable=True)\n", + "display(vwr)" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4, diff --git a/aiidalab_widgets_base/__init__.py b/aiidalab_widgets_base/__init__.py index 7a6b6e8a0..4eed4421f 100644 --- a/aiidalab_widgets_base/__init__.py +++ b/aiidalab_widgets_base/__init__.py @@ -1,15 +1,19 @@ +"""Reusable widgets for AiiDA lab applications.""" # pylint: disable=unused-import -from aiida import load_dbenv, is_dbenv_loaded -from aiida.backends import settings -if not is_dbenv_loaded(): - load_dbenv(profile=settings.AIIDADB_PROFILE) +from aiida import load_profile +load_profile() -from .codes import CodeDropdown, AiiDACodeSetup, extract_aiidacodesetup_arguments # noqa -from .computers import SshComputerSetup, extract_sshcomputersetup_arguments # noqa -from .computers import AiidaComputerSetup, extract_aiidacomputer_arguments # noqa -from .databases import CodQueryWidget # noqa -from .display import aiidalab_display # noqa -from .structures import StructureUploadWidget # noqa +from .codes import CodeDropdown, AiiDACodeSetup, valid_aiidacode_args # noqa +from .computers import SshComputerSetup, valid_sshcomputer_args # noqa +from .computers import AiidaComputerSetup, valid_aiidacomputer_args # noqa +from .computers import ComputerDropdown # noqa +from .databases import CodQueryWidget # noqa +#from .editors import editor # noqa +from .export import ExportButtonWidget # noqa +from .process import ProcessFollowerWidget, ProgressBarWidget, RunningCalcJobOutputWidget, SubmitButtonWidget # noqa +from .structures import StructureManagerWidget # noqa +from .structures import StructureBrowserWidget, StructureExamplesWidget, StructureUploadWidget, SmilesWidget # noqa from .structures_multi import MultiStructureUploadWidget # noqa +from .viewers import viewer # noqa -__version__ = "0.4.0b2" +__version__ = "1.0.0a7" diff --git a/aiidalab_widgets_base/aiida_visualizers.py b/aiidalab_widgets_base/aiida_visualizers.py deleted file mode 100644 index 3f7f396a1..000000000 --- a/aiidalab_widgets_base/aiida_visualizers.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import print_function -import os - -import ipywidgets as ipw - -class ParameterDataVisualizer(ipw.HTML): - """Visualizer class for ParameterData object""" - def __init__(self, parameter, downloadable=True, **kwargs): - super(ParameterDataVisualizer, self).__init__(**kwargs) - import pandas as pd - # Here we are defining properties of 'df' class (specified while exporting pandas table into html). - # Since the exported object is nothing more than HTML table, all 'standard' HTML table settings - # can be applied to it as well. - # For more information on how to controle the table appearance please visit: - # https://css-tricks.com/complete-guide-table-element/ - self.value = ''' - - ''' - pd.set_option('max_colwidth', 40) - df = pd.DataFrame([(key, value) for key, value - in sorted(parameter.get_dict().items()) - ], columns=['Key', 'Value']) - self.value += df.to_html(classes='df', index=False) # specify that exported table belongs to 'df' class - # this is used to setup table's appearance using CSS - if downloadable: - import base64 - payload = base64.b64encode(df.to_csv(index=False).encode()).decode() - fname = '{}.csv'.format(parameter.pk) - to_add = """Download table in csv format: {title}""" - self.value += to_add.format(filename=fname, payload=payload,title=fname) - -class StructureDataVisualizer(ipw.VBox): - """Visualizer class for StructureData object""" - def __init__(self, structure, downloadable=True, **kwargs): - import nglview - self._structure = structure - viewer = nglview.NGLWidget() - viewer.add_component(nglview.ASEStructure(self._structure.get_ase())) # adds ball+stick - viewer.add_unitcell() - children = [viewer] - if downloadable: - self.file_format = ipw.Dropdown( - options=['xyz', 'cif'], - description="File format:", - ) - self.download_btn = ipw.Button(description="Download") - self.download_btn.on_click(self.download) - children.append(ipw.HBox([self.file_format, self.download_btn])) - super(StructureDataVisualizer, self).__init__(children, **kwargs) - - def download(self, b=None): - import base64 - from tempfile import TemporaryFile - from IPython.display import Javascript - with TemporaryFile() as fobj: - self._structure.get_ase().write(fobj, format=self.file_format.value) - fobj.seek(0) - b64 = base64.b64encode(fobj.read()) - payload = b64.decode() - js = Javascript( - """ - var link = document.createElement('a'); - link.href = "data:;base64,{payload}" - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload,filename=str(self._structure.id)+'.'+self.file_format.value) - ) - display(js) - -class FolderDataVisualizer(ipw.VBox): - """Visualizer class for FolderData object""" - def __init__(self, folder, downloadable=True, **kwargs): - self._folder = folder - self.files = ipw.Dropdown( - options=self._folder.get_folder_list(), - description="Select file:", - ) - self.text = ipw.Textarea( - value="", - description='File content:', - layout={'width':"900px", 'height':'300px'}, - disabled=False - ) - self.change_file_view() - self.files.observe(self.change_file_view, names='value') - children = [self.files, self.text] - if downloadable: - self.download_btn = ipw.Button(description="Download") - self.download_btn.on_click(self.download) - children.append(self.download_btn) - super(FolderDataVisualizer, self).__init__(children, **kwargs) - - def change_file_view(self, b=None): - with open(self._folder.get_abs_path(self.files.value), "rb") as fobj: - self.text.value = fobj.read() - - def download(self, b=None): - import base64 - from IPython.display import Javascript - with open(self._folder.get_abs_path(self.files.value), "rb") as fobj: - b64 = base64.b64encode(fobj.read()) - payload = b64.decode() - js = Javascript( - """ - var link = document.createElement('a'); - link.href = "data:;base64,{payload}" - link.download = "{filename}" - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - """.format(payload=payload,filename=self.files.value) - ) - display(js) - -class BandsDataVisualizer(ipw.VBox): - """Visualizer class for BandsData object""" - def __init__(self, bands, **kwargs): - from bokeh.io import show, output_notebook - from bokeh.models import Span - from bokeh.plotting import figure - output_notebook(hide_banner=True) - out = ipw.Output() - with out: - plot_info = bands._get_bandplot_data(cartesian=True, join_symbol="|") - # Extract relevant data - y = plot_info['y'].transpose().tolist() - x = [plot_info['x'] for i in range(len(y))] - labels = plot_info['labels'] - # Create the figure - p = figure(y_axis_label='Dispersion ({})'.format(bands.units)) - p.multi_line(x, y, line_width=2, line_color='red') - p.xaxis.ticker = [l[0] for l in labels] - # This trick was suggested here: https://github.com/bokeh/bokeh/issues/8166#issuecomment-426124290 - p.xaxis.major_label_overrides = {int(l[0]) if l[0].is_integer() else l[0]:l[1] for l in labels} - # Add vertical lines - p.renderers.extend([Span(location=l[0], dimension='height', line_color='black', line_width=3) for l in labels]) - show(p) - children = [out] - super(BandsDataVisualizer, self).__init__(children, **kwargs) \ No newline at end of file diff --git a/aiidalab_widgets_base/codes.py b/aiidalab_widgets_base/codes.py index 61e758542..8d2155908 100644 --- a/aiidalab_widgets_base/codes.py +++ b/aiidalab_widgets_base/codes.py @@ -1,32 +1,27 @@ -from __future__ import print_function - -from __future__ import absolute_import -import ipywidgets as ipw +"""Module to manage AiiDA codes.""" from subprocess import check_output + +import ipywidgets as ipw from IPython.display import clear_output from aiida.orm import Code -VALID_AIIDA_CODE_SETUP_ARGUMETNS = {'label', 'selected_computer', 'plugin', 'description', - 'exec_path', 'prepend_text', 'append_text'} +from aiidalab_widgets_base.utils import predefine_settings, valid_arguments + +VALID_AIIDA_CODE_SETUP_ARGUMETNS = { + 'label', 'selected_computer', 'plugin', 'description', 'exec_path', 'prepend_text', 'append_text' +} -def valid_arguments(arguments, valid_arguments): - result = {} - for key, value in arguments.items(): - if key in valid_arguments: - if type(value) is tuple or type(value) is list: - result[key] = '\n'.join(value) - else: - result[key] = value - return result -def extract_aiidacodesetup_arguments(arguments): +def valid_aiidacode_args(arguments): return valid_arguments(arguments, VALID_AIIDA_CODE_SETUP_ARGUMETNS) class CodeDropdown(ipw.VBox): - def __init__(self, input_plugin, text='Select code:', **kwargs): + """Code selection widget.""" + + def __init__(self, input_plugin, text='Select code:', path_to_root='../', **kwargs): """ Dropdown for Codes for one input plugin. :param input_plugin: Input plugin of codes to show @@ -41,39 +36,40 @@ def __init__(self, input_plugin, text='Select code:', **kwargs): self.dropdown = ipw.Dropdown(description=text, disabled=True) self._btn_refresh = ipw.Button(description="Refresh", layout=ipw.Layout(width="70px")) self._btn_refresh.on_click(self.refresh) - # TODO: use base_url here - self._setup_another = ipw.HTML(value="""Setup new code""") + # FOR LATER: use base_url here, when it will be implemented in the appmode. + self._setup_another = ipw.HTML(value="""Setup new code""".format( + path_to_root=path_to_root, label=input_plugin, plugin=input_plugin)) self.output = ipw.Output() - children = [ipw.HBox([self.dropdown, self._btn_refresh, self._setup_another]), - self.output] + children = [ipw.HBox([self.dropdown, self._btn_refresh, self._setup_another]), self.output] super(CodeDropdown, self).__init__(children=children, **kwargs) self.refresh() - def _get_codes(self, input_plugin): + @staticmethod + def _get_codes(input_plugin): + """Query the list of available codes.""" from aiida.orm.querybuilder import QueryBuilder - from aiida.backends.utils import get_automatic_user + from aiida.orm import User from aiida.orm import Computer - current_user = get_automatic_user() - - qb = QueryBuilder() - qb.append( - Computer, filters={'enabled': True}, project=['*'], tag='computer') - qb.append( - Code, - filters={ - 'attributes.input_plugin': { - '==': input_plugin - }, - 'extras.hidden': { - "~==": True - } - }, - project=['*'], - has_computer='computer') - results = qb.all() + current_user = User.objects.get_default() + + querybuild = QueryBuilder() + querybuild.append(Computer, project=['*'], tag='computer') + querybuild.append(Code, + filters={ + 'attributes.input_plugin': { + '==': input_plugin + }, + 'extras.hidden': { + "~==": True + } + }, + project=['*'], + with_computer='computer') + results = querybuild.all() # only codes on computers configured for the current user results = [r for r in results if r[0].is_user_configured(current_user)] @@ -81,7 +77,8 @@ def _get_codes(self, input_plugin): codes = {"{}@{}".format(r[1].label, r[0].name): r[1] for r in results} return codes - def refresh(self, b=None): + def refresh(self, change=None): # pylint: disable=unused-argument + """Refresh available codes.""" with self.output: clear_output() self.codes = self._get_codes(self.input_plugin) @@ -90,32 +87,32 @@ def refresh(self, b=None): self.dropdown.options = options if not options: - print("No codes found for input plugin '{}'.".format( - self.input_plugin)) + print("No codes found for input plugin '{}'.".format(self.input_plugin)) self.dropdown.disabled = True else: self.dropdown.disabled = False @property def selected_code(self): + """Returns a selected code.""" try: return self.codes[self.dropdown.value] except KeyError: return None + class AiiDACodeSetup(ipw.VBox): """Class that allows to setup AiiDA code""" + def __init__(self, **kwargs): - from aiida.common.pluginloader import all_plugins + from aiida.plugins.entry_point import get_entry_point_names from aiidalab_widgets_base.computers import ComputerDropdown - style = {"description_width":"200px"} + style = {"description_width": "200px"} # list of widgets to be displayed - self._inp_code_label = ipw.Text(description="AiiDA code label:", - layout=ipw.Layout(width="500px"), - style=style) + self._inp_code_label = ipw.Text(description="AiiDA code label:", layout=ipw.Layout(width="500px"), style=style) self._computer = ComputerDropdown(layout={'margin': '0px 0px 0px 125px'}) @@ -124,7 +121,7 @@ def __init__(self, **kwargs): layout=ipw.Layout(width="500px"), style=style) - self._inp_code_plugin = ipw.Dropdown(options=sorted(all_plugins('calculations')), + self._inp_code_plugin = ipw.Dropdown(options=sorted(get_entry_point_names('aiida.calculations')), description="Code plugin:", layout=ipw.Layout(width="500px"), style=style) @@ -145,27 +142,23 @@ def __init__(self, **kwargs): self._btn_setup_code = ipw.Button(description="Setup code") self._btn_setup_code.on_click(self._setup_code) self._setup_code_out = ipw.Output() - children = [ipw.HBox([ipw.VBox([self._inp_code_label, - self._computer, - self._inp_code_plugin, - self._inp_code_description, - self._exec_path]), ipw.VBox([self._prepend_text, - self._append_text])]), - self._btn_setup_code, - self._setup_code_out, - ] + children = [ + ipw.HBox([ + ipw.VBox([ + self._inp_code_label, self._computer, self._inp_code_plugin, self._inp_code_description, + self._exec_path + ]), + ipw.VBox([self._prepend_text, self._append_text]) + ]), + self._btn_setup_code, + self._setup_code_out, + ] # Check if some settings were already provided - self._predefine_settings(**kwargs) + predefine_settings(self, **kwargs) super(AiiDACodeSetup, self).__init__(children, **kwargs) - def _predefine_settings(self, **kwargs): - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - else: - raise AttributeError("'{}' object has no attribute '{}'".format(self, key)) - - def _setup_code(self, b=None): + def _setup_code(self, change=None): # pylint: disable=unused-argument + """Setup an AiiDA code.""" with self._setup_code_out: clear_output() if self.label is None: @@ -175,7 +168,7 @@ def _setup_code(self, b=None): print("You did not specify absolute path to the executable") return if self.exists(): - print ("Code {}@{} already exists".format(self.label, self.selected_computer.name)) + print("Code {}@{} already exists".format(self.label, self.selected_computer.name)) return code = Code(remote_computer_exec=(self.selected_computer, self.exec_path)) code.label = self.label @@ -184,12 +177,13 @@ def _setup_code(self, b=None): code.set_prepend_text(self.prepend_text) code.set_append_text(self.append_text) code.store() - code._reveal() + code.reveal() full_string = "{}@{}".format(self.label, self.selected_computer.name) - print(check_output(['verdi', 'code', 'show', full_string])) + print(check_output(['verdi', 'code', 'show', full_string]).decode('utf-8')) def exists(self): - from aiida.common.exceptions import NotExistent, MultipleObjectsError + """Returns True if the code exists, returns False otherwise.""" + from aiida.common import NotExistent, MultipleObjectsError try: Code.get_from_string("{}@{}".format(self.label, self.selected_computer.name)) return True @@ -200,10 +194,9 @@ def exists(self): @property def label(self): - if len(self._inp_code_label.value.strip()) == 0: + if not self._inp_code_label.value.strip(): return None - else: - return self._inp_code_label.value + return self._inp_code_label.value @label.setter def label(self, label): diff --git a/aiidalab_widgets_base/computers.py b/aiidalab_widgets_base/computers.py index 96cb55eb8..fecfab822 100644 --- a/aiidalab_widgets_base/computers.py +++ b/aiidalab_widgets_base/computers.py @@ -1,250 +1,256 @@ -from __future__ import print_function - -import pexpect -import ipywidgets as ipw +"""All functionality needed to setup a computer.""" from os import path from copy import copy -from IPython.display import clear_output from subprocess import check_output, call + +import pexpect +import ipywidgets as ipw +from IPython.display import clear_output from traitlets import Int from aiida.orm import Computer -from aiida.backends.utils import get_automatic_user, get_backend_type -from aiida.common.exceptions import NotExistent -from aiida.transport.plugins.ssh import parse_sshconfig +from aiida.orm import User +from aiida.common import NotExistent +from aiida.transports.plugins.ssh import parse_sshconfig -if get_backend_type() == 'sqlalchemy': - from aiida.backends.sqlalchemy.models.authinfo import DbAuthInfo -else: - from aiida.backends.djsite.db.models import DbAuthInfo +from aiidalab_widgets_base.utils import predefine_settings, valid_arguments +STYLE = {"description_width": "200px"} VALID_SSH_COMPUTER_SETUP_ARGUMETNS = {'hostname', 'username', 'proxy_hostname', 'proxy_username'} -VALID_AIIDA_COMPUTER_SETUP_ARGUMETNS = {'name', 'hostname', 'description', 'workdir', 'mpirun_cmd', - 'ncpus', 'transport_type', 'scheduler', 'prepend_text', 'append_text'} - -def valid_arguments(arguments, valid_arguments): - result = {} - for key, value in arguments.items(): - if key in valid_arguments: - if type(value) is tuple or type(value) is list: - result[key] = '\n'.join(value) - else: - result[key] = value - return result +VALID_AIIDA_COMPUTER_SETUP_ARGUMETNS = { + 'name', 'hostname', 'description', 'workdir', 'mpirun_cmd', 'ncpus', 'transport_type', 'scheduler', 'prepend_text', + 'append_text' +} + -def extract_sshcomputersetup_arguments(arguments): +def valid_sshcomputer_args(arguments): return valid_arguments(arguments, VALID_SSH_COMPUTER_SETUP_ARGUMETNS) -def extract_aiidacomputer_arguments(arguments): + +def valid_aiidacomputer_args(arguments): return valid_arguments(arguments, VALID_AIIDA_COMPUTER_SETUP_ARGUMETNS) -class SshComputerSetup(ipw.VBox): - setup_counter = Int(0) # Traitlet to inform other widgets about changes + +class SshComputerSetup(ipw.VBox): # pylint: disable=too-many-instance-attributes + """Setup password-free access to a computer.""" + setup_counter = Int(0) # Traitlet to inform other widgets about changes + def __init__(self, **kwargs): - style = {"description_width":"200px"} computer_image = ipw.HTML('') - self._inp_username = ipw.Text(description="SSH username:", - layout=ipw.Layout(width="350px"), - style=style) - self._inp_password = ipw.Password(description="SSH password:", - layout=ipw.Layout(width="130px"), - style=style) + # Computer ssh settings + self._inp_username = ipw.Text(description="SSH username:", layout=ipw.Layout(width="350px"), style=STYLE) + self._inp_password = ipw.Password(description="SSH password:", layout=ipw.Layout(width="130px"), style=STYLE) self._inp_computer_hostname = ipw.Text(description="Computer name:", - layout=ipw.Layout(width="350px"), - style=style) - self._use_proxy = ipw.Checkbox(value=False, description='Use proxy') - self._use_proxy.observe(self.on_use_proxy_change, names='value') - + layout=ipw.Layout(width="350px"), + style=STYLE) # Proxy ssh settings + self._use_proxy = ipw.Checkbox(value=False, description='Use proxy') + self._use_proxy.observe(self.on_use_proxy_change, names='value') self._inp_proxy_address = ipw.Text(description="Proxy server address:", layout=ipw.Layout(width="350px"), - style=style) + style=STYLE) self._use_diff_proxy_username = ipw.Checkbox(value=False, - description='Use different username and password', - layout={'width': 'initial'}) + description='Use different username and password', + layout={'width': 'initial'}) self._use_diff_proxy_username.observe(self.on_use_diff_proxy_username_change, names='value') - self._inp_proxy_username = ipw.Text(value='', description="Proxy server username:", - layout=ipw.Layout(width="350px"), style=style) + layout=ipw.Layout(width="350px"), + style=STYLE) self._inp_proxy_password = ipw.Password(value='', description="Proxy server password:", layout=ipw.Layout(width="138px"), - style=style) + style=STYLE) + + # Setup ssh button and output self._btn_setup_ssh = ipw.Button(description="Setup ssh") self._btn_setup_ssh.on_click(self.on_setup_ssh) self._setup_ssh_out = ipw.Output() - # Check if some settings were already provided - self._predefine_settings(**kwargs) + # Check whether some settings were already provided + predefine_settings(self, **kwargs) # Defining widgets positions - computer_ssh_box = ipw.VBox([self._inp_computer_hostname, - self._inp_username, - self._inp_password, - self._use_proxy], - layout=ipw.Layout(width="400px")) - - self._proxy_user_password_box = ipw.VBox([self._inp_proxy_username, - self._inp_proxy_password], - layout={'visibility':'hidden'}) - - self._proxy_ssh_box = ipw.VBox([self._inp_proxy_address, - self._use_diff_proxy_username, - self._proxy_user_password_box], - layout = {'visibility':'hidden','width':'400px'}) - - children = [ipw.HBox([computer_image, computer_ssh_box, self._proxy_ssh_box]), - self._btn_setup_ssh, - self._setup_ssh_out] - + computer_ssh_box = ipw.VBox( + [self._inp_computer_hostname, self._inp_username, self._inp_password, self._use_proxy], + layout=ipw.Layout(width="400px")) + self._proxy_user_password_box = ipw.VBox([self._inp_proxy_username, self._inp_proxy_password], + layout={'visibility': 'hidden'}) + self._proxy_ssh_box = ipw.VBox( + [self._inp_proxy_address, self._use_diff_proxy_username, self._proxy_user_password_box], + layout={ + 'visibility': 'hidden', + 'width': '400px' + }) + + children = [ + ipw.HBox([computer_image, computer_ssh_box, self._proxy_ssh_box]), self._btn_setup_ssh, self._setup_ssh_out + ] super(SshComputerSetup, self).__init__(children, **kwargs) - def _predefine_settings(self, **kwargs): - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - else: - raise AttributeError("'{}' object has no attirubte '{}'".format(self, key)) - - def _ssh_keygen(self): - fn = path.expanduser("~/.ssh/id_rsa") - if not path.exists(fn): + @staticmethod + def _ssh_keygen(): + """Generate ssh key pair.""" + fname = path.expanduser("~/.ssh/id_rsa") + if not path.exists(fname): print("Creating ssh key pair") # returns non-0 if the key pair already exists - call(["ssh-keygen", "-f", fn, "-t", "rsa", "-N", ""]) + call(["ssh-keygen", "-f", fname, "-t", "rsa", "-b", "4096", "-m", "PEM", "-N", ""]) def is_host_known(self, hostname=None): + """Check if the host is known already.""" if hostname is None: hostname = self.hostname - fn = path.expanduser("~/.ssh/known_hosts") - if not path.exists(fn): + fname = path.expanduser("~/.ssh/known_hosts") + if not path.exists(fname): return False return call(["ssh-keygen", "-F", hostname]) == 0 - def _make_host_known(self, hostname, proxycmd=[]): - fn = path.expanduser("~/.ssh/known_hosts") - print("Adding keys from %s to %s"%(hostname, fn)) - hashes = check_output(proxycmd+["ssh-keyscan", "-H", hostname]) - with open(fn, "a") as f: - f.write(hashes) + @staticmethod + def _make_host_known(hostname, proxycmd=None): + """Add host information into known_hosts file.""" + proxycmd = [] if proxycmd is None else proxycmd + fname = path.expanduser("~/.ssh/known_hosts") + print("Adding keys from %s to %s" % (hostname, fname)) + hashes = check_output(proxycmd + ["ssh-keyscan", "-H", hostname]) + with open(fname, "a") as fobj: + fobj.write(hashes.decode("utf-8")) def can_login(self, silent=False): - if self.username is None: # if I can't find the username - I must fail + """Check if it is possible to login into the remote host.""" + if self.username is None: # if I can't find the username - I must fail return False - userhost = self.username+"@"+self.hostname + userhost = self.username + "@" + self.hostname if not silent: - print("Trying ssh "+userhost+"... ", end='') + print("Trying ssh " + userhost + "... ", end='') # With BatchMode on, no password prompt or other interaction is attempted, # so a connect that requires a password will fail. ret = call(["ssh", userhost, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "true"]) if not silent: - print("Ok" if ret==0 else "Failed") - return ret==0 + print("Ok" if ret == 0 else "Failed") + return ret == 0 def is_in_config(self): - fn = path.expanduser("~/.ssh/config") - if not path.exists(fn): + """Check if the config file contains host information.""" + fname = path.expanduser("~/.ssh/config") + if not path.exists(fname): return False - cfglines = open(fn).read().split("\n") - return "Host "+self.hostname in cfglines + cfglines = open(fname).read().split("\n") + return "Host " + self.hostname in cfglines def _write_ssh_config(self, proxycmd=''): - fn = path.expanduser("~/.ssh/config") - print("Adding section to "+fn) - with open(fn, "a") as f: - f.write("Host "+self.hostname+"\n") - f.write("User "+self.username+"\n") + """Put host information into the config file.""" + fname = path.expanduser("~/.ssh/config") + print("Adding section to " + fname) + with open(fname, "a") as file: + file.write("Host " + self.hostname + "\n") + file.write("User " + self.username + "\n") if proxycmd: - f.write("ProxyCommand ssh -q -Y "+proxycmd+" netcat %h %p\n") - f.write("ServerAliveInterval 5\n") + file.write("ProxyCommand ssh -q -Y " + proxycmd + " netcat %h %p\n") + file.write("ServerAliveInterval 5\n") - def _send_pubkey(self, hostname, username, password, proxycmd=''): + @staticmethod + def _send_pubkey(hostname, username, password, proxycmd=''): + """Send a publick key to a remote host.""" from pexpect import TIMEOUT timeout = 10 - print("Sending public key to {}... ".format(hostname),end='') + print("Sending public key to {}... ".format(hostname), end='') str_ssh = 'ssh-copy-id {}@{}'.format(username, hostname) if proxycmd: - str_ssh += ' -o "ProxyCommand ssh -q -Y '+proxycmd+' netcat %h %p\n"' + str_ssh += ' -o "ProxyCommand ssh -q -Y ' + proxycmd + ' netcat %h %p\n"' child = pexpect.spawn(str_ssh) try: - index = child.expect(['s password:', # 0 - 'ERROR: No identities found', # 1 - 'All keys were skipped because they already exist on the remote system', # 2 - 'Could not resolve hostname', # 3 - pexpect.EOF],timeout=timeout) # final + index = child.expect( + [ + "s password:", # 0 + "All keys were skipped because they already exist on the remote system", # 1 + "ERROR: No identities found", # 2 + "Could not resolve hostname", # 3 + pexpect.EOF + ], + timeout=timeout) # final except TIMEOUT: - print ("Exceeded {} s timeout".format(timeout)) + print("Exceeded {} s timeout".format(timeout)) return False + possible_output = { + 1: { + 'message': "Keys are already present on the remote machine", + 'status': True + }, + 2: { + 'message': "Failed\nLooks like the key pair is not present in ~/.ssh folder", + 'status': False + }, + 3: { + 'message': "Failed\nUnknown hostname", + 'status': False + }, + } if index == 0: child.sendline(password) try: - child.expect("Now try logging into",timeout=5) - except: - print("Failed") - print("Please check your username and/or password") + child.expect("Now try logging into", timeout=5) + except TIMEOUT: + print("Failed\nPlease check your username and/or password") return False print("Ok") child.close() return True - elif index == 1: - print("Failed") - print("Looks like the key pair is not present in ~/.ssh folder") - return False - elif index == 2: - print("Keys are already there") - return True - elif index == 3: - print("Failed") - print("Unknown hostname") - return False - else: - print ("Failed") - print ("Unknown problem") - print (child.before, child.after) - child.close() - return False + + if index in possible_output: + print(possible_output[index]['message']) + return possible_output[index]['status'] + + print("Failed\nUnknown problem") + print(child.before, child.after) + child.close() + return False def _configure_proxy(self, password, proxy_password): + """Configure proxy server.""" # if proxy IS required if self._use_proxy.value: # again some standard checks if proxy server parameters are provided - if self.proxy_hostname is None: # hostname + if self.proxy_hostname is None: # hostname print("Please specify the proxy server hostname") return False, '' # if proxy username and password must be different from the main computer - they should be provided if self._use_diff_proxy_username.value: + # check username - if not self.proxy_username is None: + if self.proxy_username is not None: proxy_username = self.proxy_username else: print("Please specify the proxy server username") return False, '' + # check password - if len(proxy_password.strip()) == 0: + if not proxy_password.strip(): print("Please specify the proxy server password") return False, '' - else: # if username and password are the same as for the main computer + + else: # if username and password are the same as for the main computer proxy_username = self.username proxy_password = password # make proxy server known if not self.is_host_known(self.proxy_hostname): self._make_host_known(self.proxy_hostname) + # Finally trying to connect if self._send_pubkey(self.proxy_hostname, proxy_username, proxy_password): return True, proxy_username + '@' + self.proxy_hostname - else: - print ("Could not send public key to {} (proxy server).".format(self.proxy_hostname)) - return False, '' + + print("Could not send public key to {} (proxy server).".format(self.proxy_hostname)) + return False, '' + # if proxy is NOT required - else: - return True, '' + return True, '' - def on_setup_ssh(self, b): + def on_setup_ssh(self, change): # pylint: disable=unused-argument,too-many-return-statements """ATTENTION: modifying the order of operations in this function can lead to unexpected problems""" with self._setup_ssh_out: clear_output() @@ -255,47 +261,48 @@ def on_setup_ssh(self, b): proxy_password = self.__proxy_password # step 1: if hostname is not provided - do not do anything - if self.hostname is None: # check hostname + if self.hostname is None: # check hostname print("Please specify the computer hostname") return # step 2: check if password-free access was enabled earlier if self.can_login(): - print ("Password-free access is already enabled") + print("Password-free access is already enabled") # it can still happen that password-free access is enabled # but host is not present in the config file - fixing this if not self.is_in_config(): - self._write_ssh_config() # we do not use proxy here, because if computer + self._write_ssh_config() # we do not use proxy here, because if computer # can be accessed without any info in the config - proxy is not needed. - self.setup_counter += 1 # only if config file has changed - increase setup_counter + self.setup_counter += 1 # only if config file has changed - increase setup_counter return - # step 3: if can't login already, chek whether all required information is provided - if self.username is None: # check username + # step 3: if couldn't login in the previous step, chek whether all required information is provided + if self.username is None: # check username print("Please enter your ssh username") return - if len(password.strip()) == 0: # check password + if not password.strip(): # check password print("Please enter your ssh password") return # step 4: get the right commands to access the proxy server (if provided) success, proxycmd = self._configure_proxy(password, proxy_password) if not success: - return - + return + # step 5: make host known by ssh on the proxy server if not self.is_host_known(): - self._make_host_known(self.hostname,['ssh']+[proxycmd] if proxycmd else []) + self._make_host_known(self.hostname, ['ssh'] + [proxycmd] if proxycmd else []) # step 6: sending public key to the main host if not self._send_pubkey(self.hostname, self.username, password, proxycmd): - print ("Could not send public key to {}".format(self.hostname)) + print("Could not send public key to {}".format(self.hostname)) return # step 7: modify the ssh config file if necessary if not self.is_in_config(): self._write_ssh_config(proxycmd=proxycmd) - # TODO: add a check if new config is different from the current one. If so + + # FOR LATER: add a check if new config is different from the current one. If so # infrom the user about it. # step 8: final check @@ -303,25 +310,27 @@ def on_setup_ssh(self, b): self.setup_counter += 1 print("Automatic ssh setup successful :-)") return - else: - print("Automatic ssh setup failed, sorry :-(") - return + print("Automatic ssh setup failed, sorry :-(") + return - def on_use_proxy_change(self, b): + def on_use_proxy_change(self, change): # pylint: disable=unused-argument + """If proxy check-box is clicked.""" if self._use_proxy.value: self._proxy_ssh_box.layout.visibility = 'visible' else: self._proxy_ssh_box.layout.visibility = 'hidden' self._use_diff_proxy_username.value = False - def on_use_diff_proxy_username_change(self, b): + def on_use_diff_proxy_username_change(self, change): # pylint: disable=unused-argument + """If using different username for proxy check-box is clicked.""" if self._use_diff_proxy_username.value: self._proxy_user_password_box.layout.visibility = 'visible' else: self._proxy_user_password_box.layout.visibility = 'hidden' + # Keep this function only because it might be used later. -# What it does: looks inside .ssh/config file and loads computer setup from +# What it does: looks inside .ssh/config file and loads computer setup from # there (if present) # def _get_from_config(self, b): # config = parse_sshconfig(self.hostname) @@ -356,10 +365,9 @@ def __proxy_password(self): @property def hostname(self): - if len(self._inp_computer_hostname.value.strip()) == 0: # check hostname + if not self._inp_computer_hostname.value.strip(): # check hostname return None - else: - return self._inp_computer_hostname.value + return self._inp_computer_hostname.value @hostname.setter def hostname(self, hostname): @@ -368,15 +376,14 @@ def hostname(self, hostname): @property def username(self): """Loking for username in user's input and config file""" - if len(self._inp_username.value.strip()) == 0: # if username provided by user - if not self.hostname is None: + if not self._inp_username.value.strip(): # if username provided by user + if self.hostname is not None: config = parse_sshconfig(self.hostname) - if 'user' in config: # if username is present in the config file + if 'user' in config: # if username is present in the config file return config['user'] else: return None - else: - return self._inp_username.value + return self._inp_username.value @username.setter def username(self, username): @@ -384,10 +391,9 @@ def username(self, username): @property def proxy_hostname(self): - if len(self._inp_proxy_address.value.strip()) == 0: + if not self._inp_proxy_address.value.strip(): return None - else: - return self._inp_proxy_address.value + return self._inp_proxy_address.value @proxy_hostname.setter def proxy_hostname(self, proxy_hostname): @@ -396,10 +402,9 @@ def proxy_hostname(self, proxy_hostname): @property def proxy_username(self): - if len(self._inp_proxy_username.value.strip()) == 0: + if not self._inp_proxy_username.value.strip(): return None - else: - return self._inp_proxy_username.value + return self._inp_proxy_username.value @proxy_username.setter def proxy_username(self, proxy_username): @@ -407,60 +412,61 @@ def proxy_username(self, proxy_username): self._use_diff_proxy_username.value = True self._inp_proxy_username.value = proxy_username -class AiidaComputerSetup(ipw.VBox): + +class AiidaComputerSetup(ipw.VBox): # pylint: disable=too-many-instance-attributes + """Inform AiiDA about a computer.""" + def __init__(self, **kwargs): - from aiida.transport import Transport - from aiida.scheduler import Scheduler - style = {"description_width":"200px"} + from aiida.transports import Transport + from aiida.schedulers import Scheduler # list of widgets to be displayed - self._btn_setup_comp = ipw.Button(description="Setup computer") - self._btn_setup_comp.on_click(self._on_setup_computer) self._inp_computer_name = ipw.Text(value='', placeholder='Will only be used within AiiDA', description="AiiDA computer name:", layout=ipw.Layout(width="500px"), - style=style) + style=STYLE) self._computer_hostname = ipw.Dropdown(description="Select among configured hosts:", layout=ipw.Layout(width="500px"), - style=style) + style=STYLE) self._inp_computer_description = ipw.Text(value='', placeholder='No description (yet)', description="Computer description:", layout=ipw.Layout(width="500px"), - style=style) + style=STYLE) self._computer_workdir = ipw.Text(value='/scratch/{username}/aiida_run', description="Workdir:", layout=ipw.Layout(width="500px"), - style=style) + style=STYLE) self._computer_mpirun_cmd = ipw.Text(value='mpirun -n {tot_num_mpiprocs}', description="Mpirun command:", layout=ipw.Layout(width="500px"), - style=style) + style=STYLE) self._computer_ncpus = ipw.IntText(value=12, step=1, description='Number of CPU(s) per node:', layout=ipw.Layout(width="270px"), - style=style) + style=STYLE) self._transport_type = ipw.Dropdown(value='ssh', options=Transport.get_valid_transports(), description="Transport type:", - style=style) + style=STYLE) self._scheduler = ipw.Dropdown(value='slurm', options=Scheduler.get_valid_schedulers(), description="Scheduler:", - style=style) + style=STYLE) self._prepend_text = ipw.Textarea(placeholder='Text to prepend to each command execution', description='Prepend text:', - layout=ipw.Layout(width="400px") - ) + layout=ipw.Layout(width="400px")) self._append_text = ipw.Textarea(placeholder='Text to append to each command execution', description='Append text:', - layout=ipw.Layout(width="400px") - ) + layout=ipw.Layout(width="400px")) + + # Buttons and outputs + self._btn_setup_comp = ipw.Button(description="Setup computer") + self._btn_setup_comp.on_click(self._on_setup_computer) self._btn_test = ipw.Button(description="Test computer") self._btn_test.on_click(self.test) - self._setup_comp_out = ipw.Output(layout=ipw.Layout(width="500px")) self._test_out = ipw.Output(layout=ipw.Layout(width="500px")) @@ -468,38 +474,34 @@ def __init__(self, **kwargs): self.get_available_computers() # Check if some settings were already provided - self._predefine_settings(**kwargs) - children =[ipw.HBox([ipw.VBox([self._inp_computer_name, - self._computer_hostname, - self._inp_computer_description, - self._computer_workdir, - self._computer_mpirun_cmd, - self._computer_ncpus, - self._transport_type, - self._scheduler]), - ipw.VBox([self._prepend_text, - self._append_text])]), - ipw.HBox([self._btn_setup_comp, self._btn_test]), - ipw.HBox([self._setup_comp_out, self._test_out]), - ] + predefine_settings(self, **kwargs) + + # Organize the widgets + children = [ + ipw.HBox([ + ipw.VBox([ + self._inp_computer_name, self._computer_hostname, self._inp_computer_description, + self._computer_workdir, self._computer_mpirun_cmd, self._computer_ncpus, self._transport_type, + self._scheduler + ]), + ipw.VBox([self._prepend_text, self._append_text]) + ]), + ipw.HBox([self._btn_setup_comp, self._btn_test]), + ipw.HBox([self._setup_comp_out, self._test_out]), + ] super(AiidaComputerSetup, self).__init__(children, **kwargs) - def _predefine_settings(self, **kwargs): - for key, value in kwargs.items(): - if hasattr(self, key): - setattr(self, key, value) - else: - raise AttributeError("'{}' object has no attribute '{}'".format(self, key)) - - def get_available_computers(self, b=None): - fn = path.expanduser("~/.ssh/config") - if not path.exists(fn): + def get_available_computers(self, change=None): # pylint: disable=unused-argument + """Refresh the list of available computers.""" + fname = path.expanduser("~/.ssh/config") + if not path.exists(fname): return [] - cfglines = open(fn).readlines() + cfglines = open(fname).readlines() self._computer_hostname.options = [line.split()[1] for line in cfglines if 'Host' in line] + return True def _configure_computer(self): - """create DbAuthInfo""" + """create AuthInfo""" print("Configuring '{}'".format(self.name)) sshcfg = parse_sshconfig(self.hostname) authparams = { @@ -516,35 +518,34 @@ def _configure_computer(self): if 'user' in sshcfg: authparams['username'] = sshcfg['user'] else: - print ("SSH username is not provided, please run `verdi computer configure {}` " - "from the command line".format(self.name)) + print("SSH username is not provided, please run `verdi computer configure {}` " + "from the command line".format(self.name)) return if 'proxycommand' in sshcfg: authparams['proxy_command'] = sshcfg['proxycommand'] - aiidauser = get_automatic_user() - authinfo = DbAuthInfo(dbcomputer=Computer.get(self.name).dbcomputer, aiidauser=aiidauser) + aiidauser = User.objects.get_default() + from aiida.orm import AuthInfo + authinfo = AuthInfo(computer=Computer.objects.get(name=self.name), user=aiidauser) authinfo.set_auth_params(authparams) - authinfo.save() - print(check_output(['verdi', 'computer', 'show', self.name])) + authinfo.store() + print(check_output(['verdi', 'computer', 'show', self.name]).decode('utf-8')) - def _on_setup_computer(self, b): + def _on_setup_computer(self, change): # pylint: disable=unused-argument + """When setup computer button is pressed.""" with self._setup_comp_out: clear_output() - if self.name is None: # check hostname + if self.name is None: # check hostname print("Please specify the computer name (for AiiDA)") return try: - computer = Computer.get(self.name) + computer = Computer.objects.get(name=self.name) print("A computer called {} already exists.".format(self.name)) return except NotExistent: pass print("Creating new computer with name '{}'".format(self.name)) - computer = Computer(name=self.name) - computer.set_hostname(self.hostname) - computer.set_description(self.description) - computer.set_enabled_state(True) + computer = Computer(name=self.name, hostname=self.hostname, description=self.description) computer.set_transport_type(self.transport_type) computer.set_scheduler_type(self.scheduler) computer.set_workdir(self.workdir) @@ -557,17 +558,16 @@ def _on_setup_computer(self, b): computer.store() self._configure_computer() - def test(self, b=None): + def test(self, change): # pylint: disable=unused-argument with self._test_out: clear_output() - print(check_output(['verdi', 'computer', 'test', '--traceback', self.name])) + print(check_output(['verdi', 'computer', 'test', '--print-traceback', self.name]).decode('utf-8')) @property def name(self): - if len(self._inp_computer_name.value.strip()) == 0: # check hostname + if not self._inp_computer_name.value.strip(): # check hostname return None - else: - return self._inp_computer_name.value + return self._inp_computer_name.value @name.setter def name(self, name): @@ -575,10 +575,9 @@ def name(self, name): @property def hostname(self): - if self._computer_hostname.value is None or len(self._computer_hostname.value.strip()) == 0: # check hostname + if self._computer_hostname.value is None or not self._computer_hostname.value.strip(): # check hostname return None - else: - return self._computer_hostname.value + return self._computer_hostname.value @hostname.setter def hostname(self, hostname): @@ -625,6 +624,7 @@ def transport_type(self): def transport_type(self, transport_type): if transport_type in self._transport_type.options: self._transport_type.value = transport_type + @property def scheduler(self): return self._scheduler.value @@ -650,46 +650,52 @@ def append_text(self): def append_text(self, append_text): self._append_text.value = append_text + class ComputerDropdown(ipw.VBox): - def __init__(self, text='Select computer:', **kwargs): + """Widget to select a configured computer.""" + + def __init__(self, text='Select computer:', **kwargs): """ Dropdown for Codes for one input plugin. :param text: Text to display before dropdown :type text: str """ - self._dropdown = ipw.Dropdown(options=[], description=text, style={'description_width': 'initial'}, disabled=True) + self._dropdown = ipw.Dropdown(options=[], + description=text, + style={'description_width': 'initial'}, + disabled=True) self._btn_refresh = ipw.Button(description="Refresh", layout=ipw.Layout(width="70px")) - self._setup_another = ipw.HTML(value="""Setup new computer""", - layout = {'margin': '0px 0px 0px 250px'}) + self._setup_another = ipw.HTML( + value="""Setup new computer""", + layout={'margin': '0px 0px 0px 250px'}) self._btn_refresh.on_click(self._refresh) self.output = ipw.Output() - children = [ipw.HBox([self._dropdown, self._btn_refresh]), - self._setup_another, - self.output] + children = [ipw.HBox([self._dropdown, self._btn_refresh]), self._setup_another, self.output] super(ComputerDropdown, self).__init__(children=children, **kwargs) self._refresh() def _get_computers(self): + """Get the list of available computers.""" from aiida.orm.querybuilder import QueryBuilder - current_user = get_automatic_user() + current_user = User.objects.get_default() - qb = QueryBuilder() - qb.append( - Computer, filters={'enabled': True}, project=['*'], tag='computer') + query_b = QueryBuilder() + query_b.append(Computer, project=['*'], tag='computer') - results = qb.all() + results = query_b.all() # only computers configured for the current user results = [r for r in results if r[0].is_user_configured(current_user)] - self._dropdown.options = {r[0].name:r[0] for r in results} + self._dropdown.options = {r[0].name: r[0] for r in results} - def _refresh(self, b=None): + def _refresh(self, change=None): # pylint: disable=unused-argument + """Refresh the list of configured computers.""" with self.output: clear_output() self._get_computers() @@ -705,6 +711,7 @@ def computers(self): @property def selected_computer(self): + """Return selected computer.""" try: return self._dropdown.value except KeyError: @@ -714,4 +721,3 @@ def selected_computer(self): def selected_computer(self, selected_computer): if selected_computer in self.computers: self._dropdown.label = selected_computer - diff --git a/aiidalab_widgets_base/crystal_sim_crystal.py b/aiidalab_widgets_base/crystal_sim_crystal.py deleted file mode 100644 index c1bec38a5..000000000 --- a/aiidalab_widgets_base/crystal_sim_crystal.py +++ /dev/null @@ -1,56 +0,0 @@ -import itertools -import numpy as np -import spglib - -from ase.spacegroup import crystal -from ase.data import atomic_numbers, atomic_names -from ase.spacegroup import Spacegroup -from numpy.linalg import norm - - -def ase_to_spgcell(ase_atoms): - return (ase_atoms.get_cell(), - ase_atoms.get_scaled_positions(), - ase_atoms.get_atomic_numbers()) -def check_crystal_equivalence(crystal_a, crystal_b): - """Function that identifies whether two crystals are equivalent""" - - # getting symmetry datasets for both crystals - cryst_a = spglib.get_symmetry_dataset(ase_to_spgcell(crystal_a), symprec=1e-5, angle_tolerance=-1.0, hall_number=0) - cryst_b = spglib.get_symmetry_dataset(ase_to_spgcell(crystal_b), symprec=1e-5, angle_tolerance=-1.0, hall_number=0) - - samecell = np.allclose(cryst_a['std_lattice'], cryst_b['std_lattice'], atol=1e-5) - samenatoms = len(cryst_a['std_positions']) == len(cryst_b['std_positions']) - samespg = cryst_a['number'] == cryst_b['number'] - - def test_rotations_translations(cryst_a, cryst_b, repeat): - cell = cryst_a['std_lattice'] - pristine = crystal('Mg', [(0, 0., 0.)], - spacegroup=int(cryst_a['number']), - cellpar=[cell[0]/repeat[0], cell[1]/repeat[1], cell[2]/repeat[2]]).repeat(repeat) - - sym_set_p = spglib.get_symmetry_dataset(ase_to_spgcell(pristine), symprec=1e-5, - angle_tolerance=-1.0, hall_number=0) - - for _,trans in enumerate(zip(sym_set_p['rotations'], sym_set_p['translations'])): - pnew=(np.matmul(trans[0],cryst_a['std_positions'].T).T + trans[1]) % 1.0 - fulln = np.concatenate([cryst_a['std_types'][:, None], pnew], axis=1) - fullb = np.concatenate([cryst_b['std_types'][:, None], cryst_b['std_positions']], axis=1) - sorted_n = np.array(sorted([ list(row) for row in list(fulln) ])) - sorted_b = np.array(sorted([ list(row) for row in list(fullb) ])) - if np.allclose(sorted_n, sorted_b, atol=1e-5): - return True - return False - - if samecell and samenatoms and samespg: - cell = cryst_a['std_lattice'] - # we assume there are no crystals with a lattice parameter smaller than 2 A - rng1 = range(1, int(norm(cell[0])/2.)) - rng2 = range(1, int(norm(cell[1])/2.)) - rng3 = range(1, int(norm(cell[2])/2.)) - - for repeat in itertools.product(rng1, rng2, rng3): - if test_rotations_translations(cryst_a, cryst_b, repeat): - return True - - return False diff --git a/aiidalab_widgets_base/databases.py b/aiidalab_widgets_base/databases.py index b9c66c5b2..3398a0d6c 100644 --- a/aiidalab_widgets_base/databases.py +++ b/aiidalab_widgets_base/databases.py @@ -1,8 +1,9 @@ -from __future__ import print_function +"""Widgets that allow to query online databases.""" import ipywidgets as ipw from aiida.tools.dbimporters.plugins.cod import CodDbImporter + class CodQueryWidget(ipw.VBox): '''Query structures in Crystallography Open Database (COD) Useful class members: @@ -31,71 +32,83 @@ def __init__(self, **kwargs): For the queries by structure id, plese provide the database id number. Example: 1008786 """) layout = ipw.Layout(width="400px") - style = {"description_width":"initial"} - self.inp_elements = ipw.Text(description="", value="", placeholder='e.g.: Ni Ti or id number', layout=layout, style=style) + style = {"description_width": "initial"} + self.inp_elements = ipw.Text(description="", + value="", + placeholder='e.g.: Ni Ti or id number', + layout=layout, + style=style) self.btn_query = ipw.Button(description='Query') self.query_message = ipw.HTML("Waiting for input...") - self.drop_structure = ipw.Dropdown(description="", options=[("select structure",{"status":False})], - style=style, layout=layout ) + self.drop_structure = ipw.Dropdown(description="", + options=[("select structure", { + "status": False + })], + style=style, + layout=layout) self.link = ipw.HTML("Link to the web-page will appear here") self.structure_ase = None self.btn_query.on_click(self._on_click_query) self.drop_structure.observe(self._on_select_structure, names=['value']) - children = [description, - ipw.HBox([self.btn_query, self.inp_elements]), - self.query_message, - ipw.HBox([self.drop_structure, self.link])] + children = [ + description, + ipw.HBox([self.btn_query, self.inp_elements]), self.query_message, + ipw.HBox([self.drop_structure, self.link]) + ] super(CodQueryWidget, self).__init__(children=children, **kwargs) - def _query(self, idn=None,formula=None): + @staticmethod + def _query(idn=None, formula=None): + """Make the actual query.""" importer = CodDbImporter() if idn is not None: return importer.query(id=idn) - elif formula is not None: + if formula is not None: return importer.query(formula=formula) + return None - def _on_click_query(self, change): - structures = [("select structure", {"status":False})] + def _on_click_query(self, change): # pylint: disable=unused-argument + """Call query when the corresponding button is pressed.""" + structures = [("select structure", {"status": False})] idn = None formula = None self.query_message.value = "Quering the database ... " - try: + try: idn = int(self.inp_elements.value) - except: + except ValueError: formula = str(self.inp_elements.value) for entry in self._query(idn=idn, formula=formula): try: entry_cif = entry.get_cif_node() formula = entry_cif.get_ase().get_chemical_formula() - except: + except: # pylint: disable=bare-except continue - entry_add = ("{} (id: {})".format(formula, entry.source['id']), - { - "status": True, - "cif": entry_cif, - "url": entry.source['uri'], - "id": entry.source['id'], - } - ) + entry_add = ("{} (id: {})".format(formula, entry.source['id']), { + "status": True, + "cif": entry_cif, + "url": entry.source['uri'], + "id": entry.source['id'], + }) structures.append(entry_add) - self.query_message.value += "{} structures found".format(len(structures)-1) + self.query_message.value += "{} structures found".format(len(structures) - 1) self.drop_structure.options = structures def _on_select_structure(self, change): + """When a structure was selected.""" selected = change['new'] if selected['status'] is False: self.structure_ase = None return self.structure_ase = selected['cif'].get_ase() formula = self.structure_ase.get_chemical_formula() - struct_url = selected['url'].split('.cif')[0]+'.html' - self.link.value='COD entry {}'.format(struct_url, selected['id']) - if not self.on_structure_selection is None: + struct_url = selected['url'].split('.cif')[0] + '.html' + self.link.value = 'COD entry {}'.format(struct_url, selected['id']) + if self.on_structure_selection is not None: self.on_structure_selection(structure_ase=self.structure_ase, name=formula) - def on_structure_selection(self, structure_ase=None, name=None): + def on_structure_selection(self, structure_ase, name): pass diff --git a/aiidalab_widgets_base/display.py b/aiidalab_widgets_base/display.py deleted file mode 100644 index ce4f0489d..000000000 --- a/aiidalab_widgets_base/display.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import print_function - -import importlib -from IPython.display import display - -AIIDA_VISUALIZER_MAPPING = { - 'data.parameter.ParameterData.' : 'ParameterDataVisualizer', - 'data.structure.StructureData.' : 'StructureDataVisualizer', - 'data.cif.CifData.' : 'StructureDataVisualizer', - 'data.folder.FolderData.' : 'FolderDataVisualizer', - 'data.array.bands.BandsData.' : 'BandsDataVisualizer', -} - -def aiidalab_display(obj, downloadable=True, **kwargs): - """Display AiiDA data types in Jupyter notebooks. - - :param downloadable: If True, add link/button to download content of displayed AiiDA object. - - Defers to IPython.display.display for any objects it does not recognize. - """ - from aiidalab_widgets_base import aiida_visualizers - try: - visualizer = getattr(aiida_visualizers, AIIDA_VISUALIZER_MAPPING[obj.type]) - display(visualizer(obj, downloadable=downloadable), **kwargs) - except KeyError: - display(obj, **kwargs) \ No newline at end of file diff --git a/aiidalab_widgets_base/export.py b/aiidalab_widgets_base/export.py new file mode 100644 index 000000000..cee26b98f --- /dev/null +++ b/aiidalab_widgets_base/export.py @@ -0,0 +1,39 @@ +"""Widgets to manage AiiDA export.""" +import os +from ipywidgets import Button +from IPython.display import display + + +class ExportButtonWidget(Button): + """Export Node button.""" + + def __init__(self, process, **kwargs): + self.process = process + if 'description' not in kwargs: + kwargs['description'] = "Export workflow ({})".format(self.process.id) + if 'layout' not in kwargs: + kwargs['layout'] = {} + kwargs['layout']['width'] = 'initial' + super(ExportButtonWidget, self).__init__(**kwargs) + self.on_click(self.export_aiida_subgraph) + + def export_aiida_subgraph(self, change=None): # pylint: disable=unused-argument + """Perform export when the button is pressed""" + import base64 + import subprocess + from tempfile import mkdtemp + from IPython.display import Javascript + fname = os.path.join(mkdtemp(), 'export.aiida') + subprocess.call(['verdi', 'export', 'create', fname, '-N', str(self.process.id)]) + with open(fname, 'rb') as fobj: + b64 = base64.b64encode(fobj.read()) + payload = b64.decode() + javas = Javascript(""" + var link = document.createElement('a'); + link.href = "data:;base64,{payload}" + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename='export_{}.aiida'.format(self.process.id))) + display(javas) diff --git a/aiidalab_widgets_base/misc.py b/aiidalab_widgets_base/misc.py new file mode 100644 index 000000000..2b2b03445 --- /dev/null +++ b/aiidalab_widgets_base/misc.py @@ -0,0 +1,39 @@ +"""Some useful classes used acrross the repository.""" + +import ipywidgets as ipw +from traitlets import Unicode + + +class CopyToClipboardButton(ipw.Button): + """Button to copy text to clipboard.""" + + value = Unicode(allow_none=True) # Traitlet that contains a string to copy. + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + super().on_click(self.copy_to_clipboard) + + def copy_to_clipboard(self, change=None): # pylint:disable=unused-argument + """Copy text to clipboard.""" + from IPython.display import Javascript, display + javas = Javascript(""" + function copyStringToClipboard (str) {{ + // Create new element + var el = document.createElement('textarea'); + // Set value (string to be copied) + el.value = str; + // Set non-editable to avoid focus and move outside of view + el.setAttribute('readonly', ''); + el.style = {{position: 'absolute', left: '-9999px'}}; + document.body.appendChild(el); + // Select text inside element + el.select(); + // Copy text to clipboard + document.execCommand('copy'); + // Remove temporary element + document.body.removeChild(el); + }} + copyStringToClipboard("{selection}"); + """.format(selection=self.value)) # For the moment works for Chrome, but doesn't work for Firefox. + if self.value: # If no value provided - do nothing. + display(javas) diff --git a/aiidalab_widgets_base/process.py b/aiidalab_widgets_base/process.py new file mode 100644 index 000000000..1cf427464 --- /dev/null +++ b/aiidalab_widgets_base/process.py @@ -0,0 +1,190 @@ +"""Widgets to work with processes.""" + +import os +from ipywidgets import Textarea, VBox + + +def get_running_calcs(process): + """Takes a process and returns a list of running calculations. The running calculations + can be either the process itself or its running children.""" + + from aiida.orm import CalcJobNode, ProcessNode, WorkChainNode + + # If a process is a running calculation - returning it + if issubclass(type(process), CalcJobNode) and not process.is_sealed: + return [process] + + # If the process is a running work chain - returning its children + if issubclass(type(process), WorkChainNode) and not process.is_sealed: + calcs = [] + for link in process.get_outgoing(): + if issubclass(type(link.node), ProcessNode) and not link.node.is_sealed: + calcs += get_running_calcs(link.node) + return calcs + # if it is neither calculation, nor work chain - returninng None + return [] + + +class SubmitButtonWidget(VBox): + """Submit button class that creates submit button jupyter widget.""" + + def __init__(self, process, widgets_values): + """Submit Button + :process: work chain to run + :param_funtion: the function that generates input parameters dictionary + """ + from ipywidgets import Button, Output + + self.process = None + self._process_class = process + self.widgets_values = widgets_values + self.btn_submit = Button(description="Submit", disabled=False) + self.btn_submit.on_click(self.on_btn_submit_press) + self.submit_out = Output() + children = [ + self.btn_submit, + self.submit_out, + ] + super(SubmitButtonWidget, self).__init__(children=children) + + def on_click(self, function): + self.btn_submit.on_click(function) + + def on_btn_submit_press(self, _): + """When submit button is pressed.""" + from IPython.display import clear_output + from aiida.engine import submit + + with self.submit_out: + clear_output() + self.btn_submit.disabled = True + input_dict = self.widgets_values() + self.process = submit(self._process_class, **input_dict) + print("Submitted process {}".format(self.process)) + return + + +class ProcessFollowerWidget(VBox): + """A Widget that follows a process until finished.""" + + def __init__(self, process, followers=None, update_interval=0.1, **kwargs): + """Initiate all the followers.""" + from aiida.orm import ProcessNode + + if not isinstance(process, ProcessNode): + raise TypeError("Expecting an object of type {}, got {}".format(ProcessNode, type(process))) + self.process = process + self.update_interval = update_interval + self.followers = [] + if followers is not None: + for follower in followers: + self.followers.append(follower(process=process)) + self.update() + super(ProcessFollowerWidget, self).__init__(children=self.followers, **kwargs) + + def update(self): + for follower in self.followers: + follower.update() + + def _follow(self): + """The loop that will update all the followers untill the process is running.""" + from time import sleep + while not self.process.is_sealed: + self.update() + sleep(self.update_interval) + self.update() # update the state for the last time to be 100% sure + + def follow(self, detach=False): + """Follow the process in blocking or non-blocking manner.""" + if detach: + import threading + update_state = threading.Thread(target=self._follow) + update_state.start() + else: + self._follow() + + +class ProgressBarWidget(VBox): + """A bar showing the proggress of a process.""" + + def __init__(self, process, **kwargs): + from ipywidgets import HTML, IntProgress, Layout + + self.process = process + self.correspondance = { + "created": 0, + "running": 1, + "waiting": 1, + "killed": 2, + "excepted": 2, + "finished": 2, + } + self.bar = IntProgress( # pylint: disable=blacklisted-name + value=0, + min=0, + max=2, + step=1, + description='Progress:', + bar_style='warning', # 'success', 'info', 'warning', 'danger' or '' + orientation='horizontal', + layout=Layout(width="800px")) + self.state = HTML(description="Calculation state:", value='Created', style={'description_width': 'initial'}) + children = [self.bar, self.state] + super(ProgressBarWidget, self).__init__(children=children, **kwargs) + + def update(self): + """Update the bar.""" + self.bar.value = self.correspondance[self.current_state] + if self.current_state == 'finished': + self.bar.bar_style = 'success' + elif self.current_state in ["killed", "excepted"]: + self.bar.bar_style = 'danger' + else: + self.bar.bar_style = 'info' + self.state.value = self.current_state.capitalize() + + @property + def current_state(self): + return self.process.process_state.value + + +class RunningCalcJobOutputWidget(Textarea): + """Output of a currently running calculation or one of work chain's running child.""" + + def __init__(self, process, **kwargs): + self.main_process = process + default_params = { + "value": "", + "placeholder": "Calculation output will appear here", + "description": "Calculation output:", + "layout": { + 'width': "900px", + 'height': '300px' + }, + "disabled": False, + "style": { + 'description_width': 'initial' + } + } + default_params.update(kwargs) + self.previous_calc_id = 0 + self.output = [] + super(RunningCalcJobOutputWidget, self).__init__(**default_params) + + def update(self): + """Update the displayed output.""" + calcs = get_running_calcs(self.main_process) + if calcs: + for calc in calcs: + if calc.id == self.previous_calc_id: + break + else: + self.output = [] + self.previous_calc_id = calc.id + self.value = '' + if 'remote_folder' in calc.outputs: + f_path = os.path.join(calc.outputs.remote_folder.get_remote_path(), calc.attributes['output_filename']) + if os.path.exists(f_path): + with open(f_path) as fobj: + self.output += fobj.readlines()[len(self.output):-1] + self.value = ''.join(self.output) diff --git a/aiidalab_widgets_base/search_qb.py b/aiidalab_widgets_base/search_qb.py deleted file mode 100644 index 94c70b57b..000000000 --- a/aiidalab_widgets_base/search_qb.py +++ /dev/null @@ -1,8 +0,0 @@ -from aiida.orm.querybuilder import QueryBuilder -from aiida.orm.data.structure import StructureData - -def formula_in_qb(formula): - # search for existing structures - qb = QueryBuilder() - qb.append(StructureData, filters={'extras.formula':formula}) - return [[n[0].get_ase(),n[0].pk] for n in qb.iterall()] diff --git a/aiidalab_widgets_base/structures.py b/aiidalab_widgets_base/structures.py index fe80fa2df..b8f7b7b17 100644 --- a/aiidalab_widgets_base/structures.py +++ b/aiidalab_widgets_base/structures.py @@ -1,17 +1,17 @@ -from __future__ import print_function -from __future__ import absolute_import +"""Module to provide functionality to import structures.""" import os -import ase.io -import ipywidgets as ipw -from fileupload import FileUploadWidget import tempfile -import nglview -from six.moves import zip +import datetime +from collections import OrderedDict from traitlets import Bool +import ipywidgets as ipw +from aiida.orm import CalcFunctionNode, CalcJobNode, Node, QueryBuilder, WorkChainNode, StructureData +from .utils import get_ase_from_file -class StructureUploadWidget(ipw.VBox): + +class StructureManagerWidget(ipw.VBox): # pylint: disable=too-many-instance-attributes '''Upload a structure and store it in AiiDA database. Useful class members: @@ -20,15 +20,14 @@ class StructureUploadWidget(ipw.VBox): :ivar frozen: whenter the widget is frozen (can't be modified) or not :vartype frozen: bool :ivar structure_node: link to AiiDA structure object - :vartype structure_node: StructureData or CifData - ''' + :vartype structure_node: StructureData or CifData''' + has_structure = Bool(False) frozen = Bool(False) DATA_FORMATS = ('StructureData', 'CifData') - def __init__(self, text="Upload Structure", storable=True, node_class=None, examples=[], data_importers=[], **kwargs): + + def __init__(self, importers, storable=True, node_class=None, **kwargs): """ - :param text: Text to display before upload button - :type text: str :param storable: Whether to provide Store button (together with Store format) :type storable: bool :param node_class: AiiDA node class for storing the structure. @@ -36,102 +35,63 @@ def __init__(self, text="Upload Structure", storable=True, node_class=None, exam Note: If your workflows require a specific node class, better fix it here. :param examples: list of tuples each containing a name and a path to an example structure :type examples: list - :param data_importers: list of tuples each containing a name and an object for data importing. Each object + :param importers: list of tuples each containing a name and an object for data importing. Each object should containt an empty `on_structure_selection()` method that has two parameters: structure_ase, name - :type examples: list + :type examples: list""" - """ - self._structure_sources_tab = ipw.Tab() - - self.file_upload = FileUploadWidget(text) - supported_formats = ipw.HTML("""Supported structure formats""") - - self.select_example = ipw.Dropdown(options=self.get_example_structures(examples)) - self.viewer = nglview.NGLWidget() - self.btn_store = ipw.Button( - description='Store in AiiDA', disabled=True) - self.structure_description = ipw.Text( - placeholder="Description (optional)") + from .viewers import StructureDataViewer + if not importers: # we make sure the list is not empty + raise ValueError("The parameter importers should contain a list (or tuple) of tuples " + "(\"importer name\", importer), got a falsy object.") self.structure_ase = None self._structure_node = None - self.data_format = ipw.RadioButtons( - options=self.DATA_FORMATS, description='Data type:') - structure_sources = [ipw.VBox([self.file_upload, supported_formats])] - structure_sources_names = ["Upload"] + self.viewer = StructureDataViewer(downloadable=False) - if data_importers: - for label, importer in data_importers: - structure_sources.append(importer) - structure_sources_names.append(label) - importer.on_structure_selection = self.select_structure + self.btn_store = ipw.Button(description='Store in AiiDA', disabled=True) + self.btn_store.on_click(self._on_click_store) - if examples: - structure_sources_names.append("Examples") - structure_sources.append(self.select_example) + # Description that will is stored along with the new structure. + self.structure_description = ipw.Text(placeholder="Description (optional)") - if len(structure_sources) == 1: - self._structure_sources_tab = structure_sources[0] + # Select format to store in the AiiDA database. + self.data_format = ipw.RadioButtons(options=self.DATA_FORMATS, description='Data type:') + self.data_format.observe(self.reset_structure, names=['value']) + + if len(importers) == 1: + # If there is only one importer - no need to make tabs. + self._structure_sources_tab = importers[0][1] + # Assigning a function which will be called when importer provides a structure. + importers[0][1].on_structure_selection = self.select_structure else: - self._structure_sources_tab.children = structure_sources - for i, title in enumerate(structure_sources_names): - self._structure_sources_tab.set_title(i, title) + self._structure_sources_tab = ipw.Tab() # Tabs. + self._structure_sources_tab.children = [i[1] for i in importers] # One importer per tab. + for i, (label, importer) in enumerate(importers): + # Labeling tabs. + self._structure_sources_tab.set_title(i, label) + # Assigning a function which will be called when importer provides a structure. + importer.on_structure_selection = self.select_structure if storable: if node_class is None: store = [self.btn_store, self.data_format, self.structure_description] elif node_class not in self.DATA_FORMATS: - raise ValueError("Unknown data format '{}'. Options: {}".format( - node_class, self.DATA_FORMATS)) + raise ValueError("Unknown data format '{}'. Options: {}".format(node_class, self.DATA_FORMATS)) else: self.data_format.value = node_class store = [self.btn_store, self.structure_description] else: store = [self.structure_description] store = ipw.HBox(store) - children = [self._structure_sources_tab, self.viewer, store] - super(StructureUploadWidget, self).__init__( - children=children, **kwargs) - - self.file_upload.observe(self._on_file_upload, names='data') - self.select_example.observe(self._on_select_example, names=['value']) - self.btn_store.on_click(self._on_click_store) - self.data_format.observe(self.reset_structure, names=['value']) - self.structure_description.observe(self.reset_structure, names=['value']) - - @staticmethod - def get_example_structures(examples): - if type(examples) != list: - raise ValueError("parameter examples should be of type list, {} given".format(type(examples))) - if examples: - options =[("Select structure", False)] - options += examples - return options - else: - return [] - - # pylint: disable=unused-argument - def _on_file_upload(self, change): - self.file_path = os.path.join(tempfile.mkdtemp(), self.file_upload.filename) - with open(self.file_path, 'w') as f: - f.write(self.file_upload.data) - structure_ase = self.get_ase(self.file_path) - self.select_structure(structure_ase=structure_ase, name=self.file_upload.filename) + super().__init__(children=[self._structure_sources_tab, self.viewer, store], **kwargs) - def _on_select_example(self, change): - if self.select_example.value: - structure_ase = self.get_ase(self.select_example.value) - self.file_path = self.select_example.value - else: - structure_ase = False - self.select_structure(structure_ase=structure_ase, name=self.select_example.label) - - def reset_structure(self, change=None): + def reset_structure(self, change=None): # pylint: disable=unused-argument if self.frozen: return self._structure_node = None + self.viewer.structure = None def select_structure(self, structure_ase, name): """Select structure @@ -139,65 +99,29 @@ def select_structure(self, structure_ase, name): :param structure_ase: ASE object containing structure :type structure_ase: ASE Atoms :param name: File name with extension but without path - :type name: str - """ + :type name: str""" + if self.frozen: return - self.name = name self._structure_node = None if not structure_ase: self.btn_store.disabled = True self.has_structure = False self.structure_ase = None self.structure_description.value = '' - self.reset_structure() - self.refresh_view() return self.btn_store.disabled = False self.has_structure = True - self.structure_description.value = self.get_description(structure_ase, name) + self.structure_description.value = "{} ({})".format(structure_ase.get_chemical_formula(), name) self.structure_ase = structure_ase - self.refresh_view() + self.viewer.structure = structure_ase - def get_ase(self, fname): - try: - traj = ase.io.read(fname, index=":") - except Exception as exc: - if exc.args: - print(' '.join([str(c) for c in exc.args])) - else: - print("Unknown error") - return False - if not traj: - print("Could not read any information from the file {}".format(fname)) - return False - if len(traj) > 1: - print( - "Warning: Uploaded file {} contained more than one structure. I take the first one." - .format(fname)) - return traj[0] - - def get_description(self, structure_ase, name): - formula = structure_ase.get_chemical_formula() - return "{} ({})".format(formula, name) - - def refresh_view(self): - viewer = self.viewer - # Note: viewer.clear() only removes the 1st component - # pylint: disable=protected-access - for comp_id in viewer._ngl_component_ids: - viewer.remove_component(comp_id) - if self.structure_ase is None: - return - viewer.add_component(nglview.ASEStructure(self.structure_ase)) # adds ball+stick - viewer.add_unitcell() - - # pylint: disable=unused-argument - def _on_click_store(self, change): + def _on_click_store(self, change): # pylint: disable=unused-argument self.store_structure() def store_structure(self, label=None, description=None): + """Stores the structure in AiiDA database.""" if self.frozen: return if self.structure_node is None: @@ -218,9 +142,7 @@ def freeze(self): self.frozen = True self.btn_store.disabled = True self.structure_description.disabled = True - self.file_upload.disabled = True self.data_format.disabled = True - self.select_example.disabled = True @property def node_class(self): @@ -234,29 +156,274 @@ def node_class(self, value): @property def structure_node(self): + """Returns AiiDA StructureData node.""" if self._structure_node is None: if self.structure_ase is None: return None - # determine data source - if self.name.endswith('.cif'): - source_format = 'CIF' - else: - source_format = 'ASE' # perform conversion if self.data_format.value == 'CifData': - if source_format == 'CIF': - from aiida.orm.data.cif import CifData - self._structure_node = CifData( - file=os.path.abspath(self.file_path), - scan_type='flex', - parse_policy='lazy') - else: - from aiida.orm.data.cif import CifData - self._structure_node = CifData() - self._structure_node.set_ase(self.structure_ase) + from aiida.orm.nodes.data.cif import CifData + self._structure_node = CifData() + self._structure_node.set_ase(self.structure_ase) else: # Target format is StructureData - from aiida.orm.data.structure import StructureData self._structure_node = StructureData(ase=self.structure_ase) self._structure_node.description = self.structure_description.value - self._structure_node.label = os.path.splitext(self.name)[0] - return self._structure_node \ No newline at end of file + self._structure_node.label = self.structure_ase.get_chemical_formula() + return self._structure_node + + +class StructureUploadWidget(ipw.VBox): + """Class that allows to upload structures from user's computer.""" + + def __init__(self, text="Upload Structure"): + from fileupload import FileUploadWidget + + self.on_structure_selection = lambda structure_ase, name: None + self.file_path = None + self.file_upload = FileUploadWidget(text) + supported_formats = ipw.HTML( + """ + Supported structure formats + """) + self.file_upload.observe(self._on_file_upload, names='data') + super().__init__(children=[self.file_upload, supported_formats]) + + def _on_file_upload(self, change): # pylint: disable=unused-argument + """When file upload button is pressed.""" + self.file_path = os.path.join(tempfile.mkdtemp(), self.file_upload.filename) + with open(self.file_path, 'w') as fobj: + fobj.write(self.file_upload.data.decode("utf-8")) + structure_ase = get_ase_from_file(self.file_path) + self.on_structure_selection(structure_ase=structure_ase, name=self.file_upload.filename) + + +class StructureExamplesWidget(ipw.VBox): + """Class to provide example structures for selection.""" + + def __init__(self, examples, **kwargs): + self.on_structure_selection = lambda structure_ase, name: None + self._select_structure = ipw.Dropdown(options=self.get_example_structures(examples)) + self._select_structure.observe(self._on_select_structure, names=['value']) + super().__init__(children=[self._select_structure], **kwargs) + + @staticmethod + def get_example_structures(examples): + """Get the list of example structures.""" + if not isinstance(examples, list): + raise ValueError("parameter examples should be of type list, {} given".format(type(examples))) + return [("Select structure", False)] + examples + + def _on_select_structure(self, change): # pylint: disable=unused-argument + """When structure is selected.""" + if not self._select_structure.value: + return + structure_ase = get_ase_from_file(self._select_structure.value) + self.on_structure_selection(structure_ase=structure_ase, name=self._select_structure.label) + + +class StructureBrowserWidget(ipw.VBox): + """Class to query for structures stored in the AiiDA database.""" + + def __init__(self): + # Find all process labels + qbuilder = QueryBuilder() + qbuilder.append(WorkChainNode, project="label") + qbuilder.order_by({WorkChainNode: {'ctime': 'desc'}}) + process_labels = {i[0] for i in qbuilder.all() if i[0]} + + layout = ipw.Layout(width="900px") + self.mode = ipw.RadioButtons(options=['all', 'uploaded', 'edited', 'calculated'], + layout=ipw.Layout(width="25%")) + + # Date range + self.dt_now = datetime.datetime.now() + self.dt_end = self.dt_now - datetime.timedelta(days=10) + self.date_start = ipw.Text(value='', description='From: ', style={'description_width': '120px'}) + + self.date_end = ipw.Text(value='', description='To: ') + self.date_text = ipw.HTML(value='

Select the date range:

') + self.btn_date = ipw.Button(description='Search', layout={'margin': '1em 0 0 0'}) + self.age_selection = ipw.VBox( + [self.date_text, ipw.HBox([self.date_start, self.date_end]), self.btn_date], + layout={ + 'border': '1px solid #fafafa', + 'padding': '1em' + }) + + # Labels + self.drop_label = ipw.Dropdown(options=({'All'}.union(process_labels)), + value='All', + description='Process Label', + style={'description_width': '120px'}, + layout={'width': '50%'}) + + self.btn_date.on_click(self.search) + self.mode.observe(self.search, names='value') + self.drop_label.observe(self.search, names='value') + + h_line = ipw.HTML('
') + box = ipw.VBox([self.age_selection, h_line, ipw.HBox([self.mode, self.drop_label])]) + + self.results = ipw.Dropdown(layout=layout) + self.results.observe(self._on_select_structure) + self.search() + super(StructureBrowserWidget, self).__init__([box, h_line, self.results]) + + @staticmethod + def preprocess(): + """Search structures in AiiDA database.""" + queryb = QueryBuilder() + queryb.append(StructureData, filters={'extras': {'!has_key': 'formula'}}) + for itm in queryb.all(): # iterall() would interfere with set_extra() + formula = itm[0].get_formula() + itm[0].set_extra("formula", formula) + + def search(self, change=None): # pylint: disable=unused-argument + """Launch the search of structures in AiiDA database.""" + self.preprocess() + + qbuild = QueryBuilder() + try: # If the date range is valid, use it for the search + self.start_date = datetime.datetime.strptime(self.date_start.value, '%Y-%m-%d') + self.end_date = datetime.datetime.strptime(self.date_end.value, '%Y-%m-%d') + datetime.timedelta(hours=24) + except ValueError: # Otherwise revert to the standard (i.e. last 7 days) + self.start_date = self.dt_end + self.end_date = self.dt_now + datetime.timedelta(hours=24) + + self.date_start.value = self.start_date.strftime('%Y-%m-%d') + self.date_end.value = self.end_date.strftime('%Y-%m-%d') + + filters = {} + filters['ctime'] = {'and': [{'<=': self.end_date}, {'>': self.start_date}]} + if self.drop_label.value != 'All': + qbuild.append(WorkChainNode, filters={'label': self.drop_label.value}) + # print(qbuild.all()) + # qbuild.append(CalcJobNode, with_incoming=WorkChainNode) + qbuild.append(StructureData, with_incoming=WorkChainNode, filters=filters) + else: + if self.mode.value == "uploaded": + qbuild2 = QueryBuilder() + qbuild2.append(StructureData, project=["id"]) + qbuild2.append(Node, with_outgoing=StructureData) + processed_nodes = [n[0] for n in qbuild2.all()] + if processed_nodes: + filters['id'] = {"!in": processed_nodes} + qbuild.append(StructureData, filters=filters) + + elif self.mode.value == "calculated": + qbuild.append(CalcJobNode) + qbuild.append(StructureData, with_incoming=CalcJobNode, filters=filters) + + elif self.mode.value == "edited": + qbuild.append(CalcFunctionNode) + qbuild.append(StructureData, with_incoming=CalcFunctionNode, filters=filters) + + elif self.mode.value == "all": + qbuild.append(StructureData, filters=filters) + + qbuild.order_by({StructureData: {'ctime': 'desc'}}) + matches = {n[0] for n in qbuild.iterall()} + matches = sorted(matches, reverse=True, key=lambda n: n.ctime) + + options = OrderedDict() + options["Select a Structure ({} found)".format(len(matches))] = False + + for mch in matches: + label = "PK: %d" % mch.pk + label += " | " + mch.ctime.strftime("%Y-%m-%d %H:%M") + label += " | " + mch.get_extra("formula") + label += " | " + mch.description + options[label] = mch + + self.results.options = options + + def _on_select_structure(self, change): # pylint: disable=unused-argument + """When a structure was selected.""" + if not self.results.value: + return + structure_ase = self.results.value.get_ase() + formula = structure_ase.get_chemical_formula() + if self.on_structure_selection is not None: + self.on_structure_selection(structure_ase=structure_ase, name=formula) + + def on_structure_selection(self, structure_ase, name): + pass + + +class SmilesWidget(ipw.VBox): + """Conver SMILES into 3D structure.""" + + SPINNER = """""" + + def __init__(self): + try: + import openbabel # pylint: disable=unused-import + except ImportError: + super().__init__( + [ipw.HTML("The SmilesWidget requires the OpenBabel library, " + "but the library was not found.")]) + return + + self.smiles = ipw.Text() + self.create_structure_btn = ipw.Button(description="Generate molecule", button_style='info') + self.create_structure_btn.on_click(self._on_button_pressed) + self.output = ipw.HTML("") + super().__init__([self.smiles, self.create_structure_btn, self.output]) + + @staticmethod + def pymol_2_ase(pymol): + """Convert pymol object into ASE Atoms.""" + import numpy as np + from ase import Atoms, Atom + from ase.data import chemical_symbols + + asemol = Atoms() + for atm in pymol.atoms: + asemol.append(Atom(chemical_symbols[atm.atomicnum], atm.coords)) + asemol.cell = np.amax(asemol.positions, axis=0) - np.amin(asemol.positions, axis=0) + [10] * 3 + asemol.pbc = True + asemol.center() + return asemol + + def _optimize_mol(self, mol): + """Optimize a molecule using force field (needed for complex SMILES).""" + from openbabel import pybel # pylint:disable=import-error + + self.output.value = "Screening possible conformers {}".format(self.SPINNER) #font-size:20em; + + f_f = pybel._forcefields["mmff94"] # pylint: disable=protected-access + if not f_f.Setup(mol.OBMol): + f_f = pybel._forcefields["uff"] # pylint: disable=protected-access + if not f_f.Setup(mol.OBMol): + self.output.value = "Cannot set up forcefield" + return + + # initial cleanup before the weighted search + f_f.SteepestDescent(5500, 1.0e-9) + f_f.WeightedRotorSearch(15000, 500) + f_f.ConjugateGradients(6500, 1.0e-10) + f_f.GetCoordinates(mol.OBMol) + self.output.value = "" + + def _on_button_pressed(self, change): # pylint: disable=unused-argument + """Convert SMILES to ase structure when button is pressed.""" + self.output.value = "" + from openbabel import pybel # pylint:disable=import-error + if not self.smiles.value: + return + + mol = pybel.readstring("smi", self.smiles.value) + self.output.value = """SMILES to 3D conversion {}""".format(self.SPINNER) + mol.make3D() + + pybel._builder.Build(mol.OBMol) # pylint: disable=protected-access + mol.addh() + self._optimize_mol(mol) + + structure_ase = self.pymol_2_ase(mol) + formula = structure_ase.get_chemical_formula() + if self.on_structure_selection is not None: + self.on_structure_selection(structure_ase=structure_ase, name=formula) + + def on_structure_selection(self, structure_ase, name): + pass diff --git a/aiidalab_widgets_base/structures_multi.py b/aiidalab_widgets_base/structures_multi.py index e88de5a9e..85ac84a7a 100644 --- a/aiidalab_widgets_base/structures_multi.py +++ b/aiidalab_widgets_base/structures_multi.py @@ -1,18 +1,21 @@ -from __future__ import print_function +"""Module to deal with files containing multiple structures.""" import os +import tarfile +import zipfile +import tempfile + import ase.io import ipywidgets as ipw from ipywidgets import Layout -from IPython.display import clear_output from fileupload import FileUploadWidget -import tarfile -import zipfile -import tempfile + import nglview class MultiStructureUploadWidget(ipw.VBox): + """Class to deal with archives (tar or zip) containing multiple structures.""" + def __init__(self, text="Upload Zip or Tar archive", node_class=None, **kwargs): """ Upload multiple structures and store them in AiiDA database. @@ -24,16 +27,19 @@ def __init__(self, text="Upload Zip or Tar archive", node_class=None, **kwargs): """ self.file_upload = FileUploadWidget(text) - + self.tmp_folder = None + self.archive_name = '' # define the view part of the widget self.viewer = nglview.NGLWidget() self.selection_slider = ipw.SelectionSlider( - options=[None,], + options=[ + None, + ], disabled=False, orientation='vertical', description='Browse structures:', readout=False, - layout = Layout(width='50%'), + layout=Layout(width='50%'), ) view = ipw.HBox([self.viewer, self.selection_slider]) @@ -41,8 +47,7 @@ def __init__(self, text="Upload Zip or Tar archive", node_class=None, **kwargs): self.btn_store_all = ipw.Button(description='Store all in AiiDA', disabled=True) self.btn_store_selected = ipw.Button(description='Store selected', disabled=True) self.structure_description = ipw.Text(placeholder="Description (optional)") - self.data_format = ipw.RadioButtons( - options=['StructureData', 'CifData'], description='Data type:') + self.data_format = ipw.RadioButtons(options=['StructureData', 'CifData'], description='Data type:') # if node_class is predefined, there is no need to select it afterwards if node_class is None: @@ -53,14 +58,13 @@ def __init__(self, text="Upload Zip or Tar archive", node_class=None, **kwargs): self.data_format.value = node_class # define main data objects - self.structure_ase = None # contains the selected structure in the ase format - self.structure_nodes = [] # a list that contains all stored structure objects - self.structure_names = [] # list of uploaded structures + self.structure_ase = None # contains the selected structure in the ase format + self.structure_nodes = [] # a list that contains all stored structure objects + self.structure_names = [] # list of uploaded structures # put all visual parts in children list and initialize the parent Vbox widget with it children = [self.file_upload, view, store] - super(MultiStructureUploadWidget, self).__init__( - children=children, **kwargs) + super(MultiStructureUploadWidget, self).__init__(children=children, **kwargs) # attach actions to the buttons self.file_upload.observe(self._on_file_upload, names='data') @@ -75,16 +79,16 @@ def change_structure(self): else: self.select_structure(filepath=self.selection_slider.value) - # pylint: disable=unused-argument - def _on_file_upload(self, change): + def _on_file_upload(self, change): # pylint: disable=unused-argument + """Process the archive once it is uplodaded.""" # I redefine both: structure_names and structure_nodes, since we are now uploading a different archive self.structure_names = [] self.structure_nodes = [] # download an archive and put its content into a file archive = tempfile.NamedTemporaryFile(suffix=self.file_upload.filename) - with open(archive.name, 'w') as f: - f.write(self.file_upload.data) + with open(archive.name, 'wb') as fobj: + fobj.write(self.file_upload.data) self.archive_name = archive.name # create a temporary folder where all the structure will be extracted @@ -115,9 +119,9 @@ def _on_file_upload(self, change): raise ValueError("The file you provided does not look like Zip or Tar archive") # put all extracted files into a list - for (dirpath, dirnames, filenames) in os.walk(self.tmp_folder): + for (dirpath, _, filenames) in os.walk(self.tmp_folder): for filename in filenames: - self.structure_names.append(dirpath+'/'+filename) + self.structure_names.append(dirpath + '/' + filename) if not self.structure_names: raise ValueError("Even though the input archive seem not to be empty, it does not contain any file") @@ -129,7 +133,8 @@ def _on_file_upload(self, change): self.selection_slider.value = self.structure_names[0] def get_ase(self, filepath): - file_sub_path = filepath[len(self.tmp_folder)+1:] + """Get an ase object containing the structure.""" + file_sub_path = filepath[len(self.tmp_folder) + 1:] try: traj = ase.io.read(filepath, index=":") except AttributeError: @@ -140,11 +145,14 @@ def get_ase(self, filepath): "I take the first one.".format(file_sub_path)) return traj[0] - def get_description(self, structure_ase, filepath): + @staticmethod + def get_description(structure_ase, filepath): + """Get the structure description automatically.""" formula = structure_ase.get_chemical_formula() return "{} ({})".format(formula, filepath.split('/')[-1]) def select_structure(self, filepath): + """Perform structure selection.""" structure_ase = self.get_ase(filepath) self.btn_store_all.disabled = False self.btn_store_selected.disabled = False @@ -159,8 +167,8 @@ def select_structure(self, filepath): self.structure_ase = structure_ase self.refresh_view() - def refresh_view(self): + """Refresh the structure view.""" viewer = self.viewer # Note: viewer.clear() only removes the 1st component # pylint: disable=protected-access @@ -168,18 +176,20 @@ def refresh_view(self): viewer.remove_component(comp_id) if self.structure_ase is None: return - viewer.add_component(nglview.ASEStructure( - self.structure_ase)) # adds ball+stick - viewer.add_unitcell() + viewer.add_component(nglview.ASEStructure(self.structure_ase)) # adds ball+stick + viewer.add_unitcell() # pylint: disable=no-member # pylint: disable=unused-argument def _on_click_store_all(self, change): + """Store all the uploaded structures.""" self.structure_nodes = [] - # comment this if you are sure that it is safe, and the selection_slider does not interfere with store_structure() function + # comment this if you are sure that it is safe, and the selection_slider does not interfere + # with store_structure() function self.selection_slider.disabled = True for filepath in self.structure_names: self.store_structure(filepath) - # comment this if you are sure that it is safe, and the selection_slider does not interfere with store_structure() function + # comment this if you are sure that it is safe, and the selection_slider does not interfere + # with store_structure() function self.selection_slider.disabled = False # pylint: disable=unused-argument @@ -187,6 +197,7 @@ def _on_click_store_selected(self, change): self.store_structure(self.selection_slider.value, description=self.structure_description.value) def store_structure(self, filepath, description=None): + """Store the structure in the AiiDA database.""" structure_ase = self.get_ase(filepath) if structure_ase is None: return @@ -200,26 +211,17 @@ def store_structure(self, filepath, description=None): # perform conversion if self.data_format.value == 'CifData': if source_format == 'CIF': - from aiida.orm.data.cif import CifData - structure_node = CifData( - file=filepath, - scan_type='flex', - parse_policy='lazy') + from aiida.orm.nodes.data.cif import CifData + structure_node = CifData(file=filepath, scan_type='flex', parse_policy='lazy') else: - from aiida.orm.data.cif import CifData + from aiida.orm.nodes.data.cif import CifData structure_node = CifData() structure_node.set_ase(structure_ase) else: # Target format is StructureData - from aiida.orm.data.structure import StructureData + from aiida.orm import StructureData structure_node = StructureData(ase=structure_ase) - #TODO: Figure out whether this is still necessary for StructureData - # ensure that tags got correctly translated into kinds - for t1, k in zip(structure_ase.get_tags(), - structure_node.get_site_kindnames()): - t2 = int(k[-1]) if k[-1].isnumeric() else 0 - assert t1 == t2 if description is None: structure_node.description = self.get_description(structure_ase, filepath) else: diff --git a/aiidalab_widgets_base/utils/__init__.py b/aiidalab_widgets_base/utils/__init__.py new file mode 100644 index 000000000..546fc322d --- /dev/null +++ b/aiidalab_widgets_base/utils/__init__.py @@ -0,0 +1,67 @@ +"""Some utility functions used acrross the repository.""" + + +def valid_arguments(arguments, valid_args): + """Check whether provided arguments are valid.""" + result = {} + for key, value in arguments.items(): + if key in valid_args: + if isinstance(value, (tuple, list)): + result[key] = '\n'.join(value) + else: + result[key] = value + return result + + +def predefine_settings(obj, **kwargs): + """Specify some pre-defined settings.""" + for key, value in kwargs.items(): + if hasattr(obj, key): + setattr(obj, key, value) + else: + raise AttributeError("'{}' object has no attirubte '{}'".format(obj, key)) + + +def get_ase_from_file(fname): + """Get ASE structure object.""" + from ase.io import read + try: + traj = read(fname, index=":") + except Exception as exc: # pylint: disable=broad-except + if exc.args: + print((' '.join([str(c) for c in exc.args]))) + else: + print("Unknown error") + return False + if not traj: + print(("Could not read any information from the file {}".format(fname))) + return False + if len(traj) > 1: + print(("Warning: Uploaded file {} contained more than one structure. Selecting the first one.".format(fname))) + return traj[0] + + +def find_ranges(iterable): + """Yield range of consecutive numbers.""" + import more_itertools as mit + for group in mit.consecutive_groups(iterable): + group = list(group) + if len(group) == 1: + yield group[0] + else: + yield group[0], group[-1] + + +def string_range_to_set(strng): + """Convert string like '1 3..5' into the set like {1, 3, 4, 5}.""" + singles = [int(s) for s in strng.split() if s.isdigit()] + ranges = [r for r in strng.split() if '..' in r] + if len(singles) + len(ranges) != len(strng.split()): + return set(), False + for rng in ranges: + try: + start, end = rng.split('..') + singles += [i for i in range(int(start), int(end) + 1)] + except ValueError: + return set(), False + return set(singles), True diff --git a/aiidalab_widgets_base/viewers.py b/aiidalab_widgets_base/viewers.py new file mode 100644 index 000000000..ae01ff35e --- /dev/null +++ b/aiidalab_widgets_base/viewers.py @@ -0,0 +1,493 @@ +"""Jupyter viewers for AiiDA data objects.""" +import base64 +import warnings + +import ipywidgets as ipw +from IPython.display import display +import nglview +from ase import Atoms + +from traitlets import Instance, Int, Set, Union, observe, validate +from aiida.orm import Node + +from .utils import find_ranges, string_range_to_set +from .misc import CopyToClipboardButton + + +def viewer(obj, downloadable=True, **kwargs): + """Display AiiDA data types in Jupyter notebooks. + + :param downloadable: If True, add link/button to download the content of displayed AiiDA object. + :type downloadable: bool + + Returns the object itself if the viewer wasn't found.""" + if not isinstance(obj, Node): # only working with AiiDA nodes + warnings.warn("This viewer works only with AiiDA objects, got {}".format(type(obj))) + return obj + + try: + _viewer = AIIDA_VIEWER_MAPPING[obj.node_type] + return _viewer(obj, downloadable=downloadable, **kwargs) + except (KeyError) as exc: + if obj.node_type in str(exc): + warnings.warn("Did not find an appropriate viewer for the {} object. Returning the object " + "itself.".format(type(obj))) + return obj + raise exc + + +class DictViewer(ipw.HTML): + """Viewer class for Dict object. + + :param parameter: Dict object to be viewed + :type parameter: Dict + :param downloadable: If True, add link/button to download the content of the object + :type downloadable: bool""" + + def __init__(self, parameter, downloadable=True, **kwargs): + super().__init__(**kwargs) + import pandas as pd + # Here we are defining properties of 'df' class (specified while exporting pandas table into html). + # Since the exported object is nothing more than HTML table, all 'standard' HTML table settings + # can be applied to it as well. + # For more information on how to controle the table appearance please visit: + # https://css-tricks.com/complete-guide-table-element/ + self.value = ''' + + ''' + pd.set_option('max_colwidth', 40) + dataf = pd.DataFrame([(key, value) for key, value in sorted(parameter.get_dict().items())], + columns=['Key', 'Value']) + self.value += dataf.to_html(classes='df', index=False) # specify that exported table belongs to 'df' class + # this is used to setup table's appearance using CSS + if downloadable: + payload = base64.b64encode(dataf.to_csv(index=False).encode()).decode() + fname = '{}.csv'.format(parameter.pk) + to_add = """Download table in csv format: {title}""" + self.value += to_add.format(filename=fname, payload=payload, title=fname) + + +class _StructureDataBaseViewer(ipw.VBox): + """Base viewer class for AiiDA structure or trajectory objects. + + :param configure_view: If True, add configuration tabs + :type configure_view: bool""" + selection = Set(Int, read_only=True) + DEFAULT_SELECTION_OPACITY = 0.2 + DEFAULT_SELECTION_RADIUS = 6 + DEFAULT_SELECTION_COLOR = 'green' + + def __init__(self, configure_view=True, **kwargs): + + # Defining viewer box. + + # 1. Nglviwer + self._viewer = nglview.NGLWidget() + self._viewer.camera = 'orthographic' + self._viewer.observe(self._on_atom_click, names='picked') + self._viewer.stage.set_parameters(mouse_preset='pymol') + + # 2. Camera type. + camera_type = ipw.ToggleButtons(options={ + 'Orthographic': 'orthographic', + 'Perspective': 'perspective' + }, + description='Camera type:', + value='orthographic', + layout={"align_self": "flex-end"}, + style={'button_width': '115.5px'}, + orientation='vertical') + + def change_camera(change): + self._viewer.camera = change['new'] + + camera_type.observe(change_camera, names="value") + view_box = ipw.VBox([self._viewer, camera_type]) + + # Defining selection tab. + + # 1. Selected atoms. + self._selected_atoms = ipw.Text(description='Selected atoms:', + placeholder="Example: 2 5 8..10", + value='', + style={'description_width': 'initial'}) + self._selected_atoms.observe(self._apply_selection, names='value') + + # 2. Copy to clipboard + copy_to_clipboard = CopyToClipboardButton(description="Copy to clipboard") + ipw.link((self._selected_atoms, 'value'), (copy_to_clipboard, 'value')) + + # 3. Informing about wrong syntax. + self.wrong_syntax = ipw.HTML( + value=""" wrong syntax""", + layout={'visibility': 'hidden'}) + + # 4. Button to clear selection. + clear_selection = ipw.Button(description="Clear selection") + clear_selection.on_click(self.clear_selection) + + selection_tab = ipw.VBox( + [self._selected_atoms, self.wrong_syntax, + ipw.HBox([copy_to_clipboard, clear_selection])]) + + # Defining appearance tab. + + # 1. Choose background color. + background_color = ipw.ColorPicker(description="Background") + ipw.link((background_color, 'value'), (self._viewer, 'background')) + background_color.value = 'white' + + # 2. Center button. + center_button = ipw.Button(description="Center") + center_button.on_click(lambda c: self._viewer.center()) + + appearance_tab = ipw.VBox([background_color, center_button]) + + # Defining download tab. + + # 1. Choose download file format. + self.file_format = ipw.Dropdown(options=['xyz', 'cif'], layout={"width": "200px"}, description="File format:") + + # 2. Download button. + self.download_btn = ipw.Button(description="Download") + self.download_btn.on_click(self.download) + self.download_box = ipw.VBox( + children=[ipw.Label("Download as file:"), + ipw.HBox([self.file_format, self.download_btn])]) + + # 3. Screenshot button + self.screenshot_btn = ipw.Button(description="Screenshot", icon='camera') + self.screenshot_btn.on_click(lambda _: self._viewer.download_image()) + self.screenshot_box = ipw.VBox(children=[ipw.Label("Create a screenshot:"), self.screenshot_btn]) + + download_tab = ipw.VBox([self.download_box, self.screenshot_box]) + + # Constructing configuration box + if configure_view: + configuration_box = ipw.Tab(layout=ipw.Layout(flex='1 1 auto', width='auto')) + configuration_box.children = [selection_tab, appearance_tab, download_tab] + for i, title in enumerate(["Selection", "Appearance", "Download"]): + configuration_box.set_title(i, title) + children = [ipw.HBox([view_box, configuration_box])] + view_box.layout = {'width': "60%"} + else: + children = [view_box] + + if 'children' in kwargs: + children += kwargs.pop('children') + + super().__init__(children, **kwargs) + + def _on_atom_click(self, change=None): # pylint:disable=unused-argument + """Update selection when clicked on atom.""" + if 'atom1' not in self._viewer.picked.keys(): + return # did not click on atom + index = self._viewer.picked['atom1']['index'] + + if index not in self.selection: + self.selection.add(index) + else: + self.selection.discard(index) + self._selected_atoms.value = self.shortened_selection() + + def highlight_atoms(self, + vis_list, + color=DEFAULT_SELECTION_COLOR, + size=DEFAULT_SELECTION_RADIUS, + opacity=DEFAULT_SELECTION_OPACITY): + """Highlighting atoms according to the provided list.""" + if not hasattr(self._viewer, "component_0"): + return + self._viewer._remove_representations_by_name(repr_name='selected_atoms') # pylint:disable=protected-access + self._viewer.add_ball_and_stick( # pylint:disable=no-member + name="selected_atoms", + selection=vis_list, + color=color, + aspectRatio=size, + opacity=opacity) + + def _apply_selection(self, change=None): # pylint:disable=unused-argument + """Apply selection specified in the text area.""" + short_selection = change['new'] + expanded_selection, syntax_ok = string_range_to_set(short_selection) + self.set_trait('selection', expanded_selection) + + with self.hold_trait_notifications(): + if syntax_ok: + self.wrong_syntax.layout.visibility = 'hidden' + self.highlight_atoms(self.selection) + else: + self.wrong_syntax.layout.visibility = 'visible' + + def clear_selection(self, change=None): # pylint:disable=unused-argument + with self.hold_trait_notifications(): + self._viewer._remove_representations_by_name(repr_name='selected_atoms') # pylint:disable=protected-access + self.set_trait('selection', set()) + self._selected_atoms.value = self.shortened_selection() + + def shortened_selection(self): + return " ".join([ + str(t) if isinstance(t, int) else "{}..{}".format(t[0], t[1]) for t in find_ranges(sorted(self.selection)) + ]) + + def download(self, change=None): # pylint: disable=unused-argument + """Prepare a structure for downloading.""" + self._download(payload=self._prepare_payload(), filename='structure.' + self.file_format.value) + + @staticmethod + def _download(payload, filename): + """Download payload as a file named as filename.""" + from IPython.display import Javascript + javas = Javascript(""" + var link = document.createElement('a'); + link.href = "data:;base64,{payload}" + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=filename)) + display(javas) + + def _prepare_payload(self, file_format=None): + """Prepare binary information.""" + from tempfile import NamedTemporaryFile + file_format = file_format if file_format else self.file_format.value + tmp = NamedTemporaryFile() + self.structure.write(tmp.name, format=file_format) # pylint: disable=no-member + with open(tmp.name, 'rb') as raw: + return base64.b64encode(raw.read()).decode() + + @property + def thumbnail(self): + return self._prepare_payload(file_format='png') + + +class StructureDataViewer(_StructureDataBaseViewer): + """Viewer class for AiiDA structure objects. + + :param structure: structure object to be viewed + :type structure: StructureData or CifData""" + structure = Union([Instance(Atoms), Instance(Node)], allow_none=True) + + def __init__(self, structure=None, **kwargs): + super().__init__(**kwargs) + self.structure = structure + + @validate('structure') + def _valid_structure(self, change): # pylint: disable=no-self-use + """Update structure.""" + structure = change['value'] + + if structure is None: + return None # if no structure provided, the rest of the code can be skipped + + if isinstance(structure, Atoms): + return structure + if isinstance(structure, Node): + return structure.get_ase() + raise ValueError("Unsupported type {}, structure must be one of the following types: " + "ASE Atoms object, AiiDA CifData or StructureData.") + + @observe('structure') + def _update_structure_view(self, change): + """Update structure view after structure was modified.""" + # Remove the current structure(s) from the viewer. + with self.hold_trait_notifications(): + for comp_id in self._viewer._ngl_component_ids: # pylint: disable=protected-access + self._viewer.remove_component(comp_id) + self.clear_selection() + if change['new'] is not None: + self._viewer.add_component(nglview.ASEStructure(change['new'])) # adds ball+stick + self._viewer.add_unitcell() # pylint: disable=no-member + + + +class TrajectoryDataViewer(ipw.VBox): + """Viewer class for TrajectoryData object.""" + + def __init__(self, trajectory, downloadable=True, **kwargs): + import bqplot.pyplot as plt + + # TrajectoryData object from AiiDA + self._trajectory = trajectory + self._frames = len(self._trajectory.get_stepids()) + self._structures = [trajectory.get_step_structure(i) for i in range(self._frames)] + + # Plot object. + self._plot = plt.figure(title="plot", layout={"width": "99%"}) + + # Trajectory navigator. + self._step_selector = ipw.IntSlider( + value=0, + min=0, + max=self._frames - 1, + ) + self._step_selector.observe(self.update_selection, names="value") + + # Property to plot. + self._property_selector = ipw.Dropdown( + options=trajectory.get_arraynames(), + value='energy', + description="Value to plot:", + ) + self._property_selector.observe(self.update_all, names="value") + + # Preparing scales. + x_data = self._trajectory.get_stepids() + + self._plot_line = plt.plot(x_data, self._trajectory.get_array(self._property_selector.value)[:len(x_data)]) + self._plot_circle = plt.scatter(x_data, self._trajectory.get_array(self._property_selector.value)[:len(x_data)]) + + on_plot_click_global = self.update_selection_from_plot + + def update_selection(self, change): + on_plot_click_global(self, change) + + self._plot_circle.on_element_click(update_selection) + + self._plot_select_circle = plt.scatter( + [self._trajectory.get_stepids()[self._step_selector.value]], + [self._trajectory.get_array(self._property_selector.value)[self._step_selector.value]], + stroke='red', + ) + + self._plot.axes[1].tick_format = "0.3g" + self.message = ipw.HTML() + + # Structure viewer. + self._struct_viewer = StructureDataViewer(self._structures, + downloadable=downloadable, + configure_view=False, + layout={"width": "50%"}) + + children = [ + ipw.HBox([self._struct_viewer, + ipw.VBox([self._plot, self._property_selector], layout={"width": "50%"})]), + self._step_selector, + self.message, + ] + + super().__init__(children, **kwargs) + + def update_all(self, change=None): # pylint: disable=unused-argument + """Update the data plot.""" + x_data = self._trajectory.get_stepids() + + self._plot_circle.x = x_data + self._plot_circle.y = self._trajectory.get_array(self._property_selector.value)[:len(x_data)] + + self._plot_line.x = x_data + self._plot_line.y = self._trajectory.get_array(self._property_selector.value)[:len(x_data)] + + self.update_selection() + + def update_selection(self, change=None): # pylint: disable=unused-argument + """Update selected point only.""" + self._struct_viewer.frame = self._step_selector.value + self._plot_select_circle.x = [self._trajectory.get_stepids()[self._step_selector.value]] + self._plot_select_circle.y = [ + self._trajectory.get_array(self._property_selector.value)[self._step_selector.value] + ] + + def update_selection_from_plot(self, _, selected_point): + self._step_selector.value = selected_point['data']['index'] + + +class FolderDataViewer(ipw.VBox): + """Viewer class for FolderData object. + + :param folder: FolderData object to be viewed + :type folder: FolderData + :param downloadable: If True, add link/button to download the content of the selected file in the folder + :type downloadable: bool""" + + def __init__(self, folder, downloadable=True, **kwargs): + self._folder = folder + self.files = ipw.Dropdown( + options=[obj.name for obj in self._folder.list_objects()], + description="Select file:", + ) + self.text = ipw.Textarea(value="", + description='File content:', + layout={ + 'width': "900px", + 'height': '300px' + }, + disabled=False) + self.change_file_view() + self.files.observe(self.change_file_view, names='value') + children = [self.files, self.text] + if downloadable: + self.download_btn = ipw.Button(description="Download") + self.download_btn.on_click(self.download) + children.append(self.download_btn) + super().__init__(children, **kwargs) + + def change_file_view(self, change=None): # pylint: disable=unused-argument + with self._folder.open(self.files.value) as fobj: + self.text.value = fobj.read() + + def download(self, change=None): # pylint: disable=unused-argument + """Prepare for downloading.""" + from IPython.display import Javascript + + payload = base64.b64encode(self._folder.get_object_content(self.files.value).encode()).decode() + javas = Javascript(""" + var link = document.createElement('a'); + link.href = "data:;base64,{payload}" + link.download = "{filename}" + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + """.format(payload=payload, filename=self.files.value)) + display(javas) + + +class BandsDataViewer(ipw.VBox): + """Viewer class for BandsData object. + + :param bands: BandsData object to be viewed + :type bands: BandsData""" + + def __init__(self, bands, **kwargs): + from bokeh.io import show, output_notebook + from bokeh.models import Span + from bokeh.plotting import figure + output_notebook(hide_banner=True) + out = ipw.Output() + with out: + plot_info = bands._get_bandplot_data(cartesian=True, join_symbol="|") # pylint: disable=protected-access + # Extract relevant data + y_data = plot_info['y'].transpose().tolist() + x_data = [plot_info['x'] for i in range(len(y_data))] + labels = plot_info['labels'] + # Create the figure + plot = figure(y_axis_label='Dispersion ({})'.format(bands.units)) + plot.multi_line(x_data, y_data, line_width=2, line_color='red') + plot.xaxis.ticker = [l[0] for l in labels] + # This trick was suggested here: https://github.com/bokeh/bokeh/issues/8166#issuecomment-426124290 + plot.xaxis.major_label_overrides = {int(l[0]) if l[0].is_integer() else l[0]: l[1] for l in labels} + # Add vertical lines + plot.renderers.extend( + [Span(location=l[0], dimension='height', line_color='black', line_width=3) for l in labels]) + show(plot) + children = [out] + super().__init__(children, **kwargs) + + +AIIDA_VIEWER_MAPPING = { + 'data.dict.Dict.': DictViewer, + 'data.structure.StructureData.': StructureDataViewer, + 'data.cif.CifData.': StructureDataViewer, + 'data.folder.FolderData.': FolderDataViewer, + 'data.array.bands.BandsData.': BandsDataViewer, + 'data.array.trajectory.TrajectoryData.': TrajectoryDataViewer, +} diff --git a/codes.ipynb b/codes.ipynb index a9fb7ea0f..c780c9366 100644 --- a/codes.ipynb +++ b/codes.ipynb @@ -10,37 +10,28 @@ "from IPython.display import display\n", "\n", "# Select from installed codes for 'zeopp.network' input plugin\n", - "dropdown = CodeDropdown(input_plugin='zeopp.network')\n", + "dropdown = CodeDropdown(input_plugin='quantumespresso.pw')\n", "display(dropdown)" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "dropdown.selected_code" - ] } ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4, diff --git a/metadata.json b/metadata.json index 4588fff0b..a7823767b 100644 --- a/metadata.json +++ b/metadata.json @@ -2,7 +2,7 @@ "title": "AiiDA lab Widgets", "description": "Reusable widgets for applications in the AiiDA lab.", "authors": "AiiDA Team", - "version": "0.4.0b2", + "version": "1.0.0a5", "logo": "miscellaneous/logos/aiidalab.png", "state": "stable" } diff --git a/setup.json b/setup.json index 7c926e4a8..45591c3f1 100644 --- a/setup.json +++ b/setup.json @@ -1,4 +1,5 @@ { + "version": "1.0.0a7", "name": "aiidalab-widgets-base", "author_email": "aiidateam@gmail.com", "url": "https://github.com/aiidalab/aiidalab-widgets-base", @@ -7,8 +8,8 @@ "Programming Language :: Python" ], "install_requires": [ - "aiida-core[jupyter] >= 0.11", "ase", + "bokeh", "numpy", "ipywidgets", "fileupload", @@ -16,13 +17,13 @@ ], "extras_require": { "testing": [ - "aiida-core[testing]" + "aiida-core[testing]>=1.0.b6" ], "pre-commit": [ - "pre-commit", - "yapf", - "prospector", - "pylint" + "pre-commit==1.17.0", + "yapf==0.28.0", + "prospector==1.1.7", + "pylint==2.3.1" ], "docs": [ "sphinx" diff --git a/setup.py b/setup.py index 179bbdf05..7b21390df 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,20 @@ -#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Setting up CP2K plugin for AiiDA""" +import json -from __future__ import absolute_import +from io import open # pylint: disable=redefined-builtin from setuptools import setup, find_packages -import json -# pylint: disable=syntax-error -if __name__ == '__main__': - # Provide static information in setup.json - # such that it can be discovered automatically - with open('metadata.json', 'r') as info: - metadata = json.load(info) - with open('setup.json', 'r') as info: +def run_setup(): + with open('setup.json', 'r', encoding='utf-8') as info: kwargs = json.load(info) + setup(include_package_data=True, + packages=find_packages(), + long_description=open('README.md', encoding='utf-8').read(), + long_description_content_type='text/markdown', + **kwargs) - setup( - packages=find_packages(), - long_description=open('README.md').read(), - long_description_content_type='text/markdown', - author=metadata['authors'], - description=metadata['description'], - version=metadata['version'], - **kwargs) + +if __name__ == '__main__': + run_setup() diff --git a/setup_code.ipynb b/setup_code.ipynb index 284641a8d..12e3c1e29 100644 --- a/setup_code.ipynb +++ b/setup_code.ipynb @@ -13,8 +13,8 @@ "metadata": {}, "outputs": [], "source": [ - "import urlparse\n", - "from aiidalab_widgets_base import AiiDACodeSetup, extract_aiidacodesetup_arguments" + "import urllib.parse as urlparse\n", + "from aiidalab_widgets_base import AiiDACodeSetup, valid_aiidacode_args" ] }, { @@ -24,7 +24,7 @@ "outputs": [], "source": [ "parsed_url = urlparse.parse_qs(urlparse.urlsplit(jupyter_notebook_url).query)\n", - "args = extract_aiidacodesetup_arguments(parsed_url)" + "args = valid_aiidacode_args(parsed_url)" ] }, { @@ -40,21 +40,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4, diff --git a/setup_computer.ipynb b/setup_computer.ipynb index 5ec69f1e6..01c7fd1fd 100644 --- a/setup_computer.ipynb +++ b/setup_computer.ipynb @@ -13,10 +13,10 @@ "metadata": {}, "outputs": [], "source": [ - "import urlparse\n", + "import urllib.parse as urlparse\n", "\n", - "from aiidalab_widgets_base import SshComputerSetup, extract_sshcomputersetup_arguments\n", - "from aiidalab_widgets_base import AiidaComputerSetup, extract_aiidacomputer_arguments" + "from aiidalab_widgets_base import SshComputerSetup, valid_sshcomputer_args\n", + "from aiidalab_widgets_base import AiidaComputerSetup, valid_aiidacomputer_args" ] }, { @@ -42,7 +42,7 @@ "metadata": {}, "outputs": [], "source": [ - "args = extract_sshcomputersetup_arguments(parsed_url)\n", + "args = valid_sshcomputer_args(parsed_url)\n", "sshcomputer = SshComputerSetup(**args)\n", "display(sshcomputer)" ] @@ -60,7 +60,7 @@ "metadata": {}, "outputs": [], "source": [ - "args = extract_aiidacomputer_arguments(parsed_url)\n", + "args = valid_aiidacomputer_args(parsed_url)\n", "aiidacomputer = AiidaComputerSetup(**args)\n", "sshcomputer.observe(aiidacomputer.get_available_computers, names=['setup_counter'])\n", "display(aiidacomputer)" @@ -69,21 +69,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4, diff --git a/start.py b/start.py index 076f6e7b8..b918ceae8 100644 --- a/start.py +++ b/start.py @@ -1,6 +1,7 @@ +"""Start page appearance.""" import ipywidgets as ipw -template = """ +TEMPLATE = """ @@ -15,7 +16,7 @@
AiiDA lab widgets
@@ -23,7 +24,7 @@ def get_start_widget(appbase, jupbase, notebase): - html = template.format(appbase=appbase, jupbase=jupbase, notebase=notebase) + html = TEMPLATE.format(appbase=appbase, jupbase=jupbase, notebase=notebase) return ipw.HTML(html) diff --git a/structures.ipynb b/structures.ipynb index fcfad7a20..64b188607 100644 --- a/structures.ipynb +++ b/structures.ipynb @@ -8,35 +8,44 @@ }, "outputs": [], "source": [ - "from aiidalab_widgets_base import StructureUploadWidget, CodQueryWidget\n", - "from IPython.display import display\n", + "from aiidalab_widgets_base import CodQueryWidget, SmilesWidget, StructureExamplesWidget\n", + "from aiidalab_widgets_base import StructureBrowserWidget, StructureManagerWidget, StructureUploadWidget\n", "\n", - "widget = StructureUploadWidget(examples=[(\"Silicon oxide\", 'miscellaneous/structures/SiO2.xyz')],\n", - " data_importers=[(\"COD\", CodQueryWidget())])\n", + "widget = StructureManagerWidget(importers=[\n", + " (\"From computer\", StructureUploadWidget()),\n", + " (\"COD\", CodQueryWidget()),\n", + " (\"AiiDA database\", StructureBrowserWidget()),\n", + " (\"SMILES\", SmilesWidget()), # requires OpenBabel! \n", + " (\"From Examples\", StructureExamplesWidget(\n", + " examples=[\n", + " (\"Silicon oxide\", \"miscellaneous/structures/SiO2.xyz\")\n", + " ])\n", + " ),\n", + "])\n", "# widget = StructureUploadWidget()\n", "# Enforce node format to be CifData:\n", - "# widget = StructureUploadWidget(node_class='CifData')\n", + "# widget = StructureManagerWidget(importers = [(\"From computer\", StructureUploadWidget())], node_class=\"CifData\")\n", "display(widget)" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4, diff --git a/structures_multi.ipynb b/structures_multi.ipynb index 28f08b5c6..071193c32 100644 --- a/structures_multi.ipynb +++ b/structures_multi.ipynb @@ -18,21 +18,21 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.15rc1" + "pygments_lexer": "ipython3", + "version": "3.6.8" } }, "nbformat": 4,