Skip to content

Commit 9d918e0

Browse files
authored
Add a progress bar for export and import related functionality (#3599)
When provenance graphs become large, exporting and importing the resulting archives can become time consuming operations. To give the user more feedback of how the operation is progressing, a progress bar is added. The implementation is provided by the `tqdm` library which is therefore added as a new dependency. Since the export and import code is still not written in a modular way, to allow the progress bar to get access to the inner parts and provide information that is granular enough, it is implemented as a global singleton that the export and import functions fetch. This is to prevent having to pass the progress bar instance around in method calls. This is still not an ideal solution and in the future, this should be replaced with hooks that methods can call in order to update their status. A similar problem is faced with the logging of textual progress in the export and import functions that are done haphazardly with print statements, even though it concerns module functions and not CLI facing code. This has been changed to go through logging instead. The log level is temporarily updated based on the `silent` argument that the export and import functions take. Finally, the function signature of `export` and `export_tree` have been changed. The `what` and `outfile` arguments have been deprecated and replaced by `entities` and `filename`, respectively. A deprecation message is printed if they are used and they should be removed with the release of `aiida-core==2.0.0`.
1 parent b9d4bbe commit 9d918e0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1471
-709
lines changed

aiida/cmdline/commands/cmd_export.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ def create(
9393
their provenance, according to the rules outlined in the documentation.
9494
You can modify some of those rules using options of this command.
9595
"""
96-
from aiida.tools.importexport import export, export_zip
96+
from aiida.tools.importexport import export, ExportFileFormat
97+
from aiida.tools.importexport.common.exceptions import ArchiveExportError
9798

9899
entities = []
99100

@@ -122,19 +123,18 @@ def create(
122123
}
123124

124125
if archive_format == 'zip':
125-
export_function = export_zip
126+
export_format = ExportFileFormat.ZIP
126127
kwargs.update({'use_compression': True})
127128
elif archive_format == 'zip-uncompressed':
128-
export_function = export_zip
129+
export_format = ExportFileFormat.ZIP
129130
kwargs.update({'use_compression': False})
130131
elif archive_format == 'tar.gz':
131-
export_function = export
132+
export_format = ExportFileFormat.TAR_GZIPPED
132133

133134
try:
134-
export_function(entities, outfile=output_file, **kwargs)
135-
136-
except IOError as exception:
137-
echo.echo_critical('failed to write the export archive file: {}'.format(exception))
135+
export(entities, filename=output_file, file_format=export_format, **kwargs)
136+
except ArchiveExportError as exception:
137+
echo.echo_critical('failed to write the archive file. Exception: {}'.format(exception))
138138
else:
139139
echo.echo_success('wrote the export archive file to {}'.format(output_file))
140140

aiida/cmdline/commands/cmd_import.py

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from enum import Enum
1313
import traceback
1414
import urllib.request
15+
1516
import click
1617

1718
from aiida.cmdline.commands.cmd_verdi import verdi
@@ -34,6 +35,45 @@ class ExtrasImportCode(Enum):
3435
ask = 'kca'
3536

3637

38+
def _echo_error( # pylint: disable=unused-argument
39+
message, non_interactive, more_archives, raised_exception, **kwargs
40+
):
41+
"""Utility function to help write an error message for ``verdi import``
42+
43+
:param message: Message following red-colored, bold "Error:".
44+
:type message: str
45+
:param non_interactive: Whether or not the user should be asked for input for any reason.
46+
:type non_interactive: bool
47+
:param more_archives: Whether or not there are more archives to import.
48+
:type more_archives: bool
49+
:param raised_exception: Exception raised during error.
50+
:type raised_exception: `Exception`
51+
"""
52+
from aiida.tools.importexport import close_progress_bar, IMPORT_LOGGER
53+
54+
# Close progress bar, if it exists
55+
close_progress_bar(leave=False)
56+
57+
IMPORT_LOGGER.debug('%s', traceback.format_exc())
58+
59+
exception = '{}: {}'.format(raised_exception.__class__.__name__, str(raised_exception))
60+
61+
echo.echo_error(message)
62+
echo.echo(exception)
63+
64+
if more_archives:
65+
# There are more archives to go through
66+
if non_interactive:
67+
# Continue to next archive
68+
pass
69+
else:
70+
# Ask if one should continue to next archive
71+
click.confirm('Do you want to continue?', abort=True)
72+
else:
73+
# There are no more archives
74+
click.Abort()
75+
76+
3777
def _try_import(migration_performed, file_to_import, archive, group, migration, non_interactive, **kwargs):
3878
"""Utility function for `verdi import` to try to import archive
3979
@@ -66,8 +106,12 @@ def _try_import(migration_performed, file_to_import, archive, group, migration,
66106
except IncompatibleArchiveVersionError as exception:
67107
if migration_performed:
68108
# Migration has been performed, something is still wrong
69-
crit_message = '{} has been migrated, but it still cannot be imported.\n{}'.format(archive, exception)
70-
echo.echo_critical(crit_message)
109+
_echo_error(
110+
'{} has been migrated, but it still cannot be imported'.format(archive),
111+
non_interactive=non_interactive,
112+
raised_exception=exception,
113+
**kwargs
114+
)
71115
else:
72116
# Migration has not yet been tried.
73117
if migration:
@@ -85,18 +129,20 @@ def _try_import(migration_performed, file_to_import, archive, group, migration,
85129
else:
86130
# Abort
87131
echo.echo_critical(str(exception))
88-
except Exception:
89-
echo.echo_error('an exception occurred while importing the archive {}'.format(archive))
90-
echo.echo(traceback.format_exc())
91-
if not non_interactive:
92-
click.confirm('do you want to continue?', abort=True)
132+
except Exception as exception:
133+
_echo_error(
134+
'an exception occurred while importing the archive {}'.format(archive),
135+
non_interactive=non_interactive,
136+
raised_exception=exception,
137+
**kwargs
138+
)
93139
else:
94140
echo.echo_success('imported archive {}'.format(archive))
95141

96142
return migrate_archive
97143

98144

99-
def _migrate_archive(ctx, temp_folder, file_to_import, archive, non_interactive, **kwargs): # pylint: disable=unused-argument
145+
def _migrate_archive(ctx, temp_folder, file_to_import, archive, non_interactive, more_archives, silent, **kwargs): # pylint: disable=unused-argument
100146
"""Utility function for `verdi import` to migrate archive
101147
Invoke click command `verdi export migrate`, passing in the archive,
102148
outputting the migrated archive in a temporary SandboxFolder.
@@ -107,6 +153,8 @@ def _migrate_archive(ctx, temp_folder, file_to_import, archive, non_interactive,
107153
:param file_to_import: Absolute path, including filename, of file to be migrated.
108154
:param archive: Filename of archive to be migrated, and later attempted imported.
109155
:param non_interactive: Whether or not the user should be asked for input for any reason.
156+
:param more_archives: Whether or not there are more archives to be imported.
157+
:param silent: Suppress console messages.
110158
:return: Absolute path to migrated archive within SandboxFolder.
111159
"""
112160
from aiida.cmdline.commands.cmd_export import migrate
@@ -120,18 +168,19 @@ def _migrate_archive(ctx, temp_folder, file_to_import, archive, non_interactive,
120168
# Migration
121169
try:
122170
ctx.invoke(
123-
migrate, input_file=file_to_import, output_file=temp_folder.get_abs_path(temp_out_file), silent=False
171+
migrate, input_file=file_to_import, output_file=temp_folder.get_abs_path(temp_out_file), silent=silent
124172
)
125-
except Exception:
126-
echo.echo_error(
173+
except Exception as exception:
174+
_echo_error(
127175
'an exception occurred while migrating the archive {}.\n'
128-
"Use 'verdi export migrate' to update this export file.".format(archive)
176+
"Use 'verdi export migrate' to update this export file.".format(archive),
177+
non_interactive=non_interactive,
178+
more_archives=more_archives,
179+
raised_exception=exception
129180
)
130-
echo.echo(traceback.format_exc())
131-
if not non_interactive:
132-
click.confirm('do you want to continue?', abort=True)
133181
else:
134-
echo.echo_success('archive migrated, proceeding with import')
182+
# Success
183+
echo.echo_info('proceeding with import')
135184

136185
return temp_folder.get_abs_path(temp_out_file)
137186

@@ -197,7 +246,6 @@ def cmd_import(
197246
198247
The archive can be specified by its relative or absolute file path, or its HTTP URL.
199248
"""
200-
201249
from aiida.common.folders import SandboxFolder
202250
from aiida.tools.importexport.common.utils import get_valid_import_links
203251

@@ -217,11 +265,13 @@ def cmd_import(
217265
try:
218266
echo.echo_info('retrieving archive URLS from {}'.format(webpage))
219267
urls = get_valid_import_links(webpage)
220-
except Exception:
221-
echo.echo_error('an exception occurred while trying to discover archives at URL {}'.format(webpage))
222-
echo.echo(traceback.format_exc())
223-
if not non_interactive:
224-
click.confirm('do you want to continue?', abort=True)
268+
except Exception as exception:
269+
_echo_error(
270+
'an exception occurred while trying to discover archives at URL {}'.format(webpage),
271+
non_interactive=non_interactive,
272+
more_archives=webpage != webpages[-1] or archives_file or archives_url,
273+
raised_exception=exception
274+
)
225275
else:
226276
echo.echo_success('{} archive URLs discovered and added'.format(len(urls)))
227277
archives_url += urls
@@ -239,7 +289,8 @@ def cmd_import(
239289
'extras_mode_existing': ExtrasImportCode[extras_mode_existing].value,
240290
'extras_mode_new': extras_mode_new,
241291
'comment_mode': comment_mode,
242-
'non_interactive': non_interactive
292+
'non_interactive': non_interactive,
293+
'silent': False,
243294
}
244295

245296
# Import local archives
@@ -250,6 +301,7 @@ def cmd_import(
250301
# Initialization
251302
import_opts['archive'] = archive
252303
import_opts['file_to_import'] = import_opts['archive']
304+
import_opts['more_archives'] = archive != archives_file[-1] or archives_url
253305

254306
# First attempt to import archive
255307
migrate_archive = _try_import(migration_performed=False, **import_opts)
@@ -265,13 +317,14 @@ def cmd_import(
265317

266318
# Initialization
267319
import_opts['archive'] = archive
320+
import_opts['more_archives'] = archive != archives_url[-1]
268321

269322
echo.echo_info('downloading archive {}'.format(archive))
270323

271324
try:
272325
response = urllib.request.urlopen(archive)
273326
except Exception as exception:
274-
echo.echo_warning('downloading archive {} failed: {}'.format(archive, exception))
327+
_echo_error('downloading archive {} failed'.format(archive), raised_exception=exception, **import_opts)
275328

276329
with SandboxFolder() as temp_folder:
277330
temp_file = 'importfile.tar.gz'

aiida/cmdline/commands/cmd_restapi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import click
1717

1818
from aiida.cmdline.commands.cmd_verdi import verdi
19-
from aiida.cmdline.params.options import HOSTNAME, PORT
19+
from aiida.cmdline.params.options import HOSTNAME, PORT, DEBUG
2020
from aiida.restapi.common import config
2121

2222

@@ -30,7 +30,7 @@
3030
default=config.CLI_DEFAULTS['CONFIG_DIR'],
3131
help='Path to the configuration directory'
3232
)
33-
@click.option('--debug', 'debug', is_flag=True, default=config.APP_CONFIG['DEBUG'], help='Enable debugging')
33+
@DEBUG(default=config.APP_CONFIG['DEBUG'])
3434
@click.option(
3535
'--wsgi-profile',
3636
is_flag=True,

aiida/cmdline/params/options/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
'DESCRIPTION', 'INPUT_PLUGIN', 'CALC_JOB_STATE', 'PROCESS_STATE', 'PROCESS_LABEL', 'TYPE_STRING', 'EXIT_STATUS',
3131
'FAILED', 'LIMIT', 'PROJECT', 'ORDER_BY', 'PAST_DAYS', 'OLDER_THAN', 'ALL', 'ALL_STATES', 'ALL_USERS',
3232
'GROUP_CLEAR', 'RAW', 'HOSTNAME', 'TRANSPORT', 'SCHEDULER', 'USER', 'PORT', 'FREQUENCY', 'VERBOSE', 'TIMEOUT',
33-
'FORMULA_MODE', 'TRAJECTORY_INDEX', 'WITH_ELEMENTS', 'WITH_ELEMENTS_EXCLUSIVE'
33+
'FORMULA_MODE', 'TRAJECTORY_INDEX', 'WITH_ELEMENTS', 'WITH_ELEMENTS_EXCLUSIVE', 'DEBUG'
3434
)
3535

3636
TRAVERSAL_RULE_HELP_STRING = {
@@ -522,3 +522,7 @@ def decorator(command):
522522
DICT_KEYS = OverridableOption(
523523
'-k', '--keys', type=click.STRING, cls=MultipleValueOption, help='Filter the output by one or more keys.'
524524
)
525+
526+
DEBUG = OverridableOption(
527+
'--debug', is_flag=True, default=False, help='Show debug messages. Mostly relevant for developers.', hidden=True
528+
)

aiida/common/folders.py

Lines changed: 27 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,13 @@ def get_subfolder(self, subfolder, create=False, reset_limit=False):
8686
Return a Folder object pointing to a subfolder.
8787
8888
:param subfolder: a string with the relative path of the subfolder,
89-
relative to the absolute path of this object. Note that
90-
this may also contain '..' parts,
91-
as far as this does not go beyond the folder_limit.
89+
relative to the absolute path of this object. Note that
90+
this may also contain '..' parts,
91+
as far as this does not go beyond the folder_limit.
9292
:param create: if True, the new subfolder is created, if it does not exist.
9393
:param reset_limit: when doing ``b = a.get_subfolder('xxx', reset_limit=False)``,
94-
the limit of b will be the same limit of a.
95-
if True, the limit will be set to the boundaries of folder b.
94+
the limit of b will be the same limit of a.
95+
if True, the limit will be set to the boundaries of folder b.
9696
9797
:Returns: a Folder object pointing to the subfolder.
9898
"""
@@ -114,18 +114,16 @@ def get_subfolder(self, subfolder, create=False, reset_limit=False):
114114
return new_folder
115115

116116
def get_content_list(self, pattern='*', only_paths=True):
117-
"""
118-
Return a list of files (and subfolders) in the folder,
119-
matching a given pattern.
117+
"""Return a list of files (and subfolders) in the folder, matching a given pattern.
120118
121119
Example: If you want to exclude files starting with a dot, you can
122120
call this method with ``pattern='[!.]*'``
123121
124122
:param pattern: a pattern for the file/folder names, using Unix filename
125-
pattern matching (see Python standard module fnmatch).
126-
By default, pattern is '*', matching all files and folders.
123+
pattern matching (see Python standard module fnmatch).
124+
By default, pattern is '*', matching all files and folders.
127125
:param only_paths: if False (default), return pairs (name, is_file).
128-
if True, return only a flat list.
126+
if True, return only a flat list.
129127
130128
:Returns:
131129
a list of tuples of two elements, the first is the file name and
@@ -140,8 +138,7 @@ def get_content_list(self, pattern='*', only_paths=True):
140138
return [(fname, not os.path.isdir(os.path.join(self.abspath, fname))) for fname in file_list]
141139

142140
def create_symlink(self, src, name):
143-
"""
144-
Create a symlink inside the folder to the location 'src'.
141+
"""Create a symlink inside the folder to the location 'src'.
145142
146143
:param src: the location to which the symlink must point. Can be
147144
either a relative or an absolute path. Should, however,
@@ -155,8 +152,7 @@ def create_symlink(self, src, name):
155152
# For symlinks, permissions should not be set
156153

157154
def insert_path(self, src, dest_name=None, overwrite=True):
158-
"""
159-
Copy a file to the folder.
155+
"""Copy a file to the folder.
160156
161157
:param src: the source filename to copy
162158
:param dest_name: if None, the same basename of src is used. Otherwise,
@@ -236,8 +232,7 @@ def create_file_from_filelike(self, filelike, filename, mode='wb', encoding=None
236232
return filepath
237233

238234
def remove_path(self, filename):
239-
"""
240-
Remove a file or folder from the folder.
235+
"""Remove a file or folder from the folder.
241236
242237
:param filename: the relative path name to remove
243238
"""
@@ -251,8 +246,7 @@ def remove_path(self, filename):
251246
os.remove(dest_abs_path)
252247

253248
def get_abs_path(self, relpath, check_existence=False):
254-
"""
255-
Return an absolute path for a file or folder in this folder.
249+
"""Return an absolute path for a file or folder in this folder.
256250
257251
The advantage of using this method is that it checks that filename
258252
is a valid filename within this folder,
@@ -352,24 +346,20 @@ def create(self):
352346
os.makedirs(self.abspath, mode=self.mode_dir)
353347

354348
def replace_with_folder(self, srcdir, move=False, overwrite=False):
355-
"""
356-
This routine copies or moves the source folder 'srcdir' to the local
357-
folder pointed by this Folder object.
349+
"""This routine copies or moves the source folder 'srcdir' to the local folder pointed to by this Folder.
358350
359-
:param srcdir: the source folder on the disk; this must be a string with
360-
an absolute path
361-
:param move: if True, the srcdir is moved to the repository. Otherwise, it
362-
is only copied.
351+
:param srcdir: the source folder on the disk; this must be an absolute path
352+
:type srcdir: str
353+
:param move: if True, the srcdir is moved to the repository. Otherwise, it is only copied.
354+
:type move: bool
363355
:param overwrite: if True, the folder will be erased first.
364-
if False, a IOError is raised if the folder already exists.
365-
Whatever the value of this flag, parent directories will be
366-
created, if needed.
356+
if False, an IOError is raised if the folder already exists.
357+
Whatever the value of this flag, parent directories will be created, if needed.
358+
:type overwrite: bool
367359
368-
:Raises:
369-
OSError or IOError: in case of problems accessing or writing
370-
the files.
371-
:Raises:
372-
ValueError: if the section is not recognized.
360+
:raises IOError: in case of problems accessing or writing the files.
361+
:raises OSError: in case of problems accessing or writing the files (from ``shutil`` module).
362+
:raises ValueError: if the section is not recognized.
373363
"""
374364
if not os.path.isabs(srcdir):
375365
raise ValueError('srcdir must be an absolute path')
@@ -390,13 +380,11 @@ def replace_with_folder(self, srcdir, move=False, overwrite=False):
390380

391381
# Set the mode also for the current dir, recursively
392382
for dirpath, _, filenames in os.walk(self.abspath, followlinks=False):
393-
# dirpath should already be absolute, because I am passing
394-
# an absolute path to os.walk
383+
# dirpath should already be absolute, because I am passing an absolute path to os.walk
395384
os.chmod(dirpath, self.mode_dir)
396385
for filename in filenames:
397-
# do not change permissions of symlinks (this would
398-
# actually change permissions of the linked file/dir)
399-
# Toc check whether this is a big speed loss
386+
# do not change permissions of symlinks (this would actually change permissions of the linked file/dir)
387+
# TODO check whether this is a big speed loss # pylint: disable=fixme
400388
full_file_path = os.path.join(dirpath, filename)
401389
if not os.path.islink(full_file_path):
402390
os.chmod(full_file_path, self.mode_file)

0 commit comments

Comments
 (0)