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

Read target connection settings from a YAML file #670

Merged
merged 3 commits into from
Feb 23, 2024

Conversation

@metin-arm metin-arm changed the title Clean yaml Read target connection settings from a YAML file Feb 5, 2024
@metin-arm metin-arm force-pushed the clean-yaml branch 3 times, most recently from 2096387 to cd7bea9 Compare February 5, 2024 14:45
@@ -37,6 +37,7 @@
import types
import warnings
import wrapt
import yaml
Copy link
Collaborator

Choose a reason for hiding this comment

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

I can't remember the specifics but there is a fundamental design issue with that library. I used ruamel.yaml in Lisa instead. That might have to do with a mutable singleton used in the lib for something central that could conflict between different users in the same process.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I remember now, it's this pattern that is bonkers:

import yaml
yaml.add_constructor()

So any other dependency can decide to change the behavior of pyyaml and everyone in the same process will get the change of behavior, devlib included. That may come from the underlying C library that this wraps but this is the best recipe for unsolvable issues.

Ruamel.yaml added a way to get some sort of context object and custom constructors are only registered against it. Every user has their own instance and no-one is stepping on each other's toes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the hint. Move from yaml to ruamel.

Copy link
Collaborator

@douglas-raillard-arm douglas-raillard-arm left a comment

Choose a reason for hiding this comment

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

That's not a deep review on the added code, just superficial comments

@@ -590,6 +591,31 @@ def __str__(self):
return message.format(self.filepath, self.lineno, self.message)


def load_struct_from_yaml(filepath):
Copy link
Collaborator

Choose a reason for hiding this comment

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

For now we don't want the testing infrastructure to be a public API, so this needs a non-public name starting with a single underscore (https://peps.python.org/pep-0008/)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

load_struct_from_yaml() is not for testing purposes only.
It's just moved from WA to devlib: ARM-software/workload-automation#1248

Comment on lines 599 to 606
Args:
filepath (str): Input file which contains YAML data.

Raises:
LoadSyntaxError: if there is a syntax error in YAML data.

Returns:
dict: which contains parsed YAML data.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would recommend using the autodoc style as this is the most native one with Sphinx (you can see what we use in LISA)

except yaml.YAMLError as ex:
message = ex.message if hasattr(ex, 'message') else ''
lineno = ex.problem_mark.line if hasattr(ex, 'problem_mark') else None
raise LoadSyntaxError(message, filepath=filepath, lineno=lineno) from ex
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure what LoadSyntaxError was initially for. Currently it seems like dead code in devlib. Unless it really adds something, I'd recommend just letting the YAMLError bubble up.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

LoadSyntaxError() is referenced in load_struct_from_python(), too.
I don't know the history, but apparently it makes the syntax error location more explicit, IMHO.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We use this in WA to try and provide a more friendly error message to clearly indicate the location of the syntax error.
Since this code is being moved, I'd vote for keeping the same error class.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sounds good, I did not look beyond devlib codebase

@@ -14,17 +14,30 @@
# limitations under the License.
#

''' Module for testing targets. '''
Copy link
Collaborator

Choose a reason for hiding this comment

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

