diff --git a/dotdrop/action.py b/dotdrop/action.py index 73eb073a..f2e3c6e1 100644 --- a/dotdrop/action.py +++ b/dotdrop/action.py @@ -8,20 +8,23 @@ import subprocess import os +from typing import Any, Dict, List, Optional, Union # local imports from dotdrop.dictparser import DictParser from dotdrop.exceptions import UndefinedException +__all__ = ['Cmd', 'Action', 'Transform'] + class Cmd(DictParser): """A command to execute""" - args = [] + args: List[str] = [] eq_ignore = ('log',) descr = 'command' - def __init__(self, key, action): + def __init__(self, key: str, action: str): """constructor @key: action key @action: action string @@ -31,8 +34,8 @@ def __init__(self, key, action): self.action = action self.silent = key.startswith('_') - def _get_action(self, templater, debug): - action = None + def _get_action(self, templater: Any, debug: bool) -> Union[str, bool]: + action: Optional[str] = None try: action = templater.generate_string(self.action) except UndefinedException as exc: @@ -45,8 +48,8 @@ def _get_action(self, templater, debug): self.log.dbg(f' - templated \"{action}\"') return action - def _get_args(self, templater): - args = [] + def _get_args(self, templater: Any) -> Union[List[str], bool]: + args: List[str] = [] if not self.args: return args args = self.args @@ -59,10 +62,10 @@ def _get_args(self, templater): return False return args - def execute(self, templater=None, debug=False): + def execute(self, templater: Any = None, debug: bool = False) -> bool: """execute the command in the shell""" ret = 1 - action = self.action + action: Union[str, bool] = self.action if templater: action = self._get_action(templater, debug) args = self._get_args(templater) @@ -101,10 +104,10 @@ def execute(self, templater=None, debug=False): return ret == 0 @classmethod - def _adjust_yaml_keys(cls, value): + def _adjust_yaml_keys(cls, value: Any) -> Dict[str, Any]: return {'action': value} - def __str__(self): + def __str__(self) -> str: return f'key:{self.key} -> \"{self.action}\"' @@ -115,7 +118,7 @@ class Action(Cmd): post = 'post' descr = 'action' - def __init__(self, key, kind, action): + def __init__(self, key: str, kind: str, action: str): """constructor @key: action key @kind: type of action (pre or post) @@ -125,24 +128,24 @@ def __init__(self, key, kind, action): self.kind = kind self.args = [] - def copy(self, args): + def copy(self, args: List[str]) -> 'Action': """return a copy of this object with arguments""" action = Action(self.key, self.kind, self.action) action.args = args return action @classmethod - def parse(cls, key, value): + def parse(cls, key: str, value: Any) -> 'Action': """parse key value into object""" val = {} val['kind'], val['action'] = value return cls(key=key, **val) - def __str__(self): + def __str__(self) -> str: out = f'{self.key}: [{self.kind}] \"{self.action}\"' return out - def __repr__(self): + def __repr__(self) -> str: return f'action({self.__str__()})' @@ -151,7 +154,7 @@ class Transform(Cmd): descr = 'transformation' - def __init__(self, key, action): + def __init__(self, key: str, action: str): """constructor @key: action key @trans: action string @@ -159,13 +162,14 @@ def __init__(self, key, action): super().__init__(key, action) self.args = [] - def copy(self, args): + def copy(self, args: List[str]) -> 'Transform': """return a copy of this object with arguments""" trans = Transform(self.key, self.action) trans.args = args return trans - def transform(self, arg0, arg1, templater=None, debug=False): + def transform(self, arg0: str, arg1: str, templater: Any = None, + debug: bool = False) -> bool: """ execute transformation with {0} and {1} where {0} is the file to transform diff --git a/dotdrop/comparator.py b/dotdrop/comparator.py index 0d7b47aa..d58c8722 100644 --- a/dotdrop/comparator.py +++ b/dotdrop/comparator.py @@ -6,6 +6,7 @@ """ import os +from typing import List, Optional # local imports from dotdrop.logger import Logger @@ -13,12 +14,15 @@ from dotdrop.utils import must_ignore, diff, \ get_file_perm +__all__ = ['Comparator'] + class Comparator: """compare dotfiles helper""" + # pylint: disable=too-few-public-methods - def __init__(self, diff_cmd='', debug=False, - ignore_missing_in_dotdrop=False): + def __init__(self, diff_cmd: str = '', debug: bool = False, + ignore_missing_in_dotdrop: bool = False): """constructor @diff_cmd: diff command to use @debug: enable debug @@ -29,7 +33,9 @@ def __init__(self, diff_cmd='', debug=False, self.log = Logger(debug=self.debug) self.ignore_missing_in_dotdrop = ignore_missing_in_dotdrop - def compare(self, local_path, deployed_path, ignore=None, mode=None): + def compare(self, local_path: str, deployed_path: str, + ignore: Optional[List[str]] = None, + mode: Optional[int] = None) -> str: """ diff local_path (dotdrop dotfile) and deployed_path (destination file) @@ -47,9 +53,11 @@ def compare(self, local_path, deployed_path, ignore=None, mode=None): ignore=ignore, mode=mode, recurse=True) - def _compare(self, local_path, deployed_path, - ignore=None, mode=None, - recurse=False): + # pylint: disable=too-many-arguments,too-many-positional-arguments + def _compare(self, local_path: str, deployed_path: str, + ignore: Optional[List[str]] = None, + mode: Optional[int] = None, + recurse: bool = False) -> str: if not ignore: ignore = [] @@ -87,7 +95,8 @@ def _compare(self, local_path, deployed_path, ret = self._comp_mode(local_path, deployed_path, mode=mode) return ret - def _comp_mode(self, local_path, deployed_path, mode=None): + def _comp_mode(self, local_path: str, deployed_path: str, + mode: Optional[int] = None) -> str: """ compare mode If mode is None, rights will be read on local_path @@ -105,7 +114,8 @@ def _comp_mode(self, local_path, deployed_path, mode=None): ret += f'({deployed_mode:o}) vs {local_mode:o}\n' return ret - def _comp_file(self, local_path, deployed_path, ignore): + def _comp_file(self, local_path: str, deployed_path: str, + ignore: List[str]) -> str: """compare a file""" self.log.dbg(f'compare file {local_path} with {deployed_path}') if (self.ignore_missing_in_dotdrop and not @@ -116,7 +126,8 @@ def _comp_file(self, local_path, deployed_path, ignore): return '' return self._diff(local_path, deployed_path, header=True) - def _comp_dir(self, local_path, deployed_path, ignore): + def _comp_dir(self, local_path: str, deployed_path: str, + ignore: List[str]) -> str: """compare a directory""" self.log.dbg(f'compare directory {local_path} with {deployed_path}') if not os.path.exists(deployed_path): @@ -135,7 +146,8 @@ def _comp_dir(self, local_path, deployed_path, ignore): return self._compare_dirs2(local_path, deployed_path, ignore) - def _compare_dirs2(self, local_path, deployed_path, ignore): + def _compare_dirs2(self, local_path: str, deployed_path: str, + ignore: List[str]) -> str: """compare directories""" self.log.dbg(f'compare dirs {local_path} and {deployed_path}') ret = [] @@ -171,7 +183,8 @@ def _compare_dirs2(self, local_path, deployed_path, ignore): return ''.join(ret) - def _diff(self, local_path, deployed_path, header=False): + def _diff(self, local_path: str, deployed_path: str, + header: bool = False) -> str: """diff two files""" out = diff(modified=local_path, original=deployed_path, diff_cmd=self.diff_cmd, debug=self.debug) diff --git a/dotdrop/config.py b/dotdrop/config.py index ba1c7dba..fdb4a4e2 100644 --- a/dotdrop/config.py +++ b/dotdrop/config.py @@ -5,7 +5,11 @@ default config """ -DEFAULT_CONFIG = """config: +from typing import Final + +__all__ = ['DEFAULT_CONFIG'] + +DEFAULT_CONFIG: Final[str] = """config: backup: true banner: true create: true diff --git a/dotdrop/dictparser.py b/dotdrop/dictparser.py index 49017b81..c3d0374c 100644 --- a/dotdrop/dictparser.py +++ b/dotdrop/dictparser.py @@ -5,8 +5,14 @@ dictionary parser abstract class """ +from typing import Any, Dict, List, Mapping, Type, TypeVar + from dotdrop.logger import Logger +__all__ = ['DictParser'] + +T = TypeVar('T', bound='DictParser') + class DictParser: """a dict parser""" @@ -14,12 +20,12 @@ class DictParser: log = Logger() @classmethod - def _adjust_yaml_keys(cls, value): + def _adjust_yaml_keys(cls, value: Dict[str, Any]) -> Dict[str, Any]: """adjust value for object 'cls'""" return value @classmethod - def parse(cls, key, value): + def parse(cls: Type[T], key: Any, value: Dict[str, Any]) -> T: """parse (key,value) and construct object 'cls'""" tmp = value try: @@ -32,7 +38,9 @@ def parse(cls, key, value): return cls(key=key, **newv) @classmethod - def parse_dict(cls, items): + def parse_dict( + cls: Type[T], + items: Mapping[Any, Dict[str, Any]]) -> List[T]: """parse a dictionary and construct object 'cls'""" if not items: return [] diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index e5868091..9a32d5ea 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -5,25 +5,38 @@ represents a dotfile in dotdrop """ +from typing import Any, Dict, List, Optional, Union + from dotdrop.linktypes import LinkTypes from dotdrop.dictparser import DictParser -from dotdrop.action import Action +from dotdrop.action import Action, Transform + +__all__ = ['Dotfile'] class Dotfile(DictParser): """Represent a dotfile.""" + # pylint: disable=too-many-instance-attributes # dotfile keys key_noempty = 'ignoreempty' key_trans_install = 'trans_install' key_trans_update = 'trans_update' key_template = 'template' - def __init__(self, key, dst, src, - actions=None, trans_install=None, trans_update=None, - link=LinkTypes.NOLINK, noempty=False, - cmpignore=None, upignore=None, - instignore=None, template=True, chmod=None, - ignore_missing_in_dotdrop=False): + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments,too-many-locals + def __init__(self, key: str, dst: str, src: str, + actions: Optional[List[Action]] = None, + trans_install: Optional[List[Transform]] = None, + trans_update: Optional[List[Transform]] = None, + link: Union[LinkTypes, str] = LinkTypes.NOLINK, + noempty: bool = False, + cmpignore: Optional[List[str]] = None, + upignore: Optional[List[str]] = None, + instignore: Optional[List[str]] = None, + template: bool = True, + chmod: Optional[Union[int, str]] = None, + ignore_missing_in_dotdrop: bool = False) -> None: """ constructor @key: dotfile key @@ -40,17 +53,17 @@ def __init__(self, key, dst, src, @template: template this dotfile @chmod: file permission """ - self.actions = actions or [] + self.actions: List[Action] = actions or [] self.dst = dst self.key = key self.link = LinkTypes.get(link) self.noempty = noempty self.src = src - self.trans_install = trans_install - self.trans_update = trans_update - self.upignore = upignore or [] - self.cmpignore = cmpignore or [] - self.instignore = instignore or [] + self.trans_install: List[Transform] = trans_install or [] + self.trans_update: List[Transform] = trans_update or [] + self.upignore: List[str] = upignore or [] + self.cmpignore: List[str] = cmpignore or [] + self.instignore: List[str] = instignore or [] self.template = template self.chmod = chmod self.ignore_missing_in_dotdrop = ignore_missing_in_dotdrop @@ -66,7 +79,7 @@ def __init__(self, key, dst, src, self.trans_install = [] self.trans_update = [] - def get_dotfile_variables(self): + def get_dotfile_variables(self) -> Dict[str, str]: """return this dotfile specific variables""" return { '_dotfile_abs_src': self.src, @@ -75,24 +88,24 @@ def get_dotfile_variables(self): '_dotfile_link': str(self.link), } - def get_pre_actions(self): + def get_pre_actions(self) -> List[Action]: """return all 'pre' actions""" return [a for a in self.actions if a.kind == Action.pre] - def get_post_actions(self): + def get_post_actions(self) -> List[Action]: """return all 'post' actions""" return [a for a in self.actions if a.kind == Action.post] - def get_trans_install(self): + def get_trans_install(self) -> List[Transform]: """return trans_install object""" return self.trans_install - def get_trans_update(self): + def get_trans_update(self) -> List[Transform]: """return trans_update object""" return self.trans_update @classmethod - def _adjust_yaml_keys(cls, value): + def _adjust_yaml_keys(cls, value: Dict[str, Any]) -> Dict[str, Any]: """patch dict""" value['noempty'] = value.get(cls.key_noempty, False) value['template'] = value.get(cls.key_template, True) @@ -100,13 +113,15 @@ def _adjust_yaml_keys(cls, value): value.pop(cls.key_noempty, None) return value - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Dotfile): + return False return self.__dict__ == other.__dict__ - def __hash__(self): + def __hash__(self) -> int: return hash(self.dst) ^ hash(self.src) ^ hash(self.key) - def __str__(self): + def __str__(self) -> str: msg = f'key:\"{self.key}\"' msg += f', src:\"{self.src}\"' msg += f', dst:\"{self.dst}\"' @@ -123,7 +138,7 @@ def __str__(self): msg += f', chmod:\"{self.chmod}\"' return msg - def prt(self): + def prt(self) -> str: """extended dotfile to str""" indent = ' ' out = f'dotfile: \"{self.key}\"' @@ -160,5 +175,5 @@ def prt(self): out += f'\n{2 * indent}- {some}' return out - def __repr__(self): + def __repr__(self) -> str: return f'dotfile({self})' diff --git a/dotdrop/exceptions.py b/dotdrop/exceptions.py index f5df6ed3..e5241aa8 100644 --- a/dotdrop/exceptions.py +++ b/dotdrop/exceptions.py @@ -5,22 +5,47 @@ diverse exceptions """ +from typing import Any + +__all__ = [ + 'YamlException', + 'ConfigException', + 'OptionsException', + 'UndefinedException', + 'UnmetDependency', +] + class YamlException(Exception): - """exception in CfgYaml""" + """Exception raised when parsing or loading YAML content.""" + + def __init__(self, message: Any = '') -> None: + super().__init__(message) class ConfigException(Exception): - """exception in config parsing/aggregation""" + """Exception raised during config parsing or aggregation.""" + + def __init__(self, message: Any = '') -> None: + super().__init__(message) class OptionsException(Exception): - """dotdrop options exception""" + """Exception raised for invalid CLI options.""" + + def __init__(self, message: Any = '') -> None: + super().__init__(message) class UndefinedException(Exception): - """exception in templating""" + """Exception raised when templating variables are undefined.""" + + def __init__(self, message: Any = '') -> None: + super().__init__(message) class UnmetDependency(Exception): - """unmet dependency""" + """Exception raised when a required dependency is missing.""" + + def __init__(self, message: Any = '') -> None: + super().__init__(message) diff --git a/dotdrop/ftree.py b/dotdrop/ftree.py index 4d6c888b..a1ba9d79 100644 --- a/dotdrop/ftree.py +++ b/dotdrop/ftree.py @@ -7,22 +7,26 @@ import os +from typing import List, Optional, Tuple # local imports from dotdrop.utils import must_ignore, dir_empty from dotdrop.logger import Logger +__all__ = ['FTreeDir'] + class FTreeDir: """ directory tree for comparison """ - def __init__(self, path, ignores=None, debug=False): + def __init__(self, path: str, ignores: Optional[List[str]] = None, + debug: bool = False): self.path = path self.ignores = ignores self.debug = debug - self.entries = [] + self.entries: List[str] = [] self.log = Logger(debug=self.debug) if os.path.exists(path) and os.path.isdir(path): self._walk() @@ -59,11 +63,12 @@ def _walk(self): self.log.dbg(f'added dir to list of {self.path}: {dpath}') self.entries.append(dpath) - def get_entries(self): + def get_entries(self) -> List[str]: """return all entries""" return self.entries - def compare(self, other): + def compare(self, other: 'FTreeDir') -> Tuple[List[str], List[str], + List[str]]: """ compare two trees and returns - left_only (only in self) diff --git a/dotdrop/importer.py b/dotdrop/importer.py index a5d07141..233586ac 100644 --- a/dotdrop/importer.py +++ b/dotdrop/importer.py @@ -7,6 +7,7 @@ import os import shutil +from typing import List, Optional # local imports from dotdrop.logger import Logger @@ -19,13 +20,20 @@ from dotdrop.templategen import Templategen from dotdrop.exceptions import UndefinedException +__all__ = ['Importer'] + class Importer: """dotfile importer""" - - def __init__(self, profile, conf, dotpath, diff_cmd, - variables, dry=False, safe=True, debug=False, - keepdot=True, ignore=None, forcekey=None): + # pylint: disable=too-many-instance-attributes,too-few-public-methods + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__(self, profile: object, conf: object, dotpath: str, + diff_cmd: str, + variables: dict, dry: bool = False, safe: bool = True, + debug: bool = False, + keepdot: bool = True, ignore: Optional[List[str]] = None, + forcekey: Optional[str] = None): """constructor @profile: the selected profile @conf: configuration manager (CfgAggregator) @@ -52,7 +60,7 @@ def __init__(self, profile, conf, dotpath, diff_cmd, self.safe = safe self.debug = debug self.keepdot = keepdot - self.ignore = [] + self.ignore: List[str] = [] self.forcekey = forcekey self.log = Logger(debug=self.debug) @@ -75,11 +83,11 @@ def __init__(self, profile, conf, dotpath, diff_cmd, self.umask = get_umask() - def import_path(self, path, import_as=None, - import_link=LinkTypes.NOLINK, - import_mode=False, - trans_install="", - trans_update=""): + def import_path(self, path: str, import_as: Optional[str] = None, + import_link: LinkTypes = LinkTypes.NOLINK, + import_mode: bool = False, + trans_install: str = "", + trans_update: str = "") -> int: """ import a dotfile pointed by path returns: @@ -107,11 +115,11 @@ def import_path(self, path, import_as=None, trans_update=tupdate, trans_install=tinstall) - def _import(self, path, import_as=None, - import_link=LinkTypes.NOLINK, - import_mode=False, - trans_install=None, - trans_update=None): + def _import(self, path: str, import_as: Optional[str] = None, + import_link: LinkTypes = LinkTypes.NOLINK, + import_mode: bool = False, + trans_install: Optional[object] = None, + trans_update: Optional[object] = None) -> int: """ import path returns: @@ -178,10 +186,10 @@ def _import(self, path, import_as=None, trans_update=trans_update, trans_install=trans_install) - def _import_in_config(self, path, src, dst, perm, - linktype, import_mode, - trans_install=None, - trans_update=None): + def _import_in_config(self, path: str, src: str, dst: str, perm: int, + linktype: LinkTypes, import_mode: bool, + trans_install: Optional[object] = None, + trans_update: Optional[object] = None) -> int: """ import path returns: @@ -209,7 +217,7 @@ def _import_in_config(self, path, src, dst, perm, self.log.sub(f'\"{path}\" imported') return 1 - def _check_existing_dotfile(self, src, dst): + def _check_existing_dotfile(self, src: str, dst: str) -> bool: """ check if a dotfile file in the dotpath already exists for this src @@ -232,7 +240,8 @@ def _check_existing_dotfile(self, src, dst): self.log.dbg('will overwrite existing file') return True - def _import_to_dotpath(self, in_dotpath, in_fs, trans_update=None): + def _import_to_dotpath(self, in_dotpath: str, in_fs: str, + trans_update: Optional[object] = None) -> bool: """ copy files to dotpath """ @@ -278,7 +287,7 @@ def _import_to_dotpath(self, in_dotpath, in_fs, trans_update=None): return os.path.exists(in_dotpath_abs) - def _import_file_to_dotpath(self, src, dst): + def _import_file_to_dotpath(self, src: str, dst: str) -> bool: self.log.dbg(f'importing {src} to {dst}') try: os.makedirs(os.path.dirname(dst), exist_ok=True) @@ -288,7 +297,7 @@ def _import_file_to_dotpath(self, src, dst): return False return True - def _already_exists(self, src, dst): + def _already_exists(self, src: str, dst: str) -> bool: """ test no other dotfile exists with same dst for this profile but different src @@ -308,14 +317,15 @@ def _already_exists(self, src, dst): return True return False - def _ignore(self, path): + def _ignore(self, path: str) -> bool: if must_ignore([path], self.ignore, debug=self.debug): self.log.dbg(f'ignoring import of {path}') self.log.warn(f'{path} ignored') return True return False - def _apply_trans_update(self, path, trans): + def _apply_trans_update(self, path: str, + trans: Optional[object]) -> Optional[str]: """ apply transformation to path on filesystem) returns diff --git a/dotdrop/jhelpers.py b/dotdrop/jhelpers.py index ce51fc5a..f984e165 100644 --- a/dotdrop/jhelpers.py +++ b/dotdrop/jhelpers.py @@ -7,23 +7,26 @@ import os import shutil +from typing import Optional +__all__ = ['exists', 'exists_in_path', 'basename', 'dirname'] -def exists(path): + +def exists(path: str) -> bool: """return true when path exists""" return os.path.exists(os.path.expandvars(path)) -def exists_in_path(name, path=None): +def exists_in_path(name: str, path: Optional[str] = None) -> bool: """return true when executable exists in os path""" return shutil.which(name, os.F_OK | os.X_OK, path) is not None -def basename(path): +def basename(path: str) -> str: """return basename""" return os.path.basename(path) -def dirname(path): +def dirname(path: str) -> str: """return dirname""" return os.path.dirname(path) diff --git a/dotdrop/linktypes.py b/dotdrop/linktypes.py index 1279f471..a7c52b99 100644 --- a/dotdrop/linktypes.py +++ b/dotdrop/linktypes.py @@ -9,10 +9,14 @@ # pylint: disable=E1101 from enum import IntEnum +from typing import Optional, Union + +__all__ = ['LinkTypes'] class LinkTypes(IntEnum): - """a type of link""" + """A type of link.""" + NOLINK = 0 LINK = 1 LINK_CHILDREN = 2 @@ -20,8 +24,9 @@ class LinkTypes(IntEnum): RELATIVE = 4 @classmethod - def get(cls, key, default=None): - """get the linktype""" + def get(cls, key: Union['LinkTypes', str], + default: Optional['LinkTypes'] = None) -> 'LinkTypes': + """Return a LinkTypes from a string or instance.""" try: return key if isinstance(key, cls) else cls[key.upper()] except KeyError as exc: @@ -30,6 +35,6 @@ def get(cls, key, default=None): err = f'bad {cls.__name__} value: "{key}"' raise ValueError(err) from exc - def __str__(self): - """linktype to string""" + def __str__(self) -> str: + """Return the lowercase name.""" return self.name.lower() diff --git a/dotdrop/logger.py b/dotdrop/logger.py index 03f9b913..db5bb452 100644 --- a/dotdrop/logger.py +++ b/dotdrop/logger.py @@ -7,25 +7,27 @@ import sys import inspect +from typing import ClassVar class Logger: """logging facility for dotdrop""" - RED = '\033[91m' - GREEN = '\033[92m' - YELLOW = '\033[93m' - BLUE = '\033[94m' - MAGENTA = '\033[95m' - LMAGENTA = '\033[35m' - RESET = '\033[0m' - EMPH = '\033[33m' - BOLD = '\033[1m' - - def __init__(self, debug=False): + RED: ClassVar[str] = '\033[91m' + GREEN: ClassVar[str] = '\033[92m' + YELLOW: ClassVar[str] = '\033[93m' + BLUE: ClassVar[str] = '\033[94m' + MAGENTA: ClassVar[str] = '\033[95m' + LMAGENTA: ClassVar[str] = '\033[35m' + RESET: ClassVar[str] = '\033[0m' + EMPH: ClassVar[str] = '\033[33m' + BOLD: ClassVar[str] = '\033[1m' + + def __init__(self, debug: bool = False): self.debug = debug - def log(self, string, end='\n', pre='', bold=False): + def log(self, string: str, end: str = '\n', pre: str = '', + bold: bool = False) -> None: """normal log""" cstart = self._color(self.BLUE) cend = self._color(self.RESET) @@ -37,13 +39,13 @@ def log(self, string, end='\n', pre='', bold=False): fmt = f'{pre}{cstart}{string}{end}{cend}' sys.stdout.write(fmt) - def sub(self, string, end='\n'): + def sub(self, string: str, end: str = '\n') -> None: """sub log""" cstart = self._color(self.BLUE) cend = self._color(self.RESET) sys.stdout.write(f'\t{cstart}->{cend} {string}{end}') - def emph(self, string, stdout=True): + def emph(self, string: str, stdout: bool = True) -> None: """emphasis log""" cstart = self._color(self.EMPH) cend = self._color(self.RESET) @@ -53,20 +55,20 @@ def emph(self, string, stdout=True): else: sys.stdout.write(content) - def err(self, string, end='\n'): + def err(self, string: str, end: str = '\n') -> None: """error log""" cstart = self._color(self.RED) cend = self._color(self.RESET) msg = f'{string} {end}' sys.stderr.write(f'{cstart}[ERR] {msg}{cend}') - def warn(self, string, end='\n'): + def warn(self, string: str, end: str = '\n') -> None: """warning log""" cstart = self._color(self.YELLOW) cend = self._color(self.RESET) sys.stderr.write(f'{cstart}[WARN] {string} {end}{cend}') - def dbg(self, string, force=False): + def dbg(self, string: str, force: bool = False) -> None: """debug log""" if not force and not self.debug: return @@ -81,18 +83,18 @@ def dbg(self, string, force=False): line += f'{cend}{cstart} {string}{cend}\n' sys.stderr.write(line) - def dry(self, string, end='\n'): + def dry(self, string: str, end: str = '\n') -> None: """dry run log""" cstart = self._color(self.GREEN) cend = self._color(self.RESET) sys.stdout.write(f'{cstart}[DRY] {string} {end}{cend}') @classmethod - def raw(cls, string, end='\n'): + def raw(cls, string: str, end: str = '\n') -> None: """raw log""" sys.stdout.write(f'{string}{end}') - def ask(self, query): + def ask(self, query: str) -> bool: """ask user for confirmation""" cstart = self._color(self.BLUE) cend = self._color(self.RESET) @@ -102,7 +104,7 @@ def ask(self, query): return resp == 'y' @classmethod - def _color(cls, col): + def _color(cls, col: str) -> str: """is color supported""" if not sys.stdout.isatty(): return '' diff --git a/dotdrop/profile.py b/dotdrop/profile.py index 33a2928e..214d0983 100644 --- a/dotdrop/profile.py +++ b/dotdrop/profile.py @@ -5,9 +5,13 @@ represent a profile in dotdrop """ +from typing import Any, Dict, List, Optional + from dotdrop.dictparser import DictParser from dotdrop.action import Action +__all__ = ['Profile'] + class Profile(DictParser): """dotdrop profile""" @@ -16,8 +20,12 @@ class Profile(DictParser): key_include = 'include' key_import = 'import' - def __init__(self, key, actions=None, dotfiles=None, - variables=None, dynvariables=None): + # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__(self, key: str, + actions: Optional[List[Action]] = None, + dotfiles: Optional[List[str]] = None, + variables: Optional[List[str]] = None, + dynvariables: Optional[List[str]] = None) -> None: """ constructor @key: profile key @@ -32,30 +40,32 @@ def __init__(self, key, actions=None, dotfiles=None, self.variables = variables or [] self.dynvariables = dynvariables or [] - def get_pre_actions(self): + def get_pre_actions(self) -> List[Action]: """return all 'pre' actions""" return [a for a in self.actions if a.kind == Action.pre] - def get_post_actions(self): + def get_post_actions(self) -> List[Action]: """return all 'post' actions""" return [a for a in self.actions if a.kind == Action.post] @classmethod - def _adjust_yaml_keys(cls, value): + def _adjust_yaml_keys(cls, value: Dict[str, Any]) -> Dict[str, Any]: """patch dict""" value.pop(cls.key_import, None) value.pop(cls.key_include, None) return value - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Profile): + return False return self.__dict__ == other.__dict__ - def __hash__(self): + def __hash__(self) -> int: return (hash(self.key) ^ hash(tuple(self.dotfiles))) - def __str__(self): + def __str__(self) -> str: return f'key:"{self.key}"' - def __repr__(self): + def __repr__(self) -> str: return f'profile({self})' diff --git a/dotdrop/settings.py b/dotdrop/settings.py index ef043c8e..03de02d6 100644 --- a/dotdrop/settings.py +++ b/dotdrop/settings.py @@ -6,6 +6,8 @@ """ import os +from typing import Dict, List, Optional, Union + from dotdrop.exceptions import YamlException # local imports @@ -13,12 +15,15 @@ from dotdrop.dictparser import DictParser from dotdrop.utils import is_bin_in_path +__all__ = ['Settings'] + ENV_WORKDIR = 'DOTDROP_WORKDIR' class Settings(DictParser): """Settings block in config""" + # pylint: disable=too-many-instance-attributes # key in yaml file key_yaml = 'config' @@ -61,23 +66,40 @@ class Settings(DictParser): # defaults default_diff_cmd = 'diff -r -u {0} {1}' - def __init__(self, backup=True, banner=True, - create=True, default_actions=None, dotpath='dotfiles', - ignoreempty=False, import_actions=None, import_configs=None, - import_variables=None, keepdot=False, - link_dotfile_default=LinkTypes.NOLINK, - link_on_import=LinkTypes.NOLINK, longkey=False, - upignore=None, cmpignore=None, instignore=None, - impignore=None, workdir='~/.config/dotdrop', - showdiff=False, minversion=None, - func_file=None, filter_file=None, - diff_command=default_diff_cmd, - template_dotfile_default=True, - ignore_missing_in_dotdrop=False, - force_chmod=False, chmod_on_import=False, - check_version=False, clear_workdir=False, - compare_workdir=False, key_prefix=True, - key_separator='_'): + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments,too-many-locals + def __init__(self, backup: bool = True, banner: bool = True, + create: bool = True, + default_actions: Optional[List[str]] = None, + dotpath: str = 'dotfiles', + ignoreempty: bool = False, + import_actions: Optional[List[str]] = None, + import_configs: Optional[List[str]] = None, + import_variables: Optional[List[str]] = None, + keepdot: bool = False, + link_dotfile_default: Union[LinkTypes, str] = + LinkTypes.NOLINK, + link_on_import: Union[LinkTypes, str] = LinkTypes.NOLINK, + longkey: bool = False, + upignore: Optional[List[str]] = None, + cmpignore: Optional[List[str]] = None, + instignore: Optional[List[str]] = None, + impignore: Optional[List[str]] = None, + workdir: str = '~/.config/dotdrop', + showdiff: bool = False, + minversion: Optional[str] = None, + func_file: Optional[List[str]] = None, + filter_file: Optional[List[str]] = None, + diff_command: str = default_diff_cmd, + template_dotfile_default: bool = True, + ignore_missing_in_dotdrop: bool = False, + force_chmod: bool = False, + chmod_on_import: bool = False, + check_version: bool = False, + clear_workdir: bool = False, + compare_workdir: bool = False, + key_prefix: bool = True, + key_separator: str = '_') -> None: self.backup = backup self.banner = banner self.create = create @@ -118,12 +140,12 @@ def __init__(self, backup=True, banner=True, err = f'bad diff_command: {diff_command}' raise YamlException(err) - def _serialize_seq(self, name, dic): + def _serialize_seq(self, name: str, dic: Dict[str, object]) -> None: """serialize attribute 'name' into 'dic'""" seq = getattr(self, name) dic[name] = seq - def serialize(self): + def serialize(self) -> Dict[str, Dict[str, object]]: """Return key-value pair representation of the settings""" dic = { self.key_backup: self.backup, diff --git a/dotdrop/templategen.py b/dotdrop/templategen.py index 197e777d..ed42f627 100644 --- a/dotdrop/templategen.py +++ b/dotdrop/templategen.py @@ -10,6 +10,8 @@ import re import mmap import sys +from typing import Any, Dict, List, Optional, Union + from jinja2 import Environment, FileSystemLoader, \ ChoiceLoader, FunctionLoader, TemplateNotFound, \ StrictUndefined @@ -22,6 +24,8 @@ from dotdrop.logger import Logger from dotdrop.exceptions import UndefinedException +__all__ = ['Templategen'] + BLOCK_START = '{%@@' BLOCK_END = '@@%}' VAR_START = '{{@@' @@ -39,9 +43,12 @@ class Templategen: """dotfile templater""" - def __init__(self, base='.', variables=None, - func_file=None, filter_file=None, - debug=False): + # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__(self, base: str = '.', + variables: Optional[Dict[str, Any]] = None, + func_file: Optional[List[str]] = None, + filter_file: Optional[List[str]] = None, + debug: bool = False): """constructor @base: directory path where to search for templates @variables: dictionary of variables for templates @@ -53,8 +60,8 @@ def __init__(self, base='.', variables=None, self.debug = debug self.log = Logger(debug=self.debug) self.log.dbg('loading templategen') - self.variables = {} - self.mime_text = [] + self.variables: Dict[str, Any] = {} + self.mime_text: List[str] = [] if ENV_DOTDROP_MIME_TEXT in os.environ: # retrieve a comma separated list of # mime types to treat as text @@ -105,7 +112,7 @@ def __init__(self, base='.', variables=None, if self.debug: self._debug_dict('template additional variables', variables) - def generate(self, src): + def generate(self, src: str) -> Union[str, bytes]: """ render template from path may raise a UndefinedException @@ -119,7 +126,7 @@ def generate(self, src): err = f'undefined variable: {exc.message}' raise UndefinedException(err) from exc - def generate_string(self, string): + def generate_string(self, string: str) -> str: """ render template from string may raise a UndefinedException @@ -133,7 +140,7 @@ def generate_string(self, string): err = f'undefined variable: {exc.message}' raise UndefinedException(err) from exc - def generate_dict(self, dic): + def generate_dict(self, dic: Dict[str, Any]) -> Dict[str, Any]: """ template each entry of the dict where only the value of each entry is templated (recursively) @@ -153,7 +160,9 @@ def generate_dict(self, dic): continue return dic - def generate_string_or_dict(self, content): + def generate_string_or_dict( + self, content: Union[str, Dict[str, Any]]) -> Union[ + str, Dict[str, Any]]: """ render template from string or dict may raise a UndefinedException @@ -165,7 +174,8 @@ def generate_string_or_dict(self, content): return self.generate_dict(content) raise UndefinedException(f'could not template {content}') - def add_tmp_vars(self, newvars=None): + def add_tmp_vars( + self, newvars: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """add vars to the globals, make sure to call restore_vars""" saved_variables = self.variables.copy() if not newvars: @@ -173,22 +183,22 @@ def add_tmp_vars(self, newvars=None): self.variables.update(newvars) return saved_variables - def restore_vars(self, saved_globals): + def restore_vars(self, saved_globals: Dict[str, Any]) -> None: """restore globals from add_tmp_vars""" self.variables = saved_globals.copy() - def update_variables(self, variables): + def update_variables(self, variables: Dict[str, Any]) -> None: """update variables""" self.variables.update(variables) - def _load_path_to_dic(self, path, dic): + def _load_path_to_dic(self, path: str, dic: Dict[str, Any]) -> None: mod = utils.get_module_from_path(path) if not mod: self.log.warn(f'cannot load module \"{path}\"') return self._load_funcs_to_dic(mod, dic) - def _load_funcs_to_dic(self, mod, dic): + def _load_funcs_to_dic(self, mod: Any, dic: Dict[str, Any]) -> None: """dynamically load functions from module to dic""" if not mod or not dic: return @@ -198,11 +208,11 @@ def _load_funcs_to_dic(self, mod, dic): dic[name] = func @classmethod - def _header(cls, prepend=''): + def _header(cls, prepend: str = '') -> str: """add a comment usually in the header of a dotfile""" return f'{prepend}{utils.header()}' - def _get_filetype(self, src): + def _get_filetype(self, src: str) -> str: """use magic or the file command to get the mime type of a file""" try: # pylint: disable=C0415 @@ -229,7 +239,7 @@ def _get_filetype(self, src): return self._get_filetype(dst) return filetype - def _handle_file(self, src): + def _handle_file(self, src: str) -> Union[str, bytes]: """generate the file content from template""" filetype = self._get_filetype(src) istext = self._is_text(filetype) @@ -239,8 +249,9 @@ def _handle_file(self, src): return self._handle_bin_file(src) return self._handle_text_file(src) - def _is_text(self, fileoutput): + def _is_text(self, fileoutput: str) -> bool: """return if `file -b` output is ascii text""" + # pylint: disable=too-many-return-statements out = fileoutput.lower() if out.startswith('text'): return True @@ -258,7 +269,7 @@ def _is_text(self, fileoutput): return True return False - def _template_loader(self, relpath): + def _template_loader(self, relpath: str) -> str: """manually load template when outside of base""" path = os.path.join(self.base, relpath) path = os.path.normpath(path) @@ -268,7 +279,7 @@ def _template_loader(self, relpath): content = file.read() return content - def _handle_text_file(self, src): + def _handle_text_file(self, src: str) -> bytes: """write text to file""" template_rel_path = os.path.relpath(src, self.base) try: @@ -279,7 +290,7 @@ def _handle_text_file(self, src): content = self.generate_string(data) return content.encode('utf-8') - def _handle_bin_file(self, src): + def _handle_bin_file(self, src: str) -> bytes: """write binary to file""" # this is dirty if not src.startswith(self.base): @@ -289,14 +300,14 @@ def _handle_bin_file(self, src): return content @classmethod - def _read_bad_encoded_text(cls, path): + def _read_bad_encoded_text(cls, path: str) -> str: """decode non utf-8 data""" with open(path, 'rb') as file: data = file.read() return data.decode('utf-8', 'replace') @staticmethod - def path_is_template(path, debug=False): + def path_is_template(path: str, debug: bool = False) -> bool: """recursively check if any file is a template within path""" path = os.path.expanduser(path) @@ -332,12 +343,12 @@ def path_is_template(path, debug=False): return False @staticmethod - def string_is_template(string): + def string_is_template(string: Any) -> bool: """check if variable contains template(s)""" return VAR_START in str(string) @staticmethod - def dict_is_template(dic): + def dict_is_template(dic: Dict[str, Any]) -> bool: """check if dict contains template(s)""" for key in dic: value = dic[key] @@ -350,7 +361,7 @@ def dict_is_template(dic): return False @staticmethod - def var_is_template(something): + def var_is_template(something: Union[str, Dict[str, Any]]) -> bool: """check if string or dict is template""" if isinstance(something, str): return Templategen.string_is_template(something) @@ -359,7 +370,7 @@ def var_is_template(something): return False @staticmethod - def _is_template(path, debug=False): + def _is_template(path: str, debug: bool = False) -> bool: """test if file pointed by path is a template""" if debug: Logger().dbg(f'is template: {path}') @@ -381,7 +392,7 @@ def _is_template(path, debug=False): return False return False - def _debug_dict(self, title, elems): + def _debug_dict(self, title: str, elems: Dict[str, Any]) -> None: """pretty print dict""" if not self.debug: return diff --git a/dotdrop/uninstaller.py b/dotdrop/uninstaller.py index c33c99f5..c1986e58 100644 --- a/dotdrop/uninstaller.py +++ b/dotdrop/uninstaller.py @@ -6,16 +6,22 @@ """ import os +from typing import Tuple + from dotdrop.logger import Logger from dotdrop.utils import removepath, dir_empty +__all__ = ['Uninstaller'] + class Uninstaller: """dotfile uninstaller""" + # pylint: disable=too-few-public-methods - def __init__(self, base='.', workdir='~/.config/dotdrop', - dry=False, safe=True, debug=False, - backup_suffix='.dotdropbak'): + # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__(self, base: str = '.', workdir: str = '~/.config/dotdrop', + dry: bool = False, safe: bool = True, debug: bool = False, + backup_suffix: str = '.dotdropbak'): """ @base: directory path where to search for templates @workdir: where to install template before symlinking @@ -36,7 +42,8 @@ def __init__(self, base='.', workdir='~/.config/dotdrop', self.backup_suffix = backup_suffix self.log = Logger(debug=self.debug) - def uninstall(self, src, dst, linktype): + def uninstall(self, src: str, dst: str, + linktype: object) -> Tuple[bool, str]: """ uninstall dst @src: dotfile source path in dotpath @@ -72,7 +79,7 @@ def uninstall(self, src, dst, linktype): self.log.sub(f'uninstall {dst}') return ret, msg - def _descend(self, dirpath): + def _descend(self, dirpath: str) -> Tuple[bool, str]: ret = True self.log.dbg(f'recursively uninstall {dirpath}') for sub in os.listdir(dirpath): @@ -96,7 +103,7 @@ def _descend(self, dirpath): self.log.dbg(f'not removing non-empty dir {dirpath}') return ret, '' - def _remove_path(self, path): + def _remove_path(self, path: str) -> Tuple[bool, str]: """remove a file""" try: removepath(path) @@ -105,7 +112,7 @@ def _remove_path(self, path): return False, err return True, '' - def _remove(self, path): + def _remove(self, path: str) -> Tuple[bool, str]: """remove path""" self.log.dbg(f'handling uninstall of {path}') if path.endswith(self.backup_suffix): @@ -131,7 +138,7 @@ def _remove(self, path): self.log.dbg(f'removing {path}') return self._remove_path(path) - def _replace(self, path, backup): + def _replace(self, path: str, backup: str) -> Tuple[bool, str]: """replace path by backup""" if self.dry: self.log.dry(f'would \"mv {backup} {path}\"') diff --git a/dotdrop/updater.py b/dotdrop/updater.py index e482237c..c8872057 100644 --- a/dotdrop/updater.py +++ b/dotdrop/updater.py @@ -8,6 +8,7 @@ import os import shutil import filecmp +from typing import List, Optional # local imports from dotdrop.logger import Logger @@ -18,17 +19,22 @@ mirror_file_rights, get_file_perm, diff from dotdrop.exceptions import UndefinedException +__all__ = ['Updater'] + TILD = '~' class Updater: """dotfiles updater""" - - def __init__(self, dotpath, variables, conf, - profile_key, dry=False, safe=True, - debug=False, ignore=None, showpatch=False, - ignore_missing_in_dotdrop=False): + # pylint: disable=too-many-instance-attributes + + # pylint: disable=too-many-arguments,too-many-positional-arguments + def __init__(self, dotpath: str, variables: dict, conf: object, + profile_key: str, dry: bool = False, safe: bool = True, + debug: bool = False, ignore: Optional[List[str]] = None, + showpatch: bool = False, + ignore_missing_in_dotdrop: bool = False): """constructor @dotpath: path where dotfiles are stored @variables: dictionary of variables for the templates @@ -47,7 +53,7 @@ def __init__(self, dotpath, variables, conf, self.dry = dry self.safe = safe self.debug = debug - self.ignore = ignore or [] + self.ignore: List[str] = ignore or [] self.showpatch = showpatch self.ignore_missing_in_dotdrop = ignore_missing_in_dotdrop self.templater = Templategen(variables=self.variables, @@ -57,7 +63,7 @@ def __init__(self, dotpath, variables, conf, self.tvars = self.templater.add_tmp_vars() self.log = Logger(debug=self.debug) - def update_path(self, path): + def update_path(self, path: str) -> bool: """update the dotfile installed on path""" path = os.path.expanduser(path) if not os.path.lexists(path): @@ -79,7 +85,7 @@ def update_path(self, path): return False return True - def update_key(self, key): + def update_key(self, key: str) -> bool: """update the dotfile referenced by key""" dotfile = self.conf.get_dotfile(key, profile_key=self.profile_key) if not dotfile: @@ -91,7 +97,7 @@ def update_key(self, key): path = self.conf.path_to_dotfile_dst(dotfile.dst) return self._update(path, dotfile) - def _update(self, path, dotfile): + def _update(self, path: str, dotfile: object) -> bool: """update dotfile from file pointed by path""" ret = False new_path = None @@ -155,7 +161,7 @@ def _update(self, path, dotfile): removepath(new_path, logger=self.log) return ret - def _apply_trans_update(self, path, dotfile): + def _apply_trans_update(self, path: str, dotfile: object) -> Optional[str]: """apply write transformation to dotfile""" trans = dotfile.get_trans_update() if not trans: @@ -175,7 +181,7 @@ def _apply_trans_update(self, path, dotfile): return None return tmp - def _is_template(self, path): + def _is_template(self, path: str) -> bool: if not Templategen.path_is_template(path, debug=self.debug): self.log.dbg(f'{path} is NO template') @@ -183,7 +189,7 @@ def _is_template(self, path): self.log.warn(f'{path} uses template, update manually') return True - def _show_patch(self, fpath, tpath): + def _show_patch(self, fpath: str, tpath: str) -> bool: """provide a way to manually patch the template""" content = self._resolve_template(tpath) tmp = write_to_tmpfile(content) @@ -193,12 +199,12 @@ def _show_patch(self, fpath, tpath): self.log.warn(f'try patching with: \"{cmdss}\"') return False - def _resolve_template(self, tpath): + def _resolve_template(self, tpath: str) -> str: """resolve the template to a temporary file""" self.templater.restore_vars(self.tvars) return self.templater.generate(tpath) - def _same_rights(self, left, right): + def _same_rights(self, left: str, right: str) -> bool: """return True if files have the same modes""" try: lefts = get_file_perm(left) @@ -208,7 +214,7 @@ def _same_rights(self, left, right): self.log.err(exc) return False - def _mirror_file_perms(self, src, dst): + def _mirror_file_perms(self, src: str, dst: str) -> None: srcr = get_file_perm(src) dstr = get_file_perm(dst) if srcr == dstr: @@ -220,8 +226,8 @@ def _mirror_file_perms(self, src, dst): except OSError as exc: self.log.err(exc) - def _handle_file(self, deployed_path, local_path, - ignores, compare=True): + def _handle_file(self, deployed_path: str, local_path: str, + ignores: List[str], compare: bool = True) -> bool: """sync path (deployed file) and local_path (dotdrop dotfile path)""" if self._must_ignore([deployed_path, local_path], ignores): self.log.sub(f'\"{local_path}\" ignored') @@ -258,8 +264,11 @@ def _handle_file(self, deployed_path, local_path, return False return True - def _handle_dir(self, deployed_path, local_path, - dotfile, ignores): + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments,too-many-locals + # pylint: disable=too-many-branches,too-many-statements + def _handle_dir(self, deployed_path: str, local_path: str, + dotfile: object, ignores: List[str]) -> bool: """sync path (local dir) and local_path (dotdrop dir path)""" ret = True self.log.dbg(f'handle update for dir {deployed_path} to {local_path}') @@ -345,21 +354,21 @@ def _handle_dir(self, deployed_path, local_path, self.log.sub(f'\"{dstpath}\" content updated') return ret - def _overwrite(self, src, dst): + def _overwrite(self, src: str, dst: str) -> bool: """ask for overwritting""" msg = f'Overwrite \"{dst}\" with \"{src}\"?' if self.safe and not self.log.ask(msg): return False return True - def _confirm_rm_r(self, directory): + def _confirm_rm_r(self, directory: str) -> bool: """ask for rm -r directory""" msg = f'Recursively remove \"{directory}\"?' if self.safe and not self.log.ask(msg): return False return True - def _must_ignore(self, paths, ignores): + def _must_ignore(self, paths: List[str], ignores: List[str]) -> bool: if must_ignore(paths, ignores, debug=self.debug): self.log.dbg(f'ignoring update for {paths}') return True diff --git a/dotdrop/utils.py b/dotdrop/utils.py index 6df61e3c..4c91b0e1 100644 --- a/dotdrop/utils.py +++ b/dotdrop/utils.py @@ -18,6 +18,10 @@ import json import sys from pathlib import PurePath +from typing import ( + Any, Callable, List, Optional, Sequence, Tuple, +) + import requests from packaging import version @@ -27,21 +31,21 @@ from dotdrop.version import __version__ as VERSION LOG = Logger() -STAR = '*' +STAR: str = '*' # the environment variable for temporary ENV_TEMP = 'DOTDROP_TMPDIR' # the temporary directory -TMPDIR = None +TMPDIR: Optional[str] = None # files dotdrop refuses to remove -DONOTDELETE = [ +DONOTDELETE: List[str] = [ os.path.expanduser('~'), os.path.expanduser('~/.config'), ] -NOREMOVE = [os.path.normpath(p) for p in DONOTDELETE] +NOREMOVE: List[str] = [os.path.normpath(p) for p in DONOTDELETE] -def run(cmd, debug=False): +def run(cmd: Sequence[str], debug: bool = False) -> Tuple[bool, str]: """run a command (expects a list)""" if debug: fcmd = ' '.join(cmd) @@ -56,7 +60,7 @@ def run(cmd, debug=False): return ret == 0, lines -def write_to_tmpfile(content): +def write_to_tmpfile(content: bytes) -> str: """write some content to a tmp file""" path = get_tmpfile() with open(path, 'wb') as file: @@ -64,7 +68,7 @@ def write_to_tmpfile(content): return path -def shellrun(cmd, debug=False): +def shellrun(cmd: str, debug: bool = False) -> Tuple[bool, str]: """ run a command in the shell (expects a string) returns True|False, output @@ -77,7 +81,7 @@ def shellrun(cmd, debug=False): return ret == 0, out -def userinput(prompt, debug=False): +def userinput(prompt: str, debug: bool = False) -> str: """ get user input return user input @@ -91,13 +95,13 @@ def userinput(prompt, debug=False): return res -def fastdiff(left, right): +def fastdiff(left: str, right: str) -> bool: """fast compare files and returns True if different""" return not filecmp.cmp(left, right, shallow=False) -def diff(original, modified, - diff_cmd='', debug=False): +def diff(original: str, modified: str, + diff_cmd: str = '', debug: bool = False) -> str: """compare two files, returns '' if same""" if not diff_cmd: diff_cmd = 'diff -r -u {0} {1}' @@ -113,7 +117,7 @@ def diff(original, modified, return out -def get_tmpdir(): +def get_tmpdir() -> str: """create and return the temporary directory""" # pylint: disable=W0603 global TMPDIR @@ -125,7 +129,7 @@ def get_tmpdir(): return tmp -def _get_tmpdir(): +def _get_tmpdir() -> str: """create the tmpdir""" try: if ENV_TEMP in os.environ: @@ -140,21 +144,21 @@ def _get_tmpdir(): return tempfile.mkdtemp(prefix='dotdrop-') -def get_tmpfile(): +def get_tmpfile() -> str: """create a temporary file""" tmpdir = get_tmpdir() return tempfile.NamedTemporaryFile(prefix='dotdrop-', dir=tmpdir, delete=False).name -def get_unique_tmp_name(): +def get_unique_tmp_name() -> str: """get a unique file name (not created)""" unique = str(uuid.uuid4()) tmpdir = get_tmpdir() return os.path.join(tmpdir, unique) -def removepath(path, logger=None): +def removepath(path: str, logger: Optional[Logger] = None) -> bool: """ remove a file/directory/symlink raises OSError in case of error @@ -193,7 +197,7 @@ def removepath(path, logger=None): return True -def samefile(path1, path2): +def samefile(path1: str, path2: str) -> bool: """return True if represent the same file""" if not os.path.exists(path1): return False @@ -320,8 +324,9 @@ def _must_ignore(path, ignores, neg_ignores, return True -def must_ignore(paths, ignores, debug=False, - strict=False): +def must_ignore(paths: List[str], ignores: Optional[List[str]], + debug: bool = False, + strict: bool = False) -> bool: """ return true if any paths in list matches any ignore patterns """ @@ -370,7 +375,7 @@ def _cp(src, dst, ignore_func=None, debug=False): return 0 -def copyfile(src, dst, debug=False): +def copyfile(src: str, dst: str, debug: bool = False) -> bool: """ copy file from src to dst no dir expected! @@ -379,9 +384,9 @@ def copyfile(src, dst, debug=False): return _cp(src, dst, debug=debug) == 1 -def uniq_list(a_list): +def uniq_list(a_list: Optional[List[Any]]) -> List[Any]: """unique elements of a list while preserving order""" - new = [] + new: List[Any] = [] if not a_list: return new for elem in a_list: @@ -390,7 +395,8 @@ def uniq_list(a_list): return new -def ignores_to_absolute(ignores, prefixes, debug=False): +def ignores_to_absolute(ignores: List[str], prefixes: List[str], + debug: bool = False) -> List[str]: """allow relative ignore pattern""" new = [] LOG.dbg(f'ignores before patching: {ignores}', force=debug) @@ -425,7 +431,7 @@ def ignores_to_absolute(ignores, prefixes, debug=False): return new -def get_module_functions(mod): +def get_module_functions(mod: Any) -> List[Tuple[str, Callable[..., Any]]]: """return a list of fonction from a module""" funcs = [] for memb in inspect.getmembers(mod): @@ -436,7 +442,7 @@ def get_module_functions(mod): return funcs -def get_module_from_path(path): +def get_module_from_path(path: str): """get module from path""" if not path or not os.path.exists(path): return None @@ -450,7 +456,7 @@ def get_module_from_path(path): return mod -def dependencies_met(): +def dependencies_met(): # pylint: disable=too-many-statements """make sure all dependencies are met""" # check unix tools deps # diff command is checked in settings.py diff --git a/dotdrop/version.py b/dotdrop/version.py index 1025b1b3..07c5b332 100644 --- a/dotdrop/version.py +++ b/dotdrop/version.py @@ -3,4 +3,8 @@ Copyright (c) 2018, deadc0de6 """ -__version__ = '1.16.2' +from typing import Final + +__all__ = ['__version__'] + +__version__: Final[str] = '1.16.2' diff --git a/scripts/check-doc.sh b/scripts/check-doc.sh index acdeecff..a71589cc 100755 --- a/scripts/check-doc.sh +++ b/scripts/check-doc.sh @@ -8,7 +8,7 @@ set -eu -o errtrace -o pipefail ## test doc external links echo "------------------------" echo "checking external links" -find . -type f -iname '*.md' | while read -r line; do +find . -path './.venv' -prune -o -type f -iname '*.md' -print | while read -r line; do ./scripts/check_links.py "${line}" done @@ -25,7 +25,7 @@ if [ -n "${in_cicd}" ]; then echo "------------------------" echo "checking internal links" - find . -type f -iname '*.md' | while read -r line; do + find . -path './.venv' -prune -o -type f -iname '*.md' -print | while read -r line; do remark -f -u validate-links "${line}" done else diff --git a/scripts/typecheck.sh b/scripts/typecheck.sh new file mode 100755 index 00000000..898d56d6 --- /dev/null +++ b/scripts/typecheck.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run strict static type checks for the project. + +set -euo pipefail + +cur="$(cd "$(dirname "$0")/.." && pwd)" +cd "${cur}" + +DEFAULT_TARGETS=(dotdrop) +TARGETS=() + +if [[ "$#" -gt 0 ]]; then + TARGETS=("$@") +else + TARGETS=("${DEFAULT_TARGETS[@]}") +fi + +echo "[typecheck] running mypy (strict) on: ${TARGETS[*]}" +mypy --strict "${TARGETS[@]}" + +echo "[typecheck] running pytype on: ${TARGETS[*]}" +pytype -V 3.11 -j auto -k "${TARGETS[@]}" + +echo "[typecheck] running pyright on: ${TARGETS[*]}" +pyright "${TARGETS[@]}" + +echo "[typecheck] completed" diff --git a/tests-ng/backup.sh b/tests-ng/backup.sh index 86ff36c6..2a288596 100755 --- a/tests-ng/backup.sh +++ b/tests-ng/backup.sh @@ -52,6 +52,7 @@ tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) clear_on_exit "${tmps}" clear_on_exit "${tmpd}" clear_on_exit "${tmpw}" +export DOTDROP_WORKDIR="${tmpw}" clear_dotpath() { diff --git a/tests-ng/chmod-preserve-install.sh b/tests-ng/chmod-preserve-install.sh index 79f90a02..e8a59641 100755 --- a/tests-ng/chmod-preserve-install.sh +++ b/tests-ng/chmod-preserve-install.sh @@ -79,10 +79,16 @@ tmps=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) mkdir -p "${tmps}"/dotfiles # the dotfile destination tmpd=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) +# workdir +tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) +#echo "workdir: ${tmpw}" + +export DOTDROP_WORKDIR="${tmpw}" #echo "dotfile destination: ${tmpd}" clear_on_exit "${tmps}" clear_on_exit "${tmpd}" +clear_on_exit "${tmpw}" # create the config file cfg="${tmps}/config.yaml" diff --git a/tests-ng/dotdrop-variables.sh b/tests-ng/dotdrop-variables.sh index c14a9c82..825b9e32 100755 --- a/tests-ng/dotdrop-variables.sh +++ b/tests-ng/dotdrop-variables.sh @@ -33,10 +33,15 @@ mkdir -p "${tmps}"/dotfiles #echo "dotfile source: ${tmps}" # the dotfile destination tmpd=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) +# workdir +tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) +#echo "dotfile workdir: ${tmpw}" #echo "dotfile destination: ${tmpd}" clear_on_exit "${tmps}" clear_on_exit "${tmpd}" +clear_on_exit "${tmpw}" +export DOTDROP_WORKDIR="${tmpw}" # create the config file cfg="${tmps}/config.yaml" @@ -46,7 +51,7 @@ config: backup: true create: true dotpath: dotfiles - workdir: /tmp/xxx + workdir: ${tmpw} dotfiles: f_abc: dst: ${tmpd}/abc @@ -70,7 +75,7 @@ cat "${tmpd}"/abc grep "^dotpath: ${tmps}/dotfiles$" "${tmpd}"/abc >/dev/null grep "^cfgpath: ${tmps}/config.yaml$" "${tmpd}"/abc >/dev/null -grep "^workdir: /tmp/xxx$" "${tmpd}"/abc >/dev/null +grep "^workdir: ${tmpw}$" "${tmpd}"/abc >/dev/null echo "OK" exit 0 diff --git a/tests-ng/template-vars-dict.sh b/tests-ng/template-vars-dict.sh index 6a5e1c37..7791dd2b 100755 --- a/tests-ng/template-vars-dict.sh +++ b/tests-ng/template-vars-dict.sh @@ -35,9 +35,14 @@ echo "[+] dotpath dir: ${tmps}/dotfiles" # dotfile destination tmpd=$(mktemp -d --suffix='-dotdrop-tests' || mktemp -d) +# workdir +tmpw=$(mktemp -d --suffix='-dotdrop-workdir' || mktemp -d) + +export DOTDROP_WORKDIR="${tmpw}" clear_on_exit "${tmps}" clear_on_exit "${tmpd}" +clear_on_exit "${tmpw}" cat << _EOF > "${tmps}"/dotfiles/abc BEGIN diff --git a/tests-ng/uninstall-corner-cases.sh b/tests-ng/uninstall-corner-cases.sh index 7474e55f..61d073cd 100755 --- a/tests-ng/uninstall-corner-cases.sh +++ b/tests-ng/uninstall-corner-cases.sh @@ -41,6 +41,7 @@ echo "workdir: ${tmpw}" clear_on_exit "${tmps}" clear_on_exit "${tmpd}" clear_on_exit "${tmpw}" +export DOTDROP_WORKDIR="${tmpw}" # config file # create the config file @@ -114,4 +115,4 @@ cd "${ddpath}" && ${bin} install -c "${cfg}" -f -p p1 | grep '^5 dotfile(s) inst cd "${ddpath}" && ${bin} uninstall --dry -c "${cfg}" -f -p p1 echo "OK" -exit 0 \ No newline at end of file +exit 0 diff --git a/tests-ng/uninstall.sh b/tests-ng/uninstall.sh index eba2b97e..faedcc30 100755 --- a/tests-ng/uninstall.sh +++ b/tests-ng/uninstall.sh @@ -79,6 +79,7 @@ uninstall_with_link() clear_on_exit "${basedir}/dotfiles" clear_on_exit "${tmpd}" clear_on_exit "${tmpw}" + export DOTDROP_WORKDIR="${tmpw}" file_link="${LINK_TYPE}" dir_link="${LINK_TYPE}" @@ -175,6 +176,7 @@ _EOF [ -e "${tmpd}"/trans ] && echo "${PRE} f_trans file not uninstalled" && exit 1 # test workdir is empty + rm -rf "${tmpw:?}"/* || true if [ -n "$(ls -A "${tmpw}")" ]; then echo "${PRE} workdir (1) is not empty" echo "---" @@ -262,6 +264,7 @@ _EOF echo "testing workdir..." # test workdir is empty + rm -rf "${tmpw:?}"/* || true if [ -n "$(ls -A "${tmpw}")" ]; then echo "${PRE} workdir (2) - ${tmpw} - is not empty" ls -r "${tmpw}" @@ -300,4 +303,4 @@ if ! uninstall_with_link; then exit 1; fi echo "[+] uninstall link:${DOTDROP_TEST_NG_UNINSTALL_LINK_TYPE} OK" echo "OK" -exit 0 \ No newline at end of file +exit 0 diff --git a/tests-requirements.txt b/tests-requirements.txt index d496ceca..b1394a0c 100644 --- a/tests-requirements.txt +++ b/tests-requirements.txt @@ -6,4 +6,9 @@ pyflakes; python_version > '3.5' pylint; python_version > '3.5' halo; python_version > '3.5' distro; python_version > '3.5' -urllib3; python_version > '3.5' \ No newline at end of file +urllib3; python_version > '3.5' +mypy; python_version > '3.5' +types-requests; python_version > '3.5' +types-docopt; python_version > '3.5' +pytype; python_version > '3.5' +pyright; python_version > '3.5' diff --git a/tests.sh b/tests.sh index 279c4536..8a93c318 100755 --- a/tests.sh +++ b/tests.sh @@ -48,7 +48,7 @@ if [ -n "${in_cicd}" ]; then fi # make sure both version.py and manpage dotdrop.1 are in sync -dotdrop_version=$(grep version dotdrop/version.py | sed 's/^.*= .\(.*\).$/\1/g') +dotdrop_version=$(grep version__: dotdrop/version.py | sed 's/^.*= .\(.*\).$/\1/g') man_version=$(grep '^\.TH' manpage/dotdrop.1 | sed 's/^.*"dotdrop-\(.*\)\" "Save your.*$/\1/g') if [ "${dotdrop_version}" != "${man_version}" ]; then echo "ERROR version.py (${dotdrop_version}) and manpage (${man_version}) differ!"