diff --git a/.github/workflows/python-sanity.yml b/.github/workflows/python-sanity.yml index 3de7b03..d5313d6 100644 --- a/.github/workflows/python-sanity.yml +++ b/.github/workflows/python-sanity.yml @@ -37,3 +37,6 @@ jobs: - name: pylint run: python3 -m pylint --enable=E nvmet/ nvmetcli + + - name: mypy + run: python3 -m mypy diff --git a/nvmet/__init__.py b/nvmet/__init__.py index 7566dbd..4380c17 100644 --- a/nvmet/__init__.py +++ b/nvmet/__init__.py @@ -1,5 +1,10 @@ """ NVMe-oF Target configfs-based kernel driver API """ -from .nvme import (ANAGroup, DEFAULT_SAVE_FILE, Host, Namespace, # noqa: F401 - Passthru, Port, Referral, Root, Subsystem, CFSError) +from .nvme import (ANAGroup, CFSError, CFSNode, DEFAULT_SAVE_FILE, Host, + Namespace, Passthru, Port, Referral, Root, Subsystem) + +__all__ = [ + 'ANAGroup', 'CFSError', 'CFSNode', 'DEFAULT_SAVE_FILE', 'Host', + 'Namespace', 'Passthru', 'Port', 'Referral', 'Root', 'Subsystem', +] diff --git a/nvmet/nvme.py b/nvmet/nvme.py index ecd26af..5397a00 100644 --- a/nvmet/nvme.py +++ b/nvmet/nvme.py @@ -18,6 +18,8 @@ under the License. ''' +from __future__ import annotations + import os import stat import uuid @@ -26,9 +28,12 @@ import shlex from doctest import testmod from glob import iglob as glob +from typing import Any, Callable, Iterator DEFAULT_SAVE_FILE = '/etc/nvmet/config.json' +ErrFunc = Callable[[str], None] + class CFSError(Exception): ''' @@ -54,30 +59,34 @@ class CFSNode: configfs_dir = '/sys/kernel/config/nvmet' - def __init__(self): - self._path = self.configfs_dir - self._enable = None - self.attr_groups = [] + def __init__(self) -> None: + self._path: str = self.configfs_dir + self._enable: int | None = None + self.attr_groups: list[str] = [] - def __eq__(self, other): + def __eq__(self, other: object) -> bool: ''' Checks if two CFSNode objects are equal. ''' + if not isinstance(other, CFSNode): + return NotImplemented return self._path == other._path - def __ne__(self, other): + def __ne__(self, other: object) -> bool: ''' Checks if two CFSNode objects are not equal. ''' + if not isinstance(other, CFSNode): + return NotImplemented return self._path != other._path - def _get_path(self): + def _get_path(self) -> str: ''' Returns the path of the CFSNode. ''' return self._path - def _create_in_cfs(self, mode): + def _create_in_cfs(self, mode: str) -> None: ''' Creates the configFS node if it does not already exist, depending on the mode. @@ -102,13 +111,13 @@ def _create_in_cfs(self, mode): f" in configFS") from exc self.get_enable() - def _exists(self): + def _exists(self) -> bool: ''' Returns True if the CFSNode exists, False otherwise. ''' return os.path.isdir(self.path) - def _check_self(self): + def _check_self(self) -> None: ''' Checks if the CFSNode exists. ''' @@ -116,7 +125,7 @@ def _check_self(self): raise CFSNotFound(f"This {self.__class__.__name__} does not " f"exist in configFS") - def list_attrs(self, group, writable=None): + def list_attrs(self, group: str, writable: bool | None = None) -> list[str]: ''' @param group: The attribute group @param writable: If None (default), returns all attributes, if True, @@ -141,14 +150,14 @@ def list_attrs(self, group, writable=None): names.sort() return names - def _attr_is_writable(self, group, name): + def _attr_is_writable(self, group: str, name: str) -> int: ''' Returns True if the attribute is writable, False otherwise. ''' s = os.stat(f"{self._path}/{group}_{name}") return s[stat.ST_MODE] & stat.S_IWUSR - def set_attr(self, group, attribute, value): + def set_attr(self, group: str, attribute: str, value: Any) -> None: ''' Sets the value of a named attribute. The attribute must exist in configFS. @@ -173,7 +182,7 @@ def set_attr(self, group, attribute, value): except OSError as e: raise CFSError(f"Cannot set attribute {path}: {e}") from e - def get_attr(self, group, attribute): + def get_attr(self, group: str, attribute: str) -> str: ''' Gets the value of a named attribute. @param group: The attribute group @@ -188,7 +197,7 @@ def get_attr(self, group, attribute): with open(path, 'r', encoding="utf-8") as file_fd: return file_fd.read().strip() - def get_enable(self): + def get_enable(self) -> int | None: ''' Returns the value of the 'enable' attribute. ''' @@ -201,7 +210,7 @@ def get_enable(self): self._enable = int(file_fd.read().strip()) return self._enable - def set_enable(self, value): + def set_enable(self, value: Any) -> None: ''' Sets the value of the 'enable' attribute. ''' @@ -218,7 +227,7 @@ def set_enable(self, value): raise CFSError(f"Cannot enable {self.path}: {e} ({value})") from e self._enable = value - def delete(self): + def delete(self) -> None: ''' If the underlying configFS object does not exist, this method does nothing. If the underlying configFS object exists, this method attempts @@ -235,11 +244,11 @@ def delete(self): + " deleted either by calling the delete() method, or by" + " any other means, it will be False.") - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' - d = {} + d: dict[str, Any] = {} for group in self.attr_groups: a = {} for i in self.list_attrs(group, writable=True): @@ -249,7 +258,8 @@ def dump(self): d['enable'] = self._enable return d - def _setup_attrs(self, attr_dict, err_func): + def _setup_attrs(self, attr_dict: dict[str, Any], + err_func: ErrFunc) -> None: ''' Set up attributes from a dict. ''' @@ -268,7 +278,7 @@ class Root(CFSNode): ''' The root of the NVMe target configfs hierarchy. ''' - def __init__(self): + def __init__(self) -> None: super().__init__() self.attr_groups = ['discovery'] @@ -281,7 +291,7 @@ def __init__(self): self._path = self.configfs_dir self._create_in_cfs('lookup') - def _modprobe(self, modname): + def _modprobe(self, modname: str) -> None: ''' Load a kernel module. ''' @@ -298,7 +308,7 @@ def _modprobe(self, modname): except OSError: pass - def _list_subsystems(self): + def _list_subsystems(self) -> Iterator[Subsystem]: self._check_self() for d in os.listdir(f"{self._path}/subsystems/"): @@ -307,7 +317,7 @@ def _list_subsystems(self): subsystems = property(_list_subsystems, doc="Get the list of Subsystems.") - def _list_ports(self): + def _list_ports(self) -> Iterator[Port]: self._check_self() for d in os.listdir(f"{self._path}/ports/"): @@ -316,7 +326,7 @@ def _list_ports(self): ports = property(_list_ports, doc="Get the list of Ports.") - def _list_hosts(self): + def _list_hosts(self) -> Iterator[Host]: self._check_self() for h in os.listdir(f"{self._path}/hosts/"): @@ -325,7 +335,7 @@ def _list_hosts(self): hosts = property(_list_hosts, doc="Get the list of Hosts.") - def save_to_file(self, savefile=None): + def save_to_file(self, savefile: str | None = None) -> None: ''' Write the configuration in json format to a file. ''' @@ -357,7 +367,7 @@ def save_to_file(self, savefile=None): if dir_fd: os.close(dir_fd) - def clear_existing(self): + def clear_existing(self) -> None: ''' Remove entire current configuration. ''' @@ -369,7 +379,8 @@ def clear_existing(self): for h in self.hosts: h.delete() - def restore(self, config, clear_existing=False, abort_on_error=False): + def restore(self, config: dict[str, Any], clear_existing: bool = False, + abort_on_error: bool = False) -> list[str]: ''' Takes a dict generated by dump() and reconfigures the target to match. Returns list of non-fatal errors that were encountered. @@ -382,13 +393,14 @@ def restore(self, config, clear_existing=False, abort_on_error=False): if any(self.subsystems): raise CFSError("subsystems present, not restoring") - errors = [] + errors: list[str] = [] + err_func: ErrFunc if abort_on_error: - def err_func(err_str): + def err_func(err_str: str) -> None: raise CFSError(err_str) else: - def err_func(err_str): + def err_func(err_str: str) -> None: errors.append(err_str + ", skipped") # Create the hosts first because the subsystems reference them @@ -415,8 +427,9 @@ def err_func(err_str): return errors - def restore_from_file(self, savefile=None, clear_existing=True, - abort_on_error=False): + def restore_from_file(self, savefile: str | None = None, + clear_existing: bool = True, + abort_on_error: bool = False) -> list[str]: ''' Restore the configuration from a file in json format. Returns a list of non-fatal errors. If abort_on_error is set, @@ -432,7 +445,7 @@ def restore_from_file(self, savefile=None, clear_existing=True, return self.restore(config, clear_existing=clear_existing, abort_on_error=abort_on_error) - def dump(self): + def dump(self) -> dict[str, Any]: d = super().dump() d['subsystems'] = [s.dump() for s in self.subsystems] d['ports'] = [p.dump() for p in self.ports] @@ -446,10 +459,10 @@ class Subsystem(CFSNode): A Subsystem is identified by its NQN. ''' - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, nqn=None, mode='any'): + def __init__(self, nqn: str | None = None, mode: str = 'any') -> None: ''' @param nqn: The Subsystems' NQN. If no NQN is specified, one will be generated. @@ -474,7 +487,7 @@ def __init__(self, nqn=None, mode='any'): self._path = f"{self.configfs_dir}/subsystems/{nqn}" self._create_in_cfs(mode) - def _generate_nqn(self): + def _generate_nqn(self) -> str: ''' Generates a new NQN. ''' @@ -482,7 +495,7 @@ def _generate_nqn(self): name = str(uuid.uuid4()) return f"{prefix}:{name}" - def delete(self): + def delete(self) -> None: ''' Recursively deletes a Subsystem object. This will delete all attached Namespace objects and then the @@ -495,7 +508,7 @@ def delete(self): self.remove_allowed_host(h) super().delete() - def _list_namespaces(self): + def _list_namespaces(self) -> Iterator[Namespace]: ''' Lists the namespaces of the subsystem. ''' @@ -506,7 +519,7 @@ def _list_namespaces(self): namespaces = property(_list_namespaces, doc="Get the list of Namespaces for the Subsystem.") - def _get_passthru(self): + def _get_passthru(self) -> Passthru: ''' Returns the passthru object of the subsystem. ''' @@ -516,7 +529,7 @@ def _get_passthru(self): passthru = property(_get_passthru, doc="Get the passthru node for the subsystem") - def _list_allowed_hosts(self): + def _list_allowed_hosts(self) -> list[str]: ''' Lists the allowed hosts of the subsystem. ''' @@ -527,7 +540,7 @@ def _list_allowed_hosts(self): doc="Get the list of Allowed Hosts for the " + "Subsystem.") - def add_allowed_host(self, nqn): + def add_allowed_host(self, nqn: str) -> None: ''' Enable access for the host identified by I{nqn} to the Subsystem ''' @@ -537,7 +550,7 @@ def add_allowed_host(self, nqn): except OSError as e: raise CFSError(f"Could not symlink {nqn} in configFS: {e}") from e - def remove_allowed_host(self, nqn): + def remove_allowed_host(self, nqn: str) -> None: ''' Disable access for the host identified by I{nqn} to the Subsystem ''' @@ -546,14 +559,14 @@ def remove_allowed_host(self, nqn): except OSError as e: raise CFSError(f"Could not unlink {nqn} in configFS: {e}") from e - def has_passthru(self): + def has_passthru(self) -> bool: ''' Check if the subsystem has a passthru node. ''' return os.path.isdir(os.path.join(self.path, "passthru")) @classmethod - def setup(cls, t, err_func): + def setup(cls, t: dict[str, Any], err_func: ErrFunc) -> None: ''' Set up Subsystem objects based upon t dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -579,7 +592,7 @@ def setup(cls, t, err_func): s._setup_attrs(t, err_func) - def dump(self): + def dump(self) -> dict[str, Any]: d = super().dump() d['nqn'] = self.nqn d['namespaces'] = [ns.dump() for ns in self.namespaces] @@ -597,10 +610,11 @@ class Namespace(CFSNode): MAX_NSID = 8192 - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, subsystem, nsid=None, mode='any'): + def __init__(self, subsystem: Subsystem, nsid: int | None = None, + mode: str = 'any') -> None: ''' @param subsystem: The parent Subsystem object @param nsid: The Namespace identifier @@ -641,19 +655,19 @@ def __init__(self, subsystem, nsid=None, mode='any'): self._path = f"{self.subsystem.path}/namespaces/{self.nsid}" self._create_in_cfs(mode) - def _get_subsystem(self): + def _get_subsystem(self) -> Subsystem: ''' Returns the parent subsystem. ''' return self._subsystem - def _get_nsid(self): + def _get_nsid(self) -> int: ''' Returns the namespace ID. ''' return self._nsid - def _get_grpid(self): + def _get_grpid(self) -> int: ''' Returns the ANA group ID. ''' @@ -665,7 +679,7 @@ def _get_grpid(self): _grpid = int(file_fd.read().strip()) return _grpid - def set_grpid(self, grpid): + def set_grpid(self, grpid: Any) -> None: ''' Sets the ANA group ID. ''' @@ -682,7 +696,8 @@ def set_grpid(self, grpid): nsid = property(_get_nsid, doc="Get the NSID as an int.") @classmethod - def setup(cls, subsys, n, err_func): + def setup(cls, subsys: Subsystem, n: dict[str, Any], + err_func: ErrFunc) -> None: ''' Set up a Namespace object based upon n dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -703,7 +718,7 @@ def setup(cls, subsys, n, err_func): if 'ana_grpid' in n: ns.set_grpid(int(n['ana_grpid'])) - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -719,7 +734,7 @@ class Passthru(CFSNode): A Passthru is identified by its parent Subsystem. ''' - def __init__(self, subsystem): + def __init__(self, subsystem: Subsystem) -> None: ''' @param subsystem: The parent Subsystem object. @return: A Passthru object. @@ -728,7 +743,7 @@ def __init__(self, subsystem): self._path = f"{subsystem.path}/passthru" self.attr_groups = ['device'] - def _get_clear_ids(self): + def _get_clear_ids(self) -> int: ''' Get the passthru namespace clear_ids attribute. ''' @@ -743,7 +758,7 @@ def _get_clear_ids(self): ids = property(_get_clear_ids, doc="Get the passthru namespace clear_ids attribute.") - def set_clear_ids(self, clear): + def set_clear_ids(self, clear: Any) -> None: ''' Set the passthru namespace clear_ids attribute. ''' @@ -753,7 +768,7 @@ def set_clear_ids(self, clear): with open(path, 'w', encoding="utf-8") as file_fd: file_fd.write(str(clear)) - def _get_admin_timeout(self): + def _get_admin_timeout(self) -> int: ''' Get the passthru admin command timeout. ''' @@ -768,7 +783,7 @@ def _get_admin_timeout(self): admin_timeout = property(_get_admin_timeout, doc="Get the passthru admin command timeout.") - def set_admin_timeout(self, timeout): + def set_admin_timeout(self, timeout: Any) -> None: ''' Set the passthru admin command timeout. ''' @@ -778,7 +793,7 @@ def set_admin_timeout(self, timeout): with open(path, 'w', encoding="utf-8") as file_fd: file_fd.write(str(timeout)) - def _get_io_timeout(self): + def _get_io_timeout(self) -> int: ''' Get the passthru IO command timeout. ''' @@ -793,7 +808,7 @@ def _get_io_timeout(self): io_timeout = property(_get_io_timeout, doc="Get the passthru IO command timeout.") - def set_io_timeout(self, timeout): + def set_io_timeout(self, timeout: Any) -> None: ''' Set the passthru IO command timeout. ''' @@ -804,7 +819,8 @@ def set_io_timeout(self, timeout): file_fd.write(str(timeout)) @classmethod - def setup(cls, subsys, p, err_func): + def setup(cls, subsys: Subsystem, p: dict[str, Any], + err_func: ErrFunc) -> None: ''' Set up a Passthru object based upon p dict, from saved config. ''' @@ -821,7 +837,7 @@ def setup(cls, subsys, p, err_func): if 'io_timeout' in p: pt.set_io_timeout(int(p['io_timeout'])) - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -839,10 +855,10 @@ class Port(CFSNode): MAX_PORTID = 8192 - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, portid, mode='any'): + def __init__(self, portid: Any, mode: str = 'any') -> None: super().__init__() self.attr_groups = ['addr', 'param'] @@ -850,7 +866,7 @@ def __init__(self, portid, mode='any'): self._path = f"{self.configfs_dir}/ports/{self._portid}" self._create_in_cfs(mode) - def _get_portid(self): + def _get_portid(self) -> int: ''' Returns the port ID. ''' @@ -858,7 +874,7 @@ def _get_portid(self): portid = property(_get_portid, doc="Get the Port ID as an int.") - def _list_subsystems(self): + def _list_subsystems(self) -> list[str]: ''' Lists the subsystems of the port. ''' @@ -868,7 +884,7 @@ def _list_subsystems(self): subsystems = property(_list_subsystems, doc="Get the list of Subsystem for this Port.") - def add_subsystem(self, nqn): + def add_subsystem(self, nqn: str) -> None: ''' Enable access to the Subsystem identified by I{nqn} through this Port. ''' @@ -878,7 +894,7 @@ def add_subsystem(self, nqn): except OSError as e: raise CFSError(f"Could not symlink {nqn} in configFS: {e}") from e - def remove_subsystem(self, nqn): + def remove_subsystem(self, nqn: str) -> None: ''' Disable access to the Subsystem identified by I{nqn} through this Port. ''' @@ -887,7 +903,7 @@ def remove_subsystem(self, nqn): except OSError as e: raise CFSError(f"Could not unlink {nqn} in configFS: {e}") from e - def delete(self): + def delete(self) -> None: ''' Recursively deletes a Port object. ''' @@ -900,7 +916,7 @@ def delete(self): r.delete() super().delete() - def _list_referrals(self): + def _list_referrals(self) -> Iterator[Referral]: ''' Lists the referrals of the port. ''' @@ -911,7 +927,7 @@ def _list_referrals(self): referrals = property(_list_referrals, doc="Get the list of Referrals for this Port.") - def _list_ana_groups(self): + def _list_ana_groups(self) -> Iterator[ANAGroup]: ''' Lists the ANA groups of the port. ''' @@ -924,7 +940,7 @@ def _list_ana_groups(self): doc="Get the list of ANA Groups for this Port.") @classmethod - def setup(cls, n, err_func): + def setup(cls, n: dict[str, Any], err_func: ErrFunc) -> None: ''' Set up a Port object based upon n dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -949,7 +965,7 @@ def setup(cls, n, err_func): for r in n.get('referrals', []): Referral.setup(port, r, err_func) - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -966,10 +982,10 @@ class Referral(CFSNode): This is an interface to a NVMe Referral in configFS. ''' - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, port, name, mode='any'): + def __init__(self, port: Port, name: str, mode: str = 'any') -> None: super().__init__() if not isinstance(port, Port): @@ -981,7 +997,7 @@ def __init__(self, port, name, mode='any'): self._path = f"{self.port.path}/referrals/{self._name}" self._create_in_cfs(mode) - def _get_name(self): + def _get_name(self) -> str: ''' Returns the name of the referral. ''' @@ -990,7 +1006,7 @@ def _get_name(self): name = property(_get_name, doc="Get the Referral name.") @classmethod - def setup(cls, port, n, err_func): + def setup(cls, port: Port, n: dict[str, Any], err_func: ErrFunc) -> None: ''' Set up a Referral based upon n dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -1009,7 +1025,7 @@ def setup(cls, port, n, err_func): r._setup_attrs(n, err_func) - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -1025,10 +1041,11 @@ class ANAGroup(CFSNode): MAX_GRPID = 1024 - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, port, grpid, mode='any'): + def __init__(self, port: Port, grpid: int | None, + mode: str = 'any') -> None: super().__init__() if not os.path.isdir(f"{port.path}/ana_groups"): @@ -1056,7 +1073,7 @@ def __init__(self, port, grpid, mode='any'): self._path = f"{self._port.path}/ana_groups/{self.grpid}" self._create_in_cfs(mode) - def _get_grpid(self): + def _get_grpid(self) -> int: ''' Returns the ANA group ID. ''' @@ -1065,7 +1082,7 @@ def _get_grpid(self): grpid = property(_get_grpid, doc="Get the ANA Group ID.") @classmethod - def setup(cls, port, n, err_func): + def setup(cls, port: Port, n: dict[str, Any], err_func: ErrFunc) -> None: ''' Set up an ANA Group object based upon n dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -1084,7 +1101,7 @@ def setup(cls, port, n, err_func): a._setup_attrs(n, err_func) - def delete(self): + def delete(self) -> None: ''' Deletes the ANA group. ''' @@ -1092,7 +1109,7 @@ def delete(self): if self.grpid != 1: super().delete() - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -1107,10 +1124,10 @@ class Host(CFSNode): A Host is identified by its NQN. ''' - def __repr__(self): + def __repr__(self) -> str: return f"" - def __init__(self, nqn, mode='any'): + def __init__(self, nqn: str, mode: str = 'any') -> None: ''' @param nqn: The Hosts's NQN. @type nqn: string @@ -1130,7 +1147,7 @@ def __init__(self, nqn, mode='any'): self._create_in_cfs(mode) @classmethod - def setup(cls, t, err_func): + def setup(cls, t: dict[str, Any], err_func: ErrFunc) -> None: ''' Set up Host objects based upon t dict, from saved config. Guard against missing or bad dict items, but keep going. @@ -1149,7 +1166,7 @@ def setup(cls, t, err_func): h._setup_attrs(t, err_func) - def dump(self): + def dump(self) -> dict[str, Any]: ''' Returns a dict with the config of the object. ''' @@ -1158,7 +1175,7 @@ def dump(self): return d -def _test(): +def _test() -> None: testmod() diff --git a/nvmet/test_nvmet.py b/nvmet/test_nvmet.py index 7c8900a..7a17410 100644 --- a/nvmet/test_nvmet.py +++ b/nvmet/test_nvmet.py @@ -2,6 +2,8 @@ Tests for the nvmet API. """ +from __future__ import annotations + import os import random import stat @@ -20,7 +22,7 @@ TEMP_BACKING_FILE_SIZE = 512 * 1024 * 1024 -def _is_usable_device(path): +def _is_usable_device(path: str) -> bool: ''' Return True if 'path' is usable as a namespace backing device (a block device or a regular file). Any OSError from stat() (missing @@ -34,7 +36,7 @@ def _is_usable_device(path): return stat.S_ISBLK(st.st_mode) or stat.S_ISREG(st.st_mode) -def _usable_devices(): +def _usable_devices() -> list[str]: ''' Return the subset of NVMET_TEST_DEVICES that are usable as a namespace backing device. @@ -42,7 +44,7 @@ def _usable_devices(): return [x for x in NVMET_TEST_DEVICES if _is_usable_device(x)] -def _make_temp_backing_file(): +def _make_temp_backing_file() -> str: ''' Create a temporary sparse file suitable for use as a namespace backing file, equivalent to 'truncate --size=512M'. @@ -53,7 +55,7 @@ def _make_temp_backing_file(): return path -def get_test_devices(count, testcase): +def get_test_devices(count: int, testcase: unittest.TestCase) -> list[str]: ''' Return a list of 'count' backing devices to use for namespaces. @@ -73,7 +75,7 @@ class TestNvmet(unittest.TestCase): ''' Tests for the nvmet API. ''' - def test_subsystem(self): + def test_subsystem(self) -> None: ''' Test Subsystem creation and deletion. ''' @@ -120,7 +122,7 @@ def test_subsystem(self): s.delete() self.assertEqual(len(list(root.subsystems)), 0) - def test_namespace(self): + def test_namespace(self) -> None: ''' Test Namespace creation and deletion. ''' @@ -174,7 +176,7 @@ def test_namespace(self): n.delete() self.assertEqual(len(list(s.namespaces)), 0) - def test_namespace_attrs(self): + def test_namespace_attrs(self) -> None: ''' Test Namespace attributes. ''' @@ -215,7 +217,7 @@ def test_namespace_attrs(self): n.set_enable(1) n.delete() - def test_recursive_delete(self): + def test_recursive_delete(self) -> None: ''' Test recursive deletion of a Subsystem. ''' @@ -229,7 +231,7 @@ def test_recursive_delete(self): s.delete() self.assertEqual(len(list(root.subsystems)), 0) - def test_port(self): + def test_port(self) -> None: ''' Test Port creation and deletion. ''' @@ -268,7 +270,7 @@ def test_port(self): p.delete() self.assertEqual(len(list(root.ports)), 0) - def test_loop_port(self): + def test_loop_port(self) -> None: ''' Test loop port functionality. ''' @@ -324,7 +326,7 @@ def test_loop_port(self): p.add_subsystem('testnqn') p.delete() - def test_host(self): + def test_host(self) -> None: ''' Test Host creation and deletion. ''' @@ -363,7 +365,7 @@ def test_host(self): h.delete() self.assertEqual(len(list(root.hosts)), 0) - def test_referral(self): + def test_referral(self) -> None: ''' Test Referral creation and deletion. ''' @@ -449,7 +451,7 @@ def test_referral(self): r1.delete() self.assertEqual(len(list(p.referrals)), 0) - def test_allowed_hosts(self): + def test_allowed_hosts(self) -> None: ''' Test allowed hosts functionality. ''' @@ -477,7 +479,7 @@ def test_allowed_hosts(self): # invalid removal self.assertRaises(nvme.CFSError, s.remove_allowed_host, 'foobar') - def test_invalid_input(self): + def test_invalid_input(self) -> None: ''' Test invalid input to the API. ''' @@ -502,7 +504,7 @@ def test_invalid_input(self): self.assertRaises(nvme.CFSError, nvme.Port, portid=1 << 17, mode='create') - def test_save_restore(self): + def test_save_restore(self) -> None: ''' Test save and restore functionality. ''' diff --git a/nvmetcli b/nvmetcli index f39e8af..09b908e 100755 --- a/nvmetcli +++ b/nvmetcli @@ -18,18 +18,19 @@ License for the specific language governing permissions and limitations under the License. ''' -from __future__ import print_function +from __future__ import annotations import os import sys import errno from string import hexdigits +from typing import Any, Callable import uuid import configshell import nvmet as nvme -def nguid_set(nguid): +def nguid_set(nguid: str) -> bool: ''' Check if the nguid is set. ''' @@ -40,7 +41,9 @@ class UINode(configshell.ConfigNode): ''' A node in the UI. ''' - def __init__(self, name, parent=None, cfnode=None, shell=None): + def __init__(self, name: str, parent: UINode | None = None, + cfnode: nvme.CFSNode | None = None, + shell: Any = None) -> None: configshell.ConfigNode.__init__(self, name, parent, shell) self.cfnode = cfnode if self.cfnode: @@ -49,7 +52,8 @@ class UINode(configshell.ConfigNode): self._init_group(group) self.refresh() - def _init_group(self, group): + def _init_group(self, group: str) -> None: + assert self.cfnode is not None setattr(self.__class__, f"ui_getgroup_{group}", lambda self, attr: self.cfnode.get_attr(group, attr)) @@ -66,25 +70,25 @@ class UINode(configshell.ConfigNode): t, d = getattr(self.__class__, name, {}).get(attr, ('string', '')) self.define_config_group_param(group, attr, t, d, writable) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' - self._children = set([]) + self._children: set[UINode] = set() - def status(self): + def status(self) -> str: ''' Displays the current node's status summary. ''' return "None" - def ui_command_refresh(self): + def ui_command_refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' self.refresh() - def ui_command_status(self): + def ui_command_status(self) -> None: ''' Displays the current node's status summary. @@ -94,7 +98,7 @@ class UINode(configshell.ConfigNode): ''' self.shell.log.info(f"Status for {self.path}: {self.status()}") - def ui_command_saveconfig(self, savefile=None): + def ui_command_saveconfig(self, savefile: str | None = None) -> None: ''' Saves the current configuration to a file so that it can be restored on next boot. @@ -102,6 +106,7 @@ class UINode(configshell.ConfigNode): node = self while node.parent is not None: node = node.parent + assert isinstance(node.cfnode, nvme.Root) node.cfnode.save_to_file(savefile) @@ -113,11 +118,12 @@ class UIRootNode(UINode): 'nqn': ('string', 'Discovery NQN'), } - def __init__(self, shell): - UINode.__init__(self, '/', parent=None, cfnode=nvme.Root(), - shell=shell) + def __init__(self, shell: Any) -> None: + root = nvme.Root() + UINode.__init__(self, '/', parent=None, cfnode=root, shell=shell) + self.cfnode: nvme.Root = root - def summary(self): + def summary(self) -> tuple[str, bool]: ''' Returns a summary of the root node. ''' @@ -128,7 +134,7 @@ class UIRootNode(UINode): pass return (", ".join(info), True) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -137,7 +143,8 @@ class UIRootNode(UINode): UIPortsNode(self) UIHostsNode(self) - def ui_command_restoreconfig(self, savefile=None, clear_existing=False): + def ui_command_restoreconfig(self, savefile: str | None = None, + clear_existing: bool = False) -> None: ''' Restores configuration from a file. ''' @@ -153,10 +160,10 @@ class UISubsystemsNode(UINode): ''' A node in the UI representing the subsystems. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'subsystems', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -164,7 +171,7 @@ class UISubsystemsNode(UINode): for subsys in self.parent.cfnode.subsystems: UISubsystemNode(self, subsys) - def ui_command_create(self, nqn=None): + def ui_command_create(self, nqn: str | None = None) -> None: ''' Creates a new target. If I{nqn} is omitted, then the new Subsystem will be created using a randomly generated NQN. @@ -176,7 +183,7 @@ class UISubsystemsNode(UINode): subsystem = nvme.Subsystem(nqn, mode='create') UISubsystemNode(self, subsystem) - def ui_command_delete(self, nqn): + def ui_command_delete(self, nqn: str) -> None: ''' Recursively deletes the subsystem with the specified I{nqn}, and all objects hanging under it. @@ -200,10 +207,11 @@ class UISubsystemNode(UINode): 'version': ('string', 'Export version number to hosts'), } - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.Subsystem) -> None: UINode.__init__(self, cfnode.nqn, parent, cfnode) + self.cfnode: nvme.Subsystem = cfnode - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -213,7 +221,7 @@ class UISubsystemNode(UINode): if self.cfnode.has_passthru(): UIPassthruNode(self) - def summary(self): + def summary(self) -> tuple[str, bool]: ''' Returns a summary of the subsystem. ''' @@ -233,17 +241,18 @@ class UIPassthruNode(UINode): 'path': ('string', 'Passthru device path') } - def __init__(self, parent): + def __init__(self, parent: UISubsystemNode) -> None: passthru = nvme.Passthru(parent.cfnode) UINode.__init__(self, 'passthru', parent, passthru) + self.cfnode: nvme.Passthru = passthru - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' self._children = set([]) - def ui_command_enable(self): + def ui_command_enable(self) -> None: ''' Enables the passthru. ''' @@ -257,7 +266,7 @@ class UIPassthruNode(UINode): raise configshell.ExecutionError( "The passthru could not be enabled.") from exc - def ui_command_disable(self): + def ui_command_disable(self) -> None: ''' Disables the passthru. ''' @@ -271,7 +280,7 @@ class UIPassthruNode(UINode): raise configshell.ExecutionError( "The passthru could not be disabled.") from exc - def ui_command_clear_ids(self, clear_id): + def ui_command_clear_ids(self, clear_id: Any) -> None: ''' If I{clear_id} is set to non-zero then clears the passthru namespace unique identifiers EUI/GUID/UUID. @@ -282,7 +291,7 @@ class UIPassthruNode(UINode): raise configshell.ExecutionError( "Failed to set clear_ids for this passthru target.") from exc - def ui_command_admin_timeout(self, timeout): + def ui_command_admin_timeout(self, timeout: Any) -> None: ''' Sets the timeout of admin passthru command. ''' @@ -292,7 +301,7 @@ class UIPassthruNode(UINode): raise configshell.ExecutionError( "Failed to set the admin passthru command timeout.") from exc - def ui_command_io_timeout(self, timeout): + def ui_command_io_timeout(self, timeout: Any) -> None: ''' Sets the timeout of IO passthru command. ''' @@ -302,7 +311,7 @@ class UIPassthruNode(UINode): raise configshell.ExecutionError( "Failed to set the IO passthru command timeout.") from exc - def summary(self): + def summary(self) -> tuple[str, bool]: info = [] info.append("path=" + self.cfnode.get_attr("device", "path")) if self.cfnode.ids != 0: @@ -319,10 +328,10 @@ class UINamespacesNode(UINode): ''' A node in the UI representing the namespaces. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'namespaces', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -330,7 +339,7 @@ class UINamespacesNode(UINode): for ns in self.parent.cfnode.namespaces: UINamespaceNode(self, ns) - def ui_command_create(self, nsid=None): + def ui_command_create(self, nsid: Any | None = None) -> None: ''' Creates a new namespace. If I{nsid} is omitted, then the next available namespace id will be used. @@ -342,7 +351,7 @@ class UINamespacesNode(UINode): namespace = nvme.Namespace(self.parent.cfnode, nsid, mode='create') UINamespaceNode(self, namespace) - def ui_command_delete(self, nsid): + def ui_command_delete(self, nsid: Any) -> None: ''' Recursively deletes the namespace with the specified I{nsid}, and all objects hanging under it. @@ -366,10 +375,11 @@ class UINamespaceNode(UINode): 'uuid': ('string', 'Namespace Universally Unique Identifier.'), } - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.Namespace) -> None: UINode.__init__(self, str(cfnode.nsid), parent, cfnode) + self.cfnode: nvme.Namespace = cfnode - def status(self): + def status(self) -> str: ''' Returns the status of the namespace. ''' @@ -377,7 +387,7 @@ class UINamespaceNode(UINode): return "enabled" return "disabled" - def ui_command_enable(self): + def ui_command_enable(self) -> None: ''' Enables the current Namespace. @@ -395,7 +405,7 @@ class UINamespaceNode(UINode): raise configshell.ExecutionError( "The Namespace could not be enabled.") from exc - def ui_command_disable(self): + def ui_command_disable(self) -> None: ''' Disables the current Namespace. @@ -413,7 +423,7 @@ class UINamespaceNode(UINode): raise configshell.ExecutionError( "The Namespace could not be enabled.") from exc - def ui_command_grpid(self, grpid): + def ui_command_grpid(self, grpid: Any) -> None: ''' Sets the ANA Group ID of the current Namespace to I{grpid} ''' @@ -423,7 +433,7 @@ class UINamespaceNode(UINode): raise configshell.ExecutionError( "Failed to set ANA Group ID for this Namespace.") from exc - def summary(self): + def summary(self) -> tuple[str, Any]: info = [] info.append("path=" + self.cfnode.get_attr("device", "path")) ns_uuid = self.cfnode.get_attr("device", "uuid") @@ -448,10 +458,10 @@ class UIAllowedHostsNode(UINode): ''' A node in the UI representing the allowed hosts. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'allowed_hosts', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -459,7 +469,7 @@ class UIAllowedHostsNode(UINode): for host in self.parent.cfnode.allowed_hosts: UIAllowedHostNode(self, host) - def ui_command_create(self, nqn): + def ui_command_create(self, nqn: str) -> None: ''' Grants access to parent subsystems to the host specified by I{nqn}. @@ -470,7 +480,8 @@ class UIAllowedHostsNode(UINode): self.parent.cfnode.add_allowed_host(nqn) UIAllowedHostNode(self, nqn) - def ui_complete_create(self, _parameters, _text, current_param): + def ui_complete_create(self, _parameters: dict[str, Any], _text: str, + current_param: str) -> list[str]: ''' Completes the create command. ''' @@ -483,7 +494,7 @@ class UIAllowedHostsNode(UINode): return [f"{completions[0]} "] return completions - def ui_command_delete(self, nqn): + def ui_command_delete(self, nqn: str) -> None: ''' Recursively deletes the namespace with the specified I{nsid}, and all objects hanging under it. @@ -495,7 +506,8 @@ class UIAllowedHostsNode(UINode): self.parent.cfnode.remove_allowed_host(nqn) self.refresh() - def ui_complete_delete(self, _parameters, _text, current_param): + def ui_complete_delete(self, _parameters: dict[str, Any], _text: str, + current_param: str) -> list[str]: ''' Completes the delete command. ''' @@ -513,7 +525,7 @@ class UIAllowedHostNode(UINode): ''' A node in the UI representing an allowed host. ''' - def __init__(self, parent, nqn): + def __init__(self, parent: UINode, nqn: str) -> None: UINode.__init__(self, nqn, parent) @@ -521,10 +533,10 @@ class UIPortsNode(UINode): ''' A node in the UI representing the ports. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'ports', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -532,7 +544,7 @@ class UIPortsNode(UINode): for port in self.parent.cfnode.ports: UIPortNode(self, port) - def ui_command_create(self, portid=None): + def ui_command_create(self, portid: Any | None = None) -> None: ''' Creates a new NVMe port with portid I{portid}. @@ -543,7 +555,7 @@ class UIPortsNode(UINode): port = nvme.Port(portid, mode='create') UIPortNode(self, port) - def ui_command_delete(self, portid): + def ui_command_delete(self, portid: Any) -> None: ''' Recursively deletes the NVMe Port with the specified I{port}, and all objects hanging under it. @@ -573,8 +585,9 @@ class UIPortNode(UINode): 'inline_data_size': ('string', 'Port inline data size in bytes'), } - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.Port) -> None: UINode.__init__(self, str(cfnode.portid), parent, cfnode) + self.cfnode: nvme.Port = cfnode UIPortSubsystemsNode(self) try: next(cfnode.ana_groups) @@ -584,7 +597,7 @@ class UIPortNode(UINode): UIANAGroupsNode(self) UIReferralsNode(self) - def summary(self): + def summary(self) -> tuple[str, Any]: ''' Returns a summary of the port. ''' @@ -610,10 +623,10 @@ class UIPortSubsystemsNode(UINode): ''' A node in the UI representing the port subsystems. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'subsystems', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -621,7 +634,7 @@ class UIPortSubsystemsNode(UINode): for host in self.parent.cfnode.subsystems: UIPortSubsystemNode(self, host) - def ui_command_create(self, nqn): + def ui_command_create(self, nqn: str) -> None: ''' Grants access to the subsystem specified by I{nqn} through the parent port. @@ -633,7 +646,8 @@ class UIPortSubsystemsNode(UINode): self.parent.cfnode.add_subsystem(nqn) UIPortSubsystemNode(self, nqn) - def ui_complete_create(self, _parameters, _text, current_param): + def ui_complete_create(self, _parameters: dict[str, Any], _text: str, + current_param: str) -> list[str]: ''' Completes the create command. ''' @@ -646,7 +660,7 @@ class UIPortSubsystemsNode(UINode): return [f"{completions[0]} "] return completions - def ui_command_delete(self, nqn): + def ui_command_delete(self, nqn: str) -> None: ''' Removes access to the subsystem specified by I{nqn} through the parent port. @@ -658,7 +672,8 @@ class UIPortSubsystemsNode(UINode): self.parent.cfnode.remove_subsystem(nqn) self.refresh() - def ui_complete_delete(self, _parameters, _text, current_param): + def ui_complete_delete(self, _parameters: dict[str, Any], _text: str, + current_param: str) -> list[str]: ''' Completes the delete command. ''' @@ -676,7 +691,7 @@ class UIPortSubsystemNode(UINode): ''' A node in the UI representing a port subsystem. ''' - def __init__(self, parent, nqn): + def __init__(self, parent: UINode, nqn: str) -> None: UINode.__init__(self, nqn, parent) @@ -684,10 +699,10 @@ class UIReferralsNode(UINode): ''' A node in the UI representing the referrals. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'referrals', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -695,7 +710,7 @@ class UIReferralsNode(UINode): for r in self.parent.cfnode.referrals: UIReferralNode(self, r) - def ui_command_create(self, name): + def ui_command_create(self, name: str) -> None: ''' Creates a new referral. @@ -706,7 +721,7 @@ class UIReferralsNode(UINode): r = nvme.Referral(self.parent.cfnode, name, mode='create') UIReferralNode(self, r) - def ui_command_delete(self, name): + def ui_command_delete(self, name: str) -> None: ''' Deletes the referral with the specified I{name}. @@ -733,10 +748,11 @@ class UIReferralNode(UINode): 'portid': ('number', 'Port identifier'), } - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.Referral) -> None: UINode.__init__(self, cfnode.name, parent, cfnode) + self.cfnode: nvme.Referral = cfnode - def status(self): + def status(self) -> str: ''' Returns the status of the referral. ''' @@ -744,7 +760,7 @@ class UIReferralNode(UINode): return "enabled" return "disabled" - def ui_command_enable(self): + def ui_command_enable(self) -> None: ''' Enables the current Referral. @@ -762,7 +778,7 @@ class UIReferralNode(UINode): raise configshell.ExecutionError( "The Referral could not be enabled.") from exc - def ui_command_disable(self): + def ui_command_disable(self) -> None: ''' Disables the current Referral. @@ -785,10 +801,10 @@ class UIANAGroupsNode(UINode): ''' A node in the UI representing the ANA groups. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'ana_groups', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -796,7 +812,7 @@ class UIANAGroupsNode(UINode): for a in self.parent.cfnode.ana_groups: UIANAGroupNode(self, a) - def ui_command_create(self, grpid): + def ui_command_create(self, grpid: Any) -> None: ''' Creates a new ANA Group. @@ -807,7 +823,7 @@ class UIANAGroupsNode(UINode): a = nvme.ANAGroup(self.parent.cfnode, grpid, mode='create') UIANAGroupNode(self, a) - def ui_command_delete(self, grpid): + def ui_command_delete(self, grpid: Any) -> None: ''' Deletes the ANA Group with the specified I{name}. @@ -828,10 +844,11 @@ class UIANAGroupNode(UINode): 'state': ('string', 'ANA state'), } - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.ANAGroup) -> None: UINode.__init__(self, str(cfnode.grpid), parent, cfnode) + self.cfnode: nvme.ANAGroup = cfnode - def summary(self): + def summary(self) -> tuple[str, bool]: ''' Returns a summary of the ANA group. ''' @@ -844,10 +861,10 @@ class UIHostsNode(UINode): ''' A node in the UI representing the hosts. ''' - def __init__(self, parent): + def __init__(self, parent: UINode) -> None: UINode.__init__(self, 'hosts', parent) - def refresh(self): + def refresh(self) -> None: ''' Refreshes and updates the objects tree from the current path. ''' @@ -855,7 +872,7 @@ class UIHostsNode(UINode): for host in self.parent.cfnode.hosts: UIHostNode(self, host) - def ui_command_create(self, nqn): + def ui_command_create(self, nqn: str) -> None: ''' Creates a new NVMe host. @@ -866,7 +883,7 @@ class UIHostsNode(UINode): host = nvme.Host(nqn, mode='create') UIHostNode(self, host) - def ui_command_delete(self, nqn): + def ui_command_delete(self, nqn: str) -> None: ''' Recursively deletes the NVMe Host with the specified I{nqn}, and all objects hanging under it. @@ -884,11 +901,12 @@ class UIHostNode(UINode): ''' A node in the UI representing a host. ''' - def __init__(self, parent, cfnode): + def __init__(self, parent: UINode, cfnode: nvme.Host) -> None: UINode.__init__(self, cfnode.nqn, parent, cfnode) + self.cfnode: nvme.Host = cfnode -def usage(): +def usage() -> None: ''' Prints the usage message. ''' @@ -900,14 +918,14 @@ def usage(): sys.exit(-1) -def save(to_file): +def save(to_file: str | None) -> None: ''' Saves the configuration to a file. ''' nvme.Root().save_to_file(to_file) -def restore(from_file): +def restore(from_file: str | None) -> None: ''' Restores the configuration from a file. ''' @@ -935,14 +953,14 @@ def restore(from_file): sys.exit(0) -def clear(_unused): +def clear(_unused: str | None) -> None: ''' Clears the configuration. ''' nvme.Root().clear_existing() -def execute_cmd(cmd): +def execute_cmd(cmd: str) -> None: ''' Executes a command in the shell. ''' @@ -952,10 +970,12 @@ def execute_cmd(cmd): sys.exit(0) -funcs = {'save': save, 'restore': restore, 'clear': clear} +funcs: dict[str, Callable[[str | None], None]] = { + 'save': save, 'restore': restore, 'clear': clear, +} -def main(): +def main() -> None: ''' The main function. ''' @@ -994,3 +1014,5 @@ def main(): if __name__ == "__main__": main() + +# pylint: disable=too-many-lines diff --git a/pyproject.toml b/pyproject.toml index c0f429b..43b7db1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,3 +24,27 @@ test = [ "nose2", "coverage" ] +typecheck = [ + "mypy" +] + +[tool.mypy] +files = ["nvmet", "nvmetcli"] +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_reexport = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unreachable = true +warn_return_any = true +strict_equality = true +# Not full `strict = true`: configshell ships no type stubs, so its +# ConfigNode is typed as Any, and disallow_subclassing_any would flag +# every configshell.ConfigNode subclass in nvmetcli. Everything else +# strict mode enables is clean and enforced above. + +[[tool.mypy.overrides]] +module = "configshell.*" +ignore_missing_imports = true