Docstrings should generally be trimmed, no space at the beginning or the end (unless it's needed for indentation after a newline)



class TestReadTreeValues(TestCase):
''' Class testing Target.read_tree_values_flat() '''

TARGETS_CONFIG_FILE = 'tests/target_configs.yaml'
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does pytest guarantees the current folder when running tests ? If not this could be made relative to __file__'s folder for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I run tests under /devlib dir. Will use __file__'s folder to ensure that.


def test_read_multiline_values(self):
''' Test Target.read_tree_values_flat() '''
Copy link
Collaborator

Choose a reason for hiding this comment

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

s/^ // and s/ $//

@@ -34,11 +47,13 @@ def test_read_multiline_values(self):
tempdir = tempfile.mkdtemp(prefix='devlib-test-')
Copy link
Collaborator

Choose a reason for hiding this comment

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

should use context manager classes available in tempfile

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't have a change here in this PR.
79a91b7 uses context manager anyway.

@@ -294,6 +294,10 @@ def __init__(
total_transfer_timeout=total_transfer_timeout,
transfer_poll_period=transfer_poll_period,
)

self.logger.debug('AdbConnection(): server=%s port=%s device=%s as_root=%s',
Copy link
Collaborator

Choose a reason for hiding this comment

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

s/AdbConnection(): //

The name of the class should be used when the logger is made.

@@ -294,6 +294,10 @@ def __init__(
total_transfer_timeout=total_transfer_timeout,
transfer_poll_period=transfer_poll_period,
)

self.logger.debug('AdbConnection(): server=%s port=%s device=%s as_root=%s',
Copy link
Collaborator

Choose a reason for hiding this comment

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

There is a slight technical benefit of using this way of interpolating (i.e. avoiding interpolation unless that debug level is enabled) but it's negligible and mostly just increases the risk of mixup. You can use an f-string instead to avoid that, and even let the compiler avoid redundancy with e.g. {adb_server=}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think pylint recommended using lazy formatting instead of f-string.

While we are there, also fix a trivial pylint issue regarding string
format.

Signed-off-by: Metin Kaya <[email protected]>
@metin-arm metin-arm force-pushed the clean-yaml branch 2 times, most recently from b9ffd3f to 49f1b39 Compare February 20, 2024 08:43
This is copied from WA (workload-automation/wa/utils/misc.py).
Hence, published another PR [1] removes the implementation from WA.

OTOH, this patch uses ``ruamel`` instead of ``yaml`` because of the
latter's design issues.

And also this patch fixes pylint issues in ``load_struct_from_yaml()``.

[1] ARM-software/workload-automation#1248

Signed-off-by: Metin Kaya <[email protected]>
except yaml.YAMLError as ex:
message = ex.message if hasattr(ex, 'message') else ''
lineno = ex.problem_mark.line if hasattr(ex, 'problem_mark') else None
raise LoadSyntaxError(message, filepath=filepath, lineno=lineno) from ex
Copy link
Collaborator

Choose a reason for hiding this comment

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

We use this in WA to try and provide a more friendly error message to clearly indicate the location of the syntax error.
Since this code is being moved, I'd vote for keeping the same error class.



class TestReadTreeValues(TestCase):
"""Class testing Target.read_tree_values_flat()"""

TARGETS_CONFIG_FILE = os.path.join(os.path.dirname(os.path.realpath(__file__)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

If the intention is to be able to perform these tests on different device types based on the config, do we want the parsing / initialization to be performed within each test case?
Should these actions be performed in some setup method instead that can be reused?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The final product is something like this: a7d0b9d
Yes, there are different target types and there can be multiple entries for a target.
I read config file first and see which targets need to be tested. So, I could keep target classes as is.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hmm.. I see. Apologies in advanced if I'm missing the obvious but in the final product do we end up with this code just for a single test_read_multiline_values unittest?
Does this mean this will need to be duplicated when we add another test or test Class?

Also does this require setting up a new connection to the target for each test? I'm wondering what the overhead/performance would be on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is only one test_read_multiline_values() function. It parses the YAML file and executes the test specified in run_test() for each target in the YAML file. YAML file has necessary connection information and QEMU params for each target. Depending on the target type, running run_test() can take ~1 (LocalLinux target) or ~15 secs (for QEMU target).

Final version of TestReadTreeValues() looks like this:

class TestReadTreeValues(TestCase):
    def test_read_multiline_values(self):
        def run_test(target):
            ...

        target_configs = load_struct_from_yaml(self.TARGETS_CONFIG_FILE)
        ...

        if target_configs.get('AndroidTarget') is not None:
            ...
                target = AndroidTarget(
                    connection_settings=entry['connection_settings'],
                    conn_cls=lambda **kwargs: AdbConnection(adb_as_root=True, **kwargs),
                )
                run_test(target)

        if target_configs.get('LinuxTarget') is not None:
            ...
                target = LinuxTarget(connection_settings=entry['connection_settings'])
                run_test(target)

        if target_configs.get('LocalLinuxTarget') is not None:
            ...
                target = LocalLinuxTarget(connection_settings=entry['connection_settings'])
                run_test(target)

        if target_configs.get('QEMUTargetRunner') is not None:
           ...
                with QEMUTargetRunner(
                        qemu_params=qemu_params,
                        connection_settings=connection_settings,
                ) as qemu_target:
                    run_test(qemu_target.target)

And this is a sample YAML file:

AndroidTarget:
  entry-0:
    connection_settings:
      device: 'emulator-5554'
  entry-1:
    connection_settings:
      device: 'emulator-5556'

LinuxTarget:
  entry-0:
    connection_settings:
      host: 'example.com'
      username: 'username'
      password: 'password'

LocalLinuxTarget:
  entry-0:
    connection_settings:
      unrooted: True

QEMUTargetRunner:
  entry-0:
    qemu_params:
      kernel_image: '/devlib/tools/buildroot/buildroot-v2023.11.1-aarch64/output/images/Image'

  entry-1:
    qemu_params:
      kernel_image: '/devlib/tools/buildroot/buildroot-v2023.11.1-x86_64/output/images/bzImage'
      arch: 'x86_64'
      cmdline: 'console=ttyS0'

Copy link
Collaborator

Choose a reason for hiding this comment

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

I see, thanks for the information.

Although we have only have a single test case test_read_multiline_values at the moment, if we have this nice infrastructure to be able to run these tests and have more meaningful output, I'm thinking if we were expecting to add more test cases going forward, how would that fit into the current implementation? For example if we wanted to add a test_read_escape_values test case that checks certain characters are escaped correctly.

The parsing / creation of the various targets looks like it should be mostly generic and not tied to this single test case, therefore I'm wondering if there is a common location this would be better suited to be shared?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That totally makes sense to me.

How about the new version? If it looks OK, I'm gonna update other PRs.
Moving to pytest is the simplest solution I could find to prevent duplicate target config reading, passing target parameter to the test function (as well as future ones).

Thanks.

@metin-arm metin-arm force-pushed the clean-yaml branch 4 times, most recently from aefcf76 to fc0c531 Compare February 23, 2024 14:44
This will be useful in automating CI tests without modifying the source
code.

Replace unittest with pytest in order to make parameter passing to test
functions easier.

Move target configuration reading and generating target object outside
of the test function. Because we will run the test function for new
targets and may want to add new test functions.

While we are here, also fix pylint issues.

Signed-off-by: Metin Kaya <[email protected]>
@marcbonnici marcbonnici merged commit a1718c3 into ARM-software:master Feb 23, 2024
@metin-arm metin-arm deleted the clean-yaml branch March 22, 2024 09:13
path = os.path.join(tempdir, key)
with open(path, 'w') as wfh:
wfh.write(value)
tempdir = tempfile.mkdtemp(prefix='devlib-test-')
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should use tempfile.TemporaryDirectory, as a context manager.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Latest version of this code:

    with target.make_temp() as tempdir:
        ...
        raw_result = target.read_tree_values_flat(tempdir)
        result = {os.path.basename(k): v for k, v in raw_result.items()}

And this is Target()::make_temp() implementation (295f126):

    @contextmanager
    def make_temp(self, is_directory=True, directory='', prefix='devlib-test'):
        ...
        try:
            cmd = f'mktemp -p {directory} {prefix}-XXXXXX'
            if is_directory:
                cmd += ' -d'

            temp_obj = self.execute(cmd).strip()
            yield temp_obj
        finally:
            if temp_obj is not None:
                self.remove(temp_obj)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants