Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit e521730c authored by Thomas Girardier's avatar Thomas Girardier Committed by Android (Google) Code Review
Browse files

Merge changes from topic "cherrypicker-L79300000956516311:N67600001297819102" into tm-qpr-dev

* changes:
  [Pandora] GAP MMIs
  [Pandora] Add logging to mmi2grpc
  [Pandora] Add GATT server tests
  [Pandora] Fix spelling of connectability
  [Pandora] - Remove HID flaky test
  [Pandora] Merge servicer codegen from avatar interface
  [Pandora] Fix coverage script
  [Pandora] Change intents used for connect/disconnect
  [Pandora] Utility getters
  [Pandora] Implementing advertising interfaces
  [Pandora] Set Scan Mode
  [Pandora] Pair on transport corresponding to Connection
  [Pandora] Add control over BREDR connections
  [Pandora] Add helper script to generate coverage
  [PANDORA_TEST] Implement SMP/PER/SCCT test on pts-bot
  [PTS-bot] Enable sharding
  [Pandora] Add RunInquiry Host interface
  [Pandora] GATT server interfaces
  add L2CAP/LE tests
  [Pandora] - Put back skip AVDTP/SNK tests erased
  [Pandora] Fix slow reconnect_phone
  [Pandora] - Add A2dpSink implementation
  Pandora: Add PandoraServer to general-tests
  [PTS-bot] Add max flaky tests to config
  [PTS-bot] Use global retry count
  [PANDORA_TEST] Implement SMP test on pts-bot
  [PTS-Bot]: Added 11 AVRCP test cases
  [PTS-bot] Adding 3 retries on inconclusive tests
  enable L2CAP/LE/CFC tests
  set minimum SDK requirement to tell users
  [PTS-bot] Wait for disconnect LE callback
  [Pandora] Fixes for OnPairing implementation
  [Pandora] Fix coroutine scope/lifetime issues
  mmi2grpc: Don't reconnect phone on reset
  [Pandora] - Enable AVDTP/SNK and add some A2DP/SNK
  [Pandora] - Enable A2DP/SNK/CC Tests
  [PANDORA_TEST] pts-bot HFP Coverage: PSI
  [Pandora] - Add shell functionality
parents 37bd31d6 a7deb825
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
trace*
log*
out*
+322 −0
Original line number Diff line number Diff line
#!/usr/bin/env python

import argparse
import os
from pathlib import Path
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET


def run_pts_bot():
  run_pts_bot_cmd = [
      # atest command with verbose mode.
      'atest',
      '-d',
      '-v',
      'pts-bot',
      # Coverage tool chains and specify that coverage should be flush to the
      # disk between each tests.
      '--',
      '--coverage',
      '--coverage-toolchain JACOCO',
      '--coverage-toolchain CLANG',
      '--coverage-flush',
  ]
  subprocess.run(run_pts_bot_cmd).returncode


def run_unit_tests():

  # Output logs directory
  logs_out = Path('logs_bt_tests')
  logs_out.mkdir(exist_ok=True)

  mts_tests = []
  android_build_top = os.getenv('ANDROID_BUILD_TOP')
  mts_xml = ET.parse(
      f'{android_build_top}/test/mts/tools/mts-tradefed/res/config/mts-bluetooth-tests-list.xml'
  )

  for child in mts_xml.getroot():
    value = child.attrib['value']
    if 'enable:true' in value:
      test = value.replace(':enable:true', '')
      mts_tests.append(test)

  for test in mts_tests:
    print(f'Test started: {test}')

    # Env variables necessary for native unit tests.
    env = os.environ.copy()
    env['CLANG_COVERAGE_CONTINUOUS_MODE'] = 'true'
    env['CLANG_COVERAGE'] = 'true'
    env['NATIVE_COVERAGE_PATHS'] = 'packages/modules/Bluetooth'
    run_test_cmd = [
        # atest command with verbose mode.
        'atest',
        '-d',
        '-v',
        test,
        # Coverage tool chains and specify that coverage should be flush to the
        # disk between each tests.
        '--',
        '--coverage',
        '--coverage-toolchain JACOCO',
        '--coverage-toolchain CLANG',
        '--coverage-flush',
        # Allows tests to use hidden APIs.
        '--test-arg ',
        'com.android.compatibility.testtype.LibcoreTest:hidden-api-checks:false',
        '--test-arg ',
        'com.android.tradefed.testtype.AndroidJUnitTest:hidden-api-checks:false',
        '--test-arg ',
        'com.android.tradefed.testtype.InstrumentationTest:hidden-api-checks:false',
        '--skip-system-status-check ',
        'com.android.tradefed.suite.checker.ShellStatusChecker',
    ]
    with open(f'{logs_out}/{test}.txt', 'w') as f:
      returncode = subprocess.run(
          run_test_cmd, env=env, stdout=f, stderr=subprocess.STDOUT).returncode
      print(
          f'Test ended [{"Success" if returncode == 0 else "Failed"}]: {test}')


