Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: Add the _prepare_yml method to AbstractCode #6565

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions src/aiida/cmdline/commands/cmd_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@

import click

from aiida.cmdline.commands.cmd_data.cmd_export import data_export
from aiida.cmdline.commands.cmd_verdi import verdi
from aiida.cmdline.groups.dynamic import DynamicEntryPointCommandGroup
from aiida.cmdline.params import arguments, options, types
from aiida.cmdline.params.options.commands import code as options_code
from aiida.cmdline.utils import echo, echo_tabulate
from aiida.cmdline.utils.common import generate_validate_output_file
from aiida.cmdline.utils.common import validate_output_filename
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common import exceptions

Expand Down Expand Up @@ -241,28 +242,38 @@
@options.SORT()
@with_dbenv()
def export(code, output_file, overwrite, sort):
"""Export code to a yaml file."""
"""Export code to a yaml file. If no output file is given, default name is created based on the code label."""

import yaml
other_args = {'sort': sort}

code_data = {}

for key in code.Model.model_fields.keys():
value = getattr(code, key).label if key == 'computer' else getattr(code, key)

# If the attribute is not set, for example ``with_mpi`` do not export it, because the YAML won't be valid for
# use in ``verdi code create`` since ``None`` is not a valid value on the CLI.
GeigerJ2 marked this conversation as resolved.
Show resolved Hide resolved
if value is not None:
code_data[key] = str(value)
if output_file is not None:
if not str(output_file).endswith('.yml') and not str(output_file).endswith('.yaml'):
format_str = 'Given fileformat not supported. Only .yml and .yaml supported for now.'
raise click.BadParameter(format_str, param_hint='OUTPUT_FILE')

Check warning on line 252 in src/aiida/cmdline/commands/cmd_code.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/cmdline/commands/cmd_code.py#L251-L252

Added lines #L251 - L252 were not covered by tests
fileformat = output_file.suffix[1:]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably warn (or even except) if the user specifies an output file without an extension. And the help string of the command should probably say that the format is deduced from the filename. And I think we currnetly only implement yaml for AbstractCode, correct? So it would fail for anything but .yml (what about .yaml, will that fail?). Maybe we should simply hardcode the format format, regardless of the filename specified.

Copy link
Contributor Author

@GeigerJ2 GeigerJ2 Sep 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought a bit about it and didn't find a way how one could check if the user provided a file extension in a general way, as the filename can include (arbitrarily many) periods, and, e.g., pathlib.Path().suffix also returns something if a filename without extension is given (namely the filename). So I agree that it makes sense to hard-code it, both the format check, as well as the default format, which I did now in my latest commit. Because of the reason above, I also wouldn't try to deduce it from the filename, as that seems too fragile.

Indeed, .yaml wasn't supported, but I added a small _prepare_yaml wrapper method, to make that possible, as well. I personally also prefer .yaml over .yml (and files in the aiida-code-registry all end with .yaml), so I'm inclined to set that as the default. That's different from the previous default behavior, so not sure if this is something we could just change like that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You said you removed the determination of the format but I still see:

fileformat = output_file.suffix[1:]

Now it seems that you check before that that the filename ends in either .yml or .yaml and raise otherwise, but why even do this? I would simply hardcode that you always export in YAML (pick the extension .yml or .yaml, whichever is fine) regardless of the filename, even if it contains no extension whatsoever. The extension doesn't really do anything anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeigerJ2 what about this comment?

else:
fileformat = 'yaml'
output_file = pathlib.Path(f'{code.full_label}.{fileformat}')

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

output_file.write_text(yaml.dump(code_data, sort_keys=sort))
data_export(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checked and this just catches TypeErrors being thrown from the export function. May be wise to add a catch all here and just echo_critical to prevent a nasty stack trace

node=code,
output_fname=output_file,
fileformat=fileformat,
other_args=other_args,
overwrite=overwrite,
)

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

Expand Down
14 changes: 7 additions & 7 deletions src/aiida/cmdline/commands/cmd_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from aiida.cmdline.params import arguments, options
from aiida.cmdline.params.options.commands import computer as options_computer
from aiida.cmdline.utils import echo, echo_tabulate
from aiida.cmdline.utils.common import generate_validate_output_file
from aiida.cmdline.utils.common import validate_output_filename
from aiida.cmdline.utils.decorators import with_dbenv
from aiida.common.exceptions import EntryPointError, ValidationError
from aiida.plugins.entry_point import get_entry_point_names
Expand Down Expand Up @@ -766,10 +766,10 @@ def computer_export_setup(computer, output_file, overwrite, sort):
'append_text': computer.get_append_text(),
}

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

Expand Down Expand Up @@ -804,10 +804,10 @@ def computer_export_config(computer, output_file, user, overwrite, sort):
' because computer has not been configured yet.'
)
else:
if output_file is None:
output_file = pathlib.Path(f'{computer.label}-config.yaml')
try:
output_file = generate_validate_output_file(
output_file=output_file, entity_label=computer.label, overwrite=overwrite, appendix='-config'
)
validate_output_filename(output_file=output_file, overwrite=overwrite)
except (FileExistsError, IsADirectoryError) as exception:
raise click.BadParameter(str(exception), param_hint='OUTPUT_FILE') from exception

