Skip to content

Commit 98ffc33

Browse files
authored
Refactor: Add the _prepare_yaml method to AbstractCode (aiidateam#6565)
As specified in the docs, the `Data` class implements an `export()` method. For actually exporting `Data` nodes, subclasses should implement a `_prepare_XXX` method, where `XXX` is the desired export file format. When running `verdi data <orm-data-type> export`, the available data formats for exporting are dynamically determined based on the implemented `_prepare_XXX` methods. The `Code` classes didn't follow this specification (likely because `Code` wasn't historically a subclass of `Data`), but instead a custom implementation was used for `verdi code export` in `cmd_code.py`. With the goal of increasing consistency, this PR moves the code of this custom implementation to the new `_prepare_yaml` method of the `AbstractCode` class (`_prepare_yml` is also added, as the previous default file extension was `.yml`, but it basically just calls `prepare_yaml`). The `export` function in `cmd_code.py` now instead calls the `data_export` function, as is done when exporting other classes derived from `Data`. Thus, exporting a `Code` via the CLI through `verdi code export` remains unchanged. Lastly, for the `PortableCode` class, the `prepare_yaml` method is overridden to temporarily attach the `filepath_files` attribute to the instance, as this field is defined via the `pydantic` model, but not actually saved as an attribute (instead, the contents of the given folder are added to the `NodeRepository` via `put_object_from_tree`). Without temporarily attaching the attribute, this would otherwise lead to an exception in `test_data.py` which tests the exporting of the derived `Data` nodes, as the `filepath_files` field is tried to be accessed from the instance via `getattr`. In addition, the files contained in the `NodeRepository` of the `PortableCode` are also dumped to disk, such that upon exporting the code configuration to a YAML, the `PortableCode` _can_ actually be fully re-created from this file and the additional directory.
1 parent c7c289d commit 98ffc33

File tree

12 files changed

+177
-60
lines changed

12 files changed

+177
-60
lines changed

src/aiida/cmdline/commands/cmd_code.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414

1515
import click
1616

17+
from aiida.cmdline.commands.cmd_data.cmd_export import data_export
1718
from aiida.cmdline.commands.cmd_verdi import verdi
1819
from aiida.cmdline.groups.dynamic import DynamicEntryPointCommandGroup
1920
from aiida.cmdline.params import arguments, options, types
2021
from aiida.cmdline.params.options.commands import code as options_code
2122
from aiida.cmdline.utils import echo, echo_tabulate
22-
from aiida.cmdline.utils.common import generate_validate_output_file
23+
from aiida.cmdline.utils.common import validate_output_filename
2324
from aiida.cmdline.utils.decorators import with_dbenv
2425
from aiida.common import exceptions
2526

@@ -241,28 +242,37 @@ def show(code):
241242
@options.SORT()
242243
@with_dbenv()
243244
def export(code, output_file, overwrite, sort):
244-
"""Export code to a yaml file."""
245+
"""Export code to a yaml file. If no output file is given, default name is created based on the code label."""
245246

246-
import yaml
247+
other_args = {'sort': sort}
247248

248-
code_data = {}
249+
fileformat = 'yaml'
249250

250-
for key in code.Model.model_fields.keys():
251-
value = getattr(code, key).label if key == 'computer' else getattr(code, key)
252-
253-
# If the attribute is not set, for example ``with_mpi`` do not export it, because the YAML won't be valid for
254-
# use in ``verdi code create`` since ``None`` is not a valid value on the CLI.
255-
if value is not None:
256-
code_data[key] = str(value)
251+
if output_file is None:
252+
output_file = pathlib.Path(f'{code.full_label}.{fileformat}')
257253