def generate_java_coverage(bt_apex_name, trace_path, coverage_out):

  out = os.getenv('OUT')
  android_host_out = os.getenv('ANDROID_HOST_OUT')

  java_coverage_out = Path(f'{coverage_out}/java')
  temp_path = Path(f'{coverage_out}/temp')
  if temp_path.exists():
    shutil.rmtree(temp_path, ignore_errors=True)
  temp_path.mkdir()

  framework_jar_path = Path(
      f'{out}/obj/PACKAGING/jacoco_intermediates/JAVA_LIBRARIES/framework-bluetooth.{bt_apex_name}_intermediates'
  )
  service_jar_path = Path(
      f'{out}/obj/PACKAGING/jacoco_intermediates/JAVA_LIBRARIES/service-bluetooth.{bt_apex_name}_intermediates'
  )
  app_jar_path = Path(
      f'{out}/obj/PACKAGING/jacoco_intermediates/ETC/Bluetooth{"Google" if "com.google" in bt_apex_name else ""}.{bt_apex_name}_intermediates'
  )

  # From google3/configs/wireless/android/testing/atp/prod/mainline-engprod/templates/modules/bluetooth.gcl.
  framework_exclude_classes = [
      '**/com/android/bluetooth/x/**/*.class',
      '**/*Test$*.class',
      '**/android/bluetooth/I*$Default.class',
      '**/android/bluetooth/**/I*$Default.class',
      '**/android/bluetooth/I*$Stub.class',
      '**/android/bluetooth/**/I*$Stub.class',
      '**/android/bluetooth/I*$Stub$Proxy.class',
      '**/android/bluetooth/**/I*$Stub$Proxy.class',
      '**/com/android/internal/util/**/*.class',
      '**/android/net/**/*.class',
  ]
  service_exclude_classes = [
      '**/com/android/bluetooth/x/**/*.class',
      '**/androidx/**/*.class',
      '**/android/net/**/*.class',
      '**/android/support/**/*.class',
      '**/kotlin/**/*.class',
      '**/*Test$*.class',
      '**/com/android/internal/annotations/**/*.class',
      '**/android/annotation/**/*.class',
      '**/android/net/**/*.class',
  ]
  app_exclude_classes = [
      '**/*Test$*.class',
      '**/com/android/bluetooth/x/**/*.class',
      '**/com/android/internal/annotations/**/*.class',
      '**/com/android/internal/util/**/*.class',
      '**/android/annotation/**/*.class',
      '**/android/net/**/*.class',
      '**/android/support/v4/**/*.class',
      '**/androidx/**/*.class',
      '**/kotlin/**/*.class',
      '**/com/google/**/*.class',
      '**/javax/**/*.class',
      '**/android/hardware/**/*.class',  # Added
      '**/android/hidl/**/*.class',  # Added
      '**/com/android/bluetooth/**/BluetoothMetrics*.class',  # Added
  ]

  # Merged ec files.
  merged_ec_path = Path(f'{temp_path}/merged.ec')
  subprocess.run((
      f'java -jar {android_host_out}/framework/jacoco-cli.jar merge {trace_path.absolute()}/*.ec '
      f'--destfile {merged_ec_path.absolute()}'),
                 shell=True)

  # Copy and extract jar files.
  framework_temp_path = Path(f'{temp_path}/{framework_jar_path.name}')
  service_temp_path = Path(f'{temp_path}/{service_jar_path.name}')
  app_temp_path = Path(f'{temp_path}/{app_jar_path.name}')

  shutil.copytree(framework_jar_path, framework_temp_path)
  shutil.copytree(service_jar_path, service_temp_path)
  shutil.copytree(app_jar_path, app_temp_path)

  current_dir_path = Path.cwd()
  for p in [framework_temp_path, service_temp_path, app_temp_path]:
    os.chdir(p.absolute())
    os.system('jar xf jacoco-report-classes.jar')
    os.chdir(current_dir_path)

  os.remove(f'{framework_temp_path}/jacoco-report-classes.jar')
  os.remove(f'{service_temp_path}/jacoco-report-classes.jar')
  os.remove(f'{app_temp_path}/jacoco-report-classes.jar')

  # Generate coverage report.
  exclude_classes = []
  for glob in framework_exclude_classes:
    exclude_classes.extend(list(framework_temp_path.glob(glob)))
  for glob in service_exclude_classes:
    exclude_classes.extend(list(service_temp_path.glob(glob)))
  for glob in app_exclude_classes:
    exclude_classes.extend(list(app_temp_path.glob(glob)))

  for c in exclude_classes:
    if c.exists():
      os.remove(c.absolute())

  gen_java_cov_report_cmd = [
      f'java',
      f'-jar',
      f'{android_host_out}/framework/jacoco-cli.jar',
      f'report',
      f'{merged_ec_path.absolute()}',
      f'--classfiles',
      f'{temp_path.absolute()}',
      f'--html',
      f'{java_coverage_out.absolute()}',
      f'--name',
      f'{java_coverage_out.absolute()}.html',
  ]
  subprocess.run(gen_java_cov_report_cmd)

  # Cleanup.
  shutil.rmtree(temp_path, ignore_errors=True)


def generate_native_coverage(bt_apex_name, trace_path, coverage_out):

  out = os.getenv('OUT')
  android_build_top = os.getenv('ANDROID_BUILD_TOP')

  native_coverage_out = Path(f'{coverage_out}/native')
  temp_path = Path(f'{coverage_out}/temp')
  if temp_path.exists():
    shutil.rmtree(temp_path, ignore_errors=True)
  temp_path.mkdir()

  # From google3/configs/wireless/android/testing/atp/prod/mainline-engprod/templates/modules/bluetooth.gcl.
  exclude_files = {
      'system/.*_aidl.*',
      'system/.*_test.*',
      'system/.*_mock.*',
      'system/.*_unittest.*',
      'system/binder/',
      'system/blueberry/',
      'system/build/',
      'system/conf/',
      'system/doc/',
      'system/test/',
      'system/gd/l2cap/',
      'system/gd/security/',
      'system/gd/neighbor/',
      # 'android/', # Should not be excluded
  }

  # Merge profdata files.
  profdata_path = Path(f'{temp_path}/coverage.profdata')
  subprocess.run(
      f'llvm-profdata merge --sparse -o {profdata_path.absolute()} {trace_path.absolute()}/*.profraw',
      shell=True)

  gen_native_cov_report_cmd = [
      f'llvm-cov',
      f'show',
      f'-format=html',
      f'-output-dir={native_coverage_out.absolute()}',
      f'-instr-profile={profdata_path.absolute()}',
      f'{out}/symbols/apex/{bt_apex_name}/lib64/libbluetooth_jni.so',
      f'-path-equivalence=/proc/self/cwd,{android_build_top}',
      f'/proc/self/cwd/packages/modules/Bluetooth',
  ]
  for f in exclude_files:
    gen_native_cov_report_cmd.append(f'-ignore-filename-regex={f}')
  subprocess.run(gen_native_cov_report_cmd, cwd=android_build_top)

  # Cleanup.
  shutil.rmtree(temp_path, ignore_errors=True)


if __name__ == '__main__':

  parser = argparse.ArgumentParser()
  parser.add_argument(
      '--apex-name',
      default='com.android.btservices',
      help='bluetooth apex name. Default: com.android.btservices')
  parser.add_argument(
      '--java', action='store_true', help='generate Java coverage')
  parser.add_argument(
      '--native', action='store_true', help='generate native coverage')
  parser.add_argument(
      '--out',
      type=str,
      default='out_coverage',
      help='out directory for coverage reports. Default: ./out_coverage')
  parser.add_argument(
      '--trace',
      type=str,
      default='trace',
      help='trace directory with .ec and .profraw files. Default: ./trace')
  parser.add_argument(
      '--full-report',
      action='store_true',
      help='run all tests and compute coverage report')
  args = parser.parse_args()

  coverage_out = Path(args.out)
  shutil.rmtree(coverage_out, ignore_errors=True)
  coverage_out.mkdir()

  if not args.full_report:
    trace_path = Path(args.trace)
    if (not trace_path.exists() or not trace_path.is_dir()):
      sys.exit('Trace directory does not exist')

    if (args.java):
      generate_java_coverage(args.apex_name, trace_path, coverage_out)
    if (args.native):
      generate_native_coverage(args.apex_name, trace_path, coverage_out)

  else:
    # Compute Pandora coverage.
    run_pts_bot()
    coverage_out_pandora = Path(f'{coverage_out}/pandora')
    coverage_out_pandora.mkdir()
    trace_pandora = Path('trace_pandora')
    shutil.rmtree(trace_pandora, ignore_errors=True)
    subprocess.run(['adb', 'pull', '/data/misc/trace', trace_pandora])
    generate_java_coverage(args.apex_name, trace_pandora,
                           coverage_out_pandora)
    generate_native_coverage(args.apex_name, trace_pandora,
                             coverage_out_pandora)

    # # Compute all coverage.
    run_unit_tests()
    coverage_out_mainline = Path(f'{coverage_out}/mainline')
    coverage_out_mainline.mkdir()
    trace_all = Path('trace_all')
    shutil.rmtree(trace_all, ignore_errors=True)
    subprocess.run(['adb', 'pull', '/data/misc/trace', trace_all])
    generate_java_coverage(args.apex_name, trace_all, coverage_out_mainline)
    generate_native_coverage(args.apex_name, trace_all,
                             coverage_out_mainline)
+73 −2
Original line number Diff line number Diff line
@@ -101,21 +101,92 @@ def generate_service(imports, file, service):
        f'    {methods}\n'
    ).split('\n')

def generate_servicer_method(method):
    input_mode = 'stream' if method.client_streaming else 'unary'

    if input_mode == 'stream':
        return (
            f'def {method.name}(self, request_iterator, context):\n'
            f'    context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n'
            f'    context.set_details("Method not implemented!")\n'
            f'    raise NotImplementedError("Method not implemented!")'
        ).split('\n')
    else:
        return (
            f'def {method.name}(self, request, context):\n'
            f'    context.set_code(grpc.StatusCode.UNIMPLEMENTED)\n'
            f'    context.set_details("Method not implemented!")\n'
            f'    raise NotImplementedError("Method not implemented!")'
        ).split('\n')


def generate_servicer(service):
    methods = '\n\n    '.join([
        '\n    '.join(
            generate_servicer_method(method)
        ) for method in service.method
    ])
    if len(methods) == 0:
        methods = 'pass'
    return (
        f'class {service.name}Servicer:\n'
        f'\n'
        f'    {methods}\n'
    ).split('\n')

def generate_rpc_method_handler(imports, method):
    input_mode = 'stream' if method.client_streaming else 'unary'
    output_mode = 'stream' if method.server_streaming else 'unary'

    input_type = import_type(imports, method.input_type)
    output_type = import_type(imports, method.output_type)

    return (
        f"'{method.name}': grpc.{input_mode}_{output_mode}_rpc_method_handler(\n"
        f'        servicer.{method.name},\n'
        f'        request_deserializer={input_type}.FromString,\n'
        f'        response_serializer={output_type}.SerializeToString,\n'
        f'    ),\n'
    ).split('\n')

def generate_add_servicer_to_server_method(imports, file, service):
    method_handlers = '    '.join([
        '\n    '.join(
            generate_rpc_method_handler(imports, method)
        ) for method in service.method
    ])
    return (
        f'def add_{service.name}Servicer_to_server(servicer, server):\n'
        f'    rpc_method_handlers = {{\n'
        f'        {method_handlers}\n'
        f'    }}\n'
        f'    generic_handler = grpc.method_handlers_generic_handler(\n'
        f"        '{file.package}.{service.name}', rpc_method_handlers)\n"
        f'    server.add_generic_rpc_handlers((generic_handler,))'
    ).split('\n')

files = []

for file_name in request.file_to_generate:
    file = next(filter(lambda x: x.name == file_name, request.proto_file))

    imports = set([])
    imports = set(['import grpc'])

    services = '\n'.join(sum([
        generate_service(imports, file, service) for service in file.service
    ], []))

    servicers = '\n'.join(sum([
        generate_servicer(service) for service in file.service
    ], []))

    add_servicer_methods = '\n'.join(sum([
        generate_add_servicer_to_server_method(imports, file, service) for service in file.service
    ], []))

    files.append(CodeGeneratorResponse.File(
        name=file_name.replace('.proto', '_grpc.py'),
        content='\n'.join(imports) + '\n\n' + services
        content='\n'.join(imports) + '\n\n' + services  + '\n\n' + servicers + '\n\n' + add_servicer_methods + '\n'
    ))

reponse = CodeGeneratorResponse(file=files)
+32 −17
Original line number Diff line number Diff line
@@ -25,18 +25,20 @@ import grpc
from mmi2grpc.a2dp import A2DPProxy
from mmi2grpc.avrcp import AVRCPProxy
from mmi2grpc.gatt import GATTProxy
from mmi2grpc.gap import GAPProxy
from mmi2grpc.hfp import HFPProxy
from mmi2grpc.hid import HIDProxy
from mmi2grpc.hogp import HOGPProxy
from mmi2grpc.l2cap import L2CAPProxy
from mmi2grpc.sdp import SDPProxy
from mmi2grpc.sm import SMProxy
from mmi2grpc._rootcanal import RootCanal
from mmi2grpc._helpers import format_proxy
from mmi2grpc._rootcanal import RootCanal

from pandora_experimental.host_grpc import Host

GRPC_PORT = 8999
PANDORA_SERVER_PORT = 8999
ROOTCANAL_CONTROL_PORT = 6212
MAX_RETRIES = 10
GRPC_SERVER_INIT_TIMEOUT = 10  # seconds

