diff --git a/.github/workflows/build_test.yaml b/.github/workflows/build_test.yaml index e707e65..cb1377d 100644 --- a/.github/workflows/build_test.yaml +++ b/.github/workflows/build_test.yaml @@ -29,14 +29,21 @@ jobs: - name: Check code style run: | + python3 -m pycodestyle ./*.py python3 -m pycodestyle ./lglpy python3 -m pycodestyle ./generator - name: Check typing run: | + python3 -m mypy ./*.py python3 -m mypy ./lglpy python3 -m mypy ./generator + - name: Run unit tests + # Note: Only run tests that do not require a connected device + run: | + python3 -m lglpy.ui.test + build-ubuntu-x64-clang: name: Ubuntu x64 Clang runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index 3b14f38..a0f3c78 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,13 @@ __pycache__ # CMake build directories build* +# Data files and build outputs +*.gputl +*.log +*.so + # Build and debug output files /.cache /bin* /log* -/scratch* -/out_* /x_* - diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e549fed --- /dev/null +++ b/.pylintrc @@ -0,0 +1,645 @@ +[MAIN] + +# 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 + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# 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-allow-list= + +# 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. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked and +# will not be imported (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. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Resolve imports to .pyi stubs if available. May reduce no-member messages and +# increase not-an-iterable messages. +prefer-stubs=no + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# 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=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# 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 + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=14 + +# Maximum number of locals for function / method body. +max-locals=16 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# 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=7 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[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=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# 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 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to 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 + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# 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 re-enable 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=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero, + use-symbolic-message-instead, + duplicate-code + +# 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= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +# Let 'consider-using-join' be raised when the separator to join on would be +# non-empty (resulting in expected fixes of the type: ``"- " + " - +# ".join(items)``) +suggest-join-with-non-empty-separator=yes + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 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, +# json2 (improved json format), json (old json format) and msvs (visual +# studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[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 to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=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 symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# 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,argparse.Namespace + +# 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 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# 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. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +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,past.builtins,future.builtins,builtins,io diff --git a/generator/generate_vulkan_common.py b/generator/generate_vulkan_common.py old mode 100644 new mode 100755 index 07d6976..9bf554e --- a/generator/generate_vulkan_common.py +++ b/generator/generate_vulkan_common.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to diff --git a/generator/generate_vulkan_layer.py b/generator/generate_vulkan_layer.py old mode 100644 new mode 100755 index 8242423..49d5cdf --- a/generator/generate_vulkan_layer.py +++ b/generator/generate_vulkan_layer.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to @@ -134,7 +134,7 @@ def generate_install_helper(file: TextIO, vendor: str, layer: str) -> None: vendor: The layer vendor tag. layer: The name of the layer. ''' - data = load_template('android_install.py') + data = load_template('android_install.json') data = data.replace('{LAYER_NAME}', layer) name = get_layer_api_name(vendor, layer) @@ -228,7 +228,7 @@ def main() -> int: with open(outfile, 'w', encoding='utf-8', newline='\n') as handle: generate_source_cmake(handle, args.vendor_name, args.layer_name) - outfile = os.path.join(outdir, 'android_install.py') + outfile = os.path.join(outdir, 'android_install.json') with open(outfile, 'w', encoding='utf-8', newline='\n') as handle: generate_install_helper(handle, args.vendor_name, args.layer_name) diff --git a/generator/vk_codegen/android_install.json b/generator/vk_codegen/android_install.json new file mode 100644 index 0000000..644abb6 --- /dev/null +++ b/generator/vk_codegen/android_install.json @@ -0,0 +1,4 @@ +{ + "layer_name": "{LGL_LAYER_NAME}", + "layer_binary": "lib{LAYER_NAME}.so" +} diff --git a/generator/vk_codegen/android_install.py b/generator/vk_codegen/android_install.py deleted file mode 100644 index 22d8ce0..0000000 --- a/generator/vk_codegen/android_install.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MIT -# ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the 'Software'), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ----------------------------------------------------------------------------- -''' -A simple installer for Android Vulkan layers. -''' - -import argparse -import os -import shlex -import subprocess as sp -import sys -from typing import Any, Optional - -# Android temp directory -ANDROID_TMP_DIR = '/data/local/tmp/' - -# Expected layer names -EXPECTED_VULKAN_LAYER_NAME = '{LGL_LAYER_NAME}' -EXPECTED_VULKAN_LAYER_FILE = 'lib{LAYER_NAME}.so' - - -class Device: - ''' - A basic wrapper around adb, allowing a specific device to be registered. - - Attributes: - device: The name of the device to call, or None for non-specific use. - ''' - - def adb_quiet(self, *args: str) -> None: - ''' - Call `adb` to run a command, but ignore output and errors. - - Args: - *args : List of command line parameters. - ''' - commands = ['adb'] - commands.extend(args) - sp.run(commands, stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=False) - - def adb(self, *args: str, **kwargs: Any) -> str: - ''' - Call `adb` to run command, and capture output and results. - - Args: - *args: List of command line parameters. - **kwargs: text: Is output is text, or binary? - shell: Use the host shell? - quote: Quote arguments before forwarding - - Returns: - The contents of stdout. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - commands = ['adb'] # type: Any - commands.extend(args) - - text = kwargs.get('text', True) - shell = kwargs.get('shell', False) - quote = kwargs.get('quote', False) - - # Run on the host shell - if shell: - # Unix shells need a flattened command for shell commands - if os.name != 'nt': - quoted_commands = [] - for command in commands: - if command != '>': - command = shlex.quote(command) - quoted_commands.append(command) - commands = ' '.join(quoted_commands) - - # Run on the device but with shell argument quoting - if quote: - for i, command in enumerate(commands): - commands[i] = shlex.quote(command) - - rep = sp.run(commands, check=True, shell=shell, stdout=sp.PIPE, - stderr=sp.PIPE, universal_newlines=text) - - return rep.stdout - - def adb_run_as(self, package: str, - *args: str, quiet: bool = False) -> Optional[str]: - ''' - Call `adb` to run command as a package using `run-as` or as root, - if root is accessible. If command will be run as root, this function - will change CWD to the package data directory before executing the - command. - - Args: - package: Package name to run-as or change CWD to. - *args: List of command line parameters. - quiet: If True, ignores output from adb. - - Returns: - The contents of stdout or None if quiet=True. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - command = ['shell', 'run-as', package] - command.extend(args) - - if quiet: - self.adb_quiet(*command) - return None - - return self.adb(*command) - - -def enable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Args: - device: The device instance. - package: The Android package name. - layer: The layer file path name. - ''' - - print('\nInstalling Vulkan debug layer') - - layer = os.path.normpath(layer) - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('push', layer, ANDROID_TMP_DIR) - - device.adb_run_as(package, 'cp', ANDROID_TMP_DIR + layer_base, '.') - - device.adb('shell', 'settings', 'put', 'global', - 'enable_gpu_debug_layers', '1') - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_app', package) - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_layers', EXPECTED_VULKAN_LAYER_NAME) - - -def disable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Clean up the Vulkan layer installation. - - Args: - device: The device instance. - args: The command arguments. - ''' - print('\nRemoving Vulkan debug layer') - - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('shell', 'settings', 'delete', 'global', - 'enable_gpu_debug_layers') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_app') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_layers') - - device.adb_run_as(package, 'rm', layer_base, quiet=True) - - -def get_layer() -> Optional[str]: - ''' - Find the debug layer to use in the build directory. - - Returns: - The part to the library to use. - ''' - - base_dir = './build_arm64/source/' - - # TODO: If we want to use symbolized layer we need to rename it - lib = None - - for path in os.listdir(base_dir): - # Match symbolized library first so we don't use it - if path.endswith('_sym.so'): - _ = os.path.join(base_dir, path) - elif path.endswith('.so'): - lib = os.path.join(base_dir, path) - - return lib - - -def parse_command_line() -> argparse.Namespace: - ''' - Parse the command line. - - Returns: - The parsed command line container. - ''' - parser = argparse.ArgumentParser() - - parser.add_argument('--package', required=True, - help='Android package name') - - return parser.parse_args() - - -def main() -> int: - ''' - Script main function. - - Returns: - Process return code. - ''' - args = parse_command_line() - - device = Device() - layer = get_layer() - if not layer: - print('ERROR: Layer binary not found') - return 1 - - enable_vulkan_debug_layer(device, args.package, layer) - - input('Press Enter to disable layers') - - disable_vulkan_debug_layer(device, args.package, layer) - - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except KeyboardInterrupt: - print('\n\nERROR: User interrupted execution') diff --git a/layer_example/android_install.json b/layer_example/android_install.json new file mode 100644 index 0000000..17a1d5a --- /dev/null +++ b/layer_example/android_install.json @@ -0,0 +1,4 @@ +{ + "layer_name": "VK_LAYER_LGL_EXAMPLE", + "layer_binary": "libVkLayerExample.so" +} diff --git a/layer_example/android_install.py b/layer_example/android_install.py deleted file mode 100644 index a8a300f..0000000 --- a/layer_example/android_install.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MIT -# ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the 'Software'), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ----------------------------------------------------------------------------- -''' -A simple installer for Android Vulkan layers. -''' - -import argparse -import os -import shlex -import subprocess as sp -import sys -from typing import Any, Optional - -# Android temp directory -ANDROID_TMP_DIR = '/data/local/tmp/' - -# Expected layer names -EXPECTED_VULKAN_LAYER_NAME = 'VK_LAYER_LGL_EXAMPLE' -EXPECTED_VULKAN_LAYER_FILE = 'libVkLayerExample.so' - - -class Device: - ''' - A basic wrapper around adb, allowing a specific device to be registered. - - Attributes: - device: The name of the device to call, or None for non-specific use. - ''' - - def adb_quiet(self, *args: str) -> None: - ''' - Call `adb` to run a command, but ignore output and errors. - - Args: - *args : List of command line parameters. - ''' - commands = ['adb'] - commands.extend(args) - sp.run(commands, stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=False) - - def adb(self, *args: str, **kwargs: Any) -> str: - ''' - Call `adb` to run command, and capture output and results. - - Args: - *args: List of command line parameters. - **kwargs: text: Is output is text, or binary? - shell: Use the host shell? - quote: Quote arguments before forwarding - - Returns: - The contents of stdout. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - commands = ['adb'] # type: Any - commands.extend(args) - - text = kwargs.get('text', True) - shell = kwargs.get('shell', False) - quote = kwargs.get('quote', False) - - # Run on the host shell - if shell: - # Unix shells need a flattened command for shell commands - if os.name != 'nt': - quoted_commands = [] - for command in commands: - if command != '>': - command = shlex.quote(command) - quoted_commands.append(command) - commands = ' '.join(quoted_commands) - - # Run on the device but with shell argument quoting - if quote: - for i, command in enumerate(commands): - commands[i] = shlex.quote(command) - - rep = sp.run(commands, check=True, shell=shell, stdout=sp.PIPE, - stderr=sp.PIPE, universal_newlines=text) - - return rep.stdout - - def adb_run_as(self, package: str, - *args: str, quiet: bool = False) -> Optional[str]: - ''' - Call `adb` to run command as a package using `run-as` or as root, - if root is accessible. If command will be run as root, this function - will change CWD to the package data directory before executing the - command. - - Args: - package: Package name to run-as or change CWD to. - *args: List of command line parameters. - quiet: If True, ignores output from adb. - - Returns: - The contents of stdout or None if quiet=True. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - command = ['shell', 'run-as', package] - command.extend(args) - - if quiet: - self.adb_quiet(*command) - return None - - return self.adb(*command) - - -def enable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Args: - device: The device instance. - package: The Android package name. - layer: The layer file path name. - ''' - - print('\nInstalling Vulkan debug layer') - - layer = os.path.normpath(layer) - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('push', layer, ANDROID_TMP_DIR) - - device.adb_run_as(package, 'cp', ANDROID_TMP_DIR + layer_base, '.') - - device.adb('shell', 'settings', 'put', 'global', - 'enable_gpu_debug_layers', '1') - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_app', package) - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_layers', EXPECTED_VULKAN_LAYER_NAME) - - -def disable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Clean up the Vulkan layer installation. - - Args: - device: The device instance. - args: The command arguments. - ''' - print('\nRemoving Vulkan debug layer') - - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('shell', 'settings', 'delete', 'global', - 'enable_gpu_debug_layers') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_app') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_layers') - - device.adb_run_as(package, 'rm', layer_base, quiet=True) - - -def get_layer() -> Optional[str]: - ''' - Find the debug layer to use in the build directory. - - Returns: - The part to the library to use. - ''' - - base_dir = './build_arm64/source/' - - # TODO: If we want to use symbolized layer we need to rename it - lib = None - - for path in os.listdir(base_dir): - # Match symbolized library first so we don't use it - if path.endswith('_sym.so'): - _ = os.path.join(base_dir, path) - elif path.endswith('.so'): - lib = os.path.join(base_dir, path) - - return lib - - -def parse_command_line() -> argparse.Namespace: - ''' - Parse the command line. - - Returns: - The parsed command line container. - ''' - parser = argparse.ArgumentParser() - - parser.add_argument('--package', required=True, - help='Android package name') - - return parser.parse_args() - - -def main() -> int: - ''' - Script main function. - - Returns: - Process return code. - ''' - args = parse_command_line() - - device = Device() - layer = get_layer() - if not layer: - print('ERROR: Layer binary not found') - return 1 - - enable_vulkan_debug_layer(device, args.package, layer) - - input('Press Enter to disable layers') - - disable_vulkan_debug_layer(device, args.package, layer) - - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except KeyboardInterrupt: - print('\n\nERROR: User interrupted execution') diff --git a/layer_gpu_timeline/android_install.json b/layer_gpu_timeline/android_install.json new file mode 100644 index 0000000..cfd5d56 --- /dev/null +++ b/layer_gpu_timeline/android_install.json @@ -0,0 +1,4 @@ +{ + "layer_name": "VK_LAYER_LGL_GPUTIMELINE", + "layer_binary": "libVkLayerGPUTimeline.so" +} diff --git a/layer_gpu_timeline/android_install.py b/layer_gpu_timeline/android_install.py deleted file mode 100644 index e92abf7..0000000 --- a/layer_gpu_timeline/android_install.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MIT -# ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the 'Software'), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ----------------------------------------------------------------------------- -''' -A simple installer for Android Vulkan layers. -''' - -import argparse -import os -import shlex -import subprocess as sp -import sys -from typing import Any, Optional - -# Android temp directory -ANDROID_TMP_DIR = '/data/local/tmp/' - -# Expected layer names -EXPECTED_VULKAN_LAYER_NAME = 'VK_LAYER_LGL_GPUTIMELINE' -EXPECTED_VULKAN_LAYER_FILE = 'libVkLayerGPUTimeline.so' - - -class Device: - ''' - A basic wrapper around adb, allowing a specific device to be registered. - - Attributes: - device: The name of the device to call, or None for non-specific use. - ''' - - def adb_quiet(self, *args: str) -> None: - ''' - Call `adb` to run a command, but ignore output and errors. - - Args: - *args : List of command line parameters. - ''' - commands = ['adb'] - commands.extend(args) - sp.run(commands, stdout=sp.DEVNULL, stderr=sp.DEVNULL, check=False) - - def adb(self, *args: str, **kwargs: Any) -> str: - ''' - Call `adb` to run command, and capture output and results. - - Args: - *args: List of command line parameters. - **kwargs: text: Is output is text, or binary? - shell: Use the host shell? - quote: Quote arguments before forwarding - - Returns: - The contents of stdout. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - commands = ['adb'] # type: Any - commands.extend(args) - - text = kwargs.get('text', True) - shell = kwargs.get('shell', False) - quote = kwargs.get('quote', False) - - # Run on the host shell - if shell: - # Unix shells need a flattened command for shell commands - if os.name != 'nt': - quoted_commands = [] - for command in commands: - if command != '>': - command = shlex.quote(command) - quoted_commands.append(command) - commands = ' '.join(quoted_commands) - - # Run on the device but with shell argument quoting - if quote: - for i, command in enumerate(commands): - commands[i] = shlex.quote(command) - - rep = sp.run(commands, check=True, shell=shell, stdout=sp.PIPE, - stderr=sp.PIPE, universal_newlines=text) - - return rep.stdout - - def adb_run_as(self, package: str, - *args: str, quiet: bool = False) -> Optional[str]: - ''' - Call `adb` to run command as a package using `run-as` or as root, - if root is accessible. If command will be run as root, this function - will change CWD to the package data directory before executing the - command. - - Args: - package: Package name to run-as or change CWD to. - *args: List of command line parameters. - quiet: If True, ignores output from adb. - - Returns: - The contents of stdout or None if quiet=True. - - Raises: - CalledProcessError: The subprocess was not successfully executed. - ''' - command = ['shell', 'run-as', package] - command.extend(args) - - if quiet: - self.adb_quiet(*command) - return None - - return self.adb(*command) - - -def enable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Args: - device: The device instance. - package: The Android package name. - layer: The layer file path name. - ''' - - print('\nInstalling Vulkan debug layer') - - layer = os.path.normpath(layer) - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('push', layer, ANDROID_TMP_DIR) - - device.adb_run_as(package, 'cp', ANDROID_TMP_DIR + layer_base, '.') - - device.adb('shell', 'settings', 'put', 'global', - 'enable_gpu_debug_layers', '1') - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_app', package) - - device.adb('shell', 'settings', 'put', 'global', - 'gpu_debug_layers', EXPECTED_VULKAN_LAYER_NAME) - - -def disable_vulkan_debug_layer( - device: Device, package: str, layer: str) -> None: - ''' - Clean up the Vulkan layer installation. - - Args: - device: The device instance. - args: The command arguments. - ''' - print('\nRemoving Vulkan debug layer') - - layer_base = os.path.basename(os.path.normpath(layer)) - - device.adb('shell', 'settings', 'delete', 'global', - 'enable_gpu_debug_layers') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_app') - - device.adb('shell', 'settings', 'delete', 'global', - 'gpu_debug_layers') - - device.adb_run_as(package, 'rm', layer_base, quiet=True) - - -def get_layer() -> Optional[str]: - ''' - Find the debug layer to use in the build directory. - - Returns: - The part to the library to use. - ''' - - base_dir = './build_arm64/source/' - - # TODO: If we want to use symbolized layer we need to rename it - lib = None - - for path in os.listdir(base_dir): - # Match symbolized library first so we don't use it - if path.endswith('_sym.so'): - _ = os.path.join(base_dir, path) - elif path.endswith('.so'): - lib = os.path.join(base_dir, path) - - return lib - - -def parse_command_line() -> argparse.Namespace: - ''' - Parse the command line. - - Returns: - The parsed command line container. - ''' - parser = argparse.ArgumentParser() - - parser.add_argument('--package', required=True, - help='Android package name') - - return parser.parse_args() - - -def main() -> int: - ''' - Script main function. - - Returns: - Process return code. - ''' - args = parse_command_line() - - device = Device() - layer = get_layer() - if not layer: - print('ERROR: Layer binary not found') - return 1 - - enable_vulkan_debug_layer(device, args.package, layer) - - input('Press Enter to disable layers') - - disable_vulkan_debug_layer(device, args.package, layer) - - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except KeyboardInterrupt: - print('\n\nERROR: User interrupted execution') diff --git a/layer_khronos_validation/README_LAYER.md b/layer_khronos_validation/README_LAYER.md new file mode 100644 index 0000000..1bfbf70 --- /dev/null +++ b/layer_khronos_validation/README_LAYER.md @@ -0,0 +1,14 @@ +# Layer: Khronos validation + +This layer is a dummy layer with the installation metadata needed to allow +our Android layer installation script to install the Khronos validation layer. + +Download the prebuilt Android binaries of the latest layers from GitHub: + + * https://github.com/KhronosGroup/Vulkan-ValidationLayers/releases/ + +... and copy the Arm binaries into the correct place in the build directory. + +- - - + +_Copyright © 2025, Arm Limited and contributors._ diff --git a/layer_khronos_validation/android_install.json b/layer_khronos_validation/android_install.json new file mode 100644 index 0000000..c39eb98 --- /dev/null +++ b/layer_khronos_validation/android_install.json @@ -0,0 +1,4 @@ +{ + "layer_name": "VK_LAYER_KHRONOS_validation", + "layer_binary": "libVkLayer_khronos_validation.so" +} diff --git a/layer_khronos_validation/build_arm32/source/README.md b/layer_khronos_validation/build_arm32/source/README.md new file mode 100644 index 0000000..8f3b4c6 --- /dev/null +++ b/layer_khronos_validation/build_arm32/source/README.md @@ -0,0 +1,11 @@ +# About + +Install the Arm 32-bit build of the Khronos Arm validation layer binary here. + +Prebuilt Android binaries of the latest layers can be found here: + + * https://github.com/KhronosGroup/Vulkan-ValidationLayers/releases/ + +- - - + +_Copyright © 2025, Arm Limited and contributors._ diff --git a/layer_khronos_validation/build_arm64/source/README.md b/layer_khronos_validation/build_arm64/source/README.md new file mode 100644 index 0000000..ae3f316 --- /dev/null +++ b/layer_khronos_validation/build_arm64/source/README.md @@ -0,0 +1,11 @@ +# About + +Install the Arm 64-bit build of the Khronos Arm validation layer binary here. + +Prebuilt Android binaries of the latest layers can be found here: + + * https://github.com/KhronosGroup/Vulkan-ValidationLayers/releases/ + +- - - + +_Copyright © 2025, Arm Limited and contributors._ diff --git a/lgl_android_install.py b/lgl_android_install.py new file mode 100755 index 0000000..9664f81 --- /dev/null +++ b/lgl_android_install.py @@ -0,0 +1,509 @@ +#!/bin/env python3 +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2019-2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This script is a helper utility to install one or more Vulkan layers on to an +Android device and enable them for a selected debuggable application package. + +Prerequisites +============= + +* The Android Debug Bridge utility, adb, must be on your host environment PATH. +* Your test device must be accessible to adb on your host workstation, either + over USB or a network connection. +* Your test application APK must be installed on to your target device. +* Your test application APK must be debuggable. +* Your selected layer binaries must be built. + +Interactive use +=============== + +The default behavior of this script is designed for interactive use. It +can prompt you to select a device or package to instrument, and requires you +to press a key when you are finished using the layer so it knows when to clean +up. + +If you do not specify a device or package to use, the script will prompt you +to select one using an interactive console menu. The script will auto-select +if only a single choice is available. To avoid the interactive menus you can +specify the device (--device/-D) and the package to use (--package/-P) on the +command line. + +You must specify one or more layer directories in the repository using the +--layer option, e.g. "--layer layer_example". This option can be used multiple +times to specify the installation of multiple stacked layers. + +Layers will be loaded by the Vulkan loader in the order that they are specified +on the command line, with the first layer specified being the top of the stack +closest to the application. + +An android_install.json file in the layer directory informs the script of the +layer string identifier and shared object library name. Layers without this +file cannot be installed using this script. + +Binary discoverability +====================== + +This installer will look for the layer binaries in standard build system +locations in the layer directory: + +64-bit builds: + + * Stripped: /build_arm64/source/ + * Symbolized: /build_arm64/source/ + +32-bit builds: + + * Stripped: /build_arm32/source/ + * Symbolized: /build_arm32/source/ + +By default the installer will choose to install the stripped binaries. You can +optionally enable use of the symbolized binaries using the --symbols/-S command +line option. + +Khronos validation layers +========================= + +This installer can be used to install the Khronos validation layers. A dummy +layer directory, layer_khronos_validation, is provided for this purpose with a +pre-populated android_install.json config file. + +Download the latest Khronos validation layer binaries from GitHub, and copy the +required Arm binaries into the appropriate build directory as described in the +Binary discoverability section above. + + https://github.com/KhronosGroup/Vulkan-ValidationLayers +''' + +import argparse +import json +import os +import sys +from typing import Optional + +from lglpy.android.adb import ADBConnect +from lglpy.android.utils import AndroidUtils +from lglpy.android.filesystem import AndroidFilesystem +from lglpy.ui import console + +# Android 9 is the minimum version supported for our method of enabling layers +ANDROID_MIN_VULKAN_SDK = 28 + + +class LayerMeta: + ''' + Config data for a single layer to install. + + Attributes: + name: The Vulkan name of the layer, e.g. VK_LAYER_LGL_EXAMPLE. + host_path: The full file path on the host filesystem. + device_file: The file name to use on the device. May be different to + the host_path file name. + ''' + + def __init__(self, name: str, host_path: str, device_file: str): + ''' + Create a new layer metadata object. + + Args: + name: The Vulkan name of the layer, e.g. VK_LAYER_LGL_EXAMPLE. + host_path: The full file path on the host filesystem. + device_file: The file name to use on the device. + ''' + self.name = name + self.host_path = host_path + self.device_file = device_file + + +def get_device_name( + conn: ADBConnect, device_param: Optional[str]) -> Optional[str]: + ''' + Determine which connected device to use. + + If multiple devices are connected, and the user does not provide an + unambiguous selection, then the user will be prompted to select. + + Args: + conn: The adb connection. + device_param: The user specified device name from the command line, + or None for menu-driven selection. + + Returns: + The selected device, or None if no device was selected. + ''' + good_devices, bad_devices = AndroidUtils.get_devices() + + # Log bad devices + if bad_devices: + print('\nSearching for devices:') + for device in bad_devices: + print(f' Device {device} is connected, but is not debuggable') + + # No devices found so early out + if not good_devices: + print('ERROR: No debuggable device is connected') + return None + + # If user specified a name check it exists and is non-ambiguous + if device_param: + search = device_param.lower() + match = [x for x in good_devices if x.lower().startswith(search)] + + # User device not found ... + if not match: + print(f'ERROR: Device {device_param} is not connected') + return None + + # User device found too many times ... + if len(match) > 1: + print(f'ERROR: Device {device_param} is ambiguous') + return None + + # Unambiguous match + return match[0] + + # Build a more literate option list for the menu + options = [] + for device in good_devices: + conn.set_device(device) + meta = AndroidUtils.get_device_model(conn) + + if meta: + vendor = meta[0][0].upper() + meta[0][1:] + model = meta[1][0].upper() + meta[1][1:] + options.append(f'{vendor} {model} ({device})') + + else: + options.append(f'Unknown device ({device})') + + conn.set_device(None) + + # Else match via the menu (will auto-select if only one option) + selection = console.select_from_menu('device', options) + if selection is None: + return None + + return good_devices[selection] + + +def get_package_name( + conn: ADBConnect, package_param: Optional[str], + debuggable_only: bool = True) -> Optional[str]: + ''' + Determine which application package to use. + + Currently only supports selecting launchable packages with a MAIN intent. + + Args: + conn: The adb connection. + package_param: The user specified package name from the command line. + - May be the full package name (case-insensitive). + - May be a package name prefix (case-insensitive). + - May be auto-select from menu (set as None) + debuggable_only: Show only debuggable packages if True. + + Returns: + The selected package, or None if no package was selected. + ''' + # Fast test - return all packages (ignoring debuggability) + # If user has specified a package this avoids checking all of them ... + packages = AndroidUtils.get_packages(conn, False, False) + + # No packages found so early out + if not packages: + print('ERROR: No packages detected') + return None + + # If user specified a name check it exists and is non-ambiguous + if package_param: + search = package_param.lower() + match = [x for x in packages if x.lower().startswith(search)] + + # User device not found ... + if not match: + print(f'ERROR: Package {package_param} not found') + return None + + # User device found too many times ... + if len(match) > 1: + print(f'ERROR: Package {package_param} is ambiguous') + return None + + # Check it is actually debuggable if user asked for that + if debuggable_only: + if not AndroidUtils.is_package_debuggable(conn, match[0]): + print(f'ERROR: Package {package_param} is not debuggable') + return None + + # Unambiguous match + return match[0] + + # Slower query if we need the full debuggable only list + if debuggable_only: + packages = AndroidUtils.get_packages(conn, True, False) + + # Now match via the menu (will auto-select if only one option) + title = 'debuggable package' if debuggable_only else 'package' + selection = console.select_from_menu(title, packages) + + if selection is None: + return None + + return packages[selection] + + +def get_layer_metadata( + layer_dirs: list[str], + need_32bit: bool, need_symbols: bool) -> Optional[list[LayerMeta]]: + ''' + Get the layer metadata for all of the selected layers. + + Args: + layer_dirs: Host directories to search for layers. + need_32bit: True if need 32-bit, False if 64-bit. + need_symbols: True if need symbolized build, False if stripped. + + Returns: + Loaded metadata for all of the layers, or None on error. + ''' + layer_metadata = [] + + for layer_dir in layer_dirs: + # Parse the JSON metadata file + metadata_path = os.path.join(layer_dir, 'android_install.json') + if not os.path.isfile(metadata_path): + print(f'ERROR: {layer_dir} has no android_install.json') + return None + + with open(metadata_path, 'r', encoding='utf-8') as handle: + config = json.load(handle) + + try: + layer_name = config['layer_name'] + layer_binary = config['layer_binary'] + except KeyError: + print(f'ERROR: {layer_dir} has invalid android_install.json') + return None + + # Check that the binary exists + build_dir = 'build_arm32' if need_32bit else 'build_arm64' + + host_binary = layer_binary + if need_symbols: + host_binary = host_binary.replace('.so', '_sym.so') + + host_path = os.path.join(layer_dir, build_dir, 'source', host_binary) + if not os.path.isfile(host_path): + print(f'ERROR: layer binary {host_path} is not built') + return None + + # Build the metadata + meta = LayerMeta(layer_name, host_path, layer_binary) + layer_metadata.append(meta) + + return layer_metadata + + +def install_layer_binary(conn: ADBConnect, layer: LayerMeta) -> bool: + ''' + Transfer layer binary file to the device. + + Args: + conn: The adb connection. + layer: The loaded layer metadata configuration. + + Returns: + True on success, False otherwise. + ''' + res = AndroidFilesystem.push_file_to_package(conn, layer.host_path, True) + if not res: + return False + + # Rename the file if we loaded the symbolized library from the host + host_file = os.path.basename(layer.host_path) + if host_file != layer.device_file: + res = AndroidFilesystem.rename_file_in_package( + conn, host_file, layer.device_file) + if not res: + return False + + return True + + +def uninstall_layer_binary(conn: ADBConnect, layer: LayerMeta) -> bool: + ''' + Remove layer binary file from the device. + + Args: + conn: The adb connection. + layer: The loaded layer metadata configuration. + + Returns: + True on success, False otherwise. + ''' + return AndroidFilesystem.delete_file_from_package(conn, layer.device_file) + + +def enable_layers(conn: ADBConnect, layers: list[LayerMeta]) -> bool: + ''' + Enable the selected layer drivers on the target device. + + Args: + conn: The adb connection. + layers: The loaded layer metadata configurations. + + Returns: + True on success, False otherwise. + ''' + assert conn.package, 'Enabling layers requires conn.package to be set' + + layer_names = ':'.join([x.name for x in layers]) + + s1 = AndroidUtils.set_setting(conn, 'enable_gpu_debug_layers', '1') + s2 = AndroidUtils.set_setting(conn, 'gpu_debug_app', conn.package) + s3 = AndroidUtils.set_setting(conn, 'gpu_debug_layers', layer_names) + + return s1 and s2 and s3 + + +def disable_layers(conn: ADBConnect) -> bool: + ''' + Disable all layer drivers on the target device. + + Args: + conn: The adb connection. + + Returns: + True on success, False otherwise. + ''' + assert conn.package, 'Disabling layers requires conn.package to be set' + + s1 = AndroidUtils.clear_setting(conn, 'enable_gpu_debug_layers') + s2 = AndroidUtils.clear_setting(conn, 'gpu_debug_app') + s3 = AndroidUtils.clear_setting(conn, 'gpu_debug_layers') + + return s1 and s2 and s3 + + +def parse_cli() -> argparse.Namespace: + ''' + Parse the command line. + + Returns: + An argparse results object. + ''' + parser = argparse.ArgumentParser() + + parser.add_argument( + '--device', '-D', default=None, + help='target device name or name prefix (default=auto-detected)') + + parser.add_argument( + '--package', '-P', default=None, + help='target package name or regex pattern (default=auto-detected)') + + parser.add_argument( + '--layer', '-L', action='append', required=True, + help='layer name to install (required, can be repeated)') + + parser.add_argument( + '--symbols', '-S', action='store_true', default=False, + help='use to install layers with unstripped symbols') + return parser.parse_args() + + +def main() -> int: + ''' + The script main function. + + Returns: + The process exit code. + ''' + args = parse_cli() + + conn = ADBConnect() + + # Select a device to connect to + device = get_device_name(conn, args.device) + if not device: + return 1 + + conn.set_device(device) + + # Test the device supports Vulkan layers + sdk_version = AndroidUtils.get_os_sdk_version(conn) + if not sdk_version or sdk_version < ANDROID_MIN_VULKAN_SDK: + print('ERROR: Device must support Android 9.0 or newer') + return 2 + + # Select a package to instrument + package = get_package_name(conn, args.package) + if not package: + return 3 + + conn.set_package(package) + + # Select layers to install + need_32bit = AndroidUtils.is_package_32bit(conn, package) + layers = get_layer_metadata(args.layer, need_32bit, args.symbols) + if not layers: + return 4 + + # Install files + for layer in layers: + if not install_layer_binary(conn, layer): + print('ERROR: Layer install on device failed') + return 5 + + # Enable layers + if not enable_layers(conn, layers): + print('ERROR: Layer enable on device failed') + return 6 + + print('Layers are installed and ready for use:') + for layer in layers: + print(f' - {layer.name}') + print() + + input('Press any key to uninstall all layers') + + # Disable layers + if not disable_layers(conn): + print('ERROR: Layer disable on device failed') + return 7 + + # Remove files + for layer in layers: + if not uninstall_layer_binary(conn, layer): + print('ERROR: Layer uninstall from device failed') + return 8 + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n\nERROR: User interrupted execution") diff --git a/lgl_host_server.py b/lgl_host_server.py old mode 100644 new mode 100755 index e565aaf..142c7ff --- a/lgl_host_server.py +++ b/lgl_host_server.py @@ -1,9 +1,10 @@ +#!/bin/env python3 # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to +# of this software and associated documentation files (the 'Software'), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is @@ -12,7 +13,7 @@ # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -21,55 +22,88 @@ # SOFTWARE. # ----------------------------------------------------------------------------- -# This module implements a host server that provides services over the network -# to a layer running on a remote device. -# -# Run with ... -# adb reverse localabstract:lglcomms tcp:63412 -# +''' +This script implements a simple network server that provides services to +layer drivers running on the target device over a simple network protocol. + +Android devices using layers can tunnel their connection using adb reverse +to forward a Unix domain socket on the device to a TCP socket on the host. + + adb reverse localabstract:lglcomms tcp:63412 +''' + +import argparse import sys import threading +from typing import Any + +from lglpy.comms import server +from lglpy.comms import service_gpu_timeline +from lglpy.comms import service_test +from lglpy.comms import service_log + + +def parse_cli() -> argparse.Namespace: + ''' + Parse the command line. -import lglpy.server -import lglpy.service_gpu_timeline -import lglpy.service_test -import lglpy.service_log + Returns: + An argparse results object. + ''' + parser = argparse.ArgumentParser() + + parser.add_argument( + '--test', '-T', action='store_true', default=False, + help='enable the communications unit test helper service') + + return parser.parse_args() + + +def main() -> int: + ''' + The script main function. + + Returns: + The process exit code. + ''' + args = parse_cli() -def main(): # Create a server instance - server = lglpy.server.CommsServer(63412) + svr = server.CommsServer(63412) # Register all the services with it - print(f'Registering host services:') + print('Registering host services:') + + service: Any - if 0: - service = lglpy.service_test.TestService() - endpoint_id = server.register_endpoint(service) + if args.test: + service = service_test.TestService() + endpoint_id = svr.register_endpoint(service) print(f' - [{endpoint_id}] = {service.get_service_name()}') - service = lglpy.service_log.LogService() - endpoint_id = server.register_endpoint(service) + service = service_log.LogService() + endpoint_id = svr.register_endpoint(service) print(f' - [{endpoint_id}] = {service.get_service_name()}') - service = lglpy.service_gpu_timeline.GPUTimelineService() - endpoint_id = server.register_endpoint(service) + service = service_gpu_timeline.GPUTimelineService() + endpoint_id = svr.register_endpoint(service) print(f' - [{endpoint_id}] = {service.get_service_name()}') - print() # Start it running - serverThread = threading.Thread(target=server.run, daemon=True) - serverThread.start() + svr_thread = threading.Thread(target=svr.run, daemon=True) + svr_thread.start() # Press to exit try: - input("Press any key to exit ...\n\n") + input('Press any key to exit ...\n\n') except KeyboardInterrupt: - print("Exiting ...") - sys.exit(0) + print('Exiting ...') + return 0 return 0 + if __name__ == '__main__': sys.exit(main()) diff --git a/lglpy/android/__init__.py b/lglpy/android/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lglpy/android/adb.py b/lglpy/android/adb.py new file mode 100644 index 0000000..09e1fce --- /dev/null +++ b/lglpy/android/adb.py @@ -0,0 +1,299 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2024-2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements a simple wrapper around the Android Debug Bridge command +line tool which can be used to run commands on a connected Android device. +''' + +from collections.abc import Iterable +import os +import shlex +import subprocess as sp +from typing import Optional + + +class ADBConnect: + ''' + A wrapper around adb which can be used to connect to a specific device, + and run commands as a specific package. + + - adb() runs a command using adb and waits for the result. + - adb_async() runs a command using adb and does not wait for the result. + - adb_run() runs a device shell command as the "shell" user and waits for + the result. + - adb_runas() runs a device shell command as the current package user and + waits for the result. + + The current device and package are attributes of the connection instance + which can be set at construction, or via the set_device() and set_package() + methods. + + Attributes: + device: The name of the connected device, or None for generic use. + package: The name of the debuggable package, or None for generic use. + ''' + + def __init__(self, device: Optional[str] = None, + package: Optional[str] = None): + ''' + Create a new device, defaulting to non-specific use. + + Args: + device: The device identifier, as returned by 'adb devices', or + None for non-specific use. + package: The package name, as returned by `adb shell pm list + packages` or None for non-specific use. + ''' + self.device = device + self.package = package + + def set_device(self, device: Optional[str]) -> None: + ''' + Set the device for this connection. + + Args: + device: The device identifier, as returned by 'adb devices', or + None for non-specific use. + ''' + self.device = device + + def set_package(self, package: str) -> None: + ''' + Set the package for this connection. + + Args: + package: The package name, as returned by `adb shell pm list + packages` or None for non-specific use. + ''' + self.package = package + + def get_base_command(self, args: Iterable[str]) -> list[str]: + ''' + Get the root of an adb command, injecting device selector if needed. + + Args: + args: The user argument list, may be empty. + + Returns: + The fully populated command list. + ''' + commands = ['adb'] + if self.device: + commands.extend(['-s', self.device]) + commands.extend(args) + + return commands + + def pack_commands(self, commands: list[str], + shell: bool, quote: bool) -> list[str] | str: + ''' + Pack a set of command lines suitable for a subprocess call. + + Args: + commands: List of command line parameters. + shell: True if this should invoke the host shell. + quote: True if arguments are quoted before forwarding. + + Return: + Appropriated packed command line arguments for the host OS. + ''' + # Run via the host shell + if shell: + # Unix shells need a flattened command for shell commands + if os.name != 'nt': + quoted_commands = [] + for command in commands: + if command != '>': + command = shlex.quote(command) + quoted_commands.append(command) + + return ' '.join(quoted_commands) + + # We do not currently quote on other host shells + return commands + + # Run via direct invocation of adb with quoting for target shell + if quote: + return [shlex.quote(arg) for arg in commands] + + # Run via direct invocation of adb without any additional quoting + return commands + + def adb(self, *args: str, text: bool = True, shell: bool = False, + quote: bool = False, check: bool = True) -> str: + ''' + Call adb to synchronously run a command, check its result, and capture + its output if successful. + + Commands can invoke adb directly, or via the host shell if invoked with + shell=True. When using shell=True on Unix hosts the arguments are + always quoted unless the argument is a '>' redirect shell argument. On + Windows beware of the security implications of the lack of quoting. + + Args: + *args: List of command line parameters. + text: True if output is text, False if binary + shell: True if this should invoke via host shell, False if direct. + quote: True if arguments are quoted, False if unquoted. + check: True if result is checked, False if ignored. + + Returns: + The stdout response written by adb. + + Raises: + CalledProcessError: The invoked call failed. + ''' + # Build the command list + commands = self.get_base_command(args) + packed_commands = self.pack_commands(commands, shell, quote) + + # Invoke the command + rep = sp.run(packed_commands, check=check, shell=shell, text=text, + stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=sp.PIPE) + + # Return the output + return rep.stdout + + def adb_async(self, *args: str, text: bool = True, shell: bool = False, + quote: bool = False, pipe: bool = False) -> sp.Popen: + ''' + Call adb to asynchronously run a command, without waiting for it to + complete. + + Commands can invoke adb directly, or via the host shell if invoked with + shell=True. When using shell=True on Unix hosts the arguments are + always quoted unless the argument is a '>' redirect shell argument. On + Windows beware of the security implications of the lack of quoting. + + By default, the adb stdout data is discarded. It can be kept by setting + pipe=True, but in this case the caller must call communicate() on the + returned object to avoid the child blocking indefinitely if the OS pipe + buffer fills up. + + Args: + *args: List of command line parameters. + text: True if output is text, False if binary + shell: True if this should invoke via host shell, False if direct. + quote: True if arguments are quoted, False if unquoted. + pipe: True if child stdout is collected, False if discarded. + + Returns: + The process handle. + + Raises: + CalledProcessError: The invoked call failed. + ''' + # Setup the configuration + output = sp.PIPE if pipe else sp.DEVNULL + + # Build the command list + commands = self.get_base_command(args) + packed_commands = self.pack_commands(commands, shell, quote) + + # Sink inputs to DEVNULL to stop the child process stealing keyboard + # Sink outputs to DEVNULL to stop full output buffers blocking child + # pylint: disable=consider-using-with + process = sp.Popen(packed_commands, + text=text, shell=shell, + stdin=sp.DEVNULL, stdout=output, stderr=sp.DEVNULL) + + # Return the output process a user can use to wait, if needed. + return process + + def adb_run(self, *args: str, text: bool = True, shell: bool = False, + quote: bool = False, check: bool = True) -> str: + ''' + Call adb to synchronously run a device shell command as the Android + "shell" user, check its result, and capture its output if successful. + + Commands can invoke adb directly, or via the host shell if invoked with + shell=True. When using shell=True on Unix hosts the arguments are + always quoted unless the argument is a '>' redirect shell argument. On + Windows beware of the security implications of the lack of quoting. + + Args: + *args: List of command line parameters. + text: True if output is text, False if binary + shell: True if this should invoke via host shell, False if direct. + quote: True if arguments are quoted, False if unquoted. + check: True if result is checked, False if ignored. + + Returns: + The stdout response written by adb. + + Raises: + CalledProcessError: The invoked call failed. + ''' + # Build the command list + commands = self.get_base_command(['shell']) + commands.extend(args) + packed_commands = self.pack_commands(commands, shell, quote) + + # Invoke the command + rep = sp.run(packed_commands, + check=check, shell=shell, text=text, + stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=sp.PIPE) + + # Return the output + return rep.stdout + + def adb_runas(self, *args: str, text: bool = True, shell: bool = False, + quote: bool = False, check: bool = True) -> str: + ''' + Call adb to synchronously run a device shell command as the package + user, check its result, and capture its output if successful. + + Commands can invoke adb directly, or via the host shell if invoked with + shell=True. When using shell=True on Unix hosts the arguments are + always quoted unless the argument is a '>' redirect shell argument. On + Windows beware of the security implications of the lack of quoting. + + Args: + *args: List of command line parameters. + text: True if output is text, False if binary + shell: True if this should invoke via host shell, False if direct. + quote: True if arguments are quoted, False if unquoted. + check: True if result is checked, False if ignored. + + Returns: + The stdout response written by adb. + + Raises: + CalledProcessError: The invoked call failed. + ''' + assert self.package, \ + 'Cannot use adb_runas() without package' + + # Build the command list + commands = self.get_base_command(['shell', 'run-as', self.package]) + commands.extend(args) + packed_commands = self.pack_commands(commands, shell, quote) + + # Invoke the command + rep = sp.run(packed_commands, + check=check, shell=shell, text=text, + stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=sp.PIPE) + + # Return the output + return rep.stdout diff --git a/lglpy/android/filesystem.py b/lglpy/android/filesystem.py new file mode 100644 index 0000000..8c57c55 --- /dev/null +++ b/lglpy/android/filesystem.py @@ -0,0 +1,281 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2024-2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements higher level Android filesystem utilities, built on top +of the low level ADBConnect wrapper around Android Debug Bridge. +''' + +import os +import posixpath +import subprocess as sp + +from .adb import ADBConnect + + +class AndroidFilesystem: + ''' + A library of utility methods for transferring files to/from a device. + + Attributes: + TEMP_DIR: The generally accessible device temp directory. + DATA_PERM: The file permissions to use for data files. + EXEC_PERM: The file permissions to use for executable files. + ''' + + TEMP_DIR = '/data/local/tmp' + DATA_PERM = '0666' + EXEC_PERM = '0777' + + @classmethod + def push_file_to_tmp( + cls, conn: ADBConnect, host_path: str, + executable: bool = False) -> bool: + ''' + Push a file to the device temp directory. + + File will be copied to: TEMP_DIR/. + + Args: + conn: The adb connection. + host_path: The path of the file on the host file system. + executable: True if the file should have executable permissions. + + Returns: + True if the file was copied, False otherwise. + ''' + file_name = os.path.basename(host_path) + + device_path = posixpath.join(cls.TEMP_DIR, file_name) + + try: + # Remove old file to prevent false success + conn.adb_run('rm', '-f', device_path) + + # Push new file + conn.adb('push', host_path, device_path) + + # Check it actually copied + conn.adb_run('ls', device_path) + + permission = cls.EXEC_PERM if executable else cls.DATA_PERM + conn.adb_run('chmod', permission, device_path) + + except sp.CalledProcessError: + return False + + return True + + @classmethod + def pull_file_from_tmp( + cls, conn: ADBConnect, file_name: str, + host_dir: str, delete: bool = False) -> bool: + ''' + Pull a file from the device temp directory to a host directory. + + File will be copied to: /. + + Args: + conn: The adb connection. + file_name: The name of the file in the tmp directory. + host_path: The destination directory on the host file system. + Host directory will be created if it doesn't exist. + delete: Should the file on the device be deleted after copying? + + Returns: + True if the file was copied, False otherwise. + ''' + host_dir = os.path.abspath(host_dir) + os.makedirs(host_dir, exist_ok=True) + + device_path = posixpath.join(cls.TEMP_DIR, file_name) + + try: + conn.adb('pull', device_path, host_dir) + + if delete: + cls.delete_file_from_tmp(conn, file_name) + except sp.CalledProcessError: + return False + + return True + + @classmethod + def delete_file_from_tmp( + cls, conn: ADBConnect, file_name: str, + error_ok: bool = False) -> bool: + ''' + Delete a file from the device temp directory. + + File will be deleted from: TEMP_DIR/. + + Args: + conn: The adb connection. + file_name: The name of the file to delete. + error_ok: Ignore errors if the file doesn't exist. + + Returns: + True if the file was deleted, False otherwise. + ''' + device_path = posixpath.join(cls.TEMP_DIR, file_name) + + try: + if error_ok: + conn.adb_run('rm', '-f', device_path) + else: + conn.adb_run('rm', device_path) + except sp.CalledProcessError: + return False + + return True + + @classmethod + def push_file_to_package( + cls, conn: ADBConnect, host_path: str, + executable: bool = False) -> bool: + ''' + Push a file to the connection package directory. + + File will be copied to, e.g.: /data/user/0// + + Args: + conn: The adb connection. + host_path: The path of the file on the host file system. + executable: True if the file should have executable permissions. + + Returns: + True if the file was copied, False otherwise. + ''' + assert conn.package, \ + 'Cannot use push_file_to_package() without package' + + # Determine the paths that we need + file_name = os.path.basename(host_path) + tmp_path = posixpath.join(cls.TEMP_DIR, file_name) + + # Copy file to the temp directory + success = cls.push_file_to_tmp(conn, host_path, executable) + if not success: + return False + + # Copy file to the package directory + try: + conn.adb_runas('cp', tmp_path, '.') + except sp.CalledProcessError: + return False + + # Delete the temp file copy + cls.delete_file_from_tmp(conn, file_name) + + return True + + @classmethod + def pull_file_from_package( + cls, conn: ADBConnect, src_file: str, host_dir: str, + delete: bool = False) -> bool: + ''' + Pull a file from the connection package directory to a host directory. + + File will be copied to: /. + + Args: + conn: The adb connection. + src_file: The name of the file in the tmp directory. + host_path: The destination directory on the host file system. + Host directory will be created if it doesn't exist. + delete: Should the file on the device be deleted after copying? + + Returns: + True if the file was copied, False otherwise. + ''' + assert conn.package, \ + 'Cannot use pull_file_from_package() without package' + + host_dir = os.path.abspath(host_dir) + os.makedirs(host_dir, exist_ok=True) + + # You cannot adb pull from a package, even if it's debuggable, so + # this is the non-obvious solution ... + host_file = os.path.join(host_dir, src_file) + try: + conn.adb('exec-out', 'run-as', conn.package, + 'cat', src_file, '>', host_file, + text=False, shell=True) + + if delete: + cls.delete_file_from_package(conn, src_file) + + except sp.SubprocessError: + return False + + return True + + @classmethod + def rename_file_in_package( + cls, conn: ADBConnect, file_name: str, new_file_name: str) -> bool: + ''' + Rename a file in the package directory. + + File will be renamed to, e.g.: /data/user/0// + + Args: + conn: The adb connection. + file_name: The name of the existing file to rename. + new_file_name: The new file name to use. + + Returns: + True if the file was renamed, False otherwise. + ''' + try: + conn.adb_runas('mv', file_name, new_file_name) + except sp.CalledProcessError: + return False + + return True + + @classmethod + def delete_file_from_package( + cls, conn: ADBConnect, file_name: str, + error_ok: bool = False) -> bool: + ''' + Delete a file from the package directory. + + File will be deleted from, e.g.: /data/user/0// + + Args: + conn: The adb connection. + file_name: The name of the file to delete. + error_ok: Ignore errors if the file doesn't exist. + + Returns: + True if the file was deleted, False otherwise. + ''' + try: + if error_ok: + conn.adb_runas('rm', '-f', file_name) + else: + conn.adb_runas('rm', file_name) + except sp.CalledProcessError: + return False + + return True diff --git a/lglpy/android/test.py b/lglpy/android/test.py new file mode 100644 index 0000000..92ae605 --- /dev/null +++ b/lglpy/android/test.py @@ -0,0 +1,729 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements tests for the lglpy.android package. Running this +test suite requires an Android device with at least one debuggable application +installed to be connected to the host PC with an authorized adb connection. +''' + +import contextlib +import os +import re +import shutil +import subprocess as sp +import sys +import tempfile +import unittest + +from .adb import ADBConnect +from .utils import AndroidUtils +from .filesystem import AndroidFilesystem + + +SLOW_TESTS = True # Set to True to enable slow tests, False to skip them + + +def get_script_relative_path(file_name: str) -> str: + ''' + Get the host path of a script relative file. + + Args: + file_name: The path of the file relative to this script. + + Returns: + The path of the file on disk. + ''' + dir_name = os.path.dirname(__file__) + return os.path.join(dir_name, file_name) + + +@contextlib.contextmanager +def NamedTempFile(): # pylint: disable=invalid-name + ''' + Creates a context managed temporary file that can be used with external + subprocess. + + On context entry this yields the file name, on exit it deletes the file. + + Yields: + The name of the temporary file. + ''' + name = None + + try: + f = tempfile.NamedTemporaryFile(delete=False) + name = f.name + f.close() + yield name + + finally: + if name: + os.unlink(name) + + +class AndroidTestNoDevice(unittest.TestCase): + ''' + This set of tests validates execution of global commands that can run + without a specific device attached/nominated. + ''' + DEVICES_RE = re.compile(r'^([A-Za-z0-9]+)\t(device|offline|unauthorized)$') + + def validate_devices(self, result): + ''' + Validate a well formed `adb devices` response. + ''' + lines = result.splitlines() + + # Validate fixed preamble and postamble + self.assertEqual(lines[0], 'List of devices attached') + self.assertEqual(lines[-1], '') + + # Validate that we have at least one connected device + self.assertGreater(len(lines), 2) + + # Validate per-device fields are well formed + for line in lines[1:-1]: + self.assertIsNotNone(self.DEVICES_RE.match(line)) + + def test_sync(self): + ''' + Test direct invocation of adb devices. + ''' + device = ADBConnect() + result = device.adb('devices') + self.validate_devices(result) + + def test_sync_shell(self): + ''' + Test host shell invocation of adb devices. + ''' + with NamedTempFile() as file_name: + device = ADBConnect() + result = device.adb('devices', '>', file_name, shell=True) + + # We used the shell to redirect to file so this should be empty + self.assertEqual(result, '') + + # Read the file and validate that it is correct + with open(file_name, 'r', encoding='utf-8') as handle: + data = handle.read() + self.validate_devices(data) + + def test_sync_quote(self): + ''' + Test direct adb invocation that needs device-side quoting. + ''' + device = ADBConnect() + result = device.adb_run('echo', 'a | echo', quote=True) + self.assertEqual(result, 'a | echo\n') + + @unittest.skipIf(os.name == 'nt', 'Not supported on Windows') + def test_sync_shell_quote(self): + ''' + Test host shell invocation of adb shell that needs host-side quoting. + ''' + device = ADBConnect() + result = device.adb_run('echo', 'a | echo', shell=True) + self.assertEqual(result, 'a | echo\n') + + def test_async(self): + ''' + Test direct invocation of adb devices. + ''' + device = ADBConnect() + process = device.adb_async('devices') + + # We didn't request pipe so these should be empty + stdout, _ = process.communicate() + self.assertIsNone(stdout) + + def test_async_quote(self): + ''' + Test direct adb invocation that needs device-side quoting. + ''' + device = ADBConnect() + process = device.adb_async( + 'shell', 'touch', '/data/local/tmp/a b.txt', quote=True) + + # We used the shell to redirect to file so this should be empty + stdout, _ = process.communicate() + self.assertIsNone(stdout) + + # Assert that the file was correctly created by deleting it + process = device.adb( + 'shell', 'rm', '/data/local/tmp/a b.txt', quote=True) + + def test_async_shell_pipe(self): + ''' + Test host shell invocation of adb devices. + ''' + with NamedTempFile() as file_name: + device = ADBConnect() + process = device.adb_async( + 'devices', '>', file_name, shell=True, pipe=True) + + # We used the shell to redirect to file so this should be empty + stdout, _ = process.communicate() + self.assertEqual(stdout, '') + + # Read the file and validate that it is correct + with open(file_name, 'r', encoding='utf-8') as handle: + data = handle.read() + self.validate_devices(data) + + def test_async_pipe(self): + ''' + Test direct invocation of adb devices. + ''' + device = ADBConnect() + process = device.adb_async('devices', pipe=True) + + # We requested pipe so validate output + stdout, _ = process.communicate() + self.validate_devices(stdout) + + def test_util_device_list(self): + ''' + Test helper to list devices. + ''' + devices = AndroidUtils.get_devices() + + # Test that we get at least one good device returned + self.assertGreater(len(devices[0]), 0) + self.assertGreaterEqual(len(devices[1]), 0) + + +class AndroidTestDeviceUtil(unittest.TestCase): + ''' + This set of tests validates execution of device-level commands that + require adb to have a valid implicit default device connected. + ''' + + @unittest.skipIf(not SLOW_TESTS, 'Slow tests not enabled') + def test_util_package_list_full(self): + ''' + Test helper to list packages + ''' + conn = ADBConnect() + + packages = AndroidUtils.get_packages(conn, False, False) + all_packages = len(packages) + + packages = AndroidUtils.get_packages(conn, False, True) + main_packages = len(packages) + + packages = AndroidUtils.get_packages(conn, True, True) + debug_main_packages = len(packages) + + # Test that we get at least one package returned for each case + self.assertGreater(all_packages, 0) + self.assertGreater(main_packages, 0) + self.assertGreater(debug_main_packages, 0) + + # Test that list length reduces each time as we add filters + self.assertGreater(all_packages, main_packages) + self.assertGreater(main_packages, debug_main_packages) + + def test_util_package_list(self): + ''' + Test helper to list packages + ''' + conn = ADBConnect() + + packages = AndroidUtils.get_packages(conn, False, False) + all_packages = len(packages) + + packages = AndroidUtils.get_packages(conn, True, True) + debug_main_packages = len(packages) + + # Test that we get at least one package returned for each case + self.assertGreater(all_packages, 0) + self.assertGreater(debug_main_packages, 0) + + # Test that list length reduces each time as we add filters + self.assertGreater(all_packages, debug_main_packages) + + def test_util_os_version(self): + ''' + Test helper to get OS version. + ''' + conn = ADBConnect() + version = AndroidUtils.get_os_version(conn) + self.assertIsNotNone(version) + + def test_util_os_sdk_version(self): + ''' + Test helper to get OS SDK version. + ''' + conn = ADBConnect() + version = AndroidUtils.get_os_sdk_version(conn) + self.assertGreater(version, 10) + + def test_util_device_model(self): + ''' + Test helper to get device vendor and model version. + ''' + conn = ADBConnect() + version = AndroidUtils.get_device_model(conn) + + # Test that we have a tuple and both values are non-zero + self.assertIsNotNone(version) + self.assertTrue(version[0]) + self.assertTrue(version[1]) + + def test_util_package_debuggable(self): + ''' + Test helper to get package debug status + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + all_packages = AndroidUtils.get_packages(conn, False, False) + self.assertGreater(len(all_packages), 0) + + dbg_packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(dbg_packages), 0) + + ndbg_packages = list(set(all_packages) ^ set(dbg_packages)) + self.assertGreater(len(ndbg_packages), 0) + + # Test the package + is_debug = AndroidUtils.is_package_debuggable(conn, ndbg_packages[0]) + self.assertFalse(is_debug) + + is_debug = AndroidUtils.is_package_debuggable(conn, dbg_packages[0]) + self.assertTrue(is_debug) + + def test_util_package_bitness(self): + ''' + Test helper to get package ABI bitness. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + + # Test the package + is_32bit = AndroidUtils.is_package_32bit(conn, packages[0]) + self.assertTrue(isinstance(is_32bit, bool)) + + def test_util_package_data_dir(self): + ''' + Test helper to get package data directory on the device filesystem. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + # Test the package + data_dir = AndroidUtils.get_package_data_dir(conn) + self.assertTrue(data_dir) + + +class AndroidTestDeviceProps(unittest.TestCase): + ''' + This set of tests validates modifications to device-level settings + This require adb to have a valid implicit default device connected. + ''' + + def test_util_properties_modifiers(self): + ''' + Test helper to set, get, or clear a property. + ''' + conn = ADBConnect() + prop = 'debug.vulkan.layers' + + # Ensure test device starts from a clear state + success = AndroidUtils.clear_property(conn, prop) + self.assertTrue(success) + + value = AndroidUtils.get_property(conn, prop) + self.assertEqual(value, '') + + success = AndroidUtils.set_property(conn, prop, 'test_') + self.assertTrue(success) + + value = AndroidUtils.get_property(conn, prop) + self.assertEqual(value, 'test_') + + success = AndroidUtils.clear_property(conn, prop) + self.assertTrue(success) + + value = AndroidUtils.get_property(conn, prop) + self.assertEqual(value, '') + + def test_util_settings_modifiers(self): + ''' + Test helper to set, get, or clear a setting. + ''' + conn = ADBConnect() + prop = 'enable_gpu_debug_layers' + + # Ensure test device starts from a clear state + success = AndroidUtils.clear_setting(conn, prop) + self.assertTrue(success) + + value = AndroidUtils.get_setting(conn, prop) + self.assertEqual(value, None) + + success = AndroidUtils.set_setting(conn, prop, '1') + self.assertTrue(success) + + value = AndroidUtils.get_setting(conn, prop) + self.assertEqual(value, '1') + + success = AndroidUtils.clear_setting(conn, prop) + self.assertTrue(success) + + value = AndroidUtils.get_setting(conn, prop) + self.assertEqual(value, None) + + +class AndroidTestDeviceFilesystem(unittest.TestCase): + ''' + This set of tests validates execution of device-level filesystem operations + that require adb to have a valid implicit default device connected. + ''' + + HOST_DEST_DIR = 'x_test_tmp' + + def tearDown(self): + ''' + Post-test cleanup. + ''' + shutil.rmtree(self.HOST_DEST_DIR, True) + + def test_util_copy_to_device_tmp(self): + ''' + Test filesystem copy to device temp directory. + ''' + conn = ADBConnect() + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + device_file = f'/data/local/tmp/{test_file}' + + # Push the file + success = AndroidFilesystem.push_file_to_tmp(conn, test_path, False) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_run('cat', device_file) + self.assertEqual(data.strip(), 'test payload') + + # Cleanup + success = AndroidFilesystem.delete_file_from_tmp(conn, test_file) + self.assertTrue(success) + + def test_util_copy_to_device_tmp_exec(self): + ''' + Test filesystem copy executable payload to device temp directory. + ''' + conn = ADBConnect() + + test_file = 'test_data.sh' + test_path = get_script_relative_path(test_file) + device_file = f'/data/local/tmp/{test_file}' + + # Push the file with executable permissions + success = AndroidFilesystem.push_file_to_tmp(conn, test_path, True) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_run(device_file) + self.assertEqual(data.strip(), 'test payload exec') + + # Cleanup + success = AndroidFilesystem.delete_file_from_tmp(conn, test_file) + self.assertTrue(success) + + def test_util_copy_from_device_keep(self): + ''' + Test filesystem copy executable payload from device temp directory. + ''' + conn = ADBConnect() + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_tmp(conn, test_path, False) + self.assertTrue(success) + + # Copy the file without deletion + success = AndroidFilesystem.pull_file_from_tmp( + conn, test_file, self.HOST_DEST_DIR, False) + self.assertTrue(success) + + # Cleanup + success = AndroidFilesystem.delete_file_from_tmp(conn, test_file) + self.assertTrue(success) + + def test_util_copy_from_device_delete(self): + ''' + Test filesystem copy executable payload from device temp directory. + ''' + conn = ADBConnect() + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + device_path = f'/data/local/tmp/{test_file}' + host_path = f'{self.HOST_DEST_DIR}/{test_file}' + + # Push the file + success = AndroidFilesystem.push_file_to_tmp(conn, test_path, False) + self.assertTrue(success) + + # Copy the file with deletion + success = AndroidFilesystem.pull_file_from_tmp( + conn, test_file, self.HOST_DEST_DIR, True) + self.assertTrue(success) + + with open(host_path, 'r', encoding='utf-8') as handle: + data = handle.read() + self.assertEqual(data, 'test payload') + + # Check the file is deleted - this should fail + with self.assertRaises(sp.CalledProcessError): + conn.adb_run('ls', device_path) + + def test_util_copy_to_package(self): + ''' + Test filesystem copy to package data directory. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas('ls', test_file) + self.assertEqual(data.strip(), test_file) + + # Cleanup tmp - this should fail because the file does not exist + success = AndroidFilesystem.delete_file_from_tmp(conn, test_file) + self.assertFalse(success) + + def test_util_copy_to_package_exec(self): + ''' + Test filesystem copy to package data directory. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = './test_data.sh' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path, True) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas(test_file) + self.assertEqual(data.strip(), 'test payload exec') + + # Cleanup the file + success = AndroidFilesystem.delete_file_from_package(conn, test_file) + self.assertTrue(success) + + def test_util_rename_in_package(self): + ''' + Test filesystem rename in package data directory. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas('ls', test_file) + self.assertEqual(data.strip(), test_file) + + # Rename the file + new_test_file = 'test_data_2.txt' + success = AndroidFilesystem.rename_file_in_package( + conn, test_file, new_test_file) + self.assertTrue(success) + + # Validate it was moved - this should fail + with self.assertRaises(sp.CalledProcessError): + data = conn.adb_runas('ls', test_file) + + data = conn.adb_runas('ls', new_test_file) + self.assertEqual(data.strip(), new_test_file) + + # Cleanup tmp - this should fail because the file does not exist + success = AndroidFilesystem.delete_file_from_package( + conn, new_test_file) + self.assertTrue(success) + + def test_util_copy_from_package(self): + ''' + Test filesystem copy from package data directory to host. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas('ls', test_file) + self.assertEqual(data.strip(), test_file) + + # Copy the file + with tempfile.TemporaryDirectory() as host_dir: + host_file = os.path.join(host_dir, test_file) + + AndroidFilesystem.pull_file_from_package( + conn, test_file, host_dir, False) + + # Read the file and validate that it is correct + with open(host_file, 'r', encoding='utf-8') as handle: + data = handle.read() + + self.assertEqual(data.strip(), 'test payload') + + # Cleanup the file + success = AndroidFilesystem.delete_file_from_package(conn, test_file) + self.assertTrue(success) + + def test_util_move_from_package(self): + ''' + Test filesystem move from package data directory to host. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas('ls', test_file) + self.assertEqual(data.strip(), test_file) + + # Copy the file + with tempfile.TemporaryDirectory() as host_dir: + host_file = os.path.join(host_dir, test_file) + + AndroidFilesystem.pull_file_from_package( + conn, test_file, host_dir, True) + + # Read the file and validate that it is correct + with open(host_file, 'r', encoding='utf-8') as handle: + data = handle.read() + + self.assertEqual(data.strip(), 'test payload') + + # Cleanup the file - this should fail as we deleted the file earlier + success = AndroidFilesystem.delete_file_from_package(conn, test_file) + self.assertFalse(success) + + def test_util_delete_from_package(self): + ''' + Test filesystem delete from package data directory. + ''' + conn = ADBConnect() + + # Fetch some packages that we can use + packages = AndroidUtils.get_packages(conn, True, False) + self.assertGreater(len(packages), 0) + conn.set_package(packages[0]) + + test_file = 'test_data.txt' + test_path = get_script_relative_path(test_file) + + # Push the file + success = AndroidFilesystem.push_file_to_package(conn, test_path) + self.assertTrue(success) + + # Validate it pushed OK + data = conn.adb_runas('ls', test_file) + self.assertEqual(data.strip(), test_file) + + # Cleanup the file + success = AndroidFilesystem.delete_file_from_package(conn, test_file) + self.assertTrue(success) + + # Validate it was deleted - this should fail + with self.assertRaises(sp.CalledProcessError): + data = conn.adb_runas('ls', test_file) + + +def main(): + ''' + The main function. + + Returns: + int: The process return code. + ''' + results = unittest.main(exit=False) + return 0 if results.result.wasSuccessful() else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/lglpy/android/test_data.sh b/lglpy/android/test_data.sh new file mode 100644 index 0000000..6ebd375 --- /dev/null +++ b/lglpy/android/test_data.sh @@ -0,0 +1 @@ +echo test payload exec \ No newline at end of file diff --git a/lglpy/android/test_data.txt b/lglpy/android/test_data.txt new file mode 100644 index 0000000..e5694d9 --- /dev/null +++ b/lglpy/android/test_data.txt @@ -0,0 +1 @@ +test payload \ No newline at end of file diff --git a/lglpy/android/utils.py b/lglpy/android/utils.py new file mode 100644 index 0000000..e2e4c9e --- /dev/null +++ b/lglpy/android/utils.py @@ -0,0 +1,378 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2024-2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements higher level Android queries and utility functions, +built on top of the low level ADBConnect wrapper around Android Debug Bridge. +''' + +import re +import shlex +import subprocess as sp +from typing import Optional + +from .adb import ADBConnect + + +class AndroidUtils: + ''' + A library of utility methods for querying device and package configuration. + ''' + + @staticmethod + def get_devices() -> tuple[list[str], list[str]]: + ''' + Get the list of devices that are connected to this host. + + Returns: + Tuple of two elements. + - A list of available devices that can be used. + - A list of unavailable devices, e.g. with unauthorized abd. + + Empty lists are returned on error. + ''' + good_devices = [] # typing: list[str] + bad_devices = [] # typing: list[str] + + try: + conn = ADBConnect() + result = conn.adb('devices') + + for line in result.splitlines(): + # Match devices that are available for adb + if line.endswith('device'): + good_devices.append(line.split()[0]) + # Match devices that are detectable, but not usable + elif line.endswith(('offline', 'unauthorized')): + bad_devices.append(line.split()[0]) + + except sp.CalledProcessError: + pass + + return (good_devices, bad_devices) + + @staticmethod + def get_packages( + conn: ADBConnect, debuggable_only: bool = True, + main_only: bool = True) -> list[str]: + ''' + Get the list of packages on the target device. + + Note: listing non-debuggable packages with main_only enabled is quite + slow due to the number of system packages that need testing. + + Args: + conn: The adb connection. + debuggable_only: True if show only debuggable packages, else all. + main_only: True if show only packages with MAIN intent, else all. + + Returns: + The list of packages, or an empty list on error. + ''' + opt = '-3' if debuggable_only else '' + command = f'pm list packages -e {opt} | sed "s/^package://" | sort' + + if debuggable_only: + # Test if the package is debuggable on the device + sub = 'if run-as $0 true ; then echo $0 ; fi' + command += f' | xargs -n1 sh -c {shlex.quote(sub)} 2> /dev/null' + + if main_only: + # Test if the package has a MAIN activity intent + sub = 'dumpsys package $0 | ' \ + 'if grep -q "android.intent.action.MAIN" ; then echo $0 ; fi' + command += f' | xargs -n1 sh -c {shlex.quote(sub)} 2> /dev/null' + + try: + package_list = conn.adb_run(command).splitlines() + + except sp.CalledProcessError: + return [] + + # Some shells (seen on Android 9/10) report sh as a valid package + if 'sh' in package_list: + package_list.remove('sh') + + return package_list + + @classmethod + def get_os_version(cls, conn: ADBConnect) -> Optional[str]: + ''' + Get the Android OS platform version of the target device. + + Args: + conn: The adb connection. + + Returns: + The Android platform version, or None on error. + ''' + try: + return cls.get_property(conn, 'ro.build.version.release') + except sp.CalledProcessError: + return None + + @classmethod + def get_os_sdk_version(cls, conn: ADBConnect) -> Optional[int]: + ''' + Get the Android OS SDK version of the target device. + + Args: + conn: The adb connection. + + Returns: + The Android SDK version number, or None on error. + ''' + ver = cls.get_property(conn, 'ro.build.version.sdk') + if not ver: + return None + return int(ver) + + @classmethod + def get_device_model(cls, conn: ADBConnect) -> Optional[tuple[str, str]]: + ''' + Get the vendor and model of the target device. + + Args: + conn: The adb connection. + + Returns: + The device vendor and model strings, or None on error. + ''' + vendor = cls.get_property(conn, 'ro.product.manufacturer') + if not vendor: + return None + vendor = vendor.strip() + + model = cls.get_property(conn, 'ro.product.model') + if not model: + return None + model = model.strip() + + return (vendor, model) + + @staticmethod + def is_package_debuggable(conn: ADBConnect, package: str) -> bool: + ''' + Test if a package is debuggable. + + Args: + conn: The adb connection. + package: The name of the package to test. + + Returns: + True if the package is debuggable, False otherwise. + ''' + try: + package = shlex.quote(package) + command = f'if run-as {package} true ; then echo {package} ; fi' + log = conn.adb_run(command) + return log.strip() == package + + except sp.CalledProcessError: + return False + + @classmethod + def is_package_32bit(cls, conn: ADBConnect, package: str) -> bool: + ''' + Test if a package prefers 32-bit ABI on the target device. + + Args: + conn: The adb connection. + package: The name of the package to test. + + Returns: + True if the package is 32-bit, False if 64-bit or on error. + ''' + try: + preferred_abi = None + + # Try to match the primary ABI loaded by the application + package = shlex.quote(package) + command = f'pm dump {package} | grep primaryCpuAbi' + log = conn.adb_run(command) + pattern = re.compile('primaryCpuAbi=(\\S+)') + match = pattern.search(log) + + if match: + log_abi = match.group(1) + if log_abi != 'null': + preferred_abi = log_abi + + # If that fails match against the default system ABI + if preferred_abi is None: + sys_abi = cls.get_property(conn, 'getprop ro.product.cpu.abi') + preferred_abi = sys_abi + + return preferred_abi in ('armeabi-v7a', 'armeabi') + + except sp.CalledProcessError: + return False + + @staticmethod + def get_package_data_dir(conn: ADBConnect): + ''' + Get the package data directory on the device filesystem. + + TODO: This currently only handles data directories for User 0. If a + device has multiple user profiles configured, the dumpsys output will + contain multiple dataDir records. + + Args: + conn: The adb connection. + + Returns: + The package data directory, or None on error. + ''' + assert conn.package, \ + 'Cannot use get_package_data_dir() without package' + + try: + package = shlex.quote(conn.package) + command = f'dumpsys package {package} | grep dataDir' + log = conn.adb_run(command) + return log.replace('dataDir=', '').strip() + + except sp.CalledProcessError: + return None + + @staticmethod + def set_property(conn: ADBConnect, prop: str, value: str) -> bool: + ''' + Set an Android system property to a value. + + Args: + conn: The adb connection. + prop: The name of the property to set. + value: The desired value of the property. + + Returns: + True on success, False otherwise. + ''' + try: + conn.adb_run('setprop', prop, value) + return True + + except sp.CalledProcessError: + return False + + @staticmethod + def get_property(conn: ADBConnect, prop: str) -> Optional[str]: + ''' + Get an Android system property value. + + Args: + conn: The adb connection. + prop: The name of the property to get. + + Returns: + The value of the property on success, None otherwise. Note that + deleted settings that do not exist will also return None. + ''' + try: + value = conn.adb_run('getprop', prop) + return value.strip() + + except sp.CalledProcessError: + return None + + @staticmethod + def clear_property(conn: ADBConnect, prop: str) -> bool: + ''' + Set an Android system property to an empty value. + + Args: + conn: The adb connection. + prop: The name of the property to clear. + + Returns: + True on success, False otherwise. + ''' + try: + conn.adb_run('setprop', prop, '""') + return True + + except sp.CalledProcessError: + return False + + @staticmethod + def set_setting(conn: ADBConnect, setting: str, value: str) -> bool: + ''' + Set an Android system setting to a value. + + Args: + conn: The adb connection. + setting: The name of the setting to set. + value: The desired value of the setting. + + Returns: + True on success, False otherwise. + ''' + try: + conn.adb_run('settings', 'put', 'global', setting, value) + return True + + except sp.CalledProcessError: + return False + + @staticmethod + def get_setting(conn: ADBConnect, setting: str) -> Optional[str]: + ''' + Get an Android system property setting. + + Args: + conn: The adb connection. + setting: The name of the setting to get. + + Returns: + The value of the setting on success, None otherwise. + ''' + try: + value = conn.adb_run('settings', 'get', 'global', setting) + value = value.strip() + + if value == 'null': + return None + + return value + + except sp.CalledProcessError: + return None + + @staticmethod + def clear_setting(conn: ADBConnect, setting: str) -> bool: + ''' + Clear an Android system setting. + + Args: + conn: The adb connection. + setting: The name of the setting to set. + + Returns: + True on success, False otherwise. + ''' + try: + conn.adb_run('settings', 'delete', 'global', setting) + return True + + except sp.CalledProcessError: + return False diff --git a/lglpy/comms/__init__.py b/lglpy/comms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lglpy/server.py b/lglpy/comms/server.py similarity index 92% rename from lglpy/server.py rename to lglpy/comms/server.py index 15dbd43..5e0ffb2 100644 --- a/lglpy/server.py +++ b/lglpy/comms/server.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to +# of this software and associated documentation files (the 'Software'), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -21,14 +21,16 @@ # SOFTWARE. # ----------------------------------------------------------------------------- -# This module implements the server-side communications module that can accept -# client connections from a layer driver, and dispatch messages to registered -# service handler in the server. -# -# This module currently only accepts a single connection at a time and message -# handling is synchronous inside the server. It is therefore not possible to -# implement pseudo-host-driven event loops if the layer is using multiple -# services concurrently - this needs threads per service. +''' +This module implements the server-side communications module that can accept +client connections from a layer driver, and dispatch messages to registered +service handler in the server. + +This module currently only accepts a single connection at a time and message +handling is synchronous inside the server. It is therefore not possible to +implement pseudo-host-driven event loops if the layer is using multiple +services concurrently - this needs threads per service. +''' import enum import socket @@ -134,7 +136,6 @@ class ClientDropped(Exception): ''' Exception representing loss of Client connection. ''' - pass class CommsServer: @@ -174,7 +175,7 @@ def __init__(self, port: int): self.sockl = None # type: Optional[socket.socket] self.sockd = None # type: Optional[socket.socket] - def register_endpoint(self, endpoint) -> int: + def register_endpoint(self, endpoint: Any) -> int: ''' Register a new service endpoint with the server. @@ -204,6 +205,9 @@ def handle_message(self, message: Message) -> Optional[bytes]: Returns: The response to the message. ''' + # Message is unused for this microservice - keep pylint happy + del message + data = [] for endpoint_id, endpoint in self.endpoints.items(): name = endpoint.get_service_name().encode('utf-8') diff --git a/lglpy/service_gpu_timeline.py b/lglpy/comms/service_gpu_timeline.py similarity index 88% rename from lglpy/service_gpu_timeline.py rename to lglpy/comms/service_gpu_timeline.py index fa5e6b8..700e71a 100644 --- a/lglpy/service_gpu_timeline.py +++ b/lglpy/comms/service_gpu_timeline.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to +# of this software and associated documentation files (the 'Software'), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -21,18 +21,23 @@ # SOFTWARE. # ----------------------------------------------------------------------------- -# This module implements the server-side communications module service that -# handles record preprocessing and serializing GPU Timeline layer messages to -# file on the host. +''' +This module implements the server-side communications module service that +handles record preprocessing and serializing the resulting GPU Timeline layer +frame records to a file on the host. +''' import json import struct from typing import Any -from lglpy.server import Message +from lglpy.comms.server import Message class GPUTimelineService: + ''' + A service for handling network comms from the layer_gpu_timeline layer. + ''' def __init__(self): ''' @@ -48,6 +53,7 @@ def __init__(self): } # TODO: Make file name configurable + # pylint: disable=consider-using-with self.file_handle = open('malivision.gputl', 'wb') def get_service_name(self) -> str: @@ -99,7 +105,7 @@ def handle_render_pass(self, msg: Any) -> None: ''' # Find the last workload last_render_pass = None - if len(self.frame['workloads']): + if self.frame['workloads']: last_workload = self.frame['workloads'][-1] if last_workload['type'] == 'renderpass': last_render_pass = last_workload diff --git a/lglpy/service_log.py b/lglpy/comms/service_log.py similarity index 81% rename from lglpy/service_log.py rename to lglpy/comms/service_log.py index aaf41cf..c4ba2c8 100644 --- a/lglpy/service_log.py +++ b/lglpy/comms/service_log.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to +# of this software and associated documentation files (the 'Software'), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -21,10 +21,13 @@ # SOFTWARE. # ----------------------------------------------------------------------------- -# This module implements the server-side communications module service that -# implements basic logging. +''' +This module implements the server-side communications module service that +implements basic logging, as an alternative to logcat for devices where logcat +is awkward to use. +''' -from lglpy.server import Message +from lglpy.comms.server import Message class LogService: diff --git a/lglpy/service_test.py b/lglpy/comms/service_test.py similarity index 84% rename from lglpy/service_test.py rename to lglpy/comms/service_test.py index ac44a6d..756acd6 100644 --- a/lglpy/service_test.py +++ b/lglpy/comms/service_test.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: MIT # ----------------------------------------------------------------------------- -# Copyright (c) 2024 Arm Limited +# Copyright (c) 2024-2025 Arm Limited # # Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to +# of this software and associated documentation files (the 'Software'), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is @@ -12,7 +12,7 @@ # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER @@ -21,12 +21,15 @@ # SOFTWARE. # ----------------------------------------------------------------------------- -# This module implements the server-side communications module service that -# implements a basic message endpoint for testing. +''' +This module implements the server-side communications module service that +implements a basic message endpoint for comms module unit testing. +''' -from lglpy.server import Message, MessageType from typing import Optional +from lglpy.comms.server import Message, MessageType + class TestService: ''' diff --git a/lglpy/ui/__init__.py b/lglpy/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lglpy/ui/console.py b/lglpy/ui/console.py new file mode 100644 index 0000000..21a5be4 --- /dev/null +++ b/lglpy/ui/console.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2019-2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements a simple interactive command line menu that can +present a list of options and prompt the user to select one. +''' + +import math +from typing import Optional + + +def get_input(text: str) -> str: + ''' + Wrapper around input() so that it can be mocked for testing. + + Args: + text: The text to display as a prompt. + ''' + return input(text) + + +def select_from_menu(title: str, options: list[str]) -> Optional[int]: + ''' + Prompt user to select from an on-screen menu. + + If the option list contains only a single option it will be auto-selected. + + Args: + title: The title string. + options: The list of options to present. + + Returns: + The selected list index, or None if no selection made. + ''' + assert len(options) > 0, 'No menu options provided' + + if len(options) == 1: + print(f'Select a {title}') + print(f' Auto-selected {options[0]}\n') + return 0 + + selection = None + while True: + try: + # Print the menu + print(f'\nSelect a {title}') + chars = int(math.log10(len(options))) + 1 + for i, entry in enumerate(options): + print(f' {i+1:{chars}}) {entry}') + + print(f' {0:{chars}}) Exit menu') + + # Process the response + response = int(get_input('\n Select entry: ')) + if response == 0: + return None + + if 0 < response <= len(options): + selection = response - 1 + break + + raise ValueError() + + except ValueError: + print(f'\n Please enter a value between 0 and {len(options)}') + + print('\n') + return selection diff --git a/lglpy/ui/test.py b/lglpy/ui/test.py new file mode 100644 index 0000000..05acaef --- /dev/null +++ b/lglpy/ui/test.py @@ -0,0 +1,102 @@ + +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +''' +This module implements tests for the lglpy.ui package. +''' + +import sys +import unittest +from unittest import mock + +from . import console + + +class ConsoleTestMenu(unittest.TestCase): + ''' + Tests for the console UI for simple list item menu selection. + ''' + + @staticmethod + def make_options(count: int) -> list[str]: + ''' + Make a list of options ... + ''' + return [f'Option {i + 1}' for i in range(0, count)] + + @mock.patch('lglpy.ui.console.get_input', side_effect='0') + def test_menu_cancel(self, mock_get_input): + ''' + Test the user cancelling an option in the menu. + ''' + del mock_get_input + options = self.make_options(3) + selected_option = console.select_from_menu('Title', options) + self.assertEqual(selected_option, None) + + @mock.patch('lglpy.ui.console.get_input', side_effect=['1']) + def test_menu_select_1(self, mock_get_input): + ''' + Test the user entering a valid value in the menu. + ''' + del mock_get_input + options = self.make_options(3) + selected_option = console.select_from_menu('Title', options) + self.assertEqual(selected_option, 0) + + @mock.patch('lglpy.ui.console.get_input', side_effect=['4', '2']) + def test_menu_select_bad_range(self, mock_get_input): + ''' + Test the user entering an out-of-bounds value in the menu. + ''' + options = self.make_options(3) + selected_option = console.select_from_menu('Title', options) + + self.assertEqual(mock_get_input.call_count, 2) + self.assertEqual(selected_option, 1) + + @mock.patch('lglpy.ui.console.get_input', side_effect=['fox', '3']) + def test_menu_select_bad_formnt(self, mock_get_input): + ''' + Test the user entering a non-integer value in the menu. + ''' + options = self.make_options(3) + selected_option = console.select_from_menu('Title', options) + self.assertEqual(mock_get_input.call_count, 2) + self.assertEqual(selected_option, 2) + + +def main(): + ''' + The main function. + + Returns: + int: The process return code. + ''' + results = unittest.main(exit=False) + return 0 if results.result.wasSuccessful() else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pylint.sh b/pylint.sh new file mode 100755 index 0000000..14fee35 --- /dev/null +++ b/pylint.sh @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "Running pycodestyle\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "\n" +python3 -m pycodestyle ./generator ./lglpy *.py +if [ $? -eq 0 ]; then + echo "Success: no issues found" +fi + +printf "\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "Running mypy\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "\n" +python3 -m mypy ./generator ./lglpy *.py + +printf "\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "Running pylint\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "\n" +python3 -m pylint ./generator ./lglpy *.py diff --git a/pytest_local.bat b/pytest_local.bat new file mode 100644 index 0000000..a1b0109 --- /dev/null +++ b/pytest_local.bat @@ -0,0 +1,52 @@ +:: SPDX-License-Identifier: MIT +:: ----------------------------------------------------------------------------- +:: Copyright (c) 2025 Arm Limited +:: +:: Permission is hereby granted, free of charge, to any person obtaining a copy +:: of this software and associated documentation files (the 'Software'), to +:: deal in the Software without restriction, including without limitation the +:: rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +:: sell copies of the Software, and to permit persons to whom the Software is +:: furnished to do so, subject to the following conditions: +:: +:: The above copyright notice and this permission notice shall be included in +:: all copies or substantial portions of the Software. +:: +:: THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +:: IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +:: FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +:: AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +:: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +:: OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +:: SOFTWARE. +:: ----------------------------------------------------------------------------- +@echo off +setlocal + +set total_errors = 0 +set total_runs = 0 + +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo Running tests without devices +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo: + +py -3 -m lglpy.ui.test +set /a total_runs+=1 +set /a total_errors=total_errors+%ERRORLEVEL% + +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo Running tests with devices +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo: + +py -3 -m lglpy.android.test +set /a total_runs+=1 +set /a total_errors=total_errors+%ERRORLEVEL% + +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo Test run summary statistics +echo = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = +echo: +echo - Total suites run = %total_runs% +echo - Total suites in error = %total_errors% diff --git a/pytest_local.sh b/pytest_local.sh new file mode 100755 index 0000000..de90237 --- /dev/null +++ b/pytest_local.sh @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# ----------------------------------------------------------------------------- +# Copyright (c) 2025 Arm Limited +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the 'Software'), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- + +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "Running tests without devices\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "\n" +python3 -m lglpy.ui.test + +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "Running tests with devices\n" +printf "= = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = \n" +printf "\n" +python3 -m lglpy.android.test