258254
try:
259-
output_file = generate_validate_output_file(
260-
output_file=output_file, entity_label=code.label, overwrite=overwrite, appendix=f'@{code_data["computer"]}'
255+
# In principle, output file validation is also done in the `data_export` function. However, the
256+
# `validate_output_filename` function is applied here, as well, as it is also used in the `Computer` export, and
257+
# as `Computer` is not derived from `Data`, it cannot be exported by `data_export`, so
258+
# `validate_output_filename` cannot be removed in favor of the validation done in `data_export`.
259+
validate_output_filename(
260+
output_file=output_file,
261+
overwrite=overwrite,
261262
)
262263
except (FileExistsError, IsADirectoryError) as exception:
263264
raise click.BadParameter(str(exception), param_hint='OUTPUT_FILE') from exception
264265

265-
output_file.write_text(yaml.dump(code_data, sort_keys=sort))
266+
try:
267+
data_export(
268+
node=code,
269+
output_fname=output_file,
270+
fileformat=fileformat,
271+
other_args=other_args,
272+
overwrite=overwrite,
273+
)
274+
except Exception as exception:
275+
echo.echo_critical(f'Error in the `data_export` function: {exception}')
266276

267277
echo.echo_success(f'Code<{code.pk}> {code.label} exported to file `{output_file}`.')
268278

src/aiida/cmdline/commands/cmd_computer.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from aiida.cmdline.params import arguments, options
2121
from aiida.cmdline.params.options.commands import computer as options_computer
2222
from aiida.cmdline.utils import echo, echo_tabulate
23-
from aiida.cmdline.utils.common import generate_validate_output_file
23+
from aiida.cmdline.utils.common import validate_output_filename
2424
from aiida.cmdline.utils.decorators import with_dbenv
2525
from aiida.common.exceptions import EntryPointError, ValidationError
2626
from aiida.plugins.entry_point import get_entry_point_names
@@ -766,10 +766,10 @@ def computer_export_setup(computer, output_file, overwrite, sort):
766766
'append_text': computer.get_append_text(),
767767
}
768768

769+
if output_file is None:
770+
output_file = pathlib.Path(f'{computer.label}-setup.yaml')
769771
try:
770-
output_file = generate_validate_output_file(
771-
output_file=output_file, entity_label=computer.label, overwrite=overwrite, appendix='-setup'
772-
)
772+
validate_output_filename(output_file=output_file, overwrite=overwrite)
773773
except (FileExistsError, IsADirectoryError) as exception:
774774
raise click.BadParameter(str(exception), param_hint='OUTPUT_FILE') from exception
775775

@@ -804,10 +804,10 @@ def computer_export_config(computer, output_file, user, overwrite, sort):
804804
' because computer has not been configured yet.'
805805
)
806806
else:
807+
if output_file is None:
808+
output_file = pathlib.Path(f'{computer.label}-config.yaml')
807809
try:
808-
output_file = generate_validate_output_file(
809-
output_file=output_file, entity_label=computer.label, overwrite=overwrite, appendix='-config'
810-
)
810+
validate_output_filename(output_file=output_file, overwrite=overwrite)
811811
except (FileExistsError, IsADirectoryError) as exception:
812812
raise click.BadParameter(str(exception), param_hint='OUTPUT_FILE') from exception
813813

src/aiida/cmdline/utils/common.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -486,13 +486,17 @@ def build_entries(ports):
486486
echo.echo(style('\nExit codes that invalidate the cache are marked in bold red.\n', italic=True))
487487

488488