@@ -48,15 +50,15 @@ class IUT:
    proxy which translates MMI calls to gRPC calls to the IUT.
    """

    def __init__(self, test: str, args: List[str], port: int = GRPC_PORT, **kwargs):
    def __init__(self, test: str, args: List[str], **kwargs):
        """Init IUT class for a given test.

        Args:
            test: PTS test id.
            args: test arguments.
            port: gRPC port exposed by the IUT test server.
        """
        self.port = port
        self.pandora_server_port = int(args[0]) if len(args) > 0 else PANDORA_SERVER_PORT
        self.rootcanal_control_port = int(args[1]) if len(args) > 1 else ROOTCANAL_CONTROL_PORT
        self.test = test
        self.rootcanal = None

@@ -64,21 +66,22 @@ class IUT:
        self._a2dp = None
        self._avrcp = None
        self._gatt = None
        self._gap = None
        self._hfp = None
        self._hid = None
        self._hogp = None
        self._l2cap = None
        self._sdp = None
        self._sm = None

    def __enter__(self):
        """Resets the IUT when starting a PTS test."""
        self.rootcanal = RootCanal()
        self.rootcanal = RootCanal(port=self.rootcanal_control_port)
        self.rootcanal.reconnect_phone()

        # Note: we don't keep a single gRPC channel instance in the IUT class
        # because reset is allowed to close the gRPC server.
        RootCanal().reconnect_phone()
        with grpc.insecure_channel(f'localhost:{self.port}') as channel:
        with grpc.insecure_channel(f'localhost:{self.pandora_server_port}') as channel:
            self._retry(Host(channel).HardReset)(wait_for_ready=True)

    def __exit__(self, exc_type, exc_value, exc_traceback):
@@ -88,7 +91,9 @@ class IUT:
        self._a2dp = None
        self._avrcp = None
        self._gatt = None
        self._gap = None
        self._hfp = None
        self._l2cap = None
        self._hid = None
        self._hogp = None
        self._sdp = None
@@ -120,7 +125,7 @@ class IUT:
        mut_address = None

        def read_local_address():
            with grpc.insecure_channel(f'localhost:{self.port}') as channel:
            with grpc.insecure_channel(f'localhost:{self.pandora_server_port}') as channel:
                nonlocal mut_address
                mut_address = self._retry(Host(channel).ReadLocalAddress)(wait_for_ready=True).address

@@ -150,42 +155,52 @@ class IUT:
        # Handles A2DP and AVDTP MMIs.
        if profile in ('A2DP', 'AVDTP'):
            if not self._a2dp:
                self._a2dp = A2DPProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._a2dp = A2DPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._a2dp.interact(test, interaction, description, pts_address)
        # Handles AVRCP and AVCTP MMIs.
        if profile in ('AVRCP', 'AVCTP'):
            if not self._avrcp:
                self._avrcp = AVRCPProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._avrcp = AVRCPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._avrcp.interact(test, interaction, description, pts_address)
        # Handles GATT MMIs.
        if profile in ('GATT'):
            if not self._gatt:
                self._gatt = GATTProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._gatt = GATTProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._gatt.interact(test, interaction, description, pts_address)
        # Handles GAP MMIs.
        if profile in ('GAP'):
            if not self._gap:
                self._gap = GAPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._gap.interact(test, interaction, description, pts_address)
        # Handles HFP MMIs.
        if profile in ('HFP'):
            if not self._hfp:
                self._hfp = HFPProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._hfp = HFPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._hfp.interact(test, interaction, description, pts_address)
        # Handles HID MMIs.
        if profile in ('HID'):
            if not self._hid:
                self._hid = HIDProxy(grpc.insecure_channel(f'localhost:{self.port}'), self.rootcanal)
                self._hid = HIDProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'), self.rootcanal)
            return self._hid.interact(test, interaction, description, pts_address)
        # Handles HOGP MMIs.
        if profile in ('HOGP'):
            if not self._hogp:
                self._hogp = HOGPProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._hogp = HOGPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._hogp.interact(test, interaction, description, pts_address)
        # Instantiates L2CAP proxy and reroutes corresponding MMIs to it.
        if profile in ('L2CAP'):
            if not self._l2cap:
                self._l2cap = L2CAPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._l2cap.interact(test, interaction, description, pts_address)
        # Handles SDP MMIs.
        if profile in ('SDP'):
            if not self._sdp:
                self._sdp = SDPProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._sdp = SDPProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._sdp.interact(test, interaction, description, pts_address)
        # Handles SM MMIs.
        if profile in ('SM'):
            if not self._sm:
                self._sm = SMProxy(grpc.insecure_channel(f'localhost:{self.port}'))
                self._sm = SMProxy(grpc.insecure_channel(f'localhost:{self.pandora_server_port}'))
            return self._sm.interact(test, interaction, description, pts_address)

        # Handles unsupported profiles.
+1 −1
Original line number Diff line number Diff line
@@ -115,7 +115,7 @@ def format_proxy(profile, mmi_name, mmi_description):
            f'class {profile}Proxy(ProfileProxy):\n'
            f'\n'
            f'    def __init__(self, channel):\n'
            f'        super().__init__()\n'
            f'        super().__init__(channel)\n'
            f'        self.{profile.lower()} = {profile}(channel)\n'
            f'\n'
            f'{wrapped_function}')
Loading