Expand Down
14 changes: 8 additions & 6 deletions src/aiida/cmdline/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,17 @@
echo.echo(style('\nExit codes that invalidate the cache are marked in bold red.\n', italic=True))


def generate_validate_output_file(
output_file: Path | None, entity_label: str, appendix: str = '', overwrite: bool = False
def validate_output_filename(
output_file: Path | str,
overwrite: bool = False,
):
"""Generate default output filename for `Code`/`Computer` export and validate."""
"""Validate output filename."""

if output_file is None:
output_file = Path(f'{entity_label}{appendix}.yml')
raise TypeError('Output filename must be passed for validation.')

if isinstance(output_file, str):
output_file = Path(output_file)

Check warning on line 499 in src/aiida/cmdline/utils/common.py

View check run for this annotation

Codecov / codecov/patch

src/aiida/cmdline/utils/common.py#L499

Added line #L499 was not covered by tests

if output_file.is_dir():
raise IsADirectoryError(
Expand All @@ -501,5 +505,3 @@

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

return output_file
21 changes: 21 additions & 0 deletions src/aiida/orm/nodes/data/code/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,3 +381,24 @@ def get_builder(self) -> 'ProcessBuilder':
builder.code = self

return builder

def _prepare_yaml(self, *args, **kwargs):
"""Export code to a YAML file."""
import yaml

code_data = {}
sort = kwargs.get('sort', False)

for key in self.Model.model_fields.keys():
value = getattr(self, key).label if key == 'computer' else getattr(self, key)

# If the attribute is not set, for example ``with_mpi`` do not export it
# so that there are no null-values in the resulting YAML file
if value is not None:
code_data[key] = str(value)

return yaml.dump(code_data, sort_keys=sort, encoding='utf-8'), {}

def _prepare_yml(self, *args, **kwargs):
"""Also allow for export as .yml"""
return self._prepare_yaml(*args, **kwargs)
17 changes: 16 additions & 1 deletion src/aiida/orm/nodes/data/code/portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import logging
import pathlib
import typing as t

Expand All @@ -34,6 +35,7 @@
from .legacy import Code

__all__ = ('PortableCode',)
_LOGGER = logging.getLogger(__name__)


class PortableCode(Code):
Expand Down Expand Up @@ -71,7 +73,7 @@ def validate_filepath_files(cls, value: str) -> pathlib.Path:
raise ValueError(f'The filepath `{value}` is not a directory.')
return filepath

def __init__(self, filepath_executable: str, filepath_files: pathlib.Path, **kwargs):
def __init__(self, filepath_executable: str, filepath_files: pathlib.Path | str, **kwargs):
"""Construct a new instance.

.. note:: If the files necessary for this code are not all located in a single directory or the directory
Expand Down Expand Up @@ -177,3 +179,16 @@ def filepath_executable(self, value: str) -> None:
raise ValueError('The `filepath_executable` should not be absolute.')

self.base.attributes.set(self._KEY_ATTRIBUTE_FILEPATH_EXECUTABLE, value)

def _prepare_yaml(self, *args, **kwargs):
"""Export code to a YAML file."""
target_path = pathlib.Path().cwd() / f'portablecode-{self.pk}'
setattr(self, 'filepath_files', str(target_path))
self.base.repository.copy_tree(target=target_path)
_LOGGER.warning(f'Repository files for PortableCode <{self.pk}> dumped to folder `{target_path}`.')

return super()._prepare_yaml(*args, **kwargs)
Comment on lines +186 to +190
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to undo the monkeypatch after you are done. It might have some unintended consequences if we leave it there. SO something like

Suggested change
setattr(self, 'filepath_files', str(target_path))
self.base.repository.copy_tree(target=target_path)
_LOGGER.warning(f'Repository files for PortableCode <{self.pk}> dumped to folder `{target_path}`.')
return super()._prepare_yaml(*args, **kwargs)
try:
setattr(self, 'filepath_files', str(target_path))
self.base.repository.copy_tree(target=target_path)
_LOGGER.warning(f'Repository files for PortableCode <{self.pk}> dumped to folder `{target_path}`.')
result = super()._prepare_yaml(*args, **kwargs)
finally:
try:
delattr(self, 'filepath_files')
except AttributeError:
pass
return result


def _prepare_yml(self, *args, **kwargs):
"""Also allow for export as .yml"""
return self._prepare_yaml(*args, **kwargs)
Comment on lines +192 to +194
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to override this once again. It is identical to that of the base clas right?

4 changes: 2 additions & 2 deletions tests/cmdline/commands/test_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ def test_code_export_default_filename(run_cli_command, aiida_code_installed):
options = [str(code.pk)]
run_cli_command(cmd_code.export, options)

assert pathlib.Path('code@localhost.yml').is_file()
assert pathlib.Path('code@localhost.yaml').is_file()


@pytest.mark.parametrize('non_interactive_editor', ('vim -cwq',), indirect=True)
Expand Down Expand Up @@ -464,7 +464,7 @@ def test_code_setup_local_duplicate_full_label_interactive(run_cli_command, non_
def test_code_setup_local_duplicate_full_label_non_interactive(run_cli_command):
"""Test ``verdi code setup`` for a local code in non-interactive mode specifying an existing full label."""
label = 'some-label'
code = PortableCode(filepath_executable='bash', filepath_files=pathlib.Path('/bin/bash'))
code = PortableCode(filepath_executable='bash', filepath_files=pathlib.Path('/bin/'))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't this. This will end up copying the entire bin directory to the node repo. Depending on how big it is, this can be problematic. Best to make a temporary directory and touch a bash file in there.

code.label = label
code.base.repository.put_object_from_filelike(io.BytesIO(b''), 'bash')
code.store()
Expand Down
18 changes: 9 additions & 9 deletions tests/cmdline/commands/test_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ def test_computer_export_setup(self, tmp_path, file_regression, sort_option):
comp = self.comp_builder.new()
comp.store()

exported_setup_filename = tmp_path / 'computer-setup.yml'
exported_setup_filename = tmp_path / 'computer-setup.yaml'

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

# file regresssion check
content = exported_setup_filename.read_text()
file_regression.check(content, extension='.yml')
file_regression.check(content, extension='.yaml')

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

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

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

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

exported_config_filename = tmp_path / 'computer-configure.yml'
exported_config_filename = tmp_path / 'computer-configure.yaml'

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

# verifying correctness by comparing internal and loaded yml object
# verifying correctness by comparing internal and loaded yaml object
configure_config_data = yaml.safe_load(exported_config_filename.read_text())
assert (
configure_config_data == comp.get_configuration()
Expand Down Expand Up @@ -641,7 +641,7 @@ def test_computer_export_config_overwrite(self, tmp_path):
comp.store()
comp.configure(safe_interval=0.0)

exported_config_filename = tmp_path / 'computer-configure.yml'
exported_config_filename = tmp_path / 'computer-configure.yaml'

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

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

self.cli_runner(computer_export_config, [comp.label])
assert pathlib.Path(exported_config_filename).is_file()
Expand Down
31 changes: 13 additions & 18 deletions tests/cmdline/utils/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import pytest
from aiida.cmdline.utils import common
from aiida.cmdline.utils.common import generate_validate_output_file
from aiida.cmdline.utils.common import validate_output_filename
from aiida.common import LinkType
from aiida.engine import Process, calcfunction
from aiida.orm import CalcFunctionNode, CalculationNode, WorkflowNode
Expand Down Expand Up @@ -95,35 +95,30 @@ def test_with_docstring():


@pytest.mark.usefixtures('chdir_tmp_path')
def test_generate_validate_output():
def test_validate_output_filename():
test_entity_label = 'test_code'
test_appendix = '@test_computer'
fileformat = 'yaml'

expected_output_file = Path(f'{test_entity_label}{test_appendix}.yml')
expected_output_file = Path(f'{test_entity_label}{test_appendix}.{fileformat}')

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

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

# Test that overwrite does the job
obtained_output_file = generate_validate_output_file(
output_file=None, entity_label=test_entity_label, appendix=test_appendix, overwrite=True
)
assert expected_output_file == obtained_output_file, 'Overwrite unsuccessful'
# Test that overwrite does the job -> No exception raised
validate_output_filename(output_file=expected_output_file, overwrite=True)
expected_output_file.unlink()

# Test failure if directory exists
expected_output_file.mkdir()
with pytest.raises(IsADirectoryError, match='A directory with the name.*'):
generate_validate_output_file(
output_file=None, entity_label=test_entity_label, appendix=test_appendix, overwrite=False
validate_output_filename(
output_file=expected_output_file,
overwrite=False,
)
17 changes: 17 additions & 0 deletions tests/orm/data/code/test_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,20 @@ def test_get_execname(tmp_path):
code = PortableCode(label='some-label', filepath_executable='bash', filepath_files=tmp_path)
with pytest.warns(AiidaDeprecationWarning):
assert code.get_execname() == 'bash'


def test_portablecode_repo_dump(tmp_path, chdir_tmp_path):
"""Test that the node repository contents of an orm.PortableCode are dumped upon YAML export."""
filepath_files = tmp_path / 'tmp'
filepath_files.mkdir()
(filepath_files / 'bash').touch()
(filepath_files / 'subdir').mkdir()
(filepath_files / 'subdir/test').touch()
code = PortableCode(label='some-label', filepath_executable='bash', filepath_files=filepath_files)
code.store()
chdir_tmp_path
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no-op? Are you under the impression that the chdir_tmnp_path fixture only actually changes dir when evaluated? I am pretty sure it does it automatically at the start of the test. So don't think this does anything

code._prepare_yaml()
repo_dump_path = tmp_path / f'portablecode-{code.pk}'
assert repo_dump_path.is_dir()
assert (repo_dump_path / 'bash').is_file()
assert (repo_dump_path / 'subdir/test').is_file()
Loading
Loading