489-
def generate_validate_output_file(
490-
output_file: Path | None, entity_label: str, appendix: str = '', overwrite: bool = False
489+
def validate_output_filename(
490+
output_file: Path | str,
491+
overwrite: bool = False,
491492
):
492-
"""Generate default output filename for `Code`/`Computer` export and validate."""
493+
"""Validate output filename."""
493494

494495
if output_file is None:
495-
output_file = Path(f'{entity_label}{appendix}.yml')
496+
raise TypeError('Output filename must be passed for validation.')
497+
498+
if isinstance(output_file, str):
499+
output_file = Path(output_file)
496500

497501
if output_file.is_dir():
498502
raise IsADirectoryError(
@@ -501,5 +505,3 @@ def generate_validate_output_file(
501505

502506
if output_file.is_file() and not overwrite:
503507
raise FileExistsError(f'File `{output_file}` already exists, use `--overwrite` to overwrite.')
504-
505-
return output_file

src/aiida/orm/nodes/data/code/abstract.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,24 @@ def get_builder(self) -> 'ProcessBuilder':
381381
builder.code = self
382382

383383
return builder
384+
385+
def _prepare_yaml(self, *args, **kwargs):
386+
"""Export code to a YAML file."""
387+
import yaml
388+
389+
code_data = {}
390+
sort = kwargs.get('sort', False)
391+
392+
for key in self.Model.model_fields.keys():
393+
value = getattr(self, key).label if key == 'computer' else getattr(self, key)
394+
395+
# If the attribute is not set, for example ``with_mpi`` do not export it
396+
# so that there are no null-values in the resulting YAML file
397+
if value is not None:
398+
code_data[key] = str(value)
399+
400+
return yaml.dump(code_data, sort_keys=sort, encoding='utf-8'), {}
401+
402+
def _prepare_yml(self, *args, **kwargs):
403+
"""Also allow for export as .yml"""
404+
return self._prepare_yaml(*args, **kwargs)

src/aiida/orm/nodes/data/code/portable.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
from __future__ import annotations
2121

22+
import contextlib
23+
import logging
2224
import pathlib
2325
import typing as t
2426

@@ -34,6 +36,7 @@
3436
from .legacy import Code
3537

3638
__all__ = ('PortableCode',)
39+
_LOGGER = logging.getLogger(__name__)
3740

3841

3942
class PortableCode(Code):
@@ -71,7 +74,7 @@ def validate_filepath_files(cls, value: str) -> pathlib.Path:
7174
raise ValueError(f'The filepath `{value}` is not a directory.')
7275
return filepath
7376

74-
def __init__(self, filepath_executable: str, filepath_files: pathlib.Path, **kwargs):
77+
def __init__(self, filepath_executable: str, filepath_files: pathlib.Path | str, **kwargs):
7578
"""Construct a new instance.
7679
7780
.. note:: If the files necessary for this code are not all located in a single directory or the directory
@@ -177,3 +180,33 @@ def filepath_executable(self, value: str) -> None:
177180
raise ValueError('The `filepath_executable` should not be absolute.')
178181

179182
self.base.attributes.set(self._KEY_ATTRIBUTE_FILEPATH_EXECUTABLE, value)
183+
184+
def _prepare_yaml(self, *args, **kwargs):
185+
"""Export code to a YAML file."""
186+
try:
187+
target = pathlib.Path().cwd() / f'{self.label}'
188+
setattr(self, 'filepath_files', str(target))
189+
result = super()._prepare_yaml(*args, **kwargs)[0]
190+
191+
extra_files = {}
192+
node_repository = self.base.repository
193+
194+
# Logic taken from `copy_tree` method of the `Repository` class and adapted to return
195+
# the relative file paths and their utf-8 encoded content as `extra_files` dictionary
196+
path = '.'
197+
for root, dirnames, filenames in node_repository.walk():
198+
for filename in filenames:
199+
rel_output_file_path = root.relative_to(path) / filename
200+
full_output_file_path = target / rel_output_file_path
201+
full_output_file_path.parent.mkdir(exist_ok=True, parents=True)
202+
203+
extra_files[str(full_output_file_path)] = node_repository.get_object_content(
204+
str(rel_output_file_path), mode='rb'
205+
)
206+
_LOGGER.warning(f'Repository files for PortableCode <{self.pk}> dumped to folder `{target}`.')
207+
208+
finally:
209+
with contextlib.suppress(AttributeError):
210+
delattr(self, 'filepath_files')
211+
212+
return result, extra_files

tests/cmdline/commands/test_code.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def test_code_export_default_filename(run_cli_command, aiida_code_installed):
345345
options = [str(code.pk)]
346346
run_cli_command(cmd_code.export, options)
347347

348-
assert pathlib.Path('code@localhost.yml').is_file()
348+
assert pathlib.Path('code@localhost.yaml').is_file()
349349

350350

351351
@pytest.mark.parametrize('non_interactive_editor', ('vim -cwq',), indirect=True)
@@ -461,10 +461,13 @@ def test_code_setup_local_duplicate_full_label_interactive(run_cli_command, non_
461461

462462

463463
@pytest.mark.usefixtures('aiida_profile_clean')
464-
def test_code_setup_local_duplicate_full_label_non_interactive(run_cli_command):
464+
def test_code_setup_local_duplicate_full_label_non_interactive(run_cli_command, tmp_path):
465465
"""Test ``verdi code setup`` for a local code in non-interactive mode specifying an existing full label."""
466466
label = 'some-label'
467-
code = PortableCode(filepath_executable='bash', filepath_files=pathlib.Path('/bin/bash'))
467+
tmp_bin_dir = tmp_path / 'bin'
468+
tmp_bin_dir.mkdir()
469+
(tmp_bin_dir / 'bash').touch()
470+
code = PortableCode(filepath_executable='bash', filepath_files=tmp_bin_dir)
468471
code.label = label
469472
code.base.repository.put_object_from_filelike(io.BytesIO(b''), 'bash')
470473
code.store()
@@ -477,7 +480,7 @@ def test_code_setup_local_duplicate_full_label_non_interactive(run_cli_command):
477480
'-P',
478481
'core.arithmetic.add',
479482
'--store-in-db',
480-
'--code-folder=/bin',
483+
f'--code-folder={tmp_bin_dir}',
481484
'--code-rel-path=bash',
482485
'--label',
483486
label,

tests/cmdline/commands/test_computer.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,7 @@ def test_computer_export_setup(self, tmp_path, file_regression, sort_option):
525525
comp = self.comp_builder.new()
526526
comp.store()
527527

528-
exported_setup_filename = tmp_path / 'computer-setup.yml'
528+
exported_setup_filename = tmp_path / 'computer-setup.yaml'
529529

530530
# Successfull write behavior
531531
result = self.cli_runner(computer_export_setup, [comp.label, exported_setup_filename, sort_option])
@@ -534,9 +534,9 @@ def test_computer_export_setup(self, tmp_path, file_regression, sort_option):
534534

535535
# file regresssion check
536536
content = exported_setup_filename.read_text()
537-
file_regression.check(content, extension='.yml')
537+
file_regression.check(content, extension='.yaml')
538538

539-
# verifying correctness by comparing internal and loaded yml object
539+
# verifying correctness by comparing internal and loaded yaml object
540540
configure_setup_data = yaml.safe_load(exported_setup_filename.read_text())
541541
assert configure_setup_data == self.comp_builder.get_computer_spec(
542542
comp
@@ -550,7 +550,7 @@ def test_computer_export_setup_overwrite(self, tmp_path):
550550
comp = self.comp_builder.new()
551551
comp.store()
552552

553-
exported_setup_filename = tmp_path / 'computer-setup.yml'
553+
exported_setup_filename = tmp_path / 'computer-setup.yaml'
554554
# Check that export fails if the file already exists
555555
exported_setup_filename.touch()
556556
result = self.cli_runner(computer_export_setup, [comp.label, exported_setup_filename], raises=True)
@@ -581,7 +581,7 @@ def test_computer_export_setup_default_filename(self):
581581
comp = self.comp_builder.new()
582582
comp.store()
583583

584-
exported_setup_filename = f'{comp_label}-setup.yml'
584+
exported_setup_filename = f'{comp_label}-setup.yaml'
585585

586586
self.cli_runner(computer_export_setup, [comp.label])
587587
assert pathlib.Path(exported_setup_filename).is_file()
@@ -593,7 +593,7 @@ def test_computer_export_config(self, tmp_path):
593593
comp = self.comp_builder.new()
594594
comp.store()
595595

596-
exported_config_filename = tmp_path / 'computer-configure.yml'
596+
exported_config_filename = tmp_path / 'computer-configure.yaml'
597597

598598
# We have not configured the computer yet so it should exit with an critical error
599599
result = self.cli_runner(computer_export_config, [comp.label, exported_config_filename], raises=True)
@@ -613,7 +613,7 @@ def test_computer_export_config(self, tmp_path):
613613
content = exported_config_filename.read_text()
614614
assert content.startswith('safe_interval: 0.0')
615615

616-
# verifying correctness by comparing internal and loaded yml object
616+
# verifying correctness by comparing internal and loaded yaml object
617617
configure_config_data = yaml.safe_load(exported_config_filename.read_text())
618618
assert (
619619
configure_config_data == comp.get_configuration()
@@ -641,7 +641,7 @@ def test_computer_export_config_overwrite(self, tmp_path):
641641
comp.store()
642642
comp.configure(safe_interval=0.0)
643643

644-
exported_config_filename = tmp_path / 'computer-configure.yml'
644+
exported_config_filename = tmp_path / 'computer-configure.yaml'
645645

646646
# Create directory with the same name and check that command fails
647647
exported_config_filename.mkdir()
@@ -673,7 +673,7 @@ def test_computer_export_config_default_filename(self):
673673
comp.store()
674674
comp.configure(safe_interval=0.0)
675675

676-
exported_config_filename = f'{comp_label}-config.yml'
676+
exported_config_filename = f'{comp_label}-config.yaml'
677677

678678
self.cli_runner(computer_export_config, [comp.label])
679679
assert pathlib.Path(exported_config_filename).is_file()

tests/cmdline/utils/test_common.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import pytest
1414
from aiida.cmdline.utils import common
15-
from aiida.cmdline.utils.common import generate_validate_output_file
15+
from aiida.cmdline.utils.common import validate_output_filename
1616
from aiida.common import LinkType
1717
from aiida.engine import Process, calcfunction
1818
from aiida.orm import CalcFunctionNode, CalculationNode, WorkflowNode
@@ -95,35 +95,30 @@ def test_with_docstring():
9595

9696

9797
@pytest.mark.usefixtures('chdir_tmp_path')
98-
def test_generate_validate_output():
98+
def test_validate_output_filename():
9999
test_entity_label = 'test_code'
100100
test_appendix = '@test_computer'
101+
fileformat = 'yaml'
101102

102-
expected_output_file = Path(f'{test_entity_label}{test_appendix}.yml')
103+
expected_output_file = Path(f'{test_entity_label}{test_appendix}.{fileformat}')
103104

104-
# Test default label creation
105-
obtained_output_file = generate_validate_output_file(
106-
output_file=None, entity_label=test_entity_label, appendix=test_appendix
107-
)
108-
assert expected_output_file == obtained_output_file, 'Filenames differ'
105+
# Test failure if no actual file to be validated is passed
106+
with pytest.raises(TypeError, match='.*passed for validation.'):
107+
validate_output_filename(output_file=None)
109108

110109
# Test failure if file exists, but overwrite False
111110
expected_output_file.touch()
112111
with pytest.raises(FileExistsError, match='.*use `--overwrite` to overwrite.'):
113-
generate_validate_output_file(
114-
output_file=None, entity_label=test_entity_label, appendix=test_appendix, overwrite=False
115-
)
112+
validate_output_filename(output_file=expected_output_file, overwrite=False)
116113

117-
# Test that overwrite does the job
118-
obtained_output_file = generate_validate_output_file(
119-
output_file=None, entity_label=test_entity_label, appendix=test_appendix, overwrite=True
120-
)
121-
assert expected_output_file == obtained_output_file, 'Overwrite unsuccessful'
114+
# Test that overwrite does the job -> No exception raised
115+
validate_output_filename(output_file=expected_output_file, overwrite=True)
122116
expected_output_file.unlink()
123117

124118
# Test failure if directory exists
125119
expected_output_file.mkdir()
126120
with pytest.raises(IsADirectoryError, match='A directory with the name.*'):
127-
generate_validate_output_file(
128-
output_file=None, entity_label=test_entity_label, appendix=test_appendix, overwrite=False
121+
validate_output_filename(
122+
output_file=expected_output_file,
123+
overwrite=False,
129124
)

0 commit comments

Comments
